├── .editorconfig ├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ ├── develop-publish-gh-pages.yml │ ├── master-publish-gh-pages-and-npm.yml │ └── retarget-dependabot-to-develop.yml ├── .gitignore ├── .prettierrc.js ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE.txt ├── NOTICE.txt ├── README.md ├── aws-sdk-build ├── .gitignore ├── README.md ├── index.js ├── package-lock.json ├── package.json ├── rename-output-file.js └── webpack.config.js ├── eslint.config.mjs ├── eslint ├── index.js ├── package.json └── sorted-imports.js ├── examples ├── answerer.js ├── app.css ├── app.js ├── aws-sdk-3.758.0-kvswebrtc.js ├── channelHelper.js ├── createSignalingChannel.js ├── createStream.js ├── describeMediaStorageConfiguration.js ├── favicon.ico ├── index.html ├── joinStorageSession.js ├── joinStorageSessionAsViewer.js ├── listStorageChannels.js ├── loader.css ├── master.js ├── mediaHelper.js ├── updateMediaStorageConfiguration.js └── viewer.js ├── jest.config.js ├── license ├── bundleLicenseBanner.txt └── bundleLicenseHeader.txt ├── package-lock.json ├── package.json ├── src ├── QueryParams.ts ├── RequestSigner.ts ├── Role.ts ├── SigV4RequestSigner.spec.ts ├── SigV4RequestSigner.ts ├── SignalingClient.spec.ts ├── SignalingClient.ts ├── index.spec.ts ├── index.ts └── internal │ ├── DateProvider.ts │ ├── testUtils.ts │ ├── utils.spec.ts │ └── utils.ts ├── tsconfig.json ├── webpack.config.js ├── webpack.debug.config.js ├── webpack.dev.config.js └── webpack.dist.config.js /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | end_of_line = lf 3 | indent_size = 4 4 | indent_style = space 5 | trim_trailing_whitespace = true -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: / 5 | target-branch: develop 6 | schedule: 7 | interval: weekly 8 | labels: 9 | - dependencies 10 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI Action AWS KVS WebRTC JS SDK 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - develop 8 | pull_request: 9 | branches: 10 | - master 11 | - develop 12 | 13 | jobs: 14 | nodejs-ubuntu-build: 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | node: [ 18, 19, 20, 21, 22, 23 ] 19 | fail-fast: false 20 | steps: 21 | - name: Checkout repository 22 | uses: actions/checkout@master 23 | - name: Use Node.js 24 | uses: actions/setup-node@v4 25 | with: 26 | node-version: ${{ matrix.node }} 27 | - name: Install dependencies 28 | run: npm install 29 | - name: Run release 30 | run: npm run release 31 | 32 | dependabot-auto-merge-after-ci-passes: 33 | needs: nodejs-ubuntu-build 34 | runs-on: ubuntu-latest 35 | permissions: 36 | pull-requests: write 37 | contents: write 38 | if: >- 39 | github.actor == 'dependabot[bot]' && 40 | github.event_name == 'pull_request' && 41 | github.event.pull_request.base.ref == 'develop' 42 | env: 43 | PR_URL: ${{ github.event.pull_request.html_url }} 44 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 45 | steps: 46 | - name: Approve Dependabot PR 47 | run: gh pr review --approve "$PR_URL" 48 | - name: Auto-merge Dependabot PR 49 | run: gh pr merge --squash --auto "$PR_URL" 50 | -------------------------------------------------------------------------------- /.github/workflows/develop-publish-gh-pages.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Action WebRTC JS SDK 2 | 3 | on: 4 | push: 5 | branches: 6 | - develop 7 | jobs: 8 | publish-ubuntu-build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout repository 12 | uses: actions/checkout@master 13 | - name: Use Node.js 14 | uses: actions/setup-node@v2 15 | with: 16 | node-version: 22 17 | - name: Build AWS SDK 18 | working-directory: ./aws-sdk-build 19 | run: | 20 | npm install 21 | npm run build 22 | rm -rf ../examples/*-kvswebrtc.js 23 | mv dist/*-kvswebrtc.js ../examples 24 | - name: Install dependencies 25 | run: npm install 26 | - name: Run release 27 | run: npm run release 28 | - name: Deploy to Github pages 29 | uses: JamesIves/github-pages-deploy-action@v4.2.5 30 | with: 31 | folder: dist 32 | branch: gh-pages 33 | target-folder: develop 34 | clean: true 35 | -------------------------------------------------------------------------------- /.github/workflows/master-publish-gh-pages-and-npm.yml: -------------------------------------------------------------------------------- 1 | name: Deploy and Publish Action WebRTC JS SDK 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | jobs: 8 | publish-ubuntu-build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout repository 12 | uses: actions/checkout@master 13 | - name: Use Node.js 14 | uses: actions/setup-node@v2 15 | with: 16 | node-version: 22 17 | - name: Build AWS SDK 18 | working-directory: ./aws-sdk-build 19 | run: | 20 | npm install 21 | npm run build 22 | rm -rf ../examples/*-kvswebrtc.js 23 | mv dist/*-kvswebrtc.js ../examples 24 | - name: Install dependencies 25 | run: npm install 26 | - name: Run release 27 | run: npm run release 28 | - name: Deploy to Github pages 29 | uses: JamesIves/github-pages-deploy-action@v4.2.5 30 | with: 31 | folder: dist 32 | branch: gh-pages 33 | clean: true 34 | clean-exclude: develop/ 35 | - name: Delete extra directories 36 | run: | 37 | # Delete the examples 38 | rm -rf examples dist/examples 39 | # Remove the infra to build the AWS SDK v3 browser script 40 | rm -rf aws-sdk-build 41 | - name: Deploy to npm packages 42 | uses: JS-DevTools/npm-publish@v1 43 | with: 44 | token: '${{ secrets.NPM_TOKEN }}' 45 | -------------------------------------------------------------------------------- /.github/workflows/retarget-dependabot-to-develop.yml: -------------------------------------------------------------------------------- 1 | name: Retarget Dependabot PRs to develop 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | retarget-pull-request: 10 | runs-on: ubuntu-latest 11 | if: ${{ github.actor == 'dependabot[bot]' }} 12 | permissions: 13 | pull-requests: write 14 | contents: read 15 | env: 16 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 17 | PR_URL: ${{ github.event.pull_request.html_url }} 18 | steps: 19 | - name: Change base branch to develop 20 | run: gh pr edit "$PR_URL" --base develop 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | npm-debug.log* 3 | 4 | # Dependency directories 5 | node_modules/ 6 | 7 | # Optional npm cache directory 8 | .npm 9 | 10 | # Build files 11 | dist/ 12 | lib/ 13 | 14 | # Code coverage 15 | coverage/ 16 | 17 | # IDE 18 | .idea 19 | 20 | # Mac 21 | .DS_Store -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: true, 3 | trailingComma: "all", 4 | singleQuote: true, 5 | printWidth: 160, 6 | tabWidth: 4 7 | }; -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 4 | opensource-codeofconduct@amazon.com with any additional questions or comments. 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 4 | documentation, we greatly value feedback and contributions from our community. 5 | 6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 7 | information to effectively respond to your bug report or contribution. 8 | 9 | 10 | ## Reporting Bugs/Feature Requests 11 | 12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 13 | 14 | When filing an issue, please check existing open, or recently closed, issues to make sure somebody else hasn't already 15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 16 | 17 | * A reproducible test case or series of steps 18 | * The version of our code being used 19 | * Any modifications you've made relevant to the bug 20 | * Anything unusual about your environment or deployment 21 | 22 | 23 | ## Contributing via Pull Requests 24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 25 | 26 | 1. You are working against the latest source on the *master* branch. 27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 29 | 30 | To send us a pull request, please: 31 | 32 | 1. Fork the repository. 33 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 34 | 3. Ensure local tests pass. 35 | 4. Commit to your fork using clear commit messages. 36 | 5. Send us a pull request, answering any default questions in the pull request interface. 37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 38 | 39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 41 | 42 | 43 | ## Finding contributions to work on 44 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any 'help wanted' issues is a great place to start. 45 | 46 | 47 | ## Code of Conduct 48 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 49 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 50 | opensource-codeofconduct@amazon.com with any additional questions or comments. 51 | 52 | 53 | ## Security issue notifications 54 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. 55 | 56 | 57 | ## Licensing 58 | 59 | See the [LICENSE](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 60 | 61 | We may ask you to sign a [Contributor License Agreement (CLA)](http://en.wikipedia.org/wiki/Contributor_License_Agreement) for larger changes. 62 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. -------------------------------------------------------------------------------- /NOTICE.txt: -------------------------------------------------------------------------------- 1 | Amazon Kinesis Video Streams WebRTC SDK for JavaScript 2 | Copyright 2019-2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | 4 | This product includes software developed at 5 | Amazon Web Services, Inc. (http://aws.amazon.com/). -------------------------------------------------------------------------------- /aws-sdk-build/.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | -------------------------------------------------------------------------------- /aws-sdk-build/README.md: -------------------------------------------------------------------------------- 1 | # Building the browser script version of AWS SDK for JS v3 2 | 3 | This module bundles the **AWS SDK v3** clients into a single JavaScript file that can be imported in a ` 39 | ``` 40 | 41 | After that, the `AWS` object is globally available, just like AWS SDK v2. 42 | 43 | > [!NOTE] 44 | > AWS SDK for JS v3 uses different syntax than v2. Refer to the [documentation](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/client/kinesis-video/) for the syntax and usage. 45 | 46 | ## **Updating the AWS SDK for JS v3 to the latest** 47 | 48 | If you need to update the AWS SDK clients, modify `package.json` and run: 49 | 50 | ```sh 51 | npm update 52 | ``` 53 | 54 | Then, rebuild the bundle: 55 | 56 | ```sh 57 | npm run build 58 | ``` 59 | 60 | You can now move it to the `examples` to use it: 61 | ```shell 62 | mv ./dist/aws-sdk-*-kvswebrtc.js ../examples 63 | ``` 64 | 65 | > [!NOTE] 66 | > You will also need to modify the ` 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 |

KVS WebRTC Test Page

23 |

This is the KVS Signaling Channel WebRTC test page. Use this page to connect to a signaling channel as either the MASTER or as a VIEWER.

24 | 25 |
26 |
27 |
28 |

KVS Endpoint

29 |
30 | 31 | 32 | 33 |
34 |
35 |

AWS Credentials

36 |
37 | 38 | 39 |
40 |
41 | 42 | 43 |
44 |
45 | 46 | 47 |
48 |

Signaling Channel

49 |
50 | 51 |
52 | 53 |
54 | 55 |
56 |
57 |
58 |
59 | 60 | 64 | 65 |
66 |

Tracks

67 |

Control which media types are transmitted to the remote client. For WebRTC Ingestion and Storage master, both audio and video must be sent, and viewers cannot not send video and optional audio.

68 |
69 |
70 | 71 | 72 |
73 |
74 | 75 | 76 |
77 |
78 | 79 | 80 | 84 |
85 |
86 |
WebRTC Ingestion and Storage 87 |
88 |

Configure which stream to ingest and store media to. Call update media storage configuration with an empty Stream name to disable this feature.

89 |
90 | 91 |
92 | 93 |
94 |
95 | 96 |
97 |
98 |
99 | 100 |
101 |
102 | 103 |

List storage channels outputs the ARNs of all signaling channels configured for storage and their associated stream's ARN.

104 |
105 |
106 | 107 | 108 | 112 |
113 | 114 |
115 | 116 |
117 | 118 | 119 |
120 |
121 |
122 |
123 |
124 | 125 | 126 | 129 |
130 |
131 | 132 | 133 | 136 |
137 |
138 |
139 |
140 |

Video Resolution

141 |

Set the desired video resolution and aspect ratio.

142 |
143 |
144 | 145 | 146 |
147 |
148 | 149 | 150 |
151 |
152 |

NAT Traversal

153 |

Control settings for ICE candidate generation. 154 |

158 |
159 |
160 | 161 | 162 |
163 |
164 | 165 | 166 |
167 |
168 | 169 | 170 |
171 |
172 | 173 | 174 |
175 |
176 |
177 |
178 | 179 | 180 | 184 |
185 |
186 |

Amazon KVS WebRTC DQP

187 |
188 |
189 | 190 | 191 | 195 |
196 |
197 | 198 |

Amazon KVS WebRTC Profiling Timeline chart

199 |
200 |
201 | 202 | 203 | 207 |
208 |
209 | 210 |
Advanced 211 |

Filter settings for which ICE candidates are sent to and received from the peer.

212 |
213 |
214 |
215 |
216 | 217 | 218 |
219 |
220 | 221 | 222 |
223 |
224 | 225 | 226 |
227 |
228 | 229 | 230 |
231 |
232 | 233 | 234 |
235 |
236 | 237 | 238 |
239 |
240 |
241 |
242 | 243 | 244 |
245 |
246 | 247 | 248 |
249 |
250 | 251 | 252 |
253 |
254 | 255 | 256 |
257 |
258 | 259 | 260 |
261 |
262 | 263 | 264 |
265 |
266 |
267 |
268 |

Signaling reconnect

269 |
270 |
271 |
272 |
273 | 274 | 276 |
277 |
278 |
279 |
280 |

Logging

281 |
282 |
283 |
284 |
285 | 286 | 287 |
288 |
289 |
290 |
291 |

Filter received media types

293 |
294 |
295 |
296 | 297 | 298 |
299 |
300 | 301 |
302 |
303 | 309 |
310 |
311 |
Video Codecs Allowed
312 |
313 |
314 |
315 |
Audio Codecs Allowed
316 |
317 |
318 |
319 |
320 |

Endpoint Override

321 |
322 | 323 |
324 |
325 | 326 |
327 |
328 | 329 | 330 |
331 |
332 | 333 | 361 | 362 |
363 |

Master

364 |
365 |
366 |
Master Section
367 |
368 |
369 |
370 |
Viewer Return Channel
371 |
372 |
373 |
374 |
375 |
376 |
377 | 378 |
379 |
380 |
381 |
382 |

383 |                     
384 |
385 |
386 |
387 | 388 | 389 | 390 | 391 | 392 | 393 |
394 |
395 | 396 |
397 |

Viewer

398 |
399 |
400 |
Return Channel
401 |
402 |
403 |
404 |
From Master
405 |
406 |
407 |
408 |
409 |
410 |
411 | 412 |
413 |
414 |
415 |
416 |

417 |                     
418 |
419 |
420 |
421 | 422 | 423 | 424 | 425 |
426 |
427 | 428 |
429 |
430 |

DQP Test Metrics (from Master)

431 |
432 |
433 |
434 |
435 |
436 |
437 |
438 |
439 | 440 |
441 |
442 |
443 |

Live Stats (from Master)

444 |
445 |
446 |
447 |
448 |
449 |
450 |
451 |

452 |
453 |
454 |
455 |
456 |
457 |
458 | 459 |

Logs

460 |
461 |
462 |
463 | 464 | 465 | 466 | 467 |
468 |
469 | 470 | 471 | 472 |
473 | 476 |
477 |
478 |
479 |

480 |         
481 |
482 |
483 | 484 |
485 | 486 | 487 | 488 | 489 | 490 | 491 | 492 | 493 | 494 | 495 | 496 | 497 | 498 | 499 | 500 | 501 | 502 | 503 | -------------------------------------------------------------------------------- /examples/joinStorageSession.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This function calls joinStorageSession. 3 | */ 4 | async function joinStorageSessionManually(formValues) { 5 | $('#logs-header')[0].scrollIntoView({ 6 | block: 'start', 7 | }); 8 | 9 | try { 10 | console.log('[JOIN_STORAGE_SESSION] Calling JoinStorageSession for channel', formValues.channelName); 11 | 12 | // Create KVS client 13 | const kinesisVideoClient = new AWS.KinesisVideo.KinesisVideoClient({ 14 | region: formValues.region, 15 | credentials: { 16 | accessKeyId: formValues.accessKeyId, 17 | secretAccessKey: formValues.secretAccessKey, 18 | sessionToken: formValues.sessionToken, 19 | }, 20 | endpoint: formValues.endpoint, 21 | }); 22 | 23 | // Step 1: Obtain the ARN of the Signaling Channel 24 | const describeSignalingChannelResponse = await kinesisVideoClient 25 | .send(new AWS.KinesisVideo.DescribeSignalingChannelCommand({ 26 | ChannelName: formValues.channelName, 27 | })); 28 | const channelARN = describeSignalingChannelResponse.ChannelInfo.ChannelARN; 29 | 30 | // Step 2: Obtain the WEBRTC endpoint 31 | const getSignalingChannelEndpointResponse = await kinesisVideoClient 32 | .send(new AWS.KinesisVideo.GetSignalingChannelEndpointCommand({ 33 | ChannelARN: channelARN, 34 | SingleMasterChannelEndpointConfiguration: { 35 | Protocols: ['WEBRTC'], 36 | Role: KVSWebRTC.Role.MASTER, 37 | }, 38 | })); 39 | const webrtcEndpoint = getSignalingChannelEndpointResponse.ResourceEndpointList[0].ResourceEndpoint; 40 | 41 | const kinesisVideoWebRTCStorageClient = new AWS.KinesisVideoWebRTCStorage.KinesisVideoWebRTCStorageClient({ 42 | region: formValues.region, 43 | credentials: { 44 | accessKeyId: formValues.accessKeyId, 45 | secretAccessKey: formValues.secretAccessKey, 46 | sessionToken: formValues.sessionToken, 47 | }, 48 | endpoint: webrtcEndpoint, 49 | maxRetries: 0, 50 | httpOptions: { 51 | timeout: retryIntervalForJoinStorageSession, 52 | }, 53 | }); 54 | 55 | // Step 3. Call JoinStorageSession 56 | await kinesisVideoWebRTCStorageClient 57 | .send(new AWS.KinesisVideoWebRTCStorage.JoinStorageSessionCommand({ 58 | channelArn: channelARN, 59 | })); 60 | 61 | console.log('[JOIN_STORAGE_SESSION] Finished invoking JoinStorageSession for channel', formValues.channelName); 62 | } catch (e) { 63 | console.error('[JOIN_STORAGE_SESSION] Encountered error:', e); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /examples/joinStorageSessionAsViewer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This function calls joinStorageSessionAsViewer. 3 | */ 4 | async function joinStorageSessionAsViewerManually(formValues) { 5 | $('#logs-header')[0].scrollIntoView({ 6 | block: 'start', 7 | }); 8 | 9 | try { 10 | console.log('[JOIN_STORAGE_SESSION_AS_VIEWER] Calling JoinStorageSessionAsViewer for channel', formValues.channelName, 'and clientId', formValues.clientId); 11 | 12 | // Create KVS client 13 | const kinesisVideoClient = new AWS.KinesisVideo.KinesisVideoClient({ 14 | region: formValues.region, 15 | credentials: { 16 | accessKeyId: formValues.accessKeyId, 17 | secretAccessKey: formValues.secretAccessKey, 18 | sessionToken: formValues.sessionToken, 19 | }, 20 | endpoint: formValues.endpoint, 21 | }); 22 | 23 | // Step 1: Obtain the ARN of the Signaling Channel 24 | const describeSignalingChannelResponse = await kinesisVideoClient 25 | .send(new AWS.KinesisVideo.DescribeSignalingChannelCommand({ 26 | ChannelName: formValues.channelName, 27 | })); 28 | const channelARN = describeSignalingChannelResponse.ChannelInfo.ChannelARN; 29 | 30 | // Step 2: Obtain the WEBRTC endpoint 31 | const getSignalingChannelEndpointResponse = await kinesisVideoClient 32 | .send(new AWS.KinesisVideo.GetSignalingChannelEndpointCommand({ 33 | ChannelARN: channelARN, 34 | SingleMasterChannelEndpointConfiguration: { 35 | Protocols: ['WEBRTC'], 36 | Role: KVSWebRTC.Role.VIEWER, 37 | }, 38 | })); 39 | const webrtcEndpoint = getSignalingChannelEndpointResponse.ResourceEndpointList[0].ResourceEndpoint; 40 | 41 | const kinesisVideoWebRTCStorageClient = new AWS.KinesisVideoWebRTCStorage.KinesisVideoWebRTCStorageClient({ 42 | region: formValues.region, 43 | credentials: { 44 | accessKeyId: formValues.accessKeyId, 45 | secretAccessKey: formValues.secretAccessKey, 46 | sessionToken: formValues.sessionToken, 47 | }, 48 | endpoint: webrtcEndpoint, 49 | maxRetries: 0, 50 | httpOptions: { 51 | timeout: retryIntervalForJoinStorageSession, 52 | }, 53 | logger: formValues.logAwsSdkCalls ? console : undefined, 54 | }); 55 | 56 | // Step 3. Call JoinStorageSessionAsViewer 57 | await kinesisVideoWebRTCStorageClient 58 | .send(new AWS.KinesisVideoWebRTCStorage.JoinStorageSessionAsViewerCommand({ 59 | channelArn: channelARN, 60 | clientId: formValues.clientId, 61 | })); 62 | 63 | console.log('[JOIN_STORAGE_SESSION_AS_VIEWER] Finished invoking JoinStorageSessionAsViewer for channel', formValues.channelName, 'and clientId', formValues.clientId); 64 | } catch (e) { 65 | console.error('[JOIN_STORAGE_SESSION_AS_VIEWER] Encountered error:', e); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /examples/listStorageChannels.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This function lists all storage-configured signaling channel ARNs and their associated stream's ARN. 3 | */ 4 | async function listStorageChannels(formValues) { 5 | $('#logs-header')[0].scrollIntoView({ 6 | block: 'start', 7 | }); 8 | 9 | try { 10 | console.log('[LIST_STORAGE_CHANNELS] Attempting to list all storage-configured signaling channels and their associated stream'); 11 | 12 | // Create KVS client 13 | const kinesisVideoClient = new AWS.KinesisVideo.KinesisVideoClient({ 14 | region: formValues.region, 15 | credentials: { 16 | accessKeyId: formValues.accessKeyId, 17 | secretAccessKey: formValues.secretAccessKey, 18 | sessionToken: formValues.sessionToken, 19 | }, 20 | endpoint: formValues.endpoint, 21 | logger: formValues.logAwsSdkCalls ? console : undefined, 22 | }); 23 | 24 | // Get all signaling channels 25 | const result = await kinesisVideoClient.send(new AWS.KinesisVideo.ListSignalingChannelsCommand()); 26 | const allChannels = result.ChannelInfoList; 27 | 28 | // Grab channel ARNs 29 | const allChannelARNs = allChannels.map(channel => { 30 | return channel.ChannelARN; 31 | }); 32 | 33 | let progressCounter = 0; 34 | const output = []; 35 | // Print channel ARN and its storage stream ARN if media storage is enabled for the channel 36 | for (const channelARN of allChannelARNs) { 37 | const request = { 38 | ChannelARN: channelARN, 39 | }; 40 | const storageResult = await kinesisVideoClient.send(new AWS.KinesisVideo.DescribeMediaStorageConfigurationCommand(request)); 41 | if (storageResult.MediaStorageConfiguration.Status === 'ENABLED') { 42 | output.push({ 43 | ChannelARN: channelARN, 44 | StreamARN: storageResult.MediaStorageConfiguration.StreamARN, 45 | }); 46 | } 47 | console.log('[LIST_STORAGE_CHANNELS] Progress:', ++progressCounter, '/', allChannelARNs.length); 48 | await new Promise(res => setTimeout(res, 500)); // To avoid getting rate limited 49 | } 50 | 51 | console.log('[LIST_STORAGE_CHANNELS] You have', output.length, 'channels configured for storage:'); 52 | console.log('[LIST_STORAGE_CHANNELS]', output); 53 | } catch (e) { 54 | console.error('[LIST_STORAGE_CHANNELS] Encountered error:', e); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /examples/loader.css: -------------------------------------------------------------------------------- 1 | .loader, 2 | .loader:after { 3 | border-radius: 50%; 4 | width: 10em; 5 | height: 10em; 6 | } 7 | 8 | .loader { 9 | margin: 60px auto; 10 | font-size: 10px; 11 | position: relative; 12 | text-indent: -9999em; 13 | border-top: 1.1em solid rgba(0, 0, 0, 0.2); 14 | border-right: 1.1em solid rgba(0, 0, 0, 0.2); 15 | border-bottom: 1.1em solid rgba(0, 0, 0, 0.2); 16 | border-left: 1.1em solid #000000; 17 | -webkit-transform: translateZ(0); 18 | -ms-transform: translateZ(0); 19 | transform: translateZ(0); 20 | -webkit-animation: load8 1.1s infinite linear; 21 | animation: load8 1.1s infinite linear; 22 | } 23 | @-webkit-keyframes load8 { 24 | 0% { 25 | -webkit-transform: rotate(0deg); 26 | transform: rotate(0deg); 27 | } 28 | 100% { 29 | -webkit-transform: rotate(360deg); 30 | transform: rotate(360deg); 31 | } 32 | } 33 | @keyframes load8 { 34 | 0% { 35 | -webkit-transform: rotate(0deg); 36 | transform: rotate(0deg); 37 | } 38 | 100% { 39 | -webkit-transform: rotate(360deg); 40 | transform: rotate(360deg); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /examples/master.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file demonstrates the process of starting WebRTC streaming using a KVS Signaling Channel. 3 | */ 4 | const masterDefaults = { 5 | kinesisVideoClient: null, 6 | signalingClient: null, 7 | storageClient: null, 8 | channelARN: null, 9 | peerByClientId: {}, 10 | localStream: null, 11 | peerConnectionStatsInterval: null, 12 | runId: 0, 13 | sdpOfferReceived: false, 14 | websocketOpened: false, 15 | connectionFailures: [], // Dates of when PeerConnection transitions to failed state. 16 | currentJoinStorageSessionRetries: 0, 17 | turnServerExpiryTs: 0, // Epoch millis when the TURN servers expire minus grace period 18 | iceServers: [], // Cached list of ICE servers (STUN and TURN, depending on formValues) 19 | reopenChannelCallback: null, 20 | }; 21 | 22 | let master = {}; 23 | 24 | const ingestionWithMultiViewerSupportPreviewRegions = ['us-east-1']; 25 | 26 | /** 27 | * Base milliseconds between retries of joinStorageSession API calls. 28 | * @constant 29 | * @type {number} 30 | * @default 31 | */ 32 | const retryIntervalForJoinStorageSession = 6000; 33 | 34 | /** 35 | * Seconds to start refreshing the TURN servers before the credentials expire 36 | * @constant 37 | * @type {number} 38 | * @default 39 | */ 40 | const iceServerRefreshGracePeriodSec = 15; 41 | 42 | /** 43 | * Maximum number of times we will attempt to establish Peer connection (perform 44 | * ICE connectivity checks) with the storage session within a ten-minute window 45 | * before exiting the application. This means, we have received this many SDP 46 | * offers within a 10-minute window, and, for all of them, the peer connection failed 47 | * to be established. 48 | * @constant 49 | * @type {number} 50 | * @default 51 | */ 52 | const maxConnectionFailuresWithinTenMinutesForRetries = 5; 53 | const maxAPICallRetriesPerConnectionAttempt = 5; 54 | 55 | const millisecondsInTenMinutes = 600_000; 56 | 57 | async function startMaster(localView, remoteView, formValues, onStatsReport, onRemoteDataMessage) { 58 | const role = ROLE; 59 | master = {...masterDefaults}; 60 | master.clientId = formValues.clientId; 61 | 62 | try { 63 | master.localView = localView; 64 | master.remoteView = remoteView; 65 | 66 | // Determine the media ingestion mode 67 | let ingestionMode = ChannelHelper.IngestionMode.OFF; 68 | if (formValues.autoDetermineMediaIngestMode) { 69 | ingestionMode = ChannelHelper.IngestionMode.DETERMINE_THROUGH_DESCRIBE; 70 | } else if (formValues.mediaIngestionModeOverride) { 71 | ingestionMode = ChannelHelper.IngestionMode.ON; 72 | } 73 | 74 | master.channelHelper = channelHelper || new ChannelHelper( 75 | formValues.channelName, 76 | { 77 | region: formValues.region, 78 | credentials: { 79 | accessKeyId: formValues.accessKeyId, 80 | secretAccessKey: formValues.secretAccessKey, 81 | sessionToken: formValues.sessionToken, 82 | }, 83 | }, 84 | formValues.endpoint, 85 | role, 86 | ingestionMode, 87 | `[${role}]`, 88 | role === 'VIEWER' ? formValues.clientId : undefined, 89 | formValues.logAwsSdkCalls ? console : undefined, 90 | ); 91 | 92 | await master.channelHelper.init(); 93 | 94 | if (master.channelHelper.isIngestionEnabled()) { 95 | if (role === 'MASTER' && (!formValues.sendAudio || !formValues.sendVideo)) { 96 | console.error(`[MASTER] Both Send Video and Send Audio checkboxes need to be checked to ingest and store media.`); 97 | return; 98 | } else if (role === 'VIEWER' && formValues.sendVideo) { 99 | console.warn(`[VIEWER] Not allowed to send video. Overriding to false!`); 100 | formValues.sendVideo = false; 101 | } 102 | 103 | if (formValues.openDataChannel) { 104 | console.warn(`[${role}] DataChannel is not supported for WebRTC ingestion. Overriding value to false.`); 105 | formValues.openDataChannel = false; 106 | $('.datachannel').addClass('d-none'); 107 | } 108 | 109 | } else { 110 | console.log(`[${role}] Not using media ingestion feature.`); 111 | } 112 | 113 | // Kickoff fetching ICE servers 114 | getIceServersWithCaching(formValues); 115 | 116 | // Get a stream from the webcam and display it in the local view. 117 | // If no video/audio needed, no need to request for the sources. 118 | // Otherwise, the browser will throw an error saying that either video or audio has to be enabled. 119 | if (formValues.sendVideo || formValues.sendAudio) { 120 | let type; 121 | if (formValues.sendVideo && formValues.sendAudio) { 122 | type = MediaHelper.MediaRequestType.AUDIO_AND_VIDEO; 123 | } else if (formValues.sendVideo) { 124 | type = MediaHelper.MediaRequestType.VIDEO_ONLY; 125 | } else { 126 | type = MediaHelper.MediaRequestType.AUDIO_ONLY; 127 | } 128 | 129 | master.localStream = await MediaHelper.requestCamera(type, formValues.widescreen ? 1280 : 640, formValues.widescreen ? 720 : 480) 130 | localView.srcObject = master.localStream; 131 | 132 | if (!master.localStream) { 133 | $('#stop-master-button').click(); 134 | return; 135 | } 136 | } 137 | 138 | registerMasterSignalingClientCallbacks(master.channelHelper.getSignalingClient(), formValues, onStatsReport, onRemoteDataMessage); 139 | console.log(`[${role}] Starting ${role.toLowerCase()} connection`); 140 | master.channelHelper.getSignalingClient().open(); 141 | } catch (e) { 142 | console.error(`[${role}] Encountered error starting:`, e); 143 | $('#stop-master-button').click(); 144 | } 145 | } 146 | 147 | registerMasterSignalingClientCallbacks = (signalingClient, formValues, onStatsReport, onRemoteDataMessage) => { 148 | const role = ROLE; 149 | 150 | signalingClient.on('open', async () => { 151 | const runId = ++master.runId; 152 | master.websocketOpened = true; 153 | const signalingConnected = new Date(); 154 | console.debug(`[${role}] ConnectAs${role[0].toUpperCase() + role.slice(1).toLowerCase()} completed at`, signalingConnected); 155 | console.log(`[${role}] Connected to signaling service`); 156 | console.log( 157 | `[${role}] Time to connect to signaling:`, 158 | signalingConnected.getTime() - master.channelHelper.getSignalingConnectionLastStarted().getTime(), 159 | 'ms', 160 | ); 161 | 162 | if (!formValues.autoDetermineMediaIngestMode && role === 'MASTER' && formValues.mediaIngestionModeOverride && formValues.showJSSButton) { 163 | $('#join-storage-session-button').removeClass('d-none'); 164 | console.log(`[MASTER] Waiting for media ingestion and storage peer to join... (click the button!)`); 165 | } else if (!formValues.autoDetermineMediaIngestMode && role === 'VIEWER' && formValues.mediaIngestionModeOverride && formValues.showJSSAsViewerButton) { 166 | $('#join-storage-session-as-viewer-button').removeClass('d-none'); 167 | console.log(`[VIEWER] Waiting for media ingestion and storage peer to join... (click the button!)`); 168 | } else if (master.channelHelper.isIngestionEnabled()) { 169 | if (role === 'VIEWER' && !ingestionWithMultiViewerSupportPreviewRegions.includes(formValues.region) && !formValues.endpoint) { 170 | console.error( 171 | `WebRTC ingestion with multi-viewer support is not supported in ${ 172 | formValues.region 173 | }. It is available for preview in ${ingestionWithMultiViewerSupportPreviewRegions.join(',')}!`, 174 | ); 175 | onStop(); 176 | return; 177 | } 178 | await connectToMediaServer(runId, master.channelHelper.getChannelArn(), master.channelHelper.getWebRTCStorageClient()); 179 | } else { 180 | console.log(`[${role}] Waiting for peers to join...`); 181 | } 182 | }); 183 | 184 | signalingClient.on('sdpOffer', async (offer, remoteClientId) => { 185 | console.log(`[${role}] Received SDP offer from`, remoteClientId || 'remote'); 186 | master.sdpOfferReceived = true; 187 | master.currentJoinStorageSessionRetries = 0; 188 | console.debug('SDP offer:', offer); 189 | 190 | // Close the previous peer connection in case peer with the same clientId sends another one 191 | if (master.peerByClientId[remoteClientId] && master.peerByClientId[remoteClientId].getPeerConnection().connectionState !== 'closed') { 192 | master.peerByClientId[remoteClientId].close(); 193 | console.log(`[${role}] Close previous connection`); 194 | } 195 | 196 | const configuration = { 197 | iceServers: await getIceServersWithCaching(formValues), 198 | iceTransportPolicy: formValues.forceTURN ? 'relay' : 'all', 199 | }; 200 | 201 | const answerer = new Answerer( 202 | configuration, 203 | master.localStream, 204 | offer, 205 | remoteClientId, 206 | master.channelHelper.getSignalingClient(), 207 | formValues.useTrickleICE, 208 | formValues.openDataChannel, 209 | `[${role}]`, 210 | iceCandidate => shouldSendIceCandidate(formValues, iceCandidate), 211 | iceCandidate => shouldAcceptCandidate(formValues, iceCandidate), 212 | mediaStreams => addViewerMediaStreamToMaster(remoteClientId, mediaStreams[0]), 213 | dataChannelMessage => onRemoteDataMessage(dataChannelMessage), 214 | ); 215 | 216 | await answerer.init(); 217 | 218 | master.peerByClientId[remoteClientId] = answerer; 219 | 220 | answerer.getPeerConnection().addEventListener('connectionstatechange', async event => { 221 | printPeerConnectionStateInfo(event, `[${role}]`, remoteClientId); 222 | 223 | if (master.channelHelper.isIngestionEnabled() && event.target.connectionState === 'connected') { 224 | if (role === 'MASTER') { 225 | console.log( 226 | `[MASTER] Successfully joined the storage session. Media is being recorded to`, 227 | master.channelHelper.getStreamArn() ?? 'Kinesis Video Streams', 228 | ); 229 | } else { 230 | console.log( 231 | `[VIEWER] Successfully joined the storage session. If master is present, media will be recorded to`, 232 | master.channelHelper.getStreamArn() ?? 'Kinesis Video Streams', 233 | ); 234 | } 235 | } 236 | }); 237 | 238 | // If in WebRTC ingestion mode, retry if no connection was established within 5 seconds. 239 | if (master.channelHelper.isIngestionEnabled()) { 240 | setTimeout(function () { 241 | // We check that it's not failed because if the state transitioned to failed, 242 | // the state change callback would handle this already 243 | if ( 244 | answerer.getPeerConnection().connectionState !== 'connected' && 245 | answerer.getPeerConnection().connectionState !== 'failed' && 246 | answerer.getPeerConnection().connectionState !== 'closed' 247 | ) { 248 | console.error(`[${role}] Connection failed to establish within 5 seconds. Retrying...`); 249 | onPeerConnectionFailed(remoteClientId, false, false); 250 | } 251 | }, 5000); 252 | } 253 | }); 254 | 255 | signalingClient.on('statusResponse', statusResponse => { 256 | if (statusResponse.success) { 257 | return; 258 | } 259 | console.error(`[${role}] Received response from Signaling:`, statusResponse); 260 | 261 | if (master.channelHelper.isIngestionEnabled()) { 262 | console.error(`[${role}] Encountered a fatal error. Stopping the application.`); 263 | $('#stop-master-button').click(); 264 | } 265 | }); 266 | 267 | signalingClient.on('close', () => { 268 | master.websocketOpened = false; 269 | master.runId++; 270 | console.log(`[${role}] Disconnected from signaling channel`); 271 | }); 272 | 273 | signalingClient.on('error', error => { 274 | console.error(`[${role}] Signaling client error`, error); 275 | }); 276 | 277 | if (formValues.signalingReconnect && !master.channelHelper?.isIngestionEnabled()) { 278 | master.reopenChannelCallback = () => { 279 | console.log(`[${role}] Automatically reconnecting to signaling channel`); 280 | signalingClient.open(); 281 | }; 282 | 283 | signalingClient.on('close', master.reopenChannelCallback); 284 | } 285 | }; 286 | 287 | function onPeerConnectionFailed(remoteClientId, printLostConnectionLog = true, hasConnectedAlready = true) { 288 | const role = ROLE; 289 | if (master?.channelHelper.isIngestionEnabled()) { 290 | if (printLostConnectionLog) { 291 | console.warn(`[${ROLE}] Lost connection to the storage session.`); 292 | } 293 | master?.connectionFailures?.push(new Date().getTime()); 294 | if (hasConnectedAlready && role === 'VIEWER') { 295 | $('#stop-master-button').click(); 296 | return; 297 | } 298 | if (shouldStopRetryingJoinStorageSession()) { 299 | console.error( 300 | `[${role}] Stopping the application after`, 301 | maxConnectionFailuresWithinTenMinutesForRetries, 302 | `failed attempts to connect to the storage session within a 10-minute interval [${master?.connectionFailures 303 | .map(date => new Date(date)) 304 | .join(', ')}]. Exiting the application.`, 305 | ); 306 | $('#stop-master-button').click(); 307 | return; 308 | } 309 | 310 | console.warn(`[${role}] Reconnecting...`); 311 | 312 | master.sdpOfferReceived = false; 313 | if (!master.websocketOpened) { 314 | const channelHelper = master.channelHelper; 315 | if (channelHelper) { 316 | console.log(`[${role}] Websocket is closed. Reopening...`); 317 | channelHelper.getSignalingClient().open(); 318 | } 319 | } else { 320 | connectToMediaServer(++master.runId); 321 | } 322 | } else if (master.channelHelper) { 323 | master.peerByClientId[remoteClientId]?.close(); 324 | delete master.peerByClientId[remoteClientId]; 325 | } 326 | } 327 | 328 | /** 329 | * Fetches ICE servers, caching them to prevent redundant API calls. 330 | * If the cached TURN servers are still valid, it returns them instead of making a new request. 331 | * @param {Object} formValues - Configuration settings from the UI. 332 | * @param {boolean} formValues.natTraversalDisabled - No ICE (STUN or TURN) servers at all setting. 333 | * @param {boolean} formValues.forceTURN - TURN servers only setting. 334 | * @param {boolean} formValues.forceSTUN - STUN servers only setting. 335 | * @param {boolean} formValues.sendSrflxCandidates - Send STUN candidates setting. 336 | * @param {boolean} formValues.sendRelayCandidates - Send TURN candidates setting. 337 | * @param {string} formValues.region - AWS region used to construct the STUN server URL. 338 | * @returns {Promise} List of ICE servers. 339 | */ 340 | async function getIceServersWithCaching(formValues) { 341 | const role = ROLE; 342 | 343 | // Check if cached TURN servers are still valid 344 | if (Date.now() < master.turnServerExpiryTs) { 345 | return master.iceServers; 346 | } 347 | console.log(`[${role}]`, 'Fetch new ICE servers'); 348 | 349 | /** @type {RTCIceServer[]} */ 350 | const iceServers = []; 351 | 352 | // Add the STUN server unless it is disabled 353 | if (!formValues.natTraversalDisabled && !formValues.forceTURN && formValues.sendSrflxCandidates) { 354 | iceServers.push({ urls: `stun:stun.kinesisvideo.${formValues.region}.amazonaws.com:443` }); 355 | } 356 | 357 | // Add the TURN servers unless it is disabled 358 | if (!formValues.natTraversalDisabled && !formValues.forceSTUN && formValues.sendRelayCandidates) { 359 | const [turnServers, turnServerExpiryTsMillis] = await master.channelHelper.fetchTurnServers(); 360 | master.turnServerExpiryTs = turnServerExpiryTsMillis - iceServerRefreshGracePeriodSec * 1000; 361 | iceServers.push(...turnServers); 362 | } 363 | console.log(`[${role}]`, 'ICE servers:', iceServers); 364 | 365 | master.iceServers = iceServers; 366 | return master.iceServers; 367 | } 368 | 369 | function stopMaster() { 370 | const role = ROLE; 371 | try { 372 | console.log(`[${role}] Stopping ${role} connection`); 373 | master.sdpOfferReceived = true; 374 | 375 | // Remove the callback that reopens the connection on 'close' before attempting to close the connection 376 | if (master.reopenChannelCallback) { 377 | master.channelHelper?.getSignalingClient()?.removeListener('close', master.reopenChannelCallback); 378 | } 379 | master.channelHelper?.getSignalingClient()?.close(); 380 | 381 | Object.keys(master.peerByClientId).forEach(clientId => { 382 | master.peerByClientId[clientId].close(); 383 | removeViewerTrackFromMaster(clientId); 384 | delete master.peerByClientId[clientId]; 385 | }); 386 | 387 | if (master.localStream) { 388 | master.localStream.getTracks().forEach(track => track.stop()); 389 | master.localStream = null; 390 | } 391 | 392 | if (master.localView) { 393 | master.localView.srcObject = null; 394 | } 395 | 396 | if (master.peerConnectionStatsInterval) { 397 | clearInterval(master.peerConnectionStatsInterval); 398 | master.peerConnectionStatsInterval = null; 399 | } 400 | 401 | if (master.remoteView) { 402 | master.remoteView.srcObject = null; 403 | } 404 | 405 | master = {}; 406 | } catch (e) { 407 | console.error(`[${role}] Encountered error stopping`, e); 408 | } 409 | } 410 | 411 | function sendMasterMessage(message) { 412 | const role = ROLE; 413 | if (message === '') { 414 | console.warn(`[${role}] Trying to send an empty message?`); 415 | return false; 416 | } 417 | if (Object.values(master.peerByClientId).filter(answerer => answerer.isDataChannelOpen()).length === 0) { 418 | console.warn(`[${role}] No one to send it to!`); 419 | return false; 420 | } 421 | 422 | let sent = false; 423 | for (const [clientId, answerer] of Object.entries(master.peerByClientId)) { 424 | try { 425 | answerer.sendDataChannelMessage(message); 426 | console.log(`[${role}]`, 'Sent', message, 'to', clientId); 427 | sent = true; 428 | } catch (e) { 429 | console.error(`[${role}]`, 'Send DataChannel:', e.toString()); 430 | } 431 | } 432 | return sent; 433 | } 434 | 435 | /** 436 | * Only applicable for WebRTC ingestion. 437 | *

438 | * Calls JoinStorageSession API every {@link retryInterval} until an SDP offer is received over the signaling channel. 439 | * Since JoinStorageSession is an asynchronous API, there is a chance that even though 200 OK is received, 440 | * no message is sent on the websocket. 441 | *

442 | * We will keep retrying JoinStorageSession until any of the items happens: 443 | * * SDP offer is received (success) 444 | * * Stop master button is clicked 445 | * * Non-retryable error is encountered (e.g. auth error) 446 | * * Websocket closes (times out after 10 minutes of inactivity). In this case, we reopen the Websocket and try again. 447 | * @param runId The current run identifier. If {@link master.runId} is different, we stop retrying. 448 | * @param kinesisVideoWebrtcStorageClient Kinesis Video Streams WebRTC Storage client. 449 | * @param channelARN The ARN of the signaling channel. It must have MediaStorage ENABLED. 450 | * @returns {Promise} true if successfully joined, and sdp offer was received. false if not; this includes 451 | * when the {@link master.runId} is incremented during a retry attempt. 452 | */ 453 | async function callJoinStorageSessionUntilSDPOfferReceived(runId) { 454 | let firstTime = true; // Used for log messages 455 | let shouldRetryCallingJoinStorageSession = true; 456 | while (shouldRetryCallingJoinStorageSession && !master.sdpOfferReceived && master.runId === runId && master.websocketOpened) { 457 | if (!firstTime) { 458 | console.warn(`Did not receive SDP offer from Media Service. Retrying... (${++master.currentJoinStorageSessionRetries})`); 459 | } 460 | firstTime = false; 461 | try { 462 | // The AWS SDK for JS will perform limited retries on this API call. 463 | if (ROLE === 'MASTER') { 464 | await master.channelHelper 465 | .getWebRTCStorageClient() 466 | .send(new AWS.KinesisVideoWebRTCStorage.JoinStorageSessionCommand({ 467 | channelArn: master.channelHelper.getChannelArn(), 468 | })); 469 | } else { 470 | await master.channelHelper 471 | .getWebRTCStorageClient() 472 | .send(new AWS.KinesisVideoWebRTCStorage.JoinStorageSessionAsViewerCommand({ 473 | channelArn: master.channelHelper.getChannelArn(), 474 | clientId: master.clientId, 475 | })); 476 | } 477 | } catch (e) { 478 | console.error(e); 479 | // We should only retry on ClientLimitExceededException, or internal failure. All other 480 | // cases e.g. IllegalArgumentException we should not retry. 481 | shouldRetryCallingJoinStorageSession = ( 482 | // ClientLimitExceededException is thrown for hitting TPS limit (rate limit), 483 | // but also for hitting the maximum number of viewers in a session. For TPS limit, we want to retry. 484 | // But for maximum number of viewers, we should not automatically retry. 485 | e.code === 'ClientLimitExceededException' && !e.message?.toLowerCase().includes('maximum number of viewers connected to the session') || 486 | // We should retry if the device loses connectivity 487 | e.code === 'NetworkingError' || 488 | // We should retry if the request to the service timed out 489 | e.code === 'TimeoutError' || 490 | // We should retry if there's an internal error 491 | e.statusCode === 500 492 | ); 493 | } 494 | shouldRetryCallingJoinStorageSession = shouldRetryCallingJoinStorageSession && master.currentJoinStorageSessionRetries <= maxAPICallRetriesPerConnectionAttempt; 495 | await new Promise(resolve => setTimeout(resolve, calculateJoinStorageSessionDelayMilliseconds())); 496 | } 497 | return shouldRetryCallingJoinStorageSession && master.runId === runId && master.websocketOpened; 498 | } 499 | 500 | async function connectToMediaServer(runId) { 501 | const role = ROLE; 502 | console.log(`[${role}]`, `Joining storage session${role === 'VIEWER' ? ' as viewer' : ''}...`); 503 | const success = await callJoinStorageSessionUntilSDPOfferReceived(runId); 504 | if (success) { 505 | console.log(`[${role}]`, `Join storage session ${role === 'VIEWER' ? 'as viewer ' : ''}API call(s) completed.`); 506 | } else if (runId === master.runId) { 507 | console.error(`[${role}]`, `Error joining storage session${role === 'VIEWER' ? ' as viewer' : ''}`); 508 | $('#stop-master-button').click(); 509 | } else if (!master.websocketOpened && !master.sdpOfferReceived) { 510 | // TODO: ideally, we send a ping message. But, that's unavailable in browsers. 511 | const signalingClient = master.channelHelper?.getSignalingClient(); 512 | if (signalingClient) { 513 | console.log(`[${role}]`, 'Websocket is closed. Reopening...'); 514 | signalingClient.open(); 515 | } 516 | } 517 | } 518 | 519 | /** 520 | * Check if we should stop retrying join storage session. 521 | * @returns {boolean} true if we exhausted the retries within a ten-minute window. false if we can continue retrying. 522 | */ 523 | function shouldStopRetryingJoinStorageSession() { 524 | const tenMinutesAgoEpochMillis = new Date().getTime() - millisecondsInTenMinutes; 525 | 526 | let front = master.connectionFailures[0]; 527 | while (front && front < tenMinutesAgoEpochMillis) { 528 | master.connectionFailures.shift(); 529 | front = master.connectionFailures[0]; 530 | } 531 | 532 | return master.connectionFailures.length >= maxConnectionFailuresWithinTenMinutesForRetries; 533 | } 534 | 535 | /** 536 | * The delay between joinStorageSession retries (in milliseconds) is 537 | * retryIntervalForJoinStorageSession + min(rand(0, 1) * 200 * (currentRetryNumber)^2, 10_000) 538 | * @returns {number} How long to wait between joinStorageSession retries, in milliseconds 539 | */ 540 | function calculateJoinStorageSessionDelayMilliseconds() { 541 | return retryIntervalForJoinStorageSession + Math.min(Math.random() * Math.pow(200, master.currentJoinStorageSessionRetries - 1), 10_000); 542 | } 543 | -------------------------------------------------------------------------------- /examples/mediaHelper.js: -------------------------------------------------------------------------------- 1 | class MediaHelper { 2 | // Constants for different media request types 3 | static MediaRequestType = { 4 | VIDEO_ONLY: 1, 5 | AUDIO_AND_VIDEO: 2, 6 | AUDIO_ONLY: 3, 7 | }; 8 | 9 | // Async function to request camera access. 10 | // idealWidthPx and idealHeightPx is only used if VIDEO 11 | // or AUDIO_AND_VIDEO is requested. 12 | // Returns a MediaStream upon success. Otherwise, null. 13 | static async requestCamera(requestType = this.MediaRequestType.AUDIO_AND_VIDEO, idealWidthPx = 640, idealHeightPx = 480) { 14 | const resolution = { 15 | width: {ideal: idealWidthPx}, 16 | height: {ideal: idealHeightPx}, 17 | }; 18 | 19 | let constraints; 20 | switch (requestType) { 21 | case this.MediaRequestType.VIDEO_ONLY: 22 | constraints = {video: resolution}; 23 | break; 24 | case this.MediaRequestType.AUDIO_AND_VIDEO: 25 | constraints = {video: resolution, audio: true, frameRate: { min: 10, max: 10 }}; 26 | break; 27 | case this.MediaRequestType.AUDIO_ONLY: 28 | constraints = {audio: true}; 29 | break; 30 | default: 31 | throw `requestCamera(): Unhandled case: ${requestType}!`; 32 | } 33 | 34 | try { 35 | return await navigator.mediaDevices.getUserMedia(constraints); 36 | } catch (e) { 37 | console.error(`Could not find ${Object.keys(constraints)} input device.`, e); 38 | return null; 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /examples/updateMediaStorageConfiguration.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This function updates the media storage configuration. 3 | */ 4 | async function updateMediaStorageConfiguration(formValues) { 5 | $('#logs-header')[0].scrollIntoView({ 6 | block: 'start', 7 | }); 8 | 9 | try { 10 | console.log( 11 | '[UPDATE_MEDIA_STORAGE_CONFIGURATION] Attempting to update media storage configuration to have media from', 12 | formValues.channelName, 13 | formValues.streamName ? '' : 'not', 14 | 'to be ingested and stored', 15 | formValues.streamName ? ' in ' + formValues.streamName : '', 16 | ); 17 | 18 | // Create KVS client 19 | const kinesisVideoClient = new AWS.KinesisVideo.KinesisVideoClient({ 20 | region: formValues.region, 21 | credentials: { 22 | accessKeyId: formValues.accessKeyId, 23 | secretAccessKey: formValues.secretAccessKey, 24 | sessionToken: formValues.sessionToken, 25 | }, 26 | endpoint: formValues.endpoint, 27 | }); 28 | 29 | if (formValues.streamName) { 30 | // We want to update the media storage configuration 31 | 32 | // First, grab the Stream ARN 33 | const describeStreamResponse = await kinesisVideoClient.send(new AWS.KinesisVideo.DescribeStreamCommand({ StreamName: formValues.streamName })); 34 | const streamARN = describeStreamResponse.StreamInfo.StreamARN; 35 | 36 | // Then, grab the Channel ARN 37 | const describeSignalingChannelResponse = await kinesisVideoClient.send(new AWS.KinesisVideo.DescribeSignalingChannelCommand({ ChannelName: formValues.channelName })); 38 | const channelARN = describeSignalingChannelResponse.ChannelInfo.ChannelARN; 39 | 40 | // Finally, update the media storage configuration. 41 | await kinesisVideoClient 42 | .send(new AWS.KinesisVideo.UpdateMediaStorageConfigurationCommand({ 43 | ChannelARN: channelARN, 44 | MediaStorageConfiguration: { 45 | Status: 'ENABLED', 46 | StreamARN: streamARN, 47 | }, 48 | })); 49 | 50 | console.log('[UPDATE_MEDIA_STORAGE_CONFIGURATION] Success! Media for', channelARN, 'will be ingested and stored in', streamARN); 51 | } else { 52 | // We want to disable the media storage configuration 53 | 54 | // First, grab the Channel ARN 55 | const describeSignalingChannelResponse = await kinesisVideoClient.send(new AWS.KinesisVideo.DescribeSignalingChannelCommand({ ChannelName: formValues.channelName })); 56 | const channelARN = describeSignalingChannelResponse.ChannelInfo.ChannelARN; 57 | 58 | // Then, update the media storage configuration. 59 | await kinesisVideoClient 60 | .send(new AWS.KinesisVideo.UpdateMediaStorageConfigurationCommand({ 61 | ChannelARN: channelARN, 62 | MediaStorageConfiguration: { 63 | Status: 'DISABLED', 64 | StreamARN: null, 65 | }, 66 | })); 67 | 68 | console.log('[UPDATE_MEDIA_STORAGE_CONFIGURATION] Success! Media for', channelARN, 'will be no longer be ingested and stored'); 69 | } 70 | } catch (e) { 71 | console.error('[UPDATE_MEDIA_STORAGE_CONFIGURATION] Encountered error:', e); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | process.env.PACKAGE_VERSION = 'test.test.test'; 2 | 3 | module.exports = { 4 | collectCoverage: true, 5 | coverageThreshold: { 6 | global: { 7 | branches: 100, 8 | functions: 90, 9 | lines: 100, 10 | statements: 100, 11 | }, 12 | }, 13 | roots: ['/src'], 14 | testMatch: ['**/*.spec.ts'], 15 | transform: { 16 | '^.+\\.ts$': 'ts-jest', 17 | }, 18 | testEnvironment: 'jsdom', 19 | clearMocks: true, 20 | }; 21 | -------------------------------------------------------------------------------- /license/bundleLicenseBanner.txt: -------------------------------------------------------------------------------- 1 | /* Amazon Kinesis Video Streams WebRTC SDK for JavaScript vVERSION 2 | Copyright 2019-2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | 4 | This product includes software developed at 5 | Amazon Web Services, Inc. (http://aws.amazon.com/). 6 | 7 | License at kvs-webrtc.LICENSE */ 8 | -------------------------------------------------------------------------------- /license/bundleLicenseHeader.txt: -------------------------------------------------------------------------------- 1 | The bundled package of the Amazon Kinesis Video Streams WebRTC SDK for 2 | JavaScript is available under the Apache License, Version 2.0: 3 | 4 | Copyright 2012-2017 Amazon.com, Inc. or its affiliates. All Rights Reserved. 5 | 6 | Licensed under the Apache License, Version 2.0 (the "License"). You 7 | may not use this file except in compliance with the License. A copy of 8 | the License is located at 9 | 10 | http://aws.amazon.com/apache2.0/ 11 | 12 | or in the "license" file accompanying this file. This file is 13 | distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF 14 | ANY KIND, either express or implied. See the License for the specific 15 | language governing permissions and limitations under the License. 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "amazon-kinesis-video-streams-webrtc", 3 | "version": "2.4.1", 4 | "description": "Amazon Kinesis Video Streams WebRTC SDK for JavaScript.", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/awslabs/amazon-kinesis-video-streams-webrtc-sdk-js.git" 8 | }, 9 | "main": "lib/index.js", 10 | "types": "lib/index.d.ts", 11 | "files": [ 12 | "lib/**/*", 13 | "dist/**/*", 14 | "examples/**/*", 15 | "LICENSE.txt", 16 | "NOTICE.txt" 17 | ], 18 | "scripts": { 19 | "integ-server": "ws -p 3000", 20 | "test": "jest --runInBand", 21 | "test-watch": "jest --watch", 22 | "build-all": "npm run build-commonjs && npm run build-debug && npm run build-dist", 23 | "build-commonjs": "tsc -p tsconfig.json", 24 | "build-debug": "webpack --config webpack.debug.config.js", 25 | "build-dist": "webpack --config webpack.dist.config.js", 26 | "copy-examples-to-dist": "cp -r examples dist", 27 | "develop": "webpack-dev-server --config webpack.dev.config.js", 28 | "lint": "eslint 'src/**/*.{js,ts}'", 29 | "release": "npm run lint && npm run test && npm run build-all && npm run copy-examples-to-dist" 30 | }, 31 | "author": "Divya Sampath Kumar ", 32 | "license": "Apache-2.0", 33 | "devDependencies": { 34 | "@types/jest": "^29.5.14", 35 | "@types/node": "^22.10.5", 36 | "@typescript-eslint/eslint-plugin": "^8.18.0", 37 | "@typescript-eslint/parser": "^8.18.0", 38 | "eslint": "^9.16.0", 39 | "eslint-config-prettier": "^10.0.1", 40 | "eslint-plugin-kvs-webrtc": "file:eslint", 41 | "eslint-plugin-prettier": "^5.2.1", 42 | "fork-ts-checker-webpack-plugin": "^9.0.2", 43 | "jest": "^29.5.0", 44 | "jest-environment-jsdom": "^29.5.0", 45 | "license-webpack-plugin": "^4.0.0", 46 | "prettier": "^3.4.2", 47 | "ts-jest": "^29.1.0", 48 | "ts-loader": "^9.5.1", 49 | "typescript": "^5.7.2", 50 | "webpack": "^5.0.0", 51 | "webpack-cli": "^6.0.1", 52 | "webpack-dev-server": "^5.2.0", 53 | "webpack-merge": "^6.0.1" 54 | }, 55 | "dependencies": { 56 | "isomorphic-webcrypto": "^2.3.6", 57 | "jsdom": "^26.0.0", 58 | "tslib": "^2.8.1", 59 | "ws": "^8.14.2" 60 | }, 61 | "overrides": { 62 | "qs": "6.7.3", 63 | "xml2js": "^0.5.0", 64 | "json5": "^2.2.3", 65 | "semver": ">=7.5.2", 66 | "@types/babel__traverse": "7.18.2", 67 | "@types/express-serve-static-core": "4.17.29" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/QueryParams.ts: -------------------------------------------------------------------------------- 1 | export type QueryParams = { [queryParam: string]: string }; 2 | -------------------------------------------------------------------------------- /src/RequestSigner.ts: -------------------------------------------------------------------------------- 1 | import { QueryParams } from './QueryParams'; 2 | 3 | export interface RequestSigner { 4 | getSignedURL: (signalingEndpoint: string, queryParams: QueryParams, date?: Date) => Promise; 5 | } 6 | -------------------------------------------------------------------------------- /src/Role.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Signaling client role. 3 | */ 4 | export enum Role { 5 | MASTER = 'MASTER', 6 | VIEWER = 'VIEWER', 7 | } 8 | -------------------------------------------------------------------------------- /src/SigV4RequestSigner.spec.ts: -------------------------------------------------------------------------------- 1 | import { QueryParams } from './QueryParams'; 2 | import { SigV4RequestSigner } from './SigV4RequestSigner'; 3 | import { Credentials } from './SignalingClient'; 4 | 5 | describe('SigV4RequestSigner', () => { 6 | let region: string; 7 | let credentials: Credentials; 8 | let signer: SigV4RequestSigner; 9 | let queryParams: QueryParams; 10 | let date: Date; 11 | 12 | beforeEach(() => { 13 | region = 'us-west-2'; 14 | credentials = { 15 | accessKeyId: 'AKIA4F7WJQR7FMMWMNXI', 16 | secretAccessKey: 'FakeSecretKey', 17 | sessionToken: 'FakeSessionToken', 18 | }; 19 | signer = new SigV4RequestSigner(region, credentials); 20 | queryParams = { 21 | 'X-Amz-TestParam': 'test-param-value', 22 | }; 23 | date = new Date('2019-12-01T00:00:00.000Z'); 24 | }); 25 | 26 | describe('getSignedURL', () => { 27 | it('should fail when the endpoint is not a WSS endpoint', async () => { 28 | await expect(signer.getSignedURL('https://kvs.awsamazon.com', queryParams, date)).rejects.toBeTruthy(); 29 | }); 30 | 31 | it('should fail when the endpoint contains query params', async () => { 32 | await expect(signer.getSignedURL('wss://kvs.awsamazon.com?a=b', queryParams, date)).rejects.toBeTruthy(); 33 | }); 34 | 35 | const expectedSignedURL = 36 | 'wss://kvs.awsamazon.com/?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIA4F7WJQR7FMMWMNXI%2F20191201%2Fus-west-2%2Fkinesisvideo%2Faws4_request&X-Amz-Date=20191201T000000Z&X-Amz-Expires=299&X-Amz-Security-Token=FakeSessionToken&X-Amz-Signature=fc268038be276315822b4f73eafd28ee3a5632a2a35fdb0a88db9a42b13d6c92&X-Amz-SignedHeaders=host&X-Amz-TestParam=test-param-value'; 37 | it('should generate a valid signed URL with static credentials', async () => { 38 | await expect(signer.getSignedURL('wss://kvs.awsamazon.com', queryParams, date)).resolves.toBe(expectedSignedURL); 39 | }); 40 | 41 | it('should generate a valid signed URL with dynamic credentials', async () => { 42 | credentials = { 43 | accessKeyId: null, 44 | secretAccessKey: null, 45 | getPromise(): Promise { 46 | return new Promise((resolve) => { 47 | credentials.accessKeyId = 'AKIA4F7WJQR7FMMWMNXI'; 48 | credentials.secretAccessKey = 'FakeSecretKey'; 49 | credentials.sessionToken = 'FakeSessionToken'; 50 | resolve(); 51 | }); 52 | }, 53 | }; 54 | signer = new SigV4RequestSigner(region, credentials); 55 | await expect(signer.getSignedURL('wss://kvs.awsamazon.com', queryParams, date)).resolves.toBe(expectedSignedURL); 56 | }); 57 | 58 | it('should generate a valid signed URL without a session token', async () => { 59 | delete credentials.sessionToken; 60 | signer = new SigV4RequestSigner(region, credentials); 61 | await expect(signer.getSignedURL('wss://kvs.awsamazon.com', queryParams, date)).resolves.toBe( 62 | 'wss://kvs.awsamazon.com/?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIA4F7WJQR7FMMWMNXI%2F20191201%2Fus-west-2%2Fkinesisvideo%2Faws4_request&X-Amz-Date=20191201T000000Z&X-Amz-Expires=299&X-Amz-Signature=be1e78950d956a8a9a1997417099ddbd7455619f3d08c4ad20e1e272179ca695&X-Amz-SignedHeaders=host&X-Amz-TestParam=test-param-value', 63 | ); 64 | }); 65 | 66 | it('should generate a valid signed URL with a service override', async () => { 67 | signer = new SigV4RequestSigner(region, credentials, 'firehose'); 68 | await expect(signer.getSignedURL('wss://kvs.awsamazon.com', queryParams, date)).resolves.toBe( 69 | 'wss://kvs.awsamazon.com/?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIA4F7WJQR7FMMWMNXI%2F20191201%2Fus-west-2%2Ffirehose%2Faws4_request&X-Amz-Date=20191201T000000Z&X-Amz-Expires=299&X-Amz-Security-Token=FakeSessionToken&X-Amz-Signature=f15308513d21a381d38b7607a0439f25fc2e6c9f5ff56a48c1664b486e6234d5&X-Amz-SignedHeaders=host&X-Amz-TestParam=test-param-value', 70 | ); 71 | }); 72 | 73 | it('should generate a valid signed URL with a path', async () => { 74 | await expect(signer.getSignedURL('wss://kvs.awsamazon.com/path/path/path', queryParams, date)).resolves.toBe( 75 | 'wss://kvs.awsamazon.com/path/path/path?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIA4F7WJQR7FMMWMNXI%2F20191201%2Fus-west-2%2Fkinesisvideo%2Faws4_request&X-Amz-Date=20191201T000000Z&X-Amz-Expires=299&X-Amz-Security-Token=FakeSessionToken&X-Amz-Signature=0bf3df6ca23d8d82f688e8dbfb90d69e74843d40038541b1721c545eef7612a4&X-Amz-SignedHeaders=host&X-Amz-TestParam=test-param-value', 76 | ); 77 | }); 78 | 79 | it('should generate a valid signed URL without a mocked date', async () => { 80 | await expect(signer.getSignedURL('wss://kvs.awsamazon.com', queryParams)).resolves.toBeTruthy(); 81 | }); 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /src/SigV4RequestSigner.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'isomorphic-webcrypto'; 2 | 3 | import { QueryParams } from './QueryParams'; 4 | import { RequestSigner } from './RequestSigner'; 5 | import { Credentials } from './SignalingClient'; 6 | import { validateValueNonNil } from './internal/utils'; 7 | 8 | type Headers = { [header: string]: string }; 9 | 10 | /** 11 | * Utility class for SigV4 signing requests. The AWS SDK cannot be used for this purpose because it does not have support for WebSocket endpoints. 12 | */ 13 | export class SigV4RequestSigner implements RequestSigner { 14 | private static readonly DEFAULT_ALGORITHM = 'AWS4-HMAC-SHA256'; 15 | private static readonly DEFAULT_SERVICE = 'kinesisvideo'; 16 | 17 | private readonly region: string; 18 | private readonly credentials: Credentials; 19 | private readonly service: string; 20 | 21 | public constructor(region: string, credentials: Credentials, service: string = SigV4RequestSigner.DEFAULT_SERVICE) { 22 | this.region = region; 23 | this.credentials = credentials; 24 | this.service = service; 25 | } 26 | 27 | /** 28 | * Creates a SigV4 signed WebSocket URL for the given host/endpoint with the given query params. 29 | * 30 | * @param endpoint The WebSocket service endpoint including protocol, hostname, and path (if applicable). 31 | * @param queryParams Query parameters to include in the URL. 32 | * @param date Date to use for request signing. Defaults to NOW. 33 | * 34 | * Implementation note: Query parameters should be in alphabetical order. 35 | * 36 | * Note from AWS docs: "When you add the X-Amz-Security-Token parameter to the query string, some services require that you include this parameter in the 37 | * canonical (signed) request. For other services, you add this parameter at the end, after you calculate the signature. For details, see the API reference 38 | * documentation for that service." KVS Signaling Service requires that the session token is added to the canonical request. 39 | * 40 | * @see https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-query-string-auth.html 41 | * @see https://gist.github.com/prestomation/24b959e51250a8723b9a5a4f70dcae08 42 | */ 43 | public async getSignedURL(endpoint: string, queryParams: QueryParams, date: Date = new Date()): Promise { 44 | // Refresh credentials 45 | if (typeof this.credentials.getPromise === 'function') { 46 | await this.credentials.getPromise(); 47 | } 48 | validateValueNonNil(this.credentials.accessKeyId, 'credentials.accessKeyId'); 49 | validateValueNonNil(this.credentials.secretAccessKey, 'credentials.secretAccessKey'); 50 | 51 | // Prepare date strings 52 | const datetimeString = SigV4RequestSigner.getDateTimeString(date); 53 | const dateString = SigV4RequestSigner.getDateString(date); 54 | 55 | // Validate and parse endpoint 56 | const protocol = 'wss'; 57 | const urlProtocol = `${protocol}://`; 58 | if (!endpoint.startsWith(urlProtocol)) { 59 | throw new Error(`Endpoint '${endpoint}' is not a secure WebSocket endpoint. It should start with '${urlProtocol}'.`); 60 | } 61 | if (endpoint.includes('?')) { 62 | throw new Error(`Endpoint '${endpoint}' should not contain any query parameters.`); 63 | } 64 | const pathStartIndex = endpoint.indexOf('/', urlProtocol.length); 65 | let host; 66 | let path; 67 | if (pathStartIndex < 0) { 68 | host = endpoint.substring(urlProtocol.length); 69 | path = '/'; 70 | } else { 71 | host = endpoint.substring(urlProtocol.length, pathStartIndex); 72 | path = endpoint.substring(pathStartIndex); 73 | } 74 | 75 | const signedHeaders = ['host'].join(';'); 76 | 77 | // Prepare method 78 | const method = 'GET'; // Method is always GET for signed URLs 79 | 80 | // Prepare canonical query string 81 | const credentialScope = dateString + '/' + this.region + '/' + this.service + '/' + 'aws4_request'; 82 | const canonicalQueryParams = Object.assign({}, queryParams, { 83 | 'X-Amz-Algorithm': SigV4RequestSigner.DEFAULT_ALGORITHM, 84 | 'X-Amz-Credential': this.credentials.accessKeyId + '/' + credentialScope, 85 | 'X-Amz-Date': datetimeString, 86 | 'X-Amz-Expires': '299', 87 | 'X-Amz-SignedHeaders': signedHeaders, 88 | }); 89 | if (this.credentials.sessionToken) { 90 | Object.assign(canonicalQueryParams, { 91 | 'X-Amz-Security-Token': this.credentials.sessionToken, 92 | }); 93 | } 94 | const canonicalQueryString = SigV4RequestSigner.createQueryString(canonicalQueryParams); 95 | 96 | // Prepare canonical headers 97 | const canonicalHeaders = { 98 | host, 99 | }; 100 | const canonicalHeadersString = SigV4RequestSigner.createHeadersString(canonicalHeaders); 101 | 102 | // Prepare payload hash 103 | const payloadHash = await SigV4RequestSigner.sha256(''); 104 | 105 | // Combine canonical request parts into a canonical request string and hash 106 | const canonicalRequest = [method, path, canonicalQueryString, canonicalHeadersString, signedHeaders, payloadHash].join('\n'); 107 | const canonicalRequestHash = await SigV4RequestSigner.sha256(canonicalRequest); 108 | 109 | // Create signature 110 | const stringToSign = [SigV4RequestSigner.DEFAULT_ALGORITHM, datetimeString, credentialScope, canonicalRequestHash].join('\n'); 111 | const signingKey = await this.getSignatureKey(dateString); 112 | const signature = await SigV4RequestSigner.toHex(await SigV4RequestSigner.hmac(signingKey, stringToSign)); 113 | 114 | // Add signature to query params 115 | const signedQueryParams = Object.assign({}, canonicalQueryParams, { 116 | 'X-Amz-Signature': signature, 117 | }); 118 | 119 | // Create signed URL 120 | return protocol + '://' + host + path + '?' + SigV4RequestSigner.createQueryString(signedQueryParams); 121 | } 122 | 123 | /** 124 | * Utility method for generating the key to use for calculating the signature. This combines together the date string, region, service name, and secret 125 | * access key. 126 | * 127 | * @see https://docs.aws.amazon.com/general/latest/gr/sigv4-calculate-signature.html 128 | */ 129 | private async getSignatureKey(dateString: string): Promise { 130 | const kDate = await SigV4RequestSigner.hmac('AWS4' + this.credentials.secretAccessKey, dateString); 131 | const kRegion = await SigV4RequestSigner.hmac(kDate, this.region); 132 | const kService = await SigV4RequestSigner.hmac(kRegion, this.service); 133 | return await SigV4RequestSigner.hmac(kService, 'aws4_request'); 134 | } 135 | 136 | /** 137 | * Utility method for converting a map of headers to a string for signing. 138 | */ 139 | private static createHeadersString(headers: Headers): string { 140 | return Object.keys(headers) 141 | .map((header) => `${header}:${headers[header]}\n`) 142 | .join(); 143 | } 144 | 145 | /** 146 | * Utility method for converting a map of query parameters to a string with the parameter names sorted. 147 | */ 148 | private static createQueryString(queryParams: QueryParams): string { 149 | return Object.keys(queryParams) 150 | .sort() 151 | .map((key) => `${key}=${encodeURIComponent(queryParams[key])}`) 152 | .join('&'); 153 | } 154 | 155 | /** 156 | * Gets a datetime string for the given date to use for signing. For example: "20190927T165210Z" 157 | * @param date 158 | */ 159 | private static getDateTimeString(date: Date): string { 160 | return date 161 | .toISOString() 162 | .replace(/\.\d{3}Z$/, 'Z') 163 | .replace(/[:\-]/g, ''); 164 | } 165 | 166 | /** 167 | * Gets a date string for the given date to use for signing. For example: "20190927" 168 | * @param date 169 | */ 170 | private static getDateString(date: Date): string { 171 | return this.getDateTimeString(date).substring(0, 8); 172 | } 173 | 174 | private static async sha256(message: string): Promise { 175 | const hashBuffer = await crypto.subtle.digest({ name: 'SHA-256' }, this.toUint8Array(message)); 176 | return this.toHex(hashBuffer); 177 | } 178 | 179 | private static async hmac(key: string | ArrayBuffer, message: string): Promise { 180 | const keyBuffer = typeof key === 'string' ? this.toUint8Array(key).buffer : key; 181 | const messageBuffer = this.toUint8Array(message).buffer; 182 | const cryptoKey = await crypto.subtle.importKey( 183 | 'raw', 184 | keyBuffer, 185 | { 186 | name: 'HMAC', 187 | hash: { 188 | name: 'SHA-256', 189 | }, 190 | }, 191 | false, 192 | ['sign'], 193 | ); 194 | return await crypto.subtle.sign({ name: 'HMAC', hash: { name: 'SHA-256' } }, cryptoKey, messageBuffer); 195 | } 196 | 197 | /** 198 | * Note that this implementation does not work with two-byte characters. 199 | * However, no inputs into a signed signaling service request should have two-byte characters. 200 | */ 201 | private static toUint8Array(input: string): Uint8Array { 202 | const buf = new ArrayBuffer(input.length); 203 | const bufView = new Uint8Array(buf); 204 | for (let i = 0, strLen = input.length; i < strLen; i++) { 205 | bufView[i] = input.charCodeAt(i); 206 | } 207 | return bufView; 208 | } 209 | 210 | private static toHex(buffer: ArrayBuffer): string { 211 | return Array.from(new Uint8Array(buffer)) 212 | .map((b) => b.toString(16).padStart(2, '0')) 213 | .join(''); 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /src/SignalingClient.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { EventEmitter } from 'events'; 3 | import * as util from 'util'; 4 | 5 | import { Role } from './Role'; 6 | import { SignalingClient, SignalingClientConfig } from './SignalingClient'; 7 | import { mockDateClass, restoreDateClass } from './internal/testUtils'; 8 | 9 | const RealWebSocket = window.WebSocket; 10 | 11 | const ENDPOINT = 'wss://endpoint.kinesisvideo.amazonaws.com'; 12 | const CHANNEL_ARN = 'arn:aws:kinesisvideo:us-west-2:123456789012:channel/testChannel/1234567890'; 13 | const CLIENT_ID = 'TestClientId'; 14 | const SDP_OFFER_OBJECT = { 15 | sdp: 'offer= true\nvideo= true', 16 | type: 'offer', 17 | }; 18 | const SDP_OFFER: RTCSessionDescription = { 19 | ...SDP_OFFER_OBJECT, 20 | toJSON: () => SDP_OFFER_OBJECT, 21 | } as any; 22 | const SDP_OFFER_VIEWER_STRING = '{"action":"SDP_OFFER","messagePayload":"eyJzZHAiOiJvZmZlcj0gdHJ1ZVxudmlkZW89IHRydWUiLCJ0eXBlIjoib2ZmZXIifQ=="}'; 23 | const SDP_OFFER_MASTER_STRING = 24 | '{"action":"SDP_OFFER","messagePayload":"eyJzZHAiOiJvZmZlcj0gdHJ1ZVxudmlkZW89IHRydWUiLCJ0eXBlIjoib2ZmZXIifQ==","recipientClientId":"TestClientId"}'; 25 | const SDP_OFFER_VIEWER_MESSAGE = 26 | '{"messageType":"SDP_OFFER","messagePayload":"eyJzZHAiOiJvZmZlcj0gdHJ1ZVxudmlkZW89IHRydWUiLCJ0eXBlIjoib2ZmZXIifQ==","senderClientId":"TestClientId"}'; 27 | const SDP_OFFER_MASTER_MESSAGE = '{"messageType":"SDP_OFFER","messagePayload":"eyJzZHAiOiJvZmZlcj0gdHJ1ZVxudmlkZW89IHRydWUiLCJ0eXBlIjoib2ZmZXIifQ=="}'; 28 | 29 | const SDP_ANSWER_OBJECT = { 30 | sdp: 'offer= true\nvideo= true', 31 | type: 'answer', 32 | }; 33 | const SDP_ANSWER: RTCSessionDescription = { 34 | ...SDP_ANSWER_OBJECT, 35 | toJSON: () => SDP_ANSWER_OBJECT, 36 | } as any; 37 | const SDP_ANSWER_VIEWER_STRING = '{"action":"SDP_ANSWER","messagePayload":"eyJzZHAiOiJvZmZlcj0gdHJ1ZVxudmlkZW89IHRydWUiLCJ0eXBlIjoiYW5zd2VyIn0="}'; 38 | const SDP_ANSWER_MASTER_STRING = 39 | '{"action":"SDP_ANSWER","messagePayload":"eyJzZHAiOiJvZmZlcj0gdHJ1ZVxudmlkZW89IHRydWUiLCJ0eXBlIjoiYW5zd2VyIn0=","recipientClientId":"TestClientId"}'; 40 | const SDP_ANSWER_VIEWER_MESSAGE = 41 | '{"messageType":"SDP_ANSWER","messagePayload":"eyJzZHAiOiJvZmZlcj0gdHJ1ZVxudmlkZW89IHRydWUiLCJ0eXBlIjoiYW5zd2VyIn0=","senderClientId":"TestClientId"}'; 42 | const SDP_ANSWER_MASTER_MESSAGE = '{"messageType":"SDP_ANSWER","messagePayload":"eyJzZHAiOiJvZmZlcj0gdHJ1ZVxudmlkZW89IHRydWUiLCJ0eXBlIjoiYW5zd2VyIn0="}'; 43 | 44 | const ICE_CANDIDATE_OBJECT = { 45 | candidate: 'upd 10.111.34.88', 46 | sdpMid: '1', 47 | sdpMLineIndex: 1, 48 | }; 49 | const ICE_CANDIDATE: RTCIceCandidate = { 50 | ...ICE_CANDIDATE_OBJECT, 51 | toJSON: () => ICE_CANDIDATE_OBJECT, 52 | } as any; 53 | const ICE_CANDIDATE_VIEWER_STRING = 54 | '{"action":"ICE_CANDIDATE","messagePayload":"eyJjYW5kaWRhdGUiOiJ1cGQgMTAuMTExLjM0Ljg4Iiwic2RwTWlkIjoiMSIsInNkcE1MaW5lSW5kZXgiOjF9"}'; 55 | const ICE_CANDIDATE_MASTER_STRING = 56 | '{"action":"ICE_CANDIDATE","messagePayload":"eyJjYW5kaWRhdGUiOiJ1cGQgMTAuMTExLjM0Ljg4Iiwic2RwTWlkIjoiMSIsInNkcE1MaW5lSW5kZXgiOjF9","recipientClientId":"TestClientId"}'; 57 | const ICE_CANDIDATE_VIEWER_MESSAGE = 58 | '{"messageType":"ICE_CANDIDATE","messagePayload":"eyJjYW5kaWRhdGUiOiJ1cGQgMTAuMTExLjM0Ljg4Iiwic2RwTWlkIjoiMSIsInNkcE1MaW5lSW5kZXgiOjF9","senderClientId":"TestClientId"}'; 59 | const ICE_CANDIDATE_MASTER_MESSAGE = 60 | '{"messageType":"ICE_CANDIDATE","messagePayload":"eyJjYW5kaWRhdGUiOiJ1cGQgMTAuMTExLjM0Ljg4Iiwic2RwTWlkIjoiMSIsInNkcE1MaW5lSW5kZXgiOjF9"}'; 61 | 62 | const CORRELATION_ID = '1697058567743'; 63 | const STATUS_RESPONSE_MESSAGE = 64 | '{"messageType": "STATUS_RESPONSE","statusResponse":{"correlationId": "' + 65 | CORRELATION_ID + 66 | '","errorType": "InvalidArgumentException","statusCode": "400","success": false}}'; 67 | 68 | const UNKNOWN_MESSAGE = '{"message": "Endpoint request timed out", "connectionId":"Jzo5lcQFtjvCJcq=", "requestId":"Jzo5vMzgNjZFb9Q="}'; 69 | 70 | class MockWebSocket extends EventEmitter { 71 | static instance: MockWebSocket; 72 | 73 | public readyState: number; 74 | public send = jest.fn(); 75 | public close = jest.fn().mockImplementation(() => { 76 | if (this.readyState === RealWebSocket.CONNECTING || this.readyState === RealWebSocket.OPEN) { 77 | this.readyState = RealWebSocket.CLOSING; 78 | setTimeout(() => { 79 | if (this.readyState === RealWebSocket.CLOSING) { 80 | this.readyState = RealWebSocket.CLOSED; 81 | this.emit('close'); 82 | } 83 | }, 5); 84 | } 85 | }); 86 | 87 | public constructor() { 88 | super(); 89 | this.readyState = RealWebSocket.CONNECTING; 90 | setTimeout(() => { 91 | if (this.readyState === RealWebSocket.CONNECTING) { 92 | this.readyState = RealWebSocket.OPEN; 93 | this.emit('open'); 94 | } 95 | }, 10); 96 | MockWebSocket.instance = this; 97 | } 98 | 99 | public addEventListener(...args: any[]): void { 100 | super.addListener.apply(this, args); 101 | } 102 | 103 | public removeEventListener(...args: any[]): void { 104 | super.removeListener.apply(this, args); 105 | } 106 | } 107 | window.WebSocket = MockWebSocket as any; 108 | 109 | describe('SignalingClient', () => { 110 | let config: Partial; 111 | let signer: jest.Mock; 112 | 113 | const mockDate = new Date('2020-05-01T00:00:00.000Z'); 114 | const mockClockSkewedDate = new Date('2020-05-01T00:16:40.000Z'); 115 | 116 | global.TextEncoder = util.TextEncoder; 117 | 118 | beforeEach(() => { 119 | mockDateClass(mockDate); 120 | signer = jest.fn().mockImplementation((endpoint) => new Promise((resolve) => resolve(endpoint))); 121 | config = { 122 | role: Role.VIEWER, 123 | clientId: CLIENT_ID, 124 | channelARN: CHANNEL_ARN, 125 | region: 'us-west-2', 126 | channelEndpoint: ENDPOINT, 127 | requestSigner: { 128 | getSignedURL: signer, 129 | }, 130 | }; 131 | }); 132 | 133 | afterEach(() => { 134 | restoreDateClass(); 135 | }); 136 | 137 | describe('constructor', () => { 138 | beforeEach(() => { 139 | delete config.requestSigner; 140 | config.credentials = { 141 | accessKeyId: 'ACCESS_KEY_ID', 142 | secretAccessKey: 'SECRET_ACCESS_KEY', 143 | sessionToken: 'SESSION_TOKEN', 144 | }; 145 | }); 146 | 147 | it('should not throw if valid viewer config provided', () => { 148 | new SignalingClient(config as SignalingClientConfig); 149 | }); 150 | 151 | it('should not throw if valid master config provided', () => { 152 | config.role = Role.MASTER; 153 | delete config.clientId; 154 | new SignalingClient(config as SignalingClientConfig); 155 | }); 156 | 157 | it('should throw if no config provided', () => { 158 | expect(() => new SignalingClient(null)).toThrow('SignalingClientConfig cannot be null'); 159 | }); 160 | 161 | it('should throw if viewer and no client id is provided', () => { 162 | config.clientId = null; 163 | expect(() => new SignalingClient(config as SignalingClientConfig)).toThrow('clientId cannot be null'); 164 | }); 165 | 166 | it('should throw if master and a client id is provided', () => { 167 | config.role = Role.MASTER; 168 | expect(() => new SignalingClient(config as SignalingClientConfig)).toThrow('clientId should be null'); 169 | }); 170 | 171 | it('should throw if ARN is not provided', () => { 172 | config.channelARN = null; 173 | expect(() => new SignalingClient(config as SignalingClientConfig)).toThrow('channelARN cannot be null'); 174 | }); 175 | 176 | it('should throw if region is not provided', () => { 177 | config.region = null; 178 | expect(() => new SignalingClient(config as SignalingClientConfig)).toThrow('region cannot be null'); 179 | }); 180 | 181 | it('should throw if channelEndpoint is not provided', () => { 182 | config.channelEndpoint = null; 183 | expect(() => new SignalingClient(config as SignalingClientConfig)).toThrow('channelEndpoint cannot be null'); 184 | }); 185 | }); 186 | 187 | describe('open', () => { 188 | it('should open a connection to the signaling server as the viewer', (done) => { 189 | const client = new SignalingClient(config as SignalingClientConfig); 190 | client.on('open', () => { 191 | expect(signer).toBeCalledWith( 192 | ENDPOINT, 193 | { 194 | 'X-Amz-ChannelARN': CHANNEL_ARN, 195 | 'X-Amz-ClientId': CLIENT_ID, 196 | }, 197 | mockDate, 198 | ); 199 | done(); 200 | }); 201 | client.open(); 202 | }); 203 | 204 | it('should open a connection to the signaling server as the master', (done) => { 205 | config.role = Role.MASTER; 206 | delete config.clientId; 207 | const client = new SignalingClient(config as SignalingClientConfig); 208 | client.on('open', () => { 209 | expect(signer).toBeCalledWith( 210 | ENDPOINT, 211 | { 212 | 'X-Amz-ChannelARN': CHANNEL_ARN, 213 | }, 214 | mockDate, 215 | ); 216 | done(); 217 | }); 218 | client.open(); 219 | }); 220 | 221 | it('should open a connection to the signaling server with clock skew adjusted date', (done) => { 222 | config.systemClockOffset = 1000000; 223 | const client = new SignalingClient(config as SignalingClientConfig); 224 | client.on('open', () => { 225 | expect(signer).toBeCalledWith( 226 | ENDPOINT, 227 | { 228 | 'X-Amz-ChannelARN': CHANNEL_ARN, 229 | 'X-Amz-ClientId': CLIENT_ID, 230 | }, 231 | mockClockSkewedDate, 232 | ); 233 | done(); 234 | }); 235 | client.open(); 236 | }); 237 | 238 | it('should not open a connection to the signaling server if it is closed while opening', async () => { 239 | config.requestSigner.getSignedURL = jest.fn().mockImplementation((endpoint) => new Promise((resolve) => setTimeout(() => resolve(endpoint), 5))); 240 | const client = new SignalingClient(config as SignalingClientConfig); 241 | client.on('open', () => { 242 | expect('Should not have fired an event').toBeFalsy(); 243 | }); 244 | client.open(); 245 | client.close(); 246 | return new Promise((resolve) => setTimeout(resolve, 100)); 247 | }); 248 | 249 | it('should throw an error when making multiple open requests', () => { 250 | const client = new SignalingClient(config as SignalingClientConfig); 251 | expect(() => { 252 | client.open(); 253 | client.open(); 254 | }).toThrow('Client is already open, opening, or closing'); 255 | }); 256 | 257 | it('should emit an error event if the connection cannot be started', (done) => { 258 | signer.mockImplementation((endpoint) => new Promise((_, reject) => reject(new Error(endpoint)))); 259 | const client = new SignalingClient(config as SignalingClientConfig); 260 | client.on('error', () => { 261 | done(); 262 | }); 263 | client.open(); 264 | }); 265 | }); 266 | 267 | describe('close', () => { 268 | it('should close an open connection', (done) => { 269 | const client = new SignalingClient(config as SignalingClientConfig); 270 | 271 | // Open a channel, close it, then wait for the close event. 272 | client.on('open', () => { 273 | client.close(); 274 | }); 275 | client.on('close', () => { 276 | expect(MockWebSocket.instance.close).toHaveBeenCalled(); 277 | done(); 278 | }); 279 | client.open(); 280 | }); 281 | 282 | it('should do nothing if the connection is closing', (done) => { 283 | const client = new SignalingClient(config as SignalingClientConfig); 284 | 285 | // Open a channel, close it, try to close it again, then wait for the close event. 286 | client.on('open', () => { 287 | client.close(); 288 | expect(() => client.close()).not.toThrow(); 289 | }); 290 | client.on('close', () => { 291 | done(); 292 | }); 293 | client.open(); 294 | }); 295 | 296 | it('should do nothing if the connection is not open', async () => { 297 | const client = new SignalingClient(config as SignalingClientConfig); 298 | 299 | // Close the client and then wait 100ms. If the close event fires, fail. 300 | client.on('close', () => { 301 | expect('Should not have fired an event').toBeFalsy(); 302 | }); 303 | client.close(); 304 | return new Promise((resolve) => setTimeout(resolve, 100)); 305 | }); 306 | }); 307 | 308 | describe('sendSdpOffer', () => { 309 | it('should send the message as the viewer', (done) => { 310 | const client = new SignalingClient(config as SignalingClientConfig); 311 | client.open(); 312 | client.on('open', () => { 313 | client.sendSdpOffer(SDP_OFFER); 314 | expect(MockWebSocket.instance.send).toHaveBeenCalledWith(SDP_OFFER_VIEWER_STRING); 315 | done(); 316 | }); 317 | }); 318 | 319 | it('should send the message as the master', (done) => { 320 | config.role = Role.MASTER; 321 | delete config.clientId; 322 | const client = new SignalingClient(config as SignalingClientConfig); 323 | client.open(); 324 | client.on('open', () => { 325 | client.sendSdpOffer(SDP_OFFER, CLIENT_ID); 326 | expect(MockWebSocket.instance.send).toHaveBeenCalledWith(SDP_OFFER_MASTER_STRING); 327 | done(); 328 | }); 329 | }); 330 | 331 | it('should throw an error if the connection is not open', () => { 332 | const client = new SignalingClient(config as SignalingClientConfig); 333 | expect(() => client.sendSdpOffer(SDP_OFFER)).toThrow('Could not send message because the connection to the signaling service is not open.'); 334 | }); 335 | 336 | it('should throw an error if there is a recipient id as viewer', (done) => { 337 | const client = new SignalingClient(config as SignalingClientConfig); 338 | client.open(); 339 | client.on('open', () => { 340 | expect(() => client.sendSdpOffer(SDP_OFFER, CLIENT_ID)).toThrow( 341 | 'Unexpected recipient client id. As the VIEWER, messages must not be sent with a recipient client id.', 342 | ); 343 | done(); 344 | }); 345 | }); 346 | }); 347 | 348 | describe('sendSdpAnswer', () => { 349 | it('should send the message as the viewer', (done) => { 350 | const client = new SignalingClient(config as SignalingClientConfig); 351 | client.open(); 352 | client.on('open', () => { 353 | client.sendSdpAnswer(SDP_ANSWER); 354 | expect(MockWebSocket.instance.send).toHaveBeenCalledWith(SDP_ANSWER_VIEWER_STRING); 355 | done(); 356 | }); 357 | }); 358 | 359 | it('should throw an error if the correlationId is invalid', (done) => { 360 | const client = new SignalingClient(config as SignalingClientConfig); 361 | client.open(); 362 | client.on('open', () => { 363 | expect(() => client.sendSdpAnswer(SDP_ANSWER, null, '?????')).toThrowError(); 364 | done(); 365 | }); 366 | }); 367 | 368 | it('should send the message as the master', (done) => { 369 | config.role = Role.MASTER; 370 | delete config.clientId; 371 | const client = new SignalingClient(config as SignalingClientConfig); 372 | client.open(); 373 | client.on('open', () => { 374 | client.sendSdpAnswer(SDP_ANSWER, CLIENT_ID); 375 | expect(MockWebSocket.instance.send).toHaveBeenCalledWith(SDP_ANSWER_MASTER_STRING); 376 | done(); 377 | }); 378 | }); 379 | 380 | it('should throw an error if the connection is not open', () => { 381 | const client = new SignalingClient(config as SignalingClientConfig); 382 | expect(() => client.sendSdpAnswer(SDP_ANSWER)).toThrow('Could not send message because the connection to the signaling service is not open.'); 383 | }); 384 | 385 | it('should throw an error if there is a recipient id as viewer', (done) => { 386 | const client = new SignalingClient(config as SignalingClientConfig); 387 | client.open(); 388 | client.on('open', () => { 389 | expect(() => client.sendSdpAnswer(SDP_ANSWER, CLIENT_ID)).toThrow( 390 | 'Unexpected recipient client id. As the VIEWER, messages must not be sent with a recipient client id.', 391 | ); 392 | done(); 393 | }); 394 | }); 395 | }); 396 | 397 | describe('sendIceCandidate', () => { 398 | it('should send the message as the viewer', (done) => { 399 | const client = new SignalingClient(config as SignalingClientConfig); 400 | client.open(); 401 | client.on('open', () => { 402 | client.sendIceCandidate(ICE_CANDIDATE); 403 | expect(MockWebSocket.instance.send).toHaveBeenCalledWith(ICE_CANDIDATE_VIEWER_STRING); 404 | done(); 405 | }); 406 | }); 407 | 408 | it('should send the message as the master', (done) => { 409 | config.role = Role.MASTER; 410 | delete config.clientId; 411 | const client = new SignalingClient(config as SignalingClientConfig); 412 | client.open(); 413 | client.on('open', () => { 414 | client.sendIceCandidate(ICE_CANDIDATE, CLIENT_ID); 415 | expect(MockWebSocket.instance.send).toHaveBeenCalledWith(ICE_CANDIDATE_MASTER_STRING); 416 | done(); 417 | }); 418 | }); 419 | 420 | it('should throw an error if the connection is not open', () => { 421 | const client = new SignalingClient(config as SignalingClientConfig); 422 | expect(() => client.sendIceCandidate(ICE_CANDIDATE)).toThrow('Could not send message because the connection to the signaling service is not open.'); 423 | }); 424 | 425 | it('should throw an error if there is a recipient id as viewer', (done) => { 426 | const client = new SignalingClient(config as SignalingClientConfig); 427 | client.open(); 428 | client.on('open', () => { 429 | expect(() => client.sendIceCandidate(ICE_CANDIDATE, CLIENT_ID)).toThrow( 430 | 'Unexpected recipient client id. As the VIEWER, messages must not be sent with a recipient client id.', 431 | ); 432 | done(); 433 | }); 434 | }); 435 | }); 436 | 437 | describe('events', () => { 438 | it('should ignore non-parsable messages from the signaling service', (done) => { 439 | const client = new SignalingClient(config as SignalingClientConfig); 440 | 441 | // Open a connection, receive a faulty message, and then continue to receive and process a non-faulty message. 442 | client.on('sdpOffer', () => { 443 | done(); 444 | }); 445 | client.on('open', () => { 446 | MockWebSocket.instance.emit('message', { data: 'not valid JSON' }); 447 | MockWebSocket.instance.emit('message', { data: UNKNOWN_MESSAGE }); 448 | MockWebSocket.instance.emit('message', { data: SDP_OFFER_MASTER_MESSAGE }); 449 | }); 450 | client.open(); 451 | }); 452 | 453 | describe('sdpOffer', () => { 454 | it('should parse sdpOffer messages from the master', (done) => { 455 | const client = new SignalingClient(config as SignalingClientConfig); 456 | client.once('sdpOffer', (sdpOffer, senderClientId) => { 457 | expect(sdpOffer).toEqual(SDP_OFFER_OBJECT); 458 | expect(senderClientId).toBeFalsy(); 459 | done(); 460 | }); 461 | client.once('open', () => { 462 | MockWebSocket.instance.emit('message', { data: SDP_OFFER_MASTER_MESSAGE }); 463 | }); 464 | client.open(); 465 | }); 466 | 467 | it('should parse sdpOffer messages from the viewer', (done) => { 468 | config.role = Role.MASTER; 469 | delete config.clientId; 470 | const client = new SignalingClient(config as SignalingClientConfig); 471 | client.once('sdpOffer', (sdpOffer, senderClientId) => { 472 | expect(sdpOffer).toEqual(SDP_OFFER_OBJECT); 473 | expect(senderClientId).toEqual(CLIENT_ID); 474 | done(); 475 | }); 476 | client.once('open', () => { 477 | MockWebSocket.instance.emit('message', { data: SDP_OFFER_VIEWER_MESSAGE }); 478 | }); 479 | client.open(); 480 | }); 481 | 482 | it('should parse sdpOffer messages from the master and release pending ICE candidates', (done) => { 483 | const client = new SignalingClient(config as SignalingClientConfig); 484 | let count = 0; 485 | client.once('sdpOffer', (sdpOffer, senderClientId) => { 486 | expect(sdpOffer).toEqual(SDP_OFFER_OBJECT); 487 | expect(senderClientId).toBeFalsy(); 488 | client.on('iceCandidate', (iceCandidate, senderClientId) => { 489 | expect(iceCandidate).toEqual(ICE_CANDIDATE_OBJECT); 490 | expect(senderClientId).toBeFalsy(); 491 | if (++count === 2) { 492 | done(); 493 | client.removeAllListeners(); 494 | } 495 | }); 496 | }); 497 | client.once('open', () => { 498 | MockWebSocket.instance.emit('message', { data: ICE_CANDIDATE_MASTER_MESSAGE }); 499 | MockWebSocket.instance.emit('message', { data: ICE_CANDIDATE_MASTER_MESSAGE }); 500 | MockWebSocket.instance.emit('message', { data: SDP_OFFER_MASTER_MESSAGE }); 501 | }); 502 | client.open(); 503 | }); 504 | }); 505 | 506 | describe('sdpAnswer', () => { 507 | it('should parse sdpAnswer messages from the master', (done) => { 508 | const client = new SignalingClient(config as SignalingClientConfig); 509 | client.once('sdpAnswer', (sdpAnswer, senderClientId) => { 510 | expect(sdpAnswer).toEqual(SDP_ANSWER_OBJECT); 511 | expect(senderClientId).toBeFalsy(); 512 | done(); 513 | }); 514 | client.once('open', () => { 515 | MockWebSocket.instance.emit('message', { data: SDP_ANSWER_MASTER_MESSAGE }); 516 | }); 517 | client.open(); 518 | }); 519 | 520 | it('should parse sdpAnswer messages from the viewer', (done) => { 521 | config.role = Role.MASTER; 522 | delete config.clientId; 523 | const client = new SignalingClient(config as SignalingClientConfig); 524 | client.once('sdpAnswer', (sdpAnswer, senderClientId) => { 525 | expect(sdpAnswer).toEqual(SDP_ANSWER_OBJECT); 526 | expect(senderClientId).toEqual(CLIENT_ID); 527 | done(); 528 | }); 529 | client.once('open', () => { 530 | MockWebSocket.instance.emit('message', { data: SDP_ANSWER_VIEWER_MESSAGE }); 531 | }); 532 | client.open(); 533 | }); 534 | 535 | it('should parse sdpAnswer messages from the master and release pending ICE candidates', (done) => { 536 | const client = new SignalingClient(config as SignalingClientConfig); 537 | client.once('sdpAnswer', (sdpAnswer, senderClientId) => { 538 | expect(sdpAnswer).toEqual(SDP_ANSWER_OBJECT); 539 | expect(senderClientId).toBeFalsy(); 540 | let count = 0; 541 | client.on('iceCandidate', (iceCandidate, senderClientId) => { 542 | expect(iceCandidate).toEqual(ICE_CANDIDATE_OBJECT); 543 | expect(senderClientId).toBeFalsy(); 544 | if (++count === 2) { 545 | done(); 546 | client.removeAllListeners(); 547 | } 548 | }); 549 | }); 550 | client.on('open', () => { 551 | MockWebSocket.instance.emit('message', { data: ICE_CANDIDATE_MASTER_MESSAGE }); 552 | MockWebSocket.instance.emit('message', { data: ICE_CANDIDATE_MASTER_MESSAGE }); 553 | MockWebSocket.instance.emit('message', { data: SDP_ANSWER_MASTER_MESSAGE }); 554 | }); 555 | client.open(); 556 | }); 557 | }); 558 | 559 | describe('iceCandidate', () => { 560 | it('should parse iceCandidate messages from the master', (done) => { 561 | const client = new SignalingClient(config as SignalingClientConfig); 562 | client.on('iceCandidate', (iceCandidate, senderClientId) => { 563 | expect(iceCandidate).toEqual(ICE_CANDIDATE_OBJECT); 564 | expect(senderClientId).toBeFalsy(); 565 | done(); 566 | }); 567 | client.on('open', () => { 568 | MockWebSocket.instance.emit('message', { data: SDP_ANSWER_MASTER_MESSAGE }); 569 | MockWebSocket.instance.emit('message', { data: ICE_CANDIDATE_MASTER_MESSAGE }); 570 | }); 571 | client.open(); 572 | }); 573 | 574 | it('should parse iceCandidate messages from the viewer', (done) => { 575 | config.role = Role.MASTER; 576 | delete config.clientId; 577 | const client = new SignalingClient(config as SignalingClientConfig); 578 | client.on('iceCandidate', (iceCandidate, senderClientId) => { 579 | expect(iceCandidate).toEqual(ICE_CANDIDATE_OBJECT); 580 | expect(senderClientId).toEqual(CLIENT_ID); 581 | done(); 582 | }); 583 | client.on('open', () => { 584 | MockWebSocket.instance.emit('message', { data: SDP_ANSWER_VIEWER_MESSAGE }); 585 | MockWebSocket.instance.emit('message', { data: ICE_CANDIDATE_VIEWER_MESSAGE }); 586 | }); 587 | client.open(); 588 | }); 589 | }); 590 | 591 | describe('statusResponse', () => { 592 | it('should parse statusResponse message from signaling', (done) => { 593 | config.role = Role.MASTER; 594 | delete config.clientId; 595 | const client = new SignalingClient(config as SignalingClientConfig); 596 | client.once('statusResponse', (statusResponse, senderClientId) => { 597 | expect(senderClientId).toBeUndefined(); 598 | expect(statusResponse.correlationId).toEqual(CORRELATION_ID); 599 | done(); 600 | }); 601 | client.on('open', () => { 602 | MockWebSocket.instance.emit('message', { data: STATUS_RESPONSE_MESSAGE }); 603 | }); 604 | client.open(); 605 | }); 606 | }); 607 | }); 608 | 609 | describe('outsideBrowser', () => { 610 | it('parseJSONObjectFromBase64String', (done) => { 611 | global.atob = undefined; 612 | const client = new SignalingClient(config as SignalingClientConfig); 613 | client.once('sdpAnswer', (sdpAnswer) => { 614 | expect(sdpAnswer).toEqual(SDP_ANSWER_OBJECT); 615 | done(); 616 | }); 617 | client.once('open', () => { 618 | MockWebSocket.instance.emit('message', { data: SDP_ANSWER_MASTER_MESSAGE }); 619 | }); 620 | client.open(); 621 | }); 622 | 623 | it('serializeJSONObjectAsBase64String', (done) => { 624 | global.btoa = undefined; 625 | const client = new SignalingClient(config as SignalingClientConfig); 626 | client.open(); 627 | client.on('open', () => { 628 | client.sendSdpOffer(SDP_OFFER); 629 | expect(MockWebSocket.instance.send).toHaveBeenCalledWith(SDP_OFFER_VIEWER_STRING); 630 | done(); 631 | }); 632 | }); 633 | }); 634 | }); 635 | -------------------------------------------------------------------------------- /src/SignalingClient.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | 3 | import { QueryParams } from './QueryParams'; 4 | import { RequestSigner } from './RequestSigner'; 5 | import { Role } from './Role'; 6 | import { SigV4RequestSigner } from './SigV4RequestSigner'; 7 | import DateProvider from './internal/DateProvider'; 8 | import { validateValueNil, validateValueNonNil } from './internal/utils'; 9 | 10 | /** 11 | * A partial copy of the credentials from the AWS SDK for JS: https://github.com/aws/aws-sdk-js/blob/master/lib/credentials.d.ts 12 | * The interface is copied here so that a dependency on the AWS SDK for JS is not needed. 13 | */ 14 | export interface Credentials { 15 | accessKeyId: string; 16 | secretAccessKey: string; 17 | sessionToken?: string; 18 | getPromise?(): Promise; 19 | } 20 | 21 | export interface SignalingClientConfig { 22 | channelARN: string; 23 | channelEndpoint: string; 24 | credentials?: Credentials; 25 | region: string; 26 | requestSigner?: RequestSigner; 27 | role: Role; 28 | clientId?: string; 29 | systemClockOffset?: number; 30 | } 31 | 32 | enum MessageType { 33 | SDP_ANSWER = 'SDP_ANSWER', 34 | SDP_OFFER = 'SDP_OFFER', 35 | ICE_CANDIDATE = 'ICE_CANDIDATE', 36 | STATUS_RESPONSE = 'STATUS_RESPONSE', 37 | } 38 | 39 | enum ReadyState { 40 | CONNECTING, 41 | OPEN, 42 | CLOSING, 43 | CLOSED, 44 | } 45 | 46 | interface WebSocketMessage { 47 | messageType: MessageType; 48 | messagePayload?: string; 49 | senderClientId?: string; 50 | statusResponse?: StatusResponse; 51 | } 52 | 53 | export interface StatusResponse { 54 | correlationId: 'string'; 55 | success: 'boolean'; 56 | errorType?: 'string'; 57 | statusCode?: 'string'; 58 | description?: 'string'; 59 | } 60 | 61 | /** 62 | * Client for sending and receiving messages from a KVS Signaling Channel. The client can operate as either the 'MASTER' or a 'VIEWER'. 63 | * 64 | * Typically, the 'MASTER' listens for ICE candidates and SDP offers and responds with and SDP answer and its own ICE candidates. 65 | * 66 | * Typically, the 'VIEWER' sends an SDP offer and its ICE candidates and then listens for ICE candidates and SDP answers from the 'MASTER'. 67 | */ 68 | export class SignalingClient extends EventEmitter { 69 | private static DEFAULT_CLIENT_ID = 'MASTER'; 70 | 71 | private websocket: WebSocket = null; 72 | private readyState = ReadyState.CLOSED; 73 | private readonly requestSigner: RequestSigner; 74 | private readonly config: SignalingClientConfig; 75 | private readonly pendingIceCandidatesByClientId: { [clientId: string]: object[] } = {}; 76 | private readonly hasReceivedRemoteSDPByClientId: { [clientId: string]: boolean } = {}; 77 | private readonly dateProvider: DateProvider; 78 | 79 | /** 80 | * Creates a new SignalingClient. The connection with the signaling service must be opened with the 'open' method. 81 | * @param {SignalingClientConfig} config - Configuration options and parameters. 82 | * is not provided, it will be loaded from the global scope. 83 | */ 84 | public constructor(config: SignalingClientConfig) { 85 | super(); 86 | 87 | // Validate config 88 | validateValueNonNil(config, 'SignalingClientConfig'); 89 | validateValueNonNil(config.role, 'role'); 90 | if (config.role === Role.VIEWER) { 91 | validateValueNonNil(config.clientId, 'clientId'); 92 | } else { 93 | validateValueNil(config.clientId, 'clientId'); 94 | } 95 | validateValueNonNil(config.channelARN, 'channelARN'); 96 | validateValueNonNil(config.region, 'region'); 97 | validateValueNonNil(config.channelEndpoint, 'channelEndpoint'); 98 | 99 | this.config = { ...config }; // Copy config to new object for immutability. 100 | 101 | if (config.requestSigner) { 102 | this.requestSigner = config.requestSigner; 103 | } else { 104 | validateValueNonNil(config.credentials, 'credentials'); 105 | this.requestSigner = new SigV4RequestSigner(config.region, config.credentials); 106 | } 107 | 108 | this.dateProvider = new DateProvider(config.systemClockOffset || 0); 109 | 110 | // Bind event handlers 111 | this.onOpen = this.onOpen.bind(this); 112 | this.onMessage = this.onMessage.bind(this); 113 | this.onError = this.onError.bind(this); 114 | this.onClose = this.onClose.bind(this); 115 | } 116 | 117 | /** 118 | * Opens the connection with the signaling service. Listen to the 'open' event to be notified when the connection has been opened. 119 | */ 120 | public open(): void { 121 | if (this.readyState !== ReadyState.CLOSED) { 122 | throw new Error('Client is already open, opening, or closing'); 123 | } 124 | this.readyState = ReadyState.CONNECTING; 125 | 126 | // The process of opening the connection is asynchronous via promises, but the interaction model is to handle asynchronous actions via events. 127 | // Therefore, we just kick off the asynchronous process and then return and let it fire events. 128 | this.asyncOpen() 129 | .then() 130 | .catch((err) => this.onError(err)); 131 | } 132 | 133 | /** 134 | * Asynchronous implementation of `open`. 135 | */ 136 | private async asyncOpen(): Promise { 137 | const queryParams: QueryParams = { 138 | 'X-Amz-ChannelARN': this.config.channelARN, 139 | }; 140 | if (this.config.role === Role.VIEWER) { 141 | queryParams['X-Amz-ClientId'] = this.config.clientId; 142 | } 143 | const signedURL = await this.requestSigner.getSignedURL(this.config.channelEndpoint, queryParams, this.dateProvider.getDate()); 144 | 145 | // If something caused the state to change from CONNECTING, then don't create the WebSocket instance. 146 | if (this.readyState !== ReadyState.CONNECTING) { 147 | return; 148 | } 149 | 150 | /* istanbul ignore next */ 151 | this.websocket = new (WebSocket || require('ws'))(signedURL); 152 | 153 | this.websocket.addEventListener('open', this.onOpen); 154 | this.websocket.addEventListener('message', this.onMessage); 155 | this.websocket.addEventListener('error', this.onError); 156 | this.websocket.addEventListener('close', this.onClose); 157 | } 158 | 159 | /** 160 | * Closes the connection to the KVS Signaling Service. If already closed or closing, no action is taken. Listen to the 'close' event to be notified when the 161 | * connection has been closed. 162 | */ 163 | public close(): void { 164 | if (this.websocket !== null) { 165 | this.readyState = ReadyState.CLOSING; 166 | this.websocket.close(); 167 | } else if (this.readyState !== ReadyState.CLOSED) { 168 | this.onClose(); 169 | } 170 | } 171 | 172 | /** 173 | * Sends the given SDP offer to the signaling service. 174 | * 175 | * Typically, only the 'VIEWER' role should send an SDP offer. 176 | * @param {RTCSessionDescription} sdpOffer - SDP offer to send. 177 | * @param {string} [recipientClientId] - ID of the client to send the message to. Required for 'MASTER' role. Should not be present for 'VIEWER' role. 178 | * @param {string} [correlationId] - Unique ID for this message. If this is present and there is an error, 179 | * Signaling will send a StatusResponse message describing the error. If this is not present, no error will be returned. 180 | */ 181 | public sendSdpOffer(sdpOffer: RTCSessionDescription, recipientClientId?: string, correlationId?: string): void { 182 | this.sendMessage(MessageType.SDP_OFFER, sdpOffer, recipientClientId, correlationId); 183 | } 184 | 185 | /** 186 | * Sends the given SDP answer to the signaling service. 187 | * 188 | * Typically, only the 'MASTER' role should send an SDP answer. 189 | * @param {RTCSessionDescription} sdpAnswer - SDP answer to send. 190 | * @param {string} [recipientClientId] - ID of the client to send the message to. Required for 'MASTER' role. Should not be present for 'VIEWER' role. 191 | * @param {string} [correlationId] - Unique ID for this message. If this is present and there is an error, 192 | * Signaling will send a StatusResponse message describing the error. If this is not present, no error will be returned. 193 | */ 194 | public sendSdpAnswer(sdpAnswer: RTCSessionDescription, recipientClientId?: string, correlationId?: string): void { 195 | this.sendMessage(MessageType.SDP_ANSWER, sdpAnswer, recipientClientId, correlationId); 196 | } 197 | 198 | /** 199 | * Sends the given ICE candidate to the signaling service. 200 | * 201 | * Typically, both the 'VIEWER' role and 'MASTER' role should send ICE candidates. 202 | * @param {RTCIceCandidate} iceCandidate - ICE candidate to send. 203 | * @param {string} [recipientClientId] - ID of the client to send the message to. Required for 'MASTER' role. Should not be present for 'VIEWER' role. 204 | * @param {string} [correlationId] - Unique ID for this message. If this is present and there is an error, 205 | * Signaling will send a StatusResponse message describing the error. If this is not present, no error will be returned. 206 | */ 207 | public sendIceCandidate(iceCandidate: RTCIceCandidate, recipientClientId?: string, correlationId?: string): void { 208 | this.sendMessage(MessageType.ICE_CANDIDATE, iceCandidate, recipientClientId, correlationId); 209 | } 210 | 211 | /** 212 | * Validates the WebSocket connection is open and that the recipient client id is present if sending as the 'MASTER'. Encodes the given message payload 213 | * and sends the message to the signaling service. 214 | */ 215 | private sendMessage(action: MessageType, messagePayload: object, recipientClientId?: string, correlationId?: string): void { 216 | if (this.readyState !== ReadyState.OPEN) { 217 | throw new Error('Could not send message because the connection to the signaling service is not open.'); 218 | } 219 | this.validateRecipientClientId(recipientClientId); 220 | this.validateCorrelationId(correlationId); 221 | 222 | this.websocket.send( 223 | JSON.stringify({ 224 | action, 225 | messagePayload: SignalingClient.serializeJSONObjectAsBase64String(messagePayload), 226 | recipientClientId: recipientClientId || undefined, 227 | correlationId: correlationId || undefined, 228 | }), 229 | ); 230 | } 231 | 232 | /** 233 | * Removes all event listeners from the WebSocket and removes the reference to the WebSocket object. 234 | */ 235 | private cleanupWebSocket(): void { 236 | if (this.websocket === null) { 237 | return; 238 | } 239 | this.websocket.removeEventListener('open', this.onOpen); 240 | this.websocket.removeEventListener('message', this.onMessage); 241 | this.websocket.removeEventListener('error', this.onError); 242 | this.websocket.removeEventListener('close', this.onClose); 243 | this.websocket = null; 244 | } 245 | 246 | /** 247 | * WebSocket 'open' event handler. Forwards the event on to listeners. 248 | */ 249 | private onOpen(): void { 250 | this.readyState = ReadyState.OPEN; 251 | this.emit('open'); 252 | } 253 | 254 | /** 255 | * WebSocket 'message' event handler. Attempts to parse the message and handle it according to the message type. 256 | */ 257 | private onMessage(event: MessageEvent): void { 258 | let parsedEventData: WebSocketMessage; 259 | let parsedMessagePayload: object; 260 | try { 261 | parsedEventData = JSON.parse(event.data) as WebSocketMessage; 262 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 263 | } catch (e) { 264 | // For forwards compatibility we ignore messages that are not able to be parsed. 265 | // TODO: Consider how to make it easier for users to be aware of dropped messages. 266 | return; 267 | } 268 | try { 269 | parsedMessagePayload = SignalingClient.parseJSONObjectFromBase64String(parsedEventData.messagePayload); 270 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 271 | } catch (e) { 272 | // TODO: Consider how to make it easier for users to be aware of dropped messages. 273 | } 274 | const { messageType, senderClientId, statusResponse } = parsedEventData; 275 | if (!parsedMessagePayload && !statusResponse) { 276 | // TODO: Consider how to make it easier for users to be aware of dropped messages. 277 | return; 278 | } 279 | 280 | switch (messageType) { 281 | case MessageType.SDP_OFFER: 282 | this.emit('sdpOffer', parsedMessagePayload, senderClientId); 283 | this.emitPendingIceCandidates(senderClientId); 284 | return; 285 | case MessageType.SDP_ANSWER: 286 | this.emit('sdpAnswer', parsedMessagePayload, senderClientId); 287 | this.emitPendingIceCandidates(senderClientId); 288 | return; 289 | case MessageType.ICE_CANDIDATE: 290 | this.emitOrQueueIceCandidate(parsedMessagePayload, senderClientId); 291 | return; 292 | case MessageType.STATUS_RESPONSE: 293 | this.emit('statusResponse', statusResponse); 294 | return; 295 | } 296 | } 297 | 298 | /** 299 | * Takes the given base64 encoded string and decodes it into a JSON object. 300 | */ 301 | private static parseJSONObjectFromBase64String(base64EncodedString: string): object { 302 | try { 303 | return JSON.parse(atob(base64EncodedString)); 304 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 305 | } catch (e) { 306 | return JSON.parse(Buffer.from(base64EncodedString, 'base64').toString()); 307 | } 308 | } 309 | 310 | /** 311 | * Takes the given JSON object and encodes it into a base64 string. 312 | */ 313 | private static serializeJSONObjectAsBase64String(object: object): string { 314 | try { 315 | return btoa(JSON.stringify(object)); 316 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 317 | } catch (e) { 318 | return Buffer.from(JSON.stringify(object)).toString('base64'); 319 | } 320 | } 321 | 322 | /** 323 | * If an SDP offer or answer has already been received from the given client, then the given ICE candidate is emitted. Otherwise, it is queued up for when 324 | * an SDP offer or answer is received. 325 | */ 326 | private emitOrQueueIceCandidate(iceCandidate: object, clientId?: string): void { 327 | const clientIdKey = clientId || SignalingClient.DEFAULT_CLIENT_ID; 328 | if (this.hasReceivedRemoteSDPByClientId[clientIdKey]) { 329 | this.emit('iceCandidate', iceCandidate, clientId); 330 | } else { 331 | if (!this.pendingIceCandidatesByClientId[clientIdKey]) { 332 | this.pendingIceCandidatesByClientId[clientIdKey] = []; 333 | } 334 | this.pendingIceCandidatesByClientId[clientIdKey].push(iceCandidate); 335 | } 336 | } 337 | 338 | /** 339 | * Emits any pending ICE candidates for the given client and records that an SDP offer or answer has been received from the client. 340 | */ 341 | private emitPendingIceCandidates(clientId?: string): void { 342 | const clientIdKey = clientId || SignalingClient.DEFAULT_CLIENT_ID; 343 | this.hasReceivedRemoteSDPByClientId[clientIdKey] = true; 344 | const pendingIceCandidates = this.pendingIceCandidatesByClientId[clientIdKey]; 345 | if (!pendingIceCandidates) { 346 | return; 347 | } 348 | delete this.pendingIceCandidatesByClientId[clientIdKey]; 349 | pendingIceCandidates.forEach((iceCandidate) => { 350 | this.emit('iceCandidate', iceCandidate, clientId); 351 | }); 352 | } 353 | 354 | /** 355 | * Throws an error if the recipient client id is null and the current role is 'MASTER' as all messages sent as 'MASTER' should have a recipient client id. 356 | */ 357 | private validateRecipientClientId(recipientClientId?: string): void { 358 | if (this.config.role === Role.VIEWER && recipientClientId) { 359 | throw new Error('Unexpected recipient client id. As the VIEWER, messages must not be sent with a recipient client id.'); 360 | } 361 | } 362 | 363 | /** 364 | * Throws an error if the correlationId does not fit the constraints mentioned in {@link https://docs.aws.amazon.com/kinesisvideostreams-webrtc-dg/latest/devguide/kvswebrtc-websocket-apis4.html the documentation}. 365 | */ 366 | private validateCorrelationId(correlationId?: string): void { 367 | if (correlationId && !/^[a-zA-Z0-9_.-]{1,256}$/.test(correlationId)) { 368 | throw new Error('Correlation id does not fit the constraint!'); 369 | } 370 | } 371 | 372 | /** 373 | * 'error' event handler. Forwards the error onto listeners. 374 | */ 375 | private onError(error: Error | Event): void { 376 | this.emit('error', error); 377 | } 378 | 379 | /** 380 | * 'close' event handler. Forwards the error onto listeners and cleans up the connection. 381 | */ 382 | private onClose(): void { 383 | this.readyState = ReadyState.CLOSED; 384 | this.cleanupWebSocket(); 385 | this.emit('close'); 386 | } 387 | } 388 | -------------------------------------------------------------------------------- /src/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { VERSION } from './index'; 2 | 3 | describe('index', () => { 4 | it('should export the version', () => { 5 | expect(VERSION).not.toBeFalsy(); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /*! 2 | Amazon Kinesis Video Streams WebRTC SDK for JavaScript 3 | Copyright 2019-2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. 4 | 5 | This product includes software developed at 6 | Amazon Web Services, Inc. (http://aws.amazon.com/). 7 | */ 8 | export { Role } from './Role'; 9 | export { SignalingClient } from './SignalingClient'; 10 | export { SigV4RequestSigner } from './SigV4RequestSigner'; 11 | export { QueryParams } from './QueryParams'; 12 | export { RequestSigner } from './RequestSigner'; 13 | 14 | export const VERSION = process.env.PACKAGE_VERSION; 15 | -------------------------------------------------------------------------------- /src/internal/DateProvider.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Provides dates with an offset to account for local clock skew. 3 | * 4 | * Unfortunately, WebSockets in the web do not provide any of the connection information needed to determine the clock skew from a failed connection request. 5 | * Therefore, a hard coded offset is used that is provided from the AWS SDK. 6 | * 7 | * See {@link https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/Config.html#correctClockSkew-property} 8 | */ 9 | export default class DateProvider { 10 | private readonly clockOffsetMs: number; 11 | 12 | public constructor(clockOffsetMs: number) { 13 | this.clockOffsetMs = clockOffsetMs; 14 | } 15 | 16 | /** 17 | * Gets the current date with any configured clock offset applied. 18 | */ 19 | public getDate(): Date { 20 | return new Date(Date.now() + this.clockOffsetMs); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/internal/testUtils.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | let realDate: DateConstructor; 3 | 4 | export const mockDateClass = (mockDate: Date): void => { 5 | realDate = Date; 6 | global.Date = class { 7 | constructor(date?: Date) { 8 | if (date) { 9 | return new realDate(date); 10 | } 11 | 12 | return mockDate; 13 | } 14 | 15 | static now(): number { 16 | return mockDate.getTime(); 17 | } 18 | } as DateConstructor; 19 | }; 20 | 21 | export const restoreDateClass = (): void => { 22 | global.Date = realDate; 23 | }; 24 | -------------------------------------------------------------------------------- /src/internal/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { validateValueNil, validateValueNonNil } from './utils'; 2 | 3 | describe('utils', () => { 4 | describe('validateValueNil', () => { 5 | it('should not throw for null value', () => { 6 | validateValueNil(null, 'test'); 7 | }); 8 | 9 | it('should not throw for undefined value', () => { 10 | validateValueNil(undefined, 'test'); 11 | }); 12 | 13 | it('should not throw for empty value', () => { 14 | validateValueNil('', 'test'); 15 | }); 16 | 17 | it('should throw for non-nil value', () => { 18 | expect(() => validateValueNil('not null', 'test')).toThrow('test should be null'); 19 | }); 20 | }); 21 | 22 | describe('validateValueNonNil', () => { 23 | it('should throw for null value', () => { 24 | expect(() => validateValueNonNil(null, 'test')).toThrow('test cannot be null'); 25 | }); 26 | 27 | it('should throw for undefined value', () => { 28 | expect(() => validateValueNonNil(undefined, 'test')).toThrow('test cannot be undefined'); 29 | }); 30 | 31 | it('should throw for empty value', () => { 32 | expect(() => validateValueNonNil('', 'test')).toThrow('test cannot be empty'); 33 | }); 34 | 35 | it('should not throw for non-nil value', () => { 36 | validateValueNonNil('not null', 'test'); 37 | }); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /src/internal/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Validates that the given value is not null, undefined, or empty string and throws an error if the condition is not met. 3 | */ 4 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 5 | export function validateValueNonNil(value: any, valueName: string): void { 6 | if (value === null) { 7 | throw new Error(`${valueName} cannot be null`); 8 | } else if (value === undefined) { 9 | throw new Error(`${valueName} cannot be undefined`); 10 | } else if (value === '') { 11 | throw new Error(`${valueName} cannot be empty`); 12 | } 13 | } 14 | 15 | /** 16 | * Validates that the given value is null, undefined, or empty string and throws an error if the condition is not met. 17 | */ 18 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 19 | export function validateValueNil(value: any, valueName: string): void { 20 | if (value !== null && value !== undefined && value !== '') { 21 | throw new Error(`${valueName} should be null`); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "sourceMap": true, 6 | "importHelpers": true, 7 | "moduleResolution": "node", 8 | "esModuleInterop": true, 9 | "target": "es5", 10 | "lib": [ 11 | "dom", 12 | "es5", 13 | "es2015" 14 | ], 15 | "outDir": "./lib", 16 | "baseUrl": ".", 17 | "noImplicitAny": true, 18 | "noFallthroughCasesInSwitch": true, 19 | "noImplicitReturns": true, 20 | "noImplicitThis": true, 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": true, 23 | "removeComments": false, 24 | "skipLibCheck": true 25 | }, 26 | "formatCodeOptions": { 27 | "indentSize": 4, 28 | "tabSize": 4 29 | }, 30 | "files": [ 31 | "src/index.ts" 32 | ], 33 | "include": [ 34 | "src/typings/**/*" 35 | ], 36 | "exclude": [ 37 | "node_modules/isomorphic-webcrypto/index.d.ts" 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin'); 2 | const LicenseWebpackPlugin = require('license-webpack-plugin').LicenseWebpackPlugin; 3 | const path = require('path'); 4 | const webpack = require('webpack'); 5 | const packageJson = require('./package.json'); 6 | const fs = require('fs'); 7 | 8 | const version = packageJson.version; 9 | console.log(`Package version: ${version}`); 10 | 11 | module.exports = { 12 | entry: { 13 | main: path.resolve(__dirname, 'src/index.ts'), 14 | }, 15 | output: { 16 | library: 'KVSWebRTC', 17 | libraryTarget: 'window', 18 | }, 19 | externals: { 20 | 'isomorphic-webcrypto': 'crypto', 21 | }, 22 | resolve: { 23 | extensions: ['.ts', '.js'], 24 | }, 25 | module: { 26 | rules: [ 27 | { 28 | test: /\.ts$/, 29 | use: [ 30 | { 31 | loader: 'ts-loader', 32 | options: { 33 | // disable type checker - we will use it in fork plugin 34 | transpileOnly: true, 35 | }, 36 | }, 37 | ], 38 | }, 39 | ], 40 | }, 41 | plugins: [ 42 | new ForkTsCheckerWebpackPlugin(), 43 | 44 | new webpack.EnvironmentPlugin({ 45 | PACKAGE_VERSION: version, 46 | }), 47 | 48 | new LicenseWebpackPlugin({ 49 | outputFilename: 'kvs-webrtc.LICENSE', 50 | addBanner: true, 51 | renderBanner: () => fs.readFileSync('./license/bundleLicenseBanner.txt', { encoding: 'utf-8' }).replace('VERSION', version), 52 | renderLicenses: modules => { 53 | let text = fs.readFileSync('./license/bundleLicenseHeader.txt', { encoding: 'utf-8' }); 54 | modules.forEach(module => { 55 | text += '\n'; 56 | text += `This product bundles ${module.name}, which is available under the ${module.licenseId} license:\n`; 57 | text += '\n'; 58 | text += module.licenseText 59 | .split('\n') 60 | .map(line => ` ${line}\n`) 61 | .join(''); 62 | }); 63 | return text; 64 | }, 65 | }), 66 | ], 67 | 68 | // Fail if there are any errors (such as a TypeScript type issue) 69 | bail: true, 70 | }; 71 | -------------------------------------------------------------------------------- /webpack.debug.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { merge } = require('webpack-merge'); 3 | 4 | module.exports = merge(require('./webpack.config'), { 5 | mode: 'development', 6 | 7 | output: { 8 | path: path.resolve(__dirname, 'dist'), 9 | filename: 'kvs-webrtc.js', 10 | }, 11 | 12 | // Include sourcemaps 13 | devtool: 'inline-source-map', 14 | }); 15 | -------------------------------------------------------------------------------- /webpack.dev.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { merge } = require('webpack-merge'); 3 | 4 | module.exports = merge(require('./webpack.config'), { 5 | mode: 'development', 6 | 7 | output: { 8 | path: path.resolve(__dirname, 'dist'), 9 | filename: 'kvs-webrtc.js', 10 | }, 11 | 12 | devServer: { 13 | static: { 14 | directory: path.join(__dirname, 'examples'), 15 | }, 16 | devMiddleware: { 17 | publicPath: '/', 18 | }, 19 | allowedHosts: 'auto', 20 | port: 3001, 21 | }, 22 | 23 | // Include sourcemaps 24 | devtool: 'inline-source-map', 25 | 26 | // Keep running even if there are errors 27 | bail: false, 28 | }); 29 | -------------------------------------------------------------------------------- /webpack.dist.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const TerserPlugin = require('terser-webpack-plugin'); 3 | const { merge } = require('webpack-merge'); 4 | 5 | // Define maximum asset size before gzipping 6 | const MAX_ASSET_SIZE_KIB = 30; 7 | const MAX_ASSET_SIZE_BYTES = MAX_ASSET_SIZE_KIB * 1024; 8 | 9 | module.exports = merge(require('./webpack.config'), { 10 | mode: 'production', 11 | 12 | output: { 13 | path: path.resolve(__dirname, 'dist'), 14 | filename: 'kvs-webrtc.min.js', 15 | }, 16 | 17 | // Make sure the asset is not accidentally growing in size 18 | // Make sure that size impact of adding new code is known 19 | performance: { 20 | hints: 'error', 21 | maxAssetSize: MAX_ASSET_SIZE_BYTES, 22 | maxEntrypointSize: MAX_ASSET_SIZE_BYTES, 23 | }, 24 | 25 | optimization: { 26 | minimizer: [ 27 | new TerserPlugin({ 28 | terserOptions: { 29 | output: { 30 | comments: /kvs-webrtc\.LICENSE/i, 31 | }, 32 | }, 33 | extractComments: false, 34 | }), 35 | ], 36 | }, 37 | }); 38 | --------------------------------------------------------------------------------