├── .eslintrc.js ├── .gitignore ├── .npmrc ├── .prettierrc.js ├── .stylelintrc.js ├── .vscode └── settings.json ├── README.MD ├── package.json ├── packages ├── webrtc-im │ ├── client │ │ ├── component │ │ │ ├── avatar │ │ │ │ ├── index.m.scss │ │ │ │ └── index.tsx │ │ │ ├── beacon │ │ │ │ ├── index.m.scss │ │ │ │ └── index.tsx │ │ │ ├── ellipsis │ │ │ │ ├── index.m.scss │ │ │ │ └── index.tsx │ │ │ └── icons │ │ │ │ ├── board-cast.tsx │ │ │ │ ├── pc.tsx │ │ │ │ ├── phone.tsx │ │ │ │ └── send.tsx │ │ ├── hooks │ │ │ ├── use-dark-theme.ts │ │ │ └── use-is-mobile.ts │ │ ├── index.tsx │ │ ├── service │ │ │ ├── message.ts │ │ │ ├── signal.ts │ │ │ ├── store.ts │ │ │ ├── transfer.ts │ │ │ └── webrtc.ts │ │ ├── static │ │ │ ├── favicon.ico │ │ │ └── index.html │ │ ├── store │ │ │ ├── atoms.ts │ │ │ └── global.ts │ │ ├── styles │ │ │ ├── contacts.m.scss │ │ │ ├── main.m.scss │ │ │ ├── message.m.scss │ │ │ └── tab-bar.m.scss │ │ ├── utils │ │ │ ├── connection.ts │ │ │ └── event-bus.ts │ │ └── view │ │ │ ├── contacts.tsx │ │ │ ├── main.tsx │ │ │ ├── message.tsx │ │ │ └── tab-bar.tsx │ ├── package.json │ ├── rollup.server.js │ ├── rspack.client.js │ ├── server │ │ ├── index.ts │ │ ├── session.ts │ │ └── utils.ts │ ├── tsconfig.json │ └── types │ │ ├── client.ts │ │ ├── global.d.ts │ │ ├── server.ts │ │ ├── signaling.ts │ │ ├── transfer.ts │ │ └── webrtc.ts ├── webrtc │ ├── client │ │ ├── app │ │ │ ├── app.tsx │ │ │ └── modal.tsx │ │ ├── bridge │ │ │ ├── instance.ts │ │ │ ├── signaling.ts │ │ │ └── webrtc.ts │ │ ├── index.tsx │ │ ├── layout │ │ │ ├── canvas.ts │ │ │ └── icon.tsx │ │ ├── static │ │ │ ├── favicon.ico │ │ │ └── index.html │ │ ├── styles │ │ │ ├── index.module.scss │ │ │ └── modal.module.scss │ │ ├── utils │ │ │ ├── binary.ts │ │ │ ├── format.ts │ │ │ └── tson.ts │ │ └── worker │ │ │ ├── event.ts │ │ │ ├── index.ts │ │ │ └── tsconfig.json │ ├── package.json │ ├── rollup.server.js │ ├── rspack.client.js │ ├── server │ │ ├── index.ts │ │ └── utils.ts │ ├── tsconfig.json │ └── types │ │ ├── client.ts │ │ ├── global.d.ts │ │ ├── server.ts │ │ ├── signaling.ts │ │ ├── webrtc.ts │ │ └── worker.ts └── websocket │ ├── client │ ├── app │ │ ├── app.tsx │ │ └── modal.tsx │ ├── bridge │ │ └── socket-server.ts │ ├── index.tsx │ ├── static │ │ ├── favicon.ico │ │ └── index.html │ ├── styles │ │ └── index.module.scss │ └── utils │ │ └── format.ts │ ├── package.json │ ├── rollup.server.js │ ├── rspack.client.js │ ├── server │ ├── index.ts │ └── utils.ts │ ├── tsconfig.json │ └── types │ ├── client.ts │ ├── global.d.ts │ ├── server.ts │ └── websocket.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml └── tsconfig.json /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parserOptions: { 3 | sourceType: "module", 4 | }, 5 | extends: ["eslint:recommended", "plugin:prettier/recommended"], 6 | overrides: [ 7 | { 8 | files: ["*.ts"], 9 | parser: "@typescript-eslint/parser", 10 | plugins: ["@typescript-eslint"], 11 | extends: ["plugin:@typescript-eslint/recommended"], 12 | }, 13 | { 14 | files: ["*.tsx"], 15 | parser: "@typescript-eslint/parser", 16 | plugins: ["react", "react-hooks", "@typescript-eslint/eslint-plugin"], 17 | extends: ["plugin:@typescript-eslint/recommended", "plugin:react-hooks/recommended"], 18 | }, 19 | ], 20 | env: { 21 | browser: true, 22 | node: true, 23 | commonjs: true, 24 | es2021: true, 25 | }, 26 | ignorePatterns: ["node_modules", "build", "dist", "coverage", "public"], 27 | rules: { 28 | "semi": "error", 29 | "quote-props": ["error", "consistent-as-needed"], 30 | "arrow-parens": ["error", "as-needed"], 31 | "no-var": "error", 32 | "prefer-const": "error", 33 | "no-console": "off", 34 | "@typescript-eslint/consistent-type-imports": "error", 35 | }, 36 | }; 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # log 4 | *.log 5 | 6 | # dependencies 7 | node_modules 8 | .pnp 9 | .pnp.js 10 | 11 | # testing 12 | coverage 13 | 14 | # production 15 | build 16 | output 17 | *.zip 18 | 19 | # misc 20 | .DS_Store 21 | .env.local 22 | .env.development.local 23 | .env.test.local 24 | .env.production.local 25 | 26 | npm-debug.log* 27 | yarn-debug.log* 28 | yarn-error.log* 29 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | auto-install-peers=true 2 | registry=https://registry.npmmirror.com/ 3 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "printWidth": 100, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": false, 7 | "quoteProps": "preserve", 8 | "jsxSingleQuote": false, 9 | "trailingComma": "es5", 10 | "bracketSpacing": true, 11 | "arrowParens": "avoid", 12 | "requirePragma": false, 13 | "insertPragma": false, 14 | "proseWrap": "preserve", 15 | "htmlWhitespaceSensitivity": "ignore", 16 | "endOfLine": "lf", 17 | }; 18 | -------------------------------------------------------------------------------- /.stylelintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ["stylelint-config-standard", "stylelint-config-sass-guidelines"], 3 | ignoreFiles: [ 4 | "**/node_modules/**/*.*", 5 | "**/dist/**/*.*", 6 | "**/build/**/*.*", 7 | "**/coverage/**/*.*", 8 | "**/public/**/*.*", 9 | ], 10 | rules: { 11 | "no-descending-specificity": null, 12 | "color-function-notation": null, 13 | "alpha-value-notation": null, 14 | "no-empty-source": null, 15 | "max-nesting-depth": 6, 16 | "selector-max-compound-selectors": 6, 17 | "selector-class-pattern": "^[a-z][a-zA-Z0-9_-]+$", 18 | "selector-id-pattern": "^[a-z][a-zA-Z0-9_-]+$", 19 | "selector-pseudo-class-no-unknown": [ 20 | true, 21 | { 22 | "ignorePseudoClasses": ["global"], 23 | }, 24 | ], 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "arcoblue", 4 | "pako", 5 | "pnpm", 6 | "TSON", 7 | "webrtc" 8 | ] 9 | } -------------------------------------------------------------------------------- /README.MD: -------------------------------------------------------------------------------- 1 | # FileTransfer 2 | 3 |

4 | GitHub 5 | 6 | DEMO 7 | 8 | BLOG 9 | 10 | FAQ 11 |

12 | 13 | 基于`WebRTC/WebSocket`的文件传输: 14 | 15 | 1. 局域网内可以互相发现,不需要手动输入对方`IP`地址等信息。 16 | 2. 多个设备中的任意两个设备之间可以相互传输文本消息与文件数据。 17 | 3. 设备间的数据传输采用基于`WebRTC`的`P2P`方案,无需服务器中转数据。 18 | 4. 跨局域网传输且`NAT`穿越受限的情况下,基于`WebSocket`服务器中转传输。 19 | 5. 基于`ServiceWorker`实现文件数据劫持流式传输方案,可支持大型文件下载。 20 | 21 | https://github.com/WindrunnerMax/FileTransfer/assets/33169019/b1d8d455-84e9-47c1-aa22-2fc77ffa10d1 22 | 23 | 24 | ## Development 25 | 26 | ```bash 27 | $ pnpm install --frozen-lockfile 28 | $ npm run dev:webrtc 29 | $ npm run dev:webrtc-im 30 | $ npm run dev:websocket 31 | ``` 32 | ## Deployment 33 | 34 | ```bash 35 | $ pnpm install --frozen-lockfile 36 | $ npm run deploy:webrtc 37 | $ npm run deploy:webrtc-im 38 | $ npm run deploy:websocket 39 | ``` 40 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "file-transfer", 3 | "version": "1.0.0", 4 | "scripts": { 5 | "dev:webrtc": "pnpm --filter @ft/webrtc run dev", 6 | "build:webrtc": "pnpm --filter @ft/webrtc run build", 7 | "deploy:webrtc": "pnpm --filter @ft/webrtc run deploy", 8 | "dev:webrtc-im": "pnpm --filter @ft/webrtc-im run dev", 9 | "build:webrtc-im": "pnpm --filter @ft/webrtc-im run build", 10 | "deploy:webrtc-im": "pnpm --filter @ft/webrtc-im run deploy", 11 | "dev:websocket": "pnpm --filter @ft/websocket run dev", 12 | "build:websocket": "pnpm --filter @ft/websocket run build", 13 | "deploy:websocket": "pnpm --filter @ft/websocket run deploy", 14 | "lint:ts": "pnpm --filter '*' run lint:ts" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/WindrunnerMax/FileTransfer.git" 19 | }, 20 | "keywords": [], 21 | "author": "", 22 | "license": "MIT", 23 | "bugs": { 24 | "url": "https://github.com/WindrunnerMax/FileTransfer/issues" 25 | }, 26 | "homepage": "https://github.com/WindrunnerMax/FileTransfer", 27 | "devDependencies": { 28 | "@types/node": "22.4.0", 29 | "@typescript-eslint/eslint-plugin": "6.12.0", 30 | "@typescript-eslint/parser": "6.12.0", 31 | "eslint": "7.11.0", 32 | "eslint-config-prettier": "8.3.0", 33 | "eslint-plugin-import": "2.25.3", 34 | "eslint-plugin-prettier": "3.3.1", 35 | "eslint-plugin-react": "7.27.0", 36 | "eslint-plugin-react-hooks": "4.3.0", 37 | "postcss": "8.3.3", 38 | "prettier": "2.4.1", 39 | "sass": "1.52.3", 40 | "stylelint": "14.7.1", 41 | "stylelint-config-sass-guidelines": "9.0.1", 42 | "stylelint-config-standard": "25.0.0", 43 | "typescript": "5.3.2" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /packages/webrtc-im/client/component/avatar/index.m.scss: -------------------------------------------------------------------------------- 1 | .avatar { 2 | position: relative; 3 | } 4 | 5 | .skeleton { 6 | height: inherit; 7 | width: inherit; 8 | 9 | :global(.arco-skeleton-image), 10 | :global(.arco-skeleton-header) { 11 | height: inherit; 12 | margin-right: 0; 13 | width: inherit; 14 | } 15 | } 16 | 17 | .squareAvatar { 18 | align-items: center; 19 | background-color: var(--color-white); 20 | border: 1px solid var(--color-border-1); 21 | border-radius: 50%; 22 | display: flex; 23 | flex-direction: column; 24 | justify-content: center; 25 | overflow: hidden; 26 | 27 | .row { 28 | display: flex; 29 | pointer-events: none; 30 | user-select: none; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /packages/webrtc-im/client/component/avatar/index.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from "@arco-design/web-react"; 2 | import type { FC } from "react"; 3 | import { useMemo } from "react"; 4 | import styles from "./index.m.scss"; 5 | import { cs } from "@block-kit/utils"; 6 | 7 | export const Avatar: FC<{ 8 | /** id 标识 */ 9 | id: string; 10 | /** 长宽 */ 11 | size?: number; 12 | /** 小方块长宽 */ 13 | square?: number; 14 | className?: string; 15 | }> = props => { 16 | const { square = 7, size = 40, id } = props; 17 | 18 | const colorMap = useMemo(() => { 19 | let num = 0; 20 | for (let i = 0; i < id.length; i++) { 21 | num = num + id.charCodeAt(i); 22 | } 23 | const colorList = ["#FE9E9F", "#93BAFF", "#D999F9", "#81C784", "#FFCA62", "#FFA477"]; 24 | const color = colorList[num % colorList.length]; 25 | const map: string[][] = []; 26 | const rows = Math.ceil(size / square); 27 | const halfRows = Math.ceil(rows / 2); 28 | for (let i = 0; i < rows; i++) { 29 | map[i] = []; 30 | for (let k = 0; k < halfRows; k++) { 31 | map[i][k] = num % (i * k + 1) > 1 ? color : ""; 32 | } 33 | for (let k = halfRows; k < rows; k++) { 34 | map[i][k] = map[i][rows - 1 - k]; 35 | } 36 | } 37 | return map; 38 | }, [id, size, square]); 39 | 40 | return ( 41 |
42 | {!id ? ( 43 | 49 | ) : ( 50 |
51 | {colorMap.map((row, rowIndex) => ( 52 |
53 | {row.map((color, colIndex) => ( 54 |
58 | ))} 59 |
60 | ))} 61 |
62 | )} 63 | {props.children} 64 |
65 | ); 66 | }; 67 | -------------------------------------------------------------------------------- /packages/webrtc-im/client/component/beacon/index.m.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | align-items: center; 3 | display: flex; 4 | flex: 1 0; 5 | justify-content: center; 6 | pointer-events: none; 7 | position: relative; 8 | user-select: none; 9 | } 10 | 11 | .prompt { 12 | color: rgba(var(--arcoblue-6), 1); 13 | font-size: 15px; 14 | margin-top: -140px; 15 | user-select: none; 16 | } 17 | 18 | .boardCastIcon { 19 | bottom: -3px; 20 | color: rgba(var(--arcoblue-6), 0.7); 21 | font-size: 33px; 22 | left: 50%; 23 | position: absolute; 24 | transform: translateX(-50%); 25 | } 26 | 27 | .canvas { 28 | position: absolute; 29 | } 30 | -------------------------------------------------------------------------------- /packages/webrtc-im/client/component/beacon/index.tsx: -------------------------------------------------------------------------------- 1 | import styles from "./index.m.scss"; 2 | import type { FC } from "react"; 3 | import { useEffect, useRef } from "react"; 4 | import { BoardCastIcon } from "../icons/board-cast"; 5 | import { useDarkTheme } from "../../hooks/use-dark-theme"; 6 | import { useMemoFn } from "@block-kit/utils/dist/es/hooks"; 7 | import { useIsMobile } from "../../hooks/use-is-mobile"; 8 | 9 | export const Beacon: FC = () => { 10 | const ref = useRef(null); 11 | const { isDarkMode } = useDarkTheme(); 12 | const { isMobile } = useIsMobile(); 13 | 14 | const drawing = useMemoFn(() => { 15 | const canvas = ref.current; 16 | const parent = canvas && canvas.parentElement; 17 | const ctx = canvas && canvas.getContext("2d"); 18 | if (!canvas || !parent || !ctx) return void 0; 19 | const width = parent.clientWidth; 20 | const height = parent.clientHeight; 21 | const devicePixelRatio = Math.ceil(window.devicePixelRatio || 1); 22 | canvas.width = width * devicePixelRatio; 23 | canvas.height = height * devicePixelRatio; 24 | canvas.style.width = `${width}px`; 25 | canvas.style.height = `${height}px`; 26 | ctx.scale(devicePixelRatio, devicePixelRatio); 27 | const x = width / 2; 28 | const y = height - 23; 29 | const degree = Math.max(width, height, 1000) / 20; 30 | 31 | const drawCircle = (radius: number) => { 32 | ctx.beginPath(); 33 | const base = isDarkMode ? 1 : 0; 34 | const gradient = Math.abs(base - radius / Math.max(width, height)); 35 | const color = Math.round(255 * gradient); 36 | ctx.strokeStyle = `rgba(${color}, ${color}, ${color}, 0.07)`; 37 | ctx.arc(x, y, radius, 0, 2 * Math.PI); 38 | ctx.stroke(); 39 | ctx.lineWidth = 1; 40 | }; 41 | 42 | ctx.clearRect(0, 0, width, height); 43 | for (let i = 0; i < 8; i++) { 44 | drawCircle(degree * i + i * i * 0.1 * 30); 45 | } 46 | }); 47 | 48 | useEffect(() => { 49 | Promise.resolve().then(drawing); 50 | }, [isDarkMode, drawing, isMobile]); 51 | 52 | useEffect(() => { 53 | drawing(); 54 | window.addEventListener("resize", drawing); 55 | return () => { 56 | window.removeEventListener("resize", drawing); 57 | }; 58 | }, [drawing]); 59 | 60 | return ( 61 |
62 |
Open Another Device On The LAN To Transfer Files
63 |
{BoardCastIcon}
64 | 65 |
66 | ); 67 | }; 68 | -------------------------------------------------------------------------------- /packages/webrtc-im/client/component/ellipsis/index.m.scss: -------------------------------------------------------------------------------- 1 | .text { 2 | display: inline-block; 3 | max-width: 100%; 4 | overflow: hidden; 5 | text-overflow: ellipsis; 6 | white-space: nowrap; 7 | } 8 | -------------------------------------------------------------------------------- /packages/webrtc-im/client/component/ellipsis/index.tsx: -------------------------------------------------------------------------------- 1 | import { cs } from "@block-kit/utils"; 2 | import type { FC } from "react"; 3 | import { useState } from "react"; 4 | import styles from "./index.m.scss"; 5 | import type { TriggerProps } from "@arco-design/web-react"; 6 | import { Tooltip } from "@arco-design/web-react"; 7 | 8 | export const EllipsisTooltip: FC<{ 9 | /** 样式 */ 10 | className?: string; 11 | /** 内部文本 */ 12 | text: string; 13 | /** ToolTip 文本/节点 */ 14 | tooltip: React.ReactNode; 15 | /** 触发器属性 */ 16 | triggerProps?: Partial; 17 | }> = props => { 18 | const [tooltipVisible, setTooltipVisible] = useState(false); 19 | 20 | const onRef = (el: HTMLSpanElement | null) => { 21 | if (!el) return; 22 | const isOverflowing = el.scrollWidth > el.clientWidth; 23 | setTooltipVisible(isOverflowing); 24 | }; 25 | 26 | return ( 27 | 28 | 29 | {props.text} 30 | 31 | 32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /packages/webrtc-im/client/component/icons/board-cast.tsx: -------------------------------------------------------------------------------- 1 | export const BoardCastIcon = ( 2 | 11 | 12 | 13 | ); 14 | -------------------------------------------------------------------------------- /packages/webrtc-im/client/component/icons/pc.tsx: -------------------------------------------------------------------------------- 1 | export const PCIcon = ( 2 | 12 | 13 | 14 | ); 15 | -------------------------------------------------------------------------------- /packages/webrtc-im/client/component/icons/phone.tsx: -------------------------------------------------------------------------------- 1 | export const PhoneIcon = ( 2 | 11 | 15 | 16 | ); 17 | -------------------------------------------------------------------------------- /packages/webrtc-im/client/component/icons/send.tsx: -------------------------------------------------------------------------------- 1 | export const SendIcon = ( 2 | 10 | 14 | 15 | ); 16 | -------------------------------------------------------------------------------- /packages/webrtc-im/client/hooks/use-dark-theme.ts: -------------------------------------------------------------------------------- 1 | import { IS_BROWSER_ENV } from "@block-kit/utils"; 2 | import { useEffect, useState } from "react"; 3 | 4 | const SETTER_SET = new Set>>(); 5 | 6 | const media = IS_BROWSER_ENV && window.matchMedia("(prefers-color-scheme: dark)"); 7 | 8 | if (IS_BROWSER_ENV && media) { 9 | const onDarkThemeInspect = (e?: MediaQueryListEvent) => { 10 | const mediaQuery = e || media; 11 | if (mediaQuery.matches) { 12 | document.body.setAttribute("arco-theme", "dark"); 13 | SETTER_SET.forEach(setter => setter(true)); 14 | } else { 15 | document.body.removeAttribute("arco-theme"); 16 | SETTER_SET.forEach(setter => setter(false)); 17 | } 18 | }; 19 | media.onchange = onDarkThemeInspect; 20 | onDarkThemeInspect(); 21 | } 22 | 23 | export const useDarkTheme = () => { 24 | const [isDarkMode, setIsDarkMode] = useState(() => (media ? media.matches : false)); 25 | 26 | useEffect(() => { 27 | SETTER_SET.add(setIsDarkMode); 28 | return () => { 29 | SETTER_SET.delete(setIsDarkMode); 30 | }; 31 | }, []); 32 | 33 | return { isDarkMode }; 34 | }; 35 | -------------------------------------------------------------------------------- /packages/webrtc-im/client/hooks/use-is-mobile.ts: -------------------------------------------------------------------------------- 1 | import { IS_BROWSER_ENV, IS_MOBILE } from "@block-kit/utils"; 2 | import { useEffect, useState } from "react"; 3 | 4 | const MAX_SCREEN_WIDTH = 768; 5 | const SETTER_SET = new Set>>(); 6 | 7 | const inspectMobile = () => { 8 | return IS_BROWSER_ENV ? window.innerWidth <= MAX_SCREEN_WIDTH || IS_MOBILE : IS_MOBILE; 9 | }; 10 | 11 | if (IS_BROWSER_ENV) { 12 | window.addEventListener("resize", () => { 13 | const value = inspectMobile(); 14 | SETTER_SET.forEach(setter => setter(value)); 15 | }); 16 | } 17 | 18 | export const useIsMobile = () => { 19 | const [isMobile, setIsMobile] = useState(inspectMobile); 20 | 21 | useEffect(() => { 22 | SETTER_SET.add(setIsMobile); 23 | return () => { 24 | SETTER_SET.delete(setIsMobile); 25 | }; 26 | }, []); 27 | 28 | return { isMobile, inspectMobile }; 29 | }; 30 | -------------------------------------------------------------------------------- /packages/webrtc-im/client/index.tsx: -------------------------------------------------------------------------------- 1 | import "@arco-design/web-react/es/style/index.less"; 2 | import ReactDOM from "react-dom"; 3 | import type { FC } from "react"; 4 | import { useEffect, useMemo } from "react"; 5 | import { SignalService } from "./service/signal"; 6 | import { WebRTCService } from "./service/webrtc"; 7 | import { TransferService } from "./service/transfer"; 8 | import { StoreService } from "./service/store"; 9 | import { MessageService } from "./service/message"; 10 | import enUS from "@arco-design/web-react/es/locale/en-US"; 11 | import { atoms } from "./store/atoms"; 12 | import { ConfigProvider } from "@arco-design/web-react"; 13 | import { Provider } from "jotai"; 14 | import { GlobalContext } from "./store/global"; 15 | import { Main } from "./view/main"; 16 | import { useDarkTheme } from "./hooks/use-dark-theme"; 17 | 18 | const App: FC = () => { 19 | const context = useMemo(() => { 20 | const signal = new SignalService(location.host); 21 | const rtc = new WebRTCService(signal); 22 | const transfer = new TransferService(rtc); 23 | const store = new StoreService(); 24 | const message = new MessageService(signal, rtc, store, transfer); 25 | return { signal, rtc, transfer, store, message }; 26 | }, []); 27 | 28 | useDarkTheme(); 29 | 30 | useEffect(() => { 31 | window.context = context; 32 | return () => { 33 | window.context = null; 34 | context.rtc.destroy(); 35 | context.signal.destroy(); 36 | context.message.destroy(); 37 | context.transfer.destroy(); 38 | }; 39 | }, [context]); 40 | 41 | return ( 42 | 43 | 44 | 45 |
46 | 47 | 48 | 49 | ); 50 | }; 51 | 52 | ReactDOM.render(, document.getElementById("root")); 53 | -------------------------------------------------------------------------------- /packages/webrtc-im/client/service/message.ts: -------------------------------------------------------------------------------- 1 | import type { PrimitiveAtom } from "jotai"; 2 | import { atom } from "jotai"; 3 | import type { TransferEntry, TransferEventMap, TransferFrom } from "../../types/transfer"; 4 | import { TRANSFER_EVENT, TRANSFER_TYPE } from "../../types/transfer"; 5 | import type { SignalService } from "./signal"; 6 | import type { WebRTCService } from "./webrtc"; 7 | import { atoms } from "../store/atoms"; 8 | import { Bind, Scroll, sleep } from "@block-kit/utils"; 9 | import type { CallbackEvent, ServerEvent } from "../../types/signaling"; 10 | import { SERVER_EVENT } from "../../types/signaling"; 11 | import { WEBRTC_EVENT } from "../../types/webrtc"; 12 | import type { StoreService } from "./store"; 13 | import type { TransferService } from "./transfer"; 14 | 15 | export class MessageService { 16 | public scroll: HTMLDivElement | null; 17 | public readonly listAtom: PrimitiveAtom; 18 | 19 | constructor( 20 | private signal: SignalService, 21 | private rtc: WebRTCService, 22 | private store: StoreService, 23 | private transfer: TransferService 24 | ) { 25 | this.scroll = null; 26 | this.listAtom = atom([]); 27 | this.signal.socket.on("connect", this.onSignalConnected); 28 | this.signal.socket.on("disconnect", this.onSignalDisconnected); 29 | this.signal.bus.on(SERVER_EVENT.SEND_OFFER, this.onReceiveOffer); 30 | this.signal.bus.on(SERVER_EVENT.SEND_ICE, this.onReceiveIce); 31 | this.signal.bus.on(SERVER_EVENT.SEND_ANSWER, this.onReceiveAnswer); 32 | this.signal.bus.on(SERVER_EVENT.SEND_ERROR, this.onReceiveError); 33 | this.rtc.bus.on(WEBRTC_EVENT.STATE_CHANGE, this.onRTCStateChange); 34 | this.rtc.bus.on(WEBRTC_EVENT.CONNECTING, this.onRTCConnecting); 35 | this.rtc.bus.on(WEBRTC_EVENT.CLOSE, this.onRTCClose); 36 | this.transfer.bus.on(TRANSFER_EVENT.TEXT, this.onTextMessage); 37 | this.transfer.bus.on(TRANSFER_EVENT.FILE_START, this.onFileStart); 38 | this.transfer.bus.on(TRANSFER_EVENT.FILE_PROCESS, this.onFileProcess); 39 | } 40 | 41 | public destroy() { 42 | this.signal.socket.off("connect", this.onSignalConnected); 43 | this.signal.socket.off("disconnect", this.onSignalDisconnected); 44 | this.signal.bus.off(SERVER_EVENT.SEND_OFFER, this.onReceiveOffer); 45 | this.signal.bus.off(SERVER_EVENT.SEND_ICE, this.onReceiveIce); 46 | this.signal.bus.off(SERVER_EVENT.SEND_ANSWER, this.onReceiveAnswer); 47 | this.signal.bus.off(SERVER_EVENT.SEND_ERROR, this.onReceiveError); 48 | this.rtc.bus.off(WEBRTC_EVENT.STATE_CHANGE, this.onRTCStateChange); 49 | this.rtc.bus.off(WEBRTC_EVENT.CONNECTING, this.onRTCConnecting); 50 | this.rtc.bus.off(WEBRTC_EVENT.CLOSE, this.onRTCClose); 51 | this.transfer.bus.off(TRANSFER_EVENT.TEXT, this.onTextMessage); 52 | this.transfer.bus.off(TRANSFER_EVENT.FILE_START, this.onFileStart); 53 | this.transfer.bus.off(TRANSFER_EVENT.FILE_PROCESS, this.onFileProcess); 54 | } 55 | 56 | public addEntry(entry: TransferEntry) { 57 | const currentList = atoms.get(this.listAtom); 58 | const newList = [...currentList, entry]; 59 | atoms.set(this.listAtom, newList); 60 | } 61 | 62 | public addSystemEntry(data: string) { 63 | this.addEntry({ key: TRANSFER_TYPE.SYSTEM, data }); 64 | this.scroll && Scroll.scrollToBottom(this.scroll); 65 | } 66 | 67 | public async addTextEntry(text: string, from: TransferFrom) { 68 | this.addEntry({ key: TRANSFER_TYPE.TEXT, data: text, from: from }); 69 | await sleep(10); 70 | this.scroll && Scroll.scrollToBottom(this.scroll); 71 | } 72 | 73 | public clearEntries() { 74 | atoms.set(this.listAtom, []); 75 | } 76 | 77 | @Bind 78 | private onSignalConnected() { 79 | this.addSystemEntry("Signal Connected"); 80 | } 81 | 82 | @Bind 83 | private onSignalDisconnected() { 84 | this.addSystemEntry("Signal Disconnected"); 85 | } 86 | 87 | @Bind 88 | private onRTCConnecting() { 89 | const peerId = atoms.get(this.store.peerIdAtom); 90 | this.addSystemEntry(`WebRTC Connecting To ${peerId}`); 91 | } 92 | 93 | @Bind 94 | private onRTCClose() { 95 | const peerId = atoms.get(this.store.peerIdAtom); 96 | this.addSystemEntry(`WebRTC ${peerId} Closed`); 97 | } 98 | 99 | @Bind 100 | private onRTCStateChange(connection: RTCPeerConnection) { 101 | const peerId = atoms.get(this.store.peerIdAtom); 102 | if (connection.connectionState === "disconnected") { 103 | this.addSystemEntry(`WebRTC ${peerId} Disconnected`); 104 | } 105 | if (connection.connectionState === "connected") { 106 | this.addSystemEntry(`WebRTC ${peerId} Connected`); 107 | } 108 | if (connection.connectionState === "failed") { 109 | this.addSystemEntry(`WebRTC ${peerId} Connection Failed`); 110 | } 111 | if (connection.connectionState === "closed") { 112 | this.addSystemEntry(`WebRTC ${peerId} Connection Closed`); 113 | } 114 | } 115 | 116 | @Bind 117 | private onReceiveOffer(params: ServerEvent["SEND_OFFER"]) { 118 | this.addSystemEntry(`Received ${params.from} RTC Offer`); 119 | } 120 | 121 | @Bind 122 | private onReceiveIce(params: ServerEvent["SEND_ICE"]) { 123 | this.addSystemEntry(`Received ${params.from} RTC ICE`); 124 | } 125 | 126 | @Bind 127 | private onReceiveAnswer(params: ServerEvent["SEND_ANSWER"]) { 128 | this.addSystemEntry(`Received ${params.from} RTC Answer`); 129 | } 130 | 131 | @Bind 132 | private onReceiveError(e: CallbackEvent) { 133 | this.addSystemEntry(e.message); 134 | } 135 | 136 | @Bind 137 | private async onTextMessage(event: TransferEventMap["TEXT"]) { 138 | const { data, from } = event; 139 | this.addTextEntry(data, from); 140 | } 141 | 142 | @Bind 143 | private async onFileStart(event: TransferEventMap["FILE_START"]) { 144 | const { id, name, size, from, process } = event; 145 | this.addEntry({ key: TRANSFER_TYPE.FILE, id, name, size, process, from }); 146 | await sleep(10); 147 | this.scroll && Scroll.scrollToBottom(this.scroll); 148 | } 149 | 150 | @Bind 151 | private async onFileProcess(event: TransferEventMap["FILE_PROCESS"]) { 152 | const { id, process } = event; 153 | const list = [...atoms.get(this.listAtom)]; 154 | const FILE_TYPE = TRANSFER_TYPE.FILE; 155 | const index = list.findIndex(it => it.key === FILE_TYPE && it.id === id); 156 | if (index > -1) { 157 | const node = list[index] as TransferEntry; 158 | list[index] = { ...node, process } as TransferEntry; 159 | atoms.set(this.listAtom, list); 160 | } 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /packages/webrtc-im/client/service/signal.ts: -------------------------------------------------------------------------------- 1 | import type { Socket } from "socket.io-client"; 2 | import io from "socket.io-client"; 3 | import type { 4 | CallbackEvent, 5 | ClientEvent, 6 | ClientEventKeys, 7 | ClientHandler, 8 | ServerEvent, 9 | ServerEventKeys, 10 | ServerHandler, 11 | } from "../../types/signaling"; 12 | import type { PrimitiveAtom } from "jotai"; 13 | import { atom } from "jotai"; 14 | import { CLINT_EVENT, SERVER_EVENT } from "../../types/signaling"; 15 | import { Bind, IS_MOBILE } from "@block-kit/utils"; 16 | import type { ConnectionState } from "../../types/client"; 17 | import { CONNECTION_STATE, DEVICE_TYPE } from "../../types/client"; 18 | import type { PromiseWithResolve } from "../utils/connection"; 19 | import { createConnectReadyPromise, getSessionId } from "../utils/connection"; 20 | import { atoms } from "../store/atoms"; 21 | import { EventBus } from "../utils/event-bus"; 22 | import type { P } from "@block-kit/utils/dist/es/types"; 23 | 24 | export class SignalService { 25 | /** 连接状态 */ 26 | public readonly stateAtom: PrimitiveAtom; 27 | /** 客户端 id */ 28 | public id: string; 29 | /** 客户端 ip */ 30 | public ip: string; 31 | /** 客户端 ip hash */ 32 | public hash: string; 33 | /** 事件总线 */ 34 | public bus: EventBus; 35 | /** Socket 实例 */ 36 | public readonly socket: Socket; 37 | /** 连接成功 Promise */ 38 | private connectedPromise: PromiseWithResolve | null; 39 | 40 | constructor(wss: string) { 41 | this.id = ""; 42 | this.ip = ""; 43 | this.hash = ""; 44 | this.connectedPromise = createConnectReadyPromise(); 45 | const sessionId = getSessionId(); 46 | const socket = io(wss, { 47 | transports: ["websocket"], 48 | auth: { sessionId }, 49 | }); 50 | this.socket = socket; 51 | this.bus = new EventBus(); 52 | this.socket.on("connect", this.onConnected); 53 | this.socket.on("disconnect", this.onDisconnect); 54 | this.socket.onAny(this.onAnyEvent); 55 | this.bus.on(SERVER_EVENT.INIT_USER, this.onInitUser); 56 | this.stateAtom = atom(CONNECTION_STATE.CONNECTING); 57 | } 58 | 59 | public destroy() { 60 | this.bus.clear(); 61 | this.socket.close(); 62 | this.connectedPromise = null; 63 | this.socket.offAny(this.onAnyEvent); 64 | this.socket.off("connect", this.onConnected); 65 | this.socket.off("disconnect", this.onDisconnect); 66 | this.socket.off(SERVER_EVENT.INIT_USER, this.onInitUser); 67 | } 68 | 69 | /** 70 | * 等待连接 71 | */ 72 | public isConnected() { 73 | if (!this.connectedPromise) return Promise.resolve(); 74 | return this.connectedPromise; 75 | } 76 | 77 | public emit( 78 | key: T, 79 | payload: ClientEvent[T], 80 | callback?: (state: CallbackEvent) => void 81 | ) { 82 | // @ts-expect-error unknown 83 | this.socket.emit(key, payload, callback); 84 | } 85 | 86 | @Bind 87 | private onConnected() { 88 | const payload = { 89 | device: IS_MOBILE ? DEVICE_TYPE.MOBILE : DEVICE_TYPE.PC, 90 | }; 91 | this.emit(CLINT_EVENT.JOIN_ROOM, payload); 92 | } 93 | 94 | @Bind 95 | private onInitUser(payload: ServerEvent["INIT_USER"]) { 96 | const { id, ip, hash } = payload.self; 97 | this.id = id; 98 | this.ip = ip; 99 | this.hash = hash; 100 | console.log("Init User", payload); 101 | this.connectedPromise && this.connectedPromise.resolve(); 102 | this.connectedPromise = null; 103 | atoms.set(this.stateAtom, CONNECTION_STATE.CONNECTED); 104 | } 105 | 106 | @Bind 107 | private onAnyEvent(event: string, payload: P.Any) { 108 | this.bus.emit(event as ServerEventKeys, payload); 109 | } 110 | 111 | @Bind 112 | private onDisconnect() { 113 | atoms.set(this.stateAtom, CONNECTION_STATE.CONNECTING); 114 | this.connectedPromise = createConnectReadyPromise(); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /packages/webrtc-im/client/service/store.ts: -------------------------------------------------------------------------------- 1 | import { atom } from "jotai"; 2 | import type { NetType, Users } from "../../types/client"; 3 | import { NET_TYPE } from "../../types/client"; 4 | 5 | export class StoreService { 6 | /** 列表页网络 Tab */ 7 | public netTypeAtom = atom(NET_TYPE.LAN); 8 | /** 匹配的 UserId */ 9 | public peerIdAtom = atom(""); 10 | /** 用户列表 */ 11 | public userListAtom = atom([]); 12 | } 13 | -------------------------------------------------------------------------------- /packages/webrtc-im/client/service/transfer.ts: -------------------------------------------------------------------------------- 1 | import { Bind, getId, isString, TSON } from "@block-kit/utils"; 2 | import type { FileMeta, MessageEntry, TransferEventMap } from "../../types/transfer"; 3 | import { MESSAGE_TYPE, TRANSFER_EVENT, TRANSFER_FROM } from "../../types/transfer"; 4 | import type { BufferType } from "../../types/transfer"; 5 | import { CHUNK_SIZE, ID_SIZE } from "../../types/transfer"; 6 | import type { WebRTCService } from "./webrtc"; 7 | import { WEBRTC_EVENT } from "../../types/webrtc"; 8 | import { EventBus } from "../utils/event-bus"; 9 | 10 | export class TransferService { 11 | /** 正在发送数据 */ 12 | public isSending: boolean; 13 | /** 分片传输队列 */ 14 | public tasks: BufferType[]; 15 | /** 事件总线 */ 16 | public bus: EventBus; 17 | /** 发送文件句柄 */ 18 | public fileHandler: Map; 19 | /** 接受文件切片 */ 20 | public fileMapper: Map; 21 | /** 文件接收状态 */ 22 | public fileState: Map; 23 | 24 | constructor(private rtc: WebRTCService) { 25 | this.isSending = false; 26 | this.tasks = []; 27 | this.bus = new EventBus(); 28 | this.fileState = new Map(); 29 | this.fileMapper = new Map(); 30 | this.fileHandler = new Map(); 31 | this.rtc.bus.on(WEBRTC_EVENT.MESSAGE, this.onMessage); 32 | } 33 | 34 | public destroy() { 35 | this.reset(); 36 | this.rtc.bus.off(WEBRTC_EVENT.MESSAGE, this.onMessage); 37 | } 38 | 39 | /** 40 | * 重置传输状态 41 | */ 42 | public reset() { 43 | this.isSending = false; 44 | this.tasks = []; 45 | this.fileState = new Map(); 46 | this.fileMapper = new Map(); 47 | this.fileHandler = new Map(); 48 | } 49 | 50 | public async sendTextMessage(message: MessageEntry | string) { 51 | const entry: MessageEntry = isString(message) 52 | ? { key: MESSAGE_TYPE.TEXT, data: message } 53 | : message; 54 | if (entry.key === MESSAGE_TYPE.TEXT) { 55 | this.bus.emit(TRANSFER_EVENT.TEXT, { data: entry.data, from: TRANSFER_FROM.SELF }); 56 | } 57 | this.rtc.channel.send(TSON.stringify(entry)!); 58 | } 59 | 60 | public async startSendFileList(files: FileList) { 61 | const maxChunkSize = this.getMaxMessageSize(); 62 | for (const file of files) { 63 | const name = file.name; 64 | const id = getId(ID_SIZE); 65 | const size = file.size; 66 | const total = Math.ceil(file.size / maxChunkSize); 67 | this.fileHandler.set(id, file); 68 | const { SELF } = TRANSFER_FROM; 69 | this.sendTextMessage({ key: MESSAGE_TYPE.FILE_START, id, name, size, total }); 70 | this.bus.emit(TRANSFER_EVENT.FILE_START, { id, size, name, process: 0, from: SELF }); 71 | } 72 | } 73 | 74 | /** 75 | * 接收数据消息 76 | * @param event 77 | */ 78 | @Bind 79 | private async onMessage(event: MessageEvent) { 80 | const { TEXT, FILE_NEXT } = MESSAGE_TYPE; 81 | const { FILE_START, FILE_PROCESS } = TRANSFER_EVENT; 82 | const { PEER } = TRANSFER_FROM; 83 | // String - 接收文本类型数据 84 | if (isString(event.data)) { 85 | const data = TSON.decode(event.data); 86 | console.log("OnTextMessage", data); 87 | if (!data || !data.key) return void 0; 88 | // 收到 发送方 的文本消息 89 | if (data.key === MESSAGE_TYPE.TEXT) { 90 | this.bus.emit(TEXT, { data: data.data, from: PEER }); 91 | return void 0; 92 | } 93 | // 收到 发送方 传输起始消息 准备接收数据 94 | if (data.key === MESSAGE_TYPE.FILE_START) { 95 | const { id, size, total, name } = data; 96 | this.fileState.set(id, { series: 0, ...data }); 97 | // 通知 发送方 发送首个块 98 | this.sendTextMessage({ key: FILE_NEXT, id, series: 0, size, total }); 99 | this.bus.emit(FILE_START, { id, name, size, process: 0, from: PEER }); 100 | return void 0; 101 | } 102 | // 收到 接收方 的准备接收目标块数据消息 103 | if (data.key === MESSAGE_TYPE.FILE_NEXT) { 104 | const { id, series, total } = data; 105 | const nextChunk = await this.serialize(id, series); 106 | // 向目标 接收方 发送块数据 107 | this.enqueue(nextChunk); 108 | const progress = Math.floor((series / total) * 100); 109 | this.bus.emit(FILE_PROCESS, { id, process: progress }); 110 | return void 0; 111 | } 112 | // 收到 接收方 的接收完成消息 113 | if (data.key === MESSAGE_TYPE.FILE_FINISH) { 114 | const { id } = data; 115 | this.fileState.delete(id); 116 | this.bus.emit(FILE_PROCESS, { id, process: 100 }); 117 | return void 0; 118 | } 119 | return void 0; 120 | } 121 | // Binary - 接收 发送方 ArrayBuffer 数据 122 | if (event.data instanceof ArrayBuffer || event.data instanceof Blob) { 123 | const blob = event.data; 124 | const { id, series, data } = await this.deserialize(blob); 125 | // 在此处只打印关键信息即可 如果全部打印会导致内存占用上升 126 | // 控制台会实际持有 Buffer 数据 传输文件时会导致占用大量内存 127 | console.log("OnBinaryMessage", { id, series }); 128 | const state = this.fileState.get(id); 129 | if (!state) return void 0; 130 | const { size, total } = state; 131 | const progress = Math.floor((series / total) * 100); 132 | this.bus.emit(FILE_PROCESS, { id, process: progress }); 133 | // 数据接收完毕 通知 发送方 接收完毕 134 | // 数据块序列号 [0, TOTAL) 135 | if (series >= total) { 136 | this.sendTextMessage({ key: MESSAGE_TYPE.FILE_FINISH, id }); 137 | return void 0; 138 | } 139 | // 未完成传输, 在内存中存储块数据 140 | if (series < total) { 141 | const mapper = this.fileMapper.get(id) || []; 142 | mapper[series] = data; 143 | this.fileMapper.set(id, mapper); 144 | // 通知 发送方 发送下一个序列块 145 | this.sendTextMessage({ key: FILE_NEXT, id, series: series + 1, size, total }); 146 | return void 0; 147 | } 148 | return void 0; 149 | } 150 | } 151 | 152 | /** 153 | * 序列化文件分片 154 | */ 155 | private async serialize(id: string, series: number) { 156 | const file = this.fileHandler.get(id); 157 | const chunkSize = this.getMaxMessageSize(); 158 | if (!file) return new Blob([new ArrayBuffer(chunkSize)]); 159 | const start = series * chunkSize; 160 | const end = Math.min(start + chunkSize, file.size); 161 | // 创建 12 字节用于存储 id [12B = 96bit] 162 | const idBytes = new Uint8Array(id.split("").map(char => char.charCodeAt(0))); 163 | // 创建 4 字节用于存储序列号 [4B = 32bit] 164 | const serialBytes = new Uint8Array(4); 165 | // 0xff = 1111 1111 确保结果只包含低 8 位 166 | serialBytes[0] = (series >> 24) & 0xff; 167 | serialBytes[1] = (series >> 16) & 0xff; 168 | serialBytes[2] = (series >> 8) & 0xff; 169 | serialBytes[3] = series & 0xff; 170 | return new Blob([idBytes, serialBytes, file.slice(start, end)]); 171 | } 172 | 173 | /** 174 | * 反序列化文件分片 175 | */ 176 | private async deserialize(chunk: BufferType) { 177 | const buffer = chunk instanceof Blob ? await chunk.arrayBuffer() : chunk; 178 | const id = new Uint8Array(buffer.slice(0, ID_SIZE)); 179 | const series = new Uint8Array(buffer.slice(ID_SIZE, ID_SIZE + CHUNK_SIZE)); 180 | const data = buffer.slice(ID_SIZE + CHUNK_SIZE); 181 | const idString = String.fromCharCode(...id); 182 | const seriesNumber = (series[0] << 24) | (series[1] << 16) | (series[2] << 8) | series[3]; 183 | return { id: idString, series: seriesNumber, data }; 184 | } 185 | 186 | /** 187 | * 入队准备发送数据 188 | */ 189 | private enqueue(chunk: BufferType) { 190 | this.tasks.push(chunk); 191 | !this.isSending && this.startSendBuffer(); 192 | } 193 | 194 | /** 195 | * 获取最大分片大小 196 | */ 197 | private getMaxMessageSize(originValue = false) { 198 | let maxSize = this.rtc.connection.sctp?.maxMessageSize || 64 * 1024; 199 | // https://developer.mozilla.org/en-US/docs/Web/API/RTCSctpTransport/maxMessageSize 200 | // https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API/Using_data_channels 201 | // 在 FireFox 本机传输会出现超大的值 1073741807, 约 1GB 1073741824byte 202 | // officially up to 256 KiB, but Firefox's implementation caps them at a whopping 1 GiB 203 | // 因此在这里需要将其限制为最大 256KB 以保证正确的文件传输以及 WebStream 的正常工作 204 | maxSize = Math.min(maxSize, 256 * 1024); 205 | // 最大的原始值, 而不是实际的可用分片大小 206 | if (originValue) return maxSize; 207 | // 1KB = 1024B 208 | // 1B = 8bit => 0-255 00-FF 209 | return maxSize - ID_SIZE - CHUNK_SIZE; 210 | } 211 | 212 | /** 213 | * 正式传输消息 214 | */ 215 | private async startSendBuffer() { 216 | this.isSending = true; 217 | const chunkSize = this.getMaxMessageSize(); 218 | const channel = this.rtc.channel; 219 | while (this.tasks.length) { 220 | const next = this.tasks.shift(); 221 | if (!next) break; 222 | if (channel.bufferedAmount >= chunkSize) { 223 | await new Promise(resolve => { 224 | channel.onbufferedamountlow = () => resolve(0); 225 | }); 226 | } 227 | const buffer = next instanceof Blob ? await next.arrayBuffer() : next; 228 | buffer && this.rtc.channel.send(buffer); 229 | } 230 | this.isSending = false; 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /packages/webrtc-im/client/service/webrtc.ts: -------------------------------------------------------------------------------- 1 | import type { PrimitiveAtom } from "jotai"; 2 | import { atom } from "jotai"; 3 | import type { ConnectionState } from "../../types/client"; 4 | import { CONNECTION_STATE } from "../../types/client"; 5 | import type { PromiseWithResolve } from "../utils/connection"; 6 | import { createConnectReadyPromise } from "../utils/connection"; 7 | import type { SignalService } from "./signal"; 8 | import type { ServerEvent } from "../../types/signaling"; 9 | import { CLINT_EVENT, SERVER_EVENT } from "../../types/signaling"; 10 | import { Bind } from "@block-kit/utils"; 11 | import { ERROR_CODE } from "../../types/server"; 12 | import { atoms } from "../store/atoms"; 13 | import { EventBus } from "../utils/event-bus"; 14 | import type { WebRTCEvent } from "../../types/webrtc"; 15 | import { WEBRTC_EVENT } from "../../types/webrtc"; 16 | 17 | export class WebRTCService { 18 | /** 连接状态 */ 19 | public readonly stateAtom: PrimitiveAtom; 20 | /** 链接状态 Promise */ 21 | private connectedPromise: PromiseWithResolve | null; 22 | /** 数据传输信道 */ 23 | public channel: RTCDataChannel; 24 | /** RTC 连接实例 */ 25 | public connection: RTCPeerConnection; 26 | /** 事件总线 */ 27 | public bus: EventBus; 28 | 29 | constructor(private signal: SignalService) { 30 | const rtc = this.createRTCPeerConnection(); 31 | this.channel = rtc.channel; 32 | this.connection = rtc.connection; 33 | this.bus = new EventBus(); 34 | this.connectedPromise = createConnectReadyPromise(); 35 | this.stateAtom = atom(CONNECTION_STATE.READY); 36 | this.signal.bus.on(SERVER_EVENT.SEND_OFFER, this.onReceiveOffer); 37 | this.signal.bus.on(SERVER_EVENT.SEND_ICE, this.onReceiveIce); 38 | this.signal.bus.on(SERVER_EVENT.SEND_ANSWER, this.onReceiveAnswer); 39 | this.signal.socket.on("disconnect", this.disconnect); 40 | } 41 | 42 | public destroy() { 43 | this.bus.clear(); 44 | this.signal.destroy(); 45 | this.connectedPromise = null; 46 | this.signal.bus.off(SERVER_EVENT.SEND_OFFER, this.onReceiveOffer); 47 | this.signal.bus.off(SERVER_EVENT.SEND_ICE, this.onReceiveIce); 48 | this.signal.bus.off(SERVER_EVENT.SEND_ANSWER, this.onReceiveAnswer); 49 | this.signal.socket.off("disconnect", this.disconnect); 50 | } 51 | 52 | /** 53 | * 发起连接 54 | */ 55 | public async connect(peerUserId: string) { 56 | atoms.set(this.stateAtom, CONNECTION_STATE.CONNECTING); 57 | this.bus.emit(WEBRTC_EVENT.CONNECTING, null); 58 | console.log("Send Offer To:", peerUserId); 59 | this.connection.onicecandidate = async event => { 60 | if (!event.candidate) return void 0; 61 | console.log("Local ICE", event.candidate); 62 | const payload = { ice: event.candidate, to: peerUserId }; 63 | this.signal.emit(CLINT_EVENT.SEND_ICE, payload); 64 | }; 65 | const offer = await this.connection.createOffer(); 66 | await this.connection.setLocalDescription(offer); 67 | console.log("Offer SDP", offer); 68 | const payload = { sdp: offer, to: peerUserId }; 69 | this.signal.emit(CLINT_EVENT.SEND_OFFER, payload); 70 | } 71 | 72 | /** 73 | * 断开连接 74 | */ 75 | @Bind 76 | public async disconnect() { 77 | this.channel && this.channel.close(); 78 | this.connection.close(); 79 | // 重新创建, 等待新的连接 80 | const rtc = this.createRTCPeerConnection(); 81 | this.channel = rtc.channel; 82 | this.connection = rtc.connection; 83 | atoms.set(this.stateAtom, CONNECTION_STATE.READY); 84 | } 85 | 86 | /** 87 | * 等待连接 88 | */ 89 | public isConnected() { 90 | if (!this.connectedPromise) return Promise.resolve(); 91 | return this.connectedPromise; 92 | } 93 | 94 | private createRTCPeerConnection(ice?: string) { 95 | const RTCPeerConnection = 96 | // @ts-expect-error RTCPeerConnection 97 | window.RTCPeerConnection || window.mozRTCPeerConnection || window.webkitRTCPeerConnection; 98 | // https://icetest.info/ 99 | // https://gist.github.com/mondain/b0ec1cf5f60ae726202e 100 | // https://webrtc.github.io/samples/src/content/peerconnection/trickle-ice/ 101 | const defaultIces: RTCIceServer[] = [ 102 | { 103 | urls: [ 104 | "stun:stun.services.mozilla.com", 105 | "stun:stunserver2024.stunprotocol.org", 106 | "stun:stun.l.google.com:19302", 107 | ], 108 | }, 109 | ]; 110 | const connection = new RTCPeerConnection({ 111 | iceServers: ice ? [{ urls: ice }] : defaultIces, 112 | }); 113 | if (!this.connectedPromise) { 114 | this.connectedPromise = createConnectReadyPromise(); 115 | } 116 | const channel = connection.createDataChannel("file-transfer", { 117 | ordered: true, // 保证传输顺序 118 | maxRetransmits: 50, // 最大重传次数 119 | }); 120 | connection.ondatachannel = event => { 121 | const channel = event.channel; 122 | channel.onopen = this.onDataChannelOpen; 123 | channel.onclose = this.onDataChannelClose; 124 | channel.onmessage = e => this.bus.emit(WEBRTC_EVENT.MESSAGE, e); 125 | channel.onerror = e => this.bus.emit(WEBRTC_EVENT.ERROR, e as RTCErrorEvent); 126 | }; 127 | connection.onconnectionstatechange = () => { 128 | if (channel.readyState === "closed") return void 0; 129 | this.onConnectionStateChange(connection); 130 | }; 131 | return { connection, channel }; 132 | } 133 | 134 | @Bind 135 | private onDataChannelOpen(e: Event) { 136 | this.connectedPromise && this.connectedPromise.resolve(); 137 | this.connectedPromise = null; 138 | this.bus.emit(WEBRTC_EVENT.OPEN, e); 139 | } 140 | 141 | @Bind 142 | private onDataChannelClose(e: Event) { 143 | atoms.set(this.stateAtom, CONNECTION_STATE.READY); 144 | this.bus.emit(WEBRTC_EVENT.CLOSE, e); 145 | } 146 | 147 | @Bind 148 | private onConnectionStateChange(connection: RTCPeerConnection) { 149 | if (connection.connectionState === "connected") { 150 | atoms.set(this.stateAtom, CONNECTION_STATE.CONNECTED); 151 | } 152 | if (this.connection.connectionState === "connecting") { 153 | atoms.set(this.stateAtom, CONNECTION_STATE.CONNECTING); 154 | } 155 | if ( 156 | connection.connectionState === "disconnected" || 157 | connection.connectionState === "failed" || 158 | connection.connectionState === "new" || 159 | connection.connectionState === "closed" 160 | ) { 161 | atoms.set(this.stateAtom, CONNECTION_STATE.READY); 162 | } 163 | this.bus.emit(WEBRTC_EVENT.STATE_CHANGE, connection); 164 | } 165 | 166 | @Bind 167 | private async onReceiveOffer(params: ServerEvent["SEND_OFFER"]) { 168 | const { sdp, from } = params; 169 | console.log("Receive Offer From:", from, sdp); 170 | if (this.connection.currentLocalDescription || this.connection.currentRemoteDescription) { 171 | this.signal.emit(CLINT_EVENT.SEND_ERROR, { 172 | to: from, 173 | code: ERROR_CODE.BUSY, 174 | message: `P2P Peer User ${this.signal.id} Is Busy`, 175 | }); 176 | return void 0; 177 | } 178 | this.connection.onicecandidate = async event => { 179 | if (!event.candidate) return void 0; 180 | console.log("Local ICE", event.candidate); 181 | const payload = { from: this.signal.id, ice: event.candidate, to: from }; 182 | this.signal.emit(CLINT_EVENT.SEND_ICE, payload); 183 | }; 184 | await this.connection.setRemoteDescription(sdp); 185 | const answer = await this.connection.createAnswer(); 186 | await this.connection.setLocalDescription(answer); 187 | console.log("Answer SDP", answer); 188 | const payload = { from: this.signal.id, sdp: answer, to: from }; 189 | this.signal.emit(CLINT_EVENT.SEND_ANSWER, payload); 190 | } 191 | 192 | @Bind 193 | private async onReceiveIce(params: ServerEvent["SEND_ICE"]) { 194 | const { ice: sdp, from } = params; 195 | console.log("Receive ICE From:", from, sdp); 196 | await this.connection.addIceCandidate(sdp); 197 | } 198 | 199 | @Bind 200 | private async onReceiveAnswer(params: ServerEvent["SEND_ANSWER"]) { 201 | const { sdp, from } = params; 202 | console.log("Receive Answer From:", from, sdp); 203 | if (!this.connection.currentRemoteDescription) { 204 | this.connection.setRemoteDescription(sdp); 205 | } 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /packages/webrtc-im/client/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WindRunnerMax/FileTransfer/0c9e67b950b2c08a43cd22c1fb58aeea717bd7ba/packages/webrtc-im/client/static/favicon.ico -------------------------------------------------------------------------------- /packages/webrtc-im/client/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | FileTransfer-WebRTC 7 | 8 | 9 | <% if (process.env.NODE_ENV === "production") { %> 10 | 16 | 22 | <% } else { %> 23 | 29 | 35 | <% } %> 36 |
37 | 38 | 39 | -------------------------------------------------------------------------------- /packages/webrtc-im/client/store/atoms.ts: -------------------------------------------------------------------------------- 1 | import type { F } from "@block-kit/utils/dist/es/types"; 2 | import type { Atom, WritableAtom } from "jotai"; 3 | import { createStore } from "jotai"; 4 | 5 | export class AtomsFactory { 6 | public store: F.Return; 7 | constructor() { 8 | this.store = createStore(); 9 | } 10 | 11 | public get(atom: Atom): T { 12 | return this.store.get(atom); 13 | } 14 | 15 | public set( 16 | atom: WritableAtom, 17 | ...args: Args 18 | ): Result { 19 | return this.store.set(atom, ...args); 20 | } 21 | } 22 | 23 | export const atoms = new AtomsFactory(); 24 | -------------------------------------------------------------------------------- /packages/webrtc-im/client/store/global.ts: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from "react"; 2 | import type { SignalService } from "../service/signal"; 3 | import type { WebRTCService } from "../service/webrtc"; 4 | import type { TransferService } from "../service/transfer"; 5 | import type { StoreService } from "../service/store"; 6 | import type { MessageService } from "../service/message"; 7 | 8 | export type ContextType = { 9 | signal: SignalService; 10 | rtc: WebRTCService; 11 | transfer: TransferService; 12 | store: StoreService; 13 | message: MessageService; 14 | }; 15 | 16 | export const GlobalContext = createContext(null); 17 | 18 | export const useGlobalContext = () => { 19 | const context = useContext(GlobalContext); 20 | if (!context) { 21 | throw new Error("useGlobalContext must be used within a GlobalContext.Provider"); 22 | } 23 | return context; 24 | }; 25 | -------------------------------------------------------------------------------- /packages/webrtc-im/client/styles/contacts.m.scss: -------------------------------------------------------------------------------- 1 | @import '../../node_modules/@block-kit/utils/dist/style/vars'; 2 | 3 | .container { 4 | border-right: 1px solid var(--color-border-2); 5 | box-sizing: border-box; 6 | display: flex; 7 | flex-direction: column; 8 | padding: 10px 5px; 9 | position: relative; 10 | width: 250px; 11 | } 12 | 13 | .search { 14 | box-sizing: border-box; 15 | margin-top: 7px; 16 | padding: 0 15px; 17 | } 18 | 19 | .empty { 20 | position: absolute; 21 | top: 50%; 22 | transform: translateY(-50%); 23 | 24 | :global(.arco-empty-description) { 25 | color: rgb(var(--gray-6)); 26 | font-size: 13px; 27 | } 28 | } 29 | 30 | .users { 31 | @include no-scrollbar; 32 | 33 | flex: 1 0; 34 | margin-top: 10px; 35 | overflow-y: auto; 36 | } 37 | 38 | .user { 39 | align-items: center; 40 | border-radius: 5px; 41 | cursor: pointer; 42 | display: flex; 43 | margin-bottom: 3px; 44 | padding: 8px 10px; 45 | 46 | &:hover, 47 | &.active { 48 | background-color: var(--color-fill-1); 49 | } 50 | 51 | .avatar { 52 | margin-right: 10px; 53 | } 54 | 55 | .userInfo { 56 | box-sizing: border-box; 57 | width: calc(100% - 45px); 58 | } 59 | 60 | .captain { 61 | margin-top: 3px; 62 | } 63 | 64 | .name { 65 | font-size: 13px; 66 | margin-right: 5px; 67 | } 68 | 69 | .ip { 70 | @include text-ellipsis; 71 | 72 | color: var(--color-text-2); 73 | font-size: 12px; 74 | } 75 | } 76 | 77 | .divide { 78 | background-color: var(--color-border-1); 79 | box-sizing: border-box; 80 | height: 1px; 81 | margin: 3px; 82 | } 83 | 84 | :global(.webrtc-im-mobile) { 85 | .container { 86 | flex: 1 0; 87 | max-height: calc(100% - 60px); 88 | width: 100%; 89 | 90 | * { 91 | cursor: unset !important; 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /packages/webrtc-im/client/styles/main.m.scss: -------------------------------------------------------------------------------- 1 | @import '../../node_modules/@block-kit/utils/dist//style/vars.scss'; 2 | 3 | html, 4 | body, 5 | :global(.root) { 6 | height: 100%; 7 | } 8 | 9 | body { 10 | background-color: rgb(var(--arcoblue-1)); 11 | color: var(--color-text-1); 12 | overflow: hidden; 13 | 14 | /* stylelint-disable-next-line selector-no-qualifying-type */ 15 | &[arco-theme='dark'] { 16 | background-color: var(--color-bg-3); 17 | } 18 | } 19 | 20 | .main { 21 | @include frame-box; 22 | 23 | background-color: var(--color-bg-2); 24 | border-radius: 10px; 25 | display: flex; 26 | height: 70%; 27 | left: 50%; 28 | max-width: 1000px; 29 | min-width: 800px; 30 | overflow: hidden; 31 | position: fixed; 32 | top: 50%; 33 | transform: translate(-50%, -50%); 34 | width: 80%; 35 | } 36 | 37 | .github { 38 | color: var(--color-text-1); 39 | font-size: 23px; 40 | position: fixed; 41 | right: 12px; 42 | top: 6px; 43 | } 44 | 45 | // Mobile 46 | .hidden { 47 | display: none; 48 | } 49 | 50 | :global(.webrtc-im-mobile) { 51 | &.main { 52 | flex-direction: column-reverse; 53 | height: 100%; 54 | max-width: unset; 55 | min-width: unset; 56 | width: 100%; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /packages/webrtc-im/client/styles/message.m.scss: -------------------------------------------------------------------------------- 1 | @import '../../node_modules/@block-kit/utils/dist/style/vars'; 2 | 3 | .container { 4 | display: flex; 5 | flex: 1 0; 6 | flex-direction: column; 7 | } 8 | 9 | .captainArea { 10 | align-items: center; 11 | border-bottom: 1px solid var(--color-border-1); 12 | box-sizing: border-box; 13 | display: flex; 14 | height: 60px; 15 | justify-content: space-between; 16 | padding: 10px; 17 | 18 | .captainName { 19 | font-weight: bold; 20 | margin-top: 2px; 21 | } 22 | 23 | .captain { 24 | align-items: center; 25 | display: flex; 26 | gap: 10px; 27 | } 28 | 29 | .disconnect { 30 | cursor: pointer; 31 | margin-right: 15px; 32 | } 33 | } 34 | 35 | .dot { 36 | border-radius: 50%; 37 | height: 8px; 38 | margin-top: 3px; 39 | width: 8px; 40 | } 41 | 42 | .messageArea { 43 | @include no-scrollbar; 44 | 45 | flex: 1 0; 46 | overflow-y: auto; 47 | padding: 5px 10px; 48 | 49 | .messageItem { 50 | align-items: center; 51 | color: var(--color-white); 52 | display: flex; 53 | justify-content: flex-end; 54 | margin: 10px 0; 55 | } 56 | 57 | .peerMessage { 58 | justify-content: flex-start; 59 | } 60 | 61 | .systemMessage { 62 | background-color: var(--color-fill-2); 63 | border-radius: 10px; 64 | color: var(--color-text-1); 65 | font-size: 12px; 66 | margin-left: auto; 67 | margin-right: auto; 68 | padding: 1px 15px; 69 | user-select: none; 70 | zoom: 0.9; 71 | } 72 | 73 | .basicMessage { 74 | align-items: center; 75 | background-color: rgba(var(--arcoblue-7), 0.8); 76 | border-radius: 10px; 77 | box-sizing: border-box; 78 | display: flex; 79 | margin-top: 20px; 80 | max-width: 80%; 81 | overflow-wrap: break-word; 82 | padding: 10px; 83 | white-space: pre-wrap; 84 | word-break: break-word; 85 | } 86 | 87 | .peerMessage .basicMessage { 88 | background-color: rgba(var(--green-7), 0.8); 89 | } 90 | 91 | .fileMessage { 92 | align-items: unset; 93 | color: var(--color-white); 94 | display: flex; 95 | flex-direction: column; 96 | max-width: 80%; 97 | min-width: 180px; 98 | padding: 10px; 99 | 100 | .fileInfo { 101 | align-items: center; 102 | display: flex; 103 | font-size: 12px; 104 | justify-content: space-between; 105 | } 106 | 107 | .fileName { 108 | font-size: 14px; 109 | margin-bottom: 3px; 110 | overflow-wrap: break-word; 111 | white-space: pre-wrap; 112 | word-break: break-word; 113 | } 114 | 115 | .fileIcon { 116 | margin-right: 3px; 117 | } 118 | 119 | .fileDownload { 120 | align-items: center; 121 | border: 1px solid var(--color-white); 122 | border-radius: 20px; 123 | cursor: pointer; 124 | display: flex; 125 | flex-shrink: 0; 126 | font-size: 16px; 127 | height: 26px; 128 | justify-content: center; 129 | margin-left: 20px; 130 | width: 26px; 131 | 132 | &.disable { 133 | cursor: not-allowed; 134 | opacity: 0.5; 135 | } 136 | } 137 | 138 | :global(.arco-progress-line-text) { 139 | color: inherit; 140 | } 141 | } 142 | } 143 | 144 | .inputArea { 145 | border-top: 1px solid var(--color-border-1); 146 | box-sizing: border-box; 147 | display: flex; 148 | flex-direction: column; 149 | height: 130px; 150 | padding-bottom: 3px; 151 | position: relative; 152 | 153 | .operation { 154 | align-items: center; 155 | color: var(--color-text-2); 156 | display: flex; 157 | padding: 4px 5px; 158 | padding-bottom: 2px; 159 | } 160 | 161 | .operation > div { 162 | border-radius: 3px; 163 | cursor: pointer; 164 | font-size: 16px; 165 | line-height: 1; 166 | margin-left: 3px; 167 | padding: 3px; 168 | } 169 | 170 | .operation > div:hover { 171 | background-color: var(--color-fill-2); 172 | } 173 | 174 | .textarea { 175 | @include min-scrollbar; 176 | 177 | background: transparent; 178 | border: unset; 179 | color: var(--color-text-1); 180 | flex: 1 0; 181 | padding: 0 10px; 182 | resize: none; 183 | } 184 | 185 | .send { 186 | align-items: center; 187 | background-color: rgb(var(--arcoblue-5)); 188 | border-radius: 50%; 189 | bottom: 10px; 190 | color: var(--color-white); 191 | cursor: pointer; 192 | display: flex; 193 | font-size: 15px; 194 | height: 30px; 195 | justify-content: center; 196 | position: absolute; 197 | right: 10px; 198 | width: 30px; 199 | 200 | :global(.arco-icon) { 201 | margin-left: -1px; 202 | margin-top: 2px; 203 | } 204 | } 205 | 206 | .dragging { 207 | align-items: center; 208 | border: 2px dashed var(--color-border-2); 209 | box-sizing: border-box; 210 | display: flex; 211 | height: 100%; 212 | justify-content: center; 213 | left: 0; 214 | position: absolute; 215 | top: 0; 216 | width: 100%; 217 | } 218 | } 219 | 220 | @keyframes slide-in-from-right { 221 | from { 222 | left: 100%; 223 | } 224 | 225 | to { 226 | left: 0; 227 | } 228 | } 229 | 230 | :global(.webrtc-im-mobile) { 231 | .container { 232 | animation: slide-in-from-right 0.2s ease-out forwards; 233 | background-color: var(--color-bg-3); 234 | height: 100%; 235 | position: fixed; 236 | width: 100%; 237 | 238 | * { 239 | cursor: unset !important; 240 | } 241 | } 242 | } 243 | 244 | .disabled { 245 | cursor: not-allowed !important; 246 | opacity: 0.8 !important; 247 | } 248 | -------------------------------------------------------------------------------- /packages/webrtc-im/client/styles/tab-bar.m.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | align-items: center; 3 | background-color: var(--color-fill-1); 4 | border-right: 1px solid var(--color-fill-1); 5 | display: flex; 6 | flex-direction: column; 7 | padding-top: 15px; 8 | width: 60px; 9 | } 10 | 11 | .avatar { 12 | align-items: center; 13 | display: flex; 14 | flex-direction: column; 15 | max-width: 100%; 16 | } 17 | 18 | .dot { 19 | border-radius: 50%; 20 | bottom: 0; 21 | height: 8px; 22 | position: absolute; 23 | right: 0; 24 | width: 8px; 25 | } 26 | 27 | .name { 28 | box-sizing: border-box; 29 | color: var(--color-text-2); 30 | font-size: 11px; 31 | margin-top: 8px; 32 | max-width: 100%; 33 | padding: 0 5px; 34 | } 35 | 36 | .netTab { 37 | align-items: center; 38 | border-radius: 10px; 39 | color: var(--color-text-2); 40 | cursor: pointer; 41 | display: flex; 42 | font-size: 20px; 43 | height: 40px; 44 | justify-content: center; 45 | margin-top: 15px; 46 | width: 40px; 47 | 48 | &:hover, 49 | &.active { 50 | background-color: var(--color-fill-3); 51 | } 52 | } 53 | 54 | :global(.webrtc-im-mobile) { 55 | .container { 56 | box-sizing: border-box; 57 | flex-direction: row; 58 | height: unset; 59 | justify-content: space-between; 60 | padding: 8px 20px; 61 | width: 100%; 62 | 63 | * { 64 | // pointer 会导致移动端点击出现蓝色 highlight 65 | cursor: unset !important; 66 | } 67 | } 68 | 69 | .avatar { 70 | height: 40px; 71 | width: 60px; 72 | } 73 | 74 | .name { 75 | font-size: 10px; 76 | margin-top: 1px; 77 | } 78 | 79 | .netTab { 80 | margin-top: unset; 81 | width: 60px; 82 | 83 | &:hover, 84 | &.active { 85 | background-color: var(--color-fill-3); 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /packages/webrtc-im/client/utils/connection.ts: -------------------------------------------------------------------------------- 1 | import type { O } from "@block-kit/utils/dist/es/types"; 2 | import { CONNECTION_STATE } from "../../types/client"; 3 | import { getId, Storage } from "@block-kit/utils"; 4 | import { SESSION_KEY } from "../../types/server"; 5 | 6 | export type PromiseWithResolve = Promise & { resolve: (v: T) => void }; 7 | 8 | export const createConnectReadyPromise = () => { 9 | // Promise 可以被多次 await, 类似下面的例子输出是 1 2 3 10 | // const { promise, resolve } = Promise.withResolvers(); 11 | // [1, 2, 3].forEach(async i => { 12 | // await promise; 13 | // console.log("Hello", i); 14 | // }); 15 | // resolve(); 16 | let resolve: () => void; 17 | const promise = new Promise(res => { 18 | resolve = res; 19 | }) as PromiseWithResolve; 20 | promise.resolve = resolve!; 21 | return promise; 22 | }; 23 | 24 | export const CONNECT_DOT: O.Map = { 25 | [CONNECTION_STATE.READY]: "rgb(var(--red-6))", 26 | [CONNECTION_STATE.CONNECTING]: "rgb(var(--orange-6))", 27 | [CONNECTION_STATE.CONNECTED]: "rgb(var(--green-6))", 28 | }; 29 | 30 | const IS_COPIED_TAB = "x-is-copied-tab"; 31 | 32 | export const getSessionId = () => { 33 | try { 34 | if (Storage.session.get(IS_COPIED_TAB)) { 35 | return getId(); 36 | } 37 | const [navigationEntry] = performance.getEntriesByType( 38 | "navigation" 39 | ) as PerformanceNavigationTiming[]; 40 | const isReload = navigationEntry && navigationEntry.type === "reload"; 41 | const isOpenedBefore = !!Storage.session.get(SESSION_KEY); 42 | if (!isReload && isOpenedBefore) { 43 | // 如果不是刷新页面,并且之前已经打开过,则很可能是从另一个标签页复制而来的 44 | Storage.session.set(IS_COPIED_TAB, true); 45 | return getId(); 46 | } 47 | const sessionId = Storage.session.get(SESSION_KEY) || getId(); 48 | Storage.session.set(SESSION_KEY, sessionId); 49 | return sessionId; 50 | } catch (error) { 51 | console.error("GetSessionId Error:", error); 52 | return getId(); 53 | } 54 | }; 55 | -------------------------------------------------------------------------------- /packages/webrtc-im/client/utils/event-bus.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-empty-interface 2 | export interface EventBusType {} 3 | 4 | export type EventContext = { 5 | /** 事件名 */ 6 | key: string | number | symbol; 7 | /** 已停止顺序执行 */ 8 | stopped: boolean; 9 | /** 已阻止默认行为 */ 10 | prevented: boolean; 11 | /** 停止顺序执行 */ 12 | stop: () => void; 13 | /** 阻止默认行为 */ 14 | prevent: () => void; 15 | }; 16 | 17 | export type Handler> = { 18 | once: boolean; 19 | priority: number; 20 | listener: Listener; 21 | }; 22 | 23 | export type EventKeys = keyof E; 24 | export type Listeners = { [T in EventKeys]?: Handler[] }; 25 | export type Listener> = (payload: E[T], context: EventContext) => unknown; 26 | 27 | export class EventBus { 28 | /** 29 | * 事件监听器 30 | */ 31 | private listeners: Listeners = {}; 32 | 33 | /** 34 | * 监听事件 35 | * @param key 36 | * @param listener 37 | * @param priority 默认为 100 38 | */ 39 | public on>(key: T, listener: Listener, priority = 100) { 40 | this.addEventListener(key, listener, priority, false); 41 | } 42 | 43 | /** 44 | * 一次性事件监听 45 | * @param key 46 | * @param listener 47 | * @param priority 默认为 100 48 | */ 49 | public once>(key: T, listener: Listener, priority = 100) { 50 | this.addEventListener(key, listener, priority, true); 51 | } 52 | 53 | /** 54 | * 添加事件监听 55 | * @param key 56 | * @param listener 57 | * @param priority 58 | * @param once 59 | */ 60 | private addEventListener>( 61 | key: T, 62 | listener: Listener, 63 | priority: number, 64 | once: boolean 65 | ) { 66 | const handler: Handler[] = this.listeners[key] || []; 67 | if (!handler.some(item => item.listener === listener)) { 68 | handler.push({ listener, priority, once }); 69 | } 70 | handler.sort((a, b) => a.priority - b.priority); 71 | this.listeners[key] = []>handler; 72 | } 73 | 74 | /** 75 | * 移除事件监听 76 | * @param key 77 | * @param listener 78 | */ 79 | public off>(key: T, listener: Listener) { 80 | const handler = this.listeners[key]; 81 | if (!handler) return void 0; 82 | // COMPAT: 不能直接`splice` 可能会导致`trigger`时打断`forEach` 83 | const next = handler.filter(item => item.listener !== listener); 84 | this.listeners[key] = []>next; 85 | } 86 | 87 | /** 88 | * 触发事件 89 | * @param key 90 | * @param listener 91 | * @returns prevented 92 | */ 93 | public emit>(key: T, payload: E[T]): boolean { 94 | const handler = this.listeners[key]; 95 | if (!handler) return false; 96 | const context: EventContext = { 97 | key: key, 98 | stopped: false, 99 | prevented: false, 100 | stop: () => { 101 | context.stopped = true; 102 | }, 103 | prevent: () => { 104 | context.prevented = true; 105 | }, 106 | }; 107 | for (const item of handler) { 108 | try { 109 | item.listener(payload, context); 110 | } catch (error) { 111 | console.error(`EventBus: Error for event`, item, error); 112 | } 113 | item.once && this.off(key, item.listener); 114 | if (context.stopped) { 115 | break; 116 | } 117 | } 118 | return context.prevented; 119 | } 120 | 121 | /** 122 | * 清理事件 123 | */ 124 | public clear() { 125 | this.listeners = {}; 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /packages/webrtc-im/client/view/contacts.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from "react"; 2 | import { Fragment, useEffect, useState } from "react"; 3 | import styles from "../styles/contacts.m.scss"; 4 | import type { 5 | ServerInitUserEvent, 6 | ServerLeaveRoomEvent, 7 | ServerSendOfferEvent, 8 | } from "../../types/signaling"; 9 | import { SERVER_EVENT } from "../../types/signaling"; 10 | import { Empty, Input } from "@arco-design/web-react"; 11 | import { useGlobalContext } from "../store/global"; 12 | import { useMemoFn } from "@block-kit/utils/dist/es/hooks"; 13 | import { Avatar } from "../component/avatar"; 14 | import type { Users } from "../../types/client"; 15 | import { DEVICE_TYPE, NET_TYPE } from "../../types/client"; 16 | import { PhoneIcon } from "../component/icons/phone"; 17 | import { PCIcon } from "../component/icons/pc"; 18 | import { IconSearch } from "@arco-design/web-react/icon"; 19 | import { useAtom, useAtomValue } from "jotai"; 20 | import { cs, sleep } from "@block-kit/utils"; 21 | import { atoms } from "../store/atoms"; 22 | import type { O } from "@block-kit/utils/dist/es/types"; 23 | 24 | export const Contacts: FC = () => { 25 | const { signal, store, message, rtc } = useGlobalContext(); 26 | const [search, setSearch] = useState(""); 27 | const netType = useAtomValue(store.netTypeAtom); 28 | const [peerId, setPeerId] = useAtom(store.peerIdAtom); 29 | const [list, setList] = useAtom(store.userListAtom); 30 | 31 | const onInitUser = useMemoFn(async (payload: ServerInitUserEvent) => { 32 | setList(payload.users); 33 | await sleep(10); 34 | if (!peerId) return void 0; 35 | // 如果初始化时 peerId 已经存在, 则尝试连接 36 | const list = atoms.get(store.userListAtom); 37 | const peerUser = list.find(user => user.id === peerId); 38 | peerUser && rtc.connect(peerId); 39 | }); 40 | 41 | const onJoinRoom = useMemoFn((user: Users[number]) => { 42 | console.log("Join Room", user); 43 | setList(prev => { 44 | const userMap: O.Map = {}; 45 | prev.forEach(u => (userMap[u.id] = u)); 46 | userMap[user.id] = user; 47 | return Object.values(userMap); 48 | }); 49 | }); 50 | 51 | const onLeaveRoom = useMemoFn((user: ServerLeaveRoomEvent) => { 52 | console.log("Leave Room", user); 53 | if (user.id === peerId) { 54 | // 如果离开的是当前连接的用户, 则保留当前的用户状态 55 | rtc.disconnect(); 56 | const peerUser = list.find(u => u.id === peerId); 57 | peerUser && (peerUser.offline = true); 58 | } else { 59 | setList(prev => prev.filter(u => u.id !== user.id)); 60 | } 61 | }); 62 | 63 | const connectUser = async (userId: string) => { 64 | if (peerId === userId) return void 0; 65 | rtc.disconnect(); 66 | message.clearEntries(); 67 | setPeerId(userId); 68 | await signal.isConnected(); 69 | rtc.connect(userId); 70 | }; 71 | 72 | const onReceiveOffer = useMemoFn(async (event: ServerSendOfferEvent) => { 73 | const { from } = event; 74 | // 这个实际上是先于实际 setRemoteDescription 的, 事件调用优先级会更高 75 | if ( 76 | peerId === from || 77 | rtc.connection.connectionState === "new" || 78 | rtc.connection.connectionState === "failed" || 79 | rtc.connection.connectionState === "disconnected" || 80 | rtc.connection.connectionState === "closed" 81 | ) { 82 | rtc.disconnect(); 83 | setPeerId(from); 84 | peerId !== from && message.clearEntries(); 85 | } 86 | }); 87 | 88 | useEffect(() => { 89 | signal.bus.on(SERVER_EVENT.INIT_USER, onInitUser, 105); 90 | signal.bus.on(SERVER_EVENT.JOIN_ROOM, onJoinRoom); 91 | signal.bus.on(SERVER_EVENT.LEAVE_ROOM, onLeaveRoom); 92 | signal.bus.on(SERVER_EVENT.SEND_OFFER, onReceiveOffer, 10); 93 | return () => { 94 | signal.bus.off(SERVER_EVENT.INIT_USER, onInitUser); 95 | signal.bus.off(SERVER_EVENT.JOIN_ROOM, onJoinRoom); 96 | signal.bus.off(SERVER_EVENT.LEAVE_ROOM, onLeaveRoom); 97 | signal.bus.off(SERVER_EVENT.SEND_OFFER, onReceiveOffer); 98 | }; 99 | }, [onInitUser, onJoinRoom, onLeaveRoom, onReceiveOffer, signal]); 100 | 101 | useEffect(() => { 102 | // 若 PeerId 变化, 则将通讯录的下线用户状态清除 103 | const users = atoms.get(store.userListAtom); 104 | const newUsers = users.filter(user => !user.offline); 105 | newUsers.length !== users.length && atoms.set(store.userListAtom, newUsers); 106 | }, [peerId, store.userListAtom]); 107 | 108 | const filteredList = list.filter(user => { 109 | const isMatchSearch = !search || user.id.toLowerCase().includes(search.toLowerCase()); 110 | if (netType === NET_TYPE.WAN) { 111 | return isMatchSearch; 112 | } 113 | let isLan = signal.hash === user.hash; 114 | // 本地部署应用时, ip 地址可能是 ::1 或 ::ffff: 115 | if ( 116 | isLan === true || 117 | signal.ip === ":*:*" || 118 | signal.ip.startsWith("192.168") || 119 | signal.ip.startsWith("10.") || 120 | signal.ip.startsWith("172.") || 121 | signal.ip.startsWith("::ffff:192.168") 122 | ) { 123 | isLan = true; 124 | } 125 | return isLan && isMatchSearch; 126 | }); 127 | 128 | return ( 129 |
130 | } 135 | size="small" 136 | placeholder="Search" 137 | > 138 |
139 | {filteredList.map(user => ( 140 | 141 |
connectUser(user.id)} 143 | className={cs(styles.user, peerId === user.id && styles.active)} 144 | > 145 |
146 | 147 |
148 |
149 |
150 | {user.id} 151 | {user.device === DEVICE_TYPE.MOBILE ? PhoneIcon : PCIcon} 152 |
153 |
{user.ip}
154 |
155 |
156 |
157 |
158 | ))} 159 |
160 | {!filteredList.length && ( 161 | 165 | )} 166 |
167 | ); 168 | }; 169 | -------------------------------------------------------------------------------- /packages/webrtc-im/client/view/main.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from "react"; 2 | import { Fragment } from "react"; 3 | import styles from "../styles/main.m.scss"; 4 | import { TabBar } from "./tab-bar"; 5 | import { Contacts } from "./contacts"; 6 | import { IconGithub } from "@arco-design/web-react/icon"; 7 | import { Message } from "./message"; 8 | import { cs } from "@block-kit/utils"; 9 | import { useIsMobile } from "../hooks/use-is-mobile"; 10 | 11 | export const Main: FC = () => { 12 | const { isMobile } = useIsMobile(); 13 | 14 | return ( 15 | 16 | 21 | 22 | 23 |
24 | 25 | 26 | 27 |
28 |
29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /packages/webrtc-im/client/view/message.tsx: -------------------------------------------------------------------------------- 1 | import { useAtom, useAtomValue } from "jotai"; 2 | import styles from "../styles/message.m.scss"; 3 | import type { FC } from "react"; 4 | import { Fragment, useEffect, useMemo, useRef, useState } from "react"; 5 | import { useGlobalContext } from "../store/global"; 6 | import { CONNECT_DOT } from "../utils/connection"; 7 | import { Avatar } from "../component/avatar"; 8 | import { STEAM_TYPE, TRANSFER_FROM, TRANSFER_TYPE } from "../../types/transfer"; 9 | import { IconClose, IconFile, IconFolder, IconToBottom } from "@arco-design/web-react/icon"; 10 | import { SendIcon } from "../component/icons/send"; 11 | import { cs, Format, KEY_CODE, preventNativeEvent } from "@block-kit/utils"; 12 | import { Progress } from "@arco-design/web-react"; 13 | import { atoms } from "../store/atoms"; 14 | import { CONNECTION_STATE } from "../../types/client"; 15 | import { Beacon } from "../component/beacon"; 16 | import { useIsMobile } from "../hooks/use-is-mobile"; 17 | 18 | export const Message: FC = () => { 19 | const { signal, message, store, rtc, transfer } = useGlobalContext(); 20 | const list = useAtomValue(message.listAtom); 21 | const rtcState = useAtomValue(rtc.stateAtom); 22 | const users = useAtomValue(store.userListAtom); 23 | const textareaRef = useRef(null); 24 | const [peerId, setPeerId] = useAtom(store.peerIdAtom); 25 | const [isDragging, setIsDragging] = useState(false); 26 | const { isMobile } = useIsMobile(); 27 | 28 | const isConnected = rtcState === CONNECTION_STATE.CONNECTED; 29 | 30 | const peerUser = useMemo(() => { 31 | if (!peerId) return null; 32 | return users.find(user => user.id === peerId) || null; 33 | }, [peerId, users]); 34 | 35 | useEffect(() => { 36 | // 聚焦 Textarea 37 | textareaRef.current && textareaRef.current.focus(); 38 | }, [peerId]); 39 | 40 | if (!peerId || !peerUser) { 41 | return isMobile ? null : ; 42 | } 43 | 44 | const onDisconnect = () => { 45 | setPeerId(""); 46 | rtc.disconnect(); 47 | }; 48 | 49 | const sendTextMessage = async () => { 50 | const textarea = textareaRef.current; 51 | const text = textarea && textarea.value; 52 | if (!text) return void 0; 53 | const racePeerId = atoms.get(store.peerIdAtom); 54 | await signal.isConnected(); 55 | await rtc.isConnected(); 56 | if (racePeerId !== atoms.get(store.peerIdAtom)) { 57 | return void 0; 58 | } 59 | transfer.sendTextMessage(text); 60 | textareaRef.current && (textareaRef.current.value = ""); 61 | }; 62 | 63 | const sendFileListMessage = async (files: FileList) => { 64 | const racePeerId = atoms.get(store.peerIdAtom); 65 | await signal.isConnected(); 66 | await rtc.isConnected(); 67 | if (racePeerId !== atoms.get(store.peerIdAtom)) { 68 | return void 0; 69 | } 70 | transfer.startSendFileList(files); 71 | }; 72 | 73 | const onPressEnter = (e: React.KeyboardEvent) => { 74 | if (e.keyCode === KEY_CODE.ENTER && !e.shiftKey) { 75 | sendTextMessage(); 76 | e.preventDefault(); 77 | } 78 | }; 79 | 80 | const onPickFiles = () => { 81 | const KEY = "webrtc-file-input"; 82 | const exist = document.querySelector(`body > [data-type='${KEY}']`) as HTMLInputElement; 83 | const input: HTMLInputElement = exist || document.createElement("input"); 84 | input.value = ""; 85 | input.hidden = true; 86 | input.setAttribute("data-type", KEY); 87 | input.setAttribute("type", "file"); 88 | input.setAttribute("class", styles.fileInput); 89 | input.setAttribute("accept", "*"); 90 | input.setAttribute("multiple", "true"); 91 | !exist && document.body.append(input); 92 | input.onchange = e => { 93 | const target = e.target as HTMLInputElement; 94 | document.body.removeChild(input); 95 | const files = target.files; 96 | files && sendFileListMessage(files); 97 | }; 98 | input.click(); 99 | }; 100 | 101 | const onDropFiles = (e: React.DragEvent) => { 102 | e.preventDefault(); 103 | e.stopPropagation(); 104 | setIsDragging(false); 105 | const files = e.dataTransfer.files; 106 | files && sendFileListMessage(files); 107 | }; 108 | 109 | const onDownloadFile = (id: string, fileName: string) => { 110 | const blob = transfer.fileMapper.get(id) 111 | ? new Blob(transfer.fileMapper.get(id), { type: STEAM_TYPE }) 112 | : transfer.fileHandler.get(id) || new Blob(); 113 | const url = URL.createObjectURL(blob); 114 | const a = document.createElement("a"); 115 | a.href = url; 116 | a.download = fileName; 117 | a.click(); 118 | URL.revokeObjectURL(url); 119 | }; 120 | 121 | const onPasteFiles = (event: React.ClipboardEvent) => { 122 | if (isConnected && event.clipboardData.files.length) { 123 | event.preventDefault(); 124 | sendFileListMessage(event.clipboardData.files); 125 | } 126 | const clipboardData = event.clipboardData; 127 | for (const item of clipboardData.items) { 128 | console.log(`%c${item.type}`, "background-color: #165DFF; color: #fff; padding: 3px 5px;"); 129 | console.log(item.kind === "file" ? item.getAsFile() : clipboardData.getData(item.type)); 130 | } 131 | }; 132 | 133 | return ( 134 |
135 |
136 |
137 | 138 |
{peerUser.id}
139 |
140 |
141 |
142 | 143 |
144 |
145 |
(message.scroll = el)}> 146 | {list.map((item, index) => ( 147 |
154 | {item.key === TRANSFER_TYPE.SYSTEM && ( 155 |
{item.data}
156 | )} 157 | {item.key === TRANSFER_TYPE.TEXT && ( 158 |
{item.data}
159 | )} 160 | {item.key === TRANSFER_TYPE.FILE && ( 161 |
162 |
163 |
164 |
165 | 166 | {item.name} 167 |
168 |
{Format.bytes(item.size)}
169 |
170 |
item.process >= 100 && onDownloadFile(item.id, item.name)} 173 | > 174 | 175 |
176 |
177 | 178 |
179 | )} 180 |
181 | ))} 182 |
183 |
peerId && setIsDragging(true)} 186 | onDragLeave={() => setIsDragging(false)} 187 | onDrop={onDropFiles} 188 | onDragOver={preventNativeEvent} 189 | > 190 | {isDragging && isConnected ? ( 191 |
Drop Files To Upload
192 | ) : ( 193 | 194 |
195 |
199 | 200 |
201 |
202 | 208 |
213 | {SendIcon} 214 |
215 |
216 | )} 217 |
218 |
219 | ); 220 | }; 221 | -------------------------------------------------------------------------------- /packages/webrtc-im/client/view/tab-bar.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from "react"; 2 | import styles from "../styles/tab-bar.m.scss"; 3 | import { Avatar } from "../component/avatar"; 4 | import { useGlobalContext } from "../store/global"; 5 | import { useAtom, useAtomValue } from "jotai"; 6 | import { CONNECT_DOT } from "../utils/connection"; 7 | import { IconCloud, IconUser } from "@arco-design/web-react/icon"; 8 | import { cs } from "@block-kit/utils"; 9 | import { NET_TYPE } from "../../types/client"; 10 | import { EllipsisTooltip } from "../component/ellipsis"; 11 | import { useIsMobile } from "../hooks/use-is-mobile"; 12 | 13 | export const TabBar: FC = () => { 14 | const { signal, store } = useGlobalContext(); 15 | const { isMobile } = useIsMobile(); 16 | const signalState = useAtomValue(signal.stateAtom); 17 | const [tab, setTab] = useAtom(store.netTypeAtom); 18 | 19 | return ( 20 |
21 |
22 | 23 |
24 |
25 |
26 | {" "} 31 |
32 |
33 |
setTab(NET_TYPE.LAN)} 35 | className={cs(styles.netTab, tab === NET_TYPE.LAN && styles.active)} 36 | > 37 | 38 |
39 |
setTab(NET_TYPE.WAN)} 41 | className={cs(styles.netTab, tab === NET_TYPE.WAN && styles.active)} 42 | > 43 | 44 |
45 |
46 | ); 47 | }; 48 | -------------------------------------------------------------------------------- /packages/webrtc-im/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ft/webrtc-im", 3 | "version": "1.0.0", 4 | "files": [ 5 | "build/*" 6 | ], 7 | "scripts": { 8 | "dev:client": "rspack dev -c rspack.client.js", 9 | "build:client": "rspack build -c rspack.client.js", 10 | "dev:server": "nodemon --watch server --ext ts --exec \"tsx server/index.ts\"", 11 | "build:server": "mkdir -p build && rollup -c rollup.server.js", 12 | "dev": "concurrently \"npm run dev:client\" \"npm run dev:server\"", 13 | "build": "rimraf build && npm run build:client && npm run build:server", 14 | "deploy": "npm run build && node build/server.js", 15 | "build:deploy": "npm run build && pnpm -F . --prod deploy ../../output", 16 | "lint:ts": "../../node_modules/typescript/bin/tsc --noEmit -p tsconfig.json" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/WindrunnerMax/FileTransfer.git" 21 | }, 22 | "keywords": [], 23 | "author": "", 24 | "license": "MIT", 25 | "bugs": { 26 | "url": "https://github.com/WindRunnerMax/FileTransfer/issues" 27 | }, 28 | "homepage": "https://github.com/WindRunnerMax/FileTransfer", 29 | "dependencies": { 30 | "@block-kit/utils": "1.0.6", 31 | "express": "4.18.2", 32 | "socket.io": "4.7.2" 33 | }, 34 | "devDependencies": { 35 | "@arco-design/web-react": "2.56.1", 36 | "jotai": "2.12.5", 37 | "react": "17.0.2", 38 | "react-dom": "17.0.2", 39 | "socket.io-client": "4.7.2", 40 | "@rspack/cli": "0.2.5", 41 | "@rspack/plugin-html": "0.2.5", 42 | "@types/express": "4.17.21", 43 | "@types/react": "17.0.2", 44 | "@types/react-dom": "17.0.2", 45 | "concurrently": "8.2.2", 46 | "copy-webpack-plugin": "5", 47 | "cross-env": "7.0.3", 48 | "esbuild": "0.19.6", 49 | "less": "3.0.0", 50 | "less-loader": "6.0.0", 51 | "nodemon": "3.0.1", 52 | "postcss": "8.3.3", 53 | "prettier": "2.4.1", 54 | "rimraf": "6.0.1", 55 | "rollup": "2.75.7", 56 | "rollup-plugin-esbuild": "6.1.0", 57 | "sass": "1.52.3", 58 | "sass-loader": "13.3.2", 59 | "tsx": "4.19.4" 60 | }, 61 | "engines": { 62 | "node": ">=14.0.0", 63 | "pnpm": ">=8.11.0" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /packages/webrtc-im/rollup.server.js: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import esbuild from "rollup-plugin-esbuild"; 3 | 4 | export default async () => { 5 | return { 6 | input: "./server/index.ts", 7 | output: { 8 | file: "./build/server.js", 9 | format: "cjs", 10 | }, 11 | external: [ 12 | "socket.io", 13 | "http", 14 | "node:os", 15 | "express", 16 | "process", 17 | "path", 18 | "node:crypto", 19 | "@block-kit/utils", 20 | ], 21 | plugins: [ 22 | esbuild({ 23 | exclude: [/node_modules/], 24 | target: "esnext", 25 | minify: true, 26 | charset: "utf8", 27 | tsconfig: path.resolve(__dirname, "tsconfig.json"), 28 | }), 29 | ], 30 | }; 31 | }; 32 | -------------------------------------------------------------------------------- /packages/webrtc-im/rspack.client.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const { default: HtmlPlugin } = require("@rspack/plugin-html"); 3 | const CopyPlugin = require("copy-webpack-plugin"); 4 | const { getId } = require("@block-kit/utils"); 5 | 6 | const PUBLIC_PATH = "/"; 7 | const RANDOM_ID = getId(); 8 | const isDev = process.env.NODE_ENV === "development"; 9 | 10 | /** 11 | * @type {import("@rspack/cli").Configuration} 12 | * @link https://www.rspack.dev/ 13 | */ 14 | module.exports = { 15 | context: __dirname, 16 | entry: { 17 | index: "./client/index.tsx", 18 | }, 19 | externals: { 20 | "react": "React", 21 | "react-dom": "ReactDOM", 22 | "vue": "Vue", 23 | }, 24 | plugins: [ 25 | new CopyPlugin([{ from: "./client/static", to: "." }]), 26 | new HtmlPlugin({ 27 | filename: "index.html", 28 | template: "./client/static/index.html", 29 | }), 30 | ], 31 | resolve: { 32 | alias: { 33 | "@": path.resolve(__dirname, "./client"), 34 | }, 35 | }, 36 | builtins: { 37 | define: { 38 | "__DEV__": JSON.stringify(isDev), 39 | "process.env.RANDOM_ID": JSON.stringify(RANDOM_ID), 40 | "process.env.PUBLIC_PATH": JSON.stringify(PUBLIC_PATH), 41 | "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV), 42 | }, 43 | pluginImport: [ 44 | { 45 | libraryName: "@arco-design/web-react", 46 | customName: "@arco-design/web-react/es/{{ member }}", 47 | style: true, 48 | }, 49 | ], 50 | }, 51 | module: { 52 | rules: [ 53 | { test: /\.svg$/, type: "asset" }, 54 | { 55 | test: /\.(m|module).scss$/, 56 | use: [{ loader: "sass-loader" }], 57 | type: "css/module", 58 | }, 59 | { 60 | test: /\.less$/, 61 | use: [ 62 | { 63 | loader: "less-loader", 64 | options: { 65 | lessOptions: { 66 | javascriptEnabled: true, 67 | importLoaders: true, 68 | localIdentName: "[name]__[hash:base64:5]", 69 | }, 70 | }, 71 | }, 72 | ], 73 | type: "css", 74 | }, 75 | ], 76 | }, 77 | target: isDev ? undefined : "es5", 78 | devtool: isDev ? "source-map" : false, 79 | output: { 80 | publicPath: PUBLIC_PATH, 81 | chunkLoading: "jsonp", 82 | chunkFormat: "array-push", 83 | path: path.resolve(__dirname, "build/static"), 84 | filename: isDev ? "[name].bundle.js" : "[name].[contenthash].js", 85 | chunkFilename: isDev ? "[name].chunk.js" : "[name].[contenthash].js", 86 | assetModuleFilename: isDev ? "[name].[ext]" : "[name].[contenthash].[ext]", 87 | }, 88 | devServer: { 89 | port: 8080, 90 | proxy: { 91 | "/socket.io": { 92 | target: "ws://localhost:3000", 93 | changeOrigin: true, 94 | ws: true, 95 | }, 96 | }, 97 | }, 98 | }; 99 | -------------------------------------------------------------------------------- /packages/webrtc-im/server/index.ts: -------------------------------------------------------------------------------- 1 | import http from "http"; 2 | import path from "path"; 3 | import express from "express"; 4 | import process from "process"; 5 | import { Server } from "socket.io"; 6 | import type { ServerHandler, ClientHandler, ServerJoinRoomEvent } from "../types/signaling"; 7 | import { CLINT_EVENT, SERVER_EVENT } from "../types/signaling"; 8 | import type { ServerSocket, SocketMember } from "../types/server"; 9 | import { ERROR_CODE } from "../types/server"; 10 | import { getSocketIp, getLocalIp } from "./utils"; 11 | import { LRUSession } from "./session"; 12 | 13 | const app = express(); 14 | app.use(express.static(path.resolve(__dirname, "static"))); 15 | const httpServer = http.createServer(app); 16 | const io = new Server(httpServer); 17 | 18 | const sockets = new WeakMap(); 19 | const users = new Map(); 20 | const session = new LRUSession(); 21 | 22 | io.on("connection", socket => { 23 | const sessionId = socket.handshake.auth.sessionId; 24 | const userId = session.getId(sessionId || socket.id); 25 | const { ip: userIp, hash: userIpHash } = getSocketIp(socket.request); 26 | sockets.set(socket, userId); 27 | 28 | socket.on(CLINT_EVENT.JOIN_ROOM, payload => { 29 | const user: SocketMember = { 30 | id: userId, 31 | socket: socket, 32 | connected: true, 33 | device: payload.device, 34 | ip: userIp, 35 | hash: userIpHash, 36 | }; 37 | const newUser: ServerJoinRoomEvent = { 38 | id: userId, 39 | ip: user.ip, 40 | hash: user.hash, 41 | device: payload.device, 42 | }; 43 | const currentUsers: ServerJoinRoomEvent[] = [...users.values()].map(user => ({ 44 | ip: user.ip, 45 | id: user.id, 46 | hash: user.hash, 47 | device: user.device, 48 | })); 49 | socket.emit(SERVER_EVENT.INIT_USER, { self: newUser, users: currentUsers }); 50 | users.forEach(user => { 51 | user.socket.emit(SERVER_EVENT.JOIN_ROOM, newUser); 52 | }); 53 | users.set(userId, user); 54 | }); 55 | 56 | socket.on(CLINT_EVENT.SEND_OFFER, payload => { 57 | const { to } = payload; 58 | const targetUser = users.get(to); 59 | if (!targetUser) { 60 | socket.emit(SERVER_EVENT.SEND_ERROR, { 61 | code: ERROR_CODE.NOT_FOUNT, 62 | message: `Target user ${to} not fount`, 63 | }); 64 | return void 0; 65 | } 66 | targetUser.socket.emit(SERVER_EVENT.SEND_OFFER, { ...payload, from: userId }); 67 | }); 68 | 69 | socket.on(CLINT_EVENT.SEND_ICE, payload => { 70 | const { to } = payload; 71 | const targetUser = users.get(to); 72 | if (targetUser) { 73 | targetUser.socket.emit(SERVER_EVENT.SEND_ICE, { ...payload, from: userId }); 74 | } 75 | }); 76 | 77 | socket.on(CLINT_EVENT.SEND_ANSWER, payload => { 78 | const { to } = payload; 79 | const targetUser = users.get(to); 80 | if (targetUser) { 81 | targetUser.socket.emit(SERVER_EVENT.SEND_ANSWER, { ...payload, from: userId }); 82 | } 83 | }); 84 | 85 | socket.on(CLINT_EVENT.SEND_ERROR, payload => { 86 | const { to, code, message } = payload; 87 | const targetUser = users.get(to); 88 | if (targetUser) { 89 | targetUser.socket.emit(SERVER_EVENT.SEND_ERROR, { code, message }); 90 | } 91 | }); 92 | 93 | socket.on(CLINT_EVENT.LEAVE_ROOM, () => { 94 | users.delete(userId); 95 | users.forEach(user => { 96 | user.socket.emit(SERVER_EVENT.LEAVE_ROOM, { id: userId }); 97 | }); 98 | }); 99 | 100 | socket.on("disconnect", () => { 101 | const id = sockets.get(socket); 102 | if (!id) return void 0; 103 | users.delete(id); 104 | sockets.delete(socket); 105 | users.forEach(user => { 106 | user.socket.emit(SERVER_EVENT.LEAVE_ROOM, { id }); 107 | }); 108 | }); 109 | }); 110 | 111 | process.on("SIGINT", () => { 112 | console.info("SIGINT Received, exiting..."); 113 | process.exit(0); 114 | }); 115 | 116 | process.on("SIGTERM", () => { 117 | console.info("SIGTERM Received, exiting..."); 118 | process.exit(0); 119 | }); 120 | 121 | const PORT = Number(process.env.PORT) || 3000; 122 | httpServer.listen(PORT, () => { 123 | const ip = getLocalIp(); 124 | console.log(`Listening on port http://localhost:${PORT} ...`); 125 | ip.forEach(item => { 126 | console.log(`Listening on port http://${item}:${PORT} ...`); 127 | }); 128 | }); 129 | -------------------------------------------------------------------------------- /packages/webrtc-im/server/session.ts: -------------------------------------------------------------------------------- 1 | import { getId } from "@block-kit/utils"; 2 | 3 | export class LRUSession { 4 | private MAX_SIZE: number; 5 | private SESSIONS: Map; 6 | 7 | constructor() { 8 | this.MAX_SIZE = 100; 9 | this.SESSIONS = new Map(); 10 | } 11 | 12 | public getId(key: string) { 13 | const id = this.SESSIONS.get(key) || getId(); 14 | this.SESSIONS.delete(key); 15 | this.SESSIONS.set(key, id); 16 | this.inspectLRUSession(); 17 | return id; 18 | } 19 | 20 | private inspectLRUSession() { 21 | const size = this.SESSIONS.size; 22 | if (size <= this.MAX_SIZE) { 23 | return void 0; 24 | } 25 | const n = size - this.MAX_SIZE; 26 | const iterator = this.SESSIONS.keys(); 27 | for (let i = 0; i < n; i++) { 28 | const key = iterator.next().value; 29 | key && this.SESSIONS.delete(key); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /packages/webrtc-im/server/utils.ts: -------------------------------------------------------------------------------- 1 | import os from "node:os"; 2 | import type http from "node:http"; 3 | import crypto from "node:crypto"; 4 | 5 | export const getLocalIp = () => { 6 | const result: string[] = []; 7 | const interfaces = os.networkInterfaces(); 8 | for (const key in interfaces) { 9 | const networkInterface = interfaces[key]; 10 | if (!networkInterface) continue; 11 | for (const inf of networkInterface) { 12 | if (inf.family === "IPv4" && !inf.internal) { 13 | result.push(inf.address); 14 | } 15 | } 16 | } 17 | return result; 18 | }; 19 | 20 | export const getSocketIp = (request: http.IncomingMessage) => { 21 | let ip = "127.0.0.1"; 22 | if (request.headers["x-real-ip"]) { 23 | ip = request.headers["x-real-ip"].toString(); 24 | } else if (request.headers["x-forwarded-for"]) { 25 | const forwarded = request.headers["x-forwarded-for"].toString(); 26 | const [firstIp] = forwarded.split(","); 27 | ip = firstIp ? firstIp.trim() : ip; 28 | } else { 29 | ip = request.socket.remoteAddress || ip; 30 | } 31 | const hash = crypto 32 | .createHash("md5") 33 | .update(ip + "🧂") 34 | .digest("hex"); 35 | if (ip.indexOf(".") > -1) { 36 | ip = ip.split(".").slice(0, -2).join(".") + ".*.*"; 37 | } 38 | if (ip.indexOf(":") > -1) { 39 | ip = ip.split(":").slice(0, -2).join(":") + ":*:*"; 40 | } 41 | return { ip, hash }; 42 | }; 43 | -------------------------------------------------------------------------------- /packages/webrtc-im/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "include": ["./client/**/*", "./server/*", "./types/*"], 4 | } 5 | -------------------------------------------------------------------------------- /packages/webrtc-im/types/client.ts: -------------------------------------------------------------------------------- 1 | import type { O } from "@block-kit/utils/dist/es/types"; 2 | import type { ServerJoinRoomEvent } from "./signaling"; 3 | 4 | export const CONNECTION_STATE = { 5 | READY: "READY", 6 | CONNECTING: "CONNECTING", 7 | CONNECTED: "CONNECTED", 8 | } as const; 9 | 10 | export const DEVICE_TYPE = { 11 | MOBILE: "MOBILE", 12 | PC: "PC", 13 | } as const; 14 | 15 | export const NET_TYPE = { 16 | LAN: "LAN", 17 | WAN: "WAN", 18 | } as const; 19 | 20 | export type DeviceType = O.Values; 21 | export type ConnectionState = O.Values; 22 | export type NetType = O.Values; 23 | export type Member = { id: string; device: DeviceType }; 24 | export type Users = (ServerJoinRoomEvent & { offline?: boolean })[]; 25 | -------------------------------------------------------------------------------- /packages/webrtc-im/types/global.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.scss" { 2 | const content: Record; 3 | export default content; 4 | } 5 | 6 | declare interface Window { 7 | context: ContextType | null; 8 | } 9 | 10 | declare namespace NodeJS { 11 | interface ProcessEnv { 12 | PORT: string; 13 | RANDOM_ID: string; 14 | PUBLIC_PATH: string; 15 | NODE_ENV: "development" | "production"; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/webrtc-im/types/server.ts: -------------------------------------------------------------------------------- 1 | import type { Socket } from "socket.io"; 2 | import type { ClientHandler, ServerHandler } from "./signaling"; 3 | import type { DeviceType } from "./client"; 4 | 5 | export const SESSION_KEY = "x-session-id"; 6 | 7 | export const ERROR_CODE = { 8 | OK: 0, 9 | BUSY: 400, 10 | NOT_FOUNT: 404, 11 | } as const; 12 | 13 | export type SocketMember = { 14 | /** Socket id */ 15 | id: string; 16 | /** 客户端脱敏 ip */ 17 | ip: string; 18 | /** 客户端 ip md5 */ 19 | hash: string; 20 | /** 客户端设备 */ 21 | device: DeviceType; 22 | /** webrtc 链接状态 */ 23 | connected: boolean; 24 | /** ServerSocket */ 25 | socket: ServerSocket; 26 | }; 27 | 28 | export type ServerSocket = Socket; 29 | -------------------------------------------------------------------------------- /packages/webrtc-im/types/signaling.ts: -------------------------------------------------------------------------------- 1 | import type { O } from "@block-kit/utils/dist/es/types"; 2 | import type { DeviceType } from "./client"; 3 | 4 | /** 客户端发起的消息 */ 5 | export const CLINT_EVENT = { 6 | JOIN_ROOM: "JOIN_ROOM", 7 | LEAVE_ROOM: "LEAVE_ROOM", 8 | SEND_OFFER: "SEND_OFFER", 9 | SEND_ANSWER: "SEND_ANSWER", 10 | SEND_ICE: "SEND_ICE", 11 | SEND_ERROR: "SEND_ERROR", 12 | } as const; 13 | 14 | /** 服务端发起的消息 */ 15 | export const SERVER_EVENT = { 16 | INIT_USER: "INIT_USER", 17 | JOIN_ROOM: "JOIN_ROOM", 18 | LEAVE_ROOM: "LEAVE_ROOM", 19 | SEND_OFFER: "SEND_OFFER", 20 | SEND_ANSWER: "SEND_ANSWER", 21 | SEND_ICE: "SEND_ICE", 22 | SEND_ERROR: "SEND_ERROR", 23 | } as const; 24 | 25 | export type ClientEvent = { 26 | [CLINT_EVENT.JOIN_ROOM]: ClientJoinRoomEvent; 27 | [CLINT_EVENT.LEAVE_ROOM]: ClientLeaveRoomEvent; 28 | [CLINT_EVENT.SEND_OFFER]: ClientSendOfferEvent; 29 | [CLINT_EVENT.SEND_ANSWER]: ClientSendAnswerEvent; 30 | [CLINT_EVENT.SEND_ICE]: ClientSendIceEvent; 31 | [SERVER_EVENT.SEND_ERROR]: ClientSendError; 32 | }; 33 | 34 | export type ServerEvent = { 35 | [SERVER_EVENT.INIT_USER]: ServerInitUserEvent; 36 | [SERVER_EVENT.JOIN_ROOM]: ServerJoinRoomEvent; 37 | [SERVER_EVENT.LEAVE_ROOM]: ServerLeaveRoomEvent; 38 | [SERVER_EVENT.SEND_OFFER]: ServerSendOfferEvent; 39 | [SERVER_EVENT.SEND_ANSWER]: ServerSendAnswerEvent; 40 | [SERVER_EVENT.SEND_ICE]: ServerSendIceEvent; 41 | [SERVER_EVENT.SEND_ERROR]: CallbackEvent; 42 | }; 43 | 44 | export type ICE = RTCIceCandidate; 45 | export type SDP = RTCSessionDescriptionInit; 46 | export type ClientEventKeys = O.Keys; 47 | export type ClientJoinRoomEvent = { device: DeviceType }; 48 | export type ClientLeaveRoomEvent = { id: string }; 49 | export type ClientSendOfferEvent = { to: string; sdp: SDP }; 50 | export type ClientSendAnswerEvent = { to: string; sdp: SDP }; 51 | export type ClientSendIceEvent = { to: string; ice: ICE }; 52 | export type ClientSendError = { to: string } & CallbackEvent; 53 | 54 | export type ServerEventKeys = O.Keys; 55 | export type ServerJoinRoomEvent = { id: string; device: DeviceType; ip: string; hash: string }; 56 | export type ServerInitUserEvent = { self: ServerJoinRoomEvent; users: ServerJoinRoomEvent[] }; 57 | export type ServerLeaveRoomEvent = { id: string }; 58 | export type ServerSendOfferEvent = { from: string; to: string; sdp: SDP }; 59 | export type ServerSendAnswerEvent = { from: string; to: string; sdp: SDP }; 60 | export type ServerSendIceEvent = { from: string; to: string; ice: ICE }; 61 | 62 | export type ClientFunc = ( 63 | payload: ClientEvent[T], 64 | callback?: (state: CallbackEvent) => void 65 | ) => void; 66 | 67 | export type ServerFunc = ( 68 | payload: ServerEvent[T], 69 | callback?: (state: CallbackEvent) => void 70 | ) => void; 71 | 72 | export type CallbackEvent = { code: number; message: string }; 73 | export type ClientHandler = { [K in ClientEventKeys]: ClientFunc }; 74 | export type ServerHandler = { [K in ServerEventKeys]: ServerFunc }; 75 | -------------------------------------------------------------------------------- /packages/webrtc-im/types/transfer.ts: -------------------------------------------------------------------------------- 1 | import type { O, R } from "@block-kit/utils/dist/es/types"; 2 | 3 | // 12B = 96bit => [A-Z] * 12 4 | export const ID_SIZE = 12; 5 | // 4B = 32bit = 2^32 = 4294967296 6 | export const CHUNK_SIZE = 4; 7 | export const STEAM_TYPE = "application/octet-stream"; 8 | 9 | export const MESSAGE_TYPE = { 10 | TEXT: "TEXT", 11 | FILE_START: "FILE_START", 12 | FILE_NEXT: "FILE_NEXT", 13 | FILE_FINISH: "FILE_FINISH", 14 | } as const; 15 | 16 | export const TRANSFER_TYPE = { 17 | TEXT: "TEXT", 18 | FILE: "FILE", 19 | SYSTEM: "SYSTEM", 20 | } as const; 21 | 22 | export const TRANSFER_FROM = { 23 | SELF: "SELF", 24 | PEER: "PEER", 25 | } as const; 26 | 27 | export const TRANSFER_EVENT = { 28 | TEXT: "TEXT", 29 | FILE_START: "FILE_START", 30 | FILE_PROCESS: "FILE_PROCESS", 31 | } as const; 32 | 33 | export type TransferFrom = O.Values; 34 | export type MessageType = O.Values; 35 | export type TransferType = O.Values; 36 | export type BufferType = Blob | ArrayBuffer; 37 | export type MessageEntry = R.Spread; 38 | export type TransferEntry = R.Spread; 39 | export type FileMeta = { id: string; size: number; total: number }; 40 | export type TransferEntryText = { data: string; from: TransferFrom }; 41 | 42 | export type TransferEntryFile = { 43 | id: string; 44 | size: number; 45 | name: string; 46 | process: number; 47 | from: TransferFrom; 48 | }; 49 | 50 | export type MessageEntryMap = { 51 | [MESSAGE_TYPE.TEXT]: { data: string }; 52 | [MESSAGE_TYPE.FILE_START]: { name: string } & FileMeta; 53 | [MESSAGE_TYPE.FILE_NEXT]: { series: number } & FileMeta; 54 | [MESSAGE_TYPE.FILE_FINISH]: { id: string }; 55 | }; 56 | 57 | export type TransferEntryMap = { 58 | [TRANSFER_TYPE.TEXT]: TransferEntryText; 59 | [TRANSFER_TYPE.FILE]: TransferEntryFile; 60 | [TRANSFER_TYPE.SYSTEM]: Omit & { from?: TransferFrom }; 61 | }; 62 | 63 | export type TransferEventMap = { 64 | [TRANSFER_EVENT.TEXT]: TransferEntryText; 65 | [TRANSFER_EVENT.FILE_START]: TransferEntryFile; 66 | [TRANSFER_EVENT.FILE_PROCESS]: { id: string; process: number }; 67 | }; 68 | -------------------------------------------------------------------------------- /packages/webrtc-im/types/webrtc.ts: -------------------------------------------------------------------------------- 1 | export const WEBRTC_EVENT = { 2 | OPEN: "OPEN", 3 | MESSAGE: "MESSAGE", 4 | ERROR: "ERROR", 5 | CLOSE: "CLOSE", 6 | CONNECTING: "CONNECTING", 7 | STATE_CHANGE: "STATE_CHANGE", 8 | } as const; 9 | 10 | export type WebRTCEvent = { 11 | [WEBRTC_EVENT.OPEN]: Event; 12 | [WEBRTC_EVENT.MESSAGE]: MessageEvent; 13 | [WEBRTC_EVENT.ERROR]: RTCErrorEvent; 14 | [WEBRTC_EVENT.CLOSE]: Event; 15 | [WEBRTC_EVENT.CONNECTING]: null; 16 | [WEBRTC_EVENT.STATE_CHANGE]: RTCPeerConnection; 17 | }; 18 | -------------------------------------------------------------------------------- /packages/webrtc/client/app/app.tsx: -------------------------------------------------------------------------------- 1 | import styles from "../styles/index.module.scss"; 2 | import type { FC } from "react"; 3 | import { useLayoutEffect, useMemo, useRef, useState } from "react"; 4 | import { IconGithub } from "@arco-design/web-react/icon"; 5 | import { BoardCastIcon, ComputerIcon, PhoneIcon } from "../layout/icon"; 6 | import { useMemoFn } from "laser-utils"; 7 | import { WebRTC } from "../bridge/webrtc"; 8 | import type { WebRTCApi } from "../../types/webrtc"; 9 | import type { ServerFn } from "../../types/signaling"; 10 | import { SERVER_EVENT } from "../../types/signaling"; 11 | import type { ConnectionState, Member } from "../../types/client"; 12 | import { CONNECTION_STATE, DEVICE_TYPE } from "../../types/client"; 13 | import { TransferModal } from "./modal"; 14 | import { Message } from "@arco-design/web-react"; 15 | import { ERROR_TYPE } from "../../types/server"; 16 | import { WorkerEvent } from "../worker/event"; 17 | 18 | export const App: FC = () => { 19 | const rtc = useRef(null); 20 | const connection = useRef(null); 21 | const [id, setId] = useState(""); 22 | const [peerId, setPeerId] = useState(""); 23 | const [visible, setVisible] = useState(false); 24 | const [members, setMembers] = useState([]); 25 | const [state, setState] = useState(CONNECTION_STATE.INIT); 26 | 27 | const streamMode = useMemo(() => { 28 | const search = new URL(location.href).searchParams; 29 | return search.get("mode") === "stream"; 30 | }, []); 31 | 32 | // === RTC Connection Event === 33 | const onOpen = useMemoFn(event => { 34 | console.log("OnOpen", event); 35 | setVisible(true); 36 | setState(CONNECTION_STATE.CONNECTED); 37 | }); 38 | 39 | const onClose = useMemoFn((event: Event) => { 40 | console.log("OnClose", event); 41 | setVisible(false); 42 | setPeerId(""); 43 | setState(CONNECTION_STATE.READY); 44 | }); 45 | 46 | const onError = useMemoFn((event: RTCErrorEvent) => { 47 | console.log("OnError", event); 48 | }); 49 | 50 | const onJoinRoom: ServerFn = useMemoFn(member => { 51 | console.log("JOIN ROOM", member); 52 | setMembers([...members, member]); 53 | }); 54 | 55 | const onJoinedMember: ServerFn = useMemoFn(event => { 56 | const { initialization } = event; 57 | console.log("JOINED MEMBER", initialization); 58 | setMembers([...initialization]); 59 | }); 60 | 61 | const onLeftRoom: ServerFn = useMemoFn(event => { 62 | const { id: leaveId } = event; 63 | console.log("LEFT ROOM", leaveId); 64 | const instance = rtc.current?.getInstance(); 65 | // FIX: 移动端切换后台可能会导致 signaling 关闭 66 | // 但是此时 RTC 仍处于连接活跃状态 需要等待信令切换到前台重连 67 | // 这种情况下后续的状态控制由 RTC 的 OnClose 等事件来处理更新 68 | if (leaveId === peerId && instance?.connection.connectionState !== "connected") { 69 | rtc.current?.close(); 70 | setVisible(false); 71 | setPeerId(""); 72 | } 73 | setMembers(members.filter(member => member.id !== leaveId)); 74 | }); 75 | 76 | const onReceiveOffer: ServerFn = useMemoFn(event => { 77 | const { origin } = event; 78 | if (!peerId && !visible) { 79 | setPeerId(origin); 80 | setVisible(true); 81 | setState(CONNECTION_STATE.CONNECTING); 82 | } 83 | }); 84 | 85 | const onNotifyError: ServerFn = useMemoFn(event => { 86 | const { code, message } = event; 87 | Message.error(message); 88 | switch (code) { 89 | case ERROR_TYPE.PEER_BUSY: 90 | setState(CONNECTION_STATE.READY); 91 | break; 92 | } 93 | }); 94 | 95 | // === RTC Connection INIT === 96 | useLayoutEffect(() => { 97 | const webrtc = new WebRTC({ wss: location.host }); 98 | webrtc.onOpen = onOpen; 99 | webrtc.onClose = onClose; 100 | webrtc.onError = onError; 101 | webrtc.signaling.on(SERVER_EVENT.JOINED_ROOM, onJoinRoom); 102 | webrtc.signaling.on(SERVER_EVENT.JOINED_MEMBER, onJoinedMember); 103 | webrtc.signaling.on(SERVER_EVENT.LEFT_ROOM, onLeftRoom); 104 | webrtc.signaling.on(SERVER_EVENT.FORWARD_OFFER, onReceiveOffer); 105 | webrtc.signaling.on(SERVER_EVENT.NOTIFY_ERROR, onNotifyError); 106 | webrtc.onReady = ({ rtc: instance }) => { 107 | rtc.current = instance; 108 | setState(CONNECTION_STATE.READY); 109 | }; 110 | setId(webrtc.id); 111 | connection.current = webrtc; 112 | return () => { 113 | webrtc.signaling.off(SERVER_EVENT.JOINED_ROOM, onJoinRoom); 114 | webrtc.signaling.off(SERVER_EVENT.JOINED_MEMBER, onJoinedMember); 115 | webrtc.signaling.off(SERVER_EVENT.LEFT_ROOM, onLeftRoom); 116 | webrtc.signaling.off(SERVER_EVENT.FORWARD_OFFER, onReceiveOffer); 117 | webrtc.signaling.off(SERVER_EVENT.NOTIFY_ERROR, onNotifyError); 118 | webrtc.destroy(); 119 | }; 120 | }, [ 121 | onClose, 122 | onError, 123 | onJoinRoom, 124 | onJoinedMember, 125 | onLeftRoom, 126 | onNotifyError, 127 | onOpen, 128 | onReceiveOffer, 129 | ]); 130 | 131 | const onPeerConnection = (member: Member) => { 132 | if (rtc.current) { 133 | rtc.current.connect(member.id); 134 | setVisible(true); 135 | setPeerId(member.id); 136 | setState(CONNECTION_STATE.CONNECTING); 137 | } 138 | }; 139 | 140 | const onManualRequest = () => { 141 | setPeerId(""); 142 | setVisible(true); 143 | }; 144 | 145 | return ( 146 |
147 |
148 |
{BoardCastIcon}
149 |
150 | {streamMode && WorkerEvent.isTrustEnv() && "STREAM - "}Local ID: {id} 151 |
152 |
153 | Request To Establish P2P Connection By ID 154 |
155 |
156 | {members.length === 0 && ( 157 |
Open Another Device On The LAN To Transfer Files
158 | )} 159 |
160 | {members.map(member => ( 161 |
onPeerConnection(member)}> 162 |
163 | {member.device === DEVICE_TYPE.MOBILE ? PhoneIcon : ComputerIcon} 164 |
165 |
{member.id}
166 |
167 | ))} 168 |
169 | 174 | 175 | 176 | {visible && ( 177 | 190 | )} 191 |
192 | ); 193 | }; 194 | -------------------------------------------------------------------------------- /packages/webrtc/client/bridge/instance.ts: -------------------------------------------------------------------------------- 1 | import type { SocketEventParams } from "../../types/signaling"; 2 | import { CLINT_EVENT, SERVER_EVENT } from "../../types/signaling"; 3 | import type { SignalingServer } from "./signaling"; 4 | import type { WebRTCInstanceOptions } from "../../types/webrtc"; 5 | import { ERROR_TYPE } from "../../types/server"; 6 | 7 | export class WebRTCInstance { 8 | /** 连接 id */ 9 | public readonly id: string; 10 | /** 数据传输信道 */ 11 | public readonly channel: RTCDataChannel; 12 | /** RTC 连接实例 */ 13 | public readonly connection: RTCPeerConnection; 14 | /** 信令实例 */ 15 | private readonly signaling: SignalingServer; 16 | /** 主动连接建立信号 */ 17 | public ready: Promise; 18 | /** 连接建立信号解析器 */ 19 | private _resolver: () => void; 20 | 21 | constructor(options: WebRTCInstanceOptions) { 22 | const RTCPeerConnection = 23 | // @ts-expect-error RTCPeerConnection 24 | window.RTCPeerConnection || window.mozRTCPeerConnection || window.webkitRTCPeerConnection; 25 | // https://icetest.info/ 26 | // https://gist.github.com/mondain/b0ec1cf5f60ae726202e 27 | // https://webrtc.github.io/samples/src/content/peerconnection/trickle-ice/ 28 | const defaultIces: RTCIceServer[] = [ 29 | { 30 | urls: [ 31 | "stun:stun.services.mozilla.com", 32 | "stun:stunserver2024.stunprotocol.org", 33 | "stun:stun.l.google.com:19302", 34 | ], 35 | }, 36 | { 37 | urls: ["turn:pairdrop.net:5349", "turns:turn.pairdrop.net:5349"], 38 | username: "qhyDYD7PmT1a", 39 | credential: "6uX4JSBdncNLmUmoGau97Ft", 40 | }, 41 | ]; 42 | const connection = new RTCPeerConnection({ 43 | iceServers: options.ice ? [{ urls: options.ice }] : defaultIces, 44 | }); 45 | this.id = options.id; 46 | this.signaling = options.signaling; 47 | console.log("Client WebRTC ID:", this.id); 48 | const channel = connection.createDataChannel("FileTransfer", { 49 | ordered: true, // 保证传输顺序 50 | maxRetransmits: 50, // 最大重传次数 51 | }); 52 | this.channel = channel; 53 | this.connection = connection; 54 | this.connection.ondatachannel = event => { 55 | const channel = event.channel; 56 | channel.onopen = options.onOpen || null; 57 | channel.onmessage = options.onMessage || null; 58 | channel.onerror = options.onError || null; 59 | channel.onclose = options.onClose || null; 60 | }; 61 | this._resolver = () => null; 62 | this.ready = new Promise(r => (this._resolver = r)); 63 | this.connection.onconnectionstatechange = () => { 64 | if (this.connection.connectionState === "connected") { 65 | this._resolver(); 66 | } 67 | options.onConnectionStateChange(connection); 68 | }; 69 | this.signaling.on(SERVER_EVENT.FORWARD_OFFER, this.onReceiveOffer); 70 | this.signaling.on(SERVER_EVENT.FORWARD_ICE, this.onReceiveIce); 71 | this.signaling.on(SERVER_EVENT.FORWARD_ANSWER, this.onReceiveAnswer); 72 | } 73 | 74 | public createRemoteConnection = async (target: string) => { 75 | console.log("Send Offer To:", target); 76 | this.ready = new Promise(r => (this._resolver = r)); 77 | this.connection.onicecandidate = async event => { 78 | if (!event.candidate) return void 0; 79 | console.log("Local ICE", event.candidate); 80 | const payload = { origin: this.id, ice: event.candidate, target }; 81 | this.signaling.emit(CLINT_EVENT.SEND_ICE, payload); 82 | }; 83 | const offer = await this.connection.createOffer(); 84 | await this.connection.setLocalDescription(offer); 85 | console.log("Offer SDP", offer); 86 | const payload = { origin: this.id, offer, target }; 87 | this.signaling.emit(CLINT_EVENT.SEND_OFFER, payload); 88 | }; 89 | 90 | private onReceiveOffer = async (params: SocketEventParams["FORWARD_OFFER"]) => { 91 | const { offer, origin } = params; 92 | console.log("Receive Offer From:", origin, offer); 93 | if (this.connection.currentLocalDescription || this.connection.currentRemoteDescription) { 94 | this.signaling.emit(CLINT_EVENT.SEND_ERROR, { 95 | origin: this.id, 96 | target: origin, 97 | code: ERROR_TYPE.PEER_BUSY, 98 | message: `Peer ${this.id} is Busy`, 99 | }); 100 | return void 0; 101 | } 102 | this.connection.onicecandidate = async event => { 103 | if (!event.candidate) return void 0; 104 | console.log("Local ICE", event.candidate); 105 | const payload = { origin: this.id, ice: event.candidate, target: origin }; 106 | this.signaling.emit(CLINT_EVENT.SEND_ICE, payload); 107 | }; 108 | await this.connection.setRemoteDescription(offer); 109 | const answer = await this.connection.createAnswer(); 110 | await this.connection.setLocalDescription(answer); 111 | console.log("Answer SDP", answer); 112 | const payload = { origin: this.id, answer, target: origin }; 113 | this.signaling.emit(CLINT_EVENT.SEND_ANSWER, payload); 114 | }; 115 | 116 | private onReceiveIce = async (params: SocketEventParams["FORWARD_ICE"]) => { 117 | const { ice, origin } = params; 118 | console.log("Receive ICE From:", origin, ice); 119 | await this.connection.addIceCandidate(ice); 120 | }; 121 | 122 | private onReceiveAnswer = async (params: SocketEventParams["FORWARD_ANSWER"]) => { 123 | const { answer, origin } = params; 124 | console.log("Receive Answer From:", origin, answer); 125 | if (!this.connection.currentRemoteDescription) { 126 | this.connection.setRemoteDescription(answer); 127 | } 128 | }; 129 | 130 | public destroy = () => { 131 | this.signaling.off(SERVER_EVENT.FORWARD_OFFER, this.onReceiveOffer); 132 | this.signaling.off(SERVER_EVENT.FORWARD_ICE, this.onReceiveIce); 133 | this.signaling.off(SERVER_EVENT.FORWARD_ANSWER, this.onReceiveAnswer); 134 | this.channel.close(); 135 | this.connection.close(); 136 | }; 137 | } 138 | -------------------------------------------------------------------------------- /packages/webrtc/client/bridge/signaling.ts: -------------------------------------------------------------------------------- 1 | import type { Socket } from "socket.io-client"; 2 | import io from "socket.io-client"; 3 | import type { 4 | CallBackState, 5 | ClientEventKeys, 6 | ClientHandler, 7 | ServerEventKeys, 8 | ServerFn, 9 | ServerHandler, 10 | SocketEventParams, 11 | } from "../../types/signaling"; 12 | import { CLINT_EVENT } from "../../types/signaling"; 13 | import { IS_MOBILE } from "laser-utils"; 14 | import { DEVICE_TYPE } from "../../types/client"; 15 | 16 | export class SignalingServer { 17 | public readonly socket: Socket; 18 | constructor(wss: string, private id: string) { 19 | const socket = io(wss, { transports: ["websocket"] }); 20 | this.socket = socket; 21 | this.socket.on("connect", this.onConnect); 22 | this.socket.on("disconnect", this.onDisconnect); 23 | } 24 | 25 | public on = (key: T, cb: ServerFn) => { 26 | // @ts-expect-error unknown 27 | this.socket.on(key, cb); 28 | }; 29 | 30 | public off = (key: T, cb: ServerFn) => { 31 | // @ts-expect-error unknown 32 | this.socket.off(key, cb); 33 | }; 34 | 35 | public emit = ( 36 | key: T, 37 | payload: SocketEventParams[T], 38 | callback?: (state: CallBackState) => void 39 | ) => { 40 | // @ts-expect-error unknown 41 | this.socket.emit(key, payload, callback); 42 | }; 43 | 44 | private onConnect = () => { 45 | // https://socket.io/docs/v4/server-socket-instance/#socketid 46 | this.emit(CLINT_EVENT.JOIN_ROOM, { 47 | id: this.id, 48 | device: IS_MOBILE ? DEVICE_TYPE.MOBILE : DEVICE_TYPE.PC, 49 | }); 50 | }; 51 | 52 | private onDisconnect = () => { 53 | this.emit(CLINT_EVENT.LEAVE_ROOM, { id: this.id }); 54 | }; 55 | 56 | public destroy = () => { 57 | this.socket.emit(CLINT_EVENT.LEAVE_ROOM, { id: this.id }); 58 | this.socket.off("connect", this.onConnect); 59 | this.socket.off("disconnect", this.onDisconnect); 60 | this.socket.close(); 61 | }; 62 | } 63 | -------------------------------------------------------------------------------- /packages/webrtc/client/bridge/webrtc.ts: -------------------------------------------------------------------------------- 1 | import type { WebRTCCallback, WebRTCOptions } from "../../types/webrtc"; 2 | import { WebRTCInstance } from "./instance"; 3 | import { SignalingServer } from "./signaling"; 4 | import { getUniqueId } from "laser-utils"; 5 | 6 | export class WebRTC { 7 | /** 连接 id */ 8 | public readonly id: string; 9 | /** RTC 实例 */ 10 | private instance: WebRTCInstance | null = null; 11 | /** 信令服务器 */ 12 | public readonly signaling: SignalingServer; 13 | /** Ready 事件 */ 14 | public onReady: WebRTCCallback; 15 | /** RTC Open 事件 */ 16 | public onOpen: (event: Event) => void; 17 | /** RTC Message 事件 */ 18 | public onMessage: (event: MessageEvent) => void; 19 | /** RTC Error 事件 */ 20 | public onError: (event: RTCErrorEvent) => void; 21 | /** RTC Close 事件 */ 22 | public onClose: (event: Event) => void; 23 | /** RTC Connection State Change 事件 */ 24 | public onConnectionStateChange: (pc: RTCPeerConnection) => void; 25 | 26 | constructor(options: WebRTCOptions) { 27 | this.onReady = () => null; 28 | this.onOpen = () => null; 29 | this.onMessage = () => null; 30 | this.onError = () => null; 31 | this.onClose = () => null; 32 | this.onConnectionStateChange = () => null; 33 | const STORAGE_KEY = "WEBRTC-ID"; 34 | // https://socket.io/docs/v4/server-socket-instance/#socketid 35 | this.id = sessionStorage?.getItem(STORAGE_KEY) || getUniqueId(8); 36 | sessionStorage?.setItem(STORAGE_KEY, this.id); 37 | this.signaling = new SignalingServer(options.wss, this.id); 38 | this.signaling.socket.on("connect", this.onConnection); 39 | } 40 | 41 | private createInstance = () => { 42 | const onOpen = (e: Event) => { 43 | this.onOpen(e); 44 | }; 45 | const onMessage = (event: MessageEvent) => { 46 | this.onMessage(event); 47 | }; 48 | const onError = (event: RTCErrorEvent) => { 49 | this.onError(event); 50 | }; 51 | const onClose = (e: Event) => { 52 | this.instance?.destroy(); 53 | const rtc = this.createInstance(); 54 | this.instance = rtc; 55 | this.onClose(e); 56 | }; 57 | const onConnectionStateChange = (pc: RTCPeerConnection) => { 58 | this.onConnectionStateChange(pc); 59 | }; 60 | return new WebRTCInstance({ 61 | id: this.id, 62 | signaling: this.signaling, 63 | onOpen: onOpen, 64 | onMessage: onMessage, 65 | onError: onError, 66 | onClose: onClose, 67 | onConnectionStateChange, 68 | }); 69 | }; 70 | 71 | private onConnection = () => { 72 | // FIX: 当 RTC 保持连接但是信令断开重连时, 不重置实例状态 73 | // 在移动端浏览器置于后台再切换到前台时可能会导致这个情况的发生 74 | if (this.instance && this.instance.connection.connectionState === "connected") { 75 | return void 0; 76 | } 77 | if (!this.instance) { 78 | this.instance = this.createInstance(); 79 | } 80 | const onConnect = (id: string) => { 81 | if ( 82 | !this.instance || 83 | this.instance.connection.currentLocalDescription || 84 | this.instance.connection.currentRemoteDescription 85 | ) { 86 | this.instance = this.createInstance(); 87 | } 88 | this.instance.createRemoteConnection(id); 89 | return this.instance.ready; 90 | }; 91 | const onSendMessage = (message: string | Blob | ArrayBuffer | ArrayBufferView) => { 92 | this.instance?.channel.send(message as Blob); 93 | }; 94 | const onClose = () => { 95 | this.instance?.destroy(); 96 | this.instance = this.createInstance(); 97 | }; 98 | this.onReady({ 99 | signaling: this.signaling, 100 | rtc: { 101 | connect: onConnect, 102 | send: onSendMessage, 103 | close: onClose, 104 | getInstance: () => this.instance, 105 | }, 106 | }); 107 | }; 108 | 109 | public destroy = () => { 110 | this.signaling.socket.off("connect", this.onConnection); 111 | this.signaling.destroy(); 112 | this.instance?.destroy(); 113 | this.instance = null; 114 | }; 115 | } 116 | -------------------------------------------------------------------------------- /packages/webrtc/client/index.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from "react-dom"; 2 | import { App } from "./app/app"; 3 | import "@arco-design/web-react/es/style/index.less"; 4 | import { drawingBackdrop } from "./layout/canvas"; 5 | import { IS_MOBILE } from "laser-utils"; 6 | import VConsole from "vconsole"; 7 | import { WorkerEvent } from "./worker/event"; 8 | 9 | if (process.env.NODE_ENV === "development" && IS_MOBILE) { 10 | new VConsole(); 11 | } 12 | 13 | WorkerEvent.register(); 14 | window.addEventListener("DOMContentLoaded", drawingBackdrop); 15 | ReactDOM.render(, document.getElementById("root")); 16 | -------------------------------------------------------------------------------- /packages/webrtc/client/layout/canvas.ts: -------------------------------------------------------------------------------- 1 | export const drawingBackdrop = () => { 2 | const canvas = document.createElement("canvas"); 3 | document.body.appendChild(canvas); 4 | const style = canvas.style; 5 | style.width = "100%"; 6 | style.position = "absolute"; 7 | style.zIndex = "-1"; 8 | style.top = "0"; 9 | style.left = "0"; 10 | const ctx = canvas.getContext("2d"); 11 | if (!ctx) return void 0; 12 | 13 | let x: number, y: number, width: number, height: number, degree: number; 14 | 15 | const initCanvas = () => { 16 | width = window.innerWidth; 17 | height = window.innerHeight; 18 | canvas.width = width; 19 | canvas.height = height; 20 | const offset = 135; 21 | x = width / 2; 22 | y = height - offset; 23 | degree = Math.max(width, height, 1000) / 13; 24 | drawCircles(); 25 | }; 26 | 27 | window.addEventListener("resize", initCanvas); 28 | 29 | const drawCircle = (radius: number) => { 30 | ctx.beginPath(); 31 | const color = Math.round(255 * (1 - radius / Math.max(width, height))); 32 | ctx.strokeStyle = "rgba(" + color + "," + color + "," + color + ",0.1)"; 33 | ctx.arc(x, y, radius, 0, 2 * Math.PI); 34 | ctx.stroke(); 35 | ctx.lineWidth = 2; 36 | }; 37 | 38 | let step = 0; 39 | const drawCircles = () => { 40 | ctx.clearRect(0, 0, width, height); 41 | for (let i = 0; i < 8; i++) { 42 | drawCircle(degree * i + (step % degree)); 43 | } 44 | step = (step + 1) % Number.MAX_SAFE_INTEGER; 45 | }; 46 | 47 | const drawingAnimation = () => { 48 | const handler = () => { 49 | drawCircles(); 50 | drawingAnimation(); 51 | }; 52 | requestAnimationFrame(handler); 53 | }; 54 | 55 | initCanvas(); 56 | drawingAnimation(); 57 | }; 58 | -------------------------------------------------------------------------------- /packages/webrtc/client/layout/icon.tsx: -------------------------------------------------------------------------------- 1 | export const BoardCastIcon = ( 2 | 11 | 12 | 13 | 14 | 15 | ); 16 | 17 | export const PhoneIcon = ( 18 | 27 | 31 | 32 | ); 33 | 34 | export const ComputerIcon = ( 35 | 45 | 46 | 47 | ); 48 | -------------------------------------------------------------------------------- /packages/webrtc/client/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WindRunnerMax/FileTransfer/0c9e67b950b2c08a43cd22c1fb58aeea717bd7ba/packages/webrtc/client/static/favicon.ico -------------------------------------------------------------------------------- /packages/webrtc/client/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | FileTransfer-WebRTC 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /packages/webrtc/client/styles/index.module.scss: -------------------------------------------------------------------------------- 1 | // === Page === 2 | body, 3 | html { 4 | color: var(--color-text-1); 5 | height: 100%; 6 | } 7 | 8 | // === Main === 9 | body, 10 | .container { 11 | display: flex; 12 | height: 100%; 13 | justify-content: center; 14 | margin: 0; 15 | padding: 0; 16 | width: 100%; 17 | 18 | .content { 19 | align-items: center; 20 | bottom: 30px; 21 | display: flex; 22 | flex-direction: column; 23 | justify-content: center; 24 | left: 50%; 25 | max-width: 100%; 26 | position: fixed; 27 | transform: translateX(-50%); 28 | } 29 | 30 | .boardCastIcon { 31 | color: rgba(var(--arcoblue-6), 0.8); 32 | font-size: 60px; 33 | text-align: center; 34 | } 35 | 36 | .manualEntry { 37 | color: rgba(var(--arcoblue-6), 0.8); 38 | cursor: pointer; 39 | margin-top: 5px; 40 | text-align: center; 41 | white-space: nowrap; 42 | } 43 | } 44 | 45 | .prompt { 46 | animation-duration: 0.5s; 47 | animation-fill-mode: forwards; 48 | animation-name: scale-in; 49 | color: rgba(var(--arcoblue-6), 0.8); 50 | font-size: 20px; 51 | left: 0; 52 | position: fixed; 53 | right: 0; 54 | text-align: center; 55 | top: 40%; 56 | user-select: none; 57 | width: 100%; 58 | } 59 | 60 | // === Banner === 61 | .github { 62 | color: var(--color-text-1); 63 | font-size: 23px; 64 | position: fixed; 65 | right: 12px; 66 | top: 6px; 67 | } 68 | 69 | @keyframes scale-in { 70 | 0% { 71 | opacity: 0; 72 | transform: scale(0); 73 | } 74 | 75 | 100% { 76 | opacity: 1; 77 | transform: scale(1); 78 | } 79 | } 80 | 81 | // === Device === 82 | .deviceGroup { 83 | display: flex; 84 | font-size: 14px; 85 | justify-content: center; 86 | left: 0; 87 | position: fixed; 88 | right: 0; 89 | top: 40%; 90 | user-select: none; 91 | width: 100%; 92 | 93 | .device { 94 | align-items: center; 95 | animation-duration: 0.5s; 96 | animation-fill-mode: forwards; 97 | animation-name: scale-in; 98 | cursor: pointer; 99 | display: flex; 100 | flex-direction: column; 101 | /* stylelint-disable-next-line declaration-block-no-redundant-longhand-properties */ 102 | flex-wrap: wrap; 103 | margin: 20px; 104 | 105 | .icon { 106 | align-items: center; 107 | background-color: rgba(var(--arcoblue-6), 0.8); 108 | border-radius: 70px; 109 | color: #fff; 110 | display: flex; 111 | font-size: 35px; 112 | height: 70px; 113 | justify-content: center; 114 | width: 70px; 115 | } 116 | 117 | .name { 118 | color: var(--color-text-1); 119 | margin-top: 6px; 120 | } 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /packages/webrtc/client/styles/modal.module.scss: -------------------------------------------------------------------------------- 1 | // === Modal === 2 | .modal { 3 | box-sizing: border-box; 4 | max-height: 100%; 5 | max-width: 96%; 6 | width: 600px; 7 | 8 | :global { 9 | .arco-modal-content { 10 | padding: 0; 11 | } 12 | } 13 | 14 | .title { 15 | align-items: center; 16 | display: flex; 17 | justify-content: center; 18 | 19 | .dot { 20 | border-radius: 5px; 21 | height: 7px; 22 | margin-right: 7px; 23 | margin-top: 1px; 24 | width: 7px; 25 | } 26 | } 27 | 28 | .modalContent { 29 | box-sizing: border-box; 30 | height: 500px; 31 | overflow-y: auto; 32 | padding: 5px 10px; 33 | scrollbar-width: none; 34 | 35 | &::-webkit-scrollbar { 36 | background-color: transparent; 37 | height: 0; 38 | width: 0; 39 | } 40 | 41 | .messageItem { 42 | display: flex; 43 | 44 | &.alignRight { 45 | justify-content: flex-end; 46 | 47 | .messageContent { 48 | background-color: rgba(var(--arcoblue-6), 0.8); 49 | } 50 | } 51 | } 52 | 53 | .messageContent { 54 | background-color: rgba(var(--green-7), 0.8); 55 | border-radius: 10px; 56 | box-sizing: border-box; 57 | color: #fff; 58 | margin-top: 20px; 59 | max-width: 80%; 60 | padding: 10px; 61 | } 62 | 63 | .fileMessage { 64 | max-width: 100%; 65 | min-width: 160px; 66 | padding: 0 5px; 67 | 68 | .fileInfo { 69 | align-items: center; 70 | display: flex; 71 | font-size: 12px; 72 | justify-content: space-between; 73 | } 74 | 75 | .fileName { 76 | font-size: 14px; 77 | margin-bottom: 3px; 78 | overflow-wrap: break-word; 79 | white-space: pre-wrap; 80 | word-break: break-word; 81 | } 82 | 83 | .fileIcon { 84 | margin-right: 3px; 85 | } 86 | 87 | .fileDownload { 88 | align-items: center; 89 | border: 1px solid #fff; 90 | border-radius: 20px; 91 | color: #fff; 92 | cursor: pointer; 93 | display: flex; 94 | flex-shrink: 0; 95 | font-size: 16px; 96 | height: 26px; 97 | justify-content: center; 98 | margin-left: 20px; 99 | width: 26px; 100 | 101 | &.disable { 102 | cursor: not-allowed; 103 | opacity: 0.5; 104 | } 105 | } 106 | 107 | :global { 108 | .arco-progress-line-text { 109 | color: #fff; 110 | } 111 | } 112 | } 113 | } 114 | 115 | .modalFooter { 116 | align-items: center; 117 | border-top: 1px solid var(--color-border-1); 118 | display: flex; 119 | height: 60px; 120 | padding: 5px 10px; 121 | 122 | .sendFile { 123 | margin-right: 10px; 124 | 125 | &:focus-visible { 126 | box-shadow: none; 127 | } 128 | } 129 | } 130 | } 131 | 132 | // === Misc === 133 | .fileInput { 134 | left: -1000px; 135 | position: fixed; 136 | top: -1000px; 137 | } 138 | -------------------------------------------------------------------------------- /packages/webrtc/client/utils/binary.ts: -------------------------------------------------------------------------------- 1 | import type { BufferType, FileMeta } from "../../types/client"; 2 | import type { WebRTCApi } from "../../types/webrtc"; 3 | 4 | // 12B = 96bit => [A-Z] * 12 5 | export const ID_SIZE = 12; 6 | // 4B = 32bit = 2^32 = 4294967296 7 | export const CHUNK_SIZE = 4; 8 | export const STEAM_TYPE = "application/octet-stream"; 9 | export const FILE_HANDLE: Map = new Map(); 10 | export const FILE_MAPPER: Map = new Map(); 11 | export const FILE_STATE: Map = new Map(); 12 | 13 | export const getMaxMessageSize = ( 14 | rtc: React.MutableRefObject, 15 | origin = false 16 | ) => { 17 | const instance = rtc.current?.getInstance(); 18 | let maxSize = instance?.connection.sctp?.maxMessageSize || 64 * 1024; 19 | // https://developer.mozilla.org/en-US/docs/Web/API/RTCSctpTransport/maxMessageSize 20 | // https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API/Using_data_channels 21 | // 在 FireFox 本机传输会出现超大的值 1073741807, 约 1GB 1073741824byte 22 | // officially up to 256 KiB, but Firefox's implementation caps them at a whopping 1 GiB 23 | // 因此在这里需要将其限制为最大 256KB 以保证正确的文件传输以及 WebStream 的正常工作 24 | maxSize = Math.min(maxSize, 256 * 1024); 25 | if (origin) { 26 | return maxSize; 27 | } 28 | // 1KB = 1024B 29 | // 1B = 8bit => 0-255 00-FF 30 | return maxSize - (ID_SIZE + CHUNK_SIZE); 31 | }; 32 | 33 | export const serializeNextChunk = ( 34 | instance: React.MutableRefObject, 35 | id: string, 36 | series: number 37 | ) => { 38 | const file = FILE_HANDLE.get(id); 39 | const chunkSize = getMaxMessageSize(instance); 40 | if (!file) return new Blob([new ArrayBuffer(chunkSize)]); 41 | const start = series * chunkSize; 42 | const end = Math.min(start + chunkSize, file.size); 43 | // 创建 12 字节用于存储 id [12B = 96bit] 44 | const idBytes = new Uint8Array(id.split("").map(char => char.charCodeAt(0))); 45 | // 创建 4 字节用于存储序列号 [4B = 32bit] 46 | const serialBytes = new Uint8Array(4); 47 | // 0xff = 1111 1111 确保结果只包含低 8 位 48 | serialBytes[0] = (series >> 24) & 0xff; 49 | serialBytes[1] = (series >> 16) & 0xff; 50 | serialBytes[2] = (series >> 8) & 0xff; 51 | serialBytes[3] = series & 0xff; 52 | return new Blob([idBytes, serialBytes, file.slice(start, end)]); 53 | }; 54 | 55 | let isSending = false; 56 | const QUEUE_TASK: BufferType[] = []; 57 | const start = async (rtc: React.MutableRefObject) => { 58 | isSending = true; 59 | const chunkSize = getMaxMessageSize(rtc, true); 60 | const instance = rtc.current?.getInstance(); 61 | const channel = instance?.channel; 62 | while (QUEUE_TASK.length) { 63 | const next = QUEUE_TASK.shift(); 64 | if (next && channel && rtc.current) { 65 | if (channel.bufferedAmount >= chunkSize) { 66 | await new Promise(resolve => { 67 | channel.onbufferedamountlow = () => resolve(0); 68 | }); 69 | } 70 | const buffer = next instanceof Blob ? await next.arrayBuffer() : next; 71 | buffer && rtc.current.send(buffer); 72 | } else { 73 | break; 74 | } 75 | } 76 | isSending = false; 77 | }; 78 | 79 | export const sendChunkMessage = async ( 80 | rtc: React.MutableRefObject, 81 | chunk: BufferType 82 | ) => { 83 | // 实现分片传输队列 84 | QUEUE_TASK.push(chunk); 85 | !isSending && start(rtc); 86 | }; 87 | 88 | export const deserializeChunk = async (chunk: BufferType) => { 89 | const buffer = chunk instanceof Blob ? await chunk.arrayBuffer() : chunk; 90 | const id = new Uint8Array(buffer.slice(0, ID_SIZE)); 91 | const series = new Uint8Array(buffer.slice(ID_SIZE, ID_SIZE + CHUNK_SIZE)); 92 | const data = buffer.slice(ID_SIZE + CHUNK_SIZE); 93 | const idString = String.fromCharCode(...id); 94 | const seriesNumber = (series[0] << 24) | (series[1] << 16) | (series[2] << 8) | series[3]; 95 | return { id: idString, series: seriesNumber, data }; 96 | }; 97 | -------------------------------------------------------------------------------- /packages/webrtc/client/utils/format.ts: -------------------------------------------------------------------------------- 1 | export const formatBytes = (bytes: number) => { 2 | if (bytes === 0) return "0 B"; 3 | const i = Math.floor(Math.log(bytes) / Math.log(1024)); 4 | const sizes = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; 5 | return `${(bytes / 1024 ** i).toFixed(2)} ${sizes[i]}`; 6 | }; 7 | 8 | export const scrollToBottom = (listRef: React.RefObject) => { 9 | if (listRef.current) { 10 | const el = listRef.current; 11 | Promise.resolve().then(() => { 12 | el.scrollTop = el.scrollHeight; 13 | }); 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /packages/webrtc/client/utils/tson.ts: -------------------------------------------------------------------------------- 1 | import { decodeJSON as DecodeJSON, encodeJSON as EncodeJSON } from "laser-utils"; 2 | import type { MessageType } from "../../types/client"; 3 | 4 | type EncodeJSONType = (value: MessageType) => string; 5 | type DecodeJSONType = (value: string) => MessageType | null; 6 | 7 | export const TSON = { 8 | /** 9 | * Object -> String 10 | */ 11 | encode: EncodeJSON as EncodeJSONType, 12 | /** 13 | * String -> Object 14 | */ 15 | decode: DecodeJSON as DecodeJSONType, 16 | }; 17 | -------------------------------------------------------------------------------- /packages/webrtc/client/worker/event.ts: -------------------------------------------------------------------------------- 1 | import type { MessageType } from "../../types/worker"; 2 | import { HEADER_KEY, MESSAGE_TYPE } from "../../types/worker"; 3 | 4 | export class WorkerEvent { 5 | public static channel: MessageChannel | null = null; 6 | public static worker: ServiceWorkerRegistration | null = null; 7 | public static writer: Map> = new Map(); 8 | 9 | public static async register(): Promise { 10 | if (!navigator.serviceWorker) { 11 | console.warn("Service Worker Not Supported"); 12 | return Promise.resolve(null); 13 | } 14 | try { 15 | const serviceWorker = await navigator.serviceWorker.getRegistration("./"); 16 | if (serviceWorker) { 17 | WorkerEvent.worker = serviceWorker; 18 | return Promise.resolve(serviceWorker); 19 | } 20 | const worker = await navigator.serviceWorker.register( 21 | process.env.PUBLIC_PATH + "worker.js?" + process.env.RANDOM_ID, 22 | { scope: "./" } 23 | ); 24 | WorkerEvent.worker = worker; 25 | return worker; 26 | } catch (error) { 27 | console.warn("Service Worker Register Error", error); 28 | return Promise.resolve(null); 29 | } 30 | } 31 | 32 | public static isTrustEnv() { 33 | return location.protocol === "https:" || location.hostname === "localhost"; 34 | } 35 | 36 | public static start(fileId: string, fileName: string, fileSize: number, fileTotal: number) { 37 | if (!WorkerEvent.channel) { 38 | WorkerEvent.channel = new MessageChannel(); 39 | WorkerEvent.channel.port1.onmessage = event => { 40 | console.log("WorkerEvent", event.data); 41 | }; 42 | WorkerEvent.worker?.active?.postMessage({ type: MESSAGE_TYPE.INIT_CHANNEL }, [ 43 | WorkerEvent.channel.port2, 44 | ]); 45 | } 46 | // 在 TransformStream 不可用的情况下 https://caniuse.com/?search=TransformStream 47 | // 需要在 Service Worker 中使用 ReadableStream 写入数据 fa28d9d757ddeda9c93645362 48 | // 相当于通过 controller.enqueue 将 ArrayBuffer 数据写入即可 49 | // 而直接使用 ReadableStream 需要主动处理 BackPressure 时降低写频率 50 | // 此时使用 TransformStream 实际上是内部实现了 BackPressure 的自动处理机制 51 | const ts = new TransformStream(); 52 | WorkerEvent.channel.port1.postMessage( 53 | { 54 | key: MESSAGE_TYPE.TRANSFER_START, 55 | id: fileId, 56 | readable: ts.readable, 57 | }, 58 | // 转移所有权至 Service Worker 59 | [ts.readable] 60 | ); 61 | WorkerEvent.writer.set(fileId, ts.writable.getWriter()); 62 | // 需要通过 iframe 发起下载请求, 在 Service Worker 中拦截请求 63 | // 这里如果 A 的 DOM 上引用了 B 的 iframe 框架 64 | // 此时 B 中存在的 SW 可以拦截 A 的 iframe 创建的请求 65 | // 当然前提是 A 创建的 iframe 请求是请求的 B 源下的地址 66 | const src = 67 | `/${fileId}` + 68 | `?${HEADER_KEY.FILE_ID}=${fileId}` + 69 | `&${HEADER_KEY.FILE_SIZE}=${fileSize}` + 70 | `&${HEADER_KEY.FILE_TOTAL}=${fileTotal}` + 71 | `&${HEADER_KEY.FILE_NAME}=${fileName}`; 72 | const iframe = document.createElement("iframe"); 73 | iframe.hidden = true; 74 | iframe.src = src; 75 | iframe.id = fileId; 76 | document.body.appendChild(iframe); 77 | } 78 | 79 | public static async post(fileId: string, data: ArrayBuffer) { 80 | const writer = WorkerEvent.writer.get(fileId); 81 | if (!writer) return void 0; 82 | // 感知 BackPressure 需要主动 await ready 83 | await writer.ready; 84 | return writer.write(new Uint8Array(data)); 85 | } 86 | 87 | public static close(fileId: string) { 88 | const iframe = document.getElementById(fileId); 89 | iframe && iframe.remove(); 90 | WorkerEvent.channel?.port1.postMessage({ 91 | key: MESSAGE_TYPE.TRANSFER_CLOSE, 92 | id: fileId, 93 | }); 94 | const writer = WorkerEvent.writer.get(fileId); 95 | // 必须关闭 Writer 否则浏览器无法感知下载完成 96 | writer?.close(); 97 | WorkerEvent.writer.delete(fileId); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /packages/webrtc/client/worker/index.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | declare let self: ServiceWorkerGlobalScope; 4 | import type { MessageType } from "../../types/worker"; 5 | import { HEADER_KEY, MESSAGE_TYPE } from "../../types/worker"; 6 | 7 | self.addEventListener("install", () => { 8 | // 跳过等待 直接激活 9 | // 新的 Service Worker 安装完成后会进入等待阶段 10 | // 直到旧的 Service Worker 被完全卸载后 再进行激活 11 | self.skipWaiting(); 12 | console.log("Service Worker Installed"); 13 | }); 14 | 15 | self.addEventListener("activate", event => { 16 | // 激活后立即接管所有的客户端页面 无需等待页面刷新 17 | event.waitUntil(self.clients.claim()); 18 | console.log("Service Worker Activate"); 19 | }); 20 | 21 | type StreamTuple = [ReadableStream]; 22 | const map = new Map(); 23 | 24 | self.onmessage = event => { 25 | const port = event.ports[0]; 26 | if (!port) return void 0; 27 | port.onmessage = event => { 28 | const payload = event.data as MessageType; 29 | if (!payload) return void 0; 30 | if (payload.key === MESSAGE_TYPE.TRANSFER_START) { 31 | const { id, readable } = payload; 32 | map.set(id, [readable]); 33 | } 34 | if (payload.key === MESSAGE_TYPE.TRANSFER_CLOSE) { 35 | const { id } = payload; 36 | map.delete(id); 37 | } 38 | }; 39 | }; 40 | 41 | self.onfetch = event => { 42 | const url = new URL(event.request.url); 43 | const search = url.searchParams; 44 | const fileId = search.get(HEADER_KEY.FILE_ID); 45 | const fileName = search.get(HEADER_KEY.FILE_NAME); 46 | const fileSize = search.get(HEADER_KEY.FILE_SIZE); 47 | const fileTotal = search.get(HEADER_KEY.FILE_TOTAL); 48 | if (!fileId || !fileName || !fileSize || !fileTotal) { 49 | return void 0; 50 | } 51 | const transfer = map.get(fileId); 52 | if (!transfer) { 53 | return event.respondWith(new Response(null, { status: 404 })); 54 | } 55 | const [readable] = transfer; 56 | const newFileName = encodeURIComponent(fileName).replace(/['()]/g, escape).replace(/\*/g, "%2A"); 57 | const responseHeader = new Headers({ 58 | [HEADER_KEY.FILE_ID]: fileId, 59 | [HEADER_KEY.FILE_SIZE]: fileSize, 60 | [HEADER_KEY.FILE_NAME]: newFileName, 61 | "Content-Type": "application/octet-stream; charset=utf-8", 62 | "Content-Security-Policy": "default-src 'none'", 63 | "X-Content-Security-Policy": "default-src 'none'", 64 | "X-WebKit-CSP": "default-src 'none'", 65 | "X-XSS-Protection": "1; mode=block", 66 | "Cross-Origin-Embedder-Policy": "require-corp", 67 | "Content-Disposition": "attachment; filename*=UTF-8''" + newFileName, 68 | "Content-Length": fileSize, 69 | }); 70 | const response = new Response(readable, { 71 | headers: responseHeader, 72 | }); 73 | return event.respondWith(response); 74 | }; 75 | -------------------------------------------------------------------------------- /packages/webrtc/client/worker/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "lib": ["WebWorker", "ESNext"] 5 | }, 6 | "include": ["./index.ts", "../../types/worker.ts", "../../types/client.ts", "../utils/binary.ts"] 7 | } 8 | -------------------------------------------------------------------------------- /packages/webrtc/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ft/webrtc", 3 | "version": "1.0.0", 4 | "scripts": { 5 | "dev:fe": "cross-env NODE_ENV=development rspack build -c rspack.client.js --watch", 6 | "build:fe": "rspack build -c rspack.client.js", 7 | "server": "mkdir -p build && rollup -c rollup.server.js && node build/server.js", 8 | "dev:server": "nodemon --watch server --ext ts --exec \"npm run server\"", 9 | "build:server": "mkdir -p build && rollup -c rollup.server.js", 10 | "dev": "concurrently \"npm run dev:fe\" \"npm run dev:server\"", 11 | "build": "rimraf build && npm run build:fe && npm run build:server", 12 | "deploy": "npm run build && node build/server.js", 13 | "build:deploy": "npm run build && pnpm -F . --prod deploy ../../output", 14 | "lint:ts": "../../node_modules/typescript/bin/tsc --noEmit -p tsconfig.json" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/WindrunnerMax/FileTransfer.git" 19 | }, 20 | "keywords": [], 21 | "author": "", 22 | "license": "MIT", 23 | "bugs": { 24 | "url": "https://github.com/WindrunnerMax/FileTransfer/issues" 25 | }, 26 | "homepage": "https://github.com/WindrunnerMax/FileTransfer", 27 | "dependencies": { 28 | "@arco-design/web-react": "2.56.1", 29 | "laser-utils": "0.0.5-alpha.1", 30 | "react": "17.0.2", 31 | "react-dom": "17.0.2", 32 | "socket.io": "4.7.2", 33 | "socket.io-client": "4.7.2", 34 | "express": "4.18.2", 35 | "vconsole": "^3.15.1" 36 | }, 37 | "devDependencies": { 38 | "@rspack/cli": "0.2.5", 39 | "@rspack/plugin-html": "0.2.5", 40 | "@types/express": "4.17.21", 41 | "@types/react": "17.0.2", 42 | "@types/react-dom": "17.0.2", 43 | "concurrently": "8.2.2", 44 | "copy-webpack-plugin": "5", 45 | "cross-env": "7.0.3", 46 | "esbuild": "0.19.6", 47 | "less": "3.0.0", 48 | "less-loader": "6.0.0", 49 | "postcss": "8.3.3", 50 | "prettier": "2.4.1", 51 | "rimraf": "6.0.1", 52 | "rollup": "2.75.7", 53 | "rollup-plugin-esbuild": "6.1.0", 54 | "sass": "1.52.3", 55 | "nodemon": "3.0.1", 56 | "sass-loader": "13.3.2" 57 | }, 58 | "engines": { 59 | "node": ">=14.0.0", 60 | "pnpm": ">=8.11.0" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /packages/webrtc/rollup.server.js: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import esbuild from "rollup-plugin-esbuild"; 3 | 4 | process.env.NODE_ENV = "production"; 5 | 6 | export default async () => { 7 | return { 8 | input: "./server/index.ts", 9 | output: { 10 | file: "./build/server.js", 11 | format: "cjs", 12 | }, 13 | external: ["socket.io", "http", "os", "express", "process", "path"], 14 | plugins: [ 15 | esbuild({ 16 | exclude: [/node_modules/], 17 | target: "esnext", 18 | minify: true, 19 | charset: "utf8", 20 | tsconfig: path.resolve(__dirname, "tsconfig.json"), 21 | }), 22 | ], 23 | }; 24 | }; 25 | -------------------------------------------------------------------------------- /packages/webrtc/rspack.client.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const { default: HtmlPlugin } = require("@rspack/plugin-html"); 3 | const CopyPlugin = require("copy-webpack-plugin"); 4 | const { getId } = require("laser-utils"); 5 | 6 | const PUBLIC_PATH = "/"; 7 | const RANDOM_ID = getId(); 8 | const isDev = process.env.NODE_ENV === "development"; 9 | 10 | /** 11 | * @type {import("@rspack/cli").Configuration} 12 | * @link https://www.rspack.dev/ 13 | */ 14 | const Main = { 15 | context: __dirname, 16 | entry: { 17 | index: "./client/index.tsx", 18 | }, 19 | plugins: [ 20 | new CopyPlugin([{ from: "./client/static", to: "." }]), 21 | new HtmlPlugin({ 22 | filename: "index.html", 23 | template: "./client/static/index.html", 24 | }), 25 | ], 26 | resolve: { 27 | alias: { 28 | "@": path.resolve(__dirname, "./client"), 29 | }, 30 | }, 31 | builtins: { 32 | define: { 33 | "__DEV__": JSON.stringify(isDev), 34 | "process.env.RANDOM_ID": JSON.stringify(RANDOM_ID), 35 | "process.env.PUBLIC_PATH": JSON.stringify(PUBLIC_PATH), 36 | "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV), 37 | }, 38 | pluginImport: [ 39 | { 40 | libraryName: "@arco-design/web-react", 41 | customName: "@arco-design/web-react/es/{{ member }}", 42 | style: true, 43 | }, 44 | ], 45 | }, 46 | module: { 47 | rules: [ 48 | { test: /\.svg$/, type: "asset" }, 49 | { 50 | test: /\.(m|module).scss$/, 51 | use: [{ loader: "sass-loader" }], 52 | type: "css/module", 53 | }, 54 | { 55 | test: /\.less$/, 56 | use: [ 57 | { 58 | loader: "less-loader", 59 | options: { 60 | lessOptions: { 61 | javascriptEnabled: true, 62 | importLoaders: true, 63 | localIdentName: "[name]__[hash:base64:5]", 64 | }, 65 | }, 66 | }, 67 | ], 68 | type: "css", 69 | }, 70 | ], 71 | }, 72 | target: "es5", 73 | devtool: isDev ? "source-map" : false, 74 | output: { 75 | publicPath: PUBLIC_PATH, 76 | chunkLoading: "jsonp", 77 | chunkFormat: "array-push", 78 | filename: "[name].[hash].js", 79 | path: path.resolve(__dirname, "build/static"), 80 | }, 81 | }; 82 | 83 | /** 84 | * @type {import("@rspack/cli").Configuration} 85 | */ 86 | const Worker = { 87 | context: __dirname, 88 | entry: { 89 | worker: "./client/worker/index.ts", 90 | }, 91 | devtool: isDev ? "source-map" : false, 92 | output: { 93 | clean: true, 94 | filename: "[name].js", 95 | path: path.resolve(__dirname, "build/static"), 96 | }, 97 | }; 98 | 99 | module.exports = [Main, Worker]; 100 | -------------------------------------------------------------------------------- /packages/webrtc/server/index.ts: -------------------------------------------------------------------------------- 1 | import http from "http"; 2 | import path from "path"; 3 | import express from "express"; 4 | import process from "process"; 5 | import { Server } from "socket.io"; 6 | import type { ServerHandler, ClientHandler, SocketEventParams } from "../types/signaling"; 7 | import { CLINT_EVENT, SERVER_EVENT } from "../types/signaling"; 8 | import type { Member, ServerSocket } from "../types/server"; 9 | import { ERROR_TYPE } from "../types/server"; 10 | import { getIpByRequest, getLocalIp } from "./utils"; 11 | 12 | const app = express(); 13 | app.use(express.static(path.resolve(__dirname, "static"))); 14 | const httpServer = http.createServer(app); 15 | const io = new Server(httpServer); 16 | 17 | const authenticate = new WeakMap(); 18 | const mapper = new Map(); 19 | const rooms = new Map(); 20 | 21 | io.on("connection", socket => { 22 | socket.on(CLINT_EVENT.JOIN_ROOM, ({ id, device }) => { 23 | // 验证 24 | if (!id) return void 0; 25 | authenticate.set(socket, id); 26 | // 加入房间 27 | const ip = getIpByRequest(socket.request); 28 | const room = rooms.get(ip) || []; 29 | rooms.set(ip, [...room, id]); 30 | mapper.set(id, { socket, device, ip }); 31 | // 房间通知消息 32 | const initialization: SocketEventParams["JOINED_MEMBER"]["initialization"] = []; 33 | room.forEach(key => { 34 | const instance = mapper.get(key); 35 | if (!instance) return void 0; 36 | initialization.push({ id: key, device: instance.device }); 37 | instance.socket.emit(SERVER_EVENT.JOINED_ROOM, { id, device }); 38 | }); 39 | socket.emit(SERVER_EVENT.JOINED_MEMBER, { initialization }); 40 | }); 41 | 42 | socket.on(CLINT_EVENT.SEND_OFFER, ({ origin, offer, target }) => { 43 | // 验证 44 | if (authenticate.get(socket) !== origin) return void 0; 45 | if (!mapper.has(target)) { 46 | socket.emit(SERVER_EVENT.NOTIFY_ERROR, { 47 | code: ERROR_TYPE.PEER_NOT_FOUND, 48 | message: `Peer ${target} Not Found`, 49 | }); 50 | return void 0; 51 | } 52 | // 转发`Offer` 53 | const targetSocket = mapper.get(target)?.socket; 54 | if (targetSocket) { 55 | targetSocket.emit(SERVER_EVENT.FORWARD_OFFER, { origin, offer, target }); 56 | } 57 | }); 58 | 59 | socket.on(CLINT_EVENT.SEND_ICE, ({ origin, ice, target }) => { 60 | // 验证 61 | if (authenticate.get(socket) !== origin) return void 0; 62 | // 转发`ICE` 63 | const targetSocket = mapper.get(target)?.socket; 64 | if (targetSocket) { 65 | targetSocket.emit(SERVER_EVENT.FORWARD_ICE, { origin, ice, target }); 66 | } 67 | }); 68 | 69 | socket.on(CLINT_EVENT.SEND_ANSWER, ({ origin, answer, target }) => { 70 | // 验证 71 | if (authenticate.get(socket) !== origin) return void 0; 72 | // 转发`Answer` 73 | const targetSocket = mapper.get(target)?.socket; 74 | if (targetSocket) { 75 | targetSocket.emit(SERVER_EVENT.FORWARD_ANSWER, { origin, answer, target }); 76 | } 77 | }); 78 | 79 | socket.on(CLINT_EVENT.SEND_ERROR, ({ origin, code, message, target }) => { 80 | // 验证 81 | if (authenticate.get(socket) !== origin) return void 0; 82 | // 转发`Error` 83 | const targetSocket = mapper.get(target)?.socket; 84 | if (targetSocket) { 85 | targetSocket.emit(SERVER_EVENT.NOTIFY_ERROR, { code, message }); 86 | } 87 | }); 88 | 89 | const onLeaveRoom = (id: string) => { 90 | // 验证 91 | if (authenticate.get(socket) !== id) return void 0; 92 | // 退出房间 93 | const instance = mapper.get(id); 94 | if (!instance) return void 0; 95 | const room = (rooms.get(instance.ip) || []).filter(key => key !== id); 96 | if (room.length === 0) { 97 | rooms.delete(instance.ip); 98 | } else { 99 | rooms.set(instance.ip, room); 100 | } 101 | mapper.delete(id); 102 | // 房间内通知 103 | room.forEach(key => { 104 | const instance = mapper.get(key); 105 | if (!instance) return void 0; 106 | instance.socket.emit(SERVER_EVENT.LEFT_ROOM, { id }); 107 | }); 108 | }; 109 | 110 | socket.on(CLINT_EVENT.LEAVE_ROOM, ({ id }) => { 111 | onLeaveRoom(id); 112 | }); 113 | 114 | socket.on("disconnect", () => { 115 | const id = authenticate.get(socket); 116 | id && onLeaveRoom(id); 117 | }); 118 | }); 119 | 120 | process.on("SIGINT", () => { 121 | console.info("SIGINT Received, exiting..."); 122 | process.exit(0); 123 | }); 124 | 125 | process.on("SIGTERM", () => { 126 | console.info("SIGTERM Received, exiting..."); 127 | process.exit(0); 128 | }); 129 | 130 | const PORT = Number(process.env.PORT) || 3000; 131 | httpServer.listen(PORT, () => { 132 | const ip = getLocalIp(); 133 | console.log(`Listening on port http://localhost:${PORT} ...`); 134 | ip.forEach(item => { 135 | console.log(`Listening on port http://${item}:${PORT} ...`); 136 | }); 137 | }); 138 | -------------------------------------------------------------------------------- /packages/webrtc/server/utils.ts: -------------------------------------------------------------------------------- 1 | import type { Member } from "../types/server"; 2 | import os from "os"; 3 | import type http from "http"; 4 | 5 | export const updateMember = ( 6 | map: Map, 7 | id: string, 8 | key: T, 9 | value: Member[T] 10 | ) => { 11 | const instance = map.get(id); 12 | if (instance) { 13 | map.set(id, { ...instance, [key]: value }); 14 | } else { 15 | console.warn(`UpdateMember: ${id} Not Found`); 16 | } 17 | }; 18 | 19 | export const getLocalIp = () => { 20 | const result: string[] = []; 21 | const interfaces = os.networkInterfaces(); 22 | for (const key in interfaces) { 23 | const networkInterface = interfaces[key]; 24 | if (!networkInterface) continue; 25 | for (const inf of networkInterface) { 26 | if (inf.family === "IPv4" && !inf.internal) { 27 | result.push(inf.address); 28 | } 29 | } 30 | } 31 | return result; 32 | }; 33 | 34 | export const getIpByRequest = (request: http.IncomingMessage) => { 35 | let ip = ""; 36 | if (request.headers["x-real-ip"]) { 37 | ip = request.headers["x-real-ip"].toString(); 38 | } else if (request.headers["x-forwarded-for"]) { 39 | const forwarded = request.headers["x-forwarded-for"].toString(); 40 | const [firstIp] = forwarded.split(","); 41 | ip = firstIp ? firstIp.trim() : ""; 42 | } else { 43 | ip = request.socket.remoteAddress || ""; 44 | } 45 | // 本地部署应用时, ip 地址可能是 ::1 或 ::ffff: 46 | if (ip === "::1" || ip === "::ffff:127.0.0.1" || !ip) { 47 | ip = "127.0.0.1"; 48 | } 49 | // 局域网部署应用时, ip 地址可能是 192.168.x.x / 10.x.x.x / 172.16(31).x.x 50 | // 目前 ipv6 中 fd00::/8 为内网地址, 这些暂时都先不处理了, 只处理 192.168 网段 51 | if (ip.startsWith("::ffff:192.168") || ip.startsWith("192.168")) { 52 | ip = "192.168.0.0"; 53 | } 54 | return ip; 55 | }; 56 | -------------------------------------------------------------------------------- /packages/webrtc/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "include": ["./client/**/*", "./server/*", "./types/*"], 4 | "exclude": ["./client/worker/index.ts"] 5 | } 6 | -------------------------------------------------------------------------------- /packages/webrtc/types/client.ts: -------------------------------------------------------------------------------- 1 | import type { Object } from "laser-utils"; 2 | 3 | export const CONNECTION_STATE = { 4 | INIT: "INIT", 5 | READY: "READY", 6 | CONNECTING: "CONNECTING", 7 | CONNECTED: "CONNECTED", 8 | } as const; 9 | 10 | export const DEVICE_TYPE = { 11 | MOBILE: "MOBILE", 12 | PC: "PC", 13 | } as const; 14 | 15 | export const MESSAGE_TYPE = { 16 | TEXT: "TEXT", 17 | FILE_START: "FILE_START", 18 | FILE_NEXT: "FILE_NEXT", 19 | FILE_FINISH: "FILE_FINISH", 20 | } as const; 21 | 22 | export const TRANSFER_TYPE = { 23 | TEXT: "TEXT", 24 | FILE: "FILE", 25 | } as const; 26 | 27 | export const TRANSFER_FROM = { 28 | SELF: "SELF", 29 | PEER: "PEER", 30 | } as const; 31 | 32 | export type MessageTypeMap = { 33 | [MESSAGE_TYPE.TEXT]: { data: string }; 34 | [MESSAGE_TYPE.FILE_START]: { name: string } & FileMeta; 35 | [MESSAGE_TYPE.FILE_NEXT]: { series: number } & FileMeta; 36 | [MESSAGE_TYPE.FILE_FINISH]: { id: string }; 37 | }; 38 | 39 | export type TransferTypeMap = { 40 | [TRANSFER_TYPE.TEXT]: { 41 | data: string; 42 | from: Object.Values; 43 | }; 44 | [TRANSFER_TYPE.FILE]: Omit & { 45 | name: string; 46 | progress: number; 47 | from: Object.Values; 48 | }; 49 | }; 50 | 51 | type _Spread> = { 52 | [P in T]: unknown extends M[P] ? never : M[P] & { key: P }; 53 | }; 54 | export type Spread = Object.Values<_Spread, M>>; 55 | export type BufferType = Blob | ArrayBuffer; 56 | export type MessageType = Spread; 57 | export type TransferType = Spread; 58 | export type Member = { id: string; device: DeviceType }; 59 | export type DeviceType = Object.Values; 60 | export type FileMeta = { id: string; size: number; total: number }; 61 | export type ConnectionState = Object.Values; 62 | -------------------------------------------------------------------------------- /packages/webrtc/types/global.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.scss" { 2 | const content: Record; 3 | export default content; 4 | } 5 | 6 | declare namespace NodeJS { 7 | interface ProcessEnv { 8 | PORT: string; 9 | RANDOM_ID: string; 10 | PUBLIC_PATH: string; 11 | NODE_ENV: "development" | "production"; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/webrtc/types/server.ts: -------------------------------------------------------------------------------- 1 | import type { Socket } from "socket.io"; 2 | import type { ClientHandler, ServerHandler } from "./signaling"; 3 | import type { DeviceType } from "./client"; 4 | import type { Object } from "laser-utils"; 5 | 6 | export const CONNECTION_STATE = { 7 | NORMAL: "NORMAL", 8 | LINKED: "LINKED", 9 | } as const; 10 | 11 | export const ERROR_TYPE = { 12 | PEER_BUSY: "PEER_BUSY", 13 | PEER_NOT_FOUND: "PEER_NOT_FOUND", 14 | } as const; 15 | 16 | export type ErrorType = Object.Values; 17 | export type ServerSocket = Socket; 18 | export type ConnectionState = Object.Values; 19 | export type Member = { socket: ServerSocket; device: DeviceType; ip: string }; 20 | -------------------------------------------------------------------------------- /packages/webrtc/types/signaling.ts: -------------------------------------------------------------------------------- 1 | import type { DeviceType } from "./client"; 2 | import type { ErrorType } from "./server"; 3 | 4 | const CLINT_EVENT_BASE = [ 5 | "JOIN_ROOM", 6 | "LEAVE_ROOM", 7 | "SEND_OFFER", 8 | "SEND_ANSWER", 9 | "SEND_ICE", 10 | "SEND_ERROR", 11 | ] as const; 12 | 13 | const SERVER_EVENT_BASE = [ 14 | "JOINED_ROOM", 15 | "JOINED_MEMBER", 16 | "LEFT_ROOM", 17 | "FORWARD_OFFER", 18 | "FORWARD_ANSWER", 19 | "FORWARD_ICE", 20 | "NOTIFY_ERROR", 21 | ] as const; 22 | 23 | export const CLINT_EVENT = CLINT_EVENT_BASE.reduce( 24 | (acc, cur) => ({ ...acc, [cur]: cur }), 25 | {} as { [K in ClientEventKeys]: K } 26 | ); 27 | 28 | export const SERVER_EVENT = SERVER_EVENT_BASE.reduce( 29 | (acc, cur) => ({ ...acc, [cur]: cur }), 30 | {} as { [K in ServerEventKeys]: K } 31 | ); 32 | 33 | export type ClientFn = ( 34 | payload: SocketEventParams[T], 35 | callback?: (state: CallBackState) => void 36 | ) => void; 37 | export type ClientEventKeys = typeof CLINT_EVENT_BASE[number]; 38 | export type CallBackState = { code: number; message: string }; 39 | export type ClientHandler = { [K in ClientEventKeys]: ClientFn }; 40 | 41 | export type ServerFn = ( 42 | payload: SocketEventParams[T], 43 | callback?: (state: CallBackState) => void 44 | ) => void; 45 | export type ServerEventKeys = typeof SERVER_EVENT_BASE[number]; 46 | export type ServerHandler = { [K in ServerEventKeys]: ServerFn }; 47 | 48 | export interface SocketEventParams { 49 | // CLIENT 50 | [CLINT_EVENT.JOIN_ROOM]: { 51 | id: string; 52 | device: DeviceType; 53 | }; 54 | [CLINT_EVENT.LEAVE_ROOM]: { 55 | id: string; 56 | }; 57 | [CLINT_EVENT.SEND_OFFER]: { 58 | origin: string; 59 | target: string; 60 | offer: RTCSessionDescriptionInit; 61 | }; 62 | [CLINT_EVENT.SEND_ANSWER]: { 63 | origin: string; 64 | target: string; 65 | answer: RTCSessionDescriptionInit; 66 | }; 67 | [CLINT_EVENT.SEND_ICE]: { 68 | origin: string; 69 | target: string; 70 | ice: RTCIceCandidateInit; 71 | }; 72 | [CLINT_EVENT.SEND_ERROR]: { 73 | origin: string; 74 | target: string; 75 | code: ErrorType; 76 | message: string; 77 | }; 78 | 79 | // SERVER 80 | [SERVER_EVENT.JOINED_ROOM]: { 81 | id: string; 82 | device: DeviceType; 83 | }; 84 | [SERVER_EVENT.JOINED_MEMBER]: { 85 | initialization: { 86 | id: string; 87 | device: DeviceType; 88 | }[]; 89 | }; 90 | [SERVER_EVENT.LEFT_ROOM]: { 91 | id: string; 92 | }; 93 | [SERVER_EVENT.FORWARD_OFFER]: { 94 | origin: string; 95 | target: string; 96 | offer: RTCSessionDescriptionInit; 97 | }; 98 | [SERVER_EVENT.FORWARD_ANSWER]: { 99 | origin: string; 100 | target: string; 101 | answer: RTCSessionDescriptionInit; 102 | }; 103 | [SERVER_EVENT.FORWARD_ICE]: { 104 | origin: string; 105 | target: string; 106 | ice: RTCIceCandidateInit; 107 | }; 108 | [SERVER_EVENT.NOTIFY_ERROR]: { 109 | code: ErrorType; 110 | message: string; 111 | }; 112 | } 113 | -------------------------------------------------------------------------------- /packages/webrtc/types/webrtc.ts: -------------------------------------------------------------------------------- 1 | import type { WebRTCInstance } from "../client/bridge/instance"; 2 | import type { SignalingServer } from "../client/bridge/signaling"; 3 | 4 | export type WebRTCOptions = { wss: string; ice?: string }; 5 | export type WebRTCCallback = (p: { signaling: SignalingServer; rtc: WebRTCApi }) => void; 6 | 7 | export type WebRTCApi = { 8 | connect: (id: string) => Promise; 9 | send: (message: string | ArrayBuffer) => void; 10 | close: () => void; 11 | getInstance: () => WebRTCInstance | null; 12 | }; 13 | 14 | export type WebRTCInstanceOptions = { 15 | ice?: string; 16 | signaling: SignalingServer; 17 | id: string; 18 | onOpen?: (event: Event) => void; 19 | onMessage?: (event: MessageEvent) => void; 20 | onError?: (event: RTCErrorEvent) => void; 21 | onClose?: (event: Event) => void; 22 | onConnectionStateChange: (pc: RTCPeerConnection) => void; 23 | }; 24 | -------------------------------------------------------------------------------- /packages/webrtc/types/worker.ts: -------------------------------------------------------------------------------- 1 | import type { Spread } from "./client"; 2 | 3 | export const HEADER_KEY = { 4 | FILE_ID: "X-File-Id", 5 | FILE_NAME: "X-File-Name", 6 | FILE_SIZE: "X-File-Size", 7 | FILE_TOTAL: "X-File-Total", 8 | }; 9 | 10 | export const MESSAGE_TYPE = { 11 | INIT_CHANNEL: "INIT_CHANNEL", 12 | TRANSFER_START: "TRANSFER_START", 13 | TRANSFER_CLOSE: "TRANSFER_CLOSE", 14 | } as const; 15 | 16 | export type MessageTypeMap = { 17 | [MESSAGE_TYPE.INIT_CHANNEL]: Record; 18 | [MESSAGE_TYPE.TRANSFER_START]: { id: string; readable: ReadableStream }; 19 | [MESSAGE_TYPE.TRANSFER_CLOSE]: { id: string }; 20 | }; 21 | 22 | export type MessageType = Spread; 23 | -------------------------------------------------------------------------------- /packages/websocket/client/app/app.tsx: -------------------------------------------------------------------------------- 1 | import styles from "../styles/index.module.scss"; 2 | import type { FC } from "react"; 3 | import { useLayoutEffect, useRef, useState } from "react"; 4 | import { IconGithub } from "@arco-design/web-react/icon"; 5 | import { BoardCastIcon, ComputerIcon, PhoneIcon } from "@ft/webrtc/client/layout/icon"; 6 | import { useMemoFn } from "laser-utils"; 7 | import type { ServerFn } from "../../types/websocket"; 8 | import { CLINT_EVENT, SERVER_EVENT } from "../../types/websocket"; 9 | import type { ConnectionState, Member } from "../../types/client"; 10 | import { CONNECTION_STATE, DEVICE_TYPE } from "../../types/client"; 11 | import { TransferModal } from "./modal"; 12 | import { SocketClient } from "../bridge/socket-server"; 13 | import { ERROR_TYPE, SHAKE_HANDS } from "../../types/server"; 14 | import { Message } from "@arco-design/web-react"; 15 | 16 | export const App: FC = () => { 17 | const client = useRef(null); 18 | const [id, setId] = useState(""); 19 | const [peerId, setPeerId] = useState(""); 20 | const [visible, setVisible] = useState(false); 21 | const [members, setMembers] = useState([]); 22 | const [state, setState] = useState(CONNECTION_STATE.READY); 23 | 24 | // === WebSocket Connection Event === 25 | const onJoinRoom: ServerFn = useMemoFn(member => { 26 | console.log("JOIN ROOM", member); 27 | setMembers([...members, member]); 28 | }); 29 | const onJoinedMember: ServerFn = useMemoFn(event => { 30 | const { initialization } = event; 31 | console.log("JOINED MEMBER", initialization); 32 | setMembers([...initialization]); 33 | }); 34 | const onLeftRoom: ServerFn = useMemoFn(event => { 35 | const { id } = event; 36 | console.log("LEFT ROOM", id); 37 | if (id === peerId) { 38 | setVisible(false); 39 | setPeerId(""); 40 | } 41 | setMembers(members.filter(member => member.id !== id)); 42 | }); 43 | const onReceiveRequest: ServerFn = useMemoFn(event => { 44 | console.log("RECEIVE REQUEST", event); 45 | const { origin } = event; 46 | if (!peerId && !visible) { 47 | setPeerId(origin); 48 | setVisible(true); 49 | setState(CONNECTION_STATE.CONNECTED); 50 | client.current?.emit(CLINT_EVENT.SEND_RESPONSE, { 51 | target: origin, 52 | origin: id, 53 | code: SHAKE_HANDS.ACCEPT, 54 | }); 55 | } else { 56 | client.current?.emit(CLINT_EVENT.SEND_RESPONSE, { 57 | target: origin, 58 | origin: id, 59 | code: SHAKE_HANDS.REJECT, 60 | reason: `The Device ${id} is Busy`, 61 | }); 62 | } 63 | }); 64 | const onReceiveResponse: ServerFn = useMemoFn(event => { 65 | console.log("RECEIVE RESPONSE", event); 66 | const { code, reason } = event; 67 | if (code === SHAKE_HANDS.ACCEPT) { 68 | setState(CONNECTION_STATE.CONNECTED); 69 | } else { 70 | setState(CONNECTION_STATE.READY); 71 | Message.error(reason || "Peer Rejected"); 72 | } 73 | }); 74 | const onUnpeer: ServerFn = useMemoFn(event => { 75 | console.log("UNPEER", event); 76 | if (event.target === id && event.origin === peerId) { 77 | setVisible(false); 78 | setPeerId(""); 79 | setState(CONNECTION_STATE.READY); 80 | } 81 | }); 82 | 83 | // === WebSocket Connection INIT === 84 | useLayoutEffect(() => { 85 | const socket = new SocketClient(location.host); 86 | socket.on(SERVER_EVENT.JOINED_ROOM, onJoinRoom); 87 | socket.on(SERVER_EVENT.JOINED_MEMBER, onJoinedMember); 88 | socket.on(SERVER_EVENT.LEFT_ROOM, onLeftRoom); 89 | socket.on(SERVER_EVENT.FORWARD_REQUEST, onReceiveRequest); 90 | socket.on(SERVER_EVENT.FORWARD_RESPONSE, onReceiveResponse); 91 | socket.on(SERVER_EVENT.FORWARD_UNPEER, onUnpeer); 92 | setId(socket.id); 93 | client.current = socket; 94 | return () => { 95 | socket.off(SERVER_EVENT.JOINED_ROOM, onJoinRoom); 96 | socket.off(SERVER_EVENT.JOINED_MEMBER, onJoinedMember); 97 | socket.off(SERVER_EVENT.LEFT_ROOM, onLeftRoom); 98 | socket.off(SERVER_EVENT.FORWARD_REQUEST, onReceiveRequest); 99 | socket.off(SERVER_EVENT.FORWARD_RESPONSE, onReceiveResponse); 100 | socket.off(SERVER_EVENT.FORWARD_UNPEER, onUnpeer); 101 | }; 102 | }, [onJoinRoom, onJoinedMember, onLeftRoom, onReceiveRequest, onReceiveResponse, onUnpeer]); 103 | 104 | const onPeerConnection = (member: Member) => { 105 | if (client.current) { 106 | client.current.emit(CLINT_EVENT.SEND_REQUEST, { target: member.id, origin: id }, state => { 107 | if (state.code !== ERROR_TYPE.NO_ERROR && state.message) { 108 | Message.error(state.message); 109 | } 110 | }); 111 | setVisible(true); 112 | setPeerId(member.id); 113 | setState(CONNECTION_STATE.CONNECTING); 114 | } 115 | }; 116 | 117 | return ( 118 |
119 |
120 |
{BoardCastIcon}
121 |
Local ID: {id}
122 |
123 | {state === CONNECTION_STATE.READY && members.length === 0 && ( 124 |
Open Another Device To Transfer Files
125 | )} 126 |
127 | {members.map(member => ( 128 |
onPeerConnection(member)}> 129 |
130 | {member.device === DEVICE_TYPE.MOBILE ? PhoneIcon : ComputerIcon} 131 |
132 |
{member.id.slice(0, 7)}
133 |
134 | ))} 135 |
136 | 141 | 142 | 143 | {visible && peerId && ( 144 | 155 | )} 156 |
157 | ); 158 | }; 159 | -------------------------------------------------------------------------------- /packages/websocket/client/app/modal.tsx: -------------------------------------------------------------------------------- 1 | import styles from "../styles/index.module.scss"; 2 | import type { FC } from "react"; 3 | import React, { useEffect, useRef, useState } from "react"; 4 | import type { BufferType, ConnectionState, MessageType, TransferType } from "../../types/client"; 5 | import { 6 | CHUNK_SIZE, 7 | CONNECTION_STATE, 8 | MESSAGE_TYPE, 9 | TRANSFER_FROM, 10 | TRANSFER_TYPE, 11 | } from "../../types/client"; 12 | import { Button, Input, Modal, Progress } from "@arco-design/web-react"; 13 | import { IconFile, IconSend, IconToBottom } from "@arco-design/web-react/icon"; 14 | import { useMemoFn } from "laser-utils"; 15 | import { cs, getUniqueId } from "laser-utils"; 16 | import { base64ToBlob, formatBytes, getChunkByIndex, scrollToBottom } from "../utils/format"; 17 | import type { SocketClient } from "../bridge/socket-server"; 18 | import type { ServerFn } from "../../types/websocket"; 19 | import { CLINT_EVENT, SERVER_EVENT } from "../../types/websocket"; 20 | 21 | export const TransferModal: FC<{ 22 | client: React.MutableRefObject; 23 | id: string; 24 | setId: (id: string) => void; 25 | peerId: string; 26 | setPeerId: (id: string) => void; 27 | state: ConnectionState; 28 | setState: (state: ConnectionState) => void; 29 | visible: boolean; 30 | setVisible: (visible: boolean) => void; 31 | }> = ({ client, state, peerId, visible, setVisible, setState, id }) => { 32 | const listRef = useRef(null); 33 | const fileSource = useRef>({}); 34 | const fileMapper = useRef>({}); 35 | const [text, setText] = useState(""); 36 | const [list, setList] = useState([]); 37 | 38 | const onCancel = () => { 39 | client.current?.emit(CLINT_EVENT.SEND_UNPEER, { target: peerId, origin: id }); 40 | setState(CONNECTION_STATE.READY); 41 | setVisible(false); 42 | }; 43 | 44 | const sendMessage = (message: MessageType) => { 45 | client.current?.emit(CLINT_EVENT.SEND_MESSAGE, { target: peerId, message, origin: id }); 46 | }; 47 | 48 | const updateFileProgress = (id: string, progress: number, newList = list) => { 49 | const last = newList.find(item => item.key === TRANSFER_TYPE.FILE && item.id === id); 50 | if (last && last.key === TRANSFER_TYPE.FILE) { 51 | last.progress = progress; 52 | setList([...newList]); 53 | } 54 | }; 55 | 56 | const onMessage: ServerFn = useMemoFn(event => { 57 | console.log("onMessage", event); 58 | if (event.origin !== peerId) return void 0; 59 | const data = event.message; 60 | if (data.key === MESSAGE_TYPE.TEXT) { 61 | // 收到 发送方 的文本消息 62 | setList([...list, { from: TRANSFER_FROM.PEER, ...data }]); 63 | scrollToBottom(listRef); 64 | } else if (data.key === MESSAGE_TYPE.FILE_START) { 65 | // 收到 发送方 传输起始消息 准备接收数据 66 | const { id, name, size, total } = data; 67 | fileMapper.current[id] = []; 68 | setList([ 69 | ...list, 70 | { key: TRANSFER_TYPE.FILE, from: TRANSFER_FROM.PEER, name, size, progress: 0, id }, 71 | ]); 72 | // 通知 发送方 发送首个块 73 | sendMessage({ key: MESSAGE_TYPE.FILE_NEXT, id, current: 0, size, total }); 74 | scrollToBottom(listRef); 75 | } else if (data.key === MESSAGE_TYPE.FILE_CHUNK) { 76 | // 收到 接收方 的文件块数据 77 | const { id, current, total, size, chunk } = data; 78 | const progress = Math.floor((current / total) * 100); 79 | updateFileProgress(id, progress); 80 | if (current >= total) { 81 | // 数据接收完毕 通知 发送方 接收完毕 82 | sendMessage({ key: MESSAGE_TYPE.FILE_FINISH, id }); 83 | } else { 84 | const mapper = fileMapper.current; 85 | if (!mapper[id]) mapper[id] = []; 86 | mapper[id][current] = base64ToBlob(chunk); 87 | // 通知 发送方 发送下一个序列块 88 | sendMessage({ key: MESSAGE_TYPE.FILE_NEXT, id, current: current + 1, size, total }); 89 | } 90 | } else if (data.key === MESSAGE_TYPE.FILE_NEXT) { 91 | // 收到 接收方 的准备接收块数据消息 92 | const { id, current, total, size } = data; 93 | const progress = Math.floor((current / total) * 100); 94 | updateFileProgress(id, progress); 95 | const file = fileSource.current[id]; 96 | if (file) { 97 | getChunkByIndex(file, current).then(chunk => { 98 | // 通知 接收方 发送块数据 99 | sendMessage({ key: MESSAGE_TYPE.FILE_CHUNK, id, current, total, size, chunk }); 100 | }); 101 | } 102 | } else if (data.key === MESSAGE_TYPE.FILE_FINISH) { 103 | // 收到 接收方 的接收完成消息 104 | const { id } = data; 105 | updateFileProgress(id, 100); 106 | } 107 | }); 108 | 109 | useEffect(() => { 110 | const socket = client.current; 111 | socket?.on(SERVER_EVENT.FORWARD_MESSAGE, onMessage); 112 | return () => { 113 | socket?.off(SERVER_EVENT.FORWARD_MESSAGE, onMessage); 114 | }; 115 | }, [client, onMessage]); 116 | 117 | const onSendText = () => { 118 | sendMessage({ key: MESSAGE_TYPE.TEXT, data: text }); 119 | setList([...list, { key: MESSAGE_TYPE.TEXT, from: TRANSFER_FROM.SELF, data: text }]); 120 | setText(""); 121 | scrollToBottom(listRef); 122 | }; 123 | 124 | const sendFilesBySlice = async (files: FileList) => { 125 | const newList: TransferType[] = [...list]; 126 | for (const file of files) { 127 | const name = file.name; 128 | const id = getUniqueId(); 129 | const size = file.size; 130 | const total = Math.ceil(file.size / CHUNK_SIZE); 131 | sendMessage({ key: MESSAGE_TYPE.FILE_START, id, name, size, total }); 132 | fileSource.current[id] = file; 133 | newList.push({ 134 | key: TRANSFER_TYPE.FILE, 135 | from: TRANSFER_FROM.SELF, 136 | name, 137 | size, 138 | progress: 0, 139 | id, 140 | } as const); 141 | } 142 | setList(newList); 143 | scrollToBottom(listRef); 144 | }; 145 | 146 | const onSendFile = () => { 147 | const KEY = "websocket-file-input"; 148 | const exist = document.querySelector(`body > [data-type='${KEY}']`) as HTMLInputElement; 149 | const input: HTMLInputElement = exist || document.createElement("input"); 150 | input.value = ""; 151 | input.setAttribute("data-type", KEY); 152 | input.setAttribute("type", "file"); 153 | input.setAttribute("class", styles.fileInput); 154 | input.setAttribute("accept", "*"); 155 | input.setAttribute("multiple", "true"); 156 | !exist && document.body.append(input); 157 | input.onchange = e => { 158 | const target = e.target as HTMLInputElement; 159 | document.body.removeChild(input); 160 | const files = target.files; 161 | files && sendFilesBySlice(files); 162 | }; 163 | input.click(); 164 | }; 165 | 166 | const onDownloadFile = (id: string, fileName: string) => { 167 | const blob = fileMapper.current[id] 168 | ? new Blob(fileMapper.current[id], { type: "application/octet-stream" }) 169 | : fileSource.current[id] || new Blob(); 170 | const url = URL.createObjectURL(blob); 171 | const a = document.createElement("a"); 172 | a.href = url; 173 | a.download = fileName; 174 | a.click(); 175 | URL.revokeObjectURL(url); 176 | }; 177 | 178 | const enableTransfer = state === CONNECTION_STATE.CONNECTED; 179 | 180 | return ( 181 | 185 |
198 | {state === CONNECTION_STATE.READY 199 | ? "Disconnected: " + peerId 200 | : state === CONNECTION_STATE.CONNECTING 201 | ? "Connecting: " + peerId 202 | : state === CONNECTION_STATE.CONNECTED 203 | ? "Connected: " + peerId 204 | : "Unknown State: " + peerId} 205 | 206 | } 207 | visible={visible} 208 | footer={null} 209 | onCancel={onCancel} 210 | maskClosable={false} 211 | > 212 |
213 | {list.map((item, index) => ( 214 |
221 |
222 | {item.key === TRANSFER_TYPE.TEXT ? ( 223 | {item.data} 224 | ) : ( 225 |
226 |
227 |
228 |
229 | 230 | {item.name} 231 |
232 |
{formatBytes(item.size)}
233 |
234 |
item.progress === 100 && onDownloadFile(item.id, item.name)} 237 | > 238 | 239 |
240 |
241 | 242 |
243 | )} 244 |
245 |
246 | ))} 247 |
248 |
249 | 258 | 266 | 275 |
276 |
277 | ); 278 | }; 279 | -------------------------------------------------------------------------------- /packages/websocket/client/bridge/socket-server.ts: -------------------------------------------------------------------------------- 1 | import type { Socket } from "socket.io-client"; 2 | import io from "socket.io-client"; 3 | import type { 4 | CallBackState, 5 | ClientEventKeys, 6 | ClientHandler, 7 | ServerEventKeys, 8 | ServerFn, 9 | ServerHandler, 10 | SocketEventParams, 11 | } from "../../types/websocket"; 12 | import { CLINT_EVENT } from "../../types/websocket"; 13 | import { IS_MOBILE } from "laser-utils"; 14 | import { DEVICE_TYPE } from "../../types/client"; 15 | import { getUniqueId } from "laser-utils"; 16 | 17 | export class SocketClient { 18 | public readonly id: string; 19 | public readonly socket: Socket; 20 | constructor(wss: string) { 21 | const STORAGE_KEY = "WEBSOCKET-ID"; 22 | // https://socket.io/docs/v4/server-socket-instance/#socketid 23 | this.id = sessionStorage?.getItem(STORAGE_KEY) || getUniqueId(8); 24 | sessionStorage?.setItem(STORAGE_KEY, this.id); 25 | console.log("Client WebSocket ID:", this.id); 26 | const socket = io(wss, { transports: ["websocket"] }); 27 | this.socket = socket; 28 | this.socket.on("connect", this.onConnect); 29 | this.socket.on("disconnect", this.onDisconnect); 30 | } 31 | 32 | public on = (key: T, cb: ServerFn) => { 33 | // @ts-expect-error unknown 34 | this.socket.on(key, cb); 35 | }; 36 | 37 | public off = (key: T, cb: ServerFn) => { 38 | // @ts-expect-error unknown 39 | this.socket.off(key, cb); 40 | }; 41 | 42 | public emit = ( 43 | key: T, 44 | payload: SocketEventParams[T], 45 | callback?: (state: CallBackState) => void 46 | ) => { 47 | // @ts-expect-error unknown 48 | this.socket.emit(key, payload, callback); 49 | }; 50 | 51 | private onConnect = () => { 52 | // https://socket.io/docs/v4/server-socket-instance/#socketid 53 | this.emit(CLINT_EVENT.JOIN_ROOM, { 54 | id: this.id, 55 | device: IS_MOBILE ? DEVICE_TYPE.MOBILE : DEVICE_TYPE.PC, 56 | }); 57 | }; 58 | 59 | private onDisconnect = () => { 60 | this.emit(CLINT_EVENT.LEAVE_ROOM, { id: this.id }); 61 | }; 62 | 63 | public destroy = () => { 64 | this.socket.emit(CLINT_EVENT.LEAVE_ROOM, { id: this.id }); 65 | this.socket.off("connect", this.onConnect); 66 | this.socket.off("disconnect", this.onDisconnect); 67 | this.socket.close(); 68 | }; 69 | } 70 | -------------------------------------------------------------------------------- /packages/websocket/client/index.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from "react-dom"; 2 | import { App } from "./app/app"; 3 | import "@arco-design/web-react/es/style/index.less"; 4 | import { drawingBackdrop } from "@ft/webrtc/client/layout/canvas"; 5 | 6 | window.addEventListener("DOMContentLoaded", drawingBackdrop); 7 | ReactDOM.render(, document.getElementById("root")); 8 | -------------------------------------------------------------------------------- /packages/websocket/client/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WindRunnerMax/FileTransfer/0c9e67b950b2c08a43cd22c1fb58aeea717bd7ba/packages/websocket/client/static/favicon.ico -------------------------------------------------------------------------------- /packages/websocket/client/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | FileTransfer-WebSocket 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /packages/websocket/client/styles/index.module.scss: -------------------------------------------------------------------------------- 1 | // === Page === 2 | body, 3 | html { 4 | color: var(--color-text-1); 5 | height: 100%; 6 | } 7 | 8 | // === Main === 9 | body, 10 | .container { 11 | display: flex; 12 | height: 100%; 13 | justify-content: center; 14 | margin: 0; 15 | padding: 0; 16 | width: 100%; 17 | 18 | .content { 19 | align-items: center; 20 | bottom: 30px; 21 | display: flex; 22 | flex-direction: column; 23 | justify-content: center; 24 | left: 50%; 25 | max-width: 100%; 26 | position: fixed; 27 | transform: translateX(-50%); 28 | } 29 | 30 | .boardCastIcon { 31 | color: rgba(var(--arcoblue-6), 0.8); 32 | font-size: 60px; 33 | text-align: center; 34 | } 35 | 36 | .manualEntry { 37 | color: rgba(var(--arcoblue-6), 0.8); 38 | cursor: pointer; 39 | margin-top: 5px; 40 | text-align: center; 41 | white-space: nowrap; 42 | } 43 | } 44 | 45 | .prompt { 46 | animation-duration: 0.5s; 47 | animation-fill-mode: forwards; 48 | animation-name: scale-in; 49 | color: rgba(var(--arcoblue-6), 0.8); 50 | font-size: 20px; 51 | left: 0; 52 | position: fixed; 53 | right: 0; 54 | text-align: center; 55 | top: 40%; 56 | user-select: none; 57 | width: 100%; 58 | } 59 | 60 | // === Banner === 61 | .github { 62 | color: var(--color-text-1); 63 | font-size: 23px; 64 | position: fixed; 65 | right: 12px; 66 | top: 6px; 67 | } 68 | 69 | @keyframes scale-in { 70 | 0% { 71 | opacity: 0; 72 | transform: scale(0); 73 | } 74 | 75 | 100% { 76 | opacity: 1; 77 | transform: scale(1); 78 | } 79 | } 80 | 81 | // === Device === 82 | .deviceGroup { 83 | display: flex; 84 | font-size: 14px; 85 | justify-content: center; 86 | left: 0; 87 | position: fixed; 88 | right: 0; 89 | top: 40%; 90 | user-select: none; 91 | width: 100%; 92 | 93 | .device { 94 | align-items: center; 95 | animation-duration: 0.5s; 96 | animation-fill-mode: forwards; 97 | animation-name: scale-in; 98 | cursor: pointer; 99 | display: flex; 100 | flex-direction: column; 101 | /* stylelint-disable-next-line declaration-block-no-redundant-longhand-properties */ 102 | flex-wrap: wrap; 103 | margin: 20px; 104 | 105 | .icon { 106 | align-items: center; 107 | background-color: rgba(var(--arcoblue-6), 0.8); 108 | border-radius: 70px; 109 | color: #fff; 110 | display: flex; 111 | font-size: 35px; 112 | height: 70px; 113 | justify-content: center; 114 | width: 70px; 115 | } 116 | 117 | .name { 118 | color: var(--color-text-1); 119 | margin-top: 6px; 120 | } 121 | } 122 | } 123 | 124 | // === Modal === 125 | .modal { 126 | box-sizing: border-box; 127 | max-height: 100%; 128 | max-width: 96%; 129 | 130 | :global { 131 | .arco-modal-content { 132 | padding: 0; 133 | } 134 | } 135 | 136 | .title { 137 | align-items: center; 138 | display: flex; 139 | justify-content: center; 140 | 141 | .dot { 142 | border-radius: 5px; 143 | height: 7px; 144 | margin-right: 7px; 145 | margin-top: 1px; 146 | width: 7px; 147 | } 148 | } 149 | 150 | .modalContent { 151 | box-sizing: border-box; 152 | height: 500px; 153 | overflow-y: auto; 154 | padding: 5px 10px; 155 | scrollbar-width: none; 156 | 157 | &::-webkit-scrollbar { 158 | background-color: transparent; 159 | height: 0; 160 | width: 0; 161 | } 162 | 163 | .messageItem { 164 | display: flex; 165 | 166 | &.alignRight { 167 | justify-content: flex-end; 168 | 169 | .messageContent { 170 | background-color: rgba(var(--arcoblue-6), 0.8); 171 | } 172 | } 173 | } 174 | 175 | .messageContent { 176 | background-color: rgba(var(--green-7), 0.8); 177 | border-radius: 10px; 178 | box-sizing: border-box; 179 | color: #fff; 180 | margin-top: 20px; 181 | max-width: 80%; 182 | padding: 10px; 183 | } 184 | 185 | .fileMessage { 186 | max-width: 100%; 187 | min-width: 160px; 188 | padding: 0 5px; 189 | 190 | .fileInfo { 191 | align-items: center; 192 | display: flex; 193 | font-size: 12px; 194 | justify-content: space-between; 195 | } 196 | 197 | .fileName { 198 | font-size: 14px; 199 | margin-bottom: 3px; 200 | overflow-wrap: break-word; 201 | white-space: pre-wrap; 202 | word-break: break-word; 203 | } 204 | 205 | .fileIcon { 206 | margin-right: 3px; 207 | } 208 | 209 | .fileDownload { 210 | align-items: center; 211 | border: 1px solid #fff; 212 | border-radius: 20px; 213 | color: #fff; 214 | cursor: pointer; 215 | display: flex; 216 | flex-shrink: 0; 217 | font-size: 16px; 218 | height: 26px; 219 | justify-content: center; 220 | margin-left: 20px; 221 | width: 26px; 222 | 223 | &.disable { 224 | cursor: not-allowed; 225 | opacity: 0.5; 226 | } 227 | } 228 | 229 | :global { 230 | .arco-progress-line-text { 231 | color: #fff; 232 | } 233 | } 234 | } 235 | } 236 | 237 | .modalFooter { 238 | align-items: center; 239 | border-top: 1px solid var(--color-border-1); 240 | display: flex; 241 | height: 60px; 242 | padding: 5px 10px; 243 | 244 | .sendFile { 245 | margin-right: 10px; 246 | 247 | &:focus-visible { 248 | box-shadow: none; 249 | } 250 | } 251 | } 252 | } 253 | 254 | // === Misc === 255 | .fileInput { 256 | left: -1000px; 257 | position: fixed; 258 | top: -1000px; 259 | } 260 | -------------------------------------------------------------------------------- /packages/websocket/client/utils/format.ts: -------------------------------------------------------------------------------- 1 | import { CHUNK_SIZE } from "../../types/client"; 2 | import pako from "pako"; 3 | import { Base64 } from "js-base64"; 4 | export { formatBytes, scrollToBottom } from "@ft/webrtc/client/utils/format"; 5 | 6 | export const blobToBase64 = async (blob: Blob) => { 7 | return new Promise((resolve, reject) => { 8 | const reader = new FileReader(); 9 | reader.onload = () => { 10 | const data = new Uint8Array(reader.result as ArrayBuffer); 11 | const compress = pako.deflate(data); 12 | resolve(Base64.fromUint8Array(compress)); 13 | }; 14 | reader.onerror = reject; 15 | reader.readAsArrayBuffer(blob); 16 | }); 17 | }; 18 | 19 | export const base64ToBlob = (base64: string) => { 20 | const bytes = Base64.toUint8Array(base64); 21 | const decompress = pako.inflate(bytes); 22 | const blob = new Blob([decompress]); 23 | return blob; 24 | }; 25 | 26 | export const getChunkByIndex = (file: Blob, current: number): Promise => { 27 | const start = current * CHUNK_SIZE; 28 | const end = Math.min(start + CHUNK_SIZE, file.size); 29 | return blobToBase64(file.slice(start, end)); 30 | }; 31 | -------------------------------------------------------------------------------- /packages/websocket/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ft/websocket", 3 | "version": "1.0.0", 4 | "scripts": { 5 | "build:fe": "rspack build -c rspack.client.js", 6 | "dev:fe": "cross-env NODE_ENV=development rspack build -c rspack.client.js --watch", 7 | "server": "mkdir -p build && rollup -c rollup.server.js && node build/server.js", 8 | "dev": "npm run build:fe && concurrently \"npm run dev:fe\" \"npm run server\"", 9 | "build": "rimraf build && npm run build:fe && rollup -c rollup.server.js", 10 | "deploy": "npm run build && node build/server.js", 11 | "lint:ts": "../../node_modules/typescript/bin/tsc --noEmit" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/WindrunnerMax/FileTransfer.git" 16 | }, 17 | "keywords": [], 18 | "author": "", 19 | "license": "MIT", 20 | "bugs": { 21 | "url": "https://github.com/WindrunnerMax/FileTransfer/issues" 22 | }, 23 | "homepage": "https://github.com/WindrunnerMax/FileTransfer", 24 | "dependencies": { 25 | "@arco-design/web-react": "2.56.1", 26 | "js-base64": "3.7.5", 27 | "laser-utils": "0.0.5-alpha.1", 28 | "pako": "2.1.0", 29 | "react": "17.0.2", 30 | "react-dom": "17.0.2", 31 | "socket.io": "4.7.2", 32 | "socket.io-client": "4.7.2", 33 | "express": "4.18.2", 34 | "@ft/webrtc": "workspace: *" 35 | }, 36 | "devDependencies": { 37 | "@rspack/cli": "0.2.5", 38 | "@rspack/plugin-html": "0.2.5", 39 | "@types/express": "4.17.21", 40 | "@types/pako": "2.0.3", 41 | "@types/react": "17.0.2", 42 | "@types/react-dom": "17.0.2", 43 | "concurrently": "8.2.2", 44 | "cross-env": "7.0.3", 45 | "esbuild": "0.19.6", 46 | "less": "3.0.0", 47 | "rimraf": "6.0.1", 48 | "less-loader": "6.0.0", 49 | "postcss": "8.3.3", 50 | "prettier": "2.4.1", 51 | "rollup": "2.75.7", 52 | "@rollup/plugin-node-resolve": "13.0.4", 53 | "rollup-plugin-esbuild": "6.1.0", 54 | "sass": "1.52.3", 55 | "sass-loader": "13.3.2" 56 | } 57 | } -------------------------------------------------------------------------------- /packages/websocket/rollup.server.js: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import esbuild from "rollup-plugin-esbuild"; 3 | import resolve from "@rollup/plugin-node-resolve"; 4 | 5 | process.env.NODE_ENV === "production"; 6 | /** 7 | * @return {import("./node_modules/rollup").RollupOptions} 8 | */ 9 | export default async () => { 10 | return { 11 | input: "./server/index.ts", 12 | output: { 13 | file: "./build/server.js", 14 | format: "cjs", 15 | }, 16 | external: ["socket.io", "http", "os", "express", "process", "path"], 17 | plugins: [ 18 | resolve(), 19 | esbuild({ 20 | target: "esnext", 21 | minify: true, 22 | charset: "utf8", 23 | tsconfig: path.resolve(__dirname, "tsconfig.json"), 24 | }), 25 | ], 26 | }; 27 | }; 28 | -------------------------------------------------------------------------------- /packages/websocket/rspack.client.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const { default: HtmlPlugin } = require("@rspack/plugin-html"); 3 | const CopyPlugin = require("copy-webpack-plugin"); 4 | 5 | const isDev = process.env.NODE_ENV === "development"; 6 | 7 | /** 8 | * @type {import("@rspack/cli").Configuration} 9 | * @link https://www.rspack.dev/ 10 | */ 11 | module.exports = { 12 | context: __dirname, 13 | entry: { 14 | index: "./client/index.tsx", 15 | }, 16 | plugins: [ 17 | new CopyPlugin([{ from: "./client/static", to: "." }]), 18 | new HtmlPlugin({ 19 | filename: "index.html", 20 | template: "./client/static/index.html", 21 | }), 22 | ], 23 | resolve: { 24 | alias: { 25 | "@": path.resolve(__dirname, "./client"), 26 | }, 27 | }, 28 | builtins: { 29 | define: { 30 | "__DEV__": JSON.stringify(isDev), 31 | "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV), 32 | }, 33 | pluginImport: [ 34 | { 35 | libraryName: "@arco-design/web-react", 36 | customName: "@arco-design/web-react/es/{{ member }}", 37 | style: true, 38 | }, 39 | ], 40 | }, 41 | module: { 42 | rules: [ 43 | { test: /\.svg$/, type: "asset" }, 44 | { 45 | test: /\.(m|module).scss$/, 46 | use: [{ loader: "sass-loader" }], 47 | type: "css/module", 48 | }, 49 | { 50 | test: /\.less$/, 51 | use: [ 52 | { 53 | loader: "less-loader", 54 | options: { 55 | lessOptions: { 56 | javascriptEnabled: true, 57 | importLoaders: true, 58 | localIdentName: "[name]__[hash:base64:5]", 59 | }, 60 | }, 61 | }, 62 | ], 63 | type: "css", 64 | }, 65 | ], 66 | }, 67 | target: "es5", 68 | devtool: isDev ? "source-map" : false, 69 | output: { 70 | publicPath: "/", 71 | chunkLoading: "jsonp", 72 | chunkFormat: "array-push", 73 | filename: isDev ? "[name].js" : "[name].[hash].js", 74 | path: path.resolve(__dirname, "build/static"), 75 | }, 76 | }; 77 | -------------------------------------------------------------------------------- /packages/websocket/server/index.ts: -------------------------------------------------------------------------------- 1 | import http from "http"; 2 | import path from "path"; 3 | import express from "express"; 4 | import process from "process"; 5 | import { Server } from "socket.io"; 6 | import type { ServerHandler, ClientHandler, SocketEventParams } from "../types/websocket"; 7 | import { CLINT_EVENT, SERVER_EVENT } from "../types/websocket"; 8 | import type { Member, ServerSocket } from "../types/server"; 9 | import { CONNECTION_STATE, ERROR_TYPE } from "../types/server"; 10 | import { getLocalIp, updateMember } from "./utils"; 11 | 12 | const app = express(); 13 | app.use(express.static(path.resolve(__dirname, "static"))); 14 | const httpServer = http.createServer(app); 15 | const io = new Server(httpServer); 16 | 17 | const authenticate = new WeakMap(); 18 | const room = new Map(); 19 | const peer = new Map(); 20 | 21 | io.on("connection", socket => { 22 | socket.on(CLINT_EVENT.JOIN_ROOM, ({ id, device }) => { 23 | // 验证 24 | if (!id) return void 0; 25 | authenticate.set(socket, id); 26 | // 房间通知消息 27 | const initialization: SocketEventParams["JOINED_MEMBER"]["initialization"] = []; 28 | room.forEach((instance, key) => { 29 | initialization.push({ id: key, device: instance.device }); 30 | instance.socket.emit(SERVER_EVENT.JOINED_ROOM, { id, device }); 31 | }); 32 | // 加入房间 33 | room.set(id, { socket, device, state: CONNECTION_STATE.READY }); 34 | socket.emit(SERVER_EVENT.JOINED_MEMBER, { initialization }); 35 | }); 36 | 37 | socket.on(CLINT_EVENT.SEND_REQUEST, ({ origin, target }, cb) => { 38 | // 验证 39 | if (authenticate.get(socket) !== origin) return void 0; 40 | // 转发`Request` 41 | const member = room.get(target); 42 | if (member) { 43 | if (member.state !== CONNECTION_STATE.READY) { 44 | cb?.({ code: ERROR_TYPE.PEER_BUSY, message: `Peer ${target} is Busy` }); 45 | return void 0; 46 | } 47 | updateMember(room, origin, "state", CONNECTION_STATE.CONNECTING); 48 | member.socket.emit(SERVER_EVENT.FORWARD_REQUEST, { origin, target }); 49 | } else { 50 | cb?.({ code: ERROR_TYPE.PEER_NOT_FOUND, message: `Peer ${target} Not Found` }); 51 | } 52 | }); 53 | 54 | socket.on(CLINT_EVENT.SEND_RESPONSE, ({ origin, code, reason, target }) => { 55 | // 验证 56 | if (authenticate.get(socket) !== origin) return void 0; 57 | // 转发`Response` 58 | const targetSocket = room.get(target)?.socket; 59 | if (targetSocket) { 60 | updateMember(room, origin, "state", CONNECTION_STATE.CONNECTED); 61 | updateMember(room, target, "state", CONNECTION_STATE.CONNECTED); 62 | peer.set(origin, target); 63 | peer.set(target, origin); 64 | targetSocket.emit(SERVER_EVENT.FORWARD_RESPONSE, { origin, code, reason, target }); 65 | } 66 | }); 67 | 68 | socket.on(CLINT_EVENT.SEND_MESSAGE, ({ origin, message, target }) => { 69 | // 验证 70 | if (authenticate.get(socket) !== origin) return void 0; 71 | // 转发`Message` 72 | const targetSocket = room.get(target)?.socket; 73 | if (targetSocket) { 74 | targetSocket.emit(SERVER_EVENT.FORWARD_MESSAGE, { origin, message, target }); 75 | } 76 | }); 77 | 78 | socket.on(CLINT_EVENT.SEND_UNPEER, ({ origin, target }) => { 79 | // 验证 80 | if (authenticate.get(socket) !== origin) return void 0; 81 | // 处理自身的状态 82 | peer.delete(origin); 83 | updateMember(room, origin, "state", CONNECTION_STATE.READY); 84 | // 验证 85 | if (peer.get(target) !== origin) return void 0; 86 | // 转发`Unpeer` 87 | const targetSocket = room.get(target)?.socket; 88 | if (targetSocket) { 89 | // 处理`Peer`状态 90 | updateMember(room, target, "state", CONNECTION_STATE.READY); 91 | peer.delete(target); 92 | targetSocket.emit(SERVER_EVENT.FORWARD_UNPEER, { origin, target }); 93 | } 94 | }); 95 | 96 | const onLeaveRoom = () => { 97 | // 验证 98 | const id = authenticate.get(socket); 99 | if (id) { 100 | const peerId = peer.get(id); 101 | peer.delete(id); 102 | if (peerId) { 103 | // 状态复位 104 | peer.delete(peerId); 105 | updateMember(room, peerId, "state", CONNECTION_STATE.READY); 106 | } 107 | // 退出房间 108 | room.delete(id); 109 | room.forEach(instance => { 110 | instance.socket.emit(SERVER_EVENT.LEFT_ROOM, { id }); 111 | }); 112 | } 113 | }; 114 | 115 | socket.on(CLINT_EVENT.LEAVE_ROOM, onLeaveRoom); 116 | socket.on("disconnect", onLeaveRoom); 117 | }); 118 | 119 | process.on("SIGINT", () => { 120 | console.info("SIGINT Received, exiting..."); 121 | process.exit(0); 122 | }); 123 | 124 | process.on("SIGTERM", () => { 125 | console.info("SIGTERM Received, exiting..."); 126 | process.exit(0); 127 | }); 128 | 129 | const PORT = Number(process.env.PORT) || 3000; 130 | httpServer.listen(PORT, () => { 131 | const ip = getLocalIp(); 132 | console.log(`Listening on port http://localhost:${PORT} ...`); 133 | ip.forEach(item => { 134 | console.log(`Listening on port http://${item}:${PORT} ...`); 135 | }); 136 | }); 137 | -------------------------------------------------------------------------------- /packages/websocket/server/utils.ts: -------------------------------------------------------------------------------- 1 | import type { Member } from "../types/server"; 2 | export { getLocalIp } from "@ft/webrtc/server/utils.ts"; 3 | 4 | export const updateMember = ( 5 | map: Map, 6 | id: string, 7 | key: T, 8 | value: Member[T] 9 | ) => { 10 | const instance = map.get(id); 11 | if (instance) { 12 | map.set(id, { ...instance, [key]: value }); 13 | } else { 14 | console.warn(`UpdateMember: ${id} Not Found`); 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /packages/websocket/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "include": ["./client/**/*", "./server/*", "./types/*"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/websocket/types/client.ts: -------------------------------------------------------------------------------- 1 | import type { Object } from "laser-utils"; 2 | import type { FileMeta, Spread } from "@ft/webrtc/types/client"; 3 | export { DEVICE_TYPE, TRANSFER_FROM, TRANSFER_TYPE } from "@ft/webrtc/types/client.ts"; 4 | export type { BufferType, Member, TransferType, DeviceType } from "@ft/webrtc/types/client.ts"; 5 | 6 | export const CHUNK_SIZE = 1024 * 256; // 256KB 7 | 8 | export const CONNECTION_STATE = { 9 | READY: "READY", 10 | CONNECTING: "CONNECTING", 11 | CONNECTED: "CONNECTED", 12 | } as const; 13 | 14 | export const MESSAGE_TYPE = { 15 | TEXT: "TEXT", 16 | FILE_START: "FILE_START", 17 | FILE_NEXT: "FILE_NEXT", 18 | FILE_CHUNK: "FILE_CHUNK", 19 | FILE_FINISH: "FILE_FINISH", 20 | } as const; 21 | 22 | export type MessageTypeMap = { 23 | [MESSAGE_TYPE.TEXT]: { data: string }; 24 | [MESSAGE_TYPE.FILE_START]: { name: string } & FileMeta; 25 | [MESSAGE_TYPE.FILE_NEXT]: { current: number } & FileMeta; 26 | [MESSAGE_TYPE.FILE_CHUNK]: { current: number; chunk: string } & FileMeta; 27 | [MESSAGE_TYPE.FILE_FINISH]: { id: string }; 28 | }; 29 | 30 | export type MessageType = Spread; 31 | export type ConnectionState = Object.Values; 32 | -------------------------------------------------------------------------------- /packages/websocket/types/global.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.scss" { 2 | const content: Record; 3 | export default content; 4 | } 5 | 6 | declare namespace NodeJS { 7 | interface ProcessEnv { 8 | PORT: string; 9 | NODE_ENV: "development" | "production"; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/websocket/types/server.ts: -------------------------------------------------------------------------------- 1 | import type { Socket } from "socket.io"; 2 | import type { ClientHandler, ServerHandler } from "./websocket"; 3 | import type { ConnectionState, DeviceType } from "./client"; 4 | import type { Object } from "laser-utils"; 5 | export type { ConnectionState } from "./client"; 6 | export { CONNECTION_STATE } from "./client"; 7 | 8 | export const SHAKE_HANDS = { 9 | ACCEPT: "ACCEPT", 10 | REJECT: "REJECT", 11 | } as const; 12 | 13 | export const ERROR_TYPE = { 14 | NO_ERROR: "NO_ERROR", 15 | PEER_BUSY: "PEER_BUSY", 16 | PEER_NOT_FOUND: "PEER_NOT_FOUND", 17 | } as const; 18 | 19 | export type Member = { 20 | socket: ServerSocket; 21 | device: DeviceType; 22 | state: ConnectionState; 23 | }; 24 | 25 | export type ErrorType = Object.Values; 26 | export type ShakeHands = Object.Values; 27 | export type ServerSocket = Socket; 28 | -------------------------------------------------------------------------------- /packages/websocket/types/websocket.ts: -------------------------------------------------------------------------------- 1 | import type { DeviceType, MessageType } from "./client"; 2 | import type { ErrorType, ShakeHands } from "./server"; 3 | 4 | const CLINT_EVENT_BASE = [ 5 | "JOIN_ROOM", 6 | "LEAVE_ROOM", 7 | "SEND_REQUEST", 8 | "SEND_RESPONSE", 9 | "SEND_UNPEER", 10 | "SEND_MESSAGE", 11 | ] as const; 12 | 13 | const SERVER_EVENT_BASE = [ 14 | "JOINED_ROOM", 15 | "JOINED_MEMBER", 16 | "LEFT_ROOM", 17 | "FORWARD_REQUEST", 18 | "FORWARD_RESPONSE", 19 | "FORWARD_UNPEER", 20 | "FORWARD_MESSAGE", 21 | ] as const; 22 | 23 | export const CLINT_EVENT = CLINT_EVENT_BASE.reduce( 24 | (acc, cur) => ({ ...acc, [cur]: cur }), 25 | {} as { [K in ClientEventKeys]: K } 26 | ); 27 | 28 | export const SERVER_EVENT = SERVER_EVENT_BASE.reduce( 29 | (acc, cur) => ({ ...acc, [cur]: cur }), 30 | {} as { [K in ServerEventKeys]: K } 31 | ); 32 | 33 | export type ClientFn = ( 34 | payload: SocketEventParams[T], 35 | callback?: (state: CallBackState) => void 36 | ) => void; 37 | export type ClientEventKeys = typeof CLINT_EVENT_BASE[number]; 38 | export type ClientHandler = { [K in ClientEventKeys]: ClientFn }; 39 | 40 | export type ServerFn = ( 41 | payload: SocketEventParams[T], 42 | callback?: (state: CallBackState) => void 43 | ) => void; 44 | export type ServerEventKeys = typeof SERVER_EVENT_BASE[number]; 45 | export type CallBackState = { code: ErrorType; message: string }; 46 | export type ServerHandler = { [K in ServerEventKeys]: ServerFn }; 47 | 48 | export interface SocketEventParams { 49 | // CLIENT 50 | [CLINT_EVENT.JOIN_ROOM]: { 51 | id: string; 52 | device: DeviceType; 53 | }; 54 | [CLINT_EVENT.LEAVE_ROOM]: { 55 | id: string; 56 | }; 57 | [CLINT_EVENT.SEND_REQUEST]: { 58 | origin: string; 59 | target: string; 60 | }; 61 | [CLINT_EVENT.SEND_RESPONSE]: { 62 | origin: string; 63 | target: string; 64 | code: ShakeHands; 65 | reason?: string; 66 | }; 67 | [CLINT_EVENT.SEND_UNPEER]: { 68 | origin: string; 69 | target: string; 70 | }; 71 | [CLINT_EVENT.SEND_MESSAGE]: { 72 | origin: string; 73 | target: string; 74 | message: MessageType; 75 | }; 76 | 77 | // SERVER 78 | [SERVER_EVENT.JOINED_ROOM]: { 79 | id: string; 80 | device: DeviceType; 81 | }; 82 | [SERVER_EVENT.JOINED_MEMBER]: { 83 | initialization: { 84 | id: string; 85 | device: DeviceType; 86 | }[]; 87 | }; 88 | [SERVER_EVENT.LEFT_ROOM]: { 89 | id: string; 90 | }; 91 | [SERVER_EVENT.FORWARD_REQUEST]: { 92 | origin: string; 93 | target: string; 94 | }; 95 | [SERVER_EVENT.FORWARD_RESPONSE]: { 96 | origin: string; 97 | target: string; 98 | code: ShakeHands; 99 | reason?: string; 100 | }; 101 | [SERVER_EVENT.FORWARD_UNPEER]: { 102 | origin: string; 103 | target: string; 104 | }; 105 | [SERVER_EVENT.FORWARD_MESSAGE]: { 106 | origin: string; 107 | target: string; 108 | message: MessageType; 109 | }; 110 | } 111 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "packages/*" 3 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "newLine": "lf", 6 | "allowJs": true, 7 | "skipLibCheck": true, 8 | "esModuleInterop": true, 9 | "downlevelIteration": true, 10 | "allowSyntheticDefaultImports": true, 11 | "allowImportingTsExtensions": true, 12 | "strict": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "noFallthroughCasesInSwitch": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": true, 20 | "jsx": "react-jsx", 21 | "experimentalDecorators": true 22 | }, 23 | "exclude": ["node_modules"], 24 | } 25 | --------------------------------------------------------------------------------