├── .env.template ├── .github ├── dependabot.yml └── workflows │ ├── ci.yaml │ ├── codeql.yml │ ├── dependency-review.yml │ ├── e2e-test-canary.yml │ ├── e2e-test-h265.yml │ ├── e2e-test-webkit.yml │ ├── e2e-test-wish.yml │ ├── e2e-test.yml │ ├── npm-pkg-e2e-test.yml │ └── npm-publish.yml ├── .gitignore ├── .markdownlint.yaml ├── .vscode ├── extensions.json └── settings.json ├── CHANGES.md ├── LICENSE ├── README.md ├── TYPEDOC.md ├── biome.jsonc ├── canary.py ├── e2e-tests ├── README.md ├── data_channel_signaling_only │ ├── index.html │ └── main.ts ├── fake_sendonly │ ├── index.html │ └── main.ts ├── h265 │ ├── index.html │ └── main.ts ├── index.html ├── messaging │ ├── index.html │ └── main.ts ├── package.json ├── recvonly │ ├── index.html │ └── main.ts ├── sendonly │ ├── index.html │ └── main.ts ├── sendonly_audio │ ├── index.html │ └── main.ts ├── sendonly_reconnect │ ├── index.html │ └── main.ts ├── sendrecv │ ├── index.html │ └── main.ts ├── sendrecv_webkit │ ├── index.html │ └── main.ts ├── simulcast │ ├── index.html │ └── main.ts ├── simulcast_recvonly │ ├── index.html │ └── main.ts ├── simulcast_sendonly │ ├── index.html │ └── main.ts ├── simulcast_sendonly_webkit │ ├── index.html │ └── main.ts ├── spotlight_recvonly │ ├── index.html │ └── main.ts ├── spotlight_sendonly │ ├── index.html │ └── main.ts ├── spotlight_sendrecv │ ├── index.html │ └── main.ts ├── src │ ├── fake.ts │ └── misc.ts ├── tests │ ├── authz_simulcast_encodings.test.ts │ ├── h265.test.ts │ ├── helper.ts │ ├── message_header.test.ts │ ├── messaging.test.ts │ ├── reconnect.test.ts │ ├── sendonly_audio.test.ts │ ├── sendonly_recvonly.test.ts │ ├── sendrecv.test.ts │ ├── simulcast.test.ts │ ├── simulcast_rid.test.ts │ ├── spotlight_sendonly_recvonly.test.ts │ ├── spotlight_sendrecv.test.ts │ ├── type_close.test.ts │ ├── type_switched.test.ts │ ├── webkit.test.ts │ ├── whip_simulcast.test.ts │ └── whip_whep.test.ts ├── tsconfig.json ├── vite-env.d.ts ├── vite.config.mjs ├── whep │ ├── index.html │ └── main.ts ├── whip │ ├── index.html │ └── main.ts └── whip_simulcast │ ├── index.html │ └── main.ts ├── package.json ├── playwright.config.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── src ├── base.ts ├── constants.ts ├── errors.ts ├── helpers.ts ├── messaging.ts ├── publisher.ts ├── sora.ts ├── subscriber.ts ├── types.ts ├── utils.ts └── vite-env.d.ts ├── tests └── utils.test.ts ├── tsconfig.json ├── typedoc.json ├── vite.config.ts └── vitest.config.ts /.env.template: -------------------------------------------------------------------------------- 1 | # テストに利用する Sora の設定 2 | VITE_TEST_SIGNALING_URL= 3 | VITE_TEST_CHANNEL_ID_PREFIX= 4 | VITE_TEST_CHANNEL_ID_SUFFIX= 5 | VITE_TEST_API_URL= 6 | VITE_TEST_WHIP_ENDPOINT_URL= 7 | VITE_TEST_WHEP_ENDPOINT_URL= 8 | VITE_TEST_SECRET_KEY= -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | paths-ignore: 6 | - "**.md" 7 | - "LICENSE" 8 | - "NOTICE" 9 | 10 | jobs: 11 | ci: 12 | runs-on: ubuntu-24.04 13 | strategy: 14 | matrix: 15 | node: ["20", "22", "24"] 16 | # typescript: ["5.1", "5.2", "5.3", "5.4", "5.5", "5.6", "5.7", "5.8", "next"] 17 | # next で type check が通らないので一時的にコメントアウトする 18 | # https://github.com/microsoft/TypeScript/issues/61687 19 | typescript: ["5.1", "5.2", "5.3", "5.4", "5.5", "5.6", "5.7", "5.8"] 20 | steps: 21 | - uses: actions/checkout@v4 22 | - uses: actions/setup-node@v4 23 | with: 24 | node-version: ${{ matrix.node }} 25 | - uses: pnpm/action-setup@v4 26 | with: 27 | version: 10 28 | - run: pnpm --version 29 | - run: pnpm install 30 | - run: pnpm add -E -D typescript@${{ matrix.typescript }} -w 31 | - run: pnpm run build 32 | - run: pnpm run lint 33 | - run: pnpm run check 34 | - run: pnpm run test 35 | 36 | slack_notify_failed: 37 | needs: [ci] 38 | runs-on: ubuntu-24.04 39 | if: failure() 40 | steps: 41 | - name: Slack Notification 42 | uses: rtCamp/action-slack-notify@v2 43 | env: 44 | SLACK_CHANNEL: sora-js-sdk 45 | SLACK_COLOR: danger 46 | SLACK_TITLE: Failed 47 | SLACK_ICON_EMOJI: ":japanese_ogre:" 48 | SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "develop" ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ "develop" ] 20 | schedule: 21 | - cron: '21 8 * * 6' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-24.04 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v4 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v3 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | 52 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 53 | # queries: security-extended,security-and-quality 54 | 55 | 56 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 57 | # If this step fails, then you should remove it and run the build manually (see below) 58 | - name: Autobuild 59 | uses: github/codeql-action/autobuild@v3 60 | 61 | # ℹ️ Command-line programs to run using the OS shell. 62 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 63 | 64 | # If the Autobuild fails above, remove it and uncomment the following three lines. 65 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 66 | 67 | # - run: | 68 | # echo "Run, Build Application using script" 69 | # ./location_of_script_within_repo/buildscript.sh 70 | 71 | - name: Perform CodeQL Analysis 72 | uses: github/codeql-action/analyze@v3 73 | with: 74 | category: "/language:${{matrix.language}}" 75 | -------------------------------------------------------------------------------- /.github/workflows/dependency-review.yml: -------------------------------------------------------------------------------- 1 | # Dependency Review Action 2 | # 3 | # This Action will scan dependency manifest files that change as part of a Pull Request, surfacing known-vulnerable versions of the packages declared or updated in the PR. Once installed, if the workflow run is marked as required, PRs introducing known-vulnerable packages will be blocked from merging. 4 | # 5 | # Source repository: https://github.com/actions/dependency-review-action 6 | # Public documentation: https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-dependency-review#dependency-review-enforcement 7 | name: 'Dependency Review' 8 | on: [pull_request] 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | dependency-review: 15 | runs-on: ubuntu-24.04 16 | steps: 17 | - name: 'Checkout Repository' 18 | uses: actions/checkout@v4 19 | - name: 'Dependency Review' 20 | uses: actions/dependency-review-action@v4 21 | -------------------------------------------------------------------------------- /.github/workflows/e2e-test-canary.yml: -------------------------------------------------------------------------------- 1 | name: e2e-test-canary 2 | 3 | # Chrome と Edge の Canary バージョンでのテスト 4 | # Playwright 経由では Canary バージョンのブラウザをインストールできない 5 | # Homebrew 経由でインストールしている 6 | 7 | on: 8 | push: 9 | branches: 10 | - develop 11 | - feature/* 12 | paths-ignore: 13 | - "**.md" 14 | - "LICENSE" 15 | - "NOTICE" 16 | schedule: 17 | # UTC 時間で毎日 2:00 (JST で 11:00) に実行、月曜日から金曜日 18 | - cron: "0 2 * * 1-5" 19 | 20 | jobs: 21 | e2e-test-canary: 22 | strategy: 23 | matrix: 24 | # Nodejs のテストではないので 20 でのみ動作確認する 25 | node: 26 | - "20" 27 | # - "22" 28 | # - "24" 29 | browser: 30 | - name: "Google Chrome Canary" 31 | type: "google-chrome@canary" 32 | - name: "Microsoft Edge Canary" 33 | type: "microsoft-edge@canary" 34 | env: 35 | VITE_TEST_SIGNALING_URL: ${{ secrets.TEST_SIGNALING_URL }} 36 | VITE_TEST_WHIP_ENDPOINT_URL: ${{ secrets.TEST_WHIP_ENDPOINT_URL }} 37 | VITE_TEST_WHEP_ENDPOINT_URL: ${{ secrets.TEST_WHEP_ENDPOINT_URL }} 38 | VITE_TEST_CHANNEL_ID_PREFIX: ${{ secrets.TEST_CHANNEL_ID_PREFIX }} 39 | VITE_TEST_API_URL: ${{ secrets.TEST_API_URL }} 40 | VITE_TEST_SECRET_KEY: ${{ secrets.TEST_SECRET_KEY }} 41 | runs-on: macos-15 42 | timeout-minutes: 20 43 | steps: 44 | - uses: actions/checkout@v4 45 | - uses: tailscale/github-action@v3 46 | with: 47 | oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }} 48 | oauth-secret: ${{ secrets.TS_OAUTH_SECRET }} 49 | tags: tag:ci 50 | version: latest 51 | use-cache: 'true' 52 | - uses: Homebrew/actions/setup-homebrew@master 53 | - run: brew install --cask ${{ matrix.browser.type}} 54 | - uses: actions/setup-node@v4 55 | with: 56 | node-version: ${{ matrix.node }} 57 | - uses: pnpm/action-setup@v4 58 | with: 59 | version: 10 60 | - run: pnpm --version 61 | - run: pnpm install 62 | - run: pnpm run build 63 | - run: pnpm exec playwright test --project="${{ matrix.browser.name }}" 64 | env: 65 | VITE_TEST_CHANNEL_ID_SUFFIX: _${{ matrix.node }} 66 | # - uses: actions/upload-artifact@v4 67 | # if: always() 68 | # with: 69 | # name: playwright-report 70 | # path: playwright-report/ 71 | # retention-days: 30 72 | 73 | 74 | # slack_notify_succeeded: 75 | # needs: [e2e-test] 76 | # runs-on: ubuntu-24.04 77 | # if: success() 78 | # steps: 79 | # - name: Slack Notification 80 | # uses: rtCamp/action-slack-notify@v2 81 | # env: 82 | # SLACK_CHANNEL: sora-js-sdk 83 | # SLACK_COLOR: good 84 | # SLACK_TITLE: Succeeded 85 | # SLACK_ICON_EMOJI: ":star-struck:" 86 | # SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} 87 | slack_notify_failed: 88 | needs: [e2e-test-canary] 89 | runs-on: ubuntu-24.04 90 | if: failure() 91 | steps: 92 | - name: Slack Notification 93 | uses: rtCamp/action-slack-notify@v2 94 | env: 95 | SLACK_CHANNEL: sora-js-sdk 96 | SLACK_COLOR: danger 97 | SLACK_TITLE: Failed 98 | SLACK_ICON_EMOJI: ":japanese_ogre:" 99 | SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} 100 | -------------------------------------------------------------------------------- /.github/workflows/e2e-test-h265.yml: -------------------------------------------------------------------------------- 1 | name: e2e-test-h265 2 | 3 | on: 4 | push: 5 | branches: 6 | - develop 7 | - feature/* 8 | paths-ignore: 9 | - "**.md" 10 | - "LICENSE" 11 | - "NOTICE" 12 | schedule: 13 | # UTC 時間で毎日 2:00 (JST で 11:00) に実行、月曜日から金曜日 14 | - cron: "0 2 * * 1-5" 15 | 16 | jobs: 17 | # Chrome と Edge の Canary バージョンでのテスト 18 | # Playwright 経由では Canary バージョンのブラウザをインストールできない 19 | # Homebrew 経由でインストールしている 20 | e2e-test-h265: 21 | strategy: 22 | matrix: 23 | node: 24 | - "20" 25 | browser: 26 | - name: "Google Chrome Canary" 27 | type: "google-chrome@canary" 28 | - name: "Google Chrome Dev" 29 | type: "google-chrome@dev" 30 | - name: "Google Chrome Beta" 31 | type: "google-chrome@beta" 32 | - name: "Google Chrome" 33 | type: "google-chrome" 34 | env: 35 | VITE_TEST_SIGNALING_URL: ${{ secrets.TEST_SIGNALING_URL }} 36 | VITE_TEST_WHIP_ENDPOINT_URL: ${{ secrets.TEST_WHIP_ENDPOINT_URL }} 37 | VITE_TEST_WHEP_ENDPOINT_URL: ${{ secrets.TEST_WHEP_ENDPOINT_URL }} 38 | VITE_TEST_CHANNEL_ID_PREFIX: ${{ secrets.TEST_CHANNEL_ID_PREFIX }} 39 | VITE_TEST_API_URL: ${{ secrets.TEST_API_URL }} 40 | VITE_TEST_SECRET_KEY: ${{ secrets.TEST_SECRET_KEY }} 41 | runs-on: 42 | group: Self 43 | labels: [self-hosted, macOS, ARM64, Apple-M2-Pro] 44 | timeout-minutes: 20 45 | steps: 46 | - uses: actions/checkout@v4 47 | - uses: Homebrew/actions/setup-homebrew@master 48 | - run: brew install --cask ${{ matrix.browser.type}} 49 | - uses: actions/setup-node@v4 50 | with: 51 | node-version: ${{ matrix.node }} 52 | - uses: pnpm/action-setup@v4 53 | with: 54 | version: 10 55 | - run: pnpm --version 56 | - run: pnpm install 57 | - run: pnpm run build 58 | - run: pnpm exec playwright test --project="${{ matrix.browser.name }}" e2e-tests/tests/h265.test.ts 59 | env: 60 | VITE_TEST_CHANNEL_ID_SUFFIX: _${{ matrix.node }} 61 | # - uses: actions/upload-artifact@v4 62 | # if: always() 63 | # with: 64 | # name: playwright-report 65 | # path: playwright-report/ 66 | # retention-days: 30 67 | 68 | 69 | # slack_notify_succeeded: 70 | # needs: [e2e-test] 71 | # runs-on: ubuntu-24.04 72 | # if: success() 73 | # steps: 74 | # - name: Slack Notification 75 | # uses: rtCamp/action-slack-notify@v2 76 | # env: 77 | # SLACK_CHANNEL: sora-js-sdk 78 | # SLACK_COLOR: good 79 | # SLACK_TITLE: Succeeded 80 | # SLACK_ICON_EMOJI: ":star-struck:" 81 | # SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} 82 | slack_notify_failed: 83 | needs: [e2e-test-h265] 84 | runs-on: ubuntu-24.04 85 | if: failure() 86 | steps: 87 | - name: Slack Notification 88 | uses: rtCamp/action-slack-notify@v2 89 | env: 90 | SLACK_CHANNEL: sora-js-sdk 91 | SLACK_COLOR: danger 92 | SLACK_TITLE: Failed 93 | SLACK_ICON_EMOJI: ":japanese_ogre:" 94 | SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} 95 | -------------------------------------------------------------------------------- /.github/workflows/e2e-test-webkit.yml: -------------------------------------------------------------------------------- 1 | name: e2e-test-webkit 2 | 3 | on: 4 | push: 5 | branches: 6 | - develop 7 | - feature/* 8 | paths-ignore: 9 | - "**.md" 10 | - "LICENSE" 11 | - "NOTICE" 12 | schedule: 13 | # UTC 時間で毎日 2:00 (JST で 11:00) に実行、月曜日から金曜日 14 | - cron: "0 2 * * 1-5" 15 | 16 | jobs: 17 | # WebKit のテスト 18 | e2e-test-webkit: 19 | strategy: 20 | matrix: 21 | node: 22 | - "20" 23 | env: 24 | VITE_TEST_SIGNALING_URL: ${{ secrets.TEST_SIGNALING_URL }} 25 | VITE_TEST_WHIP_ENDPOINT_URL: ${{ secrets.TEST_WHIP_ENDPOINT_URL }} 26 | VITE_TEST_WHEP_ENDPOINT_URL: ${{ secrets.TEST_WHEP_ENDPOINT_URL }} 27 | VITE_TEST_CHANNEL_ID_PREFIX: ${{ secrets.TEST_CHANNEL_ID_PREFIX }} 28 | VITE_TEST_API_URL: ${{ secrets.TEST_API_URL }} 29 | VITE_TEST_SECRET_KEY: ${{ secrets.TEST_SECRET_KEY }} 30 | runs-on: 31 | group: Self 32 | labels: [self-hosted, macOS, ARM64, Apple-M2-Pro] 33 | timeout-minutes: 20 34 | steps: 35 | - uses: actions/checkout@v4 36 | - uses: actions/setup-node@v4 37 | with: 38 | node-version: ${{ matrix.node }} 39 | - uses: pnpm/action-setup@v4 40 | with: 41 | version: 10 42 | - run: pnpm --version 43 | - run: pnpm install 44 | - run: pnpm run build 45 | - run: pnpm exec playwright install webkit --with-deps 46 | - run: pnpm exec playwright test --project="WebKit" e2e-tests/tests/webkit.test.ts 47 | env: 48 | VITE_TEST_CHANNEL_ID_SUFFIX: _${{ matrix.node }} 49 | # - uses: actions/upload-artifact@v4 50 | # if: always() 51 | # with: 52 | # name: playwright-report 53 | # path: playwright-report/ 54 | # retention-days: 30 55 | 56 | 57 | # slack_notify_succeeded: 58 | # needs: [e2e-test] 59 | # runs-on: ubuntu-24.04 60 | # if: success() 61 | # steps: 62 | # - name: Slack Notification 63 | # uses: rtCamp/action-slack-notify@v2 64 | # env: 65 | # SLACK_CHANNEL: sora-js-sdk 66 | # SLACK_COLOR: good 67 | # SLACK_TITLE: Succeeded 68 | # SLACK_ICON_EMOJI: ":star-struck:" 69 | # SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} 70 | slack_notify_failed: 71 | needs: [e2e-test-webkit] 72 | runs-on: ubuntu-24.04 73 | if: failure() 74 | steps: 75 | - name: Slack Notification 76 | uses: rtCamp/action-slack-notify@v2 77 | env: 78 | SLACK_CHANNEL: sora-js-sdk 79 | SLACK_COLOR: danger 80 | SLACK_TITLE: Failed 81 | SLACK_ICON_EMOJI: ":japanese_ogre:" 82 | SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} 83 | -------------------------------------------------------------------------------- /.github/workflows/e2e-test-wish.yml: -------------------------------------------------------------------------------- 1 | name: e2e-test-wish 2 | 3 | on: 4 | push: 5 | branches: 6 | - develop 7 | - feature/* 8 | paths-ignore: 9 | - "**.md" 10 | - "LICENSE" 11 | - "NOTICE" 12 | schedule: 13 | # UTC 時間で毎日 2:00 (JST で 11:00) に実行、月曜日から金曜日 14 | - cron: "0 2 * * 1-5" 15 | 16 | jobs: 17 | # Canary バージョンでのテスト 18 | # Playwright 経由では Canary バージョンのブラウザをインストールできない 19 | # Homebrew 経由でインストールしている 20 | e2e-test-wish: 21 | strategy: 22 | matrix: 23 | node: 24 | - "20" 25 | browser: 26 | - name: "Google Chrome Canary" 27 | type: "google-chrome@canary" 28 | - name: "Google Chrome Dev" 29 | type: "google-chrome@dev" 30 | - name: "Google Chrome Beta" 31 | type: "google-chrome@beta" 32 | # - name: "Google Chrome" 33 | # type: "google-chrome" 34 | env: 35 | VITE_TEST_SIGNALING_URL: ${{ secrets.TEST_SIGNALING_URL }} 36 | VITE_TEST_WHIP_ENDPOINT_URL: ${{ secrets.TEST_WHIP_ENDPOINT_URL }} 37 | VITE_TEST_WHEP_ENDPOINT_URL: ${{ secrets.TEST_WHEP_ENDPOINT_URL }} 38 | VITE_TEST_CHANNEL_ID_PREFIX: ${{ secrets.TEST_CHANNEL_ID_PREFIX }} 39 | VITE_TEST_API_URL: ${{ secrets.TEST_API_URL }} 40 | VITE_TEST_SECRET_KEY: ${{ secrets.TEST_SECRET_KEY }} 41 | E2E_TEST_WISH: true 42 | runs-on: 43 | group: Self 44 | labels: [self-hosted, macOS, ARM64, Apple-M2-Pro] 45 | timeout-minutes: 20 46 | steps: 47 | - uses: actions/checkout@v4 48 | - uses: Homebrew/actions/setup-homebrew@master 49 | - run: brew install --cask ${{ matrix.browser.type}} 50 | - uses: actions/setup-node@v4 51 | with: 52 | node-version: ${{ matrix.node }} 53 | - uses: pnpm/action-setup@v4 54 | with: 55 | version: 10 56 | - run: pnpm --version 57 | - run: pnpm install 58 | - run: pnpm run build 59 | - run: pnpm exec playwright test --project="${{ matrix.browser.name }}" e2e-tests/tests/whip_*.test.ts 60 | env: 61 | VITE_TEST_CHANNEL_ID_SUFFIX: _${{ matrix.node }} 62 | # - uses: actions/upload-artifact@v4 63 | # if: always() 64 | # with: 65 | # name: playwright-report 66 | # path: playwright-report/ 67 | # retention-days: 30 68 | 69 | 70 | # slack_notify_succeeded: 71 | # needs: [e2e-test] 72 | # runs-on: ubuntu-24.04 73 | # if: success() 74 | # steps: 75 | # - name: Slack Notification 76 | # uses: rtCamp/action-slack-notify@v2 77 | # env: 78 | # SLACK_CHANNEL: sora-js-sdk 79 | # SLACK_COLOR: good 80 | # SLACK_TITLE: Succeeded 81 | # SLACK_ICON_EMOJI: ":star-struck:" 82 | # SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} 83 | slack_notify_failed: 84 | needs: [e2e-test-wish] 85 | runs-on: ubuntu-24.04 86 | if: failure() 87 | steps: 88 | - name: Slack Notification 89 | uses: rtCamp/action-slack-notify@v2 90 | env: 91 | SLACK_CHANNEL: sora-js-sdk 92 | SLACK_COLOR: danger 93 | SLACK_TITLE: Failed 94 | SLACK_ICON_EMOJI: ":japanese_ogre:" 95 | SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} 96 | -------------------------------------------------------------------------------- /.github/workflows/e2e-test.yml: -------------------------------------------------------------------------------- 1 | name: e2e-test 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - develop 8 | - feature/* 9 | paths-ignore: 10 | - "**.md" 11 | - "LICENSE" 12 | - "NOTICE" 13 | schedule: 14 | # UTC 時間で毎日 2:00 (JST で 11:00) に実行、月曜日から金曜日 15 | - cron: "0 2 * * 1-5" 16 | 17 | jobs: 18 | e2e-test: 19 | strategy: 20 | matrix: 21 | node: 22 | - "20" 23 | - "22" 24 | - "24" 25 | browser: 26 | - name: "Chromium" 27 | type: "chromium" 28 | - name: "Google Chrome" 29 | type: "chrome" 30 | - name: "Google Chrome Beta" 31 | type: "chrome-beta" 32 | - name: "Microsoft Edge" 33 | type: "msedge" 34 | - name: "Microsoft Edge Beta" 35 | type: "msedge-beta" 36 | - name: "Microsoft Edge Dev" 37 | type: "msedge-dev" 38 | env: 39 | VITE_TEST_SIGNALING_URL: ${{ secrets.TEST_SIGNALING_URL }} 40 | VITE_TEST_WHIP_ENDPOINT_URL: ${{ secrets.TEST_WHIP_ENDPOINT_URL }} 41 | VITE_TEST_WHEP_ENDPOINT_URL: ${{ secrets.TEST_WHEP_ENDPOINT_URL }} 42 | VITE_TEST_CHANNEL_ID_PREFIX: ${{ secrets.TEST_CHANNEL_ID_PREFIX }} 43 | VITE_TEST_API_URL: ${{ secrets.TEST_API_URL }} 44 | VITE_TEST_SECRET_KEY: ${{ secrets.TEST_SECRET_KEY }} 45 | runs-on: ubuntu-24.04 46 | timeout-minutes: 20 47 | steps: 48 | - uses: actions/checkout@v4 49 | - uses: tailscale/github-action@v3 50 | with: 51 | oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }} 52 | oauth-secret: ${{ secrets.TS_OAUTH_SECRET }} 53 | tags: tag:ci 54 | version: latest 55 | use-cache: 'true' 56 | - uses: actions/setup-node@v4 57 | with: 58 | node-version: ${{ matrix.node }} 59 | - uses: pnpm/action-setup@v4 60 | with: 61 | version: 10 62 | - run: pnpm --version 63 | - run: pnpm install 64 | - run: pnpm run build 65 | - run: pnpm exec playwright install ${{ matrix.browser.type }} --with-deps 66 | - run: pnpm exec playwright test --project="${{ matrix.browser.name }}" 67 | env: 68 | VITE_TEST_CHANNEL_ID_SUFFIX: _${{ matrix.node }} 69 | # - uses: actions/upload-artifact@v4 70 | # if: always() 71 | # with: 72 | # name: playwright-report 73 | # path: playwright-report/ 74 | # retention-days: 30 75 | 76 | 77 | # slack_notify_succeeded: 78 | # needs: [e2e-test] 79 | # runs-on: ubuntu-24.04 80 | # if: success() 81 | # steps: 82 | # - name: Slack Notification 83 | # uses: rtCamp/action-slack-notify@v2 84 | # env: 85 | # SLACK_CHANNEL: sora-js-sdk 86 | # SLACK_COLOR: good 87 | # SLACK_TITLE: Succeeded 88 | # SLACK_ICON_EMOJI: ":star-struck:" 89 | # SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} 90 | slack_notify_failed: 91 | needs: [e2e-test] 92 | runs-on: ubuntu-24.04 93 | if: failure() 94 | steps: 95 | - name: Slack Notification 96 | uses: rtCamp/action-slack-notify@v2 97 | env: 98 | SLACK_CHANNEL: sora-js-sdk 99 | SLACK_COLOR: danger 100 | SLACK_TITLE: Failed 101 | SLACK_ICON_EMOJI: ":japanese_ogre:" 102 | SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} 103 | -------------------------------------------------------------------------------- /.github/workflows/npm-pkg-e2e-test.yml: -------------------------------------------------------------------------------- 1 | name: npm-pkg-e2e-test 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - develop 8 | - feature/* 9 | paths: 10 | - .github/workflows/npm-pkg-e2e-test.yml 11 | - e2e-tests/** 12 | schedule: 13 | # UTC 時間で毎日 2:00 (JST で 11:00) に実行、月曜日から金曜日 14 | - cron: "0 2 * * 1-5" 15 | 16 | jobs: 17 | npm-pkg-e2e-test: 18 | timeout-minutes: 20 19 | runs-on: ubuntu-24.04 20 | strategy: 21 | matrix: 22 | # メンテナンスはおてて 23 | sdk_version: [ 24 | # canary リリースを含める 25 | "2025.1.0-canary.4", 26 | "2024.2.2", 27 | "2024.1.2", 28 | "2023.2.0", 29 | "2023.1.0", 30 | "2022.3.3", 31 | "2022.2.0", 32 | "2022.1.0", 33 | "2021.2.3", 34 | # 2020.1.7 以前は E2E テストが通らない 35 | "2021.1.7", 36 | ] 37 | node: ["18", "20", "22", "24"] 38 | browser: 39 | - name: "Google Chrome" 40 | type: "chrome" 41 | - name: "Microsoft Edge" 42 | type: "msedge" 43 | env: 44 | VITE_TEST_SIGNALING_URL: ${{ secrets.TEST_SIGNALING_URL }} 45 | VITE_TEST_WHIP_ENDPOINT_URL: ${{ secrets.TEST_WHIP_ENDPOINT_URL }} 46 | VITE_TEST_WHEP_ENDPOINT_URL: ${{ secrets.TEST_WHEP_ENDPOINT_URL }} 47 | VITE_TEST_CHANNEL_ID_PREFIX: ${{ secrets.TEST_CHANNEL_ID_PREFIX }} 48 | VITE_TEST_API_URL: ${{ secrets.TEST_API_URL }} 49 | VITE_TEST_SECRET_KEY: ${{ secrets.TEST_SECRET_KEY }} 50 | NPM_PKG_E2E_TEST: true 51 | steps: 52 | - uses: actions/checkout@v4 53 | - uses: tailscale/github-action@v3 54 | with: 55 | oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }} 56 | oauth-secret: ${{ secrets.TS_OAUTH_SECRET }} 57 | tags: tag:ci 58 | version: latest 59 | use-cache: 'true' 60 | - uses: actions/setup-node@v4 61 | with: 62 | node-version: ${{ matrix.node }} 63 | - uses: pnpm/action-setup@v4 64 | with: 65 | version: 10 66 | # sora-js-sdk を npm からインストールする 67 | - run: pnpm --version 68 | - run: pnpm install 69 | - run: pnpm add -E sora-js-sdk@${{ matrix.sdk_version }} 70 | working-directory: ./e2e-tests 71 | # pnpm run build しない 72 | - run: pnpm exec playwright install ${{ matrix.browser.type }} --with-deps 73 | - run: pnpm exec playwright test --project="${{ matrix.browser.name }}" 74 | env: 75 | VITE_TEST_CHANNEL_ID_SUFFIX: _${{ matrix.node }}_${{ matrix.sdk_version }} 76 | # - uses: actions/upload-artifact@v4 77 | # if: always() 78 | # with: 79 | # name: playwright-report 80 | # path: playwright-report/ 81 | # retention-days: 30 82 | 83 | # slack_notify_succeeded: 84 | # needs: [npm-pkg-e2e-test] 85 | # runs-on: ubuntu-24.04 86 | # if: success() 87 | # steps: 88 | # - name: Slack Notification 89 | # uses: rtCamp/action-slack-notify@v2 90 | # env: 91 | # SLACK_CHANNEL: sora-js-sdk 92 | # SLACK_COLOR: good 93 | # SLACK_TITLE: Succeeded 94 | # SLACK_ICON_EMOJI: ":star-struck:" 95 | # SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} 96 | slack_notify_failed: 97 | needs: [npm-pkg-e2e-test] 98 | runs-on: ubuntu-24.04 99 | if: failure() 100 | steps: 101 | - name: Slack Notification 102 | uses: rtCamp/action-slack-notify@v2 103 | env: 104 | SLACK_CHANNEL: sora-js-sdk 105 | SLACK_COLOR: danger 106 | SLACK_TITLE: Failed 107 | SLACK_ICON_EMOJI: ":japanese_ogre:" 108 | SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} 109 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | name: npm-publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | build: 10 | name: Build 11 | runs-on: ubuntu-24.04 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: actions/setup-node@v4 15 | with: 16 | node-version: 22 17 | - uses: pnpm/action-setup@v4 18 | with: 19 | version: 10 20 | - run: pnpm --version 21 | - run: pnpm install 22 | - run: pnpm run build 23 | - run: pnpm run lint 24 | - run: pnpm run check 25 | - uses: actions/upload-artifact@v4 26 | with: 27 | name: sora-js-sdk-dist 28 | path: dist/ 29 | 30 | npm-publish-canary: 31 | name: Publish to npm 32 | runs-on: ubuntu-24.04 33 | needs: [build] 34 | permissions: 35 | contents: read 36 | id-token: write 37 | if: ${{ contains(github.ref, 'canary') }} 38 | steps: 39 | - uses: actions/checkout@v4 40 | 41 | - uses: actions/setup-node@v4 42 | with: 43 | node-version: 22 44 | registry-url: https://registry.npmjs.org 45 | 46 | - name: Download Artifact 47 | uses: actions/download-artifact@v4 48 | with: 49 | name: sora-js-sdk-dist 50 | path: dist/ 51 | 52 | # pnpm publish は CI では正常に動作しない 53 | # https://github.com/pnpm/pnpm/issues/4937 54 | - run: npm publish --provenance --no-git-checks --tag canary 55 | env: 56 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 57 | 58 | slack_notify_failed: 59 | needs: [npm-publish-canary] 60 | runs-on: ubuntu-24.04 61 | if: failure() 62 | steps: 63 | - name: Slack Notification 64 | uses: rtCamp/action-slack-notify@v2 65 | env: 66 | SLACK_CHANNEL: sora-js-sdk 67 | SLACK_COLOR: danger 68 | SLACK_TITLE: Failed 69 | SLACK_ICON_EMOJI: ":japanese_ogre:" 70 | SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | apidoc/ 4 | .log/ 5 | dist/ 6 | 7 | # .env 8 | .env* 9 | !.env.template 10 | 11 | # playwright 12 | /test-results/ 13 | /playwright-report/ 14 | /blob-report/ 15 | /playwright/.cache/ 16 | -------------------------------------------------------------------------------- /.markdownlint.yaml: -------------------------------------------------------------------------------- 1 | # line-length 2 | MD013: false 3 | 4 | # no-emphasis-as-heading 5 | MD036: false 6 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["biomejs.biome"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "[html]": { 4 | "editor.defaultFormatter": "vscode.html-language-features" 5 | }, 6 | "[css]": { 7 | "editor.defaultFormatter": "vscode.css-language-features" 8 | }, 9 | "[javascript]": { 10 | "editor.defaultFormatter": "biomejs.biome" 11 | }, 12 | "[javascriptreact]": { 13 | "editor.defaultFormatter": "biomejs.biome" 14 | }, 15 | "[typescript]": { 16 | "editor.defaultFormatter": "biomejs.biome" 17 | }, 18 | "[typescriptreact]": { 19 | "editor.defaultFormatter": "biomejs.biome" 20 | }, 21 | "editor.codeActionsOnSave": { 22 | "quickfix.biome": "explicit", 23 | "source.organizeImports.biome": "explicit" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sora JavaScript SDK 2 | 3 | ![Static Badge](https://img.shields.io/badge/Checked_with-Biome-60a5fa?style=flat&logo=biome) 4 | [![GitHub tag](https://img.shields.io/github/tag/shiguredo/sora-js-sdk.svg)](https://github.com/shiguredo/sora-js-sdk) 5 | [![npm version](https://badge.fury.io/js/sora-js-sdk.svg)](https://badge.fury.io/js/sora-js-sdk) 6 | [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) 7 | 8 | Sora JavaScript SDK は[株式会社時雨堂](https://shiguredo.jp/)が開発、販売している [WebRTC SFU Sora](https://sora.shiguredo.jp) / [Sora Cloud](https://sora-cloud.shiguredo.app/) をブラウザから扱うための SDK です。 9 | 10 | ## About Shiguredo's open source software 11 | 12 | We will not respond to PRs or issues that have not been discussed on Discord. Also, Discord is only available in Japanese. 13 | 14 | Please read before use. 15 | 16 | ## 時雨堂のオープンソースソフトウェアについて 17 | 18 | 利用前に をお読みください。 19 | 20 | ## 条件 21 | 22 | - WebRTC SFU Sora 2024.1.0 以降 23 | - TypeScript 5.1 以降 24 | 25 | ## 使い方 26 | 27 | 使い方は [Sora JavaScript SDK ドキュメント](https://sora-js-sdk.shiguredo.jp/) を参照してください。 28 | 29 | ## サンプル 30 | 31 | サンプルは [sora-js-sdk-examples](https://github.com/shiguredo/sora-js-sdk-examples) を参照してください。 32 | 33 | ## インストール 34 | 35 | ### npm 36 | 37 | ```bash 38 | npm install sora-js-sdk 39 | ``` 40 | 41 | ### pnpm 42 | 43 | ```bash 44 | pnpm add sora-js-sdk 45 | ``` 46 | 47 | ### Node.js の条件 48 | 49 | - Sora JavaScript SDK 2024.2.x までは **Node.js 18.0 以降** を要求します 50 | - 次のリリース Sora JavaScript SDK 2025.1.0 以降は **Node.js 20.0 以降** を要求します 51 | 52 | > [!CAUTION] 53 | > Sora JavaScript SDK 2024.2.0 以降は [Compression Stream API](https://developer.mozilla.org/ja/docs/Web/API/Compression_Streams_API) を利用しているため、ブラウザの要件がありますのでご確認ください。 54 | > 55 | > - Chrome / Edge 80 以降 56 | > - Firefox 113 以降 57 | > - Safari 16.4 以降 58 | 59 | ## E2E (End to End) テスト 60 | 61 | Playwright を利用した E2E テストを実行できます。 62 | 63 | ```bash 64 | # .env.local を作成して適切な値を設定してください 65 | $ cp .env.template .env.local 66 | $ pnpm install 67 | $ pnpm run build 68 | $ pnpm exec playwright install chromium --with-deps 69 | $ pnpm run e2e-test 70 | ``` 71 | 72 | ### E2E テストページ 73 | 74 | E2E テストで実行するページを Vite にて起動できます。 75 | 76 | ```bash 77 | pnpm run e2e-dev 78 | ``` 79 | 80 | ### npm に公開されている安定版のパッケージの E2E テスト 81 | 82 | 以下のバージョンの npm に公開されている安定版のパッケージは、 83 | 最新の Sora で E2E テストが通ることを確認しています。 84 | 85 | - 2024.2.2 86 | - 2024.1.2 87 | - 2023.2.0 88 | - 2023.1.0 89 | - 2022.3.3 90 | - 2022.2.0 91 | - 2022.1.0 92 | - 2021.2.3 93 | - 2021.1.7 94 | 95 | ## マルチトラックについて 96 | 97 | [WebRTC SFU Sora](https://sora.shiguredo.jp) は 1 メディアストリームにつき 1 音声トラック、 98 | 1 映像トラックまでしか対応していないため, Sora JavaScript SDK はマルチトラックに対応していません。 99 | 100 | マルチトラックへの対応は今のところ未定です。 101 | 102 | ## API 一覧 103 | 104 | [Sora JavaScript SDK ドキュメント API リファレンス](https://sora-js-sdk.shiguredo.jp/api.html) 105 | 106 | ## ライセンス 107 | 108 | Apache License 2.0 109 | 110 | ```text 111 | Copyright 2017-2025, Shiguredo Inc. 112 | Copyright 2017-2022, Yuki Ito (Original Author) 113 | 114 | Licensed under the Apache License, Version 2.0 (the "License"); 115 | you may not use this file except in compliance with the License. 116 | You may obtain a copy of the License at 117 | 118 | http://www.apache.org/licenses/LICENSE-2.0 119 | 120 | Unless required by applicable law or agreed to in writing, software 121 | distributed under the License is distributed on an "AS IS" BASIS, 122 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 123 | See the License for the specific language governing permissions and 124 | limitations under the License. 125 | ``` 126 | 127 | ## リンク 128 | 129 | ### 商用製品 130 | 131 | - [WebRTC SFU Sora](https://sora.shiguredo.jp) 132 | - [WebRTC SFU Sora ドキュメント](https://sora-doc.shiguredo.jp) 133 | - [Sora Cloud](https://sora-cloud.shiguredo.jp) 134 | - [Sora Cloud ドキュメント](https://doc.sora-cloud.shiguredo.app) 135 | 136 | ### 無料検証サービス 137 | 138 | - [Sora Labo](https://sora-labo.shiguredo.app) 139 | - [Sora Labo ドキュメント](https://github.com/shiguredo/sora-labo-doc) 140 | 141 | ### クライアント SDK 142 | 143 | - [Sora JavaScript SDK](https://github.com/shiguredo/sora-javascript-sdk) 144 | - [Sora JavaScript SDK ドキュメント](https://sora-js-sdk.shiguredo.jp/) 145 | - [Sora iOS SDK](https://github.com/shiguredo/sora-ios-sdk) 146 | - [Sora iOS SDK ドキュメント](https://sora-ios-sdk.shiguredo.jp/) 147 | - [Sora iOS SDK クイックスタート](https://github.com/shiguredo/sora-ios-sdk-quickstart) 148 | - [Sora iOS SDK サンプル集](https://github.com/shiguredo/sora-ios-sdk-samples) 149 | - [Sora Android SDK](https://github.com/shiguredo/sora-android-sdk) 150 | - [Sora Android SDK ドキュメント](https://sora-android-sdk.shiguredo.jp/) 151 | - [Sora Android SDK クイックスタート](https://github.com/shiguredo/sora-android-sdk-quickstart) 152 | - [Sora Android SDK サンプル集](https://github.com/shiguredo/sora-android-sdk-samples) 153 | - [Sora Unity SDK](https://github.com/shiguredo/sora-unity-sdk) 154 | - [Sora Unity SDK ドキュメント](https://sora-unity-sdk.shiguredo.jp/) 155 | - [Sora Unity SDK サンプル集](https://github.com/shiguredo/sora-unity-sdk-samples) 156 | - [Sora Python SDK](https://github.com/shiguredo/sora-python-sdk) 157 | - [Sora Python SDK ドキュメント](https://sora-python-sdk.shiguredo.jp/) 158 | - [Sora Python SDK サンプル集](https://github.com/shiguredo/sora-python-sdk-samples) 159 | - [Sora C++ SDK](https://github.com/shiguredo/sora-cpp-sdk) 160 | 161 | ### クライアントツール 162 | 163 | - [Sora DevTools](https://github.com/shiguredo/sora-devtools) 164 | - [Media Processors](https://github.com/shiguredo/media-processors) 165 | - [WebRTC Native Client Momo](https://github.com/shiguredo/momo) 166 | 167 | ### サーバーツール 168 | 169 | - [WebRTC Load Testing Tool Zakuro](https://github.com/shiguredo/zakuro) 170 | - Sora 専用負荷試験ツール 171 | - [WebRTC Stats Collector Kohaku](https://github.com/shiguredo/kohaku) 172 | - Sora 専用統計収集ツール 173 | - [Recording Composition Tool Hisui](https://github.com/shiguredo/hisui) 174 | - Sora 専用録画ファイル合成ツール 175 | - [Audio Streaming Gateway Suzu](https://github.com/shiguredo/suzu) 176 | - Sora 専用音声解析ゲートウェイ 177 | - [Sora Archive Uploader](https://github.com/shiguredo/sora-archive-uploader) 178 | - Sora 専用録画ファイル S3 互換オブジェクトストレージアップロードツール 179 | - [Prometheus exporter for WebRTC SFU Sora metrics](https://github.com/shiguredo/sora_exporter) 180 | - Sora 専用 OpenMetrics 形式エクスポーター 181 | -------------------------------------------------------------------------------- /TYPEDOC.md: -------------------------------------------------------------------------------- 1 | # Sora JavaScript SDK API Document 2 | 3 | Sora JavaScript SDK は[株式会社時雨堂](https://shiguredo.jp/)が開発、販売している [WebRTC SFU Sora](https://sora.shiguredo.jp) をブラウザから扱うための SDK です。 4 | 5 | ## About Shiguredo's open source software 6 | 7 | We will not respond to PRs or issues that have not been discussed on Discord. Also, Discord is only available in Japanese. 8 | 9 | Please read before use. 10 | 11 | ## 時雨堂のオープンソースソフトウェアについて 12 | 13 | 利用前に をお読みください。 14 | 15 | ## ライセンス 16 | 17 | Apache License 2.0 18 | 19 | ```text 20 | Copyright 2017-2024, Shiguredo Inc. 21 | Copyright 2017-2022, Yuki Ito (Original Author) 22 | 23 | Licensed under the Apache License, Version 2.0 (the "License"); 24 | you may not use this file except in compliance with the License. 25 | You may obtain a copy of the License at 26 | 27 | http://www.apache.org/licenses/LICENSE-2.0 28 | 29 | Unless required by applicable law or agreed to in writing, software 30 | distributed under the License is distributed on an "AS IS" BASIS, 31 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 32 | See the License for the specific language governing permissions and 33 | limitations under the License. 34 | ``` 35 | -------------------------------------------------------------------------------- /biome.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", 3 | "organizeImports": { 4 | "enabled": true 5 | }, 6 | "files": { 7 | "include": [ 8 | "src/**", 9 | "examples/**", 10 | "tests/**", 11 | "e2e-tests/**", 12 | "*.config.mjs", 13 | "package.json", 14 | "biome.jsonc" 15 | ] 16 | }, 17 | "linter": { 18 | "enabled": true, 19 | "rules": { 20 | "recommended": true 21 | } 22 | }, 23 | "formatter": { 24 | "enabled": true, 25 | "formatWithErrors": false, 26 | "ignore": [], 27 | "indentStyle": "space", 28 | "indentWidth": 2, 29 | "lineWidth": 100 30 | }, 31 | "json": { 32 | "parser": { 33 | "allowComments": true 34 | }, 35 | "formatter": { 36 | "enabled": true, 37 | "indentStyle": "space", 38 | "indentWidth": 2, 39 | "lineWidth": 100 40 | } 41 | }, 42 | "javascript": { 43 | "formatter": { 44 | "enabled": true, 45 | "quoteStyle": "single", 46 | "jsxQuoteStyle": "double", 47 | "trailingCommas": "all", 48 | "semicolons": "asNeeded", 49 | "arrowParentheses": "always", 50 | "indentStyle": "space", 51 | "indentWidth": 2, 52 | "lineWidth": 100, 53 | "quoteProperties": "asNeeded" 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /canary.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import re 3 | import subprocess 4 | from typing import Optional 5 | 6 | 7 | # ファイルを読み込み、バージョンを更新 8 | def update_version(file_path: str, dry_run: bool) -> Optional[str]: 9 | with open(file_path, "r", encoding="utf-8") as f: 10 | content: str = f.read() 11 | 12 | # 現在のバージョンを取得 13 | current_version_match = re.search(r'"version"\s*:\s*"([\d\.\w-]+)"', content) 14 | if not current_version_match: 15 | raise ValueError("Version not found or incorrect format in package.json") 16 | 17 | current_version: str = current_version_match.group(1) 18 | 19 | # バージョンが -canary.X を持っている場合の更新 20 | if "-canary." in current_version: 21 | new_content, count = re.subn( 22 | r'("version"\s*:\s*")(\d+\.\d+\.\d+-canary\.)(\d+)', 23 | lambda m: f"{m.group(1)}{m.group(2)}{int(m.group(3)) + 1}", 24 | content, 25 | ) 26 | else: 27 | # -canary.X がない場合、次のマイナーバージョンにして -canary.0 を追加 28 | new_content, count = re.subn( 29 | r'("version"\s*:\s*")(\d+)\.(\d+)\.(\d+)', 30 | lambda m: f"{m.group(1)}{m.group(2)}.{int(m.group(3)) + 1}.0-canary.0", 31 | content, 32 | ) 33 | 34 | if count == 0: 35 | raise ValueError("Version not found or incorrect format in package.json") 36 | 37 | # 新しいバージョンを確認 38 | new_version_match = re.search(r'"version"\s*:\s*"([\d\.\w-]+)"', new_content) 39 | if not new_version_match: 40 | raise ValueError("Failed to extract the new version after the update.") 41 | 42 | new_version: str = new_version_match.group(1) 43 | 44 | print(f"Current version: {current_version}") 45 | print(f"New version: {new_version}") 46 | confirmation: str = ( 47 | input("Do you want to update the version? (Y/n): ").strip().lower() 48 | ) 49 | 50 | if confirmation != "y": 51 | print("Version update canceled.") 52 | return None 53 | 54 | # Dry-run 時の動作 55 | if dry_run: 56 | print("Dry-run: Version would be updated to:") 57 | print(new_content) 58 | else: 59 | with open(file_path, "w", encoding="utf-8") as f: 60 | f.write(new_content) 61 | print(f"Version updated in package.json to {new_version}") 62 | 63 | return new_version 64 | 65 | 66 | # pnpm install & pnpm build 実行 67 | def run_pnpm_operations(dry_run: bool) -> None: 68 | if dry_run: 69 | print("Dry-run: Would run 'pnpm run build'") 70 | else: 71 | subprocess.run(["pnpm", "run", "build"], check=True) 72 | print("pnpm run build executed") 73 | 74 | 75 | # git コミット、タグ、プッシュを実行 76 | def git_commit_version(new_version: str, dry_run: bool) -> None: 77 | if dry_run: 78 | print("Dry-run: Would run 'git add package.json'") 79 | print(f"Dry-run: Would run '[canary] Bump version to {new_version}'") 80 | else: 81 | subprocess.run(["git", "add", "package.json"], check=True) 82 | subprocess.run( 83 | ["git", "commit", "-m", f"[canary] Bump version to {new_version}"], 84 | check=True, 85 | ) 86 | print(f"Version bumped and committed: {new_version}") 87 | 88 | 89 | # git コミット、タグ、プッシュを実行 90 | def git_operations_after_build(new_version: str, dry_run: bool) -> None: 91 | if dry_run: 92 | print(f"Dry-run: Would run 'git tag {new_version}'") 93 | print("Dry-run: Would run 'git push'") 94 | print(f"Dry-run: Would run 'git push origin {new_version}'") 95 | else: 96 | subprocess.run(["git", "tag", new_version], check=True) 97 | subprocess.run(["git", "push"], check=True) 98 | subprocess.run(["git", "push", "origin", new_version], check=True) 99 | 100 | 101 | # メイン処理 102 | def main() -> None: 103 | parser = argparse.ArgumentParser( 104 | description="Update package.json version, run pnpm install, build, and commit changes." 105 | ) 106 | parser.add_argument( 107 | "--dry-run", 108 | action="store_true", 109 | help="Run in dry-run mode without making actual changes", 110 | ) 111 | args = parser.parse_args() 112 | 113 | package_json_path: str = "package.json" 114 | 115 | # バージョン更新 116 | new_version: Optional[str] = update_version(package_json_path, args.dry_run) 117 | 118 | if not new_version: 119 | return # ユーザーが確認をキャンセルした場合、処理を中断 120 | 121 | # バージョン更新後にまず git commit 122 | git_commit_version(new_version, args.dry_run) 123 | 124 | # pnpm install & build 実行 125 | run_pnpm_operations(args.dry_run) 126 | 127 | # ビルド後のファイルを git commit, タグ付け、プッシュ 128 | git_operations_after_build(new_version, args.dry_run) 129 | 130 | 131 | if __name__ == "__main__": 132 | main() 133 | -------------------------------------------------------------------------------- /e2e-tests/README.md: -------------------------------------------------------------------------------- 1 | # Sora JavaScript SDK E2E テスト 2 | 3 | ## 使い方 4 | 5 | ```bash 6 | $ git clone git@github.com:shiguredo/sora-js-sdk.git 7 | $ cd sora-js-sdk 8 | # .env.local を作成して適切な値を設定してください 9 | $ cp .env.template .env.local 10 | $ pnpm install 11 | $ pnpm build 12 | $ pnpm e2e-test 13 | ``` 14 | 15 | ## WHIP/WHEP E2E テスト 16 | 17 | SDK では対応していないブラウザレベルでの WHIP/WHEP の E2E テストを用意しています。 18 | 環境変数に `E2E_TEST_WISH=true` を設定することで実行する事ができます。 19 | 20 | このサンプルは Chrome でのみ動作します。 21 | 22 | WHIP/WHEP の E2E テストを実行する場合は、 23 | `whip` と `whep` が有効になっており、 24 | `whip_turn` と `whep_turn` も有効になっている必要があります。 25 | 26 | > [!WARNING] 27 | > ブラウザから fetch する場合は CORS の設定が必要です。 28 | -------------------------------------------------------------------------------- /e2e-tests/data_channel_signaling_only/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | DataChannelSignalingOnly test 7 | 8 | 9 | 10 |
11 |

DataChannelSignalingOnly test

12 |
13 | 14 |
15 | 16 | 17 | 18 |
19 |
20 | 22 |
23 |
24 |
25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /e2e-tests/data_channel_signaling_only/main.ts: -------------------------------------------------------------------------------- 1 | import { setSoraJsSdkVersion } from '../src/misc' 2 | 3 | import Sora, { 4 | type SignalingNotifyMessage, 5 | type SignalingEvent, 6 | type ConnectionPublisher, 7 | type SoraConnection, 8 | type ConnectionOptions, 9 | } from 'sora-js-sdk' 10 | 11 | document.addEventListener('DOMContentLoaded', async () => { 12 | const signalingUrl = import.meta.env.VITE_TEST_SIGNALING_URL 13 | const channelIdPrefix = import.meta.env.VITE_TEST_CHANNEL_ID_PREFIX || '' 14 | const channelIdSuffix = import.meta.env.VITE_TEST_CHANNEL_ID_SUFFIX || '' 15 | const secretKey = import.meta.env.VITE_TEST_SECRET_KEY 16 | const apiUrl = import.meta.env.VITE_TEST_API_URL 17 | 18 | setSoraJsSdkVersion() 19 | 20 | let client: SoraClient 21 | 22 | document.querySelector('#connect')?.addEventListener('click', async () => { 23 | const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true }) 24 | 25 | // channelName 26 | const channelName = document.querySelector('#channel-name')?.value 27 | if (!channelName) { 28 | throw new Error('channelName is required') 29 | } 30 | 31 | client = new SoraClient( 32 | signalingUrl, 33 | channelIdPrefix, 34 | channelIdSuffix, 35 | secretKey, 36 | channelName, 37 | apiUrl, 38 | ) 39 | await client.connect(stream) 40 | }) 41 | 42 | document.querySelector('#disconnect')?.addEventListener('click', async () => { 43 | await client.disconnect() 44 | }) 45 | 46 | document.querySelector('#disconnect-api')?.addEventListener('click', async () => { 47 | await client.apiDisconnect() 48 | }) 49 | 50 | document.querySelector('#get-stats')?.addEventListener('click', async () => { 51 | const statsReport = await client.getStats() 52 | const statsDiv = document.querySelector('#stats-report') as HTMLElement 53 | const statsReportJsonDiv = document.querySelector('#stats-report-json') 54 | if (statsDiv && statsReportJsonDiv) { 55 | let statsHtml = '' 56 | const statsReportJson: Record[] = [] 57 | for (const report of statsReport.values()) { 58 | statsHtml += `

Type: ${report.type}

    ` 59 | const reportJson: Record = { id: report.id, type: report.type } 60 | for (const [key, value] of Object.entries(report)) { 61 | if (key !== 'type' && key !== 'id') { 62 | statsHtml += `
  • ${key}: ${value}
  • ` 63 | reportJson[key] = value 64 | } 65 | } 66 | statsHtml += '
' 67 | statsReportJson.push(reportJson) 68 | } 69 | statsDiv.innerHTML = statsHtml 70 | // データ属性としても保存(オプション) 71 | statsDiv.dataset.statsReportJson = JSON.stringify(statsReportJson) 72 | } 73 | }) 74 | }) 75 | 76 | class SoraClient { 77 | private debug = false 78 | private channelId: string 79 | private metadata: { access_token: string } 80 | private options: ConnectionOptions = { 81 | dataChannelSignaling: true, 82 | ignoreDisconnectWebSocket: true, 83 | } 84 | 85 | private sora: SoraConnection 86 | private connection: ConnectionPublisher 87 | 88 | private apiUrl: string 89 | 90 | constructor( 91 | signalingUrl: string, 92 | channelIdPrefix: string, 93 | channelIdSuffix: string, 94 | secretKey: string, 95 | channelName: string, 96 | apiUrl: string, 97 | ) { 98 | this.apiUrl = apiUrl 99 | 100 | this.sora = Sora.connection(signalingUrl, this.debug) 101 | 102 | // channel_id の生成 103 | this.channelId = `${channelIdPrefix}${channelName}${channelIdSuffix}` 104 | // access_token を指定する metadata の生成 105 | this.metadata = { access_token: secretKey } 106 | 107 | this.connection = this.sora.sendonly(this.channelId, this.metadata, this.options) 108 | this.connection.on('notify', this.onNotify.bind(this)) 109 | 110 | // E2E テスト用のコード 111 | this.connection.on('signaling', this.onSignaling.bind(this)) 112 | } 113 | 114 | async connect(stream: MediaStream): Promise { 115 | await this.connection.connect(stream) 116 | 117 | const videoElement = document.querySelector('#local-video') 118 | if (videoElement !== null) { 119 | videoElement.srcObject = stream 120 | } 121 | } 122 | 123 | async disconnect(): Promise { 124 | await this.connection.disconnect() 125 | 126 | const videoElement = document.querySelector('#local-video') 127 | if (videoElement !== null) { 128 | videoElement.srcObject = null 129 | } 130 | } 131 | 132 | getStats(): Promise { 133 | if (this.connection.pc === null) { 134 | return Promise.reject(new Error('PeerConnection is not ready')) 135 | } 136 | return this.connection.pc.getStats() 137 | } 138 | 139 | private onNotify(event: SignalingNotifyMessage): void { 140 | if ( 141 | event.event_type === 'connection.created' && 142 | this.connection.connectionId === event.connection_id 143 | ) { 144 | const connectionIdElement = document.querySelector('#connection-id') 145 | if (connectionIdElement) { 146 | connectionIdElement.textContent = event.connection_id 147 | } 148 | } 149 | } 150 | 151 | // E2E テスト用のコード 152 | private onSignaling(event: SignalingEvent): void { 153 | if (event.type === 'onmessage-switched') { 154 | console.log('[signaling]', event.type, event.transportType) 155 | } 156 | if (event.type === 'onmessage-close') { 157 | console.log('[signaling]', event.type, event.transportType) 158 | } 159 | } 160 | 161 | // E2E テスト側で実行した方が良い気がする 162 | async apiDisconnect(): Promise { 163 | if (!this.apiUrl) { 164 | throw new Error('VITE_TEST_API_URL is not set') 165 | } 166 | const response = await fetch(this.apiUrl, { 167 | method: 'POST', 168 | headers: { 169 | 'Content-Type': 'application/json', 170 | 'X-Sora-Target': 'Sora_20151104.DisconnectConnection', 171 | }, 172 | body: JSON.stringify({ 173 | channel_id: this.channelId, 174 | connection_id: this.connection.connectionId, 175 | }), 176 | }) 177 | if (!response.ok) { 178 | throw new Error(`HTTP error! status: ${response.status}`) 179 | } 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /e2e-tests/fake_sendonly/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Fake sendonly test 7 | 8 | 9 | 10 |
11 |

Fake sendonly test

12 |
13 | 14 | 15 |
16 |
17 | 18 | 19 |
20 | 21 |
22 | 23 |
24 | 25 | 26 |
27 |
28 | 30 |
31 |
32 |
33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /e2e-tests/fake_sendonly/main.ts: -------------------------------------------------------------------------------- 1 | import { getFakeMedia } from '../src/fake' 2 | import { getChannelId, setSoraJsSdkVersion } from '../src/misc' 3 | 4 | import Sora, { 5 | type SignalingNotifyMessage, 6 | type SignalingEvent, 7 | type ConnectionPublisher, 8 | type SoraConnection, 9 | } from 'sora-js-sdk' 10 | 11 | document.addEventListener('DOMContentLoaded', async () => { 12 | const signalingUrl = import.meta.env.VITE_TEST_SIGNALING_URL 13 | const channelIdPrefix = import.meta.env.VITE_TEST_CHANNEL_ID_PREFIX || '' 14 | const channelIdSuffix = import.meta.env.VITE_TEST_CHANNEL_ID_SUFFIX || '' 15 | const secretKey = import.meta.env.VITE_TEST_SECRET_KEY 16 | 17 | setSoraJsSdkVersion() 18 | 19 | let client: SoraClient 20 | 21 | document.querySelector('#connect')?.addEventListener('click', async () => { 22 | if (client) { 23 | await client.disconnect() 24 | } 25 | 26 | const channelId = getChannelId(channelIdPrefix, channelIdSuffix) 27 | 28 | client = new SoraClient(signalingUrl, channelId, secretKey) 29 | 30 | const useFakeAudio = (document.querySelector('#use-fake-audio') as HTMLInputElement).checked 31 | const useFakeVideo = (document.querySelector('#use-fake-video') as HTMLInputElement).checked 32 | 33 | const stream = getFakeMedia({ 34 | audio: useFakeAudio, 35 | video: useFakeVideo, 36 | }) 37 | 38 | await client.connect(stream) 39 | }) 40 | 41 | document.querySelector('#disconnect')?.addEventListener('click', async () => { 42 | if (client) { 43 | await client.disconnect() 44 | } 45 | }) 46 | 47 | document.querySelector('#get-stats')?.addEventListener('click', async () => { 48 | if (!client) { 49 | return 50 | } 51 | 52 | const statsReport = await client.getStats() 53 | const statsDiv = document.querySelector('#stats-report') as HTMLElement 54 | const statsReportJsonDiv = document.querySelector('#stats-report-json') 55 | if (statsDiv && statsReportJsonDiv) { 56 | let statsHtml = '' 57 | const statsReportJson: Record[] = [] 58 | for (const report of statsReport.values()) { 59 | statsHtml += `

Type: ${report.type}

    ` 60 | const reportJson: Record = { id: report.id, type: report.type } 61 | for (const [key, value] of Object.entries(report)) { 62 | if (key !== 'type' && key !== 'id') { 63 | statsHtml += `
  • ${key}: ${value}
  • ` 64 | reportJson[key] = value 65 | } 66 | } 67 | statsHtml += '
' 68 | statsReportJson.push(reportJson) 69 | } 70 | statsDiv.innerHTML = statsHtml 71 | // データ属性としても保存(オプション) 72 | statsDiv.dataset.statsReportJson = JSON.stringify(statsReportJson) 73 | } 74 | }) 75 | }) 76 | 77 | class SoraClient { 78 | private debug = false 79 | private channelId: string 80 | private metadata: { access_token: string } 81 | private options: object = {} 82 | 83 | private sora: SoraConnection 84 | private connection: ConnectionPublisher 85 | 86 | constructor(signalingUrl: string, channelId: string, secretKey: string) { 87 | this.sora = Sora.connection(signalingUrl, this.debug) 88 | 89 | this.channelId = channelId 90 | 91 | // access_token を指定する metadata の生成 92 | this.metadata = { access_token: secretKey } 93 | 94 | this.connection = this.sora.sendonly(this.channelId, this.metadata, this.options) 95 | this.connection.on('notify', this.onNotify.bind(this)) 96 | 97 | // E2E テスト用のコード 98 | this.connection.on('signaling', this.onSignaling.bind(this)) 99 | } 100 | 101 | async connect(stream: MediaStream): Promise { 102 | await this.connection.connect(stream) 103 | 104 | const videoElement = document.querySelector('#local-video') 105 | if (videoElement !== null) { 106 | videoElement.srcObject = stream 107 | } 108 | } 109 | 110 | async disconnect(): Promise { 111 | await this.connection.disconnect() 112 | 113 | const videoElement = document.querySelector('#local-video') 114 | if (videoElement !== null) { 115 | videoElement.srcObject = null 116 | } 117 | } 118 | 119 | getStats(): Promise { 120 | if (this.connection.pc === null) { 121 | return Promise.reject(new Error('PeerConnection is not ready')) 122 | } 123 | return this.connection.pc.getStats() 124 | } 125 | 126 | private onNotify(event: SignalingNotifyMessage): void { 127 | if ( 128 | event.event_type === 'connection.created' && 129 | this.connection.connectionId === event.connection_id 130 | ) { 131 | const connectionIdElement = document.querySelector('#connection-id') 132 | if (connectionIdElement) { 133 | connectionIdElement.textContent = event.connection_id 134 | } 135 | } 136 | } 137 | 138 | // E2E テスト用のコード 139 | private onSignaling(event: SignalingEvent): void { 140 | if (event.type === 'onmessage-switched') { 141 | console.log('[signaling]', event.type, event.transportType) 142 | } 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /e2e-tests/h265/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Sendrecv test 7 | 8 | 9 | 10 |
11 |

Sendrecv test

12 |
13 | 14 |
15 | 16 |
19 | 20 | 21 |
22 |
23 | 25 |
26 |
27 |
28 |
29 |
30 |
31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /e2e-tests/h265/main.ts: -------------------------------------------------------------------------------- 1 | import { generateJwt, getChannelId, getVideoCodecType, setSoraJsSdkVersion } from '../src/misc' 2 | 3 | import Sora, { 4 | type SoraConnection, 5 | type SignalingNotifyMessage, 6 | type ConnectionPublisher, 7 | type VideoCodecType, 8 | type ConnectionOptions, 9 | } from 'sora-js-sdk' 10 | 11 | document.addEventListener('DOMContentLoaded', async () => { 12 | const signalingUrl = import.meta.env.VITE_TEST_SIGNALING_URL 13 | const channelIdPrefix = import.meta.env.VITE_TEST_CHANNEL_ID_PREFIX || '' 14 | const channelIdSuffix = import.meta.env.VITE_TEST_CHANNEL_ID_SUFFIX || '' 15 | const secretKey = import.meta.env.VITE_TEST_SECRET_KEY 16 | 17 | setSoraJsSdkVersion() 18 | 19 | let client: SoraClient 20 | 21 | document.querySelector('#connect')?.addEventListener('click', async () => { 22 | const channelId = getChannelId(channelIdPrefix, channelIdSuffix) 23 | const videoCodecType = getVideoCodecType() 24 | 25 | client = new SoraClient(signalingUrl, channelId, videoCodecType, secretKey) 26 | 27 | const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true }) 28 | await client.connect(stream) 29 | }) 30 | document.querySelector('#disconnect')?.addEventListener('click', async () => { 31 | await client.disconnect() 32 | }) 33 | 34 | document.querySelector('#get-stats')?.addEventListener('click', async () => { 35 | const statsReport = await client.getStats() 36 | const statsDiv = document.querySelector('#stats-report') as HTMLElement 37 | const statsReportJsonDiv = document.querySelector('#stats-report-json') 38 | if (statsDiv && statsReportJsonDiv) { 39 | let statsHtml = '' 40 | const statsReportJson: Record[] = [] 41 | for (const report of statsReport.values()) { 42 | statsHtml += `

Type: ${report.type}

    ` 43 | const reportJson: Record = { id: report.id, type: report.type } 44 | for (const [key, value] of Object.entries(report)) { 45 | if (key !== 'type' && key !== 'id') { 46 | statsHtml += `
  • ${key}: ${value}
  • ` 47 | reportJson[key] = value 48 | } 49 | } 50 | statsHtml += '
' 51 | statsReportJson.push(reportJson) 52 | } 53 | statsDiv.innerHTML = statsHtml 54 | // データ属性としても保存(オプション) 55 | statsDiv.dataset.statsReportJson = JSON.stringify(statsReportJson) 56 | } 57 | }) 58 | }) 59 | 60 | class SoraClient { 61 | private debug = false 62 | 63 | private channelId: string 64 | private videoCodecType: VideoCodecType | undefined 65 | private metadata: { access_token: string } | undefined 66 | private options: ConnectionOptions = {} 67 | private secretKey: string 68 | 69 | private sora: SoraConnection 70 | private connection: ConnectionPublisher 71 | 72 | constructor( 73 | signalingUrl: string, 74 | channelId: string, 75 | videoCodecType: VideoCodecType | undefined, 76 | secretKey: string, 77 | ) { 78 | this.channelId = channelId 79 | this.videoCodecType = videoCodecType 80 | this.secretKey = secretKey 81 | 82 | this.sora = Sora.connection(signalingUrl, this.debug) 83 | 84 | if (this.videoCodecType !== undefined) { 85 | this.options = { ...this.options, videoCodecType: this.videoCodecType } 86 | } 87 | 88 | this.connection = this.sora.sendrecv(this.channelId, this.metadata, this.options) 89 | 90 | this.connection.on('notify', this.onnotify.bind(this)) 91 | this.connection.on('track', this.ontrack.bind(this)) 92 | this.connection.on('removetrack', this.onremovetrack.bind(this)) 93 | } 94 | 95 | async connect(stream: MediaStream) { 96 | const jwt = await generateJwt(this.channelId, this.secretKey) 97 | this.metadata = { access_token: jwt } 98 | this.connection.metadata = this.metadata 99 | 100 | await this.connection.connect(stream) 101 | const localVideo = document.querySelector('#local-video') 102 | if (localVideo) { 103 | localVideo.srcObject = stream 104 | } 105 | } 106 | 107 | async disconnect() { 108 | await this.connection.disconnect() 109 | 110 | // お掃除 111 | const localVideo = document.querySelector('#local-video') 112 | if (localVideo) { 113 | localVideo.srcObject = null 114 | } 115 | // お掃除 116 | const remoteVideos = document.querySelector('#remote-videos') 117 | if (remoteVideos) { 118 | remoteVideos.innerHTML = '' 119 | } 120 | } 121 | 122 | getStats(): Promise { 123 | if (this.connection.pc === null) { 124 | return Promise.reject(new Error('PeerConnection is not ready')) 125 | } 126 | return this.connection.pc.getStats() 127 | } 128 | 129 | private onnotify(event: SignalingNotifyMessage): void { 130 | if ( 131 | event.event_type === 'connection.created' && 132 | this.connection.connectionId === event.connection_id 133 | ) { 134 | const connectionIdElement = document.querySelector('#connection-id') 135 | if (connectionIdElement) { 136 | connectionIdElement.textContent = event.connection_id 137 | } 138 | } 139 | } 140 | 141 | private ontrack(event: RTCTrackEvent): void { 142 | const stream = event.streams[0] 143 | const remoteVideoId = `remote-video-${stream.id}` 144 | const remoteVideos = document.querySelector('#remote-videos') 145 | if (remoteVideos && !remoteVideos.querySelector(`#${remoteVideoId}`)) { 146 | const remoteVideo = document.createElement('video') 147 | remoteVideo.id = remoteVideoId 148 | remoteVideo.style.border = '1px solid red' 149 | remoteVideo.autoplay = true 150 | remoteVideo.playsInline = true 151 | remoteVideo.controls = true 152 | remoteVideo.width = 320 153 | remoteVideo.height = 240 154 | remoteVideo.srcObject = stream 155 | remoteVideos.appendChild(remoteVideo) 156 | } 157 | } 158 | 159 | private onremovetrack(event: MediaStreamTrackEvent): void { 160 | const target = event.target as MediaStream 161 | const remoteVideo = document.querySelector(`#remote-video-${target.id}`) 162 | if (remoteVideo) { 163 | document.querySelector('#remote-videos')?.removeChild(remoteVideo) 164 | } 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /e2e-tests/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Sora JavaScript SDK example 7 | 8 | 9 | 10 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /e2e-tests/messaging/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Messaging 6 | 7 | 8 | 9 |
10 |

DataChannel messaging test

11 |
12 |

複数のブラウザで開いて sendMessage することで動作確認できます

13 | 14 |
15 |
16 |
17 | 18 |
19 |
20 |
21 | 22 | 23 |
24 |
25 | 26 | 27 |
28 | 29 | 30 |
31 |
32 |

messaging

33 |
    34 |
35 |
36 |
37 |

messages

38 |
    39 |
40 |
41 |
42 |
43 |
44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /e2e-tests/messaging/main.ts: -------------------------------------------------------------------------------- 1 | import { getChannelId, setSoraJsSdkVersion } from '../src/misc' 2 | 3 | import Sora, { 4 | type SoraConnection, 5 | type ConnectionMessaging, 6 | type SignalingNotifyMessage, 7 | type DataChannelMessageEvent, 8 | type DataChannelEvent, 9 | } from 'sora-js-sdk' 10 | 11 | document.addEventListener('DOMContentLoaded', async () => { 12 | const signalingUrl = import.meta.env.VITE_TEST_SIGNALING_URL 13 | const channelIdPrefix = import.meta.env.VITE_TEST_CHANNEL_ID_PREFIX || '' 14 | const channelIdSuffix = import.meta.env.VITE_TEST_CHANNEL_ID_SUFFIX || '' 15 | const secretKey = import.meta.env.VITE_TEST_SECRET_KEY 16 | 17 | setSoraJsSdkVersion() 18 | 19 | let client: SoraClient 20 | 21 | document.querySelector('#connect')?.addEventListener('click', async () => { 22 | const channelId = getChannelId(channelIdPrefix, channelIdSuffix) 23 | 24 | client = new SoraClient(signalingUrl, channelId, secretKey) 25 | const checkCompress = document.getElementById('check-compress') as HTMLInputElement 26 | const compress = checkCompress.checked 27 | const checkHeader = document.getElementById('check-header') as HTMLInputElement 28 | const header = checkHeader.checked 29 | 30 | await client.connect(compress, header) 31 | }) 32 | document.querySelector('#disconnect')?.addEventListener('click', async () => { 33 | await client.disconnect() 34 | }) 35 | document.querySelector('#send-message')?.addEventListener('click', async () => { 36 | const value = document.querySelector('input[name=message]')?.value 37 | if (value !== undefined && value !== '') { 38 | await client.sendMessage(value) 39 | } 40 | }) 41 | 42 | document.querySelector('#get-stats')?.addEventListener('click', async () => { 43 | const statsReport = await client.getStats() 44 | const statsDiv = document.querySelector('#stats-report') as HTMLElement 45 | const statsReportJsonDiv = document.querySelector('#stats-report-json') 46 | if (statsDiv && statsReportJsonDiv) { 47 | let statsHtml = '' 48 | const statsReportJson: Record[] = [] 49 | for (const report of statsReport.values()) { 50 | statsHtml += `

Type: ${report.type}

    ` 51 | const reportJson: Record = { id: report.id, type: report.type } 52 | for (const [key, value] of Object.entries(report)) { 53 | if (key !== 'type' && key !== 'id') { 54 | statsHtml += `
  • ${key}: ${value}
  • ` 55 | reportJson[key] = value 56 | } 57 | } 58 | statsHtml += '
' 59 | statsReportJson.push(reportJson) 60 | } 61 | statsDiv.innerHTML = statsHtml 62 | // データ属性としても保存(オプション) 63 | statsDiv.dataset.statsReportJson = JSON.stringify(statsReportJson) 64 | } 65 | }) 66 | }) 67 | 68 | class SoraClient { 69 | private debug = false 70 | 71 | private channelId: string 72 | private metadata: { access_token: string } 73 | private options: object 74 | 75 | private sora: SoraConnection 76 | private connection: ConnectionMessaging 77 | 78 | constructor(signalingUrl: string, channelId: string, secretKey: string) { 79 | this.sora = Sora.connection(signalingUrl, this.debug) 80 | this.channelId = channelId 81 | this.metadata = { access_token: secretKey } 82 | 83 | this.options = { 84 | dataChannelSignaling: true, 85 | dataChannels: [ 86 | { 87 | label: '#example', 88 | direction: 'sendrecv', 89 | compress: true, 90 | }, 91 | ], 92 | } 93 | 94 | this.connection = this.sora.messaging(this.channelId, this.metadata, this.options) 95 | 96 | this.connection.on('notify', this.onnotify.bind(this)) 97 | this.connection.on('datachannel', this.ondatachannel.bind(this)) 98 | this.connection.on('message', this.onmessage.bind(this)) 99 | } 100 | 101 | async connect(compress: boolean, header: boolean) { 102 | // connect ボタンを無効にする 103 | const connectButton = document.querySelector('#connect') 104 | if (connectButton) { 105 | connectButton.disabled = true 106 | } 107 | 108 | // dataChannels の compress の設定を上書きする 109 | this.connection.options.dataChannels = [ 110 | { 111 | label: '#example', 112 | direction: 'sendrecv', 113 | compress: compress, 114 | // header が true の場合は sender_connection_id を追加 115 | header: header ? [{ type: 'sender_connection_id' }] : undefined, 116 | }, 117 | ] 118 | await this.connection.connect() 119 | 120 | // disconnect ボタンを有効にする 121 | const disconnectButton = document.querySelector('#disconnect') 122 | if (disconnectButton) { 123 | disconnectButton.disabled = false 124 | } 125 | } 126 | 127 | async disconnect() { 128 | await this.connection.disconnect() 129 | 130 | // connect ボタンを有効にする 131 | const connectButton = document.querySelector('#connect') 132 | if (connectButton) { 133 | connectButton.disabled = false 134 | } 135 | 136 | // disconnect ボタンを無効にする 137 | const disconnectButton = document.querySelector('#disconnect') 138 | if (disconnectButton) { 139 | disconnectButton.disabled = true 140 | } 141 | 142 | const receivedMessagesElement = document.querySelector('#received-messages') 143 | if (receivedMessagesElement) { 144 | receivedMessagesElement.innerHTML = '' 145 | } 146 | } 147 | 148 | getStats(): Promise { 149 | if (this.connection.pc === null) { 150 | return Promise.reject(new Error('PeerConnection is not ready')) 151 | } 152 | return this.connection.pc.getStats() 153 | } 154 | 155 | async sendMessage(message: string) { 156 | if (message !== '') { 157 | await this.connection.sendMessage('#example', new TextEncoder().encode(message)) 158 | } 159 | } 160 | 161 | private onnotify(event: SignalingNotifyMessage): void { 162 | if ( 163 | event.event_type === 'connection.created' && 164 | this.connection.connectionId === event.connection_id 165 | ) { 166 | const connectionIdElement = document.querySelector('#connection-id') 167 | if (connectionIdElement) { 168 | connectionIdElement.textContent = event.connection_id 169 | } 170 | 171 | // 送信ボタンを有効にする 172 | const sendMessageButton = document.querySelector('#send-message') 173 | if (sendMessageButton) { 174 | sendMessageButton.disabled = false 175 | } 176 | } 177 | } 178 | 179 | private ondatachannel(event: DataChannelEvent) { 180 | const openDataChannel = document.createElement('li') 181 | openDataChannel.textContent = new TextDecoder().decode( 182 | new TextEncoder().encode(event.datachannel.label), 183 | ) 184 | document.querySelector('#messaging')?.appendChild(openDataChannel) 185 | } 186 | 187 | private onmessage(event: DataChannelMessageEvent) { 188 | const message = document.createElement('li') 189 | message.textContent = new TextDecoder().decode(event.data) 190 | document.querySelector('#received-messages')?.appendChild(message) 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /e2e-tests/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sora-js-sdk-e2e-tests", 3 | "scripts": { 4 | "lint": "biome lint .", 5 | "fmt": "biome format --write .", 6 | "check": "tsc --noEmit" 7 | }, 8 | "dependencies": { 9 | "jose": "6.0.11", 10 | "sora-js-sdk": "workspace:*" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /e2e-tests/recvonly/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Recvonly test 7 | 8 | 9 | 10 |
11 |

Recvonly test

12 |
13 | 14 |
15 | 16 | 17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /e2e-tests/recvonly/main.ts: -------------------------------------------------------------------------------- 1 | import { getChannelId, setSoraJsSdkVersion } from '../src/misc' 2 | 3 | import Sora, { 4 | type SoraConnection, 5 | type SignalingNotifyMessage, 6 | type ConnectionSubscriber, 7 | } from 'sora-js-sdk' 8 | 9 | document.addEventListener('DOMContentLoaded', () => { 10 | const signalingUrl = import.meta.env.VITE_TEST_SIGNALING_URL 11 | const channelIdPrefix = import.meta.env.VITE_TEST_CHANNEL_ID_PREFIX || '' 12 | const channelIdSuffix = import.meta.env.VITE_TEST_CHANNEL_ID_SUFFIX || '' 13 | const secretKey = import.meta.env.VITE_TEST_SECRET_KEY 14 | 15 | setSoraJsSdkVersion() 16 | 17 | let client: SoraClient 18 | 19 | document.querySelector('#connect')?.addEventListener('click', async () => { 20 | if (client) { 21 | await client.disconnect() 22 | } 23 | 24 | const channelId = getChannelId(channelIdPrefix, channelIdSuffix) 25 | 26 | client = new SoraClient(signalingUrl, channelId, secretKey) 27 | 28 | await client.connect() 29 | }) 30 | 31 | document.querySelector('#disconnect')?.addEventListener('click', async () => { 32 | if (!client) { 33 | return 34 | } 35 | 36 | await client.disconnect() 37 | }) 38 | 39 | document.querySelector('#get-stats')?.addEventListener('click', async () => { 40 | if (!client) { 41 | return 42 | } 43 | 44 | const statsReport = await client.getStats() 45 | const statsDiv = document.querySelector('#stats-report') as HTMLElement 46 | const statsReportJsonDiv = document.querySelector('#stats-report-json') 47 | if (statsDiv && statsReportJsonDiv) { 48 | let statsHtml = '' 49 | const statsReportJson: Record[] = [] 50 | for (const report of statsReport.values()) { 51 | statsHtml += `

Type: ${report.type}

    ` 52 | const reportJson: Record = { id: report.id, type: report.type } 53 | for (const [key, value] of Object.entries(report)) { 54 | if (key !== 'type' && key !== 'id') { 55 | statsHtml += `
  • ${key}: ${value}
  • ` 56 | reportJson[key] = value 57 | } 58 | } 59 | statsHtml += '
' 60 | statsReportJson.push(reportJson) 61 | } 62 | statsDiv.innerHTML = statsHtml 63 | // データ属性としても保存(オプション) 64 | statsDiv.dataset.statsReportJson = JSON.stringify(statsReportJson) 65 | } 66 | }) 67 | }) 68 | 69 | class SoraClient { 70 | private debug = false 71 | private channelId: string 72 | private metadata: { access_token: string } 73 | private options: object = {} 74 | 75 | private sora: SoraConnection 76 | private connection: ConnectionSubscriber 77 | 78 | constructor(signalingUrl: string, channelId: string, secretKey: string) { 79 | this.sora = Sora.connection(signalingUrl, this.debug) 80 | this.channelId = channelId 81 | 82 | // access_token を指定する metadata の生成 83 | this.metadata = { access_token: secretKey } 84 | 85 | this.connection = this.sora.recvonly(this.channelId, this.metadata, this.options) 86 | this.connection.on('notify', this.onnotify.bind(this)) 87 | this.connection.on('track', this.ontrack.bind(this)) 88 | this.connection.on('removetrack', this.onremovetrack.bind(this)) 89 | } 90 | 91 | async connect(): Promise { 92 | await this.connection.connect() 93 | } 94 | 95 | async disconnect(): Promise { 96 | await this.connection.disconnect() 97 | const remoteVideos = document.querySelector('#remote-videos') 98 | if (remoteVideos) { 99 | remoteVideos.innerHTML = '' 100 | } 101 | } 102 | 103 | getStats(): Promise { 104 | if (this.connection.pc === null) { 105 | return Promise.reject(new Error('PeerConnection is not ready')) 106 | } 107 | return this.connection.pc.getStats() 108 | } 109 | 110 | private onnotify(event: SignalingNotifyMessage) { 111 | // 自分の connection_id を取得する 112 | if ( 113 | event.event_type === 'connection.created' && 114 | this.connection.connectionId === event.connection_id 115 | ) { 116 | const connectionIdElement = document.querySelector('#connection-id') 117 | if (connectionIdElement) { 118 | connectionIdElement.textContent = event.connection_id 119 | } 120 | } 121 | } 122 | 123 | private ontrack(event: RTCTrackEvent) { 124 | // Sora の場合、event.streams には MediaStream が 1 つだけ含まれる 125 | const stream = event.streams[0] 126 | const remoteVideoId = `remote-video-${stream.id}` 127 | const remoteVideos = document.querySelector('#remote-videos') 128 | if (remoteVideos && !remoteVideos.querySelector(`#${remoteVideoId}`)) { 129 | const remoteVideo = document.createElement('video') 130 | remoteVideo.id = remoteVideoId 131 | remoteVideo.style.border = '1px solid red' 132 | remoteVideo.autoplay = true 133 | remoteVideo.playsInline = true 134 | remoteVideo.controls = true 135 | remoteVideo.srcObject = stream 136 | remoteVideos.appendChild(remoteVideo) 137 | } 138 | } 139 | 140 | private onremovetrack(event: MediaStreamTrackEvent) { 141 | // このトラックが属している MediaStream の id を取得する 142 | const stream = event.target as MediaStream 143 | const remoteVideo = document.querySelector(`#remote-video-${stream.id}`) 144 | if (remoteVideo) { 145 | document.querySelector('#remote-videos')?.removeChild(remoteVideo) 146 | } 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /e2e-tests/sendonly/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Sendonly test 7 | 8 | 9 | 10 |
11 |

Sendonly test

12 |
13 | 14 |
15 | 16 | 17 |
18 |
19 | 21 |
22 |
23 |
24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /e2e-tests/sendonly/main.ts: -------------------------------------------------------------------------------- 1 | import { getChannelId, setSoraJsSdkVersion } from '../src/misc' 2 | 3 | import Sora, { 4 | type SignalingNotifyMessage, 5 | type SignalingEvent, 6 | type ConnectionPublisher, 7 | type SoraConnection, 8 | } from 'sora-js-sdk' 9 | 10 | document.addEventListener('DOMContentLoaded', async () => { 11 | const signalingUrl = import.meta.env.VITE_TEST_SIGNALING_URL 12 | const channelIdPrefix = import.meta.env.VITE_TEST_CHANNEL_ID_PREFIX || '' 13 | const channelIdSuffix = import.meta.env.VITE_TEST_CHANNEL_ID_SUFFIX || '' 14 | const secretKey = import.meta.env.VITE_TEST_SECRET_KEY 15 | 16 | setSoraJsSdkVersion() 17 | 18 | let client: SoraClient 19 | 20 | document.querySelector('#connect')?.addEventListener('click', async () => { 21 | if (client) { 22 | await client.disconnect() 23 | } 24 | 25 | const channelId = getChannelId(channelIdPrefix, channelIdSuffix) 26 | 27 | client = new SoraClient(signalingUrl, channelId, secretKey) 28 | 29 | const stream = await navigator.mediaDevices.getUserMedia({ 30 | audio: true, 31 | video: true, 32 | }) 33 | await client.connect(stream) 34 | }) 35 | 36 | document.querySelector('#disconnect')?.addEventListener('click', async () => { 37 | if (client) { 38 | await client.disconnect() 39 | } 40 | }) 41 | 42 | document.querySelector('#get-stats')?.addEventListener('click', async () => { 43 | if (!client) { 44 | return 45 | } 46 | 47 | const statsReport = await client.getStats() 48 | const statsDiv = document.querySelector('#stats-report') as HTMLElement 49 | const statsReportJsonDiv = document.querySelector('#stats-report-json') 50 | if (statsDiv && statsReportJsonDiv) { 51 | let statsHtml = '' 52 | const statsReportJson: Record[] = [] 53 | for (const report of statsReport.values()) { 54 | statsHtml += `

Type: ${report.type}

    ` 55 | const reportJson: Record = { id: report.id, type: report.type } 56 | for (const [key, value] of Object.entries(report)) { 57 | if (key !== 'type' && key !== 'id') { 58 | statsHtml += `
  • ${key}: ${value}
  • ` 59 | reportJson[key] = value 60 | } 61 | } 62 | statsHtml += '
' 63 | statsReportJson.push(reportJson) 64 | } 65 | statsDiv.innerHTML = statsHtml 66 | // データ属性としても保存(オプション) 67 | statsDiv.dataset.statsReportJson = JSON.stringify(statsReportJson) 68 | } 69 | }) 70 | }) 71 | 72 | class SoraClient { 73 | private debug = false 74 | private channelId: string 75 | private metadata: { access_token: string } 76 | private options: object = {} 77 | 78 | private sora: SoraConnection 79 | private connection: ConnectionPublisher 80 | 81 | constructor(signalingUrl: string, channelId: string, secretKey: string) { 82 | this.sora = Sora.connection(signalingUrl, this.debug) 83 | 84 | this.channelId = channelId 85 | 86 | // access_token を指定する metadata の生成 87 | this.metadata = { access_token: secretKey } 88 | 89 | this.connection = this.sora.sendonly(this.channelId, this.metadata, this.options) 90 | this.connection.on('notify', this.onNotify.bind(this)) 91 | 92 | // E2E テスト用のコード 93 | this.connection.on('signaling', this.onSignaling.bind(this)) 94 | } 95 | 96 | async connect(stream: MediaStream): Promise { 97 | await this.connection.connect(stream) 98 | 99 | const videoElement = document.querySelector('#local-video') 100 | if (videoElement !== null) { 101 | videoElement.srcObject = stream 102 | } 103 | } 104 | 105 | async disconnect(): Promise { 106 | await this.connection.disconnect() 107 | 108 | const videoElement = document.querySelector('#local-video') 109 | if (videoElement !== null) { 110 | videoElement.srcObject = null 111 | } 112 | } 113 | 114 | getStats(): Promise { 115 | if (this.connection.pc === null) { 116 | return Promise.reject(new Error('PeerConnection is not ready')) 117 | } 118 | return this.connection.pc.getStats() 119 | } 120 | 121 | private onNotify(event: SignalingNotifyMessage): void { 122 | if ( 123 | event.event_type === 'connection.created' && 124 | this.connection.connectionId === event.connection_id 125 | ) { 126 | const connectionIdElement = document.querySelector('#connection-id') 127 | if (connectionIdElement) { 128 | connectionIdElement.textContent = event.connection_id 129 | } 130 | } 131 | } 132 | 133 | // E2E テスト用のコード 134 | private onSignaling(event: SignalingEvent): void { 135 | if (event.type === 'onmessage-switched') { 136 | console.log('[signaling]', event.type, event.transportType) 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /e2e-tests/sendonly_audio/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Sendonly Audio test 8 | 9 | 10 | 11 |
12 |

Sendonly Audio Test

13 |
14 | 15 |
16 | 17 | 21 |
22 | 23 | 31 |
32 | 33 | 34 | 35 |
36 |
37 | 38 | 40 |
41 |
42 |
43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /e2e-tests/sendonly_audio/main.ts: -------------------------------------------------------------------------------- 1 | import { getChannelId, setSoraJsSdkVersion } from '../src/misc' 2 | 3 | import Sora, { 4 | type SignalingNotifyMessage, 5 | type ConnectionPublisher, 6 | type SoraConnection, 7 | } from 'sora-js-sdk' 8 | 9 | document.addEventListener('DOMContentLoaded', async () => { 10 | const signalingUrl = import.meta.env.VITE_TEST_SIGNALING_URL 11 | const channelIdPrefix = import.meta.env.VITE_TEST_CHANNEL_ID_PREFIX || '' 12 | const channelIdSuffix = import.meta.env.VITE_TEST_CHANNEL_ID_SUFFIX || '' 13 | const secretKey = import.meta.env.VITE_TEST_SECRET_KEY 14 | 15 | setSoraJsSdkVersion() 16 | 17 | let client: SoraClient 18 | 19 | document.querySelector('#connect')?.addEventListener('click', async () => { 20 | // 音声コーデックの選択を取得 21 | const audioCodecType = document.getElementById('audio-codec-type') as HTMLSelectElement 22 | const selectedCodecType = audioCodecType.value === 'OPUS' ? audioCodecType.value : undefined 23 | 24 | // 音声ビットレートの選択を取得 25 | const audioBitRateSelect = document.getElementById('audio-bit-rate') as HTMLSelectElement 26 | const selectedBitRate = audioBitRateSelect.value 27 | ? Number.parseInt(audioBitRateSelect.value) 28 | : undefined 29 | 30 | const channelId = getChannelId(channelIdPrefix, channelIdSuffix) 31 | 32 | client = new SoraClient(signalingUrl, channelId, secretKey) 33 | 34 | const stream = await navigator.mediaDevices.getUserMedia({ video: false, audio: true }) 35 | await client.connect(stream, selectedCodecType, selectedBitRate) 36 | }) 37 | 38 | document.querySelector('#disconnect')?.addEventListener('click', async () => { 39 | await client.disconnect() 40 | }) 41 | 42 | document.querySelector('#get-stats')?.addEventListener('click', async () => { 43 | const statsReport = await client.getStats() 44 | const statsDiv = document.querySelector('#stats-report') as HTMLElement 45 | const statsReportJsonDiv = document.querySelector('#stats-report-json') 46 | if (statsDiv && statsReportJsonDiv) { 47 | let statsHtml = '' 48 | const statsReportJson: Record[] = [] 49 | for (const report of statsReport.values()) { 50 | statsHtml += `

Type: ${report.type}

    ` 51 | const reportJson: Record = { id: report.id, type: report.type } 52 | for (const [key, value] of Object.entries(report)) { 53 | if (key !== 'type' && key !== 'id') { 54 | statsHtml += `
  • ${key}: ${value}
  • ` 55 | reportJson[key] = value 56 | } 57 | } 58 | statsHtml += '
' 59 | statsReportJson.push(reportJson) 60 | } 61 | statsDiv.innerHTML = statsHtml 62 | // データ属性としても保存(オプション) 63 | statsDiv.dataset.statsReportJson = JSON.stringify(statsReportJson) 64 | } 65 | }) 66 | }) 67 | 68 | class SoraClient { 69 | private debug = false 70 | private channelId: string 71 | private metadata: { access_token: string } 72 | private options: object = {} 73 | 74 | private sora: SoraConnection 75 | private connection: ConnectionPublisher 76 | 77 | constructor(signalingUrl: string, channelId: string, secretKey: string) { 78 | this.sora = Sora.connection(signalingUrl, this.debug) 79 | this.channelId = channelId 80 | 81 | // access_token を指定する metadata の生成 82 | this.metadata = { access_token: secretKey } 83 | 84 | this.connection = this.sora.sendonly(this.channelId, this.metadata, this.options) 85 | this.connection.on('notify', this.onnotify.bind(this)) 86 | } 87 | 88 | async connect( 89 | stream: MediaStream, 90 | audioCodecType?: string, 91 | audioBitRate?: number, 92 | ): Promise { 93 | if (audioCodecType && audioCodecType === 'OPUS') { 94 | // 音声コーデックを上書きする 95 | this.connection.options.audioCodecType = audioCodecType 96 | } 97 | if (audioBitRate) { 98 | // 音声ビットレートを上書きする 99 | this.connection.options.audioBitRate = audioBitRate 100 | } 101 | await this.connection.connect(stream) 102 | 103 | const audioElement = document.querySelector('#local-audio') 104 | if (audioElement !== null) { 105 | audioElement.srcObject = stream 106 | } 107 | } 108 | 109 | async disconnect(): Promise { 110 | await this.connection.disconnect() 111 | 112 | const audioElement = document.querySelector('#local-audio') 113 | if (audioElement !== null) { 114 | audioElement.srcObject = null 115 | } 116 | } 117 | 118 | getStats(): Promise { 119 | if (this.connection.pc === null) { 120 | return Promise.reject(new Error('PeerConnection is not ready')) 121 | } 122 | return this.connection.pc.getStats() 123 | } 124 | 125 | private onnotify(event: SignalingNotifyMessage): void { 126 | if ( 127 | event.event_type === 'connection.created' && 128 | this.connection.connectionId === event.connection_id 129 | ) { 130 | const connectionIdElement = document.querySelector('#connection-id') 131 | if (connectionIdElement) { 132 | connectionIdElement.textContent = event.connection_id 133 | } 134 | } 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /e2e-tests/sendonly_reconnect/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Sendonly test 7 | 8 | 9 | 10 |
11 |

Sendonly test

12 |
13 | 14 |
15 | 16 | 17 | 18 |
19 |
20 | 22 | 23 |
24 | 25 |
26 |
27 |
28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /e2e-tests/sendrecv/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Sendrecv test 7 | 8 | 9 | 10 |
11 |

Sendrecv test

12 |
13 | 14 |
15 | 16 |
26 | 27 | 28 |
29 |
30 | 32 |
33 |
34 |
35 |
36 |
37 |
38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /e2e-tests/sendrecv/main.ts: -------------------------------------------------------------------------------- 1 | import { getChannelId, getVideoCodecType, setSoraJsSdkVersion } from '../src/misc' 2 | 3 | import Sora, { 4 | type SoraConnection, 5 | type SignalingNotifyMessage, 6 | type ConnectionPublisher, 7 | type VideoCodecType, 8 | type ConnectionOptions, 9 | } from 'sora-js-sdk' 10 | 11 | document.addEventListener('DOMContentLoaded', async () => { 12 | const signalingUrl = import.meta.env.VITE_TEST_SIGNALING_URL 13 | const channelIdPrefix = import.meta.env.VITE_TEST_CHANNEL_ID_PREFIX || '' 14 | const channelIdSuffix = import.meta.env.VITE_TEST_CHANNEL_ID_SUFFIX || '' 15 | const secretKey = import.meta.env.VITE_TEST_SECRET_KEY 16 | 17 | setSoraJsSdkVersion() 18 | 19 | let client: SoraClient 20 | 21 | document.querySelector('#connect')?.addEventListener('click', async () => { 22 | const channelId = getChannelId(channelIdPrefix, channelIdSuffix) 23 | const videoCodecType = getVideoCodecType() 24 | 25 | client = new SoraClient(signalingUrl, channelId, secretKey, videoCodecType) 26 | 27 | const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true }) 28 | await client.connect(stream) 29 | }) 30 | document.querySelector('#disconnect')?.addEventListener('click', async () => { 31 | await client.disconnect() 32 | }) 33 | 34 | document.querySelector('#get-stats')?.addEventListener('click', async () => { 35 | const statsReport = await client.getStats() 36 | const statsDiv = document.querySelector('#stats-report') as HTMLElement 37 | const statsReportJsonDiv = document.querySelector('#stats-report-json') 38 | if (statsDiv && statsReportJsonDiv) { 39 | let statsHtml = '' 40 | const statsReportJson: Record[] = [] 41 | for (const report of statsReport.values()) { 42 | statsHtml += `

Type: ${report.type}

    ` 43 | const reportJson: Record = { id: report.id, type: report.type } 44 | for (const [key, value] of Object.entries(report)) { 45 | if (key !== 'type' && key !== 'id') { 46 | statsHtml += `
  • ${key}: ${value}
  • ` 47 | reportJson[key] = value 48 | } 49 | } 50 | statsHtml += '
' 51 | statsReportJson.push(reportJson) 52 | } 53 | statsDiv.innerHTML = statsHtml 54 | // データ属性としても保存(オプション) 55 | statsDiv.dataset.statsReportJson = JSON.stringify(statsReportJson) 56 | } 57 | }) 58 | }) 59 | 60 | class SoraClient { 61 | private debug = false 62 | 63 | private channelId: string 64 | private metadata: { access_token: string } 65 | private options: ConnectionOptions 66 | 67 | private sora: SoraConnection 68 | private connection: ConnectionPublisher 69 | 70 | constructor( 71 | signalingUrl: string, 72 | channelId: string, 73 | secretKey: string, 74 | videoCodecType: VideoCodecType | undefined, 75 | ) { 76 | this.sora = Sora.connection(signalingUrl, this.debug) 77 | this.channelId = channelId 78 | 79 | this.metadata = { access_token: secretKey } 80 | this.options = {} 81 | 82 | if (videoCodecType !== undefined) { 83 | this.options = { ...this.options, videoCodecType: videoCodecType } 84 | } 85 | 86 | this.connection = this.sora.sendrecv(this.channelId, this.metadata, this.options) 87 | 88 | this.connection.on('notify', this.onnotify.bind(this)) 89 | this.connection.on('track', this.ontrack.bind(this)) 90 | this.connection.on('removetrack', this.onremovetrack.bind(this)) 91 | } 92 | 93 | async connect(stream: MediaStream) { 94 | await this.connection.connect(stream) 95 | const localVideo = document.querySelector('#local-video') 96 | if (localVideo) { 97 | localVideo.srcObject = stream 98 | } 99 | } 100 | 101 | async disconnect() { 102 | await this.connection.disconnect() 103 | 104 | // お掃除 105 | const localVideo = document.querySelector('#local-video') 106 | if (localVideo) { 107 | localVideo.srcObject = null 108 | } 109 | // お掃除 110 | const remoteVideos = document.querySelector('#remote-videos') 111 | if (remoteVideos) { 112 | remoteVideos.innerHTML = '' 113 | } 114 | } 115 | 116 | getStats(): Promise { 117 | if (this.connection.pc === null) { 118 | return Promise.reject(new Error('PeerConnection is not ready')) 119 | } 120 | return this.connection.pc.getStats() 121 | } 122 | 123 | private onnotify(event: SignalingNotifyMessage): void { 124 | if ( 125 | event.event_type === 'connection.created' && 126 | this.connection.connectionId === event.connection_id 127 | ) { 128 | const connectionIdElement = document.querySelector('#connection-id') 129 | if (connectionIdElement) { 130 | connectionIdElement.textContent = event.connection_id 131 | } 132 | } 133 | } 134 | 135 | private ontrack(event: RTCTrackEvent): void { 136 | const stream = event.streams[0] 137 | const remoteVideoId = `remote-video-${stream.id}` 138 | const remoteVideos = document.querySelector('#remote-videos') 139 | if (remoteVideos && !remoteVideos.querySelector(`#${remoteVideoId}`)) { 140 | const remoteVideo = document.createElement('video') 141 | remoteVideo.id = remoteVideoId 142 | remoteVideo.style.border = '1px solid red' 143 | remoteVideo.autoplay = true 144 | remoteVideo.playsInline = true 145 | remoteVideo.controls = true 146 | remoteVideo.width = 320 147 | remoteVideo.height = 240 148 | remoteVideo.srcObject = stream 149 | remoteVideos.appendChild(remoteVideo) 150 | } 151 | } 152 | 153 | private onremovetrack(event: MediaStreamTrackEvent): void { 154 | const target = event.target as MediaStream 155 | const remoteVideo = document.querySelector(`#remote-video-${target.id}`) 156 | if (remoteVideo) { 157 | document.querySelector('#remote-videos')?.removeChild(remoteVideo) 158 | } 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /e2e-tests/sendrecv_webkit/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Sendrecv WebKit test 7 | 8 | 9 | 10 |
11 |

Sendrecv WebKit test

12 |
13 | 14 |
15 | 16 |
23 | 24 | 25 |
26 |
27 | 29 |
30 |
31 |
32 |
33 |
34 |
35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /e2e-tests/sendrecv_webkit/main.ts: -------------------------------------------------------------------------------- 1 | import { getChannelId, getVideoCodecType, setSoraJsSdkVersion } from '../src/misc' 2 | import { getFakeMedia } from '../src/fake' 3 | import Sora, { 4 | type SoraConnection, 5 | type SignalingNotifyMessage, 6 | type ConnectionPublisher, 7 | type VideoCodecType, 8 | type ConnectionOptions, 9 | } from 'sora-js-sdk' 10 | 11 | document.addEventListener('DOMContentLoaded', async () => { 12 | const signalingUrl = import.meta.env.VITE_TEST_SIGNALING_URL 13 | const channelIdPrefix = import.meta.env.VITE_TEST_CHANNEL_ID_PREFIX || '' 14 | const channelIdSuffix = import.meta.env.VITE_TEST_CHANNEL_ID_SUFFIX || '' 15 | const secretKey = import.meta.env.VITE_TEST_SECRET_KEY 16 | 17 | setSoraJsSdkVersion() 18 | 19 | let client: SoraClient 20 | 21 | document.querySelector('#connect')?.addEventListener('click', async () => { 22 | const channelId = getChannelId(channelIdPrefix, channelIdSuffix) 23 | const videoCodecType = getVideoCodecType() 24 | 25 | client = new SoraClient(signalingUrl, channelId, secretKey, videoCodecType) 26 | 27 | const stream = getFakeMedia({ 28 | audio: true, 29 | video: true, 30 | }) 31 | await client.connect(stream) 32 | }) 33 | document.querySelector('#disconnect')?.addEventListener('click', async () => { 34 | await client.disconnect() 35 | }) 36 | 37 | document.querySelector('#get-stats')?.addEventListener('click', async () => { 38 | const statsReport = await client.getStats() 39 | const statsDiv = document.querySelector('#stats-report') as HTMLElement 40 | const statsReportJsonDiv = document.querySelector('#stats-report-json') 41 | if (statsDiv && statsReportJsonDiv) { 42 | let statsHtml = '' 43 | const statsReportJson: Record[] = [] 44 | for (const report of statsReport.values()) { 45 | statsHtml += `

Type: ${report.type}

    ` 46 | const reportJson: Record = { id: report.id, type: report.type } 47 | for (const [key, value] of Object.entries(report)) { 48 | if (key !== 'type' && key !== 'id') { 49 | statsHtml += `
  • ${key}: ${value}
  • ` 50 | reportJson[key] = value 51 | } 52 | } 53 | statsHtml += '
' 54 | statsReportJson.push(reportJson) 55 | } 56 | statsDiv.innerHTML = statsHtml 57 | // データ属性としても保存(オプション) 58 | statsDiv.dataset.statsReportJson = JSON.stringify(statsReportJson) 59 | } 60 | }) 61 | }) 62 | 63 | class SoraClient { 64 | private debug = false 65 | 66 | private channelId: string 67 | private metadata: { access_token: string } 68 | private options: ConnectionOptions 69 | 70 | private sora: SoraConnection 71 | private connection: ConnectionPublisher 72 | 73 | constructor( 74 | signalingUrl: string, 75 | channelId: string, 76 | secretKey: string, 77 | videoCodecType: VideoCodecType | undefined, 78 | ) { 79 | this.sora = Sora.connection(signalingUrl, this.debug) 80 | this.channelId = channelId 81 | 82 | this.metadata = { access_token: secretKey } 83 | this.options = {} 84 | 85 | if (videoCodecType !== undefined) { 86 | this.options = { ...this.options, videoCodecType: videoCodecType } 87 | } 88 | 89 | this.connection = this.sora.sendrecv(this.channelId, this.metadata, this.options) 90 | 91 | this.connection.on('notify', this.onnotify.bind(this)) 92 | this.connection.on('track', this.ontrack.bind(this)) 93 | this.connection.on('removetrack', this.onremovetrack.bind(this)) 94 | } 95 | 96 | async connect(stream: MediaStream) { 97 | await this.connection.connect(stream) 98 | const localVideo = document.querySelector('#local-video') 99 | if (localVideo) { 100 | localVideo.srcObject = stream 101 | } 102 | } 103 | 104 | async disconnect() { 105 | await this.connection.disconnect() 106 | 107 | // お掃除 108 | const localVideo = document.querySelector('#local-video') 109 | if (localVideo) { 110 | localVideo.srcObject = null 111 | } 112 | // お掃除 113 | const remoteVideos = document.querySelector('#remote-videos') 114 | if (remoteVideos) { 115 | remoteVideos.innerHTML = '' 116 | } 117 | } 118 | 119 | getStats(): Promise { 120 | if (this.connection.pc === null) { 121 | return Promise.reject(new Error('PeerConnection is not ready')) 122 | } 123 | return this.connection.pc.getStats() 124 | } 125 | 126 | private onnotify(event: SignalingNotifyMessage): void { 127 | if ( 128 | event.event_type === 'connection.created' && 129 | this.connection.connectionId === event.connection_id 130 | ) { 131 | const connectionIdElement = document.querySelector('#connection-id') 132 | if (connectionIdElement) { 133 | connectionIdElement.textContent = event.connection_id 134 | } 135 | } 136 | } 137 | 138 | private ontrack(event: RTCTrackEvent): void { 139 | const stream = event.streams[0] 140 | const remoteVideoId = `remote-video-${stream.id}` 141 | const remoteVideos = document.querySelector('#remote-videos') 142 | if (remoteVideos && !remoteVideos.querySelector(`#${remoteVideoId}`)) { 143 | const remoteVideo = document.createElement('video') 144 | remoteVideo.id = remoteVideoId 145 | remoteVideo.style.border = '1px solid red' 146 | remoteVideo.autoplay = true 147 | remoteVideo.playsInline = true 148 | remoteVideo.controls = true 149 | remoteVideo.width = 320 150 | remoteVideo.height = 240 151 | remoteVideo.srcObject = stream 152 | remoteVideos.appendChild(remoteVideo) 153 | } 154 | } 155 | 156 | private onremovetrack(event: MediaStreamTrackEvent): void { 157 | const target = event.target as MediaStream 158 | const remoteVideo = document.querySelector(`#remote-video-${target.id}`) 159 | if (remoteVideo) { 160 | document.querySelector('#remote-videos')?.removeChild(remoteVideo) 161 | } 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /e2e-tests/simulcast/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Simulcast test 7 | 8 | 9 | 10 |
11 |

Simulcast test

12 |
13 | 14 |
15 | 16 | 17 |
18 |
19 |

sendonly

20 |
21 | 23 |
24 |
25 |

recvonly r0

26 |
27 | 28 |
29 |
30 |

recvonly r1

31 |
32 | 33 |
34 |
35 |

recvonly r2

36 |
37 | 38 |
39 | 40 |
41 |
42 |
43 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /e2e-tests/simulcast_recvonly/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Simulcast test 7 | 8 | 9 | 10 |
11 |

Simulcast test

12 |
13 | 14 |
15 | 16 | 17 |
18 | 19 | 25 |
26 |
27 |
28 |
29 | 30 |
31 |
32 |
33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /e2e-tests/simulcast_recvonly/main.ts: -------------------------------------------------------------------------------- 1 | import { getChannelId, setSoraJsSdkVersion } from '../src/misc' 2 | 3 | import Sora, { 4 | type SoraConnection, 5 | type SignalingNotifyMessage, 6 | type ConnectionSubscriber, 7 | type SimulcastRid, 8 | } from 'sora-js-sdk' 9 | 10 | document.addEventListener('DOMContentLoaded', () => { 11 | const signalingUrl = import.meta.env.VITE_TEST_SIGNALING_URL 12 | const channelIdPrefix = import.meta.env.VITE_TEST_CHANNEL_ID_PREFIX || '' 13 | const channelIdSuffix = import.meta.env.VITE_TEST_CHANNEL_ID_SUFFIX || '' 14 | const secretKey = import.meta.env.VITE_TEST_SECRET_KEY 15 | 16 | setSoraJsSdkVersion() 17 | 18 | let recvonly: SimulcastRecvonlySoraClient 19 | 20 | document.querySelector('#connect')?.addEventListener('click', async () => { 21 | const simulcastRidElement = document.querySelector('#simulcast-rid') 22 | const simulcastRid = 23 | simulcastRidElement?.value === '' ? undefined : (simulcastRidElement?.value as SimulcastRid) 24 | 25 | const channelId = getChannelId(channelIdPrefix, channelIdSuffix) 26 | 27 | recvonly = new SimulcastRecvonlySoraClient(signalingUrl, channelId, secretKey) 28 | 29 | await recvonly.connect(simulcastRid) 30 | }) 31 | 32 | document.querySelector('#disconnect')?.addEventListener('click', async () => { 33 | await recvonly.disconnect() 34 | }) 35 | 36 | document.querySelector('#get-stats')?.addEventListener('click', async () => { 37 | const statsReport = await recvonly.getStats() 38 | const statsDiv = document.querySelector('#stats-report') as HTMLElement 39 | const statsReportJsonDiv = document.querySelector('#stats-report-json') 40 | if (statsDiv && statsReportJsonDiv) { 41 | let statsHtml = '' 42 | const statsReportJson: Record[] = [] 43 | for (const report of statsReport.values()) { 44 | statsHtml += `

Type: ${report.type}

    ` 45 | const reportJson: Record = { id: report.id, type: report.type } 46 | for (const [key, value] of Object.entries(report)) { 47 | if (key !== 'type' && key !== 'id') { 48 | statsHtml += `
  • ${key}: ${value}
  • ` 49 | reportJson[key] = value 50 | } 51 | } 52 | statsHtml += '
' 53 | statsReportJson.push(reportJson) 54 | } 55 | statsDiv.innerHTML = statsHtml 56 | // データ属性としても保存(オプション) 57 | statsDiv.dataset.statsReportJson = JSON.stringify(statsReportJson) 58 | } 59 | }) 60 | }) 61 | 62 | class SimulcastRecvonlySoraClient { 63 | private debug = false 64 | 65 | private channelId: string 66 | private rid: SimulcastRid | undefined 67 | 68 | private sora: SoraConnection 69 | private connection: ConnectionSubscriber 70 | 71 | constructor(signalingUrl: string, channelId: string, secretKey: string) { 72 | this.channelId = channelId 73 | 74 | this.sora = Sora.connection(signalingUrl, this.debug) 75 | this.connection = this.sora.recvonly( 76 | this.channelId, 77 | { access_token: secretKey }, 78 | { simulcast: true }, 79 | ) 80 | 81 | this.connection.on('notify', this.onnotify.bind(this)) 82 | this.connection.on('track', this.ontrack.bind(this)) 83 | this.connection.on('removetrack', this.onremovetrack.bind(this)) 84 | } 85 | 86 | async connect(simulcastRid?: SimulcastRid) { 87 | if (simulcastRid) { 88 | this.rid = simulcastRid 89 | this.connection.options.simulcastRid = simulcastRid 90 | } 91 | 92 | await this.connection.connect() 93 | } 94 | 95 | async disconnect() { 96 | await this.connection.disconnect() 97 | const remoteVideo = document.querySelector(`#remote-video-${this.rid}`) 98 | if (remoteVideo) { 99 | remoteVideo.srcObject = null 100 | } 101 | } 102 | 103 | getStats(): Promise { 104 | if (this.connection.pc === null) { 105 | return Promise.reject(new Error('PeerConnection is not ready')) 106 | } 107 | return this.connection.pc.getStats() 108 | } 109 | 110 | private onnotify(event: SignalingNotifyMessage) { 111 | if ( 112 | event.event_type === 'connection.created' && 113 | event.connection_id === this.connection.connectionId 114 | ) { 115 | const localVideoConnectionId = document.querySelector('#connection-id') 116 | if (localVideoConnectionId) { 117 | localVideoConnectionId.textContent = `${event.connection_id}` 118 | } 119 | } 120 | } 121 | 122 | private ontrack(event: RTCTrackEvent) { 123 | const remoteVideos = document.querySelector('#remote-videos') 124 | const stream = event.streams[0] 125 | const remoteVideoId = `remote-video-${stream.id}` 126 | if (remoteVideos && !remoteVideos.querySelector(`#${remoteVideoId}`)) { 127 | const videoElement = document.createElement('video') 128 | videoElement.id = remoteVideoId 129 | videoElement.autoplay = true 130 | videoElement.playsInline = true 131 | videoElement.controls = true 132 | videoElement.srcObject = stream 133 | remoteVideos.appendChild(videoElement) 134 | } 135 | } 136 | 137 | private onremovetrack(event: MediaStreamTrackEvent) { 138 | const stream = event.target as MediaStream 139 | const remoteVideo = document.querySelector(`#remote-video-${stream.id}`) 140 | if (remoteVideo) { 141 | remoteVideo.srcObject = null 142 | } 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /e2e-tests/simulcast_sendonly/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Simulcast sendonly test 8 | 9 | 10 | 11 |
12 |

Simulcast sendonly test

13 |
14 |
15 | 16 |
17 |
18 |
19 | 20 | 28 |
29 |
30 | 31 | 32 |
33 |
simulcast_encodings:
34 |
35 | 36 |
37 | 38 | 39 |
40 | 41 |
42 | 43 | 44 |
45 |
46 |
47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /e2e-tests/simulcast_sendonly/main.ts: -------------------------------------------------------------------------------- 1 | import Sora, { 2 | type SoraConnection, 3 | type ConnectionPublisher, 4 | type SignalingNotifyMessage, 5 | type VideoCodecType, 6 | } from 'sora-js-sdk' 7 | import { generateJwt, getChannelId, setSoraJsSdkVersion } from '../src/misc' 8 | 9 | document.addEventListener('DOMContentLoaded', () => { 10 | const signalingUrl = import.meta.env.VITE_TEST_SIGNALING_URL 11 | const channelIdPrefix = import.meta.env.VITE_TEST_CHANNEL_ID_PREFIX || '' 12 | const channelIdSuffix = import.meta.env.VITE_TEST_CHANNEL_ID_SUFFIX || '' 13 | const secretKey = import.meta.env.VITE_TEST_SECRET_KEY 14 | 15 | setSoraJsSdkVersion() 16 | 17 | let sendonly: SimulcastSendonlySoraClient 18 | 19 | document.querySelector('#connect')?.addEventListener('click', async () => { 20 | const channelId = getChannelId(channelIdPrefix, channelIdSuffix) 21 | 22 | const videoCodecTypeElement = document.querySelector('#video-codec-type') as HTMLSelectElement 23 | const videoCodecType = videoCodecTypeElement.value as VideoCodecType 24 | const rawVideoBitRate = document.querySelector('#video-bit-rate') as HTMLInputElement 25 | const videoBitRate = Number.parseInt(rawVideoBitRate.value) 26 | 27 | let simulcastEncodings: Record | undefined 28 | const simulcastEncodingsElement = document.querySelector( 29 | '#simulcast-encodings', 30 | ) as HTMLTextAreaElement 31 | if (simulcastEncodingsElement.value !== '') { 32 | console.log(`simulcastEncodingsElement.value=${simulcastEncodingsElement.value}`) 33 | try { 34 | simulcastEncodings = JSON.parse(simulcastEncodingsElement.value) 35 | } catch (error) { 36 | throw new Error('Failed to parse simulcastEncodings') 37 | } 38 | } 39 | 40 | sendonly = new SimulcastSendonlySoraClient( 41 | signalingUrl, 42 | channelId, 43 | videoCodecType, 44 | videoBitRate, 45 | simulcastEncodings, 46 | secretKey, 47 | ) 48 | 49 | const stream = await navigator.mediaDevices.getUserMedia({ 50 | audio: false, 51 | video: { width: { exact: 960 }, height: { exact: 540 } }, 52 | }) 53 | await sendonly.connect(stream) 54 | }) 55 | 56 | document.querySelector('#disconnect')?.addEventListener('click', async () => { 57 | await sendonly.disconnect() 58 | }) 59 | 60 | document.querySelector('#get-stats')?.addEventListener('click', async () => { 61 | const statsReport = await sendonly.getStats() 62 | const statsDiv = document.querySelector('#stats-report') as HTMLElement 63 | const statsReportJsonDiv = document.querySelector('#stats-report-json') 64 | if (statsDiv && statsReportJsonDiv) { 65 | let statsHtml = '' 66 | const statsReportJson: Record[] = [] 67 | for (const report of statsReport.values()) { 68 | statsHtml += `

Type: ${report.type}

    ` 69 | const reportJson: Record = { id: report.id, type: report.type } 70 | for (const [key, value] of Object.entries(report)) { 71 | if (key !== 'type' && key !== 'id') { 72 | statsHtml += `
  • ${key}: ${value}
  • ` 73 | reportJson[key] = value 74 | } 75 | } 76 | statsHtml += '
' 77 | statsReportJson.push(reportJson) 78 | } 79 | statsDiv.innerHTML = statsHtml 80 | // データ属性としても保存(オプション) 81 | statsDiv.dataset.statsReportJson = JSON.stringify(statsReportJson) 82 | } 83 | }) 84 | }) 85 | 86 | class SimulcastSendonlySoraClient { 87 | private debug = false 88 | 89 | private channelId: string 90 | private videoCodecType: VideoCodecType 91 | private videoBitRate: number 92 | private simulcastEncodings: Record | undefined 93 | 94 | private secretKey: string 95 | 96 | private sora: SoraConnection 97 | private connection: ConnectionPublisher 98 | 99 | constructor( 100 | signalingUrl: string, 101 | channelId: string, 102 | videoCodecType: VideoCodecType, 103 | videoBitRate: number, 104 | simulcastEncodings: Record | undefined, 105 | secretKey: string, 106 | ) { 107 | this.channelId = channelId 108 | this.videoCodecType = videoCodecType 109 | this.videoBitRate = videoBitRate 110 | this.simulcastEncodings = simulcastEncodings 111 | 112 | this.secretKey = secretKey 113 | 114 | this.sora = Sora.connection(signalingUrl, this.debug) 115 | this.connection = this.sora.sendonly(this.channelId, undefined, { 116 | audio: false, 117 | video: true, 118 | videoCodecType: this.videoCodecType, 119 | videoBitRate: this.videoBitRate, 120 | simulcast: true, 121 | }) 122 | 123 | this.connection.on('notify', this.onnotify.bind(this)) 124 | } 125 | 126 | async connect(stream: MediaStream) { 127 | const privateClaims: Record = {} 128 | if (this.simulcastEncodings !== undefined) { 129 | privateClaims.simulcast_encodings = this.simulcastEncodings 130 | } 131 | 132 | const jwt = await generateJwt(this.channelId, this.secretKey, privateClaims) 133 | this.connection.metadata = { access_token: jwt } 134 | 135 | await this.connection.connect(stream) 136 | const localVideo = document.querySelector('#local-video') 137 | if (localVideo) { 138 | localVideo.srcObject = stream 139 | } 140 | } 141 | 142 | async disconnect() { 143 | await this.connection.disconnect() 144 | const localVideo = document.querySelector('#local-video') 145 | if (localVideo) { 146 | localVideo.srcObject = null 147 | } 148 | } 149 | 150 | getStats(): Promise { 151 | if (this.connection.pc === null) { 152 | return Promise.reject(new Error('PeerConnection is not ready')) 153 | } 154 | return this.connection.pc.getStats() 155 | } 156 | 157 | private onnotify(event: SignalingNotifyMessage) { 158 | if ( 159 | event.event_type === 'connection.created' && 160 | event.connection_id === this.connection.connectionId 161 | ) { 162 | const localVideoConnectionId = document.querySelector('#connection-id') 163 | if (localVideoConnectionId) { 164 | localVideoConnectionId.textContent = `${event.connection_id}` 165 | } 166 | } 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /e2e-tests/simulcast_sendonly_webkit/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Simulcast sendonly WebKit test 8 | 9 | 10 | 11 |
12 |

Simulcast sendonly WebKit test

13 |
14 |
15 | 16 |
17 |
18 |
19 | 20 | 28 |
29 |
30 | 31 | 32 |
33 |
simulcast_encodings:
34 |
35 | 36 |
37 | 38 | 39 |
40 | 41 |
42 | 43 | 44 |
45 |
46 |
47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /e2e-tests/simulcast_sendonly_webkit/main.ts: -------------------------------------------------------------------------------- 1 | import Sora, { 2 | type SoraConnection, 3 | type ConnectionPublisher, 4 | type SignalingNotifyMessage, 5 | type VideoCodecType, 6 | } from 'sora-js-sdk' 7 | import { getFakeMedia } from '../src/fake' 8 | import { generateJwt } from '../src/misc' 9 | import { getChannelId, setSoraJsSdkVersion } from '../src/misc' 10 | 11 | document.addEventListener('DOMContentLoaded', () => { 12 | const signalingUrl = import.meta.env.VITE_TEST_SIGNALING_URL 13 | const channelIdPrefix = import.meta.env.VITE_TEST_CHANNEL_ID_PREFIX || '' 14 | const channelIdSuffix = import.meta.env.VITE_TEST_CHANNEL_ID_SUFFIX || '' 15 | const secretKey = import.meta.env.VITE_TEST_SECRET_KEY 16 | 17 | setSoraJsSdkVersion() 18 | 19 | let sendonly: SimulcastSendonlySoraClient 20 | 21 | document.querySelector('#connect')?.addEventListener('click', async () => { 22 | const channelId = getChannelId(channelIdPrefix, channelIdSuffix) 23 | 24 | const videoCodecTypeElement = document.querySelector('#video-codec-type') as HTMLSelectElement 25 | const videoCodecType = videoCodecTypeElement.value as VideoCodecType 26 | const rawVideoBitRate = document.querySelector('#video-bit-rate') as HTMLInputElement 27 | const videoBitRate = Number.parseInt(rawVideoBitRate.value) 28 | 29 | let simulcastEncodings: Array> | undefined 30 | const simulcastEncodingsElement = document.querySelector( 31 | '#simulcast-encodings', 32 | ) as HTMLTextAreaElement 33 | if (simulcastEncodingsElement.value !== '') { 34 | console.log(`simulcastEncodingsElement.value=${simulcastEncodingsElement.value}`) 35 | try { 36 | simulcastEncodings = JSON.parse(simulcastEncodingsElement.value) 37 | } catch (error) { 38 | throw new Error('Failed to parse simulcastEncodings') 39 | } 40 | } 41 | 42 | sendonly = new SimulcastSendonlySoraClient( 43 | signalingUrl, 44 | channelId, 45 | videoCodecType, 46 | videoBitRate, 47 | simulcastEncodings, 48 | secretKey, 49 | ) 50 | 51 | const stream = getFakeMedia({ 52 | audio: false, 53 | video: { width: 960, height: 540 }, 54 | }) 55 | await sendonly.connect(stream) 56 | }) 57 | 58 | document.querySelector('#disconnect')?.addEventListener('click', async () => { 59 | await sendonly.disconnect() 60 | }) 61 | 62 | document.querySelector('#get-stats')?.addEventListener('click', async () => { 63 | const statsReport = await sendonly.getStats() 64 | const statsDiv = document.querySelector('#stats-report') as HTMLElement 65 | const statsReportJsonDiv = document.querySelector('#stats-report-json') 66 | if (statsDiv && statsReportJsonDiv) { 67 | let statsHtml = '' 68 | const statsReportJson: Record[] = [] 69 | for (const report of statsReport.values()) { 70 | statsHtml += `

Type: ${report.type}

    ` 71 | const reportJson: Record = { id: report.id, type: report.type } 72 | for (const [key, value] of Object.entries(report)) { 73 | if (key !== 'type' && key !== 'id') { 74 | statsHtml += `
  • ${key}: ${value}
  • ` 75 | reportJson[key] = value 76 | } 77 | } 78 | statsHtml += '
' 79 | statsReportJson.push(reportJson) 80 | } 81 | statsDiv.innerHTML = statsHtml 82 | // データ属性としても保存(オプション) 83 | statsDiv.dataset.statsReportJson = JSON.stringify(statsReportJson) 84 | } 85 | }) 86 | }) 87 | 88 | class SimulcastSendonlySoraClient { 89 | private debug = false 90 | 91 | private channelId: string 92 | private videoCodecType: VideoCodecType 93 | private videoBitRate: number 94 | private simulcastEncodings: Record | undefined 95 | 96 | private secretKey: string 97 | 98 | private sora: SoraConnection 99 | private connection: ConnectionPublisher 100 | 101 | constructor( 102 | signalingUrl: string, 103 | channelId: string, 104 | videoCodecType: VideoCodecType, 105 | videoBitRate: number, 106 | simulcastEncodings: Record | undefined, 107 | secretKey: string, 108 | ) { 109 | this.channelId = channelId 110 | this.videoCodecType = videoCodecType 111 | this.videoBitRate = videoBitRate 112 | this.simulcastEncodings = simulcastEncodings 113 | 114 | this.secretKey = secretKey 115 | 116 | this.sora = Sora.connection(signalingUrl, this.debug) 117 | this.connection = this.sora.sendonly(this.channelId, undefined, { 118 | audio: false, 119 | video: true, 120 | videoCodecType: this.videoCodecType, 121 | videoBitRate: this.videoBitRate, 122 | simulcast: true, 123 | }) 124 | 125 | this.connection.on('notify', this.onnotify.bind(this)) 126 | } 127 | 128 | async connect(stream: MediaStream) { 129 | const privateClaims: Record = {} 130 | if (this.simulcastEncodings !== undefined) { 131 | privateClaims.simulcast_encodings = this.simulcastEncodings 132 | } 133 | 134 | const jwt = await generateJwt(this.channelId, this.secretKey, privateClaims) 135 | this.connection.metadata = { access_token: jwt } 136 | 137 | await this.connection.connect(stream) 138 | const localVideo = document.querySelector('#local-video') 139 | if (localVideo) { 140 | localVideo.srcObject = stream 141 | } 142 | } 143 | 144 | async disconnect() { 145 | await this.connection.disconnect() 146 | const localVideo = document.querySelector('#local-video') 147 | if (localVideo) { 148 | localVideo.srcObject = null 149 | } 150 | } 151 | 152 | getStats(): Promise { 153 | if (this.connection.pc === null) { 154 | return Promise.reject(new Error('PeerConnection is not ready')) 155 | } 156 | return this.connection.pc.getStats() 157 | } 158 | 159 | private onnotify(event: SignalingNotifyMessage) { 160 | if ( 161 | event.event_type === 'connection.created' && 162 | event.connection_id === this.connection.connectionId 163 | ) { 164 | const localVideoConnectionId = document.querySelector('#connection-id') 165 | if (localVideoConnectionId) { 166 | localVideoConnectionId.textContent = `${event.connection_id}` 167 | } 168 | } 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /e2e-tests/spotlight_recvonly/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Spotlight Recvonly test 7 | 8 | 9 | 10 |
11 |

Spotlight Recvonly test

12 |
13 | 14 |
15 | 16 |
17 |
18 |
19 |
20 |
21 |
22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /e2e-tests/spotlight_recvonly/main.ts: -------------------------------------------------------------------------------- 1 | import { getChannelId, setSoraJsSdkVersion } from '../src/misc' 2 | 3 | import Sora, { 4 | type SoraConnection, 5 | type SignalingNotifyMessage, 6 | type ConnectionSubscriber, 7 | } from 'sora-js-sdk' 8 | 9 | document.addEventListener('DOMContentLoaded', () => { 10 | const signalingUrl = import.meta.env.VITE_TEST_SIGNALING_URL 11 | const channelIdPrefix = import.meta.env.VITE_TEST_CHANNEL_ID_PREFIX || '' 12 | const channelIdSuffix = import.meta.env.VITE_TEST_CHANNEL_ID_SUFFIX || '' 13 | const secretKey = import.meta.env.VITE_TEST_SECRET_KEY 14 | 15 | setSoraJsSdkVersion() 16 | 17 | let client: SoraClient 18 | 19 | document.querySelector('#connect')?.addEventListener('click', async () => { 20 | const channelId = getChannelId(channelIdPrefix, channelIdSuffix) 21 | 22 | client = new SoraClient(signalingUrl, channelId, secretKey) 23 | await client.connect() 24 | }) 25 | 26 | document.querySelector('#disconnect')?.addEventListener('click', async () => { 27 | await client.disconnect() 28 | }) 29 | }) 30 | 31 | class SoraClient { 32 | private debug = false 33 | private channelId: string 34 | private metadata: { access_token: string } 35 | private options: object = {} 36 | 37 | private sora: SoraConnection 38 | private connection: ConnectionSubscriber 39 | 40 | constructor(signalingUrl: string, channelId: string, secretKey: string) { 41 | this.channelId = channelId 42 | this.sora = Sora.connection(signalingUrl, this.debug) 43 | 44 | // access_token を指定する metadata の生成 45 | this.metadata = { access_token: secretKey } 46 | 47 | this.options = { 48 | simulcast: true, 49 | spotlight: true, 50 | } 51 | 52 | this.connection = this.sora.recvonly(this.channelId, this.metadata, this.options) 53 | this.connection.on('notify', this.onnotify.bind(this)) 54 | this.connection.on('track', this.ontrack.bind(this)) 55 | this.connection.on('removetrack', this.onremovetrack.bind(this)) 56 | } 57 | 58 | async connect(): Promise { 59 | await this.connection.connect() 60 | } 61 | 62 | async disconnect(): Promise { 63 | await this.connection.disconnect() 64 | const remoteVideos = document.querySelector('#remote-videos') 65 | if (remoteVideos) { 66 | remoteVideos.innerHTML = '' 67 | } 68 | 69 | const connectionIdElement = document.querySelector('#connection-id') 70 | if (connectionIdElement) { 71 | connectionIdElement.textContent = null 72 | } 73 | } 74 | 75 | private onnotify(event: SignalingNotifyMessage) { 76 | // 自分の connection_id を取得する 77 | if ( 78 | event.event_type === 'connection.created' && 79 | this.connection.connectionId === event.connection_id 80 | ) { 81 | const connectionIdElement = document.querySelector('#connection-id') 82 | if (connectionIdElement) { 83 | connectionIdElement.textContent = event.connection_id 84 | } 85 | } 86 | } 87 | 88 | private ontrack(event: RTCTrackEvent) { 89 | // Sora の場合、event.streams には MediaStream が 1 つだけ含まれる 90 | const stream = event.streams[0] 91 | const remoteVideoId = `remotevideo-${stream.id}` 92 | const remoteVideos = document.querySelector('#remote-videos') 93 | if (remoteVideos && !remoteVideos.querySelector(`#${remoteVideoId}`)) { 94 | const remoteVideo = document.createElement('video') 95 | remoteVideo.id = remoteVideoId 96 | remoteVideo.style.border = '1px solid red' 97 | remoteVideo.autoplay = true 98 | remoteVideo.playsInline = true 99 | remoteVideo.controls = true 100 | remoteVideo.srcObject = stream 101 | remoteVideos.appendChild(remoteVideo) 102 | } 103 | } 104 | 105 | private onremovetrack(event: MediaStreamTrackEvent) { 106 | // このトラックが属している MediaStream の id を取得する 107 | const stream = event.target as MediaStream 108 | const remoteVideo = document.querySelector(`#remotevideo-${stream.id}`) 109 | if (remoteVideo) { 110 | document.querySelector('#remote-videos')?.removeChild(remoteVideo) 111 | } 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /e2e-tests/spotlight_sendonly/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Spotlight Sendonly test 7 | 8 | 9 | 10 |
11 |

Spotlight Sendonly test

12 |
13 | 14 |
15 | 16 |
17 |
18 | 20 |
21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /e2e-tests/spotlight_sendonly/main.ts: -------------------------------------------------------------------------------- 1 | import { getChannelId, setSoraJsSdkVersion } from '../src/misc' 2 | 3 | import Sora, { 4 | type SignalingNotifyMessage, 5 | type ConnectionPublisher, 6 | type SoraConnection, 7 | } from 'sora-js-sdk' 8 | 9 | document.addEventListener('DOMContentLoaded', async () => { 10 | const signalingUrl = import.meta.env.VITE_TEST_SIGNALING_URL 11 | const channelIdPrefix = import.meta.env.VITE_TEST_CHANNEL_ID_PREFIX || '' 12 | const channelIdSuffix = import.meta.env.VITE_TEST_CHANNEL_ID_SUFFIX || '' 13 | const secretKey = import.meta.env.VITE_TEST_SECRET_KEY 14 | 15 | setSoraJsSdkVersion() 16 | 17 | let client: SoraClient 18 | 19 | document.querySelector('#connect')?.addEventListener('click', async () => { 20 | const channelId = getChannelId(channelIdPrefix, channelIdSuffix) 21 | 22 | client = new SoraClient(signalingUrl, channelId, secretKey) 23 | 24 | const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true }) 25 | await client.connect(stream) 26 | }) 27 | 28 | document.querySelector('#disconnect')?.addEventListener('click', async () => { 29 | await client.disconnect() 30 | }) 31 | }) 32 | 33 | class SoraClient { 34 | private debug = false 35 | private channelId: string 36 | private metadata: { access_token: string } 37 | private options: object = {} 38 | 39 | private sora: SoraConnection 40 | private connection: ConnectionPublisher 41 | 42 | constructor(signalingUrl: string, channelId: string, secretKey: string) { 43 | this.channelId = channelId 44 | 45 | this.sora = Sora.connection(signalingUrl, this.debug) 46 | 47 | // access_token を指定する metadata の生成 48 | this.metadata = { access_token: secretKey } 49 | 50 | this.options = { 51 | simulcast: true, 52 | spotlight: true, 53 | } 54 | 55 | this.connection = this.sora.sendonly(this.channelId, this.metadata, this.options) 56 | this.connection.on('notify', this.onnotify.bind(this)) 57 | } 58 | 59 | async connect(stream: MediaStream): Promise { 60 | await this.connection.connect(stream) 61 | 62 | const videoElement = document.querySelector('#local-video') 63 | if (videoElement !== null) { 64 | videoElement.srcObject = stream 65 | } 66 | } 67 | 68 | async disconnect(): Promise { 69 | await this.connection.disconnect() 70 | 71 | const videoElement = document.querySelector('#local-video') 72 | if (videoElement !== null) { 73 | videoElement.srcObject = null 74 | } 75 | 76 | const connectionIdElement = document.querySelector('#connection-id') 77 | if (connectionIdElement) { 78 | connectionIdElement.textContent = null 79 | } 80 | } 81 | 82 | private onnotify(event: SignalingNotifyMessage): void { 83 | if ( 84 | event.event_type === 'connection.created' && 85 | this.connection.connectionId === event.connection_id 86 | ) { 87 | const connectionIdElement = document.querySelector('#connection-id') 88 | if (connectionIdElement) { 89 | connectionIdElement.textContent = event.connection_id 90 | } 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /e2e-tests/spotlight_sendrecv/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Spotlight Sendrecv test 7 | 8 | 9 | 10 |
11 |

Spotlight Sendrecv test

12 |
13 | 14 |
15 | 16 | 17 |
18 |
19 | 20 | 22 |
23 |
24 |
25 |
26 | 27 |
28 |
29 | 30 |
31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /e2e-tests/spotlight_sendrecv/main.ts: -------------------------------------------------------------------------------- 1 | import { getChannelId, setSoraJsSdkVersion } from '../src/misc' 2 | 3 | import Sora, { 4 | type SoraConnection, 5 | type ConnectionPublisher, 6 | type SignalingNotifyMessage, 7 | } from 'sora-js-sdk' 8 | 9 | document.addEventListener('DOMContentLoaded', async () => { 10 | const signalingUrl = import.meta.env.VITE_TEST_SIGNALING_URL 11 | const channelIdPrefix = import.meta.env.VITE_TEST_CHANNEL_ID_PREFIX || '' 12 | const channelIdSuffix = import.meta.env.VITE_TEST_CHANNEL_ID_SUFFIX || '' 13 | const secretKey = import.meta.env.VITE_TEST_SECRET_KEY 14 | 15 | setSoraJsSdkVersion() 16 | 17 | let sendrecv: SoraClient 18 | 19 | document.querySelector('#connect')?.addEventListener('click', async () => { 20 | const channelId = getChannelId(channelIdPrefix, channelIdSuffix) 21 | 22 | sendrecv = new SoraClient(signalingUrl, channelId, secretKey) 23 | 24 | const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true }) 25 | await sendrecv.connect(stream) 26 | }) 27 | document.querySelector('#disconnect')?.addEventListener('click', async () => { 28 | await sendrecv.disconnect() 29 | }) 30 | }) 31 | 32 | class SoraClient { 33 | private debug = false 34 | 35 | private channelId: string 36 | private metadata: { access_token: string } 37 | private options: object 38 | 39 | private sora: SoraConnection 40 | private connection: ConnectionPublisher 41 | 42 | constructor(signalingUrl: string, channelId: string, secretKey: string) { 43 | this.channelId = channelId 44 | 45 | this.sora = Sora.connection(signalingUrl, this.debug) 46 | 47 | this.metadata = { access_token: secretKey } 48 | 49 | this.options = { 50 | audio: true, 51 | video: true, 52 | simulcast: true, 53 | spotlight: true, 54 | spotlightNumber: 1, 55 | } 56 | 57 | this.connection = this.sora.sendrecv(this.channelId, this.metadata, this.options) 58 | 59 | this.connection.on('notify', this.onnotify.bind(this)) 60 | this.connection.on('track', this.ontrack.bind(this)) 61 | this.connection.on('removetrack', this.onremovetrack.bind(this)) 62 | } 63 | 64 | async connect(stream: MediaStream) { 65 | await this.connection.connect(stream) 66 | const localVideo = document.querySelector('#local-video') 67 | if (localVideo) { 68 | localVideo.srcObject = stream 69 | } 70 | } 71 | 72 | async disconnect() { 73 | await this.connection.disconnect() 74 | 75 | // お掃除 76 | const localVideo = document.querySelector('#local-video') 77 | if (localVideo) { 78 | localVideo.srcObject = null 79 | } 80 | // お掃除 81 | const remoteVideos = document.querySelector('#remote-videos') 82 | if (remoteVideos) { 83 | remoteVideos.innerHTML = '' 84 | } 85 | } 86 | 87 | private onnotify(event: SignalingNotifyMessage): void { 88 | if ( 89 | event.event_type === 'connection.created' && 90 | this.connection.connectionId === event.connection_id 91 | ) { 92 | const connectionIdElement = document.querySelector('#connection-id') 93 | if (connectionIdElement) { 94 | connectionIdElement.textContent = event.connection_id 95 | } 96 | } 97 | } 98 | 99 | private ontrack(event: RTCTrackEvent): void { 100 | const stream = event.streams[0] 101 | const remoteVideoId = `remote-video-${stream.id}` 102 | const remoteVideos = document.querySelector('#remote-videos') 103 | if (remoteVideos && !remoteVideos.querySelector(`#${remoteVideoId}`)) { 104 | const remoteVideo = document.createElement('video') 105 | remoteVideo.id = remoteVideoId 106 | remoteVideo.style.border = '1px solid red' 107 | remoteVideo.autoplay = true 108 | remoteVideo.playsInline = true 109 | remoteVideo.controls = true 110 | remoteVideo.width = 160 111 | remoteVideo.height = 120 112 | remoteVideo.srcObject = stream 113 | remoteVideos.appendChild(remoteVideo) 114 | } 115 | } 116 | 117 | private onremovetrack(event: MediaStreamTrackEvent): void { 118 | const target = event.target as MediaStream 119 | const remoteVideo = document.querySelector(`#remote-video-${target.id}`) 120 | if (remoteVideo) { 121 | document.querySelector('#remote-videos')?.removeChild(remoteVideo) 122 | } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /e2e-tests/src/misc.ts: -------------------------------------------------------------------------------- 1 | import { SignJWT } from 'jose' 2 | import type { VideoCodecType } from 'sora-js-sdk' 3 | 4 | import Sora from 'sora-js-sdk' 5 | 6 | export const generateJwt = async ( 7 | channelId: string, 8 | secretKey: string, 9 | privateClaims: Record = {}, 10 | ): Promise => { 11 | console.log('privateClaims:', privateClaims) 12 | const payload = { 13 | channel_id: channelId, 14 | ...privateClaims, 15 | } 16 | console.log('payload:', payload) 17 | return ( 18 | new SignJWT(payload) 19 | .setProtectedHeader({ alg: 'HS256', typ: 'JWT' }) 20 | // 30 秒後に有効期限切れ 21 | .setExpirationTime('30s') 22 | .sign(new TextEncoder().encode(secretKey)) 23 | ) 24 | } 25 | 26 | export const setSoraJsSdkVersion = (id = 'sora-js-sdk-version'): void => { 27 | const sdkVersionElement = document.querySelector(`#${id}`) 28 | if (sdkVersionElement) { 29 | sdkVersionElement.textContent = `${Sora.version()}` 30 | } 31 | } 32 | 33 | export const getChannelId = ( 34 | channelIdPrefix: string, 35 | channelIdSuffix: string, 36 | id = 'channel-name', 37 | ): string => { 38 | const channelNameElement = document.querySelector(`#${id}`) 39 | const channelName = channelNameElement?.value 40 | if (channelName === '' || channelName === undefined) { 41 | throw new Error('channelName is empty') 42 | } 43 | return `${channelIdPrefix}${channelName}${channelIdSuffix}` 44 | } 45 | 46 | export const getVideoCodecType = (id = 'video-codec-type'): VideoCodecType => { 47 | const videoCodecTypeElement = document.querySelector(`#${id}`) 48 | const videoCodecType = videoCodecTypeElement?.value 49 | if (videoCodecType === '') { 50 | throw new Error('videoCodecType is empty') 51 | } 52 | return videoCodecType as VideoCodecType 53 | } 54 | -------------------------------------------------------------------------------- /e2e-tests/tests/authz_simulcast_encodings.test.ts: -------------------------------------------------------------------------------- 1 | import { randomUUID } from 'node:crypto' 2 | import { expect, test } from '@playwright/test' 3 | 4 | test('authz simulcast encodings', async ({ page }) => { 5 | await page.goto('http://localhost:9000/simulcast_sendonly/') 6 | 7 | const channelName = randomUUID() 8 | 9 | await page.fill('#channel-name', channelName) 10 | 11 | const videoCodecType = 'VP8' 12 | const videoBitRate = '1500' 13 | 14 | await page.selectOption('#video-codec-type', videoCodecType) 15 | await page.fill('#video-bit-rate', videoBitRate) 16 | 17 | await page.fill( 18 | '#simulcast-encodings', 19 | JSON.stringify([ 20 | { rid: 'r0', active: true, scalabilityMode: 'L1T1' }, 21 | { rid: 'r1', active: false }, 22 | { rid: 'r2', active: false }, 23 | ]), 24 | ) 25 | 26 | await page.click('#connect') 27 | 28 | await page.waitForSelector('#connection-id:not(:empty)') 29 | const connectionId = await page.$eval('#connection-id', (el) => el.textContent) 30 | console.log(`connectionId=${connectionId}`) 31 | 32 | await page.waitForTimeout(3000) 33 | 34 | await page.click('#get-stats') 35 | 36 | await page.click('#disconnect') 37 | 38 | // simulcast sendonly 統計情報 39 | const sendonlyStatsReportJson: Record[] = await page.evaluate(() => { 40 | const statsReportDiv = document.querySelector('#stats-report') 41 | return statsReportDiv ? JSON.parse(statsReportDiv.dataset.statsReportJson || '[]') : [] 42 | }) 43 | 44 | const sendonlyVideoCodecStats = sendonlyStatsReportJson.find( 45 | (stats) => stats.type === 'codec' && stats.mimeType === `video/${videoCodecType}`, 46 | ) 47 | expect(sendonlyVideoCodecStats).toBeDefined() 48 | 49 | const sendonlyVideoR0OutboundRtpStats = sendonlyStatsReportJson.find( 50 | (stats) => stats.type === 'outbound-rtp' && stats.kind === 'video' && stats.rid === 'r0', 51 | ) 52 | expect(sendonlyVideoR0OutboundRtpStats).toBeDefined() 53 | expect(sendonlyVideoR0OutboundRtpStats?.bytesSent).toBeGreaterThan(0) 54 | expect(sendonlyVideoR0OutboundRtpStats?.packetsSent).toBeGreaterThan(2) 55 | expect(sendonlyVideoR0OutboundRtpStats?.scalabilityMode).toEqual('L1T1') 56 | 57 | const sendonlyVideoR1OutboundRtpStats = sendonlyStatsReportJson.find( 58 | (stats) => stats.type === 'outbound-rtp' && stats.kind === 'video' && stats.rid === 'r1', 59 | ) 60 | expect(sendonlyVideoR1OutboundRtpStats).toBeDefined() 61 | expect(sendonlyVideoR1OutboundRtpStats?.bytesSent).toBe(0) 62 | expect(sendonlyVideoR1OutboundRtpStats?.packetsSent).toBeLessThanOrEqual(2) 63 | 64 | const sendonlyVideoR2OutboundRtpStats = sendonlyStatsReportJson.find( 65 | (stats) => stats.type === 'outbound-rtp' && stats.kind === 'video' && stats.rid === 'r2', 66 | ) 67 | expect(sendonlyVideoR2OutboundRtpStats).toBeDefined() 68 | expect(sendonlyVideoR2OutboundRtpStats?.bytesSent).toBe(0) 69 | expect(sendonlyVideoR2OutboundRtpStats?.packetsSent).toBeLessThanOrEqual(2) 70 | 71 | await page.close() 72 | }) 73 | -------------------------------------------------------------------------------- /e2e-tests/tests/h265.test.ts: -------------------------------------------------------------------------------- 1 | import { randomUUID } from 'node:crypto' 2 | import { expect, test } from '@playwright/test' 3 | 4 | test('H265', async ({ browser }) => { 5 | test.skip( 6 | (test.info().project.name !== 'Google Chrome Canary' && 7 | test.info().project.name !== 'Google Chrome Dev' && 8 | test.info().project.name !== 'Google Chrome Beta' && 9 | test.info().project.name !== 'Google Chrome') || 10 | process.env.RUNNER_ENVIRONMENT !== 'self-hosted' || 11 | process.platform !== 'darwin', 12 | 'H265 は Self-hosted の macOS の Google Chrome でテストを行う', 13 | ) 14 | 15 | console.log(`${browser.browserType().name()}: ${browser.version()}`) 16 | 17 | const sendrecv1 = await browser.newPage() 18 | const sendrecv2 = await browser.newPage() 19 | 20 | await sendrecv1.goto('http://localhost:9000/h265/') 21 | await sendrecv2.goto('http://localhost:9000/h265/') 22 | 23 | const channelName = randomUUID() 24 | 25 | // チャンネル名を設定 26 | await sendrecv1.fill('#channel-name', channelName) 27 | await sendrecv2.fill('#channel-name', channelName) 28 | 29 | console.log(`sendrecv1 channelName: ${channelName}`) 30 | console.log(`sendrecv2 channelName: ${channelName}`) 31 | 32 | const videoCodecType = 'H265' 33 | 34 | await sendrecv1.selectOption('#video-codec-type', videoCodecType) 35 | await sendrecv2.selectOption('#video-codec-type', videoCodecType) 36 | 37 | // 選択されたコーデックをログに出力 38 | const sendrecv1VideoCodecType = await sendrecv1.$eval( 39 | '#video-codec-type', 40 | (el) => (el as HTMLSelectElement).value, 41 | ) 42 | const sendrecv2VideoCodecType = await sendrecv2.$eval( 43 | '#video-codec-type', 44 | (el) => (el as HTMLSelectElement).value, 45 | ) 46 | console.log(`sendrecv1 videoCodecType: ${sendrecv1VideoCodecType}`) 47 | console.log(`sendrecv2 videoCodecType: ${sendrecv2VideoCodecType}`) 48 | 49 | await sendrecv1.click('#connect') 50 | await sendrecv2.click('#connect') 51 | 52 | // #connection-id 要素が存在し、その内容が空でないことを確認するまで待つ 53 | await sendrecv1.waitForSelector('#connection-id:not(:empty)') 54 | 55 | // #connection-id 要素の内容を取得 56 | const sendrecv1ConnectionId = await sendrecv1.$eval('#connection-id', (el) => el.textContent) 57 | console.log(`sendrecv1 connectionId=${sendrecv1ConnectionId}`) 58 | 59 | // #sendrecv1-connection-id 要素が存在し、その内容が空でないことを確認するまで待つ 60 | await sendrecv2.waitForSelector('#connection-id:not(:empty)') 61 | 62 | // #sendrecv1-connection-id 要素の内容を取得 63 | const sendrecv2ConnectionId = await sendrecv2.$eval('#connection-id', (el) => el.textContent) 64 | console.log(`sendrecv2 connectionId=${sendrecv2ConnectionId}`) 65 | 66 | // レース対策 67 | await sendrecv1.waitForTimeout(3000) 68 | await sendrecv2.waitForTimeout(3000) 69 | 70 | // page1 stats report 71 | 72 | // 'Get Stats' ボタンをクリックして統計情報を取得 73 | await sendrecv1.click('#get-stats') 74 | await sendrecv2.click('#get-stats') 75 | 76 | // 統計情報が表示されるまで待機 77 | await sendrecv1.waitForSelector('#stats-report') 78 | await sendrecv2.waitForSelector('#stats-report') 79 | 80 | // データセットから統計情報を取得 81 | const sendrecv1StatsReportJson: Record[] = await sendrecv1.evaluate(() => { 82 | const statsReportDiv = document.querySelector('#stats-report') as HTMLDivElement 83 | return statsReportDiv ? JSON.parse(statsReportDiv.dataset.statsReportJson || '[]') : [] 84 | }) 85 | 86 | const sendrecv1VideoCodecStats = sendrecv1StatsReportJson.find( 87 | (stats) => stats.type === 'codec' && stats.mimeType === `video/${videoCodecType}`, 88 | ) 89 | expect(sendrecv1VideoCodecStats).toBeDefined() 90 | 91 | const sendrecv1VideoOutboundRtpStats = sendrecv1StatsReportJson.find( 92 | (stats) => stats.type === 'outbound-rtp' && stats.kind === 'video', 93 | ) 94 | expect(sendrecv1VideoOutboundRtpStats).toBeDefined() 95 | expect(sendrecv1VideoOutboundRtpStats?.bytesSent).toBeGreaterThan(0) 96 | expect(sendrecv1VideoOutboundRtpStats?.packetsSent).toBeGreaterThan(0) 97 | 98 | const sendrecv1VideoInboundRtpStats = sendrecv1StatsReportJson.find( 99 | (stats) => stats.type === 'inbound-rtp' && stats.kind === 'video', 100 | ) 101 | expect(sendrecv1VideoInboundRtpStats).toBeDefined() 102 | expect(sendrecv1VideoInboundRtpStats?.bytesReceived).toBeGreaterThan(0) 103 | expect(sendrecv1VideoInboundRtpStats?.packetsReceived).toBeGreaterThan(0) 104 | 105 | // データセットから統計情報を取得 106 | const sendrecv2StatsReportJson: Record[] = await sendrecv2.evaluate(() => { 107 | const statsReportDiv = document.querySelector('#stats-report') as HTMLDivElement 108 | return statsReportDiv ? JSON.parse(statsReportDiv.dataset.statsReportJson || '[]') : [] 109 | }) 110 | 111 | const sendrecv2VideoCodecStats = sendrecv2StatsReportJson.find( 112 | (stats) => stats.type === 'codec' && stats.mimeType === `video/${videoCodecType}`, 113 | ) 114 | expect(sendrecv2VideoCodecStats).toBeDefined() 115 | 116 | const sendrecv2VideoOutboundRtpStats = sendrecv2StatsReportJson.find( 117 | (stats) => stats.type === 'outbound-rtp' && stats.kind === 'video', 118 | ) 119 | expect(sendrecv2VideoOutboundRtpStats).toBeDefined() 120 | expect(sendrecv2VideoOutboundRtpStats?.bytesSent).toBeGreaterThan(0) 121 | expect(sendrecv2VideoOutboundRtpStats?.packetsSent).toBeGreaterThan(0) 122 | 123 | const sendrecv2VideoInboundRtpStats = sendrecv2StatsReportJson.find( 124 | (stats) => stats.type === 'inbound-rtp' && stats.kind === 'video', 125 | ) 126 | expect(sendrecv2VideoInboundRtpStats).toBeDefined() 127 | expect(sendrecv2VideoInboundRtpStats?.bytesReceived).toBeGreaterThan(0) 128 | expect(sendrecv2VideoInboundRtpStats?.packetsReceived).toBeGreaterThan(0) 129 | 130 | await sendrecv1.click('#disconnect') 131 | await sendrecv2.click('#disconnect') 132 | 133 | await sendrecv1.close() 134 | await sendrecv2.close() 135 | }) 136 | -------------------------------------------------------------------------------- /e2e-tests/tests/helper.ts: -------------------------------------------------------------------------------- 1 | // バージョン比較用のヘルパー関数を追加 2 | export const isVersionGreaterThanOrEqual = (packageVersion: string, version: string): boolean => { 3 | const v1Parts = packageVersion.split('.').map(Number) 4 | const v2Parts = version.split('.').map(Number) 5 | 6 | for (let i = 0; i < Math.max(v1Parts.length, v2Parts.length); i++) { 7 | const v1 = v1Parts[i] || 0 8 | const v2 = v2Parts[i] || 0 9 | if (v1 > v2) return true 10 | if (v1 < v2) return false 11 | } 12 | return true 13 | } 14 | -------------------------------------------------------------------------------- /e2e-tests/tests/reconnect.test.ts: -------------------------------------------------------------------------------- 1 | import { randomUUID } from 'node:crypto' 2 | import { expect, test } from '@playwright/test' 3 | 4 | // Sora API を利用するので要注意 5 | test('sendonly_reconnect type:reconnect pages', async ({ page }) => { 6 | test.skip( 7 | process.env.RUNNER_ENVIRONMENT === 'self-hosted', 8 | 'Sora API を利用するので Tailscale が利用できない self-hosted では実行しない', 9 | ) 10 | 11 | // デバッグ用 12 | page.on('console', (msg) => { 13 | console.log(msg.type(), msg.text()) 14 | }) 15 | 16 | // それぞれのページに対して操作を行う 17 | await page.goto('http://localhost:9000/sendonly_reconnect/') 18 | 19 | // SDK バージョンの表示 20 | await page.waitForSelector('#sora-js-sdk-version') 21 | const sdkVersion = await page.$eval('#sora-js-sdk-version', (el) => el.textContent) 22 | console.log(`sdkVersion=${sdkVersion}`) 23 | 24 | const channelName = randomUUID() 25 | await page.fill('#channel-name', channelName) 26 | 27 | await page.click('#connect') 28 | 29 | // #connection-id 要素が存在し、その内容が空でないことを確認するまで待つ 30 | await page.waitForSelector('#connection-id:not(:empty)') 31 | const connectionId = await page.$eval('#connection-id', (el) => el.textContent) 32 | console.log(`connectionId=${connectionId}`) 33 | 34 | // レース対策 35 | await page.waitForTimeout(3000) 36 | 37 | // API で切断 38 | await page.click('#disconnect-api') 39 | 40 | // レース対策 41 | await page.waitForTimeout(3000) 42 | 43 | // #connection-id 要素の内容を取得 44 | await page.waitForSelector('#connection-id:not(:empty)') 45 | const reconnectConnectionId = await page.$eval('#connection-id', (el) => el.textContent) 46 | console.log(`reconnectConnectionId=${reconnectConnectionId}`) 47 | 48 | expect(reconnectConnectionId).not.toBe(connectionId) 49 | 50 | await page.waitForSelector('#reconnect-log') 51 | const reconnectLog = await page.$eval('#reconnect-log', (el) => el.textContent) 52 | expect(reconnectLog).toBe('Success') 53 | 54 | await page.close() 55 | }) 56 | -------------------------------------------------------------------------------- /e2e-tests/tests/sendonly_audio.test.ts: -------------------------------------------------------------------------------- 1 | import { randomUUID } from 'node:crypto' 2 | import { expect, test } from '@playwright/test' 3 | test('sendonly audio pages', async ({ browser }) => { 4 | // 新しいページを作成 5 | const sendonly = await browser.newPage() 6 | // ページに対して操作を行う 7 | await sendonly.goto('http://localhost:9000/sendonly_audio/') 8 | 9 | // チャネル名を uuid で生成する 10 | const channelName = randomUUID() 11 | 12 | await sendonly.fill('#channel-name', channelName) 13 | 14 | // select 要素から直接オプションを取得してランダムに選択する 15 | // 音声コーデック 16 | const selectedAudioCodec = await sendonly.evaluate(() => { 17 | const select = document.querySelector('#audio-codec-type') as HTMLSelectElement 18 | const options = Array.from(select.options) 19 | const randomOption = options[Math.floor(Math.random() * options.length)] 20 | select.value = randomOption.value 21 | return randomOption.value 22 | }) 23 | // 音声ビットレート 24 | const selectedBitRate = await sendonly.evaluate(() => { 25 | const select = document.querySelector('#audio-bit-rate') as HTMLSelectElement 26 | const options = Array.from(select.options).filter((option) => option.value !== '') // 未指定を除外 27 | const randomOption = options[Math.floor(Math.random() * options.length)] 28 | select.value = randomOption.value 29 | return randomOption.value 30 | }) 31 | 32 | // ランダムで選択した音声コーデック・音声ビットレートをログに表示する 33 | console.log(`Selected AudioCodec: ${selectedAudioCodec}`) 34 | console.log(`Selected BitRate: ${selectedBitRate} kbps`) 35 | 36 | // 'connect' ボタンをクリックして音声の送信を開始する 37 | await sendonly.click('#connect') 38 | // #connection-id 要素が存在し、その内容が空でないことを確認するまで待つ 39 | await sendonly.waitForSelector('#connection-id:not(:empty)') 40 | // #connection-id 要素の内容を取得 41 | const sendonlyConnectionId = await sendonly.$eval('#connection-id', (el) => el.textContent) 42 | console.log(`sendonly connectionId=${sendonlyConnectionId}`) 43 | 44 | // レース対策 45 | await sendonly.waitForTimeout(3000) 46 | 47 | // 'Get Stats' ボタンをクリックして統計情報を取得 48 | await sendonly.click('#get-stats') 49 | // 統計情報が表示されるまで待機 50 | await sendonly.waitForSelector('#stats-report') 51 | 52 | // データセットから統計情報を取得 53 | const sendonlyStatsReportJson: Record[] = await sendonly.evaluate(() => { 54 | const statsReportDiv = document.querySelector('#stats-report') as HTMLDivElement 55 | return statsReportDiv ? JSON.parse(statsReportDiv.dataset.statsReportJson || '[]') : [] 56 | }) 57 | 58 | // 音声コーデックを確認する : 今は指定してもしなくても OPUS のみ 59 | const sendonlyAudioCodecStats = sendonlyStatsReportJson.find( 60 | (report) => report.type === 'codec' && report.mimeType === 'audio/opus', 61 | ) 62 | expect(sendonlyAudioCodecStats).toBeDefined() 63 | 64 | // 音声ビットレートを確認する:音声を送れているかと targetBitrate の確認 65 | const sendonlyAudioOutboundRtp = sendonlyStatsReportJson.find( 66 | (report) => report.type === 'outbound-rtp' && report.kind === 'audio', 67 | ) 68 | expect(sendonlyAudioOutboundRtp).toBeDefined() 69 | 70 | // 音声が正常に送れているかを確認する 71 | expect(sendonlyAudioOutboundRtp?.bytesSent).toBeGreaterThan(0) 72 | expect(sendonlyAudioOutboundRtp?.packetsSent).toBeGreaterThan(0) 73 | 74 | // 音声ビットレートの選択に基づいて期待値を設定し一致するかを確認する 75 | const expectedBitRate = Number.parseInt(selectedBitRate) * 1000 76 | expect(sendonlyAudioOutboundRtp?.targetBitrate).toEqual(expectedBitRate) 77 | 78 | await sendonly.click('#disconnect') 79 | await sendonly.close() 80 | }) 81 | -------------------------------------------------------------------------------- /e2e-tests/tests/sendonly_recvonly.test.ts: -------------------------------------------------------------------------------- 1 | import { randomUUID } from 'node:crypto' 2 | import { expect, test } from '@playwright/test' 3 | 4 | test('sendonly/recvonly pages', async ({ browser }) => { 5 | // 新しいページを2つ作成 6 | const sendonly = await browser.newPage() 7 | const recvonly = await browser.newPage() 8 | 9 | // それぞれのページに対して操作を行う 10 | await sendonly.goto('http://localhost:9000/sendonly/') 11 | await recvonly.goto('http://localhost:9000/recvonly/') 12 | 13 | const channelName = randomUUID() 14 | 15 | // チャンネル名を設定 16 | await sendonly.fill('#channel-name', channelName) 17 | await recvonly.fill('#channel-name', channelName) 18 | 19 | // SDK バージョンの表示 20 | await sendonly.waitForSelector('#sora-js-sdk-version') 21 | const sendonlySdkVersion = await sendonly.$eval('#sora-js-sdk-version', (el) => el.textContent) 22 | console.log(`sendonly sdkVersion=${sendonlySdkVersion}`) 23 | 24 | await recvonly.waitForSelector('#sora-js-sdk-version') 25 | const recvonlySdkVersion = await recvonly.$eval('#sora-js-sdk-version', (el) => el.textContent) 26 | console.log(`recvonly sdkVersion=${recvonlySdkVersion}`) 27 | 28 | await sendonly.click('#connect') 29 | await recvonly.click('#connect') 30 | 31 | // sendonly の #connection-id 要素が存在し、その内容が空でないことを確認するまで待つ 32 | await sendonly.waitForSelector('#connection-id:not(:empty)') 33 | 34 | // sendonly の #connection-id 要素の内容を取得 35 | const sendonlyConnectionId = await sendonly.$eval('#connection-id', (el) => el.textContent) 36 | console.log(`sendonly connectionId=${sendonlyConnectionId}`) 37 | 38 | // recvonly の #connection-id 要素が存在し、その内容が空でないことを確認するまで待つ 39 | await recvonly.waitForSelector('#connection-id:not(:empty)') 40 | 41 | // recvonly の #connection-id 要素の内容を取得 42 | const recvonlyConnectionId = await recvonly.$eval('#connection-id', (el) => el.textContent) 43 | console.log(`recvonly connectionId=${recvonlyConnectionId}`) 44 | 45 | // レース対策 46 | await sendonly.waitForTimeout(3000) 47 | await recvonly.waitForTimeout(3000) 48 | 49 | // 'Get Stats' ボタンをクリックして統計情報を取得 50 | await sendonly.click('#get-stats') 51 | 52 | // 統計情報が表示されるまで待機 53 | await sendonly.waitForSelector('#stats-report') 54 | // データセットから統計情報を取得 55 | const sendonlyStatsReportJson: Record[] = await sendonly.evaluate(() => { 56 | const statsReportDiv = document.querySelector('#stats-report') as HTMLDivElement 57 | return statsReportDiv ? JSON.parse(statsReportDiv.dataset.statsReportJson || '[]') : [] 58 | }) 59 | 60 | // 'Get Stats' ボタンをクリックして統計情報を取得 61 | await recvonly.click('#get-stats') 62 | 63 | // 統計情報が表示されるまで待機 64 | await recvonly.waitForSelector('#stats-report') 65 | // データセットから統計情報を取得 66 | const recvonlyStatsReportJson: Record[] = await recvonly.evaluate(() => { 67 | const statsReportDiv = document.querySelector('#stats-report') as HTMLDivElement 68 | return statsReportDiv ? JSON.parse(statsReportDiv.dataset.statsReportJson || '[]') : [] 69 | }) 70 | 71 | // sendonly audio codec 72 | const sendonlyAudioCodecStats = sendonlyStatsReportJson.find( 73 | (report) => report.type === 'codec' && report.mimeType === 'audio/opus', 74 | ) 75 | expect(sendonlyAudioCodecStats).toBeDefined() 76 | 77 | // sendonly audio outbound-rtp 78 | const sendonlyAudioOutboundRtp = sendonlyStatsReportJson.find( 79 | (report) => report.type === 'outbound-rtp' && report.kind === 'audio', 80 | ) 81 | expect(sendonlyAudioOutboundRtp).toBeDefined() 82 | expect(sendonlyAudioOutboundRtp?.bytesSent).toBeGreaterThan(0) 83 | expect(sendonlyAudioOutboundRtp?.packetsSent).toBeGreaterThan(0) 84 | 85 | // sendonly video codec 86 | const sendonlyVideoCodecStats = sendonlyStatsReportJson.find( 87 | (stats) => stats.type === 'codec' && stats.mimeType === 'video/VP9', 88 | ) 89 | expect(sendonlyVideoCodecStats).toBeDefined() 90 | 91 | // sendonly video outbound-rtp 92 | const sendonlyVideoOutboundRtpStats = sendonlyStatsReportJson.find( 93 | (stats) => stats.type === 'outbound-rtp' && stats.kind === 'video', 94 | ) 95 | expect(sendonlyVideoOutboundRtpStats).toBeDefined() 96 | expect(sendonlyVideoOutboundRtpStats?.bytesSent).toBeGreaterThan(0) 97 | expect(sendonlyVideoOutboundRtpStats?.packetsSent).toBeGreaterThan(0) 98 | 99 | // recvonly audio codec 100 | const recvonlyAudioCodecStats = recvonlyStatsReportJson.find( 101 | (stats) => stats.type === 'codec' && stats.mimeType === 'audio/opus', 102 | ) 103 | expect(recvonlyAudioCodecStats).toBeDefined() 104 | 105 | // recvonly audio inbound-rtp 106 | const recvonlyAudioInboundRtpStats = recvonlyStatsReportJson.find( 107 | (stats) => stats.type === 'inbound-rtp' && stats.kind === 'audio', 108 | ) 109 | expect(recvonlyAudioInboundRtpStats).toBeDefined() 110 | expect(recvonlyAudioInboundRtpStats?.bytesReceived).toBeGreaterThan(0) 111 | expect(recvonlyAudioInboundRtpStats?.packetsReceived).toBeGreaterThan(0) 112 | 113 | // recvonly video codec 114 | const recvonlyVideoCodecStats = recvonlyStatsReportJson.find( 115 | (stats) => stats.type === 'codec' && stats.mimeType === 'video/VP9', 116 | ) 117 | expect(recvonlyVideoCodecStats).toBeDefined() 118 | 119 | // recvonly video inbound-rtp 120 | const recvonlyVideoInboundRtpStats = recvonlyStatsReportJson.find( 121 | (stats) => stats.type === 'inbound-rtp' && stats.kind === 'video', 122 | ) 123 | expect(recvonlyVideoInboundRtpStats).toBeDefined() 124 | expect(recvonlyVideoInboundRtpStats?.bytesReceived).toBeGreaterThan(0) 125 | expect(recvonlyVideoInboundRtpStats?.packetsReceived).toBeGreaterThan(0) 126 | 127 | await sendonly.click('#disconnect') 128 | await recvonly.click('#disconnect') 129 | 130 | await sendonly.close() 131 | await recvonly.close() 132 | }) 133 | -------------------------------------------------------------------------------- /e2e-tests/tests/simulcast.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test' 2 | import { randomUUID } from 'node:crypto' 3 | 4 | test('simulcast sendonly/recvonly pages', async ({ page }) => { 5 | await page.goto('http://localhost:9000/simulcast/') 6 | 7 | const channelName = randomUUID() 8 | 9 | await page.fill('#channel-name', channelName) 10 | 11 | await page.click('#connect') 12 | 13 | // 安全によせて 5 秒待つ 14 | await page.waitForTimeout(5000) 15 | 16 | await page.waitForSelector('#local-video-connection-id:not(:empty)') 17 | const localConnectionId = await page.$eval('#local-video-connection-id', (el) => el.textContent) 18 | console.log(`local connectionId=${localConnectionId}`) 19 | 20 | await page.waitForSelector('#remote-video-connection-id-r0:not(:empty)') 21 | const remoteR0ConnectionId = await page.$eval( 22 | '#remote-video-connection-id-r0', 23 | (el) => el.textContent, 24 | ) 25 | console.log(`remote | rid=r0, connectionId=${remoteR0ConnectionId}`) 26 | 27 | await page.waitForSelector('#remote-video-connection-id-r1:not(:empty)') 28 | const remoteR1ConnectionId = await page.$eval( 29 | '#remote-video-connection-id-r1', 30 | (el) => el.textContent, 31 | ) 32 | console.log(`remote | rid=r1, connectionId=${remoteR1ConnectionId}`) 33 | 34 | await page.waitForSelector('#remote-video-connection-id-r2:not(:empty)') 35 | const remoteR2ConnectionId = await page.$eval( 36 | '#remote-video-connection-id-r2', 37 | (el) => el.textContent, 38 | ) 39 | console.log(`remote | rid=r2, connectionId=${remoteR2ConnectionId}`) 40 | 41 | // 'Get Stats' ボタンをクリックして統計情報を取得 42 | await page.click('#get-stats') 43 | 44 | // 統計情報が表示されるまで待機 45 | await page.waitForSelector('#stats-report') 46 | // データセットから統計情報を取得 47 | const sendonlyStatsReportJson: Record[] = await page.evaluate(() => { 48 | const statsReportDiv = document.querySelector('#stats-report') as HTMLDivElement 49 | return statsReportDiv ? JSON.parse(statsReportDiv.dataset.statsReportJson || '[]') : [] 50 | }) 51 | 52 | // sendonly stats report 53 | const sendonlyVideoCodecStats = sendonlyStatsReportJson.find( 54 | (stats) => stats.type === 'codec' && stats.mimeType === 'video/VP8', 55 | ) 56 | expect(sendonlyVideoCodecStats).toBeDefined() 57 | 58 | const sendonlyVideoR0OutboundRtpStats = sendonlyStatsReportJson.find( 59 | (stats) => stats.type === 'outbound-rtp' && stats.kind === 'video' && stats.rid === 'r0', 60 | ) 61 | expect(sendonlyVideoR0OutboundRtpStats).toBeDefined() 62 | expect(sendonlyVideoR0OutboundRtpStats?.bytesSent).toBeGreaterThan(0) 63 | expect(sendonlyVideoR0OutboundRtpStats?.packetsSent).toBeGreaterThan(0) 64 | expect(sendonlyVideoR0OutboundRtpStats?.scalabilityMode).toEqual('L1T1') 65 | 66 | const sendonlyVideoR1OutboundRtpStats = sendonlyStatsReportJson.find( 67 | (stats) => stats.type === 'outbound-rtp' && stats.kind === 'video' && stats.rid === 'r1', 68 | ) 69 | expect(sendonlyVideoR1OutboundRtpStats).toBeDefined() 70 | expect(sendonlyVideoR1OutboundRtpStats?.bytesSent).toBeGreaterThan(0) 71 | expect(sendonlyVideoR1OutboundRtpStats?.packetsSent).toBeGreaterThan(0) 72 | expect(sendonlyVideoR1OutboundRtpStats?.scalabilityMode).toEqual('L1T1') 73 | 74 | const sendonlyVideoR2OutboundRtpStats = sendonlyStatsReportJson.find( 75 | (stats) => stats.type === 'outbound-rtp' && stats.kind === 'video' && stats.rid === 'r2', 76 | ) 77 | expect(sendonlyVideoR2OutboundRtpStats).toBeDefined() 78 | expect(sendonlyVideoR2OutboundRtpStats?.bytesSent).toBeGreaterThan(0) 79 | expect(sendonlyVideoR2OutboundRtpStats?.packetsSent).toBeGreaterThan(0) 80 | expect(sendonlyVideoR2OutboundRtpStats?.scalabilityMode).toEqual('L1T1') 81 | 82 | await page.click('#disconnect') 83 | }) 84 | -------------------------------------------------------------------------------- /e2e-tests/tests/simulcast_rid.test.ts: -------------------------------------------------------------------------------- 1 | import { randomUUID } from 'node:crypto' 2 | import { expect, test } from '@playwright/test' 3 | 4 | test('simulcast sendonly/recvonly pages', async ({ browser }) => { 5 | const sendonly = await browser.newPage() 6 | const recvonly = await browser.newPage() 7 | 8 | await sendonly.goto('http://localhost:9000/simulcast_sendonly/') 9 | await recvonly.goto('http://localhost:9000/simulcast_recvonly/') 10 | 11 | const channelName = randomUUID() 12 | 13 | // チャンネル名を設定 14 | await sendonly.fill('#channel-name', channelName) 15 | await recvonly.fill('#channel-name', channelName) 16 | 17 | // recvonly の simulcast_rid を r1 に設定 18 | const simulcastRid = 'r1' 19 | await recvonly.selectOption('#simulcast-rid', simulcastRid) 20 | console.log(`recvonly simulcast_rid=${simulcastRid}`) 21 | 22 | await sendonly.click('#connect') 23 | await recvonly.click('#connect') 24 | 25 | // #connection-id 要素が存在し、その内容が空でないことを確認するまで待つ 26 | await sendonly.waitForSelector('#connection-id:not(:empty)') 27 | 28 | // #connection-id 要素の内容を取得 29 | const sendonlyConnectionId = await sendonly.$eval('#connection-id', (el) => el.textContent) 30 | console.log(`sendonly connectionId=${sendonlyConnectionId}`) 31 | 32 | // #recvonly-connection-id 要素が存在し、その内容が空でないことを確認するまで待つ 33 | await recvonly.waitForSelector('#connection-id:not(:empty)') 34 | // #recvonly-connection-id 要素の内容を取得 35 | const recvonlyConnectionId = await recvonly.$eval('#connection-id', (el) => el.textContent) 36 | console.log(`recvonly connectionId=${recvonlyConnectionId}`) 37 | 38 | // レース対策 39 | await recvonly.waitForTimeout(8000) 40 | 41 | // sendonly stats report 42 | await sendonly.click('#get-stats') 43 | await recvonly.click('#get-stats') 44 | 45 | // 統計情報が表示されるまで待機 46 | await sendonly.waitForSelector('#stats-report') 47 | await recvonly.waitForSelector('#stats-report') 48 | 49 | // sendonly 統計情報 50 | const sendonlyStatsReportJson: Record[] = await sendonly.evaluate(() => { 51 | const statsReportDiv = document.querySelector('#stats-report') 52 | return statsReportDiv ? JSON.parse(statsReportDiv.dataset.statsReportJson || '[]') : [] 53 | }) 54 | 55 | const sendonlyVideoCodecStats = sendonlyStatsReportJson.find( 56 | (stats) => stats.type === 'codec' && stats.mimeType === 'video/VP8', 57 | ) 58 | expect(sendonlyVideoCodecStats).toBeDefined() 59 | 60 | const sendonlyVideoR0OutboundRtpStats = sendonlyStatsReportJson.find( 61 | (stats) => stats.type === 'outbound-rtp' && stats.kind === 'video' && stats.rid === 'r0', 62 | ) 63 | expect(sendonlyVideoR0OutboundRtpStats).toBeDefined() 64 | expect(sendonlyVideoR0OutboundRtpStats?.bytesSent).toBeGreaterThan(0) 65 | expect(sendonlyVideoR0OutboundRtpStats?.packetsSent).toBeGreaterThan(0) 66 | expect(sendonlyVideoR0OutboundRtpStats?.scalabilityMode).toEqual('L1T1') 67 | 68 | const sendonlyVideoR1OutboundRtpStats = sendonlyStatsReportJson.find( 69 | (stats) => stats.type === 'outbound-rtp' && stats.kind === 'video' && stats.rid === 'r1', 70 | ) 71 | expect(sendonlyVideoR1OutboundRtpStats).toBeDefined() 72 | expect(sendonlyVideoR1OutboundRtpStats?.bytesSent).toBeGreaterThan(0) 73 | expect(sendonlyVideoR1OutboundRtpStats?.packetsSent).toBeGreaterThan(0) 74 | expect(sendonlyVideoR1OutboundRtpStats?.scalabilityMode).toEqual('L1T1') 75 | 76 | const sendonlyVideoR2OutboundRtpStats = sendonlyStatsReportJson.find( 77 | (stats) => stats.type === 'outbound-rtp' && stats.kind === 'video' && stats.rid === 'r2', 78 | ) 79 | expect(sendonlyVideoR2OutboundRtpStats).toBeDefined() 80 | expect(sendonlyVideoR2OutboundRtpStats?.bytesSent).toBeGreaterThan(0) 81 | expect(sendonlyVideoR2OutboundRtpStats?.packetsSent).toBeGreaterThan(0) 82 | expect(sendonlyVideoR2OutboundRtpStats?.scalabilityMode).toEqual('L1T1') 83 | 84 | // recvonly の統計情報を取得 85 | const recvonlyStatsReportJson: Record[] = await recvonly.evaluate(() => { 86 | const statsReportDiv = document.querySelector('#stats-report') 87 | return statsReportDiv ? JSON.parse(statsReportDiv.dataset.statsReportJson || '[]') : [] 88 | }) 89 | 90 | const recvonlyVideoInboundRtpStats = recvonlyStatsReportJson.find( 91 | (stats) => stats.type === 'inbound-rtp' && stats.kind === 'video', 92 | ) 93 | expect(recvonlyVideoInboundRtpStats).toBeDefined() 94 | 95 | // r1 が送信している解像度と等しいかどうかを確認する 96 | // r2 は一番早めに諦められてしまい flaky test になるので r1 を確認する 97 | // ここは解像度を固定していないのは flaky test になるためあくまで送信している解像度と等しいかどうかを確認する 98 | expect(recvonlyVideoInboundRtpStats?.frameWidth).toBe(sendonlyVideoR1OutboundRtpStats?.frameWidth) 99 | expect(recvonlyVideoInboundRtpStats?.frameHeight).toBe( 100 | sendonlyVideoR1OutboundRtpStats?.frameHeight, 101 | ) 102 | expect(recvonlyVideoInboundRtpStats?.bytesReceived).toBeGreaterThan(0) 103 | expect(recvonlyVideoInboundRtpStats?.packetsReceived).toBeGreaterThan(0) 104 | 105 | await sendonly.click('#disconnect') 106 | await recvonly.click('#disconnect') 107 | 108 | await sendonly.close() 109 | await recvonly.close() 110 | }) 111 | -------------------------------------------------------------------------------- /e2e-tests/tests/spotlight_sendonly_recvonly.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from '@playwright/test' 2 | 3 | test('spotlight sendonly/recvonly pages', async ({ browser }) => { 4 | // 新しいページを2つ作成 5 | const sendonly = await browser.newPage() 6 | const recvonly = await browser.newPage() 7 | 8 | // それぞれのページに対して操作を行う 9 | await sendonly.goto('http://localhost:9000/spotlight_sendonly/') 10 | await recvonly.goto('http://localhost:9000/spotlight_recvonly/') 11 | 12 | await sendonly.click('#connect') 13 | await recvonly.click('#connect') 14 | 15 | // #sendrecv1-connection-id 要素が存在し、その内容が空でないことを確認するまで待つ 16 | await sendonly.waitForSelector('#connection-id:not(:empty)') 17 | 18 | // #sendonly-connection-id 要素の内容を取得 19 | const sendonlyConnectionId = await sendonly.$eval('#connection-id', (el) => el.textContent) 20 | console.log(`sendonly connectionId=${sendonlyConnectionId}`) 21 | 22 | // #sendrecv1-connection-id 要素が存在し、その内容が空でないことを確認するまで待つ 23 | await recvonly.waitForSelector('#connection-id:not(:empty)') 24 | 25 | // #sendrecv1-connection-id 要素の内容を取得 26 | const recvonlyConnectionId = await recvonly.$eval('#connection-id', (el) => el.textContent) 27 | console.log(`recvonly connectionId=${recvonlyConnectionId}`) 28 | 29 | await sendonly.click('#disconnect') 30 | await recvonly.click('#disconnect') 31 | 32 | await sendonly.close() 33 | await recvonly.close() 34 | }) 35 | -------------------------------------------------------------------------------- /e2e-tests/tests/spotlight_sendrecv.test.ts: -------------------------------------------------------------------------------- 1 | import { randomUUID } from 'node:crypto' 2 | import { test } from '@playwright/test' 3 | 4 | test('spotlight sendrecv x2', async ({ browser }) => { 5 | const sendrecv1 = await browser.newPage() 6 | const sendrecv2 = await browser.newPage() 7 | 8 | await sendrecv1.goto('http://localhost:9000/spotlight_sendrecv/') 9 | await sendrecv2.goto('http://localhost:9000/spotlight_sendrecv/') 10 | 11 | const channelName = randomUUID() 12 | 13 | await sendrecv1.fill('#channel-name', channelName) 14 | await sendrecv2.fill('#channel-name', channelName) 15 | 16 | await sendrecv1.click('#connect') 17 | await sendrecv2.click('#connect') 18 | 19 | // #connection-id 要素が存在し、その内容が空でないことを確認するまで待つ 20 | await sendrecv1.waitForSelector('#connection-id:not(:empty)') 21 | await sendrecv2.waitForSelector('#connection-id:not(:empty)') 22 | 23 | // レース対策 24 | await sendrecv1.waitForTimeout(3000) 25 | await sendrecv2.waitForTimeout(3000) 26 | 27 | // #sendrecv1-connection-id 要素の内容を取得 28 | const sendrecv1ConnectionId = await sendrecv1.$eval('#connection-id', (el) => el.textContent) 29 | console.log(`sendrecv1 connectionId=${sendrecv1ConnectionId}`) 30 | 31 | // #sendrecv1-connection-id 要素が存在し、その内容が空でないことを確認するまで待つ 32 | await sendrecv2.waitForSelector('#connection-id:not(:empty)') 33 | 34 | // #sendrecv1-connection-id 要素の内容を取得 35 | const sendrecv2ConnectionId = await sendrecv2.$eval('#connection-id', (el) => el.textContent) 36 | console.log(`sendrecv2 connectionId=${sendrecv2ConnectionId}`) 37 | 38 | await sendrecv1.click('#disconnect') 39 | await sendrecv2.click('#disconnect') 40 | }) 41 | -------------------------------------------------------------------------------- /e2e-tests/tests/type_close.test.ts: -------------------------------------------------------------------------------- 1 | import { randomUUID } from 'node:crypto' 2 | import { expect, test } from '@playwright/test' 3 | 4 | // Sora API を利用するので要注意 5 | test('data_channel_signaling_only type:close pages', async ({ browser }) => { 6 | test.skip( 7 | process.env.RUNNER_ENVIRONMENT === 'self-hosted', 8 | 'Sora API を利用するので Tailscale が利用できない self-hosted では実行しない', 9 | ) 10 | 11 | // 新しいページを2つ作成 12 | const dataChannelSignalingOnly = await browser.newPage() 13 | 14 | // デバッグ用 15 | dataChannelSignalingOnly.on('console', (msg) => { 16 | console.log(msg.type(), msg.text()) 17 | }) 18 | 19 | // それぞれのページに対して操作を行う 20 | await dataChannelSignalingOnly.goto('http://localhost:9000/data_channel_signaling_only/') 21 | 22 | // SDK バージョンの表示 23 | await dataChannelSignalingOnly.waitForSelector('#sora-js-sdk-version') 24 | const dataChannelSignalingOnlySdkVersion = await dataChannelSignalingOnly.$eval( 25 | '#sora-js-sdk-version', 26 | (el) => el.textContent, 27 | ) 28 | console.log(`dataChannelSignalingOnly sdkVersion=${dataChannelSignalingOnlySdkVersion}`) 29 | 30 | const channelName = randomUUID() 31 | await dataChannelSignalingOnly.fill('#channel-name', channelName) 32 | 33 | await dataChannelSignalingOnly.click('#connect') 34 | 35 | // #sendrecv1-connection-id 要素が存在し、その内容が空でないことを確認するまで待つ 36 | await dataChannelSignalingOnly.waitForSelector('#connection-id:not(:empty)') 37 | 38 | // #sendonly-connection-id 要素の内容を取得 39 | const dataChannelSignalingOnlyConnectionId = await dataChannelSignalingOnly.$eval( 40 | '#connection-id', 41 | (el) => el.textContent, 42 | ) 43 | console.log(`dataChannelSignalingOnly connectionId=${dataChannelSignalingOnlyConnectionId}`) 44 | 45 | // レース対策 46 | await dataChannelSignalingOnly.waitForTimeout(3000) 47 | 48 | // API で切断 49 | await dataChannelSignalingOnly.click('#disconnect-api') 50 | // console.log に [signaling] switched が出力されるまで待機するための Promise を作成する 51 | const consolePromise = dataChannelSignalingOnly.waitForEvent('console') 52 | 53 | // レース対策 54 | await dataChannelSignalingOnly.waitForTimeout(3000) 55 | 56 | // Console log の Promise が解決されるまで待機する 57 | const msg = await consolePromise 58 | // log [signaling] onmessage-close websocket が出力されるので、args 0/1/2 をそれぞれチェックする 59 | // [signaling] 60 | const value1 = await msg.args()[0].jsonValue() 61 | expect(value1).toBe('[signaling]') 62 | // onmessage-close 63 | const value2 = await msg.args()[1].jsonValue() 64 | expect(value2).toBe('onmessage-close') 65 | // websocket 66 | const value3 = await msg.args()[2].jsonValue() 67 | expect(value3).toBe('datachannel') 68 | 69 | await dataChannelSignalingOnly.close() 70 | }) 71 | -------------------------------------------------------------------------------- /e2e-tests/tests/type_switched.test.ts: -------------------------------------------------------------------------------- 1 | import { randomUUID } from 'node:crypto' 2 | import { expect, test } from '@playwright/test' 3 | 4 | test('data_channel_signaling_only type:switched pages', async ({ browser }) => { 5 | // 新しいページを2つ作成 6 | const dataChannelSignalingOnly = await browser.newPage() 7 | 8 | // デバッグ用 9 | // dataChannelSignalingOnly.on('console', (msg) => { 10 | // console.log(msg.type(), msg.text()) 11 | // }) 12 | 13 | // それぞれのページに対して操作を行う 14 | await dataChannelSignalingOnly.goto('http://localhost:9000/data_channel_signaling_only/') 15 | 16 | // SDK バージョンの表示 17 | await dataChannelSignalingOnly.waitForSelector('#sora-js-sdk-version') 18 | const dataChannelSignalingOnlySdkVersion = await dataChannelSignalingOnly.$eval( 19 | '#sora-js-sdk-version', 20 | (el) => el.textContent, 21 | ) 22 | console.log(`dataChannelSignalingOnly sdkVersion=${dataChannelSignalingOnlySdkVersion}`) 23 | 24 | const channelName = randomUUID() 25 | await dataChannelSignalingOnly.fill('#channel-name', channelName) 26 | 27 | await dataChannelSignalingOnly.click('#connect') 28 | 29 | // console.log に [signaling] switched が出力されるまで待機するための Promise を作成する 30 | const consolePromise = dataChannelSignalingOnly.waitForEvent('console') 31 | 32 | // #sendrecv1-connection-id 要素が存在し、その内容が空でないことを確認するまで待つ 33 | await dataChannelSignalingOnly.waitForSelector('#connection-id:not(:empty)') 34 | 35 | // #sendonly-connection-id 要素の内容を取得 36 | const dataChannelSignalingOnlyConnectionId = await dataChannelSignalingOnly.$eval( 37 | '#connection-id', 38 | (el) => el.textContent, 39 | ) 40 | console.log(`dataChannelSignalingOnly connectionId=${dataChannelSignalingOnlyConnectionId}`) 41 | 42 | // レース対策 43 | await dataChannelSignalingOnly.waitForTimeout(3000) 44 | 45 | // Console log の Promise が解決されるまで待機する 46 | const msg = await consolePromise 47 | // log [signaling] onmessage-switched websocket が出力されるので、args 0/1/2 をそれぞれチェックする 48 | // [signaling] 49 | const value1 = await msg.args()[0].jsonValue() 50 | expect(value1).toBe('[signaling]') 51 | // onmessage-switched 52 | const value2 = await msg.args()[1].jsonValue() 53 | expect(value2).toBe('onmessage-switched') 54 | // websocket 55 | const value3 = await msg.args()[2].jsonValue() 56 | expect(value3).toBe('websocket') 57 | 58 | await dataChannelSignalingOnly.click('#disconnect') 59 | 60 | await dataChannelSignalingOnly.close() 61 | }) 62 | -------------------------------------------------------------------------------- /e2e-tests/tests/whip_simulcast.test.ts: -------------------------------------------------------------------------------- 1 | import { randomUUID } from 'node:crypto' 2 | import { expect, test } from '@playwright/test' 3 | 4 | for (const videoCodecType of ['AV1', 'H264', 'H265']) { 5 | test(`whip-simulcast/${videoCodecType}`, async ({ browser }) => { 6 | test.skip( 7 | process.env.E2E_TEST_WISH !== 'true', 8 | 'E2E_TEST_WISH が true でない場合は WHIP/WHEP 関連のテストはスキップする', 9 | ) 10 | 11 | // Google Chrome (m136) になったらこの skip は削除する 12 | test.skip( 13 | test.info().project.name === 'Google Chrome' && videoCodecType === 'H265', 14 | 'Google Chrome (m135) では H.265 に対応していないのでスキップします', 15 | ) 16 | 17 | // ブラウザのバージョンを取得 18 | const browserName = browser.browserType().name() 19 | const browserVersion = browser.version() 20 | console.log(`browser name=${browserName} version=${browserVersion}`) 21 | 22 | const page = await browser.newPage() 23 | 24 | await page.goto('http://localhost:9000/whip_simulcast/') 25 | 26 | const channelName = randomUUID() 27 | 28 | await page.fill('#channel-name', channelName) 29 | 30 | await page.selectOption('#video-codec-type', videoCodecType) 31 | 32 | await page.click('#connect') 33 | 34 | // 安全によせて 5 秒待つ 35 | await page.waitForTimeout(5000) 36 | 37 | // connection-state が "connected" になるまで待つ 38 | await page.waitForSelector('#connection-state:has-text("connected")') 39 | 40 | // connection-stateの値を取得して確認 41 | const whipConnectionState = await page.$eval('#connection-state', (el) => el.textContent) 42 | console.log(`whip connectionState=${whipConnectionState}`) 43 | 44 | // 'Get Stats' ボタンをクリックして統計情報を取得 45 | await page.click('#get-stats') 46 | 47 | // 統計情報が表示されるまで待機 48 | await page.waitForSelector('#stats-report') 49 | // データセットから統計情報を取得 50 | const statsReportJson: Record[] = await page.evaluate(() => { 51 | const statsReportDiv = document.querySelector('#stats-report') as HTMLDivElement 52 | return statsReportDiv ? JSON.parse(statsReportDiv.dataset.statsReportJson || '[]') : [] 53 | }) 54 | 55 | // sendonly stats report 56 | const videoCodecStats = statsReportJson.find( 57 | (stats) => stats.type === 'codec' && stats.mimeType === `video/${videoCodecType}`, 58 | ) 59 | expect(videoCodecStats).toBeDefined() 60 | 61 | const videoR0OutboundRtpStats = statsReportJson.find( 62 | (stats) => stats.type === 'outbound-rtp' && stats.kind === 'video' && stats.rid === 'r0', 63 | ) 64 | expect(videoR0OutboundRtpStats).toBeDefined() 65 | 66 | const videoR1OutboundRtpStats = statsReportJson.find( 67 | (stats) => stats.type === 'outbound-rtp' && stats.kind === 'video' && stats.rid === 'r1', 68 | ) 69 | expect(videoR1OutboundRtpStats).toBeDefined() 70 | 71 | const videoR2OutboundRtpStats = statsReportJson.find( 72 | (stats) => stats.type === 'outbound-rtp' && stats.kind === 'video' && stats.rid === 'r2', 73 | ) 74 | expect(videoR2OutboundRtpStats).toBeDefined() 75 | 76 | await page.click('#disconnect') 77 | 78 | // disconnected になるまで待つ 79 | // await page.waitForSelector('#connection-state:has-text("disconnected")') 80 | 81 | // ページを閉じる 82 | await page.close() 83 | }) 84 | } 85 | -------------------------------------------------------------------------------- /e2e-tests/tests/whip_whep.test.ts: -------------------------------------------------------------------------------- 1 | import { randomUUID } from 'node:crypto' 2 | import { expect, test } from '@playwright/test' 3 | 4 | for (const videoCodecType of ['AV1', 'H264', 'H265']) { 5 | test(`whip/whep/${videoCodecType}`, async ({ browser }) => { 6 | test.skip( 7 | process.env.E2E_TEST_WISH !== 'true', 8 | 'E2E_TEST_WISH が true でない場合は WHIP/WHEP 関連のテストはスキップする', 9 | ) 10 | 11 | // Google Chrome (m136) になったらこの skip は削除する 12 | test.skip( 13 | test.info().project.name === 'Google Chrome' && videoCodecType === 'H265', 14 | 'Google Chrome (m135) では H.265 に対応していないのでスキップします', 15 | ) 16 | 17 | // ブラウザのバージョンを取得 18 | const browserName = browser.browserType().name() 19 | const browserVersion = browser.version() 20 | console.log(`browser name=${browserName} version=${browserVersion}`) 21 | 22 | const whip = await browser.newPage() 23 | const whep = await browser.newPage() 24 | 25 | await whip.goto('http://localhost:9000/whip/') 26 | await whep.goto('http://localhost:9000/whep/') 27 | 28 | await whip.selectOption('#video-codec-type', videoCodecType) 29 | // コーデックの取得 30 | const whipVideoCodecType = await whip.evaluate(() => { 31 | const videoElement = document.querySelector('#video-codec-type') as HTMLSelectElement 32 | return videoElement.value 33 | }) 34 | console.log(`whipVideoCodecType=${whipVideoCodecType}`) 35 | 36 | await whep.selectOption('#video-codec-type', videoCodecType) 37 | // コーデックの取得 38 | const whepVideoCodecType = await whep.evaluate(() => { 39 | const videoElement = document.querySelector('#video-codec-type') as HTMLSelectElement 40 | return videoElement.value 41 | }) 42 | console.log(`whepVideoCodecType=${whepVideoCodecType}`) 43 | 44 | // チャンネル名を uuid 文字列にする 45 | const channelName = randomUUID() 46 | 47 | // チャンネル名を設定 48 | await whip.fill('#channel-name', channelName) 49 | await whep.fill('#channel-name', channelName) 50 | 51 | await whip.click('#connect') 52 | await whep.click('#connect') 53 | 54 | // connection-stateが"connected"になるまで待つ 55 | await whip.waitForSelector('#connection-state:has-text("connected")') 56 | await whep.waitForSelector('#connection-state:has-text("connected")') 57 | 58 | // connection-stateの値を取得して確認 59 | const whipConnectionState = await whip.$eval('#connection-state', (el) => el.textContent) 60 | console.log(`whip connectionState=${whipConnectionState}`) 61 | 62 | const whepConnectionState = await whep.$eval('#connection-state', (el) => el.textContent) 63 | console.log(`whep connectionState=${whepConnectionState}`) 64 | 65 | // レース対策 66 | await whip.waitForTimeout(3000) 67 | await whep.waitForTimeout(3000) 68 | 69 | // 'Get Stats' ボタンをクリックして統計情報を取得 70 | await whip.click('#get-stats') 71 | await whep.click('#get-stats') 72 | 73 | // 統計情報が表示されるまで待機 74 | await whip.waitForSelector('#stats-report') 75 | await whep.waitForSelector('#stats-report') 76 | 77 | // 統計情報を取得 78 | const whipStatsReportJson: Record[] = await whip.evaluate(() => { 79 | const statsReportDiv = document.querySelector('#stats-report') as HTMLDivElement 80 | return statsReportDiv ? JSON.parse(statsReportDiv.dataset.statsReportJson || '[]') : [] 81 | }) 82 | // whip video codec 83 | const whipVideoCodecStats = whipStatsReportJson.find( 84 | (stats) => stats.type === 'codec' && stats.mimeType === `video/${videoCodecType}`, 85 | ) 86 | expect(whipVideoCodecStats).toBeDefined() 87 | 88 | // whip video outbound-rtp 89 | const whipVideoOutboundRtpStats = whipStatsReportJson.find( 90 | (stats) => stats.type === 'outbound-rtp' && stats.kind === 'video', 91 | ) 92 | expect(whipVideoOutboundRtpStats).toBeDefined() 93 | expect(whipVideoOutboundRtpStats?.bytesSent).toBeGreaterThan(0) 94 | expect(whipVideoOutboundRtpStats?.packetsSent).toBeGreaterThan(0) 95 | 96 | // データセットから統計情報を取得 97 | const whepStatsReportJson: Record[] = await whep.evaluate(() => { 98 | const statsReportDiv = document.querySelector('#stats-report') as HTMLDivElement 99 | return statsReportDiv ? JSON.parse(statsReportDiv.dataset.statsReportJson || '[]') : [] 100 | }) 101 | 102 | // whep video codec 103 | const whepVideoCodecStats = whepStatsReportJson.find( 104 | (stats) => stats.type === 'codec' && stats.mimeType === `video/${videoCodecType}`, 105 | ) 106 | expect(whepVideoCodecStats).toBeDefined() 107 | 108 | // whep video inbound-rtp 109 | const whepVideoInboundRtpStats = whepStatsReportJson.find( 110 | (stats) => stats.type === 'inbound-rtp' && stats.kind === 'video', 111 | ) 112 | expect(whepVideoInboundRtpStats).toBeDefined() 113 | expect(whepVideoInboundRtpStats?.bytesReceived).toBeGreaterThan(0) 114 | expect(whepVideoInboundRtpStats?.packetsReceived).toBeGreaterThan(0) 115 | 116 | await whip.click('#disconnect') 117 | await whep.click('#disconnect') 118 | 119 | await whip.close() 120 | await whep.close() 121 | }) 122 | } 123 | -------------------------------------------------------------------------------- /e2e-tests/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "strict": true, 6 | "declaration": true, 7 | "strictNullChecks": true, 8 | "importHelpers": true, 9 | "moduleResolution": "Bundler", 10 | "experimentalDecorators": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "resolveJsonModule": true, 14 | "stripInternal": true, 15 | "newLine": "LF", 16 | "types": ["node"], 17 | "paths": { 18 | "sora-js-sdk": ["../dist/sora.d.ts"] 19 | }, 20 | "lib": ["esnext", "dom", "dom.iterable", "scripthost"] 21 | }, 22 | "exclude": ["node_modules"] 23 | } 24 | -------------------------------------------------------------------------------- /e2e-tests/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | interface ImportMetaEnv { 4 | VITE_TEST_SIGNALING_URL: string 5 | VITE_TEST_API_URL: string 6 | VITE_TEST_CHANNEL_ID_PREFIX: string 7 | VITE_TEST_CHANNEL_ID_SUFFIX: string 8 | VITE_TEST_SECRET_KEY: string 9 | 10 | VITE_TEST_WHIP_ENDPOINT_URL: string 11 | VITE_TEST_WHEP_ENDPOINT_URL: string 12 | } 13 | 14 | interface ImportMeta { 15 | readonly env: ImportMetaEnv 16 | } 17 | -------------------------------------------------------------------------------- /e2e-tests/vite.config.mjs: -------------------------------------------------------------------------------- 1 | import { resolve } from 'node:path' 2 | import { defineConfig } from 'vite' 3 | 4 | // root が examples なので examples/dist にビルドされる 5 | 6 | export default defineConfig({ 7 | root: resolve(__dirname), 8 | resolve: { 9 | // NPM_PKG_E2E_TEST が true の時は alias を無効化する 10 | // これは .github/workflows/npm-pkg-e2e-test.yml で、 11 | // E2E テストで複数のバージョンの npm の sora-js-sdk をインストールして利用するため 12 | alias: process.env.NPM_PKG_E2E_TEST 13 | ? {} 14 | : { 15 | 'sora-js-sdk': resolve(__dirname, '../dist/sora.mjs'), 16 | }, 17 | }, 18 | build: { 19 | rollupOptions: { 20 | input: { 21 | index: resolve(__dirname, 'index.html'), 22 | sendrecv: resolve(__dirname, 'sendrecv/index.html'), 23 | sendrecv_webkit: resolve(__dirname, 'sendrecv_webkit/index.html'), 24 | sendonly: resolve(__dirname, 'sendonly/index.html'), 25 | recvonly: resolve(__dirname, 'recvonly/index.html'), 26 | check_stereo: resolve(__dirname, 'check_stereo/index.html'), 27 | check_stereo_multi: resolve(__dirname, 'check_stereo_multi/index.html'), 28 | replace_track: resolve(__dirname, 'replace_track/index.html'), 29 | simulcast: resolve(__dirname, 'simulcast/index.html'), 30 | simulcast_sendonly: resolve(__dirname, 'simulcast_sendonly/index.html'), 31 | simulcast_recvonly: resolve(__dirname, 'simulcast_recvonly/index.html'), 32 | spotlight_sendrecv: resolve(__dirname, 'spotlight_sendrecv/index.html'), 33 | spotlight_sendonly: resolve(__dirname, 'spotlight_sendonly/index.html'), 34 | spotlight_recvonly: resolve(__dirname, 'spotlight_recvonly/index.html'), 35 | sendonly_audio: resolve(__dirname, 'sendonly_audio/index.html'), 36 | messaging: resolve(__dirname, 'messaging/index.html'), 37 | data_channel_signaling_only: resolve(__dirname, 'data_channel_signaling_only/index.html'), 38 | whip: resolve(__dirname, 'whip/index.html'), 39 | whip_simulcast: resolve(__dirname, 'whip_simulcast/index.html'), 40 | whep: resolve(__dirname, 'whep/index.html'), 41 | fake_sendonly: resolve(__dirname, 'fake_sendonly/index.html'), 42 | simulcast_sendonly_webkit: resolve(__dirname, 'simulcast_sendonly_webkit/index.html'), 43 | }, 44 | }, 45 | }, 46 | envDir: resolve(__dirname, '..'), 47 | }) 48 | -------------------------------------------------------------------------------- /e2e-tests/whep/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Sora WHEP Client 9 | 10 | 11 | 12 |

Sora WHEP Client

13 |
14 | 15 |
16 | 17 |
22 | 23 | 24 | 25 |
26 | 27 |

28 | 29 |

30 | 31 |
32 | 33 |
34 |
35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /e2e-tests/whip/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Sora WHIP Client 9 | 10 | 11 | 12 |

Sora WHIP Client

13 |
14 | 15 | 16 |
17 | 18 |
23 | 24 | 25 | 26 |
27 | 28 |

29 | 30 |

31 | 32 |
33 | 34 |
35 |
36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /e2e-tests/whip_simulcast/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Sora WHIP Client 8 | 9 | 10 | 11 |

Sora WHIP Client

12 |
13 | 14 | 15 |
16 | 17 |
22 | 23 | 24 | 25 |
26 |

27 | 28 |

29 | 30 |
31 |
32 |
33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sora-js-sdk", 3 | "version": "2025.1.0-canary.7", 4 | "description": "WebRTC SFU Sora JavaScript SDK", 5 | "main": "dist/sora.js", 6 | "module": "dist/sora.mjs", 7 | "exports": { 8 | ".": { 9 | "types": "./dist/sora.d.ts", 10 | "import": "./dist/sora.mjs", 11 | "require": "./dist/sora.mjs" 12 | } 13 | }, 14 | "scripts": { 15 | "build": "vite build", 16 | "watch": "vite build --watch", 17 | "e2e-dev": "vite --config e2e-tests/vite.config.mjs", 18 | "e2e-test": "pnpm build && playwright test --project='chromium'", 19 | "e2e-test-chrome": "pnpm build && playwright test --project='Google Chrome*'", 20 | "e2e-test-edge": "pnpm build && playwright test --project='Microsoft Edge*'", 21 | "e2e-test-webkit": "pnpm build && playwright test --project='WebKit'", 22 | "lint": "biome lint", 23 | "fmt": "biome format --write", 24 | "check": "tsc --noEmit", 25 | "test": "vitest run", 26 | "doc": "typedoc" 27 | }, 28 | "repository": { 29 | "type": "git", 30 | "url": "git+https://github.com/shiguredo/sora-js-sdk.git" 31 | }, 32 | "author": "Shiguredo Inc.", 33 | "license": "Apache-2.0", 34 | "bugs": { 35 | "url": "https://discord.gg/shiguredo" 36 | }, 37 | "homepage": "https://github.com/shiguredo/sora-js-sdk#readme", 38 | "files": [ 39 | "dist" 40 | ], 41 | "devDependencies": { 42 | "@biomejs/biome": "1.9.4", 43 | "@playwright/test": "1.52.0", 44 | "@types/node": "22.15.26", 45 | "jsdom": "26.1.0", 46 | "tslib": "2.8.1", 47 | "typedoc": "0.28.5", 48 | "typescript": "5.8.3", 49 | "vite": "6.3.5", 50 | "vite-plugin-dts": "4.5.4", 51 | "vitest": "3.1.4" 52 | }, 53 | "engines": { 54 | "pnpm": ">=8", 55 | "node": ">=20" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from '@playwright/test' 2 | 3 | // pnpm exec playwright test --ui 4 | 5 | export default defineConfig({ 6 | testDir: 'e2e-tests/tests', 7 | workers: 1, 8 | // fullyParallel: true, 9 | reporter: 'list', 10 | use: { 11 | launchOptions: { 12 | args: [ 13 | // CORS 無効 14 | '--disable-web-security', 15 | '--disable-features=IsolateOrigins,site-per-process', 16 | 17 | '--use-fake-ui-for-media-stream', 18 | '--use-fake-device-for-media-stream', 19 | // "--use-file-for-fake-video-capture=/app/sample.mjpeg", 20 | 21 | // '--enable-features=WebRtcAllowH265Send,WebRtcAllowH265Receive', 22 | ], 23 | }, 24 | }, 25 | projects: [ 26 | // Chromium 27 | { 28 | name: 'Chromium', 29 | use: { ...devices['Desktop Chrome'] }, 30 | }, 31 | 32 | // Chrome 33 | { 34 | name: 'Google Chrome', 35 | use: { ...devices['Desktop Chrome'], channel: 'chrome' }, 36 | }, 37 | { 38 | name: 'Google Chrome Beta', 39 | use: { ...devices['Desktop Chrome'], channel: 'chrome-beta' }, 40 | }, 41 | { 42 | name: 'Google Chrome Dev', 43 | use: { ...devices['Desktop Chrome'], channel: 'chrome-dev' }, 44 | }, 45 | { 46 | name: 'Google Chrome Canary', 47 | use: { ...devices['Desktop Chrome'], channel: 'chrome-canary' }, 48 | }, 49 | 50 | // Edge 51 | { 52 | name: 'Microsoft Edge', 53 | use: { ...devices['Desktop Edge'], channel: 'msedge' }, 54 | }, 55 | { 56 | name: 'Microsoft Edge Beta', 57 | use: { ...devices['Desktop Edge'], channel: 'msedge-beta' }, 58 | }, 59 | { 60 | name: 'Microsoft Edge Dev', 61 | use: { ...devices['Desktop Edge'], channel: 'msedge-dev' }, 62 | }, 63 | { 64 | name: 'Microsoft Edge Canary', 65 | use: { ...devices['Desktop Edge'], channel: 'msedge-canary' }, 66 | }, 67 | 68 | { 69 | name: 'WebKit', 70 | use: { ...devices['Desktop Safari'] }, 71 | }, 72 | 73 | // { 74 | // name: 'firefox', 75 | // use: { ...devices['Desktop Firefox'] }, 76 | // }, 77 | ], 78 | webServer: { 79 | command: 'pnpm run e2e-dev --port 9000', 80 | url: 'http://localhost:9000/', 81 | reuseExistingServer: !process.env.CI, 82 | stdout: 'pipe', 83 | stderr: 'pipe', 84 | }, 85 | }) 86 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - e2e-tests 3 | onlyBuiltDependencies: 4 | - '@biomejs/biome' 5 | - esbuild 6 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | // シグナリングトランスポートタイプ 2 | export const TRANSPORT_TYPE_WEBSOCKET = 'websocket' as const 3 | export const TRANSPORT_TYPE_DATACHANNEL = 'datachannel' as const 4 | 5 | // シグナリング ROLE 6 | export const SIGNALING_ROLE_SENDRECV = 'sendrecv' as const 7 | export const SIGNALING_ROLE_SENDONLY = 'sendonly' as const 8 | export const SIGNALING_ROLE_RECVONLY = 'recvonly' as const 9 | 10 | // WebSocket シグナリングでのみ利用する 11 | export const SIGNALING_MESSAGE_TYPE_CONNECT = 'connect' as const 12 | export const SIGNALING_MESSAGE_TYPE_REDIRECT = 'redirect' as const 13 | export const SIGNALING_MESSAGE_TYPE_OFFER = 'offer' as const 14 | export const SIGNALING_MESSAGE_TYPE_ANSWER = 'answer' as const 15 | export const SIGNALING_MESSAGE_TYPE_CANDIDATE = 'candidate' as const 16 | export const SIGNALING_MESSAGE_TYPE_SWITCHED = 'switched' as const 17 | export const SIGNALING_MESSAGE_TYPE_PING = 'ping' as const 18 | export const SIGNALING_MESSAGE_TYPE_PONG = 'pong' as const 19 | 20 | // DataChannel シグナリングでのみ利用する 21 | export const SIGNALING_MESSAGE_TYPE_REQ_STATS = 'req-stats' as const 22 | export const SIGNALING_MESSAGE_TYPE_STATS = 'stats' as const 23 | export const SIGNALING_MESSAGE_TYPE_CLOSE = 'close' as const 24 | 25 | // WebSocket と DataChannel シグナリング両方で了する 26 | export const SIGNALING_MESSAGE_TYPE_RE_OFFER = 're-offer' as const 27 | export const SIGNALING_MESSAGE_TYPE_RE_ANSWER = 're-answer' as const 28 | export const SIGNALING_MESSAGE_TYPE_DISCONNECT = 'disconnect' as const 29 | export const SIGNALING_MESSAGE_TYPE_NOTIFY = 'notify' as const 30 | export const SIGNALING_MESSAGE_TYPE_PUSH = 'push' as const 31 | 32 | // @deprecated この定数は将来的に削除される予定です 33 | export const SIGNALING_MESSAGE_TYPE_UPDATE = 'update' as const 34 | 35 | // データチャネル必須ラベル 36 | export const DATA_CHANNEL_LABEL_SIGNALING = 'signaling' as const 37 | export const DATA_CHANNEL_LABEL_PUSH = 'push' as const 38 | export const DATA_CHANNEL_LABEL_NOTIFY = 'notify' as const 39 | export const DATA_CHANNEL_LABEL_STATS = 'stats' as const 40 | -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | // 切断待機タイムアウトエラー 2 | export class DisconnectWaitTimeoutError extends Error { 3 | constructor() { 4 | super('DISCONNECT-WAIT-TIMEOUT-ERROR') 5 | } 6 | } 7 | 8 | // 内部エラー 9 | export class DisconnectInternalError extends Error { 10 | constructor() { 11 | super('DISCONNECT-INTERNAL-ERROR') 12 | } 13 | } 14 | 15 | // DataChannel onerror によるエラー 16 | export class DisconnectDataChannelError extends Error { 17 | constructor() { 18 | super('DISCONNECT-DATA-CHANNEL-ERROR') 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * MediaStream の constraints を動的に変更するメソッド. 3 | * 4 | * @param mediaStream - メディアストリーム 5 | * 6 | * @param constraints - メディアストリーム制約 7 | * 8 | * @public 9 | */ 10 | async function applyMediaStreamConstraints( 11 | mediaStream: MediaStream, 12 | constraints: MediaStreamConstraints, 13 | ): Promise { 14 | if (constraints.audio && typeof constraints.audio !== 'boolean') { 15 | for (const track of mediaStream.getAudioTracks()) { 16 | await track.applyConstraints(constraints.audio) 17 | } 18 | } 19 | if (constraints.video && typeof constraints.video !== 'boolean') { 20 | for (const track of mediaStream.getVideoTracks()) { 21 | await track.applyConstraints(constraints.video) 22 | } 23 | } 24 | } 25 | 26 | export { applyMediaStreamConstraints } 27 | -------------------------------------------------------------------------------- /src/messaging.ts: -------------------------------------------------------------------------------- 1 | import ConnectionBase from './base' 2 | 3 | /** 4 | * messaging_only 専用のクラス 5 | * 利用する場合は Sora 側での設定が必要 6 | * Role は "sendonly" に固定される 7 | */ 8 | export default class ConnectionMessaging extends ConnectionBase { 9 | /** 10 | * Sora へ接続するメソッド、legacyStream は利用できない 11 | * 12 | * @example 13 | * ```typescript 14 | * const messaging = connection.messaging("sora"); 15 | * await messaging.connect(); 16 | * ``` 17 | * 18 | * @public 19 | */ 20 | async connect(): Promise { 21 | // options が 22 | await Promise.race([ 23 | this.multiStream().finally(() => { 24 | this.clearConnectionTimeout() 25 | this.clearMonitorSignalingWebSocketEvent() 26 | }), 27 | this.setConnectionTimeout(), 28 | this.monitorSignalingWebSocketEvent(), 29 | ]) 30 | this.monitorWebSocketEvent() 31 | this.monitorPeerConnectionState() 32 | } 33 | 34 | /** 35 | * マルチストリームで Sora へ接続するメソッド 36 | */ 37 | private async multiStream(): Promise { 38 | await this.disconnect() 39 | const ws = await this.getSignalingWebSocket(this.signalingUrlCandidates) 40 | const signalingMessage = await this.signaling(ws) 41 | await this.connectPeerConnection(signalingMessage) 42 | await this.setRemoteDescription(signalingMessage) 43 | await this.createAnswer(signalingMessage) 44 | this.sendAnswer() 45 | if (!this.options.skipIceCandidateEvent) { 46 | await this.onIceCandidate() 47 | } 48 | await this.waitChangeConnectionStateConnected() 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/publisher.ts: -------------------------------------------------------------------------------- 1 | import ConnectionBase from './base' 2 | 3 | /** 4 | * Role が "sendonly" または "sendrecv" の場合に Sora との WebRTC 接続を扱うクラス 5 | */ 6 | export default class ConnectionPublisher extends ConnectionBase { 7 | /** 8 | * Sora へ接続するメソッド 9 | * 10 | * @example 11 | * ```typescript 12 | * const sendrecv = connection.sendrecv("sora"); 13 | * const mediaStream = await navigator.mediaDevices.getUserMedia({audio: true, video: true}); 14 | * await sendrecv.connect(mediaStream); 15 | * ``` 16 | * 17 | * @param stream - メディアストリーム 18 | * 19 | * @public 20 | */ 21 | async connect(stream: MediaStream): Promise { 22 | // options.multistream が明示的に false を指定した時だけレガシーストリームにする 23 | await Promise.race([ 24 | this.multiStream(stream).finally(() => { 25 | this.clearConnectionTimeout() 26 | this.clearMonitorSignalingWebSocketEvent() 27 | }), 28 | this.setConnectionTimeout(), 29 | this.monitorSignalingWebSocketEvent(), 30 | ]) 31 | this.monitorWebSocketEvent() 32 | this.monitorPeerConnectionState() 33 | return stream 34 | } 35 | 36 | /** 37 | * マルチストリームで Sora へ接続するメソッド 38 | * 39 | * @param stream - メディアストリーム 40 | */ 41 | private async multiStream(stream: MediaStream): Promise { 42 | await this.disconnect() 43 | const ws = await this.getSignalingWebSocket(this.signalingUrlCandidates) 44 | const signalingMessage = await this.signaling(ws) 45 | await this.connectPeerConnection(signalingMessage) 46 | if (this.pc) { 47 | this.pc.ontrack = async (event): Promise => { 48 | const stream = event.streams[0] 49 | if (!stream) { 50 | return 51 | } 52 | const data = { 53 | 'stream.id': stream.id, 54 | id: event.track.id, 55 | label: event.track.label, 56 | enabled: event.track.enabled, 57 | kind: event.track.kind, 58 | muted: event.track.muted, 59 | readyState: event.track.readyState, 60 | } 61 | this.writePeerConnectionTimelineLog('ontrack', data) 62 | if (stream.id === 'default') { 63 | return 64 | } 65 | if (stream.id === this.connectionId) { 66 | return 67 | } 68 | this.callbacks.track(event) 69 | stream.onremovetrack = (event): void => { 70 | this.callbacks.removetrack(event) 71 | if (event.target) { 72 | const streamId = (event.target as MediaStream).id 73 | const index = this.remoteConnectionIds.indexOf(streamId) 74 | if (-1 < index) { 75 | delete this.remoteConnectionIds[index] 76 | } 77 | } 78 | } 79 | if (-1 < this.remoteConnectionIds.indexOf(stream.id)) { 80 | return 81 | } 82 | this.remoteConnectionIds.push(stream.id) 83 | } 84 | } 85 | await this.setRemoteDescription(signalingMessage) 86 | stream.getTracks().filter((track) => { 87 | if (this.pc) { 88 | this.pc.addTrack(track, stream) 89 | } 90 | }) 91 | this.stream = stream 92 | await this.createAnswer(signalingMessage) 93 | this.sendAnswer() 94 | if (!this.options.skipIceCandidateEvent) { 95 | await this.onIceCandidate() 96 | } 97 | await this.waitChangeConnectionStateConnected() 98 | return stream 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/subscriber.ts: -------------------------------------------------------------------------------- 1 | import ConnectionBase from './base' 2 | 3 | /** 4 | * Role が "recvonly" の場合に Sora との WebRTC 接続を扱うクラス 5 | */ 6 | export default class ConnectionSubscriber extends ConnectionBase { 7 | /** 8 | * Sora へ接続するメソッド 9 | * 10 | * @example 11 | * ```typescript 12 | * const recvonly = connection.sendrecv("sora"); 13 | * await recvonly.connect(); 14 | * ``` 15 | * 16 | * @public 17 | */ 18 | // biome-ignore lint/suspicious/noConfusingVoidType: stream が なのでどうしようもない 19 | async connect(): Promise { 20 | await Promise.race([ 21 | this.multiStream().finally(() => { 22 | this.clearConnectionTimeout() 23 | this.clearMonitorSignalingWebSocketEvent() 24 | }), 25 | this.setConnectionTimeout(), 26 | this.monitorSignalingWebSocketEvent(), 27 | ]) 28 | this.monitorWebSocketEvent() 29 | this.monitorPeerConnectionState() 30 | } 31 | 32 | /** 33 | * マルチストリームで Sora へ接続するメソッド 34 | */ 35 | private async multiStream(): Promise { 36 | await this.disconnect() 37 | const ws = await this.getSignalingWebSocket(this.signalingUrlCandidates) 38 | const signalingMessage = await this.signaling(ws) 39 | await this.connectPeerConnection(signalingMessage) 40 | if (this.pc) { 41 | this.pc.ontrack = async (event): Promise => { 42 | const stream = event.streams[0] 43 | if (stream.id === 'default') { 44 | return 45 | } 46 | if (stream.id === this.connectionId) { 47 | return 48 | } 49 | const data = { 50 | 'stream.id': stream.id, 51 | id: event.track.id, 52 | label: event.track.label, 53 | enabled: event.track.enabled, 54 | kind: event.track.kind, 55 | muted: event.track.muted, 56 | readyState: event.track.readyState, 57 | } 58 | this.writePeerConnectionTimelineLog('ontrack', data) 59 | this.callbacks.track(event) 60 | stream.onremovetrack = (event): void => { 61 | this.callbacks.removetrack(event) 62 | if (event.target) { 63 | const streamId = (event.target as MediaStream).id 64 | const index = this.remoteConnectionIds.indexOf(streamId) 65 | if (-1 < index) { 66 | delete this.remoteConnectionIds[index] 67 | } 68 | } 69 | } 70 | if (-1 < this.remoteConnectionIds.indexOf(stream.id)) { 71 | return 72 | } 73 | this.remoteConnectionIds.push(stream.id) 74 | } 75 | } 76 | await this.setRemoteDescription(signalingMessage) 77 | await this.createAnswer(signalingMessage) 78 | this.sendAnswer() 79 | if (!this.options.skipIceCandidateEvent) { 80 | await this.onIceCandidate() 81 | } 82 | await this.waitChangeConnectionStateConnected() 83 | return 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare const __SORA_JS_SDK_VERSION__: string 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "ES2022", 5 | "strict": true, 6 | "declaration": true, 7 | "declarationDir": "./dist", 8 | "strictNullChecks": true, 9 | "importHelpers": true, 10 | "moduleResolution": "Bundler", 11 | "experimentalDecorators": true, 12 | "esModuleInterop": true, 13 | "allowSyntheticDefaultImports": true, 14 | "resolveJsonModule": true, 15 | "stripInternal": true, 16 | // 未使用のローカル変数をエラーにする 17 | "noUnusedLocals": true, 18 | // 未使用のパラメータをエラーにする 19 | "noUnusedParameters": true, 20 | // 関数の引数の型を厳密にチェックする 21 | "strictFunctionTypes": true, 22 | // プロパティの初期化を厳密にチェックする 23 | "strictPropertyInitialization": true, 24 | "newLine": "LF", 25 | "types": [], 26 | "lib": ["esnext", "dom", "dom.iterable", "scripthost"] 27 | }, 28 | "include": ["src/**/*.ts"], 29 | "exclude": ["node_modules", "tests"] 30 | } 31 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "entryPoints": ["./packages/sdk/src/sora.ts"], 3 | "tsconfig": "./packages/sdk/tsconfig.json", 4 | "disableSources": true, 5 | "excludePrivate": true, 6 | "excludeProtected": true, 7 | "readme": "./TYPEDOC.md", 8 | "out": "apidoc" 9 | } 10 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'node:path' 2 | import { defineConfig } from 'vite' 3 | import dts from 'vite-plugin-dts' 4 | import pkg from './package.json' 5 | 6 | const banner = `/** 7 | * ${pkg.name} 8 | * ${pkg.description} 9 | * @version: ${pkg.version} 10 | * @author: ${pkg.author} 11 | * @license: ${pkg.license} 12 | **/ 13 | ` 14 | export default defineConfig({ 15 | define: { 16 | __SORA_JS_SDK_VERSION__: JSON.stringify(pkg.version), 17 | }, 18 | root: process.cwd(), 19 | build: { 20 | minify: 'esbuild', 21 | target: 'es2022', 22 | emptyOutDir: true, 23 | manifest: false, 24 | outDir: resolve(__dirname, './dist'), 25 | lib: { 26 | entry: resolve(__dirname, 'src/sora.ts'), 27 | name: 'WebRTC SFU Sora JavaScript SDK', 28 | formats: ['es'], 29 | fileName: 'sora', 30 | }, 31 | rollupOptions: { 32 | output: { 33 | banner: banner, 34 | }, 35 | }, 36 | }, 37 | envDir: resolve(__dirname, './'), 38 | plugins: [ 39 | dts({ 40 | include: ['src/**/*'], 41 | }), 42 | ], 43 | }) 44 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { defineConfig, mergeConfig } from 'vitest/config' 4 | import viteConfig from './vite.config.js' 5 | 6 | export default mergeConfig( 7 | viteConfig, 8 | defineConfig({ 9 | test: { 10 | environment: 'jsdom', 11 | include: ['tests/**/*.ts'], 12 | }, 13 | }), 14 | ) 15 | --------------------------------------------------------------------------------