├── .browserslistrc ├── .changeset ├── README.md ├── cold-bags-dance.md └── config.json ├── .eslintrc.cjs ├── .eslintrc.dist.cjs ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.yaml │ ├── config.yml │ └── feature_request.yaml ├── banner_dark.png ├── banner_light.png └── workflows │ ├── release.yaml │ ├── size-limit.yaml │ └── test.yaml ├── .gitignore ├── .gitmodules ├── .npmignore ├── .prettierignore ├── .prettierrc ├── .size-limit.cjs ├── CHANGELOG.md ├── LICENSE ├── NOTICE ├── README.md ├── examples ├── demo │ ├── demo.ts │ ├── index.html │ ├── styles.css │ └── tsconfig.json └── rpc │ ├── README.md │ ├── api.ts │ ├── index.html │ ├── package.json │ ├── pnpm-lock.yaml │ ├── rpc-demo.ts │ ├── styles.css │ ├── tsconfig.json │ └── vite.config.js ├── package.json ├── pnpm-lock.yaml ├── renovate.json ├── rollup.config.js ├── rollup.config.worker.js ├── src ├── api │ ├── SignalClient.ts │ ├── utils.test.ts │ └── utils.ts ├── connectionHelper │ ├── ConnectionCheck.ts │ └── checks │ │ ├── Checker.ts │ │ ├── cloudRegion.ts │ │ ├── connectionProtocol.ts │ │ ├── publishAudio.ts │ │ ├── publishVideo.ts │ │ ├── reconnect.ts │ │ ├── turn.ts │ │ ├── webrtc.ts │ │ └── websocket.ts ├── e2ee │ ├── E2eeManager.ts │ ├── KeyProvider.ts │ ├── constants.ts │ ├── errors.ts │ ├── events.ts │ ├── index.ts │ ├── types.ts │ ├── utils.ts │ └── worker │ │ ├── FrameCryptor.test.ts │ │ ├── FrameCryptor.ts │ │ ├── ParticipantKeyHandler.test.ts │ │ ├── ParticipantKeyHandler.ts │ │ ├── SifGuard.ts │ │ ├── __snapshots__ │ │ └── ParticipantKeyHandler.test.ts.snap │ │ ├── e2ee.worker.ts │ │ └── tsconfig.json ├── index.ts ├── logger.ts ├── options.ts ├── room │ ├── DefaultReconnectPolicy.ts │ ├── DeviceManager.test.ts │ ├── DeviceManager.ts │ ├── PCTransport.ts │ ├── PCTransportManager.ts │ ├── RTCEngine.ts │ ├── ReconnectPolicy.ts │ ├── RegionUrlProvider.ts │ ├── Room.test.ts │ ├── Room.ts │ ├── StreamReader.ts │ ├── StreamWriter.ts │ ├── attribute-typings.ts │ ├── defaults.ts │ ├── errors.ts │ ├── events.ts │ ├── participant │ │ ├── LocalParticipant.ts │ │ ├── Participant.ts │ │ ├── ParticipantTrackPermission.ts │ │ ├── RemoteParticipant.ts │ │ ├── publishUtils.test.ts │ │ └── publishUtils.ts │ ├── rpc.test.ts │ ├── rpc.ts │ ├── stats.ts │ ├── timers.ts │ ├── track │ │ ├── LocalAudioTrack.ts │ │ ├── LocalTrack.ts │ │ ├── LocalTrackPublication.ts │ │ ├── LocalVideoTrack.test.ts │ │ ├── LocalVideoTrack.ts │ │ ├── RemoteAudioTrack.ts │ │ ├── RemoteTrack.ts │ │ ├── RemoteTrackPublication.ts │ │ ├── RemoteVideoTrack.test.ts │ │ ├── RemoteVideoTrack.ts │ │ ├── Track.ts │ │ ├── TrackPublication.ts │ │ ├── create.ts │ │ ├── facingMode.test.ts │ │ ├── facingMode.ts │ │ ├── options.ts │ │ ├── processor │ │ │ └── types.ts │ │ ├── record.ts │ │ ├── types.ts │ │ ├── utils.test.ts │ │ └── utils.ts │ ├── types.ts │ ├── utils.test.ts │ ├── utils.ts │ └── worker.d.ts ├── test │ ├── MockMediaStreamTrack.ts │ └── mocks.ts ├── type-polyfills │ └── document-pip.d.ts ├── utils │ ├── AsyncQueue.test.ts │ ├── AsyncQueue.ts │ ├── browserParser.test.ts │ ├── browserParser.ts │ ├── cloneDeep.test.ts │ └── cloneDeep.ts └── version.ts ├── tsconfig.eslint.json ├── tsconfig.json └── vite.config.mjs /.browserslistrc: -------------------------------------------------------------------------------- 1 | safari >= 11.1 2 | ios_saf >= 11.3 3 | chrome >= 64 4 | and_chr >= 64 5 | android >= 64 6 | firefox >= 58 7 | and_ff >= 58 8 | edge >= 79 9 | Opera >= 52 10 | Samsung >= 9.2 11 | not IE 11 12 | not dead -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/cold-bags-dance.md: -------------------------------------------------------------------------------- 1 | --- 2 | "livekit-client": patch 3 | --- 4 | 5 | fix: ensure signal connect future is reset after disconnecting from room 6 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.0.0/schema.json", 3 | "changelog": ["@livekit/changesets-changelog-github", { "repo": "livekit/client-sdk-js" }], 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ['plugin:import/recommended', 'airbnb-typescript/base', 'prettier'], 4 | parserOptions: { 5 | project: './tsconfig.eslint.json', 6 | }, 7 | rules: { 8 | 'import/export': 'off', 9 | 'max-classes-per-file': 'off', 10 | 'no-param-reassign': 'off', 11 | 'no-await-in-loop': 'off', 12 | 'no-restricted-syntax': 'off', 13 | 'consistent-return': 'off', 14 | 'class-methods-use-this': 'off', 15 | 'no-underscore-dangle': 'off', 16 | '@typescript-eslint/no-use-before-define': 'off', 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /.eslintrc.dist.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parserOptions: { 4 | ecmaVersion: 2022, 5 | // sourceType: 'module', 6 | project: undefined, 7 | }, 8 | env: { es2021: true }, 9 | plugins: ['ecmascript-compat'], 10 | rules: { 11 | 'ecmascript-compat/compat': [ 12 | 'warn', // FIXME once ecmascript-compat/compat supports feature checks or allows rule customization (https://github.com/robatwilliams/es-compat/issues/80) set this back to `error` 13 | { 14 | polyfills: [ 15 | // rollup-common-js and tsproto have environment checks using `globalThis` which causes the compat check to fail on the output 16 | 'globalThis', 17 | ], 18 | }, 19 | ], 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yaml: -------------------------------------------------------------------------------- 1 | name: "\U0001F41E Bug report" 2 | description: Report an issue with LiveKit client SDK JS 3 | body: 4 | - type: markdown 5 | attributes: 6 | value: | 7 | Thanks for taking the time to fill out this bug report! 8 | Please report security issues by email to security@livekit.io 9 | **Before you start, make sure you have the latest versions of the `livekit-client` npm package installed** 10 | - type: textarea 11 | id: bug-description 12 | attributes: 13 | label: Describe the bug 14 | description: Describe what you are expecting vs. what happens instead. 15 | placeholder: | 16 | ### What I'm expecting 17 | ### What happens instead 18 | validations: 19 | required: true 20 | - type: textarea 21 | id: reproduction 22 | attributes: 23 | label: Reproduction 24 | description: A detailed step-by-step guide on how to reproduce the issue or (preferably) a link to a repository that reproduces the issue. Reproductions must be [short, self-contained and correct](http://sscce.org/) and must not contain files or code that aren't relevant to the issue. It's best if you use the sample app in `examples/demo/demo.ts` as a starting point for your reproduction. We will prioritize issues that include a working minimal reproduction repository. 25 | placeholder: Reproduction 26 | validations: 27 | required: true 28 | - type: textarea 29 | id: logs 30 | attributes: 31 | label: Logs 32 | description: "Please include browser console and server logs around the time this bug occurred. Enable debug logging by calling `setLogLevel('debug')` from the livekit-client package as early as possible. Please try not to insert an image but copy paste the log text." 33 | render: shell 34 | - type: textarea 35 | id: system-info 36 | attributes: 37 | label: System Info 38 | description: Please mention the OS (incl. Version) and Browser (including exact version) on which you are seeing this issue. For ease of use you can run `npx envinfo --system --binaries --browsers --npmPackages "{livekit-*,@livekit/*}"` from within your livekit project, to give us all the needed info about your current environment 39 | render: shell 40 | placeholder: System, Binaries, Browsers 41 | validations: 42 | required: true 43 | - type: dropdown 44 | id: severity 45 | attributes: 46 | label: Severity 47 | options: 48 | - annoyance 49 | - serious, but I can work around it 50 | - blocking an upgrade 51 | - blocking all usage of LiveKit 52 | validations: 53 | required: true 54 | - type: textarea 55 | id: additional-context 56 | attributes: 57 | label: Additional Information 58 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Slack Community Chat 4 | url: https://livekit.io/join-slack 5 | about: Ask questions and discuss with other LiveKit users in real time. -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yaml: -------------------------------------------------------------------------------- 1 | name: "Feature Request" 2 | description: Suggest an idea for this project 3 | body: 4 | - type: markdown 5 | attributes: 6 | value: | 7 | Thanks for taking the time to request this feature! 8 | - type: textarea 9 | id: problem 10 | attributes: 11 | label: Describe the problem 12 | description: Please provide a clear and concise description the problem this feature would solve. The more information you can provide here, the better. 13 | placeholder: I would like to be able to ... in order to solve ... 14 | validations: 15 | required: true 16 | - type: textarea 17 | id: solution 18 | attributes: 19 | label: Describe the proposed solution 20 | description: Please provide a clear and concise description of what you would like to happen. 21 | placeholder: I would like to see ... 22 | validations: 23 | required: true 24 | - type: textarea 25 | id: alternatives 26 | attributes: 27 | label: Alternatives considered 28 | description: "Please provide a clear and concise description of any alternative solutions or features you've considered." 29 | - type: dropdown 30 | id: importance 31 | attributes: 32 | label: Importance 33 | description: How important is this feature to you? 34 | options: 35 | - nice to have 36 | - would make my life easier 37 | - I cannot use LiveKit without it 38 | validations: 39 | required: true 40 | - type: textarea 41 | id: additional-context 42 | attributes: 43 | label: Additional Information 44 | description: Add any other context or screenshots about the feature request here. -------------------------------------------------------------------------------- /.github/banner_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livekit/client-sdk-js/f12535dd4c8a75c002020610b073abcdd4bc3845/.github/banner_dark.png -------------------------------------------------------------------------------- /.github/banner_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livekit/client-sdk-js/f12535dd4c8a75c002020610b073abcdd4bc3845/.github/banner_light.png -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | 9 | concurrency: ${{ github.workflow }}-${{ github.ref }} 10 | 11 | jobs: 12 | release: 13 | name: Release 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout Repo 17 | uses: actions/checkout@v4 18 | - uses: pnpm/action-setup@v4 19 | - name: Use Node.js 20 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: 20 23 | cache: 'pnpm' 24 | - name: Install dependencies 25 | run: pnpm install 26 | - name: Create Release Pull Request or Publish to npm 27 | id: changesets 28 | uses: changesets/action@v1 29 | with: 30 | publish: pnpm ci:publish 31 | env: 32 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 33 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 34 | - name: Build Docs 35 | if: steps.changesets.outputs.published == 'true' 36 | run: pnpm build-docs 37 | - name: S3 Upload 38 | if: steps.changesets.outputs.published == 'true' 39 | run: aws s3 cp docs/ s3://livekit-docs/client-sdk-js --recursive 40 | env: 41 | AWS_ACCESS_KEY_ID: ${{ secrets.DOCS_DEPLOY_AWS_ACCESS_KEY }} 42 | AWS_SECRET_ACCESS_KEY: ${{ secrets.DOCS_DEPLOY_AWS_API_SECRET }} 43 | AWS_DEFAULT_REGION: "us-east-1" 44 | -------------------------------------------------------------------------------- /.github/workflows/size-limit.yaml: -------------------------------------------------------------------------------- 1 | name: 'size' 2 | on: 3 | pull_request: 4 | branches: 5 | - main 6 | workflow_dispatch: 7 | jobs: 8 | package-size: 9 | runs-on: ubuntu-latest 10 | env: 11 | CI_JOB_NUMBER: 1 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: pnpm/action-setup@v4 15 | - name: Use Node.js 20 16 | uses: actions/setup-node@v4 17 | with: 18 | node-version: 20 19 | cache: 'pnpm' 20 | - name: Install dependencies 21 | run: pnpm install 22 | - uses: andresz1/size-limit-action@v1.8.0 23 | with: 24 | github_token: ${{ secrets.GITHUB_TOKEN }} 25 | script: npx size-limit --json 26 | package_manager: pnpm 27 | build_script: build 28 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | push: 4 | branches: [ main ] 5 | pull_request: 6 | branches: [ main ] 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: pnpm/action-setup@v4 14 | - name: Use Node.js 20 15 | uses: actions/setup-node@v4 16 | with: 17 | node-version: 20 18 | cache: 'pnpm' 19 | 20 | - name: Install dependencies 21 | run: pnpm install 22 | 23 | - name: ESLint 24 | run: pnpm lint 25 | 26 | - name: Prettier 27 | run: pnpm format:check 28 | 29 | - name: Run Tests 30 | run: pnpm test 31 | 32 | - name: Check browser target compatibility 33 | run: | 34 | pnpm build 35 | pnpm compat -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | # MacOS 107 | .DS_Store 108 | 109 | # Docs folder, generated 110 | docs/ 111 | 112 | pkg/ 113 | bin/ 114 | examples/**/build/ 115 | 116 | .env.local -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "protocol"] 2 | path = protocol 3 | url = https://github.com/livekit/protocol 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .github 2 | bin 3 | docs 4 | protocol 5 | examples 6 | node_modules 7 | tsconfig.json 8 | test 9 | .prettierrc 10 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .github/ 2 | dist/ 3 | docs/ 4 | node_modules/ 5 | protocol/ 6 | src/proto/ 7 | src/room/attributes/ 8 | yarn.lock 9 | pnpm-lock.yaml -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "semi": true, 5 | "tabWidth": 2, 6 | "printWidth": 100, 7 | "importOrder": ["", "^[./]"], 8 | "importOrderSeparation": false, 9 | "importOrderSortSpecifiers": true, 10 | "importOrderParserPlugins": ["typescript"], 11 | "plugins": ["@trivago/prettier-plugin-sort-imports"] 12 | } 13 | -------------------------------------------------------------------------------- /.size-limit.cjs: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | { 3 | path: 'dist/livekit-client.esm.mjs', 4 | import: '{ Room }', 5 | limit: '100 kB', 6 | }, 7 | { 8 | path: 'dist/livekit-client.umd.js', 9 | import: '{ Room }', 10 | limit: '120 kB', 11 | }, 12 | ]; 13 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | Copyright 2021 LiveKit, Inc. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /examples/demo/styles.css: -------------------------------------------------------------------------------- 1 | #connect-area { 2 | display: grid; 3 | grid-template-columns: 1fr 1fr; 4 | grid-template-rows: min-content min-content; 5 | grid-auto-flow: column; 6 | grid-gap: 10px; 7 | margin-bottom: 15px; 8 | } 9 | 10 | #options-area { 11 | display: flex; 12 | flex-wrap: wrap; 13 | margin-left: 1.25rem; 14 | margin-right: 1.25rem; 15 | column-gap: 3rem; 16 | row-gap: 1rem; 17 | margin-bottom: 10px; 18 | } 19 | 20 | #actions-area { 21 | display: grid; 22 | grid-template-columns: fit-content(100px) auto; 23 | grid-gap: 1.25rem; 24 | margin-bottom: 15px; 25 | } 26 | 27 | #inputs-area { 28 | display: grid; 29 | grid-template-columns: repeat(3, 1fr); 30 | grid-gap: 1.25rem; 31 | margin-bottom: 10px; 32 | } 33 | 34 | #chat-input-area { 35 | margin-top: 1.2rem; 36 | display: grid; 37 | grid-template-columns: auto min-content; 38 | gap: 1.25rem; 39 | } 40 | 41 | #screenshare-area { 42 | position: relative; 43 | margin-top: 1.25rem; 44 | margin-bottom: 1.25rem; 45 | display: none; 46 | } 47 | 48 | #screenshare-area video { 49 | max-width: 900px; 50 | max-height: 900px; 51 | border: 3px solid rgba(0, 0, 0, 0.5); 52 | } 53 | 54 | #participants-area { 55 | display: grid; 56 | grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); 57 | gap: 20px; 58 | } 59 | 60 | #participants-area > .participant { 61 | width: 100%; 62 | } 63 | 64 | #participants-area > .participant::before { 65 | content: ''; 66 | display: inline-block; 67 | width: 1px; 68 | height: 0; 69 | padding-bottom: calc(100% / (16 / 9)); 70 | } 71 | 72 | #log-area { 73 | margin-top: 1.25rem; 74 | margin-bottom: 1rem; 75 | } 76 | 77 | #log { 78 | width: 66.6%; 79 | height: 100px; 80 | } 81 | 82 | .participant { 83 | position: relative; 84 | padding: 0; 85 | margin: 0; 86 | border-radius: 5px; 87 | border: 3px solid rgba(0, 0, 0, 0); 88 | overflow: hidden; 89 | } 90 | 91 | .participant video { 92 | position: absolute; 93 | left: 0; 94 | top: 0; 95 | width: 100%; 96 | height: 100%; 97 | background-color: #aaa; 98 | object-fit: cover; 99 | border-radius: 5px; 100 | } 101 | 102 | .participant .info-bar { 103 | position: absolute; 104 | width: 100%; 105 | bottom: 0; 106 | display: grid; 107 | color: #eee; 108 | padding: 2px 8px 2px 8px; 109 | background-color: rgba(0, 0, 0, 0.35); 110 | grid-template-columns: minmax(50px, auto) 1fr minmax(50px, auto); 111 | z-index: 5; 112 | } 113 | 114 | .participant .size { 115 | text-align: center; 116 | } 117 | 118 | .participant .right { 119 | text-align: right; 120 | } 121 | 122 | .participant.speaking { 123 | border: 3px solid rgba(94, 166, 190, 0.7); 124 | } 125 | 126 | .participant .mic-off { 127 | color: #d33; 128 | text-align: right; 129 | } 130 | 131 | .participant .mic-on { 132 | text-align: right; 133 | } 134 | 135 | .participant .connection-excellent { 136 | color: green; 137 | } 138 | 139 | .participant .connection-good { 140 | color: orange; 141 | } 142 | 143 | .participant .connection-poor { 144 | color: red; 145 | } 146 | 147 | .participant .volume-control { 148 | position: absolute; 149 | top: 4px; 150 | right: 2px; 151 | display: flex; 152 | z-index: 4; 153 | height: 100%; 154 | } 155 | 156 | .participant .volume-control > input { 157 | width: 16px; 158 | height: 40%; 159 | -webkit-appearance: slider-vertical; /* Chromium */ 160 | } 161 | 162 | .participant .volume-meter { 163 | position: absolute; 164 | z-index: 4; 165 | } 166 | -------------------------------------------------------------------------------- /examples/demo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "target": "ESNext" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, 5 | "module": "ESNext" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, 6 | "outDir": "build", 7 | "declaration": true, 8 | "declarationMap": true, 9 | "sourceMap": true, 10 | "strict": true /* Enable all strict type-checking options. */, 11 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 12 | "skipLibCheck": true /* Skip type checking of declaration files. */, 13 | "noUnusedLocals": true, 14 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */, 15 | "moduleResolution": "node", 16 | "resolveJsonModule": true 17 | }, 18 | "include": ["../../src/**/*", "demo.ts"], 19 | "exclude": ["**/*.test.ts", "build/**/*"] 20 | } 21 | -------------------------------------------------------------------------------- /examples/rpc/README.md: -------------------------------------------------------------------------------- 1 | # RPC Demo 2 | 3 | A working multi-participant live demo of the LiveKit RPC feature. 4 | 5 | ## Running the Demo 6 | 7 | 1. Create `.env.local` with `LIVEKIT_API_KEY`, `LIVEKIT_API_SECRET`, and `LIVEKIT_URL` 8 | 1. Install dependencies: `pnpm install` 9 | 1. Start server: `pnpm dev` 10 | 1. Open browser to local URL (typically http://localhost:5173) 11 | 1. Press the button to watch the demo run 12 | 13 | For more detailed information on using RPC with LiveKit, refer to the [main README](../../README.md#rpc). 14 | -------------------------------------------------------------------------------- /examples/rpc/api.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv'; 2 | import express from 'express'; 3 | import type { Express } from 'express'; 4 | import { AccessToken } from 'livekit-server-sdk'; 5 | 6 | dotenv.config({ path: '.env.local' }); 7 | 8 | const LIVEKIT_API_KEY = process.env.LIVEKIT_API_KEY; 9 | const LIVEKIT_API_SECRET = process.env.LIVEKIT_API_SECRET; 10 | const LIVEKIT_URL = process.env.LIVEKIT_URL; 11 | 12 | const app = express(); 13 | app.use(express.json()); 14 | 15 | app.post('/api/get-token', async (req, res) => { 16 | const { identity, roomName } = req.body; 17 | 18 | if (!LIVEKIT_API_KEY || !LIVEKIT_API_SECRET) { 19 | res.status(500).json({ error: 'Server misconfigured' }); 20 | return; 21 | } 22 | 23 | const token = new AccessToken(LIVEKIT_API_KEY, LIVEKIT_API_SECRET, { 24 | identity, 25 | }); 26 | token.addGrant({ 27 | room: roomName, 28 | roomJoin: true, 29 | canPublish: true, 30 | canSubscribe: true, 31 | }); 32 | 33 | res.json({ 34 | token: await token.toJwt(), 35 | url: LIVEKIT_URL, 36 | }); 37 | }); 38 | 39 | export const handler: Express = app; 40 | -------------------------------------------------------------------------------- /examples/rpc/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | LiveKit RPC Demo 7 | 8 | 9 | 10 |
11 |

LiveKit RPC Demo

12 |
13 | 14 |
15 | 16 |
17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /examples/rpc/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "livekit-rpc-example", 3 | "version": "1.0.0", 4 | "description": "Example of using LiveKit RPC", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "cors": "^2.8.5", 13 | "dotenv": "^16.4.5", 14 | "express": "^4.21.1", 15 | "livekit-server-sdk": "^2.7.0", 16 | "vite": "^3.2.7", 17 | "vite-plugin-mix": "^0.4.0" 18 | }, 19 | "devDependencies": { 20 | "@types/cors": "^2.8.17", 21 | "@types/express": "^5.0.0", 22 | "concurrently": "^8.2.0", 23 | "tsx": "^4.7.0", 24 | "typescript": "^5.4.5" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /examples/rpc/styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 3 | 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; 4 | background-color: #f0f2f5; 5 | color: #333; 6 | line-height: 1.6; 7 | margin: 0; 8 | padding: 0; 9 | } 10 | 11 | .container { 12 | max-width: 800px; 13 | margin: 40px auto; 14 | padding: 30px; 15 | background-color: #ffffff; 16 | box-shadow: 0 0 20px rgba(0, 0, 0, 0.1); 17 | border-radius: 12px; 18 | } 19 | 20 | h1 { 21 | text-align: center; 22 | color: #2c3e50; 23 | margin-bottom: 30px; 24 | font-weight: 600; 25 | } 26 | 27 | #log-area { 28 | margin-top: 20px; 29 | margin-bottom: 20px; 30 | } 31 | 32 | #log { 33 | box-sizing: border-box; 34 | width: 100%; 35 | height: 300px; 36 | padding: 10px; 37 | border: 1px solid #ddd; 38 | border-radius: 4px; 39 | font-family: monospace; 40 | font-size: 14px; 41 | resize: vertical; 42 | } 43 | 44 | .btn { 45 | display: block; 46 | width: 200px; 47 | padding: 10px 20px; 48 | background-color: #3498db; 49 | color: white; 50 | border: none; 51 | border-radius: 5px; 52 | font-size: 16px; 53 | cursor: pointer; 54 | transition: 55 | background-color 0.3s, 56 | transform 0.1s; 57 | margin: 0 auto; 58 | font-weight: 500; 59 | } 60 | 61 | .btn:hover { 62 | background-color: #2980b9; 63 | transform: translateY(-2px); 64 | } 65 | 66 | .btn:active { 67 | transform: translateY(0); 68 | } 69 | 70 | .btn:disabled { 71 | background-color: #bdc3c7; 72 | color: #7f8c8d; 73 | cursor: not-allowed; 74 | transform: none; 75 | } 76 | 77 | .btn:disabled:hover { 78 | background-color: #bdc3c7; 79 | transform: none; 80 | } 81 | -------------------------------------------------------------------------------- /examples/rpc/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "target": "ESNext" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, 5 | "module": "ESNext" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, 6 | "outDir": "build", 7 | "declaration": true, 8 | "declarationMap": true, 9 | "sourceMap": true, 10 | "strict": true /* Enable all strict type-checking options. */, 11 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 12 | "skipLibCheck": true /* Skip type checking of declaration files. */, 13 | "noUnusedLocals": true, 14 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */, 15 | "moduleResolution": "node", 16 | "resolveJsonModule": true 17 | }, 18 | "include": ["../../src/**/*", "rpc-demo.ts", "api.ts"], 19 | "exclude": ["**/*.test.ts", "build/**/*"] 20 | } 21 | -------------------------------------------------------------------------------- /examples/rpc/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import mix from 'vite-plugin-mix'; 3 | 4 | export default defineConfig({ 5 | plugins: [ 6 | mix.default({ 7 | handler: './api.ts', 8 | }), 9 | ], 10 | }); 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "livekit-client", 3 | "version": "2.13.3", 4 | "description": "JavaScript/TypeScript client SDK for LiveKit", 5 | "main": "./dist/livekit-client.umd.js", 6 | "unpkg": "./dist/livekit-client.umd.js", 7 | "module": "./dist/livekit-client.esm.mjs", 8 | "exports": { 9 | ".": { 10 | "types": "./dist/src/index.d.ts", 11 | "import": "./dist/livekit-client.esm.mjs", 12 | "require": "./dist/livekit-client.umd.js" 13 | }, 14 | "./e2ee-worker": { 15 | "types": "./dist/src/e2ee/worker/e2ee.worker.d.ts", 16 | "import": "./dist/livekit-client.e2ee.worker.mjs", 17 | "require": "./dist/livekit-client.e2ee.worker.js" 18 | } 19 | }, 20 | "files": [ 21 | "dist", 22 | "src" 23 | ], 24 | "types": "dist/src/index.d.ts", 25 | "typesVersions": { 26 | "<4.8": { 27 | "./dist/src/index.d.ts": [ 28 | "./dist/ts4.2/src/index.d.ts" 29 | ], 30 | "./dist/src/e2ee/worker/e2ee.worker.d.ts": [ 31 | "./dist/ts4.2/dist/src/e2ee/worker/e2ee.worker.d.ts" 32 | ] 33 | } 34 | }, 35 | "repository": "git@github.com:livekit/client-sdk-js.git", 36 | "author": "LiveKit ", 37 | "license": "Apache-2.0", 38 | "scripts": { 39 | "build": "rollup --config --bundleConfigAsCjs && rollup --config rollup.config.worker.js --bundleConfigAsCjs && pnpm downlevel-dts", 40 | "build:watch": "rollup --watch --config --bundleConfigAsCjs", 41 | "build:worker:watch": "rollup --watch --config rollup.config.worker.js --bundleConfigAsCjs", 42 | "build-docs": "typedoc && mkdir -p docs/assets/github && cp .github/*.png docs/assets/github/ && find docs -name '*.html' -type f -exec sed -i.bak 's|=\"/.github/|=\"assets/github/|g' {} + && find docs -name '*.bak' -delete", 43 | "proto": "protoc --es_out src/proto --es_opt target=ts -I./protocol ./protocol/livekit_rtc.proto ./protocol/livekit_models.proto", 44 | "examples:demo": "vite examples/demo -c vite.config.mjs", 45 | "dev": "pnpm examples:demo", 46 | "lint": "eslint src", 47 | "test": "vitest run src", 48 | "deploy": "gh-pages -d examples/demo/dist", 49 | "format": "prettier --write src examples/**/*.ts", 50 | "format:check": "prettier --check src examples/**/*.ts", 51 | "ci:publish": "pnpm build && pnpm compat && changeset publish", 52 | "downlevel-dts": "downlevel-dts ./dist/ ./dist/ts4.2 --to=4.2", 53 | "compat": "eslint --no-eslintrc --config ./.eslintrc.dist.cjs ./dist/livekit-client.umd.js", 54 | "size-limit": "size-limit" 55 | }, 56 | "dependencies": { 57 | "@livekit/mutex": "1.1.1", 58 | "@livekit/protocol": "1.38.0", 59 | "events": "^3.3.0", 60 | "loglevel": "^1.9.2", 61 | "sdp-transform": "^2.15.0", 62 | "ts-debounce": "^4.0.0", 63 | "tslib": "2.8.1", 64 | "typed-emitter": "^2.1.0", 65 | "webrtc-adapter": "^9.0.1" 66 | }, 67 | "peerDependencies": { 68 | "@types/dom-mediacapture-record": "^1" 69 | }, 70 | "devDependencies": { 71 | "@babel/core": "7.27.1", 72 | "@babel/preset-env": "7.27.2", 73 | "@bufbuild/protoc-gen-es": "^1.10.0", 74 | "@changesets/cli": "2.29.4", 75 | "@livekit/changesets-changelog-github": "^0.0.4", 76 | "@rollup/plugin-babel": "6.0.4", 77 | "@rollup/plugin-commonjs": "28.0.3", 78 | "@rollup/plugin-json": "6.1.0", 79 | "@rollup/plugin-node-resolve": "16.0.1", 80 | "@rollup/plugin-terser": "^0.4.4", 81 | "@size-limit/file": "^11.2.0", 82 | "@size-limit/webpack": "^11.2.0", 83 | "@trivago/prettier-plugin-sort-imports": "^5.0.0", 84 | "@types/events": "^3.0.3", 85 | "@types/sdp-transform": "2.4.9", 86 | "@types/ua-parser-js": "0.7.39", 87 | "@typescript-eslint/eslint-plugin": "7.18.0", 88 | "@typescript-eslint/parser": "7.18.0", 89 | "downlevel-dts": "^0.11.0", 90 | "eslint": "8.57.1", 91 | "eslint-config-airbnb-typescript": "18.0.0", 92 | "eslint-config-prettier": "9.1.0", 93 | "eslint-plugin-ecmascript-compat": "^3.2.1", 94 | "eslint-plugin-import": "2.31.0", 95 | "gh-pages": "6.3.0", 96 | "happy-dom": "^17.2.0", 97 | "jsdom": "^26.1.0", 98 | "prettier": "^3.4.2", 99 | "rollup": "4.41.0", 100 | "rollup-plugin-delete": "^2.1.0", 101 | "rollup-plugin-typescript2": "0.36.0", 102 | "size-limit": "^11.2.0", 103 | "typedoc": "0.28.4", 104 | "typedoc-plugin-no-inherit": "1.6.1", 105 | "typescript": "5.8.3", 106 | "vite": "5.4.19", 107 | "vitest": "^1.6.0" 108 | }, 109 | "packageManager": "pnpm@9.15.9" 110 | } 111 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["config:base"], 4 | "schedule": "before 6am on the first day of the month", 5 | "packageRules": [ 6 | { 7 | "matchDepTypes": ["devDependencies"], 8 | "matchUpdateTypes": ["patch", "minor"], 9 | "groupName": "devDependencies (non-major)" 10 | }, 11 | { 12 | "matchSourceUrlPrefixes": ["https://github.com/livekit/"], 13 | "rangeStrategy": "replace", 14 | "groupName": "LiveKit dependencies", 15 | "automerge": true 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { babel } from '@rollup/plugin-babel'; 3 | import commonjs from '@rollup/plugin-commonjs'; 4 | import json from '@rollup/plugin-json'; 5 | import { nodeResolve } from '@rollup/plugin-node-resolve'; 6 | import terser from '@rollup/plugin-terser'; 7 | import del from 'rollup-plugin-delete'; 8 | import typescript from 'rollup-plugin-typescript2'; 9 | import packageJson from './package.json'; 10 | 11 | export function kebabCaseToPascalCase(string = '') { 12 | return string.replace(/(^\w|-\w)/g, (replaceString) => 13 | replaceString.replace(/-/, '').toUpperCase(), 14 | ); 15 | } 16 | 17 | /** 18 | * @type {import('rollup').InputPluginOption} 19 | */ 20 | export const commonPlugins = [ 21 | nodeResolve({ browser: true, preferBuiltins: false }), 22 | commonjs(), 23 | json(), 24 | babel({ 25 | babelHelpers: 'bundled', 26 | plugins: ['@babel/plugin-transform-object-rest-spread'], 27 | presets: ['@babel/preset-env'], 28 | extensions: ['.js', '.ts', '.mjs'], 29 | babelrc: false, 30 | }), 31 | ]; 32 | 33 | /** 34 | * @type {import('rollup').RollupOptions} 35 | */ 36 | export default { 37 | input: 'src/index.ts', 38 | output: [ 39 | { 40 | file: `dist/${packageJson.name}.esm.mjs`, 41 | format: 'es', 42 | strict: true, 43 | sourcemap: true, 44 | }, 45 | { 46 | file: `dist/${packageJson.name}.umd.js`, 47 | format: 'umd', 48 | strict: true, 49 | sourcemap: true, 50 | name: kebabCaseToPascalCase(packageJson.name), 51 | plugins: [terser()], 52 | }, 53 | ], 54 | plugins: [ 55 | del({ targets: 'dist/*' }), 56 | typescript({ tsconfig: './tsconfig.json' }), 57 | ...commonPlugins, 58 | ], 59 | }; 60 | -------------------------------------------------------------------------------- /rollup.config.worker.js: -------------------------------------------------------------------------------- 1 | import terser from '@rollup/plugin-terser'; 2 | import typescript from 'rollup-plugin-typescript2'; 3 | import packageJson from './package.json'; 4 | import { commonPlugins, kebabCaseToPascalCase } from './rollup.config'; 5 | 6 | export default { 7 | input: 'src/e2ee/worker/e2ee.worker.ts', 8 | output: [ 9 | { 10 | file: `dist/${packageJson.name}.e2ee.worker.mjs`, 11 | format: 'es', 12 | strict: true, 13 | sourcemap: true, 14 | }, 15 | { 16 | file: `dist/${packageJson.name}.e2ee.worker.js`, 17 | format: 'umd', 18 | strict: true, 19 | sourcemap: true, 20 | name: kebabCaseToPascalCase(packageJson.name) + '.e2ee.worker', 21 | plugins: [terser()], 22 | }, 23 | ], 24 | plugins: [typescript({ tsconfig: './src/e2ee/worker/tsconfig.json' }), ...commonPlugins], 25 | }; 26 | -------------------------------------------------------------------------------- /src/api/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { createRtcUrl, createValidateUrl } from './utils'; 3 | 4 | describe('createRtcUrl', () => { 5 | it('should create a basic RTC URL', () => { 6 | const url = 'wss://example.com'; 7 | const searchParams = new URLSearchParams(); 8 | const result = createRtcUrl(url, searchParams); 9 | expect(result.toString()).toBe('wss://example.com/rtc'); 10 | }); 11 | 12 | it('should create a basic RTC URL with http protocol', () => { 13 | const url = 'http://example.com'; 14 | const searchParams = new URLSearchParams(); 15 | const result = createRtcUrl(url, searchParams); 16 | expect(result.toString()).toBe('ws://example.com/rtc'); 17 | }); 18 | 19 | it('should handle search parameters', () => { 20 | const url = 'wss://example.com'; 21 | const searchParams = new URLSearchParams({ 22 | token: 'test-token', 23 | room: 'test-room', 24 | }); 25 | const result = createRtcUrl(url, searchParams); 26 | 27 | const parsedResult = new URL(result); 28 | expect(parsedResult.pathname).toBe('/rtc'); 29 | expect(parsedResult.searchParams.get('token')).toBe('test-token'); 30 | expect(parsedResult.searchParams.get('room')).toBe('test-room'); 31 | }); 32 | 33 | it('should handle ws protocol', () => { 34 | const url = 'ws://example.com'; 35 | const searchParams = new URLSearchParams(); 36 | const result = createRtcUrl(url, searchParams); 37 | 38 | const parsedResult = new URL(result); 39 | expect(parsedResult.pathname).toBe('/rtc'); 40 | }); 41 | 42 | it('should handle sub paths', () => { 43 | const url = 'wss://example.com/sub/path'; 44 | const searchParams = new URLSearchParams(); 45 | const result = createRtcUrl(url, searchParams); 46 | 47 | const parsedResult = new URL(result); 48 | expect(parsedResult.pathname).toBe('/sub/path/rtc'); 49 | }); 50 | 51 | it('should handle sub paths with trailing slashes', () => { 52 | const url = 'wss://example.com/sub/path/'; 53 | const searchParams = new URLSearchParams(); 54 | const result = createRtcUrl(url, searchParams); 55 | 56 | const parsedResult = new URL(result); 57 | expect(parsedResult.pathname).toBe('/sub/path/rtc'); 58 | }); 59 | 60 | it('should handle sub paths with url params', () => { 61 | const url = 'wss://example.com/sub/path?param=value'; 62 | const searchParams = new URLSearchParams(); 63 | searchParams.set('token', 'test-token'); 64 | const result = createRtcUrl(url, searchParams); 65 | 66 | const parsedResult = new URL(result); 67 | expect(parsedResult.pathname).toBe('/sub/path/rtc'); 68 | expect(parsedResult.searchParams.get('param')).toBe('value'); 69 | expect(parsedResult.searchParams.get('token')).toBe('test-token'); 70 | }); 71 | }); 72 | 73 | describe('createValidateUrl', () => { 74 | it('should create a basic validate URL', () => { 75 | const rtcUrl = createRtcUrl('wss://example.com', new URLSearchParams()); 76 | const result = createValidateUrl(rtcUrl); 77 | expect(result.toString()).toBe('https://example.com/rtc/validate'); 78 | }); 79 | 80 | it('should handle search parameters', () => { 81 | const rtcUrl = createRtcUrl( 82 | 'wss://example.com', 83 | new URLSearchParams({ 84 | token: 'test-token', 85 | room: 'test-room', 86 | }), 87 | ); 88 | const result = createValidateUrl(rtcUrl); 89 | 90 | const parsedResult = new URL(result); 91 | expect(parsedResult.pathname).toBe('/rtc/validate'); 92 | expect(parsedResult.searchParams.get('token')).toBe('test-token'); 93 | expect(parsedResult.searchParams.get('room')).toBe('test-room'); 94 | }); 95 | 96 | it('should handle ws protocol', () => { 97 | const rtcUrl = createRtcUrl('ws://example.com', new URLSearchParams()); 98 | const result = createValidateUrl(rtcUrl); 99 | 100 | const parsedResult = new URL(result); 101 | expect(parsedResult.pathname).toBe('/rtc/validate'); 102 | }); 103 | 104 | it('should preserve the original path', () => { 105 | const rtcUrl = createRtcUrl('wss://example.com/some/path', new URLSearchParams()); 106 | const result = createValidateUrl(rtcUrl); 107 | 108 | const parsedResult = new URL(result); 109 | expect(parsedResult.pathname).toBe('/some/path/rtc/validate'); 110 | }); 111 | 112 | it('should handle sub paths with trailing slashes', () => { 113 | const rtcUrl = createRtcUrl('wss://example.com/sub/path/', new URLSearchParams()); 114 | const result = createValidateUrl(rtcUrl); 115 | 116 | const parsedResult = new URL(result); 117 | expect(parsedResult.pathname).toBe('/sub/path/rtc/validate'); 118 | }); 119 | }); 120 | -------------------------------------------------------------------------------- /src/api/utils.ts: -------------------------------------------------------------------------------- 1 | import { toHttpUrl, toWebsocketUrl } from '../room/utils'; 2 | 3 | export function createRtcUrl(url: string, searchParams: URLSearchParams) { 4 | const urlObj = new URL(toWebsocketUrl(url)); 5 | searchParams.forEach((value, key) => { 6 | urlObj.searchParams.set(key, value); 7 | }); 8 | return appendUrlPath(urlObj, 'rtc'); 9 | } 10 | 11 | export function createValidateUrl(rtcWsUrl: string) { 12 | const urlObj = new URL(toHttpUrl(rtcWsUrl)); 13 | return appendUrlPath(urlObj, 'validate'); 14 | } 15 | 16 | function ensureTrailingSlash(path: string) { 17 | return path.endsWith('/') ? path : `${path}/`; 18 | } 19 | 20 | function appendUrlPath(urlObj: URL, path: string) { 21 | urlObj.pathname = `${ensureTrailingSlash(urlObj.pathname)}${path}`; 22 | return urlObj.toString(); 23 | } 24 | -------------------------------------------------------------------------------- /src/connectionHelper/ConnectionCheck.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | import type TypedEmitter from 'typed-emitter'; 3 | import type { CheckInfo, CheckerOptions, InstantiableCheck } from './checks/Checker'; 4 | import { CheckStatus, Checker } from './checks/Checker'; 5 | import { CloudRegionCheck } from './checks/cloudRegion'; 6 | import { ConnectionProtocolCheck, type ProtocolStats } from './checks/connectionProtocol'; 7 | import { PublishAudioCheck } from './checks/publishAudio'; 8 | import { PublishVideoCheck } from './checks/publishVideo'; 9 | import { ReconnectCheck } from './checks/reconnect'; 10 | import { TURNCheck } from './checks/turn'; 11 | import { WebRTCCheck } from './checks/webrtc'; 12 | import { WebSocketCheck } from './checks/websocket'; 13 | 14 | export type { CheckInfo, CheckStatus }; 15 | 16 | export class ConnectionCheck extends (EventEmitter as new () => TypedEmitter) { 17 | token: string; 18 | 19 | url: string; 20 | 21 | options: CheckerOptions = {}; 22 | 23 | private checkResults: Map = new Map(); 24 | 25 | constructor(url: string, token: string, options: CheckerOptions = {}) { 26 | super(); 27 | this.url = url; 28 | this.token = token; 29 | this.options = options; 30 | } 31 | 32 | private getNextCheckId() { 33 | const nextId = this.checkResults.size; 34 | this.checkResults.set(nextId, { 35 | logs: [], 36 | status: CheckStatus.IDLE, 37 | name: '', 38 | description: '', 39 | }); 40 | return nextId; 41 | } 42 | 43 | private updateCheck(checkId: number, info: CheckInfo) { 44 | this.checkResults.set(checkId, info); 45 | this.emit('checkUpdate', checkId, info); 46 | } 47 | 48 | isSuccess() { 49 | return Array.from(this.checkResults.values()).every((r) => r.status !== CheckStatus.FAILED); 50 | } 51 | 52 | getResults() { 53 | return Array.from(this.checkResults.values()); 54 | } 55 | 56 | async createAndRunCheck(check: InstantiableCheck) { 57 | const checkId = this.getNextCheckId(); 58 | const test = new check(this.url, this.token, this.options); 59 | const handleUpdate = (info: CheckInfo) => { 60 | this.updateCheck(checkId, info); 61 | }; 62 | test.on('update', handleUpdate); 63 | const result = await test.run(); 64 | test.off('update', handleUpdate); 65 | return result; 66 | } 67 | 68 | async checkWebsocket() { 69 | return this.createAndRunCheck(WebSocketCheck); 70 | } 71 | 72 | async checkWebRTC() { 73 | return this.createAndRunCheck(WebRTCCheck); 74 | } 75 | 76 | async checkTURN() { 77 | return this.createAndRunCheck(TURNCheck); 78 | } 79 | 80 | async checkReconnect() { 81 | return this.createAndRunCheck(ReconnectCheck); 82 | } 83 | 84 | async checkPublishAudio() { 85 | return this.createAndRunCheck(PublishAudioCheck); 86 | } 87 | 88 | async checkPublishVideo() { 89 | return this.createAndRunCheck(PublishVideoCheck); 90 | } 91 | 92 | async checkConnectionProtocol() { 93 | const info = await this.createAndRunCheck(ConnectionProtocolCheck); 94 | if (info.data && 'protocol' in info.data) { 95 | const stats = info.data as ProtocolStats; 96 | this.options.protocol = stats.protocol; 97 | } 98 | return info; 99 | } 100 | 101 | async checkCloudRegion() { 102 | return this.createAndRunCheck(CloudRegionCheck); 103 | } 104 | } 105 | 106 | type ConnectionCheckCallbacks = { 107 | checkUpdate: (id: number, info: CheckInfo) => void; 108 | }; 109 | -------------------------------------------------------------------------------- /src/connectionHelper/checks/Checker.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | import type TypedEmitter from 'typed-emitter'; 3 | import type { RoomConnectOptions, RoomOptions } from '../../options'; 4 | import type RTCEngine from '../../room/RTCEngine'; 5 | import Room, { ConnectionState } from '../../room/Room'; 6 | import { RoomEvent } from '../../room/events'; 7 | import type { SimulationScenario } from '../../room/types'; 8 | import { sleep } from '../../room/utils'; 9 | 10 | type LogMessage = { 11 | level: 'info' | 'warning' | 'error'; 12 | message: string; 13 | }; 14 | 15 | export enum CheckStatus { 16 | IDLE, 17 | RUNNING, 18 | SKIPPED, 19 | SUCCESS, 20 | FAILED, 21 | } 22 | 23 | export type CheckInfo = { 24 | name: string; 25 | logs: Array; 26 | status: CheckStatus; 27 | description: string; 28 | data?: any; 29 | }; 30 | 31 | export interface CheckerOptions { 32 | errorsAsWarnings?: boolean; 33 | roomOptions?: RoomOptions; 34 | connectOptions?: RoomConnectOptions; 35 | protocol?: 'udp' | 'tcp'; 36 | } 37 | 38 | export abstract class Checker extends (EventEmitter as new () => TypedEmitter) { 39 | protected url: string; 40 | 41 | protected token: string; 42 | 43 | room: Room; 44 | 45 | connectOptions?: RoomConnectOptions; 46 | 47 | status: CheckStatus = CheckStatus.IDLE; 48 | 49 | logs: Array = []; 50 | 51 | name: string; 52 | 53 | options: CheckerOptions = {}; 54 | 55 | constructor(url: string, token: string, options: CheckerOptions = {}) { 56 | super(); 57 | this.url = url; 58 | this.token = token; 59 | this.name = this.constructor.name; 60 | this.room = new Room(options.roomOptions); 61 | this.connectOptions = options.connectOptions; 62 | this.options = options; 63 | } 64 | 65 | abstract get description(): string; 66 | 67 | protected abstract perform(): Promise; 68 | 69 | async run(onComplete?: () => void) { 70 | if (this.status !== CheckStatus.IDLE) { 71 | throw Error('check is running already'); 72 | } 73 | this.setStatus(CheckStatus.RUNNING); 74 | 75 | try { 76 | await this.perform(); 77 | } catch (err) { 78 | if (err instanceof Error) { 79 | if (this.options.errorsAsWarnings) { 80 | this.appendWarning(err.message); 81 | } else { 82 | this.appendError(err.message); 83 | } 84 | } 85 | } 86 | 87 | await this.disconnect(); 88 | 89 | // sleep for a bit to ensure disconnect 90 | await new Promise((resolve) => setTimeout(resolve, 500)); 91 | 92 | // @ts-ignore 93 | if (this.status !== CheckStatus.SKIPPED) { 94 | this.setStatus(this.isSuccess() ? CheckStatus.SUCCESS : CheckStatus.FAILED); 95 | } 96 | 97 | if (onComplete) { 98 | onComplete(); 99 | } 100 | return this.getInfo(); 101 | } 102 | 103 | protected isSuccess(): boolean { 104 | return !this.logs.some((l) => l.level === 'error'); 105 | } 106 | 107 | protected async connect(url?: string): Promise { 108 | if (this.room.state === ConnectionState.Connected) { 109 | return this.room; 110 | } 111 | if (!url) { 112 | url = this.url; 113 | } 114 | await this.room.connect(url, this.token, this.connectOptions); 115 | return this.room; 116 | } 117 | 118 | protected async disconnect() { 119 | if (this.room && this.room.state !== ConnectionState.Disconnected) { 120 | await this.room.disconnect(); 121 | // wait for it to go through 122 | await new Promise((resolve) => setTimeout(resolve, 500)); 123 | } 124 | } 125 | 126 | protected skip() { 127 | this.setStatus(CheckStatus.SKIPPED); 128 | } 129 | 130 | protected async switchProtocol(protocol: 'udp' | 'tcp' | 'tls') { 131 | let hasReconnecting = false; 132 | let hasReconnected = false; 133 | this.room.on(RoomEvent.Reconnecting, () => { 134 | hasReconnecting = true; 135 | }); 136 | this.room.once(RoomEvent.Reconnected, () => { 137 | hasReconnected = true; 138 | }); 139 | this.room.simulateScenario(`force-${protocol}` as SimulationScenario); 140 | await new Promise((resolve) => setTimeout(resolve, 1000)); 141 | if (!hasReconnecting) { 142 | // no need to wait for reconnection 143 | return; 144 | } 145 | 146 | // wait for 10 seconds for reconnection 147 | const timeout = Date.now() + 10000; 148 | while (Date.now() < timeout) { 149 | if (hasReconnected) { 150 | return; 151 | } 152 | await sleep(100); 153 | } 154 | throw new Error(`Could not reconnect using ${protocol} protocol after 10 seconds`); 155 | } 156 | 157 | protected appendMessage(message: string) { 158 | this.logs.push({ level: 'info', message }); 159 | this.emit('update', this.getInfo()); 160 | } 161 | 162 | protected appendWarning(message: string) { 163 | this.logs.push({ level: 'warning', message }); 164 | this.emit('update', this.getInfo()); 165 | } 166 | 167 | protected appendError(message: string) { 168 | this.logs.push({ level: 'error', message }); 169 | this.emit('update', this.getInfo()); 170 | } 171 | 172 | protected setStatus(status: CheckStatus) { 173 | this.status = status; 174 | this.emit('update', this.getInfo()); 175 | } 176 | 177 | protected get engine(): RTCEngine | undefined { 178 | return this.room?.engine; 179 | } 180 | 181 | getInfo(): CheckInfo { 182 | return { 183 | logs: this.logs, 184 | name: this.name, 185 | status: this.status, 186 | description: this.description, 187 | }; 188 | } 189 | } 190 | export type InstantiableCheck = { 191 | new (url: string, token: string, options?: CheckerOptions): T; 192 | }; 193 | 194 | type CheckerCallbacks = { 195 | update: (info: CheckInfo) => void; 196 | }; 197 | -------------------------------------------------------------------------------- /src/connectionHelper/checks/cloudRegion.ts: -------------------------------------------------------------------------------- 1 | import { RegionUrlProvider } from '../../room/RegionUrlProvider'; 2 | import { type CheckInfo, Checker } from './Checker'; 3 | 4 | export interface RegionStats { 5 | region: string; 6 | rtt: number; 7 | duration: number; 8 | } 9 | 10 | /** 11 | * Checks for connections quality to closests Cloud regions and determining the best quality 12 | */ 13 | export class CloudRegionCheck extends Checker { 14 | private bestStats?: RegionStats; 15 | 16 | get description(): string { 17 | return 'Cloud regions'; 18 | } 19 | 20 | async perform(): Promise { 21 | const regionProvider = new RegionUrlProvider(this.url, this.token); 22 | if (!regionProvider.isCloud()) { 23 | this.skip(); 24 | return; 25 | } 26 | 27 | const regionStats: RegionStats[] = []; 28 | const seenUrls: Set = new Set(); 29 | for (let i = 0; i < 3; i++) { 30 | const regionUrl = await regionProvider.getNextBestRegionUrl(); 31 | if (!regionUrl) { 32 | break; 33 | } 34 | if (seenUrls.has(regionUrl)) { 35 | continue; 36 | } 37 | seenUrls.add(regionUrl); 38 | const stats = await this.checkCloudRegion(regionUrl); 39 | this.appendMessage(`${stats.region} RTT: ${stats.rtt}ms, duration: ${stats.duration}ms`); 40 | regionStats.push(stats); 41 | } 42 | 43 | regionStats.sort((a, b) => { 44 | return (a.duration - b.duration) * 0.5 + (a.rtt - b.rtt) * 0.5; 45 | }); 46 | const bestRegion = regionStats[0]; 47 | this.bestStats = bestRegion; 48 | this.appendMessage(`best Cloud region: ${bestRegion.region}`); 49 | } 50 | 51 | getInfo(): CheckInfo { 52 | const info = super.getInfo(); 53 | info.data = this.bestStats; 54 | return info; 55 | } 56 | 57 | private async checkCloudRegion(url: string): Promise { 58 | await this.connect(url); 59 | if (this.options.protocol === 'tcp') { 60 | await this.switchProtocol('tcp'); 61 | } 62 | const region = this.room.serverInfo?.region; 63 | if (!region) { 64 | throw new Error('Region not found'); 65 | } 66 | 67 | const writer = await this.room.localParticipant.streamText({ topic: 'test' }); 68 | const chunkSize = 1000; // each chunk is about 1000 bytes 69 | const totalSize = 1_000_000; // approximately 1MB of data 70 | const numChunks = totalSize / chunkSize; // will yield 1000 chunks 71 | const chunkData = 'A'.repeat(chunkSize); // create a string of 1000 'A' characters 72 | 73 | const startTime = Date.now(); 74 | for (let i = 0; i < numChunks; i++) { 75 | await writer.write(chunkData); 76 | } 77 | await writer.close(); 78 | const endTime = Date.now(); 79 | const stats = await this.room.engine.pcManager?.publisher.getStats(); 80 | const regionStats: RegionStats = { 81 | region: region, 82 | rtt: 10000, 83 | duration: endTime - startTime, 84 | }; 85 | stats?.forEach((stat) => { 86 | if (stat.type === 'candidate-pair' && stat.nominated) { 87 | regionStats.rtt = stat.currentRoundTripTime * 1000; 88 | } 89 | }); 90 | 91 | await this.disconnect(); 92 | return regionStats; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/connectionHelper/checks/connectionProtocol.ts: -------------------------------------------------------------------------------- 1 | import { type CheckInfo, Checker } from './Checker'; 2 | 3 | export interface ProtocolStats { 4 | protocol: 'udp' | 'tcp'; 5 | packetsLost: number; 6 | packetsSent: number; 7 | qualityLimitationDurations: Record; 8 | // total metrics measure sum of all measurements, along with a count 9 | rttTotal: number; 10 | jitterTotal: number; 11 | bitrateTotal: number; 12 | count: number; 13 | } 14 | 15 | const TEST_DURATION = 10000; 16 | 17 | export class ConnectionProtocolCheck extends Checker { 18 | private bestStats?: ProtocolStats; 19 | 20 | get description(): string { 21 | return 'Connection via UDP vs TCP'; 22 | } 23 | 24 | async perform(): Promise { 25 | const udpStats = await this.checkConnectionProtocol('udp'); 26 | const tcpStats = await this.checkConnectionProtocol('tcp'); 27 | this.bestStats = udpStats; 28 | // udp should is the better protocol typically. however, we'd prefer TCP when either of these conditions are true: 29 | // 1. the bandwidth limitation is worse on UDP by 500ms 30 | // 2. the packet loss is higher on UDP by 1% 31 | if ( 32 | udpStats.qualityLimitationDurations.bandwidth - 33 | tcpStats.qualityLimitationDurations.bandwidth > 34 | 0.5 || 35 | (udpStats.packetsLost - tcpStats.packetsLost) / udpStats.packetsSent > 0.01 36 | ) { 37 | this.appendMessage('best connection quality via tcp'); 38 | this.bestStats = tcpStats; 39 | } else { 40 | this.appendMessage('best connection quality via udp'); 41 | } 42 | 43 | const stats = this.bestStats; 44 | this.appendMessage( 45 | `upstream bitrate: ${(stats.bitrateTotal / stats.count / 1000 / 1000).toFixed(2)} mbps`, 46 | ); 47 | this.appendMessage(`RTT: ${((stats.rttTotal / stats.count) * 1000).toFixed(2)} ms`); 48 | this.appendMessage(`jitter: ${((stats.jitterTotal / stats.count) * 1000).toFixed(2)} ms`); 49 | 50 | if (stats.packetsLost > 0) { 51 | this.appendWarning( 52 | `packets lost: ${((stats.packetsLost / stats.packetsSent) * 100).toFixed(2)}%`, 53 | ); 54 | } 55 | if (stats.qualityLimitationDurations.bandwidth > 1) { 56 | this.appendWarning( 57 | `bandwidth limited ${((stats.qualityLimitationDurations.bandwidth / (TEST_DURATION / 1000)) * 100).toFixed(2)}%`, 58 | ); 59 | } 60 | if (stats.qualityLimitationDurations.cpu > 0) { 61 | this.appendWarning( 62 | `cpu limited ${((stats.qualityLimitationDurations.cpu / (TEST_DURATION / 1000)) * 100).toFixed(2)}%`, 63 | ); 64 | } 65 | } 66 | 67 | getInfo(): CheckInfo { 68 | const info = super.getInfo(); 69 | info.data = this.bestStats; 70 | return info; 71 | } 72 | 73 | private async checkConnectionProtocol(protocol: 'tcp' | 'udp'): Promise { 74 | await this.connect(); 75 | if (protocol === 'tcp') { 76 | await this.switchProtocol('tcp'); 77 | } else { 78 | await this.switchProtocol('udp'); 79 | } 80 | 81 | // create a canvas with animated content 82 | const canvas = document.createElement('canvas'); 83 | canvas.width = 1280; 84 | canvas.height = 720; 85 | const ctx = canvas.getContext('2d'); 86 | if (!ctx) { 87 | throw new Error('Could not get canvas context'); 88 | } 89 | 90 | let hue = 0; 91 | const animate = () => { 92 | hue = (hue + 1) % 360; 93 | ctx.fillStyle = `hsl(${hue}, 100%, 50%)`; 94 | ctx.fillRect(0, 0, canvas.width, canvas.height); 95 | requestAnimationFrame(animate); 96 | }; 97 | animate(); 98 | 99 | // create video track from canvas 100 | const stream = canvas.captureStream(30); // 30fps 101 | const videoTrack = stream.getVideoTracks()[0]; 102 | 103 | // publish to room 104 | const pub = await this.room.localParticipant.publishTrack(videoTrack, { 105 | simulcast: false, 106 | degradationPreference: 'maintain-resolution', 107 | videoEncoding: { 108 | maxBitrate: 2000000, 109 | }, 110 | }); 111 | const track = pub!.track!; 112 | 113 | const protocolStats: ProtocolStats = { 114 | protocol, 115 | packetsLost: 0, 116 | packetsSent: 0, 117 | qualityLimitationDurations: {}, 118 | rttTotal: 0, 119 | jitterTotal: 0, 120 | bitrateTotal: 0, 121 | count: 0, 122 | }; 123 | // gather stats once a second 124 | const interval = setInterval(async () => { 125 | const stats = await track.getRTCStatsReport(); 126 | stats?.forEach((stat) => { 127 | if (stat.type === 'outbound-rtp') { 128 | protocolStats.packetsSent = stat.packetsSent; 129 | protocolStats.qualityLimitationDurations = stat.qualityLimitationDurations; 130 | protocolStats.bitrateTotal += stat.targetBitrate; 131 | protocolStats.count++; 132 | } else if (stat.type === 'remote-inbound-rtp') { 133 | protocolStats.packetsLost = stat.packetsLost; 134 | protocolStats.rttTotal += stat.roundTripTime; 135 | protocolStats.jitterTotal += stat.jitter; 136 | } 137 | }); 138 | }, 1000); 139 | 140 | // wait a bit to gather stats 141 | await new Promise((resolve) => setTimeout(resolve, TEST_DURATION)); 142 | clearInterval(interval); 143 | 144 | videoTrack.stop(); 145 | canvas.remove(); 146 | await this.disconnect(); 147 | return protocolStats; 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/connectionHelper/checks/publishAudio.ts: -------------------------------------------------------------------------------- 1 | import { createLocalAudioTrack } from '../../room/track/create'; 2 | import { detectSilence } from '../../room/track/utils'; 3 | import { Checker } from './Checker'; 4 | 5 | export class PublishAudioCheck extends Checker { 6 | get description(): string { 7 | return 'Can publish audio'; 8 | } 9 | 10 | async perform(): Promise { 11 | const room = await this.connect(); 12 | 13 | const track = await createLocalAudioTrack(); 14 | 15 | const trackIsSilent = await detectSilence(track, 1000); 16 | if (trackIsSilent) { 17 | throw new Error('unable to detect audio from microphone'); 18 | } 19 | this.appendMessage('detected audio from microphone'); 20 | 21 | room.localParticipant.publishTrack(track); 22 | // wait for a few seconds to publish 23 | await new Promise((resolve) => setTimeout(resolve, 3000)); 24 | 25 | // verify RTC stats that it's publishing 26 | const stats = await track.sender?.getStats(); 27 | if (!stats) { 28 | throw new Error('Could not get RTCStats'); 29 | } 30 | let numPackets = 0; 31 | stats.forEach((stat) => { 32 | if ( 33 | stat.type === 'outbound-rtp' && 34 | (stat.kind === 'audio' || (!stat.kind && stat.mediaType === 'audio')) 35 | ) { 36 | numPackets = stat.packetsSent; 37 | } 38 | }); 39 | if (numPackets === 0) { 40 | throw new Error('Could not determine packets are sent'); 41 | } 42 | this.appendMessage(`published ${numPackets} audio packets`); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/connectionHelper/checks/publishVideo.ts: -------------------------------------------------------------------------------- 1 | import { createLocalVideoTrack } from '../../room/track/create'; 2 | import { Checker } from './Checker'; 3 | 4 | export class PublishVideoCheck extends Checker { 5 | get description(): string { 6 | return 'Can publish video'; 7 | } 8 | 9 | async perform(): Promise { 10 | const room = await this.connect(); 11 | 12 | const track = await createLocalVideoTrack(); 13 | 14 | // check if we have video from camera 15 | await this.checkForVideo(track.mediaStreamTrack); 16 | 17 | room.localParticipant.publishTrack(track); 18 | // wait for a few seconds to publish 19 | await new Promise((resolve) => setTimeout(resolve, 5000)); 20 | 21 | // verify RTC stats that it's publishing 22 | const stats = await track.sender?.getStats(); 23 | if (!stats) { 24 | throw new Error('Could not get RTCStats'); 25 | } 26 | let numPackets = 0; 27 | stats.forEach((stat) => { 28 | if ( 29 | stat.type === 'outbound-rtp' && 30 | (stat.kind === 'video' || (!stat.kind && stat.mediaType === 'video')) 31 | ) { 32 | numPackets += stat.packetsSent; 33 | } 34 | }); 35 | if (numPackets === 0) { 36 | throw new Error('Could not determine packets are sent'); 37 | } 38 | this.appendMessage(`published ${numPackets} video packets`); 39 | } 40 | 41 | async checkForVideo(track: MediaStreamTrack) { 42 | const stream = new MediaStream(); 43 | stream.addTrack(track.clone()); 44 | 45 | // Create video element to check frames 46 | const video = document.createElement('video'); 47 | video.srcObject = stream; 48 | video.muted = true; 49 | 50 | await new Promise((resolve) => { 51 | video.onplay = () => { 52 | setTimeout(() => { 53 | const canvas = document.createElement('canvas'); 54 | const settings = track.getSettings(); 55 | const width = settings.width ?? video.videoWidth ?? 1280; 56 | const height = settings.height ?? video.videoHeight ?? 720; 57 | canvas.width = width; 58 | canvas.height = height; 59 | const ctx = canvas.getContext('2d')!; 60 | 61 | // Draw video frame to canvas 62 | ctx.drawImage(video, 0, 0); 63 | 64 | // Get image data and check if all pixels are black 65 | const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); 66 | const data = imageData.data; 67 | let isAllBlack = true; 68 | for (let i = 0; i < data.length; i += 4) { 69 | if (data[i] !== 0 || data[i + 1] !== 0 || data[i + 2] !== 0) { 70 | isAllBlack = false; 71 | break; 72 | } 73 | } 74 | 75 | if (isAllBlack) { 76 | this.appendError('camera appears to be producing only black frames'); 77 | } else { 78 | this.appendMessage('received video frames'); 79 | } 80 | resolve(); 81 | }, 1000); 82 | }; 83 | video.play(); 84 | }); 85 | 86 | video.remove(); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/connectionHelper/checks/reconnect.ts: -------------------------------------------------------------------------------- 1 | import { ConnectionState } from '../../room/Room'; 2 | import { RoomEvent } from '../../room/events'; 3 | import { Checker } from './Checker'; 4 | 5 | export class ReconnectCheck extends Checker { 6 | get description(): string { 7 | return 'Resuming connection after interruption'; 8 | } 9 | 10 | async perform(): Promise { 11 | const room = await this.connect(); 12 | let reconnectingTriggered = false; 13 | let reconnected = false; 14 | 15 | let reconnectResolver: (value: unknown) => void; 16 | const reconnectTimeout = new Promise((resolve) => { 17 | setTimeout(resolve, 5000); 18 | reconnectResolver = resolve; 19 | }); 20 | 21 | const handleReconnecting = () => { 22 | reconnectingTriggered = true; 23 | }; 24 | 25 | room 26 | .on(RoomEvent.SignalReconnecting, handleReconnecting) 27 | .on(RoomEvent.Reconnecting, handleReconnecting) 28 | .on(RoomEvent.Reconnected, () => { 29 | reconnected = true; 30 | reconnectResolver(true); 31 | }); 32 | 33 | room.engine.client.ws?.close(); 34 | const onClose = room.engine.client.onClose; 35 | if (onClose) { 36 | onClose(''); 37 | } 38 | 39 | await reconnectTimeout; 40 | 41 | if (!reconnectingTriggered) { 42 | throw new Error('Did not attempt to reconnect'); 43 | } else if (!reconnected || room.state !== ConnectionState.Connected) { 44 | this.appendWarning('reconnection is only possible in Redis-based configurations'); 45 | throw new Error('Not able to reconnect'); 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/connectionHelper/checks/turn.ts: -------------------------------------------------------------------------------- 1 | import { SignalClient } from '../../api/SignalClient'; 2 | import { Checker } from './Checker'; 3 | 4 | export class TURNCheck extends Checker { 5 | get description(): string { 6 | return 'Can connect via TURN'; 7 | } 8 | 9 | async perform(): Promise { 10 | const signalClient = new SignalClient(); 11 | const joinRes = await signalClient.join(this.url, this.token, { 12 | autoSubscribe: true, 13 | maxRetries: 0, 14 | e2eeEnabled: false, 15 | websocketTimeout: 15_000, 16 | }); 17 | 18 | let hasTLS = false; 19 | let hasTURN = false; 20 | let hasSTUN = false; 21 | 22 | for (let iceServer of joinRes.iceServers) { 23 | for (let url of iceServer.urls) { 24 | if (url.startsWith('turn:')) { 25 | hasTURN = true; 26 | hasSTUN = true; 27 | } else if (url.startsWith('turns:')) { 28 | hasTURN = true; 29 | hasSTUN = true; 30 | hasTLS = true; 31 | } 32 | if (url.startsWith('stun:')) { 33 | hasSTUN = true; 34 | } 35 | } 36 | } 37 | if (!hasSTUN) { 38 | this.appendWarning('No STUN servers configured on server side.'); 39 | } else if (hasTURN && !hasTLS) { 40 | this.appendWarning('TURN is configured server side, but TURN/TLS is unavailable.'); 41 | } 42 | await signalClient.close(); 43 | if (this.connectOptions?.rtcConfig?.iceServers || hasTURN) { 44 | await this.room!.connect(this.url, this.token, { 45 | rtcConfig: { 46 | iceTransportPolicy: 'relay', 47 | }, 48 | }); 49 | } else { 50 | this.appendWarning('No TURN servers configured.'); 51 | this.skip(); 52 | await new Promise((resolve) => setTimeout(resolve, 0)); 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/connectionHelper/checks/webrtc.ts: -------------------------------------------------------------------------------- 1 | import log from '../../logger'; 2 | import { RoomEvent } from '../../room/events'; 3 | import { Checker } from './Checker'; 4 | 5 | export class WebRTCCheck extends Checker { 6 | get description(): string { 7 | return 'Establishing WebRTC connection'; 8 | } 9 | 10 | protected async perform(): Promise { 11 | let hasTcp = false; 12 | let hasIpv4Udp = false; 13 | this.room.on(RoomEvent.SignalConnected, () => { 14 | const prevTrickle = this.room.engine.client.onTrickle; 15 | 16 | const candidates: RTCIceCandidate[] = []; 17 | this.room.engine.client.onTrickle = (sd, target) => { 18 | if (sd.candidate) { 19 | const candidate = new RTCIceCandidate(sd); 20 | candidates.push(candidate); 21 | let str = `${candidate.protocol} ${candidate.address}:${candidate.port} ${candidate.type}`; 22 | if (candidate.address) { 23 | if (isIPPrivate(candidate.address)) { 24 | str += ' (private)'; 25 | } else { 26 | if (candidate.protocol === 'tcp' && candidate.tcpType === 'passive') { 27 | hasTcp = true; 28 | str += ' (passive)'; 29 | } else if (candidate.protocol === 'udp') { 30 | hasIpv4Udp = true; 31 | } 32 | } 33 | } 34 | this.appendMessage(str); 35 | } 36 | if (prevTrickle) { 37 | prevTrickle(sd, target); 38 | } 39 | }; 40 | 41 | if (this.room.engine.pcManager) { 42 | this.room.engine.pcManager.subscriber.onIceCandidateError = (ev) => { 43 | if (ev instanceof RTCPeerConnectionIceErrorEvent) { 44 | this.appendWarning( 45 | `error with ICE candidate: ${ev.errorCode} ${ev.errorText} ${ev.url}`, 46 | ); 47 | } 48 | }; 49 | } 50 | }); 51 | try { 52 | await this.connect(); 53 | log.info('now the room is connected'); 54 | } catch (err) { 55 | this.appendWarning('ports need to be open on firewall in order to connect.'); 56 | throw err; 57 | } 58 | if (!hasTcp) { 59 | this.appendWarning('Server is not configured for ICE/TCP'); 60 | } 61 | if (!hasIpv4Udp) { 62 | this.appendWarning( 63 | 'No public IPv4 UDP candidates were found. Your server is likely not configured correctly', 64 | ); 65 | } 66 | } 67 | } 68 | 69 | function isIPPrivate(address: string): boolean { 70 | const parts = address.split('.'); 71 | if (parts.length === 4) { 72 | if (parts[0] === '10') { 73 | return true; 74 | } else if (parts[0] === '192' && parts[1] === '168') { 75 | return true; 76 | } else if (parts[0] === '172') { 77 | const second = parseInt(parts[1], 10); 78 | if (second >= 16 && second <= 31) { 79 | return true; 80 | } 81 | } 82 | } 83 | return false; 84 | } 85 | -------------------------------------------------------------------------------- /src/connectionHelper/checks/websocket.ts: -------------------------------------------------------------------------------- 1 | import { ServerInfo_Edition } from '@livekit/protocol'; 2 | import { SignalClient } from '../../api/SignalClient'; 3 | import { Checker } from './Checker'; 4 | 5 | export class WebSocketCheck extends Checker { 6 | get description(): string { 7 | return 'Connecting to signal connection via WebSocket'; 8 | } 9 | 10 | protected async perform(): Promise { 11 | if (this.url.startsWith('ws:') || this.url.startsWith('http:')) { 12 | this.appendWarning('Server is insecure, clients may block connections to it'); 13 | } 14 | 15 | let signalClient = new SignalClient(); 16 | const joinRes = await signalClient.join(this.url, this.token, { 17 | autoSubscribe: true, 18 | maxRetries: 0, 19 | e2eeEnabled: false, 20 | websocketTimeout: 15_000, 21 | }); 22 | this.appendMessage(`Connected to server, version ${joinRes.serverVersion}.`); 23 | if (joinRes.serverInfo?.edition === ServerInfo_Edition.Cloud && joinRes.serverInfo?.region) { 24 | this.appendMessage(`LiveKit Cloud: ${joinRes.serverInfo?.region}`); 25 | } 26 | await signalClient.close(); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/e2ee/KeyProvider.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | import type TypedEventEmitter from 'typed-emitter'; 3 | import log from '../logger'; 4 | import { KEY_PROVIDER_DEFAULTS } from './constants'; 5 | import { type KeyProviderCallbacks, KeyProviderEvent } from './events'; 6 | import type { KeyInfo, KeyProviderOptions, RatchetResult } from './types'; 7 | import { createKeyMaterialFromBuffer, createKeyMaterialFromString } from './utils'; 8 | 9 | /** 10 | * @experimental 11 | */ 12 | export class BaseKeyProvider extends (EventEmitter as new () => TypedEventEmitter) { 13 | private keyInfoMap: Map; 14 | 15 | private readonly options: KeyProviderOptions; 16 | 17 | constructor(options: Partial = {}) { 18 | super(); 19 | this.keyInfoMap = new Map(); 20 | this.options = { ...KEY_PROVIDER_DEFAULTS, ...options }; 21 | this.on(KeyProviderEvent.KeyRatcheted, this.onKeyRatcheted); 22 | } 23 | 24 | /** 25 | * callback to invoke once a key has been set for a participant 26 | * @param key 27 | * @param participantIdentity 28 | * @param keyIndex 29 | */ 30 | protected onSetEncryptionKey(key: CryptoKey, participantIdentity?: string, keyIndex?: number) { 31 | const keyInfo: KeyInfo = { key, participantIdentity, keyIndex }; 32 | if (!this.options.sharedKey && !participantIdentity) { 33 | throw new Error( 34 | 'participant identity needs to be passed for encryption key if sharedKey option is false', 35 | ); 36 | } 37 | this.keyInfoMap.set(`${participantIdentity ?? 'shared'}-${keyIndex ?? 0}`, keyInfo); 38 | this.emit(KeyProviderEvent.SetKey, keyInfo); 39 | } 40 | 41 | /** 42 | * Callback being invoked after a key has been ratcheted. 43 | * Can happen when: 44 | * - A decryption failure occurs and the key is auto-ratcheted 45 | * - A ratchet request is sent (see {@link ratchetKey()}) 46 | * @param ratchetResult Contains the ratcheted chain key (exportable to other participants) and the derived new key material. 47 | * @param participantId 48 | * @param keyIndex 49 | */ 50 | protected onKeyRatcheted = ( 51 | ratchetResult: RatchetResult, 52 | participantId?: string, 53 | keyIndex?: number, 54 | ) => { 55 | log.debug('key ratcheted event received', { ratchetResult, participantId, keyIndex }); 56 | }; 57 | 58 | getKeys() { 59 | return Array.from(this.keyInfoMap.values()); 60 | } 61 | 62 | getOptions() { 63 | return this.options; 64 | } 65 | 66 | ratchetKey(participantIdentity?: string, keyIndex?: number) { 67 | this.emit(KeyProviderEvent.RatchetRequest, participantIdentity, keyIndex); 68 | } 69 | } 70 | 71 | /** 72 | * A basic KeyProvider implementation intended for a single shared 73 | * passphrase between all participants 74 | * @experimental 75 | */ 76 | export class ExternalE2EEKeyProvider extends BaseKeyProvider { 77 | ratchetInterval: number | undefined; 78 | 79 | constructor(options: Partial> = {}) { 80 | const opts: Partial = { 81 | ...options, 82 | sharedKey: true, 83 | // for a shared key provider failing to decrypt for a specific participant 84 | // should not mark the key as invalid, so we accept wrong keys forever 85 | // and won't try to auto-ratchet 86 | ratchetWindowSize: 0, 87 | failureTolerance: -1, 88 | }; 89 | super(opts); 90 | } 91 | 92 | /** 93 | * Accepts a passphrase that's used to create the crypto keys. 94 | * When passing in a string, PBKDF2 is used. 95 | * When passing in an Array buffer of cryptographically random numbers, HKDF is being used. (recommended) 96 | * @param key 97 | */ 98 | async setKey(key: string | ArrayBuffer) { 99 | const derivedKey = 100 | typeof key === 'string' 101 | ? await createKeyMaterialFromString(key) 102 | : await createKeyMaterialFromBuffer(key); 103 | this.onSetEncryptionKey(derivedKey); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/e2ee/constants.ts: -------------------------------------------------------------------------------- 1 | import type { KeyProviderOptions } from './types'; 2 | 3 | export const ENCRYPTION_ALGORITHM = 'AES-GCM'; 4 | 5 | // How many consecutive frames can fail decrypting before a particular key gets marked as invalid 6 | export const DECRYPTION_FAILURE_TOLERANCE = 10; 7 | 8 | // We copy the first bytes of the VP8 payload unencrypted. 9 | // For keyframes this is 10 bytes, for non-keyframes (delta) 3. See 10 | // https://tools.ietf.org/html/rfc6386#section-9.1 11 | // This allows the bridge to continue detecting keyframes (only one byte needed in the JVB) 12 | // and is also a bit easier for the VP8 decoder (i.e. it generates funny garbage pictures 13 | // instead of being unable to decode). 14 | // This is a bit for show and we might want to reduce to 1 unconditionally in the final version. 15 | // 16 | // For audio (where frame.type is not set) we do not encrypt the opus TOC byte: 17 | // https://tools.ietf.org/html/rfc6716#section-3.1 18 | export const UNENCRYPTED_BYTES = { 19 | key: 10, 20 | delta: 3, 21 | audio: 1, // frame.type is not set on audio, so this is set manually 22 | empty: 0, 23 | } as const; 24 | 25 | /* We use a 12 byte bit IV. This is signalled in plain together with the 26 | packet. See https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/encrypt#parameters */ 27 | export const IV_LENGTH = 12; 28 | 29 | // flag set to indicate that e2ee has been setup for sender/receiver; 30 | export const E2EE_FLAG = 'lk_e2ee'; 31 | 32 | export const SALT = 'LKFrameEncryptionKey'; 33 | 34 | export const KEY_PROVIDER_DEFAULTS: KeyProviderOptions = { 35 | sharedKey: false, 36 | ratchetSalt: SALT, 37 | ratchetWindowSize: 8, 38 | failureTolerance: DECRYPTION_FAILURE_TOLERANCE, 39 | keyringSize: 16, 40 | } as const; 41 | 42 | export const MAX_SIF_COUNT = 100; 43 | export const MAX_SIF_DURATION = 2000; 44 | -------------------------------------------------------------------------------- /src/e2ee/errors.ts: -------------------------------------------------------------------------------- 1 | import { LivekitError } from '../room/errors'; 2 | 3 | export enum CryptorErrorReason { 4 | InvalidKey = 0, 5 | MissingKey = 1, 6 | InternalError = 2, 7 | } 8 | 9 | export class CryptorError extends LivekitError { 10 | reason: CryptorErrorReason; 11 | 12 | participantIdentity?: string; 13 | 14 | constructor( 15 | message?: string, 16 | reason: CryptorErrorReason = CryptorErrorReason.InternalError, 17 | participantIdentity?: string, 18 | ) { 19 | super(40, message); 20 | this.reason = reason; 21 | this.participantIdentity = participantIdentity; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/e2ee/events.ts: -------------------------------------------------------------------------------- 1 | import type Participant from '../room/participant/Participant'; 2 | import type { CryptorError } from './errors'; 3 | import type { KeyInfo, RatchetResult } from './types'; 4 | 5 | export enum KeyProviderEvent { 6 | SetKey = 'setKey', 7 | /** Event for requesting to ratchet the key used to encrypt the stream */ 8 | RatchetRequest = 'ratchetRequest', 9 | /** Emitted when a key is ratcheted. Could be after auto-ratcheting on decryption failure or 10 | * following a `RatchetRequest`, will contain the ratcheted key material */ 11 | KeyRatcheted = 'keyRatcheted', 12 | } 13 | 14 | export type KeyProviderCallbacks = { 15 | [KeyProviderEvent.SetKey]: (keyInfo: KeyInfo) => void; 16 | [KeyProviderEvent.RatchetRequest]: (participantIdentity?: string, keyIndex?: number) => void; 17 | [KeyProviderEvent.KeyRatcheted]: ( 18 | ratchetedResult: RatchetResult, 19 | participantIdentity?: string, 20 | keyIndex?: number, 21 | ) => void; 22 | }; 23 | 24 | export enum KeyHandlerEvent { 25 | /** Emitted when a key has been ratcheted. Is emitted when any key has been ratcheted 26 | * i.e. when the FrameCryptor tried to ratchet when decryption is failing */ 27 | KeyRatcheted = 'keyRatcheted', 28 | } 29 | 30 | export type ParticipantKeyHandlerCallbacks = { 31 | [KeyHandlerEvent.KeyRatcheted]: ( 32 | ratchetResult: RatchetResult, 33 | participantIdentity: string, 34 | keyIndex?: number, 35 | ) => void; 36 | }; 37 | 38 | export enum EncryptionEvent { 39 | ParticipantEncryptionStatusChanged = 'participantEncryptionStatusChanged', 40 | EncryptionError = 'encryptionError', 41 | } 42 | 43 | export type E2EEManagerCallbacks = { 44 | [EncryptionEvent.ParticipantEncryptionStatusChanged]: ( 45 | enabled: boolean, 46 | participant: Participant, 47 | ) => void; 48 | [EncryptionEvent.EncryptionError]: (error: Error) => void; 49 | }; 50 | 51 | export type CryptorCallbacks = { 52 | [CryptorEvent.Error]: (error: CryptorError) => void; 53 | }; 54 | 55 | export enum CryptorEvent { 56 | Error = 'cryptorError', 57 | } 58 | -------------------------------------------------------------------------------- /src/e2ee/index.ts: -------------------------------------------------------------------------------- 1 | export * from './KeyProvider'; 2 | export * from './utils'; 3 | export * from './types'; 4 | export * from './events'; 5 | export * from './errors'; 6 | -------------------------------------------------------------------------------- /src/e2ee/types.ts: -------------------------------------------------------------------------------- 1 | import type { LogLevel } from '../logger'; 2 | import type { VideoCodec } from '../room/track/options'; 3 | import type { BaseE2EEManager } from './E2eeManager'; 4 | import type { BaseKeyProvider } from './KeyProvider'; 5 | 6 | export interface BaseMessage { 7 | kind: string; 8 | data?: unknown; 9 | } 10 | 11 | export interface InitMessage extends BaseMessage { 12 | kind: 'init'; 13 | data: { 14 | keyProviderOptions: KeyProviderOptions; 15 | loglevel: LogLevel; 16 | }; 17 | } 18 | 19 | export interface SetKeyMessage extends BaseMessage { 20 | kind: 'setKey'; 21 | data: { 22 | participantIdentity?: string; 23 | isPublisher: boolean; 24 | key: CryptoKey; 25 | keyIndex?: number; 26 | }; 27 | } 28 | 29 | export interface RTPVideoMapMessage extends BaseMessage { 30 | kind: 'setRTPMap'; 31 | data: { 32 | map: Map; 33 | participantIdentity: string; 34 | }; 35 | } 36 | 37 | export interface SifTrailerMessage extends BaseMessage { 38 | kind: 'setSifTrailer'; 39 | data: { 40 | trailer: Uint8Array; 41 | }; 42 | } 43 | 44 | export interface EncodeMessage extends BaseMessage { 45 | kind: 'decode' | 'encode'; 46 | data: { 47 | participantIdentity: string; 48 | readableStream: ReadableStream; 49 | writableStream: WritableStream; 50 | trackId: string; 51 | codec?: VideoCodec; 52 | }; 53 | } 54 | 55 | export interface RemoveTransformMessage extends BaseMessage { 56 | kind: 'removeTransform'; 57 | data: { 58 | participantIdentity: string; 59 | trackId: string; 60 | }; 61 | } 62 | 63 | export interface UpdateCodecMessage extends BaseMessage { 64 | kind: 'updateCodec'; 65 | data: { 66 | participantIdentity: string; 67 | trackId: string; 68 | codec: VideoCodec; 69 | }; 70 | } 71 | 72 | export interface RatchetRequestMessage extends BaseMessage { 73 | kind: 'ratchetRequest'; 74 | data: { 75 | participantIdentity?: string; 76 | keyIndex?: number; 77 | }; 78 | } 79 | 80 | export interface RatchetMessage extends BaseMessage { 81 | kind: 'ratchetKey'; 82 | data: { 83 | participantIdentity: string; 84 | keyIndex?: number; 85 | ratchetResult: RatchetResult; 86 | }; 87 | } 88 | 89 | export interface ErrorMessage extends BaseMessage { 90 | kind: 'error'; 91 | data: { 92 | error: Error; 93 | }; 94 | } 95 | 96 | export interface EnableMessage extends BaseMessage { 97 | kind: 'enable'; 98 | data: { 99 | participantIdentity: string; 100 | enabled: boolean; 101 | }; 102 | } 103 | 104 | export interface InitAck extends BaseMessage { 105 | kind: 'initAck'; 106 | data: { 107 | enabled: boolean; 108 | }; 109 | } 110 | 111 | export type E2EEWorkerMessage = 112 | | InitMessage 113 | | SetKeyMessage 114 | | EncodeMessage 115 | | ErrorMessage 116 | | EnableMessage 117 | | RemoveTransformMessage 118 | | RTPVideoMapMessage 119 | | UpdateCodecMessage 120 | | RatchetRequestMessage 121 | | RatchetMessage 122 | | SifTrailerMessage 123 | | InitAck; 124 | 125 | export type KeySet = { material: CryptoKey; encryptionKey: CryptoKey }; 126 | 127 | export type RatchetResult = { 128 | // The ratchet chain key, which is used to derive the next key. 129 | // Can be shared/exported to other participants. 130 | chainKey: ArrayBuffer; 131 | cryptoKey: CryptoKey; 132 | }; 133 | 134 | export type KeyProviderOptions = { 135 | sharedKey: boolean; 136 | ratchetSalt: string; 137 | ratchetWindowSize: number; 138 | failureTolerance: number; 139 | keyringSize: number; 140 | }; 141 | 142 | export type KeyInfo = { 143 | key: CryptoKey; 144 | participantIdentity?: string; 145 | keyIndex?: number; 146 | }; 147 | 148 | export type E2EEManagerOptions = { 149 | keyProvider: BaseKeyProvider; 150 | worker: Worker; 151 | }; 152 | export type E2EEOptions = 153 | | E2EEManagerOptions 154 | | { 155 | /** For react-native usage. */ 156 | e2eeManager: BaseE2EEManager; 157 | }; 158 | 159 | export type DecodeRatchetOptions = { 160 | /** attempts */ 161 | ratchetCount: number; 162 | /** ratcheted key to try */ 163 | encryptionKey?: CryptoKey; 164 | }; 165 | -------------------------------------------------------------------------------- /src/e2ee/utils.ts: -------------------------------------------------------------------------------- 1 | import { ENCRYPTION_ALGORITHM } from './constants'; 2 | 3 | export function isE2EESupported() { 4 | return isInsertableStreamSupported() || isScriptTransformSupported(); 5 | } 6 | 7 | export function isScriptTransformSupported() { 8 | // @ts-ignore 9 | return typeof window.RTCRtpScriptTransform !== 'undefined'; 10 | } 11 | 12 | export function isInsertableStreamSupported() { 13 | return ( 14 | typeof window.RTCRtpSender !== 'undefined' && 15 | // @ts-ignore 16 | typeof window.RTCRtpSender.prototype.createEncodedStreams !== 'undefined' 17 | ); 18 | } 19 | 20 | export function isVideoFrame( 21 | frame: RTCEncodedAudioFrame | RTCEncodedVideoFrame, 22 | ): frame is RTCEncodedVideoFrame { 23 | return 'type' in frame; 24 | } 25 | 26 | export async function importKey( 27 | keyBytes: Uint8Array | ArrayBuffer, 28 | algorithm: string | { name: string } = { name: ENCRYPTION_ALGORITHM }, 29 | usage: 'derive' | 'encrypt' = 'encrypt', 30 | ) { 31 | // https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/importKey 32 | return crypto.subtle.importKey( 33 | 'raw', 34 | keyBytes, 35 | algorithm, 36 | false, 37 | usage === 'derive' ? ['deriveBits', 'deriveKey'] : ['encrypt', 'decrypt'], 38 | ); 39 | } 40 | 41 | export async function createKeyMaterialFromString(password: string) { 42 | let enc = new TextEncoder(); 43 | 44 | const keyMaterial = await crypto.subtle.importKey( 45 | 'raw', 46 | enc.encode(password), 47 | { 48 | name: 'PBKDF2', 49 | }, 50 | false, 51 | ['deriveBits', 'deriveKey'], 52 | ); 53 | 54 | return keyMaterial; 55 | } 56 | 57 | export async function createKeyMaterialFromBuffer(cryptoBuffer: ArrayBuffer) { 58 | const keyMaterial = await crypto.subtle.importKey('raw', cryptoBuffer, 'HKDF', false, [ 59 | 'deriveBits', 60 | 'deriveKey', 61 | ]); 62 | 63 | return keyMaterial; 64 | } 65 | 66 | function getAlgoOptions(algorithmName: string, salt: string) { 67 | const textEncoder = new TextEncoder(); 68 | const encodedSalt = textEncoder.encode(salt); 69 | switch (algorithmName) { 70 | case 'HKDF': 71 | return { 72 | name: 'HKDF', 73 | salt: encodedSalt, 74 | hash: 'SHA-256', 75 | info: new ArrayBuffer(128), 76 | }; 77 | case 'PBKDF2': { 78 | return { 79 | name: 'PBKDF2', 80 | salt: encodedSalt, 81 | hash: 'SHA-256', 82 | iterations: 100000, 83 | }; 84 | } 85 | default: 86 | throw new Error(`algorithm ${algorithmName} is currently unsupported`); 87 | } 88 | } 89 | 90 | /** 91 | * Derives a set of keys from the master key. 92 | * See https://tools.ietf.org/html/draft-omara-sframe-00#section-4.3.1 93 | */ 94 | export async function deriveKeys(material: CryptoKey, salt: string) { 95 | const algorithmOptions = getAlgoOptions(material.algorithm.name, salt); 96 | 97 | // https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/deriveKey#HKDF 98 | // https://developer.mozilla.org/en-US/docs/Web/API/HkdfParams 99 | const encryptionKey = await crypto.subtle.deriveKey( 100 | algorithmOptions, 101 | material, 102 | { 103 | name: ENCRYPTION_ALGORITHM, 104 | length: 128, 105 | }, 106 | false, 107 | ['encrypt', 'decrypt'], 108 | ); 109 | 110 | return { material, encryptionKey }; 111 | } 112 | 113 | export function createE2EEKey(): Uint8Array { 114 | return window.crypto.getRandomValues(new Uint8Array(32)); 115 | } 116 | 117 | /** 118 | * Ratchets a key. See 119 | * https://tools.ietf.org/html/draft-omara-sframe-00#section-4.3.5.1 120 | */ 121 | export async function ratchet(material: CryptoKey, salt: string): Promise { 122 | const algorithmOptions = getAlgoOptions(material.algorithm.name, salt); 123 | 124 | // https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/deriveBits 125 | return crypto.subtle.deriveBits(algorithmOptions, material, 256); 126 | } 127 | 128 | export function needsRbspUnescaping(frameData: Uint8Array) { 129 | for (var i = 0; i < frameData.length - 3; i++) { 130 | if (frameData[i] == 0 && frameData[i + 1] == 0 && frameData[i + 2] == 3) return true; 131 | } 132 | return false; 133 | } 134 | 135 | export function parseRbsp(stream: Uint8Array): Uint8Array { 136 | const dataOut: number[] = []; 137 | var length = stream.length; 138 | for (var i = 0; i < stream.length; ) { 139 | // Be careful about over/underflow here. byte_length_ - 3 can underflow, and 140 | // i + 3 can overflow, but byte_length_ - i can't, because i < byte_length_ 141 | // above, and that expression will produce the number of bytes left in 142 | // the stream including the byte at i. 143 | if (length - i >= 3 && !stream[i] && !stream[i + 1] && stream[i + 2] == 3) { 144 | // Two rbsp bytes. 145 | dataOut.push(stream[i++]); 146 | dataOut.push(stream[i++]); 147 | // Skip the emulation byte. 148 | i++; 149 | } else { 150 | // Single rbsp byte. 151 | dataOut.push(stream[i++]); 152 | } 153 | } 154 | return new Uint8Array(dataOut); 155 | } 156 | 157 | const kZerosInStartSequence = 2; 158 | const kEmulationByte = 3; 159 | 160 | export function writeRbsp(data_in: Uint8Array): Uint8Array { 161 | const dataOut: number[] = []; 162 | var numConsecutiveZeros = 0; 163 | for (var i = 0; i < data_in.length; ++i) { 164 | var byte = data_in[i]; 165 | if (byte <= kEmulationByte && numConsecutiveZeros >= kZerosInStartSequence) { 166 | // Need to escape. 167 | dataOut.push(kEmulationByte); 168 | numConsecutiveZeros = 0; 169 | } 170 | dataOut.push(byte); 171 | if (byte == 0) { 172 | ++numConsecutiveZeros; 173 | } else { 174 | numConsecutiveZeros = 0; 175 | } 176 | } 177 | return new Uint8Array(dataOut); 178 | } 179 | -------------------------------------------------------------------------------- /src/e2ee/worker/SifGuard.ts: -------------------------------------------------------------------------------- 1 | import { MAX_SIF_COUNT, MAX_SIF_DURATION } from '../constants'; 2 | 3 | export class SifGuard { 4 | private consecutiveSifCount = 0; 5 | 6 | private sifSequenceStartedAt: number | undefined; 7 | 8 | private lastSifReceivedAt: number = 0; 9 | 10 | private userFramesSinceSif: number = 0; 11 | 12 | recordSif() { 13 | this.consecutiveSifCount += 1; 14 | this.sifSequenceStartedAt ??= Date.now(); 15 | this.lastSifReceivedAt = Date.now(); 16 | } 17 | 18 | recordUserFrame() { 19 | if (this.sifSequenceStartedAt === undefined) { 20 | return; 21 | } else { 22 | this.userFramesSinceSif += 1; 23 | } 24 | if ( 25 | // reset if we received more user frames than SIFs 26 | this.userFramesSinceSif > this.consecutiveSifCount || 27 | // also reset if we got a new user frame and the latest SIF frame hasn't been updated in a while 28 | Date.now() - this.lastSifReceivedAt > MAX_SIF_DURATION 29 | ) { 30 | this.reset(); 31 | } 32 | } 33 | 34 | isSifAllowed() { 35 | return ( 36 | this.consecutiveSifCount < MAX_SIF_COUNT && 37 | (this.sifSequenceStartedAt === undefined || 38 | Date.now() - this.sifSequenceStartedAt < MAX_SIF_DURATION) 39 | ); 40 | } 41 | 42 | reset() { 43 | this.userFramesSinceSif = 0; 44 | this.consecutiveSifCount = 0; 45 | this.sifSequenceStartedAt = undefined; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/e2ee/worker/__snapshots__/ParticipantKeyHandler.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`ParticipantKeyHandler > ratchetKey > ratchets keys predictably > ciphertexts 1`] = ` 4 | [ 5 | Uint8Array [ 6 | 42, 7 | 226, 8 | 94, 9 | 49, 10 | 152, 11 | 18, 12 | 79, 13 | 1, 14 | 55, 15 | 190, 16 | 250, 17 | 80, 18 | 143, 19 | 19, 20 | 134, 21 | 218, 22 | 200, 23 | 55, 24 | 87, 25 | 102, 26 | 117, 27 | 217, 28 | 130, 29 | 48, 30 | 11, 31 | 66, 32 | 63, 33 | 102, 34 | 115, 35 | 144, 36 | 117, 37 | 92, 38 | 232, 39 | ], 40 | Uint8Array [ 41 | 4, 42 | 164, 43 | 120, 44 | 76, 45 | 172, 46 | 225, 47 | 17, 48 | 14, 49 | 176, 50 | 186, 51 | 111, 52 | 5, 53 | 10, 54 | 176, 55 | 29, 56 | 42, 57 | 19, 58 | 215, 59 | 72, 60 | 227, 61 | 203, 62 | 139, 63 | 219, 64 | 147, 65 | 85, 66 | 78, 67 | 62, 68 | 191, 69 | 186, 70 | 123, 71 | 248, 72 | 60, 73 | 147, 74 | ], 75 | Uint8Array [ 76 | 58, 77 | 173, 78 | 85, 79 | 162, 80 | 211, 81 | 200, 82 | 200, 83 | 87, 84 | 214, 85 | 65, 86 | 218, 87 | 92, 88 | 74, 89 | 53, 90 | 143, 91 | 183, 92 | 58, 93 | 242, 94 | 19, 95 | 39, 96 | 226, 97 | 180, 98 | 204, 99 | 127, 100 | 109, 101 | 219, 102 | 156, 103 | 152, 104 | 103, 105 | 158, 106 | 186, 107 | 240, 108 | 161, 109 | ], 110 | Uint8Array [ 111 | 46, 112 | 137, 113 | 107, 114 | 8, 115 | 239, 116 | 248, 117 | 217, 118 | 214, 119 | 106, 120 | 234, 121 | 103, 122 | 34, 123 | 108, 124 | 179, 125 | 18, 126 | 186, 127 | 5, 128 | 33, 129 | 31, 130 | 21, 131 | 9, 132 | 48, 133 | 194, 134 | 205, 135 | 206, 136 | 136, 137 | 6, 138 | 179, 139 | 64, 140 | 150, 141 | 126, 142 | 175, 143 | 132, 144 | ], 145 | Uint8Array [ 146 | 249, 147 | 123, 148 | 86, 149 | 179, 150 | 18, 151 | 9, 152 | 149, 153 | 42, 154 | 110, 155 | 112, 156 | 29, 157 | 193, 158 | 208, 159 | 63, 160 | 48, 161 | 118, 162 | 15, 163 | 186, 164 | 27, 165 | 101, 166 | 23, 167 | 31, 168 | 111, 169 | 152, 170 | 193, 171 | 235, 172 | 89, 173 | 25, 174 | 161, 175 | 246, 176 | 231, 177 | 198, 178 | 126, 179 | ], 180 | Uint8Array [ 181 | 56, 182 | 134, 183 | 196, 184 | 195, 185 | 242, 186 | 10, 187 | 187, 188 | 122, 189 | 111, 190 | 179, 191 | 147, 192 | 206, 193 | 74, 194 | 153, 195 | 45, 196 | 244, 197 | 88, 198 | 119, 199 | 25, 200 | 114, 201 | 11, 202 | 2, 203 | 149, 204 | 121, 205 | 227, 206 | 219, 207 | 39, 208 | 11, 209 | 13, 210 | 175, 211 | 182, 212 | 168, 213 | 96, 214 | ], 215 | Uint8Array [ 216 | 36, 217 | 12, 218 | 119, 219 | 157, 220 | 146, 221 | 42, 222 | 43, 223 | 107, 224 | 61, 225 | 43, 226 | 242, 227 | 166, 228 | 11, 229 | 180, 230 | 148, 231 | 39, 232 | 177, 233 | 248, 234 | 194, 235 | 166, 236 | 201, 237 | 53, 238 | 192, 239 | 57, 240 | 62, 241 | 70, 242 | 37, 243 | 252, 244 | 243, 245 | 156, 246 | 140, 247 | 185, 248 | 42, 249 | ], 250 | Uint8Array [ 251 | 222, 252 | 127, 253 | 32, 254 | 228, 255 | 99, 256 | 198, 257 | 31, 258 | 158, 259 | 192, 260 | 101, 261 | 82, 262 | 129, 263 | 175, 264 | 153, 265 | 112, 266 | 43, 267 | 57, 268 | 207, 269 | 246, 270 | 54, 271 | 127, 272 | 165, 273 | 99, 274 | 254, 275 | 4, 276 | 178, 277 | 40, 278 | 53, 279 | 66, 280 | 92, 281 | 13, 282 | 69, 283 | 0, 284 | ], 285 | Uint8Array [ 286 | 249, 287 | 181, 288 | 181, 289 | 168, 290 | 27, 291 | 48, 292 | 221, 293 | 221, 294 | 174, 295 | 187, 296 | 16, 297 | 153, 298 | 144, 299 | 156, 300 | 164, 301 | 232, 302 | 185, 303 | 202, 304 | 132, 305 | 58, 306 | 75, 307 | 156, 308 | 232, 309 | 238, 310 | 191, 311 | 33, 312 | 9, 313 | 31, 314 | 151, 315 | 43, 316 | 224, 317 | 136, 318 | 190, 319 | ], 320 | Uint8Array [ 321 | 58, 322 | 99, 323 | 16, 324 | 151, 325 | 148, 326 | 131, 327 | 138, 328 | 94, 329 | 230, 330 | 95, 331 | 38, 332 | 81, 333 | 137, 334 | 162, 335 | 78, 336 | 61, 337 | 243, 338 | 207, 339 | 255, 340 | 109, 341 | 48, 342 | 248, 343 | 217, 344 | 135, 345 | 133, 346 | 248, 347 | 56, 348 | 74, 349 | 41, 350 | 113, 351 | 153, 352 | 108, 353 | 202, 354 | ], 355 | ] 356 | `; 357 | -------------------------------------------------------------------------------- /src/e2ee/worker/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../../tsconfig.json", 3 | "compilerOptions": { 4 | "lib": [ 5 | "DOM", 6 | "DOM.Iterable", 7 | "ES2017", 8 | "ES2018.Promise", 9 | "WebWorker", 10 | "ES2021.WeakRef", 11 | "DOM.AsyncIterable" 12 | ] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Mutex } from '@livekit/mutex'; 2 | import { DataPacket_Kind, DisconnectReason, SubscriptionError, TrackType } from '@livekit/protocol'; 3 | import { LogLevel, LoggerNames, getLogger, setLogExtension, setLogLevel } from './logger'; 4 | import DefaultReconnectPolicy from './room/DefaultReconnectPolicy'; 5 | import type { ReconnectContext, ReconnectPolicy } from './room/ReconnectPolicy'; 6 | import Room, { ConnectionState } from './room/Room'; 7 | import * as attributes from './room/attribute-typings'; 8 | import LocalParticipant from './room/participant/LocalParticipant'; 9 | import Participant, { ConnectionQuality, ParticipantKind } from './room/participant/Participant'; 10 | import type { ParticipantTrackPermission } from './room/participant/ParticipantTrackPermission'; 11 | import RemoteParticipant from './room/participant/RemoteParticipant'; 12 | import type { 13 | AudioReceiverStats, 14 | AudioSenderStats, 15 | VideoReceiverStats, 16 | VideoSenderStats, 17 | } from './room/stats'; 18 | import CriticalTimers from './room/timers'; 19 | import LocalAudioTrack from './room/track/LocalAudioTrack'; 20 | import LocalTrack from './room/track/LocalTrack'; 21 | import LocalTrackPublication from './room/track/LocalTrackPublication'; 22 | import LocalVideoTrack from './room/track/LocalVideoTrack'; 23 | import RemoteAudioTrack from './room/track/RemoteAudioTrack'; 24 | import RemoteTrack from './room/track/RemoteTrack'; 25 | import RemoteTrackPublication from './room/track/RemoteTrackPublication'; 26 | import type { ElementInfo } from './room/track/RemoteVideoTrack'; 27 | import RemoteVideoTrack from './room/track/RemoteVideoTrack'; 28 | import { TrackPublication } from './room/track/TrackPublication'; 29 | import type { LiveKitReactNativeInfo } from './room/types'; 30 | import type { AudioAnalyserOptions } from './room/utils'; 31 | import { 32 | compareVersions, 33 | createAudioAnalyser, 34 | getEmptyAudioStreamTrack, 35 | getEmptyVideoStreamTrack, 36 | isAudioTrack, 37 | isBrowserSupported, 38 | isLocalParticipant, 39 | isLocalTrack, 40 | isRemoteParticipant, 41 | isRemoteTrack, 42 | isVideoTrack, 43 | supportsAV1, 44 | supportsAdaptiveStream, 45 | supportsDynacast, 46 | supportsVP9, 47 | } from './room/utils'; 48 | import { getBrowser } from './utils/browserParser'; 49 | 50 | export { RpcError, type RpcInvocationData, type PerformRpcParams } from './room/rpc'; 51 | 52 | export * from './connectionHelper/ConnectionCheck'; 53 | export * from './connectionHelper/checks/Checker'; 54 | export * from './e2ee'; 55 | export type { BaseE2EEManager } from './e2ee/E2eeManager'; 56 | export * from './options'; 57 | export * from './room/errors'; 58 | export * from './room/events'; 59 | export * from './room/track/Track'; 60 | export * from './room/track/create'; 61 | export { facingModeFromDeviceLabel, facingModeFromLocalTrack } from './room/track/facingMode'; 62 | export * from './room/track/options'; 63 | export * from './room/track/processor/types'; 64 | export * from './room/track/types'; 65 | export type * from './room/StreamReader'; 66 | export type * from './room/StreamWriter'; 67 | export type { 68 | DataPublishOptions, 69 | SimulationScenario, 70 | TranscriptionSegment, 71 | ChatMessage, 72 | SendTextOptions, 73 | } from './room/types'; 74 | export * from './version'; 75 | export { 76 | /** @internal */ 77 | attributes, 78 | ConnectionQuality, 79 | ConnectionState, 80 | CriticalTimers, 81 | DataPacket_Kind, 82 | DefaultReconnectPolicy, 83 | DisconnectReason, 84 | LocalAudioTrack, 85 | LocalParticipant, 86 | LocalTrack, 87 | LocalTrackPublication, 88 | LocalVideoTrack, 89 | LogLevel, 90 | LoggerNames, 91 | Participant, 92 | RemoteAudioTrack, 93 | RemoteParticipant, 94 | ParticipantKind, 95 | RemoteTrack, 96 | RemoteTrackPublication, 97 | RemoteVideoTrack, 98 | Room, 99 | SubscriptionError, 100 | TrackPublication, 101 | TrackType, 102 | compareVersions, 103 | createAudioAnalyser, 104 | getBrowser, 105 | getEmptyAudioStreamTrack, 106 | getEmptyVideoStreamTrack, 107 | getLogger, 108 | isBrowserSupported, 109 | setLogExtension, 110 | setLogLevel, 111 | supportsAV1, 112 | supportsAdaptiveStream, 113 | supportsDynacast, 114 | supportsVP9, 115 | Mutex, 116 | isAudioTrack, 117 | isLocalTrack, 118 | isRemoteTrack, 119 | isVideoTrack, 120 | isLocalParticipant, 121 | isRemoteParticipant, 122 | }; 123 | export type { 124 | AudioAnalyserOptions, 125 | ElementInfo, 126 | LiveKitReactNativeInfo, 127 | ParticipantTrackPermission, 128 | AudioReceiverStats, 129 | AudioSenderStats, 130 | VideoReceiverStats, 131 | VideoSenderStats, 132 | ReconnectContext, 133 | ReconnectPolicy, 134 | }; 135 | 136 | export { LocalTrackRecorder } from './room/track/record'; 137 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | import * as log from 'loglevel'; 2 | 3 | export enum LogLevel { 4 | trace = 0, 5 | debug = 1, 6 | info = 2, 7 | warn = 3, 8 | error = 4, 9 | silent = 5, 10 | } 11 | 12 | export enum LoggerNames { 13 | Default = 'livekit', 14 | Room = 'livekit-room', 15 | Participant = 'livekit-participant', 16 | Track = 'livekit-track', 17 | Publication = 'livekit-track-publication', 18 | Engine = 'livekit-engine', 19 | Signal = 'livekit-signal', 20 | PCManager = 'livekit-pc-manager', 21 | PCTransport = 'livekit-pc-transport', 22 | E2EE = 'lk-e2ee', 23 | } 24 | 25 | type LogLevelString = keyof typeof LogLevel; 26 | 27 | export type StructuredLogger = log.Logger & { 28 | trace: (msg: string, context?: object) => void; 29 | debug: (msg: string, context?: object) => void; 30 | info: (msg: string, context?: object) => void; 31 | warn: (msg: string, context?: object) => void; 32 | error: (msg: string, context?: object) => void; 33 | setDefaultLevel: (level: log.LogLevelDesc) => void; 34 | setLevel: (level: log.LogLevelDesc) => void; 35 | getLevel: () => number; 36 | }; 37 | 38 | let livekitLogger = log.getLogger('livekit'); 39 | const livekitLoggers = Object.values(LoggerNames).map((name) => log.getLogger(name)); 40 | 41 | livekitLogger.setDefaultLevel(LogLevel.info); 42 | 43 | export default livekitLogger as StructuredLogger; 44 | 45 | /** 46 | * @internal 47 | */ 48 | export function getLogger(name: string) { 49 | const logger = log.getLogger(name); 50 | logger.setDefaultLevel(livekitLogger.getLevel()); 51 | return logger as StructuredLogger; 52 | } 53 | 54 | export function setLogLevel(level: LogLevel | LogLevelString, loggerName?: LoggerNames) { 55 | if (loggerName) { 56 | log.getLogger(loggerName).setLevel(level); 57 | } else { 58 | for (const logger of livekitLoggers) { 59 | logger.setLevel(level); 60 | } 61 | } 62 | } 63 | 64 | export type LogExtension = (level: LogLevel, msg: string, context?: object) => void; 65 | 66 | /** 67 | * use this to hook into the logging function to allow sending internal livekit logs to third party services 68 | * if set, the browser logs will lose their stacktrace information (see https://github.com/pimterry/loglevel#writing-plugins) 69 | */ 70 | export function setLogExtension(extension: LogExtension, logger?: StructuredLogger) { 71 | const loggers = logger ? [logger] : livekitLoggers; 72 | 73 | loggers.forEach((logR) => { 74 | const originalFactory = logR.methodFactory; 75 | 76 | logR.methodFactory = (methodName, configLevel, loggerName) => { 77 | const rawMethod = originalFactory(methodName, configLevel, loggerName); 78 | 79 | const logLevel = LogLevel[methodName as LogLevelString]; 80 | const needLog = logLevel >= configLevel && logLevel < LogLevel.silent; 81 | 82 | return (msg, context?: [msg: string, context: object]) => { 83 | if (context) rawMethod(msg, context); 84 | else rawMethod(msg); 85 | if (needLog) { 86 | extension(logLevel, msg, context); 87 | } 88 | }; 89 | }; 90 | logR.setLevel(logR.getLevel()); 91 | }); 92 | } 93 | 94 | export const workerLogger = log.getLogger('lk-e2ee') as StructuredLogger; 95 | -------------------------------------------------------------------------------- /src/options.ts: -------------------------------------------------------------------------------- 1 | import type { E2EEOptions } from './e2ee/types'; 2 | import type { ReconnectPolicy } from './room/ReconnectPolicy'; 3 | import type { 4 | AudioCaptureOptions, 5 | AudioOutputOptions, 6 | TrackPublishDefaults, 7 | VideoCaptureOptions, 8 | } from './room/track/options'; 9 | import type { AdaptiveStreamSettings } from './room/track/types'; 10 | 11 | export interface WebAudioSettings { 12 | audioContext: AudioContext; 13 | } 14 | 15 | /** 16 | * @internal 17 | */ 18 | export interface InternalRoomOptions { 19 | /** 20 | * AdaptiveStream lets LiveKit automatically manage quality of subscribed 21 | * video tracks to optimize for bandwidth and CPU. 22 | * When attached video elements are visible, it'll choose an appropriate 23 | * resolution based on the size of largest video element it's attached to. 24 | * 25 | * When none of the video elements are visible, it'll temporarily pause 26 | * the data flow until they are visible again. 27 | */ 28 | adaptiveStream: AdaptiveStreamSettings | boolean; 29 | 30 | /** 31 | * enable Dynacast, off by default. With Dynacast dynamically pauses 32 | * video layers that are not being consumed by any subscribers, significantly 33 | * reducing publishing CPU and bandwidth usage. 34 | * 35 | * Dynacast will be enabled if SVC codecs (VP9/AV1) are used. Multi-codec simulcast 36 | * requires dynacast 37 | */ 38 | dynacast: boolean; 39 | 40 | /** 41 | * default options to use when capturing user's audio 42 | */ 43 | audioCaptureDefaults?: AudioCaptureOptions; 44 | 45 | /** 46 | * default options to use when capturing user's video 47 | */ 48 | videoCaptureDefaults?: VideoCaptureOptions; 49 | 50 | /** 51 | * default options to use when publishing tracks 52 | */ 53 | publishDefaults?: TrackPublishDefaults; 54 | 55 | /** 56 | * audio output for the room 57 | */ 58 | audioOutput?: AudioOutputOptions; 59 | 60 | /** 61 | * should local tracks be stopped when they are unpublished. defaults to true 62 | * set this to false if you would prefer to clean up unpublished local tracks manually. 63 | */ 64 | stopLocalTrackOnUnpublish: boolean; 65 | 66 | /** 67 | * policy to use when attempting to reconnect 68 | */ 69 | reconnectPolicy: ReconnectPolicy; 70 | 71 | /** 72 | * specifies whether the sdk should automatically disconnect the room 73 | * on 'pagehide' and 'beforeunload' events 74 | */ 75 | disconnectOnPageLeave: boolean; 76 | 77 | /** 78 | * @internal 79 | * experimental flag, introduce a delay before sending signaling messages 80 | */ 81 | expSignalLatency?: number; 82 | 83 | /** 84 | * mix all audio tracks in web audio, helps to tackle some audio auto playback issues 85 | * allows for passing in your own AudioContext instance, too 86 | */ 87 | 88 | webAudioMix: boolean | WebAudioSettings; 89 | 90 | /** 91 | * @experimental 92 | */ 93 | e2ee?: E2EEOptions; 94 | 95 | loggerName?: string; 96 | } 97 | 98 | /** 99 | * Options for when creating a new room 100 | */ 101 | export interface RoomOptions extends Partial {} 102 | 103 | /** 104 | * @internal 105 | */ 106 | export interface InternalRoomConnectOptions { 107 | /** autosubscribe to room tracks after joining, defaults to true */ 108 | autoSubscribe: boolean; 109 | 110 | /** amount of time for PeerConnection to be established, defaults to 15s */ 111 | peerConnectionTimeout: number; 112 | 113 | /** 114 | * use to override any RTCConfiguration options. 115 | */ 116 | rtcConfig?: RTCConfiguration; 117 | 118 | /** specifies how often an initial join connection is allowed to retry (only applicable if server is not reachable) */ 119 | maxRetries: number; 120 | 121 | /** amount of time for Websocket connection to be established, defaults to 15s */ 122 | websocketTimeout: number; 123 | } 124 | 125 | /** 126 | * Options for Room.connect() 127 | */ 128 | export interface RoomConnectOptions extends Partial {} 129 | -------------------------------------------------------------------------------- /src/room/DefaultReconnectPolicy.ts: -------------------------------------------------------------------------------- 1 | import type { ReconnectContext, ReconnectPolicy } from './ReconnectPolicy'; 2 | 3 | const maxRetryDelay = 7000; 4 | 5 | const DEFAULT_RETRY_DELAYS_IN_MS = [ 6 | 0, 7 | 300, 8 | 2 * 2 * 300, 9 | 3 * 3 * 300, 10 | 4 * 4 * 300, 11 | maxRetryDelay, 12 | maxRetryDelay, 13 | maxRetryDelay, 14 | maxRetryDelay, 15 | maxRetryDelay, 16 | ]; 17 | 18 | class DefaultReconnectPolicy implements ReconnectPolicy { 19 | private readonly _retryDelays: number[]; 20 | 21 | constructor(retryDelays?: number[]) { 22 | this._retryDelays = retryDelays !== undefined ? [...retryDelays] : DEFAULT_RETRY_DELAYS_IN_MS; 23 | } 24 | 25 | public nextRetryDelayInMs(context: ReconnectContext): number | null { 26 | if (context.retryCount >= this._retryDelays.length) return null; 27 | 28 | const retryDelay = this._retryDelays[context.retryCount]; 29 | if (context.retryCount <= 1) return retryDelay; 30 | 31 | return retryDelay + Math.random() * 1_000; 32 | } 33 | } 34 | 35 | export default DefaultReconnectPolicy; 36 | -------------------------------------------------------------------------------- /src/room/DeviceManager.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import DeviceManager from './DeviceManager'; 3 | 4 | class MockDeviceManager extends DeviceManager { 5 | dummyDevices?: MediaDeviceInfo[]; 6 | 7 | async getDevices( 8 | kind?: MediaDeviceKind | undefined, 9 | requestPermissions?: boolean, 10 | ): Promise { 11 | if (this.dummyDevices) { 12 | return this.dummyDevices; 13 | } else { 14 | return super.getDevices(kind, requestPermissions); 15 | } 16 | } 17 | } 18 | 19 | describe('Active device switch', () => { 20 | const deviceManager = new MockDeviceManager(); 21 | it('normalizes default ID correctly', async () => { 22 | deviceManager.dummyDevices = [ 23 | { 24 | deviceId: 'default', 25 | kind: 'audiooutput', 26 | label: 'Default - Speakers (Intel® Smart Sound Technology for I2S Audio)', 27 | groupId: 'c94fea7109a30d468722f3b7778302c716d683a619f41b264b0cf8b2ec202d9g', 28 | toJSON: () => 'dummy', 29 | }, 30 | { 31 | deviceId: 'communications', 32 | kind: 'audiooutput', 33 | label: 'Communications - Speakers (2- USB Advanced Audio Device) (0d8c:016c)', 34 | groupId: '5146b7ad442c53c9366b141edceca8be30c0a4b31c181968cc732a100176e765', 35 | toJSON: () => 'dummy', 36 | }, 37 | { 38 | deviceId: 'bfbbf4fbdba0ce0b12159f2f615ba856bdf17743d8b791265db22952d98c28cf', 39 | kind: 'audiooutput', 40 | label: 'Speakers (2- USB Advanced Audio Device) (0d8c:016c)', 41 | groupId: '5146b7ad442c53c9366b141edceca8be30c0a4b31c181968cc732a100176e765', 42 | toJSON: () => 'dummy', 43 | }, 44 | { 45 | deviceId: '6ca3eb8140dc3d2919d6747f73d8277e6ecaf0f179426695154e98615bafd2b9', 46 | kind: 'audiooutput', 47 | label: 'Speakers (Intel® Smart Sound Technology for I2S Audio)', 48 | groupId: 'c94fea7109a30d468722f3b7778302c716d683a619f41b264b0cf8b2ec202d9g', 49 | toJSON: () => 'dummy', 50 | }, 51 | ]; 52 | 53 | const normalizedID = await deviceManager.normalizeDeviceId('audiooutput', 'default'); 54 | expect(normalizedID).toBe('6ca3eb8140dc3d2919d6747f73d8277e6ecaf0f179426695154e98615bafd2b9'); 55 | }); 56 | it('returns undefined when default cannot be determined', async () => { 57 | deviceManager.dummyDevices = [ 58 | { 59 | deviceId: 'default', 60 | kind: 'audiooutput', 61 | label: 'Default', 62 | groupId: 'default', 63 | toJSON: () => 'dummy', 64 | }, 65 | { 66 | deviceId: 'd5a1ad8b1314736ad1936aae1d74fa524f954c3281b4af3b65b2492330c3a830', 67 | kind: 'audiooutput', 68 | label: 'Alder Lake PCH-P High Definition Audio Controller HDMI / DisplayPort 3 Output', 69 | groupId: 'af6745746c55f7697eadbb5e31a8f28ef836b4d8aefdc3655189a9e7d81eb8d', 70 | toJSON: () => 'dummy', 71 | }, 72 | { 73 | deviceId: '093f4e51743557382b19da4c0250869b9c6d176423b241f0d52ed665f636e9d2', 74 | kind: 'audiooutput', 75 | label: 'Alder Lake PCH-P High Definition Audio Controller HDMI / DisplayPort 2 Output', 76 | groupId: 'e53791b0ce4bad2b3a515cb1e154acf4758bb563b11942949130190d5c2e0d4', 77 | toJSON: () => 'dummy', 78 | }, 79 | { 80 | deviceId: 'a6ffa042ac4a88e9ff552ce50b016d0bbf60a9e3c2173a444b064ef1aa022fb5', 81 | kind: 'audiooutput', 82 | label: 'Alder Lake PCH-P High Definition Audio Controller HDMI / DisplayPort 1 Output', 83 | groupId: '69bb8042d093e8b33e9c33710cdfb8c0bba08889904b012e1a186704d74b39a', 84 | toJSON: () => 'dummy', 85 | }, 86 | { 87 | deviceId: 'd746e22bcfa3f8f76dfce7ee887612982c226eb1f2ed77502ed621b9d7cdae00', 88 | kind: 'audiooutput', 89 | label: 'Alder Lake PCH-P High Definition Audio Controller Speaker + Headphones', 90 | groupId: 'd08b9d0b8d1460c8c120333bdcbc42fbb92fa8e902926fb8b1f35d43ad7f10f', 91 | toJSON: () => 'dummy', 92 | }, 93 | { 94 | deviceId: 'c43858eb7092870122d5bc3af7b7b7e2f9baf9b3aa829adb34cc84c9f65538a3', 95 | kind: 'audiooutput', 96 | label: 'T11', 97 | groupId: '1ecff3666059160ac3ae559e97286de0ee2487bce8808e9040cba26805d3e15', 98 | toJSON: () => 'dummy', 99 | }, 100 | ]; 101 | 102 | const normalizedID = await deviceManager.normalizeDeviceId('audiooutput', 'default'); 103 | expect(normalizedID).toBe(undefined); 104 | }); 105 | }); 106 | -------------------------------------------------------------------------------- /src/room/DeviceManager.ts: -------------------------------------------------------------------------------- 1 | import log from '../logger'; 2 | import { isSafari } from './utils'; 3 | 4 | const defaultId = 'default'; 5 | 6 | export default class DeviceManager { 7 | private static instance?: DeviceManager; 8 | 9 | static mediaDeviceKinds: MediaDeviceKind[] = ['audioinput', 'audiooutput', 'videoinput']; 10 | 11 | static getInstance(): DeviceManager { 12 | if (this.instance === undefined) { 13 | this.instance = new DeviceManager(); 14 | } 15 | return this.instance; 16 | } 17 | 18 | static userMediaPromiseMap: Map> = new Map(); 19 | 20 | private _previousDevices: MediaDeviceInfo[] = []; 21 | 22 | get previousDevices() { 23 | return this._previousDevices; 24 | } 25 | 26 | async getDevices( 27 | kind?: MediaDeviceKind, 28 | requestPermissions: boolean = true, 29 | ): Promise { 30 | if (DeviceManager.userMediaPromiseMap?.size > 0) { 31 | log.debug('awaiting getUserMedia promise'); 32 | try { 33 | if (kind) { 34 | await DeviceManager.userMediaPromiseMap.get(kind); 35 | } else { 36 | await Promise.all(DeviceManager.userMediaPromiseMap.values()); 37 | } 38 | } catch (e: any) { 39 | log.warn('error waiting for media permissons'); 40 | } 41 | } 42 | let devices = await navigator.mediaDevices.enumerateDevices(); 43 | 44 | if ( 45 | requestPermissions && 46 | // for safari we need to skip this check, as otherwise it will re-acquire user media and fail on iOS https://bugs.webkit.org/show_bug.cgi?id=179363 47 | !(isSafari() && this.hasDeviceInUse(kind)) 48 | ) { 49 | const isDummyDeviceOrEmpty = 50 | devices.filter((d) => d.kind === kind).length === 0 || 51 | devices.some((device) => { 52 | const noLabel = device.label === ''; 53 | const isRelevant = kind ? device.kind === kind : true; 54 | return noLabel && isRelevant; 55 | }); 56 | 57 | if (isDummyDeviceOrEmpty) { 58 | const permissionsToAcquire = { 59 | video: kind !== 'audioinput' && kind !== 'audiooutput', 60 | audio: kind !== 'videoinput' && { deviceId: { ideal: 'default' } }, 61 | }; 62 | const stream = await navigator.mediaDevices.getUserMedia(permissionsToAcquire); 63 | devices = await navigator.mediaDevices.enumerateDevices(); 64 | stream.getTracks().forEach((track) => { 65 | track.stop(); 66 | }); 67 | } 68 | } 69 | this._previousDevices = devices; 70 | 71 | if (kind) { 72 | devices = devices.filter((device) => device.kind === kind); 73 | } 74 | return devices; 75 | } 76 | 77 | async normalizeDeviceId( 78 | kind: MediaDeviceKind, 79 | deviceId?: string, 80 | groupId?: string, 81 | ): Promise { 82 | if (deviceId !== defaultId) { 83 | return deviceId; 84 | } 85 | 86 | // resolve actual device id if it's 'default': Chrome returns it when no 87 | // device has been chosen 88 | const devices = await this.getDevices(kind); 89 | 90 | const defaultDevice = devices.find((d) => d.deviceId === defaultId); 91 | 92 | if (!defaultDevice) { 93 | log.warn('could not reliably determine default device'); 94 | return undefined; 95 | } 96 | 97 | const device = devices.find( 98 | (d) => d.deviceId !== defaultId && d.groupId === (groupId ?? defaultDevice.groupId), 99 | ); 100 | 101 | if (!device) { 102 | log.warn('could not reliably determine default device'); 103 | return undefined; 104 | } 105 | 106 | return device?.deviceId; 107 | } 108 | 109 | private hasDeviceInUse(kind?: MediaDeviceKind): boolean { 110 | return kind 111 | ? DeviceManager.userMediaPromiseMap.has(kind) 112 | : DeviceManager.userMediaPromiseMap.size > 0; 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/room/ReconnectPolicy.ts: -------------------------------------------------------------------------------- 1 | /** Controls reconnecting of the client */ 2 | export interface ReconnectPolicy { 3 | /** Called after disconnect was detected 4 | * 5 | * @returns {number | null} Amount of time in milliseconds to delay the next reconnect attempt, `null` signals to stop retrying. 6 | */ 7 | nextRetryDelayInMs(context: ReconnectContext): number | null; 8 | } 9 | 10 | export interface ReconnectContext { 11 | /** 12 | * Number of failed reconnect attempts 13 | */ 14 | readonly retryCount: number; 15 | 16 | /** 17 | * Elapsed amount of time in milliseconds since the disconnect. 18 | */ 19 | readonly elapsedMs: number; 20 | 21 | /** 22 | * Reason for retrying 23 | */ 24 | readonly retryReason?: Error; 25 | 26 | readonly serverUrl?: string; 27 | } 28 | -------------------------------------------------------------------------------- /src/room/RegionUrlProvider.ts: -------------------------------------------------------------------------------- 1 | import type { RegionInfo, RegionSettings } from '@livekit/protocol'; 2 | import log from '../logger'; 3 | import { ConnectionError, ConnectionErrorReason } from './errors'; 4 | import { isCloud } from './utils'; 5 | 6 | export class RegionUrlProvider { 7 | private serverUrl: URL; 8 | 9 | private token: string; 10 | 11 | private regionSettings: RegionSettings | undefined; 12 | 13 | private lastUpdateAt: number = 0; 14 | 15 | private settingsCacheTime = 3_000; 16 | 17 | private attemptedRegions: RegionInfo[] = []; 18 | 19 | constructor(url: string, token: string) { 20 | this.serverUrl = new URL(url); 21 | this.token = token; 22 | } 23 | 24 | updateToken(token: string) { 25 | this.token = token; 26 | } 27 | 28 | isCloud() { 29 | return isCloud(this.serverUrl); 30 | } 31 | 32 | getServerUrl() { 33 | return this.serverUrl; 34 | } 35 | 36 | async getNextBestRegionUrl(abortSignal?: AbortSignal) { 37 | if (!this.isCloud()) { 38 | throw Error('region availability is only supported for LiveKit Cloud domains'); 39 | } 40 | if (!this.regionSettings || Date.now() - this.lastUpdateAt > this.settingsCacheTime) { 41 | this.regionSettings = await this.fetchRegionSettings(abortSignal); 42 | } 43 | const regionsLeft = this.regionSettings.regions.filter( 44 | (region) => !this.attemptedRegions.find((attempted) => attempted.url === region.url), 45 | ); 46 | if (regionsLeft.length > 0) { 47 | const nextRegion = regionsLeft[0]; 48 | this.attemptedRegions.push(nextRegion); 49 | log.debug(`next region: ${nextRegion.region}`); 50 | return nextRegion.url; 51 | } else { 52 | return null; 53 | } 54 | } 55 | 56 | resetAttempts() { 57 | this.attemptedRegions = []; 58 | } 59 | 60 | /* @internal */ 61 | async fetchRegionSettings(signal?: AbortSignal) { 62 | const regionSettingsResponse = await fetch(`${getCloudConfigUrl(this.serverUrl)}/regions`, { 63 | headers: { authorization: `Bearer ${this.token}` }, 64 | signal, 65 | }); 66 | if (regionSettingsResponse.ok) { 67 | const regionSettings = (await regionSettingsResponse.json()) as RegionSettings; 68 | this.lastUpdateAt = Date.now(); 69 | return regionSettings; 70 | } else { 71 | throw new ConnectionError( 72 | `Could not fetch region settings: ${regionSettingsResponse.statusText}`, 73 | regionSettingsResponse.status === 401 74 | ? ConnectionErrorReason.NotAllowed 75 | : ConnectionErrorReason.InternalError, 76 | regionSettingsResponse.status, 77 | ); 78 | } 79 | } 80 | 81 | setServerReportedRegions(regions: RegionSettings) { 82 | this.regionSettings = regions; 83 | this.lastUpdateAt = Date.now(); 84 | } 85 | } 86 | 87 | function getCloudConfigUrl(serverUrl: URL) { 88 | return `${serverUrl.protocol.replace('ws', 'http')}//${serverUrl.host}/settings`; 89 | } 90 | -------------------------------------------------------------------------------- /src/room/Room.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import Room from './Room'; 3 | import { RoomEvent } from './events'; 4 | 5 | describe('Active device switch', () => { 6 | it('updates devices correctly', async () => { 7 | const room = new Room(); 8 | await room.switchActiveDevice('audioinput', 'test'); 9 | expect(room.getActiveDevice('audioinput')).toBe('test'); 10 | }); 11 | it('updates devices with exact constraint', async () => { 12 | const room = new Room(); 13 | await room.switchActiveDevice('audioinput', 'test', true); 14 | expect(room.getActiveDevice('audioinput')).toBe('test'); 15 | }); 16 | it('emits changed event', async () => { 17 | const room = new Room(); 18 | let kind: MediaDeviceKind | undefined; 19 | let deviceId: string | undefined; 20 | const deviceChangeHandler = (_kind: MediaDeviceKind, _deviceId: string) => { 21 | kind = _kind; 22 | deviceId = _deviceId; 23 | }; 24 | room.on(RoomEvent.ActiveDeviceChanged, deviceChangeHandler); 25 | await room.switchActiveDevice('audioinput', 'test', true); 26 | 27 | expect(deviceId).toBe('test'); 28 | expect(kind).toBe('audioinput'); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/room/StreamReader.ts: -------------------------------------------------------------------------------- 1 | import type { DataStream_Chunk } from '@livekit/protocol'; 2 | import type { BaseStreamInfo, ByteStreamInfo, TextStreamInfo } from './types'; 3 | import { bigIntToNumber } from './utils'; 4 | 5 | abstract class BaseStreamReader { 6 | protected reader: ReadableStream; 7 | 8 | protected totalByteSize?: number; 9 | 10 | protected _info: T; 11 | 12 | protected bytesReceived: number; 13 | 14 | get info() { 15 | return this._info; 16 | } 17 | 18 | constructor(info: T, stream: ReadableStream, totalByteSize?: number) { 19 | this.reader = stream; 20 | this.totalByteSize = totalByteSize; 21 | this._info = info; 22 | this.bytesReceived = 0; 23 | } 24 | 25 | protected abstract handleChunkReceived(chunk: DataStream_Chunk): void; 26 | 27 | onProgress?: (progress: number | undefined) => void; 28 | 29 | abstract readAll(): Promise>; 30 | } 31 | 32 | export class ByteStreamReader extends BaseStreamReader { 33 | protected handleChunkReceived(chunk: DataStream_Chunk) { 34 | this.bytesReceived += chunk.content.byteLength; 35 | const currentProgress = this.totalByteSize 36 | ? this.bytesReceived / this.totalByteSize 37 | : undefined; 38 | this.onProgress?.(currentProgress); 39 | } 40 | 41 | onProgress?: (progress: number | undefined) => void; 42 | 43 | [Symbol.asyncIterator]() { 44 | const reader = this.reader.getReader(); 45 | 46 | return { 47 | next: async (): Promise> => { 48 | try { 49 | const { done, value } = await reader.read(); 50 | if (done) { 51 | return { done: true, value: undefined as any }; 52 | } else { 53 | this.handleChunkReceived(value); 54 | return { done: false, value: value.content }; 55 | } 56 | } catch (error) { 57 | // TODO handle errors 58 | return { done: true, value: undefined }; 59 | } 60 | }, 61 | 62 | async return(): Promise> { 63 | reader.releaseLock(); 64 | return { done: true, value: undefined }; 65 | }, 66 | }; 67 | } 68 | 69 | async readAll(): Promise> { 70 | let chunks: Set = new Set(); 71 | for await (const chunk of this) { 72 | chunks.add(chunk); 73 | } 74 | return Array.from(chunks); 75 | } 76 | } 77 | 78 | /** 79 | * A class to read chunks from a ReadableStream and provide them in a structured format. 80 | */ 81 | export class TextStreamReader extends BaseStreamReader { 82 | private receivedChunks: Map; 83 | 84 | /** 85 | * A TextStreamReader instance can be used as an AsyncIterator that returns the entire string 86 | * that has been received up to the current point in time. 87 | */ 88 | constructor( 89 | info: TextStreamInfo, 90 | stream: ReadableStream, 91 | totalChunkCount?: number, 92 | ) { 93 | super(info, stream, totalChunkCount); 94 | this.receivedChunks = new Map(); 95 | } 96 | 97 | protected handleChunkReceived(chunk: DataStream_Chunk) { 98 | const index = bigIntToNumber(chunk.chunkIndex); 99 | const previousChunkAtIndex = this.receivedChunks.get(index); 100 | if (previousChunkAtIndex && previousChunkAtIndex.version > chunk.version) { 101 | // we have a newer version already, dropping the old one 102 | return; 103 | } 104 | this.receivedChunks.set(index, chunk); 105 | this.bytesReceived += chunk.content.byteLength; 106 | const currentProgress = this.totalByteSize 107 | ? this.bytesReceived / this.totalByteSize 108 | : undefined; 109 | this.onProgress?.(currentProgress); 110 | } 111 | 112 | /** 113 | * @param progress - progress of the stream between 0 and 1. Undefined for streams of unknown size 114 | */ 115 | onProgress?: (progress: number | undefined) => void; 116 | 117 | /** 118 | * Async iterator implementation to allow usage of `for await...of` syntax. 119 | * Yields structured chunks from the stream. 120 | * 121 | */ 122 | [Symbol.asyncIterator]() { 123 | const reader = this.reader.getReader(); 124 | const decoder = new TextDecoder(); 125 | 126 | return { 127 | next: async (): Promise> => { 128 | try { 129 | const { done, value } = await reader.read(); 130 | if (done) { 131 | return { done: true, value: undefined }; 132 | } else { 133 | this.handleChunkReceived(value); 134 | 135 | return { 136 | done: false, 137 | value: decoder.decode(value.content), 138 | }; 139 | } 140 | } catch (error) { 141 | // TODO handle errors 142 | return { done: true, value: undefined }; 143 | } 144 | }, 145 | 146 | async return(): Promise> { 147 | reader.releaseLock(); 148 | return { done: true, value: undefined }; 149 | }, 150 | }; 151 | } 152 | 153 | async readAll(): Promise { 154 | let finalString: string = ''; 155 | for await (const chunk of this) { 156 | finalString += chunk; 157 | } 158 | return finalString; 159 | } 160 | } 161 | 162 | export type ByteStreamHandler = ( 163 | reader: ByteStreamReader, 164 | participantInfo: { identity: string }, 165 | ) => void; 166 | 167 | export type TextStreamHandler = ( 168 | reader: TextStreamReader, 169 | participantInfo: { identity: string }, 170 | ) => void; 171 | -------------------------------------------------------------------------------- /src/room/StreamWriter.ts: -------------------------------------------------------------------------------- 1 | import type { BaseStreamInfo, ByteStreamInfo, TextStreamInfo } from './types'; 2 | 3 | class BaseStreamWriter { 4 | protected writableStream: WritableStream; 5 | 6 | protected defaultWriter: WritableStreamDefaultWriter; 7 | 8 | protected onClose?: () => void; 9 | 10 | readonly info: InfoType; 11 | 12 | constructor(writableStream: WritableStream, info: InfoType, onClose?: () => void) { 13 | this.writableStream = writableStream; 14 | this.defaultWriter = writableStream.getWriter(); 15 | this.onClose = onClose; 16 | this.info = info; 17 | } 18 | 19 | write(chunk: T): Promise { 20 | return this.defaultWriter.write(chunk); 21 | } 22 | 23 | async close() { 24 | await this.defaultWriter.close(); 25 | this.defaultWriter.releaseLock(); 26 | this.onClose?.(); 27 | } 28 | } 29 | 30 | export class TextStreamWriter extends BaseStreamWriter {} 31 | 32 | export class ByteStreamWriter extends BaseStreamWriter {} 33 | -------------------------------------------------------------------------------- /src/room/attribute-typings.ts: -------------------------------------------------------------------------------- 1 | // This file was generated from JSON Schema using quicktype, do not modify it directly. 2 | // The code generation lives at https://github.com/livekit/attribute-definitions 3 | // 4 | // To parse this data: 5 | // 6 | // import { Convert, AgentAttributes, TranscriptionAttributes } from "./file"; 7 | // 8 | // const agentAttributes = Convert.toAgentAttributes(json); 9 | // const transcriptionAttributes = Convert.toTranscriptionAttributes(json); 10 | 11 | export interface AgentAttributes { 12 | 'lk.agent.inputs'?: AgentInput[]; 13 | 'lk.agent.outputs'?: AgentOutput[]; 14 | 'lk.agent.state'?: AgentState; 15 | 'lk.publish_on_behalf'?: string; 16 | [property: string]: any; 17 | } 18 | 19 | export type AgentInput = 'audio' | 'video' | 'text'; 20 | 21 | export type AgentOutput = 'transcription' | 'audio'; 22 | 23 | export type AgentState = 'idle' | 'initializing' | 'listening' | 'thinking' | 'speaking'; 24 | 25 | /** 26 | * Schema for transcription-related attributes 27 | */ 28 | export interface TranscriptionAttributes { 29 | /** 30 | * The segment id of the transcription 31 | */ 32 | 'lk.segment_id'?: string; 33 | /** 34 | * The associated track id of the transcription 35 | */ 36 | 'lk.transcribed_track_id'?: string; 37 | /** 38 | * Whether the transcription is final 39 | */ 40 | 'lk.transcription_final'?: boolean; 41 | [property: string]: any; 42 | } 43 | 44 | // Converts JSON strings to/from your types 45 | export class Convert { 46 | public static toAgentAttributes(json: string): AgentAttributes { 47 | return JSON.parse(json); 48 | } 49 | 50 | public static agentAttributesToJson(value: AgentAttributes): string { 51 | return JSON.stringify(value); 52 | } 53 | 54 | public static toTranscriptionAttributes(json: string): TranscriptionAttributes { 55 | return JSON.parse(json); 56 | } 57 | 58 | public static transcriptionAttributesToJson(value: TranscriptionAttributes): string { 59 | return JSON.stringify(value); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/room/defaults.ts: -------------------------------------------------------------------------------- 1 | import type { InternalRoomConnectOptions, InternalRoomOptions } from '../options'; 2 | import DefaultReconnectPolicy from './DefaultReconnectPolicy'; 3 | import type { 4 | AudioCaptureOptions, 5 | TrackPublishDefaults, 6 | VideoCaptureOptions, 7 | } from './track/options'; 8 | import { AudioPresets, ScreenSharePresets, VideoPresets } from './track/options'; 9 | 10 | export const defaultVideoCodec = 'vp8'; 11 | 12 | export const publishDefaults: TrackPublishDefaults = { 13 | audioPreset: AudioPresets.music, 14 | dtx: true, 15 | red: true, 16 | forceStereo: false, 17 | simulcast: true, 18 | screenShareEncoding: ScreenSharePresets.h1080fps15.encoding, 19 | stopMicTrackOnMute: false, 20 | videoCodec: defaultVideoCodec, 21 | backupCodec: true, 22 | preConnectBuffer: false, 23 | } as const; 24 | 25 | export const audioDefaults: AudioCaptureOptions = { 26 | deviceId: { ideal: 'default' }, 27 | autoGainControl: true, 28 | echoCancellation: true, 29 | noiseSuppression: true, 30 | voiceIsolation: true, 31 | }; 32 | 33 | export const videoDefaults: VideoCaptureOptions = { 34 | deviceId: { ideal: 'default' }, 35 | resolution: VideoPresets.h720.resolution, 36 | }; 37 | 38 | export const roomOptionDefaults: InternalRoomOptions = { 39 | adaptiveStream: false, 40 | dynacast: false, 41 | stopLocalTrackOnUnpublish: true, 42 | reconnectPolicy: new DefaultReconnectPolicy(), 43 | disconnectOnPageLeave: true, 44 | webAudioMix: false, 45 | } as const; 46 | 47 | export const roomConnectOptionDefaults: InternalRoomConnectOptions = { 48 | autoSubscribe: true, 49 | maxRetries: 1, 50 | peerConnectionTimeout: 15_000, 51 | websocketTimeout: 15_000, 52 | } as const; 53 | -------------------------------------------------------------------------------- /src/room/errors.ts: -------------------------------------------------------------------------------- 1 | import { DisconnectReason, RequestResponse_Reason } from '@livekit/protocol'; 2 | 3 | export class LivekitError extends Error { 4 | code: number; 5 | 6 | constructor(code: number, message?: string) { 7 | super(message || 'an error has occured'); 8 | this.name = 'LiveKitError'; 9 | this.code = code; 10 | } 11 | } 12 | 13 | export enum ConnectionErrorReason { 14 | NotAllowed, 15 | ServerUnreachable, 16 | InternalError, 17 | Cancelled, 18 | LeaveRequest, 19 | Timeout, 20 | } 21 | 22 | export class ConnectionError extends LivekitError { 23 | status?: number; 24 | 25 | context?: unknown | DisconnectReason; 26 | 27 | reason: ConnectionErrorReason; 28 | 29 | reasonName: string; 30 | 31 | constructor( 32 | message: string, 33 | reason: ConnectionErrorReason, 34 | status?: number, 35 | context?: unknown | DisconnectReason, 36 | ) { 37 | super(1, message); 38 | this.name = 'ConnectionError'; 39 | this.status = status; 40 | this.reason = reason; 41 | this.context = context; 42 | this.reasonName = ConnectionErrorReason[reason]; 43 | } 44 | } 45 | 46 | export class DeviceUnsupportedError extends LivekitError { 47 | constructor(message?: string) { 48 | super(21, message ?? 'device is unsupported'); 49 | this.name = 'DeviceUnsupportedError'; 50 | } 51 | } 52 | 53 | export class TrackInvalidError extends LivekitError { 54 | constructor(message?: string) { 55 | super(20, message ?? 'track is invalid'); 56 | this.name = 'TrackInvalidError'; 57 | } 58 | } 59 | 60 | export class UnsupportedServer extends LivekitError { 61 | constructor(message?: string) { 62 | super(10, message ?? 'unsupported server'); 63 | this.name = 'UnsupportedServer'; 64 | } 65 | } 66 | 67 | export class UnexpectedConnectionState extends LivekitError { 68 | constructor(message?: string) { 69 | super(12, message ?? 'unexpected connection state'); 70 | this.name = 'UnexpectedConnectionState'; 71 | } 72 | } 73 | 74 | export class NegotiationError extends LivekitError { 75 | constructor(message?: string) { 76 | super(13, message ?? 'unable to negotiate'); 77 | this.name = 'NegotiationError'; 78 | } 79 | } 80 | 81 | export class PublishDataError extends LivekitError { 82 | constructor(message?: string) { 83 | super(14, message ?? 'unable to publish data'); 84 | this.name = 'PublishDataError'; 85 | } 86 | } 87 | 88 | export class PublishTrackError extends LivekitError { 89 | status: number; 90 | 91 | constructor(message: string, status: number) { 92 | super(15, message); 93 | this.name = 'PublishTrackError'; 94 | this.status = status; 95 | } 96 | } 97 | 98 | export type RequestErrorReason = 99 | | Exclude 100 | | 'TimeoutError'; 101 | 102 | export class SignalRequestError extends LivekitError { 103 | reason: RequestErrorReason; 104 | 105 | reasonName: string; 106 | 107 | constructor(message: string, reason: RequestErrorReason) { 108 | super(15, message); 109 | this.reason = reason; 110 | this.reasonName = typeof reason === 'string' ? reason : RequestResponse_Reason[reason]; 111 | } 112 | } 113 | 114 | export enum MediaDeviceFailure { 115 | // user rejected permissions 116 | PermissionDenied = 'PermissionDenied', 117 | // device is not available 118 | NotFound = 'NotFound', 119 | // device is in use. On Windows, only a single tab may get access to a device at a time. 120 | DeviceInUse = 'DeviceInUse', 121 | Other = 'Other', 122 | } 123 | 124 | export namespace MediaDeviceFailure { 125 | export function getFailure(error: any): MediaDeviceFailure | undefined { 126 | if (error && 'name' in error) { 127 | if (error.name === 'NotFoundError' || error.name === 'DevicesNotFoundError') { 128 | return MediaDeviceFailure.NotFound; 129 | } 130 | if (error.name === 'NotAllowedError' || error.name === 'PermissionDeniedError') { 131 | return MediaDeviceFailure.PermissionDenied; 132 | } 133 | if (error.name === 'NotReadableError' || error.name === 'TrackStartError') { 134 | return MediaDeviceFailure.DeviceInUse; 135 | } 136 | return MediaDeviceFailure.Other; 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/room/participant/ParticipantTrackPermission.ts: -------------------------------------------------------------------------------- 1 | import { TrackPermission } from '@livekit/protocol'; 2 | 3 | export interface ParticipantTrackPermission { 4 | /** 5 | * The participant identity this permission applies to. 6 | * You can either provide this or `participantSid` 7 | */ 8 | participantIdentity?: string; 9 | 10 | /** 11 | * The participant server id this permission applies to. 12 | * You can either provide this or `participantIdentity` 13 | */ 14 | participantSid?: string; 15 | 16 | /** 17 | * Grant permission to all all tracks. Takes precedence over allowedTrackSids. 18 | * false if unset. 19 | */ 20 | allowAll?: boolean; 21 | 22 | /** 23 | * The list of track ids that the target participant can subscribe to. 24 | * When unset, it'll allow all tracks to be subscribed by the participant. 25 | * When empty, this participant is disallowed from subscribing to any tracks. 26 | */ 27 | allowedTrackSids?: string[]; 28 | } 29 | 30 | export function trackPermissionToProto(perms: ParticipantTrackPermission): TrackPermission { 31 | if (!perms.participantSid && !perms.participantIdentity) { 32 | throw new Error( 33 | 'Invalid track permission, must provide at least one of participantIdentity and participantSid', 34 | ); 35 | } 36 | return new TrackPermission({ 37 | participantIdentity: perms.participantIdentity ?? '', 38 | participantSid: perms.participantSid ?? '', 39 | allTracks: perms.allowAll ?? false, 40 | trackSids: perms.allowedTrackSids || [], 41 | }); 42 | } 43 | -------------------------------------------------------------------------------- /src/room/participant/publishUtils.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { ScreenSharePresets, VideoPreset, VideoPresets, VideoPresets43 } from '../track/options'; 3 | import { 4 | computeDefaultScreenShareSimulcastPresets, 5 | computeVideoEncodings, 6 | determineAppropriateEncoding, 7 | presets43, 8 | presets169, 9 | presetsForResolution, 10 | presetsScreenShare, 11 | sortPresets, 12 | } from './publishUtils'; 13 | 14 | describe('presetsForResolution', () => { 15 | it('handles screenshare', () => { 16 | expect(presetsForResolution(true, 600, 300)).toEqual(presetsScreenShare); 17 | }); 18 | 19 | it('handles landscape', () => { 20 | expect(presetsForResolution(false, 600, 300)).toEqual(presets169); 21 | expect(presetsForResolution(false, 500, 500)).toEqual(presets43); 22 | }); 23 | 24 | it('handles portrait', () => { 25 | expect(presetsForResolution(false, 300, 600)).toEqual(presets169); 26 | expect(presetsForResolution(false, 500, 500)).toEqual(presets43); 27 | }); 28 | }); 29 | 30 | describe('determineAppropriateEncoding', () => { 31 | it('uses higher encoding', () => { 32 | expect(determineAppropriateEncoding(false, 600, 300)).toEqual(VideoPresets.h360.encoding); 33 | }); 34 | 35 | it('handles portrait', () => { 36 | expect(determineAppropriateEncoding(false, 300, 600)).toEqual(VideoPresets.h360.encoding); 37 | }); 38 | }); 39 | 40 | describe('computeVideoEncodings', () => { 41 | it('handles non-simulcast', () => { 42 | const encodings = computeVideoEncodings(false, 640, 480, { 43 | simulcast: false, 44 | }); 45 | expect(encodings).toEqual([{}]); 46 | }); 47 | 48 | it('respects client defined bitrate', () => { 49 | const encodings = computeVideoEncodings(false, 640, 480, { 50 | simulcast: false, 51 | videoEncoding: { 52 | maxBitrate: 1024, 53 | }, 54 | }); 55 | expect(encodings).toHaveLength(1); 56 | expect(encodings![0].maxBitrate).toBe(1024); 57 | }); 58 | 59 | it('returns three encodings for high-res simulcast', () => { 60 | const encodings = computeVideoEncodings(false, 960, 540, { 61 | simulcast: true, 62 | }); 63 | expect(encodings).toHaveLength(3); 64 | 65 | // ensure they are what we expect 66 | expect(encodings![0].rid).toBe('q'); 67 | expect(encodings![0].maxBitrate).toBe(VideoPresets.h180.encoding.maxBitrate); 68 | expect(encodings![0].scaleResolutionDownBy).toBe(3); 69 | expect(encodings![1].rid).toBe('h'); 70 | expect(encodings![1].scaleResolutionDownBy).toBe(1.5); 71 | expect(encodings![2].rid).toBe('f'); 72 | }); 73 | 74 | it('handles portrait simulcast', () => { 75 | const encodings = computeVideoEncodings(false, 540, 960, { 76 | simulcast: true, 77 | }); 78 | expect(encodings).toHaveLength(3); 79 | expect(encodings![0].scaleResolutionDownBy).toBe(3); 80 | expect(encodings![1].scaleResolutionDownBy).toBe(1.5); 81 | expect(encodings![2].maxBitrate).toBe(VideoPresets.h540.encoding.maxBitrate); 82 | }); 83 | 84 | it('returns two encodings for lower-res simulcast', () => { 85 | const encodings = computeVideoEncodings(false, 640, 360, { 86 | simulcast: true, 87 | }); 88 | expect(encodings).toHaveLength(2); 89 | 90 | // ensure they are what we expect 91 | expect(encodings![0].rid).toBe('q'); 92 | expect(encodings![0].maxBitrate).toBe(VideoPresets.h180.encoding.maxBitrate); 93 | expect(encodings![1].rid).toBe('h'); 94 | expect(encodings![1].maxBitrate).toBe(VideoPresets.h360.encoding.maxBitrate); 95 | }); 96 | 97 | it('returns one encoding if an empty array is provided for custom screen share layers', () => { 98 | const encodings = computeVideoEncodings(true, 1920, 1080, { 99 | simulcast: true, 100 | screenShareSimulcastLayers: [], 101 | }); 102 | expect(encodings).toHaveLength(1); 103 | 104 | // ensure they are what we expect 105 | expect(encodings![0].rid).toBe('q'); 106 | expect(encodings![0].scaleResolutionDownBy).toBe(1); 107 | }); 108 | 109 | it('respects provided min resolution', () => { 110 | const encodings = computeVideoEncodings(false, 100, 120, { 111 | simulcast: true, 112 | }); 113 | expect(encodings).toHaveLength(1); 114 | expect(encodings![0].rid).toBe('q'); 115 | expect(encodings![0].maxBitrate).toBe(VideoPresets43.h120.encoding.maxBitrate); 116 | expect(encodings![0].scaleResolutionDownBy).toBe(1); 117 | }); 118 | 119 | // it('respects default backup codec encoding', () => { 120 | // const vp8Encodings = computeTrackBackupEncodings(false, 100, 120, { simulcast: true }); 121 | // const h264Encodings = computeVideoEncodings(false, 100, 120, { 122 | // simulcast: true, 123 | // videoCodec: 'h264', 124 | // }); 125 | // const av1Encodings = computeVideoEncodings(false, 100, 120, { 126 | // simulcast: true, 127 | // videoCodec: 'av1', 128 | // }); 129 | // expect(h264Encodings).toHaveLength(1); 130 | // expect(h264Encodings![0].rid).toBe('q'); 131 | // expect(h264Encodings![0].maxBitrate).toBe(vp8Encodings[0].maxBitrate! * 1.1); 132 | // expect(av1Encodings![0].maxBitrate).toBe(vp8Encodings[0].maxBitrate! * 0.7); 133 | // expect(h264Encodings![0].scaleResolutionDownBy).toBe(1); 134 | // }); 135 | 136 | // it('respects custom backup codec encoding', () => { 137 | // const encodings = computeVideoEncodings(false, 100, 120, { 138 | // simulcast: true, 139 | // videoCodec: 'h264', 140 | // backupCodec: { 141 | // vp8: { maxBitrate: 1_000 }, 142 | // h264: { maxBitrate: 2_000 }, 143 | // }, 144 | // }); 145 | // expect(encodings).toHaveLength(1); 146 | // expect(encodings![0].rid).toBe('q'); 147 | // expect(encodings![0].maxBitrate).toBe(2_000); 148 | // expect(encodings![0].scaleResolutionDownBy).toBe(1); 149 | // }); 150 | }); 151 | 152 | describe('customSimulcastLayers', () => { 153 | it('sorts presets from lowest to highest', () => { 154 | const sortedPresets = sortPresets([ 155 | VideoPresets.h1440, 156 | VideoPresets.h360, 157 | VideoPresets.h1080, 158 | VideoPresets.h90, 159 | ]) as Array; 160 | expect(sortPresets).not.toBeUndefined(); 161 | expect(sortedPresets[0]).toBe(VideoPresets.h90); 162 | expect(sortedPresets[1]).toBe(VideoPresets.h360); 163 | expect(sortedPresets[2]).toBe(VideoPresets.h1080); 164 | expect(sortedPresets[3]).toBe(VideoPresets.h1440); 165 | }); 166 | it('sorts presets from lowest to highest, even when dimensions are the same', () => { 167 | const sortedPresets = sortPresets([ 168 | new VideoPreset(1920, 1080, 3_000_000, 20), 169 | new VideoPreset(1920, 1080, 2_000_000, 15), 170 | new VideoPreset(1920, 1080, 3_000_000, 15), 171 | ]) as Array; 172 | expect(sortPresets).not.toBeUndefined(); 173 | expect(sortedPresets[0].encoding.maxBitrate).toBe(2_000_000); 174 | expect(sortedPresets[1].encoding.maxFramerate).toBe(15); 175 | expect(sortedPresets[2].encoding.maxFramerate).toBe(20); 176 | }); 177 | }); 178 | 179 | describe('screenShareSimulcastDefaults', () => { 180 | it('computes appropriate bitrate from original preset', () => { 181 | const defaultSimulcastLayers = computeDefaultScreenShareSimulcastPresets( 182 | ScreenSharePresets.h720fps15, 183 | ); 184 | expect(defaultSimulcastLayers[0].width).toBe(640); 185 | expect(defaultSimulcastLayers[0].height).toBe(360); 186 | expect(defaultSimulcastLayers[0].encoding.maxFramerate).toBe(15); 187 | expect(defaultSimulcastLayers[0].encoding.maxBitrate).toBe(375000); 188 | }); 189 | }); 190 | -------------------------------------------------------------------------------- /src/room/rpc.ts: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: 2024 LiveKit, Inc. 2 | // 3 | // SPDX-License-Identifier: Apache-2.0 4 | import { RpcError as RpcError_Proto } from '@livekit/protocol'; 5 | 6 | /** Parameters for initiating an RPC call */ 7 | export interface PerformRpcParams { 8 | /** The `identity` of the destination participant */ 9 | destinationIdentity: string; 10 | /** The method name to call */ 11 | method: string; 12 | /** The method payload */ 13 | payload: string; 14 | /** Timeout for receiving a response after initial connection (milliseconds). Default: 10000 */ 15 | responseTimeout?: number; 16 | } 17 | 18 | /** 19 | * Data passed to method handler for incoming RPC invocations 20 | */ 21 | export interface RpcInvocationData { 22 | /** 23 | * The unique request ID. Will match at both sides of the call, useful for debugging or logging. 24 | */ 25 | requestId: string; 26 | 27 | /** 28 | * The unique participant identity of the caller. 29 | */ 30 | callerIdentity: string; 31 | 32 | /** 33 | * The payload of the request. User-definable format, typically JSON. 34 | */ 35 | payload: string; 36 | 37 | /** 38 | * The maximum time the caller will wait for a response. 39 | */ 40 | responseTimeout: number; 41 | } 42 | 43 | /** 44 | * Specialized error handling for RPC methods. 45 | * 46 | * Instances of this type, when thrown in a method handler, will have their `message` 47 | * serialized and sent across the wire. The sender will receive an equivalent error on the other side. 48 | * 49 | * Built-in types are included but developers may use any string, with a max length of 256 bytes. 50 | */ 51 | 52 | export class RpcError extends Error { 53 | static MAX_MESSAGE_BYTES = 256; 54 | 55 | static MAX_DATA_BYTES = 15360; // 15 KB 56 | 57 | code: number; 58 | 59 | data?: string; 60 | 61 | /** 62 | * Creates an error object with the given code and message, plus an optional data payload. 63 | * 64 | * If thrown in an RPC method handler, the error will be sent back to the caller. 65 | * 66 | * Error codes 1001-1999 are reserved for built-in errors (see RpcError.ErrorCode for their meanings). 67 | */ 68 | constructor(code: number, message: string, data?: string) { 69 | super(message); 70 | this.code = code; 71 | this.message = truncateBytes(message, RpcError.MAX_MESSAGE_BYTES); 72 | this.data = data ? truncateBytes(data, RpcError.MAX_DATA_BYTES) : undefined; 73 | } 74 | 75 | /** 76 | * @internal 77 | */ 78 | static fromProto(proto: RpcError_Proto) { 79 | return new RpcError(proto.code, proto.message, proto.data); 80 | } 81 | 82 | /** 83 | * @internal 84 | */ 85 | toProto() { 86 | return new RpcError_Proto({ 87 | code: this.code as number, 88 | message: this.message, 89 | data: this.data, 90 | }); 91 | } 92 | 93 | static ErrorCode = { 94 | APPLICATION_ERROR: 1500, 95 | CONNECTION_TIMEOUT: 1501, 96 | RESPONSE_TIMEOUT: 1502, 97 | RECIPIENT_DISCONNECTED: 1503, 98 | RESPONSE_PAYLOAD_TOO_LARGE: 1504, 99 | SEND_FAILED: 1505, 100 | 101 | UNSUPPORTED_METHOD: 1400, 102 | RECIPIENT_NOT_FOUND: 1401, 103 | REQUEST_PAYLOAD_TOO_LARGE: 1402, 104 | UNSUPPORTED_SERVER: 1403, 105 | UNSUPPORTED_VERSION: 1404, 106 | } as const; 107 | 108 | /** 109 | * @internal 110 | */ 111 | static ErrorMessage: Record = { 112 | APPLICATION_ERROR: 'Application error in method handler', 113 | CONNECTION_TIMEOUT: 'Connection timeout', 114 | RESPONSE_TIMEOUT: 'Response timeout', 115 | RECIPIENT_DISCONNECTED: 'Recipient disconnected', 116 | RESPONSE_PAYLOAD_TOO_LARGE: 'Response payload too large', 117 | SEND_FAILED: 'Failed to send', 118 | 119 | UNSUPPORTED_METHOD: 'Method not supported at destination', 120 | RECIPIENT_NOT_FOUND: 'Recipient not found', 121 | REQUEST_PAYLOAD_TOO_LARGE: 'Request payload too large', 122 | UNSUPPORTED_SERVER: 'RPC not supported by server', 123 | UNSUPPORTED_VERSION: 'Unsupported RPC version', 124 | } as const; 125 | 126 | /** 127 | * Creates an error object from the code, with an auto-populated message. 128 | * 129 | * @internal 130 | */ 131 | static builtIn(key: keyof typeof RpcError.ErrorCode, data?: string): RpcError { 132 | return new RpcError(RpcError.ErrorCode[key], RpcError.ErrorMessage[key], data); 133 | } 134 | } 135 | 136 | /* 137 | * Maximum payload size for RPC requests and responses. If a payload exceeds this size, 138 | * the RPC call will fail with a REQUEST_PAYLOAD_TOO_LARGE(1402) or RESPONSE_PAYLOAD_TOO_LARGE(1504) error. 139 | */ 140 | export const MAX_PAYLOAD_BYTES = 15360; // 15 KB 141 | 142 | /** 143 | * @internal 144 | */ 145 | export function byteLength(str: string): number { 146 | const encoder = new TextEncoder(); 147 | return encoder.encode(str).length; 148 | } 149 | 150 | /** 151 | * @internal 152 | */ 153 | export function truncateBytes(str: string, maxBytes: number): string { 154 | if (byteLength(str) <= maxBytes) { 155 | return str; 156 | } 157 | 158 | let low = 0; 159 | let high = str.length; 160 | const encoder = new TextEncoder(); 161 | 162 | while (low < high) { 163 | const mid = Math.floor((low + high + 1) / 2); 164 | if (encoder.encode(str.slice(0, mid)).length <= maxBytes) { 165 | low = mid; 166 | } else { 167 | high = mid - 1; 168 | } 169 | } 170 | 171 | return str.slice(0, low); 172 | } 173 | -------------------------------------------------------------------------------- /src/room/stats.ts: -------------------------------------------------------------------------------- 1 | export const monitorFrequency = 2000; 2 | 3 | // key stats for senders and receivers 4 | interface SenderStats { 5 | /** number of packets sent */ 6 | packetsSent?: number; 7 | 8 | /** number of bytes sent */ 9 | bytesSent?: number; 10 | 11 | /** jitter as perceived by remote */ 12 | jitter?: number; 13 | 14 | /** packets reported lost by remote */ 15 | packetsLost?: number; 16 | 17 | /** RTT reported by remote */ 18 | roundTripTime?: number; 19 | 20 | /** ID of the outbound stream */ 21 | streamId?: string; 22 | 23 | timestamp: number; 24 | } 25 | 26 | export interface AudioSenderStats extends SenderStats { 27 | type: 'audio'; 28 | } 29 | 30 | export interface VideoSenderStats extends SenderStats { 31 | type: 'video'; 32 | 33 | firCount: number; 34 | 35 | pliCount: number; 36 | 37 | nackCount: number; 38 | 39 | rid: string; 40 | 41 | frameWidth: number; 42 | 43 | frameHeight: number; 44 | 45 | framesPerSecond: number; 46 | 47 | framesSent: number; 48 | 49 | // bandwidth, cpu, other, none 50 | qualityLimitationReason?: string; 51 | 52 | qualityLimitationDurations?: Record; 53 | 54 | qualityLimitationResolutionChanges?: number; 55 | 56 | retransmittedPacketsSent?: number; 57 | 58 | targetBitrate: number; 59 | } 60 | 61 | interface ReceiverStats { 62 | jitterBufferDelay?: number; 63 | 64 | /** packets reported lost by remote */ 65 | packetsLost?: number; 66 | 67 | /** number of packets sent */ 68 | packetsReceived?: number; 69 | 70 | bytesReceived?: number; 71 | 72 | streamId?: string; 73 | 74 | jitter?: number; 75 | 76 | timestamp: number; 77 | } 78 | 79 | export interface AudioReceiverStats extends ReceiverStats { 80 | type: 'audio'; 81 | 82 | concealedSamples?: number; 83 | 84 | concealmentEvents?: number; 85 | 86 | silentConcealedSamples?: number; 87 | 88 | silentConcealmentEvents?: number; 89 | 90 | totalAudioEnergy?: number; 91 | 92 | totalSamplesDuration?: number; 93 | } 94 | 95 | export interface VideoReceiverStats extends ReceiverStats { 96 | type: 'video'; 97 | 98 | framesDecoded: number; 99 | 100 | framesDropped: number; 101 | 102 | framesReceived: number; 103 | 104 | frameWidth?: number; 105 | 106 | frameHeight?: number; 107 | 108 | firCount?: number; 109 | 110 | pliCount?: number; 111 | 112 | nackCount?: number; 113 | 114 | decoderImplementation?: string; 115 | 116 | mimeType?: string; 117 | } 118 | 119 | export function computeBitrate( 120 | currentStats: T, 121 | prevStats?: T, 122 | ): number { 123 | if (!prevStats) { 124 | return 0; 125 | } 126 | let bytesNow: number | undefined; 127 | let bytesPrev: number | undefined; 128 | if ('bytesReceived' in currentStats) { 129 | bytesNow = (currentStats as ReceiverStats).bytesReceived; 130 | bytesPrev = (prevStats as ReceiverStats).bytesReceived; 131 | } else if ('bytesSent' in currentStats) { 132 | bytesNow = (currentStats as SenderStats).bytesSent; 133 | bytesPrev = (prevStats as SenderStats).bytesSent; 134 | } 135 | if ( 136 | bytesNow === undefined || 137 | bytesPrev === undefined || 138 | currentStats.timestamp === undefined || 139 | prevStats.timestamp === undefined 140 | ) { 141 | return 0; 142 | } 143 | return ((bytesNow - bytesPrev) * 8 * 1000) / (currentStats.timestamp - prevStats.timestamp); 144 | } 145 | -------------------------------------------------------------------------------- /src/room/timers.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Timers that can be overridden with platform specific implementations 3 | * that ensure that they are fired. These should be used when it is critical 4 | * that the timer fires on time. 5 | */ 6 | export default class CriticalTimers { 7 | static setTimeout: (...args: Parameters) => ReturnType = ( 8 | ...args: Parameters 9 | // eslint-disable-next-line @typescript-eslint/no-implied-eval 10 | ) => setTimeout(...args); 11 | 12 | static setInterval: (...args: Parameters) => ReturnType = 13 | // eslint-disable-next-line @typescript-eslint/no-implied-eval 14 | (...args: Parameters) => setInterval(...args); 15 | 16 | static clearTimeout: ( 17 | ...args: Parameters 18 | ) => ReturnType = (...args: Parameters) => 19 | clearTimeout(...args); 20 | 21 | static clearInterval: ( 22 | ...args: Parameters 23 | ) => ReturnType = (...args: Parameters) => 24 | clearInterval(...args); 25 | } 26 | -------------------------------------------------------------------------------- /src/room/track/LocalTrackPublication.ts: -------------------------------------------------------------------------------- 1 | import { AudioTrackFeature, TrackInfo } from '@livekit/protocol'; 2 | import { TrackEvent } from '../events'; 3 | import type { LoggerOptions } from '../types'; 4 | import { isAudioTrack } from '../utils'; 5 | import LocalAudioTrack from './LocalAudioTrack'; 6 | import type LocalTrack from './LocalTrack'; 7 | import type LocalVideoTrack from './LocalVideoTrack'; 8 | import type { Track } from './Track'; 9 | import { TrackPublication } from './TrackPublication'; 10 | import type { TrackPublishOptions } from './options'; 11 | 12 | export default class LocalTrackPublication extends TrackPublication { 13 | track?: LocalTrack = undefined; 14 | 15 | options?: TrackPublishOptions; 16 | 17 | get isUpstreamPaused() { 18 | return this.track?.isUpstreamPaused; 19 | } 20 | 21 | constructor(kind: Track.Kind, ti: TrackInfo, track?: LocalTrack, loggerOptions?: LoggerOptions) { 22 | super(kind, ti.sid, ti.name, loggerOptions); 23 | 24 | this.updateInfo(ti); 25 | this.setTrack(track); 26 | } 27 | 28 | setTrack(track?: Track) { 29 | if (this.track) { 30 | this.track.off(TrackEvent.Ended, this.handleTrackEnded); 31 | } 32 | 33 | super.setTrack(track); 34 | 35 | if (track) { 36 | track.on(TrackEvent.Ended, this.handleTrackEnded); 37 | } 38 | } 39 | 40 | get isMuted(): boolean { 41 | if (this.track) { 42 | return this.track.isMuted; 43 | } 44 | return super.isMuted; 45 | } 46 | 47 | get audioTrack(): LocalAudioTrack | undefined { 48 | return super.audioTrack as LocalAudioTrack | undefined; 49 | } 50 | 51 | get videoTrack(): LocalVideoTrack | undefined { 52 | return super.videoTrack as LocalVideoTrack | undefined; 53 | } 54 | 55 | get isLocal() { 56 | return true; 57 | } 58 | 59 | /** 60 | * Mute the track associated with this publication 61 | */ 62 | async mute() { 63 | return this.track?.mute(); 64 | } 65 | 66 | /** 67 | * Unmute track associated with this publication 68 | */ 69 | async unmute() { 70 | return this.track?.unmute(); 71 | } 72 | 73 | /** 74 | * Pauses the media stream track associated with this publication from being sent to the server 75 | * and signals "muted" event to other participants 76 | * Useful if you want to pause the stream without pausing the local media stream track 77 | */ 78 | async pauseUpstream() { 79 | await this.track?.pauseUpstream(); 80 | } 81 | 82 | /** 83 | * Resumes sending the media stream track associated with this publication to the server after a call to [[pauseUpstream()]] 84 | * and signals "unmuted" event to other participants (unless the track is explicitly muted) 85 | */ 86 | async resumeUpstream() { 87 | await this.track?.resumeUpstream(); 88 | } 89 | 90 | getTrackFeatures() { 91 | if (isAudioTrack(this.track)) { 92 | const settings = this.track!.getSourceTrackSettings(); 93 | const features: Set = new Set(); 94 | if (settings.autoGainControl) { 95 | features.add(AudioTrackFeature.TF_AUTO_GAIN_CONTROL); 96 | } 97 | if (settings.echoCancellation) { 98 | features.add(AudioTrackFeature.TF_ECHO_CANCELLATION); 99 | } 100 | if (settings.noiseSuppression) { 101 | features.add(AudioTrackFeature.TF_NOISE_SUPPRESSION); 102 | } 103 | if (settings.channelCount && settings.channelCount > 1) { 104 | features.add(AudioTrackFeature.TF_STEREO); 105 | } 106 | if (!this.options?.dtx) { 107 | features.add(AudioTrackFeature.TF_NO_DTX); 108 | } 109 | if (this.track.enhancedNoiseCancellation) { 110 | features.add(AudioTrackFeature.TF_ENHANCED_NOISE_CANCELLATION); 111 | } 112 | return Array.from(features.values()); 113 | } else return []; 114 | } 115 | 116 | handleTrackEnded = () => { 117 | this.emit(TrackEvent.Ended); 118 | }; 119 | } 120 | -------------------------------------------------------------------------------- /src/room/track/LocalVideoTrack.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { videoLayersFromEncodings } from './LocalVideoTrack'; 3 | import { VideoQuality } from './Track'; 4 | 5 | describe('videoLayersFromEncodings', () => { 6 | it('returns single layer for no encoding', () => { 7 | const layers = videoLayersFromEncodings(640, 360); 8 | expect(layers).toHaveLength(1); 9 | expect(layers[0].quality).toBe(VideoQuality.HIGH); 10 | expect(layers[0].width).toBe(640); 11 | expect(layers[0].height).toBe(360); 12 | }); 13 | 14 | it('returns single layer for explicit encoding', () => { 15 | const layers = videoLayersFromEncodings(640, 360, [ 16 | { 17 | maxBitrate: 200_000, 18 | }, 19 | ]); 20 | expect(layers).toHaveLength(1); 21 | expect(layers[0].quality).toBe(VideoQuality.HIGH); 22 | expect(layers[0].bitrate).toBe(200_000); 23 | }); 24 | 25 | it('returns three layers for simulcast', () => { 26 | const layers = videoLayersFromEncodings(1280, 720, [ 27 | { 28 | scaleResolutionDownBy: 4, 29 | rid: 'q', 30 | maxBitrate: 125_000, 31 | }, 32 | { 33 | scaleResolutionDownBy: 2, 34 | rid: 'h', 35 | maxBitrate: 500_000, 36 | }, 37 | { 38 | rid: 'f', 39 | maxBitrate: 1_200_000, 40 | }, 41 | ]); 42 | 43 | expect(layers).toHaveLength(3); 44 | expect(layers[0].quality).toBe(VideoQuality.LOW); 45 | expect(layers[0].width).toBe(320); 46 | expect(layers[2].quality).toBe(VideoQuality.HIGH); 47 | expect(layers[2].height).toBe(720); 48 | }); 49 | 50 | it('returns qualities starting from lowest for SVC', () => { 51 | const layers = videoLayersFromEncodings( 52 | 1280, 53 | 720, 54 | [ 55 | { 56 | /** @ts-ignore */ 57 | scalabilityMode: 'L2T2', 58 | }, 59 | ], 60 | true, 61 | ); 62 | 63 | expect(layers).toHaveLength(2); 64 | expect(layers[0].quality).toBe(VideoQuality.MEDIUM); 65 | expect(layers[0].width).toBe(1280); 66 | expect(layers[1].quality).toBe(VideoQuality.LOW); 67 | expect(layers[1].width).toBe(640); 68 | }); 69 | 70 | it('returns qualities starting from lowest for SVC (three layers)', () => { 71 | const layers = videoLayersFromEncodings( 72 | 1280, 73 | 720, 74 | [ 75 | { 76 | /** @ts-ignore */ 77 | scalabilityMode: 'L3T3', 78 | }, 79 | ], 80 | true, 81 | ); 82 | 83 | expect(layers).toHaveLength(3); 84 | expect(layers[0].quality).toBe(VideoQuality.HIGH); 85 | expect(layers[0].width).toBe(1280); 86 | expect(layers[1].quality).toBe(VideoQuality.MEDIUM); 87 | expect(layers[1].width).toBe(640); 88 | expect(layers[2].quality).toBe(VideoQuality.LOW); 89 | expect(layers[2].width).toBe(320); 90 | }); 91 | 92 | it('returns qualities starting from lowest for SVC (single layer)', () => { 93 | const layers = videoLayersFromEncodings( 94 | 1280, 95 | 720, 96 | [ 97 | { 98 | /** @ts-ignore */ 99 | scalabilityMode: 'L1T2', 100 | }, 101 | ], 102 | true, 103 | ); 104 | 105 | expect(layers).toHaveLength(1); 106 | expect(layers[0].quality).toBe(VideoQuality.LOW); 107 | expect(layers[0].width).toBe(1280); 108 | }); 109 | 110 | it('handles portrait', () => { 111 | const layers = videoLayersFromEncodings(720, 1280, [ 112 | { 113 | scaleResolutionDownBy: 4, 114 | rid: 'q', 115 | maxBitrate: 125_000, 116 | }, 117 | { 118 | scaleResolutionDownBy: 2, 119 | rid: 'h', 120 | maxBitrate: 500_000, 121 | }, 122 | { 123 | rid: 'f', 124 | maxBitrate: 1_200_000, 125 | }, 126 | ]); 127 | expect(layers).toHaveLength(3); 128 | expect(layers[0].quality).toBe(VideoQuality.LOW); 129 | expect(layers[0].height).toBe(320); 130 | expect(layers[2].quality).toBe(VideoQuality.HIGH); 131 | expect(layers[2].width).toBe(720); 132 | }); 133 | }); 134 | -------------------------------------------------------------------------------- /src/room/track/RemoteTrack.ts: -------------------------------------------------------------------------------- 1 | import { TrackEvent } from '../events'; 2 | import { monitorFrequency } from '../stats'; 3 | import type { LoggerOptions } from '../types'; 4 | import { Track } from './Track'; 5 | import { supportsSynchronizationSources } from './utils'; 6 | 7 | export default abstract class RemoteTrack< 8 | TrackKind extends Track.Kind = Track.Kind, 9 | > extends Track { 10 | /** @internal */ 11 | receiver: RTCRtpReceiver | undefined; 12 | 13 | constructor( 14 | mediaTrack: MediaStreamTrack, 15 | sid: string, 16 | kind: TrackKind, 17 | receiver: RTCRtpReceiver, 18 | loggerOptions?: LoggerOptions, 19 | ) { 20 | super(mediaTrack, kind, loggerOptions); 21 | 22 | this.sid = sid; 23 | this.receiver = receiver; 24 | } 25 | 26 | get isLocal() { 27 | return false; 28 | } 29 | 30 | /** @internal */ 31 | setMuted(muted: boolean) { 32 | if (this.isMuted !== muted) { 33 | this.isMuted = muted; 34 | this._mediaStreamTrack.enabled = !muted; 35 | this.emit(muted ? TrackEvent.Muted : TrackEvent.Unmuted, this); 36 | } 37 | } 38 | 39 | /** @internal */ 40 | setMediaStream(stream: MediaStream) { 41 | // this is needed to determine when the track is finished 42 | this.mediaStream = stream; 43 | const onRemoveTrack = (event: MediaStreamTrackEvent) => { 44 | if (event.track === this._mediaStreamTrack) { 45 | stream.removeEventListener('removetrack', onRemoveTrack); 46 | if (this.receiver && 'playoutDelayHint' in this.receiver) { 47 | this.receiver.playoutDelayHint = undefined; 48 | } 49 | this.receiver = undefined; 50 | this._currentBitrate = 0; 51 | this.emit(TrackEvent.Ended, this); 52 | } 53 | }; 54 | stream.addEventListener('removetrack', onRemoveTrack); 55 | } 56 | 57 | start() { 58 | this.startMonitor(); 59 | // use `enabled` of track to enable re-use of transceiver 60 | super.enable(); 61 | } 62 | 63 | stop() { 64 | this.stopMonitor(); 65 | // use `enabled` of track to enable re-use of transceiver 66 | super.disable(); 67 | } 68 | 69 | /** 70 | * Gets the RTCStatsReport for the RemoteTrack's underlying RTCRtpReceiver 71 | * See https://developer.mozilla.org/en-US/docs/Web/API/RTCStatsReport 72 | * 73 | * @returns Promise | undefined 74 | */ 75 | async getRTCStatsReport(): Promise { 76 | if (!this.receiver?.getStats) { 77 | return; 78 | } 79 | const statsReport = await this.receiver.getStats(); 80 | return statsReport; 81 | } 82 | 83 | /** 84 | * Allows to set a playout delay (in seconds) for this track. 85 | * A higher value allows for more buffering of the track in the browser 86 | * and will result in a delay of media being played back of `delayInSeconds` 87 | */ 88 | setPlayoutDelay(delayInSeconds: number): void { 89 | if (this.receiver) { 90 | if ('playoutDelayHint' in this.receiver) { 91 | this.receiver.playoutDelayHint = delayInSeconds; 92 | } else { 93 | this.log.warn('Playout delay not supported in this browser'); 94 | } 95 | } else { 96 | this.log.warn('Cannot set playout delay, track already ended'); 97 | } 98 | } 99 | 100 | /** 101 | * Returns the current playout delay (in seconds) of this track. 102 | */ 103 | getPlayoutDelay(): number { 104 | if (this.receiver) { 105 | if ('playoutDelayHint' in this.receiver) { 106 | return this.receiver.playoutDelayHint as number; 107 | } else { 108 | this.log.warn('Playout delay not supported in this browser'); 109 | } 110 | } else { 111 | this.log.warn('Cannot get playout delay, track already ended'); 112 | } 113 | return 0; 114 | } 115 | 116 | /* @internal */ 117 | startMonitor() { 118 | if (!this.monitorInterval) { 119 | this.monitorInterval = setInterval(() => this.monitorReceiver(), monitorFrequency); 120 | } 121 | if (supportsSynchronizationSources()) { 122 | this.registerTimeSyncUpdate(); 123 | } 124 | } 125 | 126 | protected abstract monitorReceiver(): void; 127 | 128 | registerTimeSyncUpdate() { 129 | const loop = () => { 130 | this.timeSyncHandle = requestAnimationFrame(() => loop()); 131 | const sources = this.receiver?.getSynchronizationSources()[0]; 132 | if (sources) { 133 | const { timestamp, rtpTimestamp } = sources; 134 | if (rtpTimestamp && this.rtpTimestamp !== rtpTimestamp) { 135 | this.emit(TrackEvent.TimeSyncUpdate, { timestamp, rtpTimestamp }); 136 | this.rtpTimestamp = rtpTimestamp; 137 | } 138 | } 139 | }; 140 | loop(); 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/room/track/RemoteVideoTrack.test.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 2 | import MockMediaStreamTrack from '../../test/MockMediaStreamTrack'; 3 | import { TrackEvent } from '../events'; 4 | import RemoteVideoTrack, { ElementInfo } from './RemoteVideoTrack'; 5 | import type { Track } from './Track'; 6 | 7 | vi.useFakeTimers(); 8 | 9 | describe('RemoteVideoTrack', () => { 10 | let track: RemoteVideoTrack; 11 | 12 | beforeEach(() => { 13 | track = new RemoteVideoTrack(new MockMediaStreamTrack(), 'sid', undefined, {}); 14 | }); 15 | describe('element visibility', () => { 16 | let events: boolean[] = []; 17 | 18 | beforeEach(() => { 19 | track.on(TrackEvent.VisibilityChanged, (visible) => { 20 | events.push(visible); 21 | }); 22 | }); 23 | afterEach(() => { 24 | events = []; 25 | }); 26 | 27 | it('emits a visibility event upon observing visible element', () => { 28 | const elementInfo = new MockElementInfo(); 29 | elementInfo.visible = true; 30 | 31 | track.observeElementInfo(elementInfo); 32 | 33 | expect(events).toHaveLength(1); 34 | expect(events[0]).toBeTruthy(); 35 | }); 36 | 37 | it('emits a visibility event upon element becoming visible', () => { 38 | const elementInfo = new MockElementInfo(); 39 | track.observeElementInfo(elementInfo); 40 | 41 | elementInfo.setVisible(true); 42 | 43 | expect(events).toHaveLength(2); 44 | expect(events[1]).toBeTruthy(); 45 | }); 46 | 47 | it('emits a visibility event upon removing only visible element', () => { 48 | const elementInfo = new MockElementInfo(); 49 | elementInfo.visible = true; 50 | 51 | track.observeElementInfo(elementInfo); 52 | track.stopObservingElementInfo(elementInfo); 53 | 54 | expect(events).toHaveLength(2); 55 | expect(events[1]).toBeFalsy(); 56 | }); 57 | }); 58 | 59 | describe('element dimensions', () => { 60 | let events: Track.Dimensions[] = []; 61 | 62 | beforeEach(() => { 63 | track.on(TrackEvent.VideoDimensionsChanged, (dimensions) => { 64 | events.push(dimensions); 65 | }); 66 | }); 67 | 68 | afterEach(() => { 69 | events = []; 70 | }); 71 | 72 | it('emits a dimensions event upon observing element', () => { 73 | const elementInfo = new MockElementInfo(); 74 | elementInfo.setDimensions(100, 100); 75 | 76 | track.observeElementInfo(elementInfo); 77 | vi.runAllTimers(); 78 | 79 | expect(events).toHaveLength(1); 80 | expect(events[0].width).toBe(100); 81 | expect(events[0].height).toBe(100); 82 | }); 83 | 84 | it('emits a dimensions event upon element resize', () => { 85 | const elementInfo = new MockElementInfo(); 86 | elementInfo.setDimensions(100, 100); 87 | 88 | track.observeElementInfo(elementInfo); 89 | vi.runAllTimers(); 90 | 91 | elementInfo.setDimensions(200, 200); 92 | vi.runAllTimers(); 93 | 94 | expect(events).toHaveLength(2); 95 | expect(events[1].width).toBe(200); 96 | expect(events[1].height).toBe(200); 97 | }); 98 | }); 99 | }); 100 | 101 | class MockElementInfo implements ElementInfo { 102 | element: object = {}; 103 | 104 | private _width = 0; 105 | 106 | private _height = 0; 107 | 108 | setDimensions(width: number, height: number) { 109 | let shouldEmit = false; 110 | if (this._width !== width) { 111 | this._width = width; 112 | shouldEmit = true; 113 | } 114 | if (this._height !== height) { 115 | this._height = height; 116 | shouldEmit = true; 117 | } 118 | 119 | if (shouldEmit) { 120 | this.handleResize?.(); 121 | } 122 | } 123 | 124 | width(): number { 125 | return this._width; 126 | } 127 | 128 | height(): number { 129 | return this._height; 130 | } 131 | 132 | visible = false; 133 | 134 | pictureInPicture = false; 135 | 136 | setVisible = (visible: boolean) => { 137 | if (this.visible !== visible) { 138 | this.visible = visible; 139 | this.handleVisibilityChanged?.(); 140 | } 141 | }; 142 | 143 | visibilityChangedAt = 0; 144 | 145 | handleResize?: () => void; 146 | 147 | handleVisibilityChanged?: () => void; 148 | 149 | observe(): void {} 150 | 151 | stopObserving(): void {} 152 | } 153 | -------------------------------------------------------------------------------- /src/room/track/TrackPublication.ts: -------------------------------------------------------------------------------- 1 | import { Encryption_Type } from '@livekit/protocol'; 2 | import type { 3 | SubscriptionError, 4 | TrackInfo, 5 | UpdateSubscription, 6 | UpdateTrackSettings, 7 | } from '@livekit/protocol'; 8 | import { EventEmitter } from 'events'; 9 | import type TypedEventEmitter from 'typed-emitter'; 10 | import log, { LoggerNames, getLogger } from '../../logger'; 11 | import { TrackEvent } from '../events'; 12 | import type { LoggerOptions, TranscriptionSegment } from '../types'; 13 | import { isAudioTrack, isVideoTrack } from '../utils'; 14 | import LocalAudioTrack from './LocalAudioTrack'; 15 | import LocalVideoTrack from './LocalVideoTrack'; 16 | import RemoteAudioTrack from './RemoteAudioTrack'; 17 | import type RemoteTrack from './RemoteTrack'; 18 | import RemoteVideoTrack from './RemoteVideoTrack'; 19 | import { Track } from './Track'; 20 | import { getLogContextFromTrack } from './utils'; 21 | 22 | export abstract class TrackPublication extends (EventEmitter as new () => TypedEventEmitter) { 23 | kind: Track.Kind; 24 | 25 | trackName: string; 26 | 27 | trackSid: Track.SID; 28 | 29 | track?: Track; 30 | 31 | source: Track.Source; 32 | 33 | /** MimeType of the published track */ 34 | mimeType?: string; 35 | 36 | /** dimension of the original published stream, video-only */ 37 | dimensions?: Track.Dimensions; 38 | 39 | /** true if track was simulcasted to server, video-only */ 40 | simulcasted?: boolean; 41 | 42 | /** @internal */ 43 | trackInfo?: TrackInfo; 44 | 45 | protected metadataMuted: boolean = false; 46 | 47 | protected encryption: Encryption_Type = Encryption_Type.NONE; 48 | 49 | protected log = log; 50 | 51 | private loggerContextCb?: LoggerOptions['loggerContextCb']; 52 | 53 | constructor(kind: Track.Kind, id: string, name: string, loggerOptions?: LoggerOptions) { 54 | super(); 55 | this.log = getLogger(loggerOptions?.loggerName ?? LoggerNames.Publication); 56 | this.loggerContextCb = this.loggerContextCb; 57 | this.setMaxListeners(100); 58 | this.kind = kind; 59 | this.trackSid = id; 60 | this.trackName = name; 61 | this.source = Track.Source.Unknown; 62 | } 63 | 64 | /** @internal */ 65 | setTrack(track?: Track) { 66 | if (this.track) { 67 | this.track.off(TrackEvent.Muted, this.handleMuted); 68 | this.track.off(TrackEvent.Unmuted, this.handleUnmuted); 69 | } 70 | 71 | this.track = track; 72 | 73 | if (track) { 74 | // forward events 75 | track.on(TrackEvent.Muted, this.handleMuted); 76 | track.on(TrackEvent.Unmuted, this.handleUnmuted); 77 | } 78 | } 79 | 80 | protected get logContext() { 81 | return { 82 | ...this.loggerContextCb?.(), 83 | ...getLogContextFromTrack(this), 84 | }; 85 | } 86 | 87 | get isMuted(): boolean { 88 | return this.metadataMuted; 89 | } 90 | 91 | get isEnabled(): boolean { 92 | return true; 93 | } 94 | 95 | get isSubscribed(): boolean { 96 | return this.track !== undefined; 97 | } 98 | 99 | get isEncrypted(): boolean { 100 | return this.encryption !== Encryption_Type.NONE; 101 | } 102 | 103 | abstract get isLocal(): boolean; 104 | 105 | /** 106 | * an [AudioTrack] if this publication holds an audio track 107 | */ 108 | get audioTrack(): LocalAudioTrack | RemoteAudioTrack | undefined { 109 | if (isAudioTrack(this.track)) { 110 | return this.track; 111 | } 112 | } 113 | 114 | /** 115 | * an [VideoTrack] if this publication holds a video track 116 | */ 117 | get videoTrack(): LocalVideoTrack | RemoteVideoTrack | undefined { 118 | if (isVideoTrack(this.track)) { 119 | return this.track; 120 | } 121 | } 122 | 123 | handleMuted = () => { 124 | this.emit(TrackEvent.Muted); 125 | }; 126 | 127 | handleUnmuted = () => { 128 | this.emit(TrackEvent.Unmuted); 129 | }; 130 | 131 | /** @internal */ 132 | updateInfo(info: TrackInfo) { 133 | this.trackSid = info.sid; 134 | this.trackName = info.name; 135 | this.source = Track.sourceFromProto(info.source); 136 | this.mimeType = info.mimeType; 137 | if (this.kind === Track.Kind.Video && info.width > 0) { 138 | this.dimensions = { 139 | width: info.width, 140 | height: info.height, 141 | }; 142 | this.simulcasted = info.simulcast; 143 | } 144 | this.encryption = info.encryption; 145 | this.trackInfo = info; 146 | this.log.debug('update publication info', { ...this.logContext, info }); 147 | } 148 | } 149 | 150 | export namespace TrackPublication { 151 | export enum SubscriptionStatus { 152 | Desired = 'desired', 153 | Subscribed = 'subscribed', 154 | Unsubscribed = 'unsubscribed', 155 | } 156 | 157 | export enum PermissionStatus { 158 | Allowed = 'allowed', 159 | NotAllowed = 'not_allowed', 160 | } 161 | } 162 | 163 | export type PublicationEventCallbacks = { 164 | muted: () => void; 165 | unmuted: () => void; 166 | ended: (track?: Track) => void; 167 | updateSettings: (settings: UpdateTrackSettings) => void; 168 | subscriptionPermissionChanged: ( 169 | status: TrackPublication.PermissionStatus, 170 | prevStatus: TrackPublication.PermissionStatus, 171 | ) => void; 172 | updateSubscription: (sub: UpdateSubscription) => void; 173 | subscribed: (track: RemoteTrack) => void; 174 | unsubscribed: (track: RemoteTrack) => void; 175 | subscriptionStatusChanged: ( 176 | status: TrackPublication.SubscriptionStatus, 177 | prevStatus: TrackPublication.SubscriptionStatus, 178 | ) => void; 179 | subscriptionFailed: (error: SubscriptionError) => void; 180 | transcriptionReceived: (transcription: TranscriptionSegment[]) => void; 181 | timeSyncUpdate: (timestamp: number) => void; 182 | }; 183 | -------------------------------------------------------------------------------- /src/room/track/facingMode.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest'; 2 | import { facingModeFromDeviceLabel } from './facingMode'; 3 | 4 | describe('Test facingMode detection', () => { 5 | test('OBS virtual camera should be detected.', () => { 6 | const result = facingModeFromDeviceLabel('OBS Virtual Camera'); 7 | expect(result?.facingMode).toEqual('environment'); 8 | expect(result?.confidence).toEqual('medium'); 9 | }); 10 | 11 | test.each([ 12 | ['Peter’s iPhone Camera', { facingMode: 'environment', confidence: 'medium' }], 13 | ['iPhone de Théo Camera', { facingMode: 'environment', confidence: 'medium' }], 14 | ])( 15 | 'Device labels that contain "iphone" should return facingMode "environment".', 16 | (label, expected) => { 17 | const result = facingModeFromDeviceLabel(label); 18 | expect(result?.facingMode).toEqual(expected.facingMode); 19 | expect(result?.confidence).toEqual(expected.confidence); 20 | }, 21 | ); 22 | 23 | test.each([ 24 | ['Peter’s iPad Camera', { facingMode: 'environment', confidence: 'medium' }], 25 | ['iPad de Théo Camera', { facingMode: 'environment', confidence: 'medium' }], 26 | ])('Device label that contain "ipad" should detect.', (label, expected) => { 27 | const result = facingModeFromDeviceLabel(label); 28 | expect(result?.facingMode).toEqual(expected.facingMode); 29 | expect(result?.confidence).toEqual(expected.confidence); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /src/room/track/facingMode.ts: -------------------------------------------------------------------------------- 1 | import log from '../../logger'; 2 | import { isLocalTrack } from '../utils'; 3 | import LocalTrack from './LocalTrack'; 4 | import type { VideoCaptureOptions } from './options'; 5 | 6 | type FacingMode = NonNullable; 7 | type FacingModeFromLocalTrackOptions = { 8 | /** 9 | * If no facing mode can be determined, this value will be used. 10 | * @defaultValue 'user' 11 | */ 12 | defaultFacingMode?: FacingMode; 13 | }; 14 | type FacingModeFromLocalTrackReturnValue = { 15 | /** 16 | * The (probable) facingMode of the track. 17 | * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints/facingMode | MDN docs on facingMode} 18 | */ 19 | facingMode: FacingMode; 20 | /** 21 | * The confidence that the returned facingMode is correct. 22 | */ 23 | confidence: 'high' | 'medium' | 'low'; 24 | }; 25 | 26 | /** 27 | * Try to analyze the local track to determine the facing mode of a track. 28 | * 29 | * @remarks 30 | * There is no property supported by all browsers to detect whether a video track originated from a user- or environment-facing camera device. 31 | * For this reason, we use the `facingMode` property when available, but will fall back on a string-based analysis of the device label to determine the facing mode. 32 | * If both methods fail, the default facing mode will be used. 33 | * 34 | * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints/facingMode | MDN docs on facingMode} 35 | * @experimental 36 | */ 37 | export function facingModeFromLocalTrack( 38 | localTrack: LocalTrack | MediaStreamTrack, 39 | options: FacingModeFromLocalTrackOptions = {}, 40 | ): FacingModeFromLocalTrackReturnValue { 41 | const track = isLocalTrack(localTrack) ? localTrack.mediaStreamTrack : localTrack; 42 | const trackSettings = track.getSettings(); 43 | let result: FacingModeFromLocalTrackReturnValue = { 44 | facingMode: options.defaultFacingMode ?? 'user', 45 | confidence: 'low', 46 | }; 47 | 48 | // 1. Try to get facingMode from track settings. 49 | if ('facingMode' in trackSettings) { 50 | const rawFacingMode = trackSettings.facingMode; 51 | log.trace('rawFacingMode', { rawFacingMode }); 52 | if (rawFacingMode && typeof rawFacingMode === 'string' && isFacingModeValue(rawFacingMode)) { 53 | result = { facingMode: rawFacingMode, confidence: 'high' }; 54 | } 55 | } 56 | 57 | // 2. If we don't have a high confidence we try to get the facing mode from the device label. 58 | if (['low', 'medium'].includes(result.confidence)) { 59 | log.trace(`Try to get facing mode from device label: (${track.label})`); 60 | const labelAnalysisResult = facingModeFromDeviceLabel(track.label); 61 | if (labelAnalysisResult !== undefined) { 62 | result = labelAnalysisResult; 63 | } 64 | } 65 | 66 | return result; 67 | } 68 | 69 | const knownDeviceLabels = new Map([ 70 | ['obs virtual camera', { facingMode: 'environment', confidence: 'medium' }], 71 | ]); 72 | const knownDeviceLabelSections = new Map([ 73 | ['iphone', { facingMode: 'environment', confidence: 'medium' }], 74 | ['ipad', { facingMode: 'environment', confidence: 'medium' }], 75 | ]); 76 | /** 77 | * Attempt to analyze the device label to determine the facing mode. 78 | * 79 | * @experimental 80 | */ 81 | export function facingModeFromDeviceLabel( 82 | deviceLabel: string, 83 | ): FacingModeFromLocalTrackReturnValue | undefined { 84 | const label = deviceLabel.trim().toLowerCase(); 85 | // Empty string is a valid device label but we can't infer anything from it. 86 | if (label === '') { 87 | return undefined; 88 | } 89 | 90 | // Can we match against widely known device labels. 91 | if (knownDeviceLabels.has(label)) { 92 | return knownDeviceLabels.get(label); 93 | } 94 | 95 | // Can we match against sections of the device label. 96 | return Array.from(knownDeviceLabelSections.entries()).find(([section]) => 97 | label.includes(section), 98 | )?.[1]; 99 | } 100 | 101 | function isFacingModeValue(item: string): item is FacingMode { 102 | const allowedValues: FacingMode[] = ['user', 'environment', 'left', 'right']; 103 | return item === undefined || allowedValues.includes(item as FacingMode); 104 | } 105 | -------------------------------------------------------------------------------- /src/room/track/processor/types.ts: -------------------------------------------------------------------------------- 1 | import type Room from '../../Room'; 2 | import type { Track } from '../Track'; 3 | 4 | /** 5 | * @experimental 6 | */ 7 | export type ProcessorOptions = { 8 | kind: T; 9 | track: MediaStreamTrack; 10 | element?: HTMLMediaElement; 11 | audioContext?: AudioContext; 12 | }; 13 | 14 | /** 15 | * @experimental 16 | */ 17 | export interface AudioProcessorOptions extends ProcessorOptions { 18 | audioContext: AudioContext; 19 | } 20 | 21 | /** 22 | * @experimental 23 | */ 24 | export interface VideoProcessorOptions extends ProcessorOptions {} 25 | 26 | /** 27 | * @experimental 28 | */ 29 | export interface TrackProcessor< 30 | T extends Track.Kind, 31 | U extends ProcessorOptions = ProcessorOptions, 32 | > { 33 | name: string; 34 | init: (opts: U) => Promise; 35 | restart: (opts: U) => Promise; 36 | destroy: () => Promise; 37 | processedTrack?: MediaStreamTrack; 38 | onPublish?: (room: Room) => Promise; 39 | onUnpublish?: () => Promise; 40 | } 41 | -------------------------------------------------------------------------------- /src/room/track/record.ts: -------------------------------------------------------------------------------- 1 | import type LocalTrack from './LocalTrack'; 2 | 3 | // Check if MediaRecorder is available 4 | const isMediaRecorderAvailable = typeof MediaRecorder !== 'undefined'; 5 | 6 | // Fallback class for environments without MediaRecorder 7 | class FallbackRecorder { 8 | constructor() { 9 | throw new Error('MediaRecorder is not available in this environment'); 10 | } 11 | } 12 | 13 | // Use conditional inheritance to avoid parse-time errors 14 | const RecorderBase = isMediaRecorderAvailable 15 | ? MediaRecorder 16 | : (FallbackRecorder as unknown as typeof MediaRecorder); 17 | 18 | export class LocalTrackRecorder extends RecorderBase { 19 | byteStream: ReadableStream; 20 | 21 | constructor(track: T, options?: MediaRecorderOptions) { 22 | if (!isMediaRecorderAvailable) { 23 | throw new Error('MediaRecorder is not available in this environment'); 24 | } 25 | 26 | super(new MediaStream([track.mediaStreamTrack]), options); 27 | 28 | let dataListener: (event: BlobEvent) => void; 29 | 30 | let streamController: ReadableStreamDefaultController | undefined; 31 | 32 | const isClosed = () => streamController === undefined; 33 | 34 | const onStop = () => { 35 | this.removeEventListener('dataavailable', dataListener); 36 | this.removeEventListener('stop', onStop); 37 | this.removeEventListener('error', onError); 38 | streamController?.close(); 39 | streamController = undefined; 40 | }; 41 | 42 | const onError = (event: Event) => { 43 | streamController?.error(event); 44 | this.removeEventListener('dataavailable', dataListener); 45 | this.removeEventListener('stop', onStop); 46 | this.removeEventListener('error', onError); 47 | streamController = undefined; 48 | }; 49 | 50 | this.byteStream = new ReadableStream({ 51 | start: (controller) => { 52 | streamController = controller; 53 | dataListener = async (event: BlobEvent) => { 54 | const arrayBuffer = await event.data.arrayBuffer(); 55 | if (isClosed()) { 56 | return; 57 | } 58 | controller.enqueue(new Uint8Array(arrayBuffer)); 59 | }; 60 | this.addEventListener('dataavailable', dataListener); 61 | }, 62 | cancel: () => { 63 | onStop(); 64 | }, 65 | }); 66 | 67 | this.addEventListener('stop', onStop); 68 | this.addEventListener('error', onError); 69 | } 70 | } 71 | 72 | // Helper function to check if recording is supported 73 | export function isRecordingSupported(): boolean { 74 | return isMediaRecorderAvailable; 75 | } 76 | -------------------------------------------------------------------------------- /src/room/track/types.ts: -------------------------------------------------------------------------------- 1 | import type LocalAudioTrack from './LocalAudioTrack'; 2 | import type LocalVideoTrack from './LocalVideoTrack'; 3 | import type RemoteAudioTrack from './RemoteAudioTrack'; 4 | import type RemoteVideoTrack from './RemoteVideoTrack'; 5 | 6 | export type AudioTrack = RemoteAudioTrack | LocalAudioTrack; 7 | export type VideoTrack = RemoteVideoTrack | LocalVideoTrack; 8 | 9 | export type AdaptiveStreamSettings = { 10 | /** 11 | * Set a custom pixel density. Defaults to 2 for high density screens (3+) or 12 | * 1 otherwise. 13 | * When streaming videos on a ultra high definition screen this setting 14 | * let's you account for the devicePixelRatio of those screens. 15 | * Set it to `screen` to use the actual pixel density of the screen 16 | * Note: this might significantly increase the bandwidth consumed by people 17 | * streaming on high definition screens. 18 | */ 19 | pixelDensity?: number | 'screen'; 20 | /** 21 | * If true, video gets paused when switching to another tab. 22 | * Defaults to true. 23 | */ 24 | pauseVideoInBackground?: boolean; 25 | }; 26 | 27 | export interface ReplaceTrackOptions { 28 | userProvidedTrack?: boolean; 29 | stopProcessor?: boolean; 30 | } 31 | -------------------------------------------------------------------------------- /src/room/track/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { audioDefaults, videoDefaults } from '../defaults'; 3 | import { type AudioCaptureOptions, VideoPresets } from './options'; 4 | import { constraintsForOptions, diffAttributes, mergeDefaultOptions } from './utils'; 5 | 6 | describe('mergeDefaultOptions', () => { 7 | it('does not enable undefined options', () => { 8 | const opts = mergeDefaultOptions(undefined, audioDefaults, videoDefaults); 9 | expect(opts.audio).toEqual(undefined); 10 | expect(opts.video).toEqual(undefined); 11 | }); 12 | 13 | it('does not enable explicitly disabled', () => { 14 | const opts = mergeDefaultOptions({ 15 | video: false, 16 | }); 17 | expect(opts.audio).toEqual(undefined); 18 | expect(opts.video).toEqual(false); 19 | }); 20 | 21 | it('accepts true for options', () => { 22 | const opts = mergeDefaultOptions( 23 | { 24 | audio: true, 25 | }, 26 | audioDefaults, 27 | videoDefaults, 28 | ); 29 | expect(opts.audio).toEqual(audioDefaults); 30 | expect(opts.video).toEqual(undefined); 31 | }); 32 | 33 | it('enables overriding specific fields', () => { 34 | const opts = mergeDefaultOptions( 35 | { 36 | audio: { channelCount: 1 }, 37 | }, 38 | audioDefaults, 39 | videoDefaults, 40 | ); 41 | const audioOpts = opts.audio as AudioCaptureOptions; 42 | expect(audioOpts.channelCount).toEqual(1); 43 | expect(audioOpts.autoGainControl).toEqual(true); 44 | }); 45 | 46 | it('does not override explicit false', () => { 47 | const opts = mergeDefaultOptions( 48 | { 49 | audio: { autoGainControl: false }, 50 | }, 51 | audioDefaults, 52 | videoDefaults, 53 | ); 54 | const audioOpts = opts.audio as AudioCaptureOptions; 55 | expect(audioOpts.autoGainControl).toEqual(false); 56 | }); 57 | }); 58 | 59 | describe('constraintsForOptions', () => { 60 | it('correctly enables audio bool', () => { 61 | const constraints = constraintsForOptions({ 62 | audio: true, 63 | }); 64 | expect(constraints.audio).toEqual({ deviceId: audioDefaults.deviceId }); 65 | expect(constraints.video).toEqual(false); 66 | }); 67 | 68 | it('converts audio options correctly', () => { 69 | const constraints = constraintsForOptions({ 70 | audio: { 71 | noiseSuppression: true, 72 | echoCancellation: false, 73 | }, 74 | }); 75 | const audioOpts = constraints.audio as MediaTrackConstraints; 76 | expect(Object.keys(audioOpts)).toEqual(['noiseSuppression', 'echoCancellation', 'deviceId']); 77 | expect(audioOpts.noiseSuppression).toEqual(true); 78 | expect(audioOpts.echoCancellation).toEqual(false); 79 | }); 80 | 81 | it('converts video options correctly', () => { 82 | const constraints = constraintsForOptions({ 83 | video: { 84 | resolution: VideoPresets.h720.resolution, 85 | facingMode: 'user', 86 | deviceId: 'video123', 87 | }, 88 | }); 89 | const videoOpts = constraints.video as MediaTrackConstraints; 90 | expect(Object.keys(videoOpts)).toEqual([ 91 | 'width', 92 | 'height', 93 | 'frameRate', 94 | 'aspectRatio', 95 | 'facingMode', 96 | 'deviceId', 97 | ]); 98 | expect(videoOpts.width).toEqual(VideoPresets.h720.resolution.width); 99 | expect(videoOpts.height).toEqual(VideoPresets.h720.resolution.height); 100 | expect(videoOpts.frameRate).toEqual(VideoPresets.h720.resolution.frameRate); 101 | expect(videoOpts.aspectRatio).toEqual(VideoPresets.h720.resolution.aspectRatio); 102 | }); 103 | }); 104 | 105 | describe('diffAttributes', () => { 106 | it('detects changed values', () => { 107 | const oldValues: Record = { a: 'value', b: 'initial', c: 'value' }; 108 | const newValues: Record = { a: 'value', b: 'updated', c: 'value' }; 109 | 110 | const diff = diffAttributes(oldValues, newValues); 111 | expect(Object.keys(diff).length).toBe(1); 112 | expect(diff.b).toBe('updated'); 113 | }); 114 | it('detects new values', () => { 115 | const newValues: Record = { a: 'value', b: 'value', c: 'value' }; 116 | const oldValues: Record = { a: 'value', b: 'value' }; 117 | 118 | const diff = diffAttributes(oldValues, newValues); 119 | expect(Object.keys(diff).length).toBe(1); 120 | expect(diff.c).toBe('value'); 121 | }); 122 | it('detects deleted values as empty strings', () => { 123 | const newValues: Record = { a: 'value', b: 'value' }; 124 | const oldValues: Record = { a: 'value', b: 'value', c: 'value' }; 125 | 126 | const diff = diffAttributes(oldValues, newValues); 127 | expect(Object.keys(diff).length).toBe(1); 128 | expect(diff.c).toBe(''); 129 | }); 130 | it('compares with undefined values', () => { 131 | const newValues: Record = { a: 'value', b: 'value' }; 132 | 133 | const diff = diffAttributes(undefined, newValues); 134 | expect(Object.keys(diff).length).toBe(2); 135 | expect(diff.a).toBe('value'); 136 | }); 137 | }); 138 | -------------------------------------------------------------------------------- /src/room/types.ts: -------------------------------------------------------------------------------- 1 | import type { DataStream_Chunk } from '@livekit/protocol'; 2 | 3 | export type SimulationOptions = { 4 | publish?: { 5 | audio?: boolean; 6 | video?: boolean; 7 | useRealTracks?: boolean; 8 | }; 9 | participants?: { 10 | count?: number; 11 | aspectRatios?: Array; 12 | audio?: boolean; 13 | video?: boolean; 14 | }; 15 | }; 16 | 17 | export interface SendTextOptions { 18 | topic?: string; 19 | // replyToMessageId?: string; 20 | destinationIdentities?: Array; 21 | attachments?: Array; 22 | onProgress?: (progress: number) => void; 23 | attributes?: Record; 24 | } 25 | 26 | export interface StreamTextOptions { 27 | topic?: string; 28 | destinationIdentities?: Array; 29 | type?: 'create' | 'update'; 30 | streamId?: string; 31 | version?: number; 32 | attachedStreamIds?: Array; 33 | replyToStreamId?: string; 34 | totalSize?: number; 35 | attributes?: Record; 36 | } 37 | 38 | export type DataPublishOptions = { 39 | /** 40 | * whether to send this as reliable or lossy. 41 | * For data that you need delivery guarantee (such as chat messages), use Reliable. 42 | * For data that should arrive as quickly as possible, but you are ok with dropped 43 | * packets, use Lossy. 44 | */ 45 | reliable?: boolean; 46 | /** 47 | * the identities of participants who will receive the message, will be sent to every one if empty 48 | */ 49 | destinationIdentities?: string[]; 50 | /** the topic under which the message gets published */ 51 | topic?: string; 52 | }; 53 | 54 | export type LiveKitReactNativeInfo = { 55 | // Corresponds to RN's PlatformOSType 56 | platform: 'ios' | 'android' | 'windows' | 'macos' | 'web' | 'native'; 57 | devicePixelRatio: number; 58 | }; 59 | 60 | export type SimulationScenario = 61 | | 'signal-reconnect' 62 | | 'speaker' 63 | | 'node-failure' 64 | | 'server-leave' 65 | | 'migration' 66 | | 'resume-reconnect' 67 | | 'force-tcp' 68 | | 'force-tls' 69 | | 'full-reconnect' 70 | // overrides server-side bandwidth estimator with set bandwidth 71 | // this can be used to test application behavior when congested or 72 | // to disable congestion control entirely (by setting bandwidth to 100Mbps) 73 | | 'subscriber-bandwidth' 74 | | 'disconnect-signal-on-resume' 75 | | 'disconnect-signal-on-resume-no-messages' 76 | // instructs the server to send a full reconnect reconnect action to the client 77 | | 'leave-full-reconnect'; 78 | 79 | export type LoggerOptions = { 80 | loggerName?: string; 81 | loggerContextCb?: () => Record; 82 | }; 83 | 84 | export interface TranscriptionSegment { 85 | id: string; 86 | text: string; 87 | language: string; 88 | startTime: number; 89 | endTime: number; 90 | final: boolean; 91 | firstReceivedTime: number; 92 | lastReceivedTime: number; 93 | } 94 | 95 | export interface ChatMessage { 96 | id: string; 97 | timestamp: number; 98 | message: string; 99 | editTimestamp?: number; 100 | attachedFiles?: Array; 101 | } 102 | 103 | export interface StreamController { 104 | info: BaseStreamInfo; 105 | controller: ReadableStreamDefaultController; 106 | startTime: number; 107 | endTime?: number; 108 | } 109 | 110 | export interface BaseStreamInfo { 111 | id: string; 112 | mimeType: string; 113 | topic: string; 114 | timestamp: number; 115 | /** total size in bytes for finite streams and undefined for streams of unknown size */ 116 | size?: number; 117 | attributes?: Record; 118 | } 119 | export interface ByteStreamInfo extends BaseStreamInfo { 120 | name: string; 121 | } 122 | 123 | export interface TextStreamInfo extends BaseStreamInfo {} 124 | -------------------------------------------------------------------------------- /src/room/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { splitUtf8, toWebsocketUrl } from './utils'; 3 | 4 | describe('toWebsocketUrl', () => { 5 | it('leaves wss urls alone', () => { 6 | expect(toWebsocketUrl('ws://mywebsite.com')).toEqual('ws://mywebsite.com'); 7 | }); 8 | 9 | it('converts https to wss', () => { 10 | expect(toWebsocketUrl('https://mywebsite.com')).toEqual('wss://mywebsite.com'); 11 | }); 12 | 13 | it('does not convert other parts of URL', () => { 14 | expect(toWebsocketUrl('https://httpsmywebsite.com')).toEqual('wss://httpsmywebsite.com'); 15 | }); 16 | }); 17 | 18 | describe('splitUtf8', () => { 19 | it('splits a string into chunks of the given size', () => { 20 | expect(splitUtf8('hello world', 5)).toEqual([ 21 | new TextEncoder().encode('hello'), 22 | new TextEncoder().encode(' worl'), 23 | new TextEncoder().encode('d'), 24 | ]); 25 | }); 26 | 27 | it('splits a string with special characters into chunks of the given size', () => { 28 | expect(splitUtf8('héllo wörld', 5)).toEqual([ 29 | new TextEncoder().encode('héll'), 30 | new TextEncoder().encode('o wö'), 31 | new TextEncoder().encode('rld'), 32 | ]); 33 | }); 34 | 35 | it('splits a string with multi-byte utf8 characters correctly', () => { 36 | expect(splitUtf8('こんにちは世界', 5)).toEqual([ 37 | new TextEncoder().encode('こ'), 38 | new TextEncoder().encode('ん'), 39 | new TextEncoder().encode('に'), 40 | new TextEncoder().encode('ち'), 41 | new TextEncoder().encode('は'), 42 | new TextEncoder().encode('世'), 43 | new TextEncoder().encode('界'), 44 | ]); 45 | }); 46 | 47 | it('handles a string with a single multi-byte utf8 character', () => { 48 | expect(splitUtf8('😊', 5)).toEqual([new TextEncoder().encode('😊')]); 49 | }); 50 | 51 | it('handles a string with mixed single and multi-byte utf8 characters', () => { 52 | expect(splitUtf8('a😊b', 4)).toEqual([ 53 | new TextEncoder().encode('a'), 54 | new TextEncoder().encode('😊'), 55 | new TextEncoder().encode('b'), 56 | ]); 57 | }); 58 | 59 | it('handles an empty string', () => { 60 | expect(splitUtf8('', 5)).toEqual([]); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /src/room/worker.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'web-worker:*' { 2 | const WorkerFactory: new () => Worker; 3 | export default WorkerFactory; 4 | } 5 | -------------------------------------------------------------------------------- /src/test/MockMediaStreamTrack.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | // @ts-ignore 3 | export default class MockMediaStreamTrack implements MediaStreamTrack { 4 | contentHint: string = ''; 5 | 6 | enabled: boolean = true; 7 | 8 | id: string = 'id'; 9 | 10 | kind: string = 'video'; 11 | 12 | label: string = 'label'; 13 | 14 | muted: boolean = false; 15 | 16 | onended: ((this: MediaStreamTrack, ev: Event) => any) | null = null; 17 | 18 | onmute: ((this: MediaStreamTrack, ev: Event) => any) | null = null; 19 | 20 | onunmute: ((this: MediaStreamTrack, ev: Event) => any) | null = null; 21 | 22 | readyState: MediaStreamTrackState = 'live'; 23 | 24 | isolated: boolean = false; 25 | 26 | onisolationchange: ((this: MediaStreamTrack, ev: Event) => any) | null = null; 27 | 28 | // @ts-ignore 29 | applyConstraints(constraints?: MediaTrackConstraints): Promise { 30 | throw new Error('Method not implemented.'); 31 | } 32 | 33 | clone(): MediaStreamTrack { 34 | throw new Error('Method not implemented.'); 35 | } 36 | 37 | getCapabilities(): MediaTrackCapabilities { 38 | throw new Error('Method not implemented.'); 39 | } 40 | 41 | getConstraints(): MediaTrackConstraints { 42 | throw new Error('Method not implemented.'); 43 | } 44 | 45 | getSettings(): MediaTrackSettings { 46 | throw new Error('Method not implemented.'); 47 | } 48 | 49 | stop(): void { 50 | throw new Error('Method not implemented.'); 51 | } 52 | 53 | addEventListener( 54 | type: K, 55 | listener: (this: MediaStreamTrack, ev: MediaStreamTrackEventMap[K]) => any, 56 | options?: boolean | AddEventListenerOptions, 57 | ): void; 58 | addEventListener( 59 | type: string, 60 | listener: EventListenerOrEventListenerObject, 61 | options?: boolean | AddEventListenerOptions, 62 | ): void; 63 | addEventListener(type: any, listener: any, options?: any): void { 64 | throw new Error('Method not implemented.'); 65 | } 66 | 67 | removeEventListener( 68 | type: K, 69 | listener: (this: MediaStreamTrack, ev: MediaStreamTrackEventMap[K]) => any, 70 | options?: boolean | EventListenerOptions, 71 | ): void; 72 | removeEventListener( 73 | type: string, 74 | listener: EventListenerOrEventListenerObject, 75 | options?: boolean | EventListenerOptions, 76 | ): void; 77 | removeEventListener(type: any, listener: any, options?: any): void { 78 | throw new Error('Method not implemented.'); 79 | } 80 | 81 | dispatchEvent(event: Event): boolean { 82 | throw new Error('Method not implemented.'); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/test/mocks.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-extraneous-dependencies 2 | import { type MockedClass, vi } from 'vitest'; 3 | import { SignalClient } from '../api/SignalClient'; 4 | import RTCEngine from '../room/RTCEngine'; 5 | 6 | vi.mock('../api/SignalClient'); 7 | vi.mock('../room/RTCEngine'); 8 | 9 | // mock helpers for testing 10 | 11 | const mocks: { 12 | SignalClient: MockedClass; 13 | RTCEngine: MockedClass; 14 | MockLocalVideoTrack: { stop: () => void }; 15 | } = { 16 | SignalClient: SignalClient as MockedClass, 17 | RTCEngine: RTCEngine as MockedClass, 18 | MockLocalVideoTrack: { 19 | stop: vi.fn(), 20 | }, 21 | }; 22 | 23 | export default mocks; 24 | -------------------------------------------------------------------------------- /src/type-polyfills/document-pip.d.ts: -------------------------------------------------------------------------------- 1 | interface Window { 2 | /** 3 | * Currently only available in Chromium based browsers: 4 | * https://developer.mozilla.org/en-US/docs/Web/API/DocumentPictureInPicture 5 | */ 6 | documentPictureInPicture?: DocumentPictureInPicture; 7 | } 8 | 9 | interface DocumentPictureInPicture extends EventTarget { 10 | window?: Window; 11 | requestWindow(options?: { width: number; height: number }): Promise; 12 | } 13 | -------------------------------------------------------------------------------- /src/utils/AsyncQueue.test.ts: -------------------------------------------------------------------------------- 1 | import { assert, describe, expect, it } from 'vitest'; 2 | import { sleep } from '../room/utils'; 3 | import { AsyncQueue } from './AsyncQueue'; 4 | 5 | describe('asyncQueue', () => { 6 | it('runs multiple tasks in order', async () => { 7 | const queue = new AsyncQueue(); 8 | const tasksExecuted: number[] = []; 9 | 10 | for (let i = 0; i < 5; i++) { 11 | queue.run(async () => { 12 | await sleep(50); 13 | tasksExecuted.push(i); 14 | }); 15 | } 16 | await queue.flush(); 17 | expect(tasksExecuted).toMatchObject([0, 1, 2, 3, 4]); 18 | }); 19 | it('runs tasks sequentially and not in parallel', async () => { 20 | const queue = new AsyncQueue(); 21 | const results: number[] = []; 22 | for (let i = 0; i < 5; i++) { 23 | queue.run(async () => { 24 | results.push(i); 25 | await sleep(10); 26 | results.push(i); 27 | }); 28 | } 29 | await queue.flush(); 30 | expect(results).toMatchObject([0, 0, 1, 1, 2, 2, 3, 3, 4, 4]); 31 | }); 32 | it('continues executing tasks if one task throws an error', async () => { 33 | const queue = new AsyncQueue(); 34 | 35 | let task1threw = false; 36 | let task2Executed = false; 37 | 38 | queue 39 | .run(async () => { 40 | await sleep(100); 41 | throw Error('task 1 throws'); 42 | }) 43 | .catch(() => { 44 | task1threw = true; 45 | }); 46 | 47 | await queue 48 | .run(async () => { 49 | task2Executed = true; 50 | }) 51 | .catch(() => { 52 | assert.fail('task 2 should not have thrown'); 53 | }); 54 | 55 | expect(task1threw).toBeTruthy(); 56 | expect(task2Executed).toBeTruthy(); 57 | }); 58 | it('returns the result of the task', async () => { 59 | const queue = new AsyncQueue(); 60 | 61 | const result = await queue.run(async () => { 62 | await sleep(10); 63 | return 'result'; 64 | }); 65 | 66 | expect(result).toBe('result'); 67 | }); 68 | it('returns only when the enqueued task and all previous tasks have completed', async () => { 69 | const queue = new AsyncQueue(); 70 | const tasksExecuted: number[] = []; 71 | for (let i = 0; i < 10; i += 1) { 72 | queue.run(async () => { 73 | await sleep(10); 74 | tasksExecuted.push(i); 75 | return i; 76 | }); 77 | } 78 | 79 | const result = await queue.run(async () => { 80 | await sleep(10); 81 | tasksExecuted.push(999); 82 | return 'result'; 83 | }); 84 | 85 | expect(result).toBe('result'); 86 | expect(tasksExecuted).toMatchObject([...new Array(10).fill(0).map((_, idx) => idx), 999]); 87 | }); 88 | it('can handle queue sizes of up to 10_000 tasks', async () => { 89 | const queue = new AsyncQueue(); 90 | const tasksExecuted: number[] = []; 91 | 92 | for (let i = 0; i < 10_000; i++) { 93 | queue.run(async () => { 94 | tasksExecuted.push(i); 95 | }); 96 | } 97 | await queue.flush(); 98 | expect(tasksExecuted).toMatchObject(new Array(10_000).fill(0).map((_, idx) => idx)); 99 | }); 100 | }); 101 | -------------------------------------------------------------------------------- /src/utils/AsyncQueue.ts: -------------------------------------------------------------------------------- 1 | import { Mutex } from '@livekit/mutex'; 2 | 3 | type QueueTask = () => PromiseLike; 4 | 5 | enum QueueTaskStatus { 6 | 'WAITING', 7 | 'RUNNING', 8 | 'COMPLETED', 9 | } 10 | 11 | type QueueTaskInfo = { 12 | id: number; 13 | enqueuedAt: number; 14 | executedAt?: number; 15 | status: QueueTaskStatus; 16 | }; 17 | 18 | export class AsyncQueue { 19 | private pendingTasks: Map; 20 | 21 | private taskMutex: Mutex; 22 | 23 | private nextTaskIndex: number; 24 | 25 | constructor() { 26 | this.pendingTasks = new Map(); 27 | this.taskMutex = new Mutex(); 28 | this.nextTaskIndex = 0; 29 | } 30 | 31 | async run(task: QueueTask) { 32 | const taskInfo: QueueTaskInfo = { 33 | id: this.nextTaskIndex++, 34 | enqueuedAt: Date.now(), 35 | status: QueueTaskStatus.WAITING, 36 | }; 37 | this.pendingTasks.set(taskInfo.id, taskInfo); 38 | const unlock = await this.taskMutex.lock(); 39 | try { 40 | taskInfo.executedAt = Date.now(); 41 | taskInfo.status = QueueTaskStatus.RUNNING; 42 | return await task(); 43 | } finally { 44 | taskInfo.status = QueueTaskStatus.COMPLETED; 45 | this.pendingTasks.delete(taskInfo.id); 46 | unlock(); 47 | } 48 | } 49 | 50 | async flush() { 51 | return this.run(async () => {}); 52 | } 53 | 54 | snapshot() { 55 | return Array.from(this.pendingTasks.values()); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/utils/browserParser.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { compareVersions } from '../room/utils'; 3 | import { getBrowser } from './browserParser'; 4 | 5 | describe('browser parser', () => { 6 | const macOSSafariUA = 7 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Safari/605.1.15'; 8 | 9 | const iOSSafariUA = 10 | 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Mobile/15E148 Safari/604.1'; 11 | 12 | const firefoxUA = 13 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/112.0'; 14 | 15 | const iOSFirefoxUA = 16 | 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/115.0 Mobile/15E148 Safari/605.1.15'; 17 | 18 | const chromeUA = 19 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36'; 20 | 21 | const iOSChromeUA = 22 | 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/115.0.5790.130 Mobile/15E148 Safari/604.11'; 23 | 24 | const braveUA = 25 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.134 Safari/537.36'; 26 | 27 | it('parses Safari macOS correctly', () => { 28 | const details = getBrowser(macOSSafariUA, true); 29 | expect(details?.name).toBe('Safari'); 30 | expect(details?.version).toBe('16.3'); 31 | expect(details?.os).toBe('macOS'); 32 | expect(details?.osVersion).toBe('10.15.7'); 33 | }); 34 | it('parses Safari iOS correctly', () => { 35 | const details = getBrowser(iOSSafariUA, true); 36 | expect(details?.name).toBe('Safari'); 37 | expect(details?.version).toBe('16.5'); 38 | expect(details?.os).toBe('iOS'); 39 | expect(details?.osVersion).toBe('16.5.1'); 40 | }); 41 | it('parses Firefox correctly', () => { 42 | const details = getBrowser(firefoxUA, true); 43 | expect(details?.name).toBe('Firefox'); 44 | expect(details?.version).toBe('112.0'); 45 | }); 46 | it('parses iOS Firefox correctly', () => { 47 | const details = getBrowser(iOSFirefoxUA, true); 48 | expect(details?.name).toBe('Firefox'); 49 | expect(details?.version).toBe('115.0'); 50 | expect(details?.os).toBe('iOS'); 51 | expect(details?.osVersion).toBe('13.4.1'); 52 | }); 53 | it('parses Chrome correctly', () => { 54 | const details = getBrowser(chromeUA, true); 55 | expect(details?.name).toBe('Chrome'); 56 | expect(details?.version).toBe('112.0.0.0'); 57 | }); 58 | it('parses iOS Chrome correctly', () => { 59 | const details = getBrowser(iOSChromeUA, true); 60 | expect(details?.name).toBe('Chrome'); 61 | expect(details?.version).toBe('115.0.5790.130'); 62 | expect(details?.os).toBe('iOS'); 63 | expect(details?.osVersion).toBe('16.5'); 64 | }); 65 | it('detects brave as chromium based', () => { 66 | const details = getBrowser(braveUA, true); 67 | expect(details?.name).toBe('Chrome'); 68 | expect(details?.version).toBe('103.0.5060.134'); 69 | }); 70 | }); 71 | 72 | describe('version compare', () => { 73 | it('compares versions correctly', () => { 74 | expect(compareVersions('12.3.5', '11.8.9')).toBe(1); 75 | expect(compareVersions('12.3.5', '12.3.5')).toBe(0); 76 | expect(compareVersions('12.3.5', '14.1.5')).toBe(-1); 77 | }); 78 | it('can handle different version lengths', () => { 79 | expect(compareVersions('12', '11.8.9')).toBe(1); 80 | expect(compareVersions('12', '12.0.0')).toBe(0); 81 | expect(compareVersions('12', '14.1.5')).toBe(-1); 82 | 83 | expect(compareVersions('12.3.5', '11')).toBe(1); 84 | expect(compareVersions('12.0.0', '12')).toBe(0); 85 | expect(compareVersions('12.3.5', '14')).toBe(-1); 86 | }); 87 | 88 | it('treats empty strings as smaller', () => { 89 | expect(compareVersions('12', '')).toBe(1); 90 | expect(compareVersions('', '12.0.0')).toBe(-1); 91 | }); 92 | }); 93 | -------------------------------------------------------------------------------- /src/utils/browserParser.ts: -------------------------------------------------------------------------------- 1 | // tiny, simplified version of https://github.com/lancedikson/bowser/blob/master/src/parser-browsers.js 2 | // reduced to only differentiate Chrome(ium) based browsers / Firefox / Safari 3 | 4 | const commonVersionIdentifier = /version\/(\d+(\.?_?\d+)+)/i; 5 | 6 | export type DetectableBrowser = 'Chrome' | 'Firefox' | 'Safari'; 7 | export type DetectableOS = 'iOS' | 'macOS'; 8 | 9 | export type BrowserDetails = { 10 | name: DetectableBrowser; 11 | version: string; 12 | os?: DetectableOS; 13 | osVersion?: string; 14 | }; 15 | 16 | let browserDetails: BrowserDetails | undefined; 17 | 18 | /** 19 | * @internal 20 | */ 21 | export function getBrowser(userAgent?: string, force = true): BrowserDetails | undefined { 22 | if (typeof userAgent === 'undefined' && typeof navigator === 'undefined') { 23 | return; 24 | } 25 | const ua = (userAgent ?? navigator.userAgent).toLowerCase(); 26 | if (browserDetails === undefined || force) { 27 | const browser = browsersList.find(({ test }) => test.test(ua)); 28 | browserDetails = browser?.describe(ua); 29 | } 30 | return browserDetails; 31 | } 32 | 33 | const browsersList = [ 34 | { 35 | test: /firefox|iceweasel|fxios/i, 36 | describe(ua: string) { 37 | const browser: BrowserDetails = { 38 | name: 'Firefox', 39 | version: getMatch(/(?:firefox|iceweasel|fxios)[\s/](\d+(\.?_?\d+)+)/i, ua), 40 | os: ua.toLowerCase().includes('fxios') ? 'iOS' : undefined, 41 | osVersion: getOSVersion(ua), 42 | }; 43 | return browser; 44 | }, 45 | }, 46 | { 47 | test: /chrom|crios|crmo/i, 48 | describe(ua: string) { 49 | const browser: BrowserDetails = { 50 | name: 'Chrome', 51 | version: getMatch(/(?:chrome|chromium|crios|crmo)\/(\d+(\.?_?\d+)+)/i, ua), 52 | os: ua.toLowerCase().includes('crios') ? 'iOS' : undefined, 53 | osVersion: getOSVersion(ua), 54 | }; 55 | 56 | return browser; 57 | }, 58 | }, 59 | /* Safari */ 60 | { 61 | test: /safari|applewebkit/i, 62 | describe(ua: string) { 63 | const browser: BrowserDetails = { 64 | name: 'Safari', 65 | version: getMatch(commonVersionIdentifier, ua), 66 | os: ua.includes('mobile/') ? 'iOS' : 'macOS', 67 | osVersion: getOSVersion(ua), 68 | }; 69 | 70 | return browser; 71 | }, 72 | }, 73 | ]; 74 | 75 | function getMatch(exp: RegExp, ua: string, id = 1) { 76 | const match = ua.match(exp); 77 | return (match && match.length >= id && match[id]) || ''; 78 | } 79 | 80 | function getOSVersion(ua: string) { 81 | return ua.includes('mac os') 82 | ? getMatch(/\(.+?(\d+_\d+(:?_\d+)?)/, ua, 1).replace(/_/g, '.') 83 | : undefined; 84 | } 85 | -------------------------------------------------------------------------------- /src/utils/cloneDeep.test.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 2 | import { cloneDeep } from './cloneDeep'; 3 | 4 | describe('cloneDeep', () => { 5 | beforeEach(() => { 6 | global.structuredClone = vi.fn((val) => { 7 | return JSON.parse(JSON.stringify(val)); 8 | }); 9 | }); 10 | 11 | afterEach(() => { 12 | vi.restoreAllMocks(); 13 | }); 14 | 15 | it('should clone a simple object', () => { 16 | const structuredCloneSpy = vi.spyOn(global, 'structuredClone'); 17 | 18 | const original = { name: 'John', age: 30 }; 19 | const cloned = cloneDeep(original); 20 | 21 | expect(cloned).toEqual(original); 22 | expect(cloned).not.toBe(original); 23 | expect(structuredCloneSpy).toHaveBeenCalledTimes(1); 24 | }); 25 | 26 | it('should clone an object with nested properties', () => { 27 | const structuredCloneSpy = vi.spyOn(global, 'structuredClone'); 28 | 29 | const original = { name: 'John', age: 30, children: [{ name: 'Mark', age: 7 }] }; 30 | const cloned = cloneDeep(original); 31 | 32 | expect(cloned).toEqual(original); 33 | expect(cloned).not.toBe(original); 34 | expect(cloned?.children).not.toBe(original.children); 35 | expect(structuredCloneSpy).toHaveBeenCalledTimes(1); 36 | }); 37 | 38 | it('should use JSON namespace as a fallback', () => { 39 | const structuredCloneSpy = vi.spyOn(global, 'structuredClone'); 40 | const serializeSpy = vi.spyOn(JSON, 'stringify'); 41 | const deserializeSpy = vi.spyOn(JSON, 'parse'); 42 | 43 | global.structuredClone = undefined as any; 44 | 45 | const original = { name: 'John', age: 30 }; 46 | const cloned = cloneDeep(original); 47 | 48 | expect(cloned).toEqual(original); 49 | expect(cloned).not.toBe(original); 50 | expect(structuredCloneSpy).not.toHaveBeenCalled(); 51 | expect(serializeSpy).toHaveBeenCalledTimes(1); 52 | expect(deserializeSpy).toHaveBeenCalledTimes(1); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /src/utils/cloneDeep.ts: -------------------------------------------------------------------------------- 1 | export function cloneDeep(value: T): T { 2 | if (typeof value === 'undefined') { 3 | return value as T; 4 | } 5 | 6 | if (typeof structuredClone === 'function') { 7 | if (typeof value === 'object' && value !== null) { 8 | // ensure that the value is not a proxy by spreading it 9 | return structuredClone({ ...value }); 10 | } 11 | return structuredClone(value); 12 | } else { 13 | return JSON.parse(JSON.stringify(value)) as T; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/version.ts: -------------------------------------------------------------------------------- 1 | import { version as v } from '../package.json'; 2 | 3 | export const version = v; 4 | export const protocolVersion = 16; 5 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": [ 4 | "src/**/*.ts", 5 | "src/**/*.js", 6 | "examples/**/*.ts", 7 | ".eslintrc.cjs", 8 | ".eslintrc.dist.cjs", 9 | "rollup.config.js", 10 | "rollup.config.worker.js", 11 | "vite.config.mjs" 12 | ], 13 | "exclude": ["dist/**", "examples/**/dist", "test/**"] 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "types": ["sdp-transform", "ua-parser-js", "events", "dom-mediacapture-record"], 4 | "target": "ES2015" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, 5 | "module": "ES2020" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, 6 | "lib": [ 7 | "DOM", 8 | "DOM.Iterable", 9 | "DOM.AsyncIterable", 10 | "ES2017", 11 | "ES2018.Promise", 12 | "ES2021.WeakRef" 13 | ], 14 | "rootDir": "./", 15 | "outDir": "dist", 16 | "declaration": true, 17 | "declarationMap": true, 18 | "sourceMap": true, 19 | "strict": true /* Enable all strict type-checking options. */, 20 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 21 | "skipLibCheck": true /* Skip type checking of declaration files. */, 22 | "noUnusedLocals": true, 23 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */, 24 | "moduleResolution": "node", 25 | "resolveJsonModule": true, 26 | "verbatimModuleSyntax": true, 27 | "ignoreDeprecations": "5.0" 28 | }, 29 | "exclude": ["dist", "**/*.test.ts", "test/**"], 30 | "include": ["src/**/*.ts"], 31 | "typedocOptions": { 32 | "entryPoints": ["src/index.ts"], 33 | "excludeInternal": true, 34 | "excludePrivate": true, 35 | "excludeProtected": true, 36 | "excludeExternals": true, 37 | "includeVersion": true, 38 | "name": "LiveKit JS Client SDK", 39 | "out": "docs", 40 | "theme": "default" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /vite.config.mjs: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | import { babel } from '@rollup/plugin-babel'; 3 | import dns from 'dns'; 4 | import { resolve } from 'path'; 5 | import { defineConfig } from 'vite'; 6 | 7 | dns.setDefaultResultOrder('verbatim'); 8 | 9 | export default defineConfig({ 10 | server: { 11 | port: 8080, 12 | open: true, 13 | fs: { 14 | strict: false, 15 | }, 16 | }, 17 | build: { 18 | minify: 'esbuild', 19 | target: 'es2019', 20 | lib: { 21 | // Could also be a dictionary or array of multiple entry points 22 | entry: resolve(__dirname, 'src/index.ts'), 23 | name: 'Livekit Client SDK JS', 24 | // the proper extensions will be added 25 | fileName: 'livekit-client', 26 | }, 27 | rollupOptions: { 28 | // make sure to externalize deps that shouldn't be bundled 29 | // into your library 30 | external: [], 31 | output: {}, 32 | plugins: [ 33 | babel({ 34 | babelHelpers: 'bundled', 35 | plugins: ['@babel/plugin-proposal-object-rest-spread'], 36 | presets: ['@babel/preset-env'], 37 | extensions: ['.js', '.ts', '.mjs'], 38 | }), 39 | ], 40 | }, 41 | }, 42 | test: { 43 | environment: 'happy-dom', 44 | }, 45 | }); 46 | --------------------------------------------------------------------------------