├── .gitignore ├── src ├── index.ts ├── hooks │ ├── index.ts │ ├── usePeerReducer.ts │ ├── useIceServers.spec.ts │ ├── useLocalMedia.ts │ ├── useSignalingClient.spec.ts │ ├── useSignalingClient.ts │ ├── useLocalMedia.spec.ts │ ├── useSignalingChannelEndpoints.spec.ts │ ├── useIceServers.ts │ ├── useSignalingChannelEndpoints.ts │ ├── useViewer.spec.ts │ ├── useViewer.ts │ ├── useMaster.ts │ └── useMaster.spec.ts ├── Peer.ts ├── withErrorLog.ts ├── debug.ts ├── ConfigOptions.ts ├── constants.ts └── logger.ts ├── package └── .gitignore ├── tslint.json ├── jest.config.js ├── __test__ ├── fixtures │ ├── config.json │ ├── getIceServerConfigCommandOutput.json │ └── getSignalingChannelEndpointCommandOutput.json └── mocks │ ├── mockSignalingClient.ts │ ├── mockKinesisVideoSignalingClient.ts │ ├── mockKinesisVideo.ts │ ├── mockNavigator.ts │ └── mockRTCPeerConnection.ts ├── tsconfig.json ├── .github └── workflows │ └── release.yml ├── .eslintrc.json ├── rollup.config.js ├── LICENSE.txt ├── README.md ├── CHANGELOG.md └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | .vscode 5 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { useMaster, useViewer } from "./hooks"; 2 | -------------------------------------------------------------------------------- /package/.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore everything in this directory 2 | * 3 | # Except this file 4 | !.gitignore -------------------------------------------------------------------------------- /src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export { useMaster } from "./useMaster"; 2 | export { useViewer } from "./useViewer"; 3 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint-react-hooks"], 3 | "rules": { 4 | "react-hooks-nesting": "error" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: "ts-jest", 3 | transform: { 4 | "^.+\\.(ts|tsx)?$": "ts-jest", 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /src/Peer.ts: -------------------------------------------------------------------------------- 1 | export interface Peer { 2 | id: string; 3 | connection?: RTCPeerConnection; 4 | isWaitingForMedia?: boolean; 5 | media?: MediaStream; 6 | } 7 | -------------------------------------------------------------------------------- /src/withErrorLog.ts: -------------------------------------------------------------------------------- 1 | export const withErrorLog = 2 | (fn: (e: Error) => void) => 3 | (error: Error): void => { 4 | console.error(error); 5 | return fn(error); 6 | }; 7 | -------------------------------------------------------------------------------- /__test__/fixtures/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "channelARN": "string", 3 | "channelEndpoint": "wss://testendpoint", 4 | "credentials": { "accessKeyId": "string", "secretAccessKey": "string" }, 5 | "region": "string" 6 | } 7 | -------------------------------------------------------------------------------- /__test__/fixtures/getIceServerConfigCommandOutput.json: -------------------------------------------------------------------------------- 1 | { 2 | "$metadata": {}, 3 | "IceServerList": [ 4 | { 5 | "Uris": ["https://test"], 6 | "Username": "test", 7 | "Password": "pass" 8 | } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /src/debug.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | 3 | export function usePrevious(value: T): T { 4 | const ref = useRef(value); 5 | 6 | useEffect(() => { 7 | ref.current = value; 8 | }); 9 | 10 | return ref.current; 11 | } 12 | -------------------------------------------------------------------------------- /__test__/fixtures/getSignalingChannelEndpointCommandOutput.json: -------------------------------------------------------------------------------- 1 | { 2 | "$metadata": {}, 3 | "ResourceEndpointList": [ 4 | { 5 | "Protocol": "WSS", 6 | "ResourceEndpoint": "wss://test" 7 | }, 8 | { 9 | "Protocol": "HTTPS", 10 | "ResourceEndpoint": "https://test" 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react", 4 | "module": "es2015", 5 | "moduleResolution": "node", 6 | "plugins": [ 7 | { 8 | "name": "typescript-tslint-plugin" 9 | } 10 | ], 11 | "resolveJsonModule": true, 12 | "sourceMap": true, 13 | "strict": true 14 | }, 15 | "include": ["src", "__test__"] 16 | } 17 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release to npm 2 | on: 3 | release: 4 | types: [created] 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - uses: actions/setup-node@v1 11 | with: 12 | node-version: "12.x" 13 | - run: npm run release 14 | env: 15 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 16 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "parserOptions": { 4 | "jsx": true, 5 | "useJSXTextNode": true 6 | }, 7 | "extends": ["plugin:@typescript-eslint/recommended"], 8 | "plugins": ["prettier", "@typescript-eslint", "react-hooks"], 9 | "rules": { 10 | "@typescript-eslint/explicit-function-return-type": "off", 11 | "prettier/prettier": "error", 12 | "react-hooks/rules-of-hooks": "error", 13 | "react-hooks/exhaustive-deps": "warn" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import pkg from "./package.json"; 2 | import commonjs from "@rollup/plugin-commonjs"; 3 | import resolve from "@rollup/plugin-node-resolve"; 4 | import typescript from "@rollup/plugin-typescript"; 5 | 6 | export default { 7 | input: "src/index.ts", 8 | output: [ 9 | { 10 | file: pkg.main, 11 | format: "cjs", 12 | sourcemap: true, 13 | }, 14 | { 15 | file: pkg.module, 16 | format: "esm", 17 | sourcemap: true, 18 | }, 19 | ], 20 | external: [ 21 | ...Object.keys(pkg.dependencies || {}), 22 | ...Object.keys(pkg.peerDependencies || {}), 23 | ], 24 | plugins: [commonjs(), resolve(), typescript()], 25 | }; 26 | -------------------------------------------------------------------------------- /src/ConfigOptions.ts: -------------------------------------------------------------------------------- 1 | import * as KVSWebRTC from "amazon-kinesis-video-streams-webrtc"; 2 | 3 | type AWSCredentials = { 4 | accessKeyId: string; 5 | secretAccessKey: string; 6 | sessionToken?: string; 7 | }; 8 | 9 | type MediaConfig = { 10 | audio?: boolean; 11 | video?: boolean | MediaTrackConstraints; 12 | }; 13 | 14 | export interface ConfigOptions { 15 | channelARN: string; 16 | credentials: AWSCredentials; 17 | debug?: boolean; 18 | region: string; 19 | } 20 | 21 | export interface PeerConfigOptions extends ConfigOptions { 22 | media: MediaConfig; 23 | } 24 | 25 | export interface SignalingClientConfigOptions extends ConfigOptions { 26 | channelEndpoint: string | undefined; 27 | clientId?: string; 28 | role: KVSWebRTC.Role; 29 | systemClockOffset: number; 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2020-2022 Patrick D. Carroll 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 10 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 11 | AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 12 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 13 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 14 | OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 15 | PERFORMANCE OF THIS SOFTWARE. -------------------------------------------------------------------------------- /__test__/mocks/mockSignalingClient.ts: -------------------------------------------------------------------------------- 1 | import { SignalingClient } from "amazon-kinesis-video-streams-webrtc"; 2 | 3 | export function mockSignalingClient({ 4 | open = () => null, 5 | close = () => null, 6 | sendIceCandidate = () => null, 7 | sendSdpAnswer = () => null, 8 | sendSdpOffer = () => null, 9 | } = {}): void { 10 | jest.spyOn(SignalingClient.prototype, "open").mockImplementation(open); 11 | jest.spyOn(SignalingClient.prototype, "close").mockImplementation(close); 12 | jest 13 | .spyOn(SignalingClient.prototype, "sendIceCandidate") 14 | .mockImplementation(sendIceCandidate); 15 | jest 16 | .spyOn(SignalingClient.prototype, "sendSdpAnswer") 17 | .mockImplementation(sendSdpAnswer); 18 | jest 19 | .spyOn(SignalingClient.prototype, "sendSdpOffer") 20 | .mockImplementation(sendSdpOffer); 21 | } 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Kinesis WebRTC 2 | 3 | An experimental library of React hooks for the [amazon-kinesis-video-streams-webrtc-sdk-js](https://github.com/awslabs/amazon-kinesis-video-streams-webrtc-sdk-js) library. 4 | 5 | Provides a simple, declarative API that can handle peer-to-peer connections within React components. 6 | 7 | **This library is still experimental and is therefore not yet suitable for production.** 8 | 9 | ## Getting Started 10 | 11 | Install the library: 12 | 13 | ```shell 14 | $ npm install react-kinesis-webrtc 15 | ``` 16 | 17 | Import KVS WebRTC hooks into your React project: 18 | 19 | ```javascript 20 | import { useMaster, useViewer } from "react-kinesis-webrtc"; 21 | ``` 22 | 23 | ## Documentation 24 | 25 | See the [Wiki](https://github.com/pdcarroll/react-kinesis-webrtc/wiki) for API documentation and example usage. 26 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const ERROR_CHANNEL_ARN_MISSING = "Missing channel ARN"; 2 | export const ERROR_CONNECTION_OBJECT_NOT_PROVIDED = 3 | "Please provide a connection object"; 4 | export const ERROR_ICE_CANDIDATE_NOT_FOUND = "No ice candidate found"; 5 | export const ERROR_ICE_SERVERS_RESPONSE = "Could not get ice servers response"; 6 | export const ERROR_PEER_CONNECTION_LOCAL_DESCRIPTION_REQUIRED = 7 | "Could not find local description for peer connection"; 8 | export const ERROR_PEER_CONNECTION_NOT_INITIALIZED = 9 | "Peer connection has not been initialized"; 10 | export const ERROR_PEER_CONNECTION_NOT_FOUND = "Peer connection not found"; 11 | export const ERROR_PEER_ID_MISSING = "Peer id is missing"; 12 | export const ERROR_RESOURCE_ENDPOINT_LIST_MISSING = 13 | "Missing ResourceEndpointList"; 14 | export const ERROR_SIGNALING_CLIENT_NOT_CONNECTED = 15 | "Signaling client connection has not been established"; 16 | -------------------------------------------------------------------------------- /__test__/mocks/mockKinesisVideoSignalingClient.ts: -------------------------------------------------------------------------------- 1 | import { mockClient, AwsStub } from "aws-sdk-client-mock"; 2 | import { 3 | GetIceServerConfigCommand, 4 | GetIceServerConfigCommandInput, 5 | GetIceServerConfigCommandOutput, 6 | KinesisVideoSignalingClient, 7 | } from "@aws-sdk/client-kinesis-video-signaling"; 8 | import * as getIceServerConfigCommandOutput from "../fixtures/getIceServerConfigCommandOutput.json"; 9 | 10 | const kinesisVideoSignalingClientMock = mockClient(KinesisVideoSignalingClient); 11 | 12 | export function mockGetIceServerConfig( 13 | error: Error | null, 14 | response: GetIceServerConfigCommandOutput = getIceServerConfigCommandOutput 15 | ): AwsStub { 16 | kinesisVideoSignalingClientMock 17 | .on(GetIceServerConfigCommand) 18 | [error ? "rejects" : "resolves"](error || response); 19 | 20 | return kinesisVideoSignalingClientMock; 21 | } 22 | 23 | export default kinesisVideoSignalingClientMock; 24 | -------------------------------------------------------------------------------- /src/hooks/usePeerReducer.ts: -------------------------------------------------------------------------------- 1 | import { useReducer, Dispatch } from "react"; 2 | import { Peer } from "../Peer"; 3 | 4 | type State = Record; 5 | type Action = { 6 | type: "add" | "update" | "remove"; 7 | payload: Peer; 8 | }; 9 | 10 | function peerReducer(state: State, action: Action) { 11 | switch (action.type) { 12 | case "add": 13 | return { 14 | ...state, 15 | [action.payload.id as string]: action.payload, 16 | }; 17 | case "update": 18 | return { 19 | ...state, 20 | [action.payload.id as string]: { 21 | ...state[action.payload.id as string], 22 | ...action.payload, 23 | }, 24 | }; 25 | case "remove": 26 | const updated = { ...state }; 27 | delete updated[action.payload.id as string]; 28 | return updated; 29 | default: 30 | return state; 31 | } 32 | } 33 | 34 | export const usePeerReducer = ( 35 | initialState: State 36 | ): [State, Dispatch] => useReducer(peerReducer, initialState); 37 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | class Logger { 2 | logger?: Console; 3 | logPrefix = "[react-kinesis-webrtc]"; 4 | constructor(logger?: Console) { 5 | this.logger = logger; 6 | } 7 | private _log = ( 8 | message: unknown, 9 | prefix?: string, 10 | prefixStyle?: string 11 | ): void => { 12 | this.logger?.log( 13 | `%c${this.logPrefix} ${prefixStyle ? "%c" : ""}${prefix || ""}`, 14 | "color: gray;", 15 | prefixStyle, 16 | message 17 | ); 18 | }; 19 | log = (message: unknown, prefix?: string, prefixStyle?: string): void => { 20 | this._log(message, prefix, prefixStyle); 21 | }; 22 | logMaster = (message: unknown): void => { 23 | this.log(message, `MASTER:`, "color: royalblue; font-weight:bold;"); 24 | }; 25 | logViewer = (message: unknown): void => { 26 | this.log(message, `VIEWER:`, "color: green; font-weight: bold;"); 27 | }; 28 | } 29 | 30 | export const getLogger = ({ 31 | debug = false, 32 | }: { debug?: boolean } = {}): Logger => 33 | debug ? new Logger(console) : new Logger(); 34 | -------------------------------------------------------------------------------- /__test__/mocks/mockKinesisVideo.ts: -------------------------------------------------------------------------------- 1 | import { mockClient } from "aws-sdk-client-mock"; 2 | import { 3 | GetSignalingChannelEndpointCommand, 4 | GetSignalingChannelEndpointCommandInput, 5 | GetSignalingChannelEndpointCommandOutput, 6 | KinesisVideo, 7 | } from "@aws-sdk/client-kinesis-video"; 8 | import { AwsStub } from "aws-sdk-client-mock"; 9 | import * as getSignalingChannelEndpointCommandOutput from "../fixtures/getSignalingChannelEndpointCommandOutput.json"; 10 | 11 | const kinesisVideoMock = mockClient(KinesisVideo); 12 | 13 | export function mockGetSignalingChannelEndpoints( 14 | error: Error | null, 15 | response: GetSignalingChannelEndpointCommandOutput = getSignalingChannelEndpointCommandOutput 16 | ): AwsStub< 17 | GetSignalingChannelEndpointCommandInput, 18 | GetSignalingChannelEndpointCommandOutput 19 | > { 20 | kinesisVideoMock 21 | .on(GetSignalingChannelEndpointCommand) 22 | [error ? "rejects" : "resolves"](error || response); 23 | 24 | // @ts-expect-error - ??? 25 | return kinesisVideoMock; 26 | } 27 | 28 | export default kinesisVideoMock; 29 | -------------------------------------------------------------------------------- /__test__/mocks/mockNavigator.ts: -------------------------------------------------------------------------------- 1 | export function mockMediaDevices({ 2 | getUserMedia = mockGetUserMedia(), 3 | } = {}): void { 4 | Object.defineProperty(global, "navigator", { 5 | value: Object.assign( 6 | {}, 7 | { 8 | mediaDevices: { getUserMedia }, 9 | } 10 | ), 11 | writable: true, 12 | }); 13 | } 14 | 15 | export function mockMediaTrack(): Partial { 16 | return { 17 | stop: jest.fn(), 18 | }; 19 | } 20 | 21 | export function mockUserMediaStream({ 22 | mediaTracks = [mockMediaTrack() as MediaStreamTrack], 23 | } = {}): MediaStream { 24 | return { 25 | getTracks: () => mediaTracks, 26 | } as MediaStream; 27 | } 28 | 29 | export function mockGetUserMedia({ 30 | error, 31 | userMediaStream = mockUserMediaStream() as MediaStream, 32 | }: { error?: Error; userMediaStream?: MediaStream } = {}): jest.Mock { 33 | return jest.fn( 34 | (): Promise => 35 | new Promise((resolve, reject) => 36 | error ? reject(error) : resolve(userMediaStream as MediaStream) 37 | ) 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /src/hooks/useIceServers.spec.ts: -------------------------------------------------------------------------------- 1 | import { cleanup, renderHook } from "@testing-library/react-hooks"; 2 | import { 3 | GetIceServerConfigCommandInput, 4 | GetIceServerConfigCommandOutput, 5 | } from "@aws-sdk/client-kinesis-video-signaling"; 6 | import { AwsStub } from "aws-sdk-client-mock"; 7 | import * as mockConfig from "../../__test__/fixtures/config.json"; 8 | import { mockGetIceServerConfig } from "../../__test__/mocks/mockKinesisVideoSignalingClient"; 9 | import { useIceServers } from "./useIceServers"; 10 | 11 | let getIceServerConfigMock: AwsStub< 12 | GetIceServerConfigCommandInput, 13 | GetIceServerConfigCommandOutput 14 | >; 15 | 16 | beforeEach(() => { 17 | getIceServerConfigMock = mockGetIceServerConfig(null); 18 | }); 19 | 20 | afterEach(() => { 21 | getIceServerConfigMock.reset(); 22 | }); 23 | 24 | test("returns a list of ice servers", async () => { 25 | const { result, waitForNextUpdate } = renderHook(() => 26 | useIceServers(mockConfig) 27 | ); 28 | await waitForNextUpdate(); 29 | expect(result.current.iceServers).toBeInstanceOf(Array); 30 | }); 31 | 32 | test("returns an error", async () => { 33 | const error = new Error(); 34 | getIceServerConfigMock = mockGetIceServerConfig(error); 35 | const { result, waitForNextUpdate } = renderHook(() => 36 | useIceServers(mockConfig) 37 | ); 38 | await waitForNextUpdate(); 39 | expect(result.current.error).toBe(error); 40 | }); 41 | 42 | test("cancels on cleanup", async () => { 43 | const { result } = renderHook(() => useIceServers(mockConfig)); 44 | await cleanup(); 45 | expect(result.current.iceServers).toBeUndefined(); 46 | }); 47 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 4 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 5 | 6 | ## [0.3.0] - 2022-07-27 7 | 8 | ### Added 9 | 10 | - Support `sessionToken` in AWS credentials configuration 11 | - Enable cancellation of local media stream when an external error occurs 12 | 13 | ## [0.2.0] - 2022-05-04 14 | 15 | ### Added 16 | 17 | - (useMaster) Return param - `isOpen` - to indicate whether or not the underlying signaling client is open and ready to accept peers 18 | - License file 19 | 20 | ### Changed 21 | 22 | - Refactor: consolidated hooks in master & viewer 23 | - Moved documentation from README to Wiki 24 | 25 | ## [0.1.1] - 2022-05-02 26 | 27 | ### Changed 28 | 29 | - Fixes a bug that impacts sending a master local media stream to multiple remote peers simultaneously 30 | - Delays initialization of peer connections until the local media stream is active (for two-way connections). 31 | This fixes bugs caused by a race between the local media stream and remote peer connection events, most 32 | visible when a user doesn't immediately grant access to the device's media 33 | - Some cleanup in debug logging and variable names 34 | 35 | ## [0.1.0] - 2022-04-30 36 | 37 | ### Added 38 | 39 | - (useViewer) Viewer-only mode to support one-way peer connections 40 | 41 | ### Changed 42 | 43 | - (useViewer) Return errors from setting peer local description 44 | 45 | ## [0.0.5] - 2022-04-29 46 | 47 | ### Added 48 | 49 | - Tests 50 | - Option to view debug logs 51 | - npm pack run script 52 | 53 | ### Changed 54 | 55 | - Refactored peer management across hooks 56 | -------------------------------------------------------------------------------- /src/hooks/useLocalMedia.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from "react"; 2 | import { withErrorLog } from "../withErrorLog"; 3 | 4 | /** 5 | * @description Opens and returns local media stream. Closes stream on cleanup. 6 | **/ 7 | export function useLocalMedia({ 8 | audio, 9 | video, 10 | }: { 11 | audio?: boolean; 12 | video?: boolean | MediaTrackConstraints; 13 | }): { 14 | error: Error | undefined; 15 | media: MediaStream | undefined; 16 | cancel: () => void; 17 | } { 18 | const [media, setMedia] = useState(); 19 | const [error, setError] = useState(); 20 | const isCancelled = useRef(false); 21 | 22 | useEffect(() => { 23 | if (isCancelled.current) { 24 | return; 25 | } 26 | 27 | if (!video && !audio) { 28 | return; 29 | } 30 | 31 | let _media: MediaStream; 32 | 33 | navigator.mediaDevices 34 | .getUserMedia({ video, audio }) 35 | .then((mediaStream) => { 36 | _media = mediaStream; 37 | if (isCancelled.current) { 38 | _media.getTracks().forEach((track) => { 39 | track.stop(); 40 | }); 41 | return; 42 | } 43 | setMedia(mediaStream); 44 | }) 45 | .catch( 46 | withErrorLog((error) => { 47 | if (isCancelled.current) { 48 | return; 49 | } 50 | setError(error); 51 | }) 52 | ); 53 | 54 | return function cleanup() { 55 | isCancelled.current = true; 56 | 57 | _media?.getTracks().forEach((track: MediaStreamTrack) => { 58 | track.stop(); 59 | }); 60 | }; 61 | }, [video, audio, isCancelled]); 62 | 63 | const cancel = () => { 64 | isCancelled.current = true; 65 | }; 66 | 67 | return { 68 | error, 69 | media, 70 | cancel, 71 | }; 72 | } 73 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-kinesis-webrtc", 3 | "version": "0.3.0", 4 | "description": "An experimental library of React hooks for the AWS Kinesis WebRTC JavaScript SDK.", 5 | "main": "dist/index.js", 6 | "module": "dist/index.esm.js", 7 | "scripts": { 8 | "build": "rm -rf dist && rollup -c", 9 | "pack": "npm run build && npm pack --pack-destination=package", 10 | "test": "jest", 11 | "release": "npm run test && npm run build && npm publish" 12 | }, 13 | "author": "Patrick Carroll ", 14 | "license": "ISC", 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/pdcarroll/react-kinesis-webrtc" 18 | }, 19 | "keywords": [ 20 | "React", 21 | "Kinesis", 22 | "WebRTC", 23 | "Typescript" 24 | ], 25 | "devDependencies": { 26 | "@rollup/plugin-commonjs": "^15.1.0", 27 | "@rollup/plugin-node-resolve": "^9.0.0", 28 | "@rollup/plugin-typescript": "^6.0.0", 29 | "@testing-library/react-hooks": "^8.0.0", 30 | "@types/jest": "^27.4.1", 31 | "@types/node": "^14.10.1", 32 | "@types/react": "^16.9.49", 33 | "@types/uuid": "^8.3.0", 34 | "@typescript-eslint/eslint-plugin": "^4.2.0", 35 | "@typescript-eslint/parser": "^4.2.0", 36 | "aws-sdk-client-mock": "^0.6.2", 37 | "eslint": "^7.9.0", 38 | "eslint-config-prettier": "^6.11.0", 39 | "eslint-plugin-prettier": "^3.3.1", 40 | "eslint-plugin-react-hooks": "^4.1.2", 41 | "jest": "^27.5.1", 42 | "prettier": "^2.1.2", 43 | "react": "^17.0.2", 44 | "react-dom": "^17.0.2", 45 | "react-test-renderer": "^17.0.2", 46 | "rollup": "^2.29.0", 47 | "ts-jest": "^27.1.4", 48 | "ts-loader": "^8.0.4", 49 | "tslib": "^2.0.3", 50 | "tslint": "^6.1.3", 51 | "tslint-react-hooks": "^2.2.2", 52 | "typescript": "^4.0.2" 53 | }, 54 | "peerDependencies": { 55 | "react": "^17.0.2" 56 | }, 57 | "dependencies": { 58 | "@aws-sdk/client-kinesis-video": "^3.6.0", 59 | "@aws-sdk/client-kinesis-video-signaling": "^3.6.0", 60 | "amazon-kinesis-video-streams-webrtc": "^1.0.8", 61 | "uuid": "^8.3.0" 62 | }, 63 | "files": [ 64 | "dist" 65 | ] 66 | } 67 | -------------------------------------------------------------------------------- /src/hooks/useSignalingClient.spec.ts: -------------------------------------------------------------------------------- 1 | import { act, renderHook } from "@testing-library/react-hooks"; 2 | import * as KVSWebRTC from "amazon-kinesis-video-streams-webrtc"; 3 | import * as mockBaseConfig from "../../__test__/fixtures/config.json"; 4 | import { mockSignalingClient } from "../../__test__/mocks/mockSignalingClient"; 5 | import { SignalingClientConfigOptions } from "../ConfigOptions"; 6 | import { useSignalingClient } from "./useSignalingClient"; 7 | import { randomUUID } from "crypto"; 8 | 9 | const mockSignalingClientConfig: SignalingClientConfigOptions = { 10 | ...mockBaseConfig, 11 | clientId: randomUUID(), 12 | role: KVSWebRTC.Role.VIEWER, 13 | systemClockOffset: 0, 14 | }; 15 | 16 | beforeEach(() => { 17 | mockSignalingClient(); 18 | }); 19 | 20 | afterEach(() => { 21 | jest.clearAllMocks(); 22 | }); 23 | 24 | test("returns the signaling client instance", () => { 25 | const { result } = renderHook(() => 26 | useSignalingClient(mockSignalingClientConfig) 27 | ); 28 | expect(result.current.signalingClient).toBeDefined(); 29 | }); 30 | 31 | test("signaling client is not initialized when channelEndpoint is undefined", () => { 32 | const { result } = renderHook(() => 33 | useSignalingClient({ 34 | ...mockSignalingClientConfig, 35 | channelEndpoint: undefined, 36 | }) 37 | ); 38 | expect(result.current.signalingClient).toBeUndefined(); 39 | }); 40 | 41 | test("initializes the signaling client when channelEndpoint is defined", () => { 42 | let channelEndpoint = ""; 43 | const { result, rerender } = renderHook(() => 44 | useSignalingClient({ 45 | ...mockSignalingClientConfig, 46 | channelEndpoint, 47 | }) 48 | ); 49 | channelEndpoint = "wss://test"; 50 | rerender(); 51 | expect(result.current.signalingClient).toBeDefined(); 52 | }); 53 | 54 | test("returns a signaling client error", () => { 55 | const { result } = renderHook(() => 56 | useSignalingClient(mockSignalingClientConfig) 57 | ); 58 | const signalingClientError = new Error(); 59 | act(() => { 60 | result.current.signalingClient?.emit("error", signalingClientError); 61 | }); 62 | expect(result.current.error).toBe(signalingClientError); 63 | }); 64 | -------------------------------------------------------------------------------- /src/hooks/useSignalingClient.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { Role, SignalingClient } from "amazon-kinesis-video-streams-webrtc"; 3 | import { SignalingClientConfigOptions } from "../ConfigOptions"; 4 | 5 | /** 6 | * @description Creates a signaling channel. 7 | **/ 8 | export function useSignalingClient(config: SignalingClientConfigOptions): { 9 | error: Error | undefined; 10 | signalingClient: SignalingClient | undefined; 11 | } { 12 | const { 13 | channelARN, 14 | channelEndpoint, 15 | credentials: { accessKeyId = "", secretAccessKey = "", sessionToken = undefined } = {}, 16 | clientId, 17 | region, 18 | role, 19 | systemClockOffset, 20 | } = config; 21 | 22 | const [signalingClient, setSignalingClient] = useState(); 23 | const [signalingClientError, setSignalingClientError] = useState(); 24 | 25 | /** Create signaling client when endpoints are available. */ 26 | useEffect(() => { 27 | if (!channelEndpoint) { 28 | return; 29 | } 30 | 31 | if (!clientId && role === Role.VIEWER) { 32 | return; 33 | } 34 | 35 | setSignalingClient( 36 | new SignalingClient({ 37 | channelARN, 38 | channelEndpoint, 39 | clientId, 40 | credentials: { accessKeyId, secretAccessKey, sessionToken }, 41 | region, 42 | role, 43 | systemClockOffset, 44 | }) 45 | ); 46 | }, [ 47 | accessKeyId, 48 | channelARN, 49 | channelEndpoint, 50 | clientId, 51 | region, 52 | role, 53 | secretAccessKey, 54 | sessionToken, 55 | systemClockOffset, 56 | ]); 57 | 58 | /** Handle signaling client lifecycle. */ 59 | useEffect(() => { 60 | let isCancelled = false; 61 | 62 | function handleSignalingClientError(error: Error) { 63 | console.error(error); 64 | 65 | if (isCancelled) { 66 | return; 67 | } 68 | setSignalingClientError(error); 69 | } 70 | 71 | signalingClient?.on("error", handleSignalingClientError); 72 | 73 | return function cleanup() { 74 | isCancelled = true; 75 | 76 | signalingClient?.off("error", handleSignalingClientError); 77 | }; 78 | }, [signalingClient]); 79 | 80 | return { error: signalingClientError, signalingClient }; 81 | } 82 | -------------------------------------------------------------------------------- /src/hooks/useLocalMedia.spec.ts: -------------------------------------------------------------------------------- 1 | import { cleanup, renderHook } from "@testing-library/react-hooks"; 2 | import { 3 | mockGetUserMedia, 4 | mockMediaDevices, 5 | mockMediaTrack, 6 | mockUserMediaStream, 7 | } from "../../__test__/mocks/mockNavigator"; 8 | import { useLocalMedia } from "./useLocalMedia"; 9 | 10 | beforeEach(() => { 11 | mockMediaDevices(); 12 | }); 13 | 14 | test("returns a media stream", async () => { 15 | const { result, waitForNextUpdate } = renderHook(() => 16 | useLocalMedia({ audio: true, video: true }) 17 | ); 18 | await waitForNextUpdate(); 19 | expect(result.current.media).toBeDefined(); 20 | }); 21 | 22 | test("returns an error", async () => { 23 | const getUserMediaError = new Error(); 24 | mockMediaDevices({ 25 | getUserMedia: mockGetUserMedia({ error: getUserMediaError }), 26 | }); 27 | const { result, waitForNextUpdate } = renderHook(() => 28 | useLocalMedia({ audio: true, video: true }) 29 | ); 30 | await waitForNextUpdate(); 31 | expect(result.current.error).toBe(getUserMediaError); 32 | }); 33 | 34 | test("stops media stream tracks when options are updated", async () => { 35 | let audio = true; 36 | const video = true; 37 | const mediaTrack = mockMediaTrack(); 38 | const userMediaStream = mockUserMediaStream({ 39 | mediaTracks: [mediaTrack as MediaStreamTrack], 40 | }); 41 | mockMediaDevices({ 42 | getUserMedia: mockGetUserMedia({ userMediaStream }), 43 | }); 44 | const { rerender, waitForNextUpdate } = renderHook(() => 45 | useLocalMedia({ audio, video }) 46 | ); 47 | await waitForNextUpdate(); 48 | audio = false; 49 | rerender(); 50 | expect(mediaTrack.stop).toHaveBeenCalledTimes(1); 51 | }); 52 | 53 | test("cancels media stream on cleanup", async () => { 54 | const { result } = renderHook(() => 55 | useLocalMedia({ audio: true, video: true }) 56 | ); 57 | await cleanup(); 58 | expect(result.current.media).toBeUndefined(); 59 | }); 60 | 61 | test("does not access local media when media config options are not set", () => { 62 | const getUserMedia = mockGetUserMedia(); 63 | mockMediaDevices({ getUserMedia }); 64 | renderHook(() => useLocalMedia({ audio: false, video: false })); 65 | expect(getUserMedia).toHaveBeenCalledTimes(0); 66 | }); 67 | 68 | test("is cancellable", async () => { 69 | const { result, rerender } = renderHook(() => 70 | useLocalMedia({ audio: true, video: true }) 71 | ); 72 | result.current.cancel(); 73 | rerender(); 74 | expect(result.current.media).toBeUndefined(); 75 | }); 76 | -------------------------------------------------------------------------------- /src/hooks/useSignalingChannelEndpoints.spec.ts: -------------------------------------------------------------------------------- 1 | import { cleanup, renderHook } from "@testing-library/react-hooks"; 2 | import { 3 | GetSignalingChannelEndpointCommandInput, 4 | GetSignalingChannelEndpointCommandOutput, 5 | } from "@aws-sdk/client-kinesis-video"; 6 | import * as KVSWebRTC from "amazon-kinesis-video-streams-webrtc"; 7 | import { AwsStub } from "aws-sdk-client-mock"; 8 | import * as getSignalingChannelEndpointCommandOutput from "../../__test__/fixtures/getSignalingChannelEndpointCommandOutput.json"; 9 | import kinesisVideoMock, { 10 | mockGetSignalingChannelEndpoints, 11 | } from "../../__test__/mocks/mockKinesisVideo"; 12 | import { useSignalingChannelEndpoints } from "./useSignalingChannelEndpoints"; 13 | 14 | let getSignalingChannelEndpointsMock: AwsStub< 15 | GetSignalingChannelEndpointCommandInput, 16 | GetSignalingChannelEndpointCommandOutput 17 | >; 18 | 19 | beforeEach(() => { 20 | getSignalingChannelEndpointsMock = mockGetSignalingChannelEndpoints(null); 21 | }); 22 | 23 | afterEach(() => { 24 | getSignalingChannelEndpointsMock.reset(); 25 | }); 26 | 27 | test("returns a list of signaling channel endpoints", async () => { 28 | getSignalingChannelEndpointsMock = mockGetSignalingChannelEndpoints( 29 | null, 30 | getSignalingChannelEndpointCommandOutput 31 | ); 32 | const { result, waitForNextUpdate } = renderHook(() => 33 | useSignalingChannelEndpoints({ 34 | channelARN: "x", 35 | role: KVSWebRTC.Role.MASTER, 36 | // @ts-expect-error - client mock 37 | kinesisVideoClient: kinesisVideoMock, 38 | }) 39 | ); 40 | await waitForNextUpdate(); 41 | expect(result.current.signalingChannelEndpoints).toEqual({ 42 | WSS: "wss://test", 43 | HTTPS: "https://test", 44 | }); 45 | }); 46 | 47 | test("returns an error", async () => { 48 | const error = new Error(); 49 | getSignalingChannelEndpointsMock = mockGetSignalingChannelEndpoints(error); 50 | const { result, waitForNextUpdate } = renderHook(() => 51 | useSignalingChannelEndpoints({ 52 | channelARN: "x", 53 | role: KVSWebRTC.Role.MASTER, 54 | // @ts-expect-error - client mock 55 | kinesisVideoClient: kinesisVideoMock, 56 | }) 57 | ); 58 | await waitForNextUpdate(); 59 | expect(result.current.error).toBe(error); 60 | }); 61 | 62 | test("cancels request on cleanup", async () => { 63 | const { result } = renderHook(() => 64 | useSignalingChannelEndpoints({ 65 | channelARN: "x", 66 | role: KVSWebRTC.Role.MASTER, 67 | // @ts-expect-error - client mock 68 | kinesisVideoClient: kinesisVideoMock, 69 | }) 70 | ); 71 | await cleanup(); 72 | expect(result.current.signalingChannelEndpoints).toBeUndefined(); 73 | }); 74 | -------------------------------------------------------------------------------- /src/hooks/useIceServers.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { 3 | GetIceServerConfigCommand, 4 | KinesisVideoSignalingClient, 5 | IceServer, 6 | } from "@aws-sdk/client-kinesis-video-signaling"; 7 | import { ConfigOptions } from "../ConfigOptions"; 8 | import { ERROR_ICE_SERVERS_RESPONSE } from "../constants"; 9 | import { withErrorLog } from "../withErrorLog"; 10 | 11 | /** 12 | * @description Fetches ice servers for a signaling channel. 13 | **/ 14 | export function useIceServers( 15 | config: ConfigOptions & { 16 | channelEndpoint: string | undefined; 17 | } 18 | ): { 19 | error: Error | undefined; 20 | iceServers: RTCIceServer[] | undefined; 21 | } { 22 | const { 23 | channelARN, 24 | channelEndpoint, 25 | credentials: { accessKeyId = "", secretAccessKey = "", sessionToken = undefined} = {}, 26 | region, 27 | } = config; 28 | const [error, setError] = useState(); 29 | const [iceServers, setIceServers] = useState(); 30 | 31 | useEffect(() => { 32 | if (!channelEndpoint) { 33 | return; 34 | } 35 | 36 | let isCancelled = false; 37 | 38 | const kinesisVideoSignalingChannelsClient = new KinesisVideoSignalingClient( 39 | { 40 | region, 41 | credentials: { 42 | accessKeyId, 43 | secretAccessKey, 44 | sessionToken, 45 | }, 46 | endpoint: channelEndpoint, 47 | } 48 | ); 49 | 50 | const getIceServerConfigCommand = new GetIceServerConfigCommand({ 51 | ChannelARN: channelARN, 52 | }); 53 | 54 | kinesisVideoSignalingChannelsClient 55 | .send(getIceServerConfigCommand) 56 | .then((getIceServerConfigResponse) => { 57 | if (!getIceServerConfigResponse) { 58 | throw new Error(ERROR_ICE_SERVERS_RESPONSE); 59 | } 60 | 61 | if (!getIceServerConfigResponse.IceServerList) { 62 | throw new Error(ERROR_ICE_SERVERS_RESPONSE); 63 | } 64 | 65 | const dict: RTCIceServer[] = [ 66 | { urls: `stun:stun.kinesisvideo.${region}.amazonaws.com:443` }, 67 | ]; 68 | 69 | getIceServerConfigResponse?.IceServerList?.forEach( 70 | (iceServer: IceServer) => { 71 | if (!iceServer.Uris) { 72 | return; 73 | } 74 | dict.push({ 75 | urls: iceServer.Uris, 76 | username: iceServer.Username, 77 | credential: iceServer.Password, 78 | }); 79 | } 80 | ); 81 | 82 | return dict; 83 | }) 84 | .then((iceServers) => { 85 | if (isCancelled) { 86 | return; 87 | } 88 | setIceServers(iceServers); 89 | }) 90 | .catch( 91 | withErrorLog((error) => { 92 | if (isCancelled) { 93 | return; 94 | } 95 | setError(error); 96 | }) 97 | ); 98 | 99 | return function cleanup() { 100 | isCancelled = true; 101 | }; 102 | }, [accessKeyId, channelARN, channelEndpoint, region, secretAccessKey]); 103 | 104 | return { error, iceServers }; 105 | } 106 | -------------------------------------------------------------------------------- /src/hooks/useSignalingChannelEndpoints.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { 3 | GetSignalingChannelEndpointCommand, 4 | GetSignalingChannelEndpointOutput, 5 | KinesisVideo, 6 | ResourceEndpointListItem, 7 | } from "@aws-sdk/client-kinesis-video"; 8 | import * as KVSWebRTC from "amazon-kinesis-video-streams-webrtc"; 9 | import { 10 | ERROR_CHANNEL_ARN_MISSING, 11 | ERROR_RESOURCE_ENDPOINT_LIST_MISSING, 12 | } from "../constants"; 13 | import { withErrorLog } from "../withErrorLog"; 14 | 15 | type SignalingChannelEndpoints = { 16 | WSS?: string; 17 | HTTPS?: string; 18 | }; 19 | 20 | /** 21 | * @description Maps AWS KinesisVideo output to readable format. 22 | **/ 23 | function mapSignalingChannelEndpoints( 24 | data: GetSignalingChannelEndpointOutput 25 | ): SignalingChannelEndpoints { 26 | if (!Array.isArray(data.ResourceEndpointList)) { 27 | throw new Error(ERROR_RESOURCE_ENDPOINT_LIST_MISSING); 28 | } 29 | 30 | const endpointsByProtocol = data.ResourceEndpointList.reduce( 31 | ( 32 | endpoints: SignalingChannelEndpoints, 33 | endpoint: ResourceEndpointListItem 34 | ) => { 35 | if (!endpoint.Protocol) { 36 | return endpoints; 37 | } 38 | endpoints[endpoint.Protocol as "WSS" | "HTTPS"] = 39 | endpoint.ResourceEndpoint; 40 | return endpoints; 41 | }, 42 | {} 43 | ); 44 | 45 | return endpointsByProtocol; 46 | } 47 | 48 | /** 49 | * @description Fetches signaling channel endpoints. 50 | **/ 51 | export function useSignalingChannelEndpoints(config: { 52 | channelARN: string; 53 | role: KVSWebRTC.Role; 54 | kinesisVideoClient: KinesisVideo; 55 | }): { 56 | error: Error | undefined; 57 | signalingChannelEndpoints: SignalingChannelEndpoints | undefined; 58 | } { 59 | const { channelARN, kinesisVideoClient, role } = config; 60 | const [error, setError] = useState(); 61 | const [signalingChannelEndpoints, setSignalingChannelEndpoints] = 62 | useState(); 63 | 64 | if (!channelARN) { 65 | throw new Error(ERROR_CHANNEL_ARN_MISSING); 66 | } 67 | 68 | useEffect(() => { 69 | let isCancelled = false; 70 | 71 | const command = new GetSignalingChannelEndpointCommand({ 72 | ChannelARN: channelARN, 73 | SingleMasterChannelEndpointConfiguration: { 74 | Protocols: ["WSS", "HTTPS"], 75 | Role: role, 76 | }, 77 | }); 78 | 79 | kinesisVideoClient 80 | .send(command) 81 | .then(mapSignalingChannelEndpoints) 82 | .then((endpoints) => { 83 | if (isCancelled) { 84 | return; 85 | } 86 | setSignalingChannelEndpoints(endpoints); 87 | }) 88 | .catch( 89 | withErrorLog((error) => { 90 | if (isCancelled) { 91 | return; 92 | } 93 | setError(typeof error === "string" ? new Error(error) : error); 94 | }) 95 | ); 96 | 97 | return function cleanup() { 98 | isCancelled = true; 99 | }; 100 | }, [channelARN, kinesisVideoClient, role]); 101 | 102 | return { error, signalingChannelEndpoints }; 103 | } 104 | -------------------------------------------------------------------------------- /__test__/mocks/mockRTCPeerConnection.ts: -------------------------------------------------------------------------------- 1 | import { mockMediaTrack } from "./mockNavigator"; 2 | 3 | const mediaTrackMock = mockMediaTrack(); 4 | 5 | export interface MockRTCPeerConnection extends EventTarget { 6 | listeners?: { [key: string]: Array<(data?: unknown) => void> }; 7 | addEventListener: (event: string, callback: (data: unknown) => void) => void; 8 | addTrack: jest.Mock; 9 | createAnswer: () => Promise; 10 | createOffer: () => Promise; 11 | iceConnectionState: RTCIceConnectionState; 12 | localDescription: { 13 | type: string; 14 | sdp: string; 15 | }; 16 | setLocalDescription: (description: RTCSessionDescription) => void; 17 | setRemoteDescription: (description: RTCSessionDescription) => void; 18 | } 19 | 20 | type MockOptions = { 21 | RTCPeerConnection?: { 22 | setLocalDescription?: { 23 | error?: Error; 24 | response?: RTCSessionDescription; 25 | }; 26 | }; 27 | }; 28 | 29 | export function mockRTCPeerConnection(options?: MockOptions): void { 30 | Object.defineProperty(global, "RTCIceCandidate", { 31 | value: class MockRTCIceCandidate { 32 | candidate = { 33 | toJSON: () => null, 34 | }; 35 | }, 36 | writable: true, 37 | }); 38 | 39 | Object.defineProperty(global, "MediaStream", { 40 | value: class MockMediaStream { 41 | getTracks = () => [mediaTrackMock]; 42 | }, 43 | writable: true, 44 | }); 45 | 46 | Object.defineProperty(global, "RTCPeerConnection", { 47 | value: class MockRTCPeerConnection extends EventTarget { 48 | private listeners: { 49 | [key: string]: Array<(data?: unknown) => void>; 50 | } = {}; 51 | addEventListener = ( 52 | event: string, 53 | callback: (data?: unknown) => void 54 | ) => { 55 | this.listeners[event] 56 | ? this.listeners[event].push(callback) 57 | : (this.listeners[event] = [callback]); 58 | }; 59 | addTransceiver = jest.fn(() => null); 60 | addTrack = jest.fn().mockImplementation(() => { 61 | if (this.listeners.track) { 62 | this.listeners.track.forEach((callback) => 63 | callback({ streams: [new MediaStream()] }) 64 | ); 65 | } 66 | }); 67 | close = jest.fn(); 68 | createAnswer = () => 69 | Promise.resolve({ 70 | sdp: "", 71 | type: "", 72 | toJSON: () => ({}), 73 | }); 74 | createOffer: () => Promise> = () => 75 | Promise.resolve({ 76 | type: "offer", 77 | sdp: "", 78 | }); 79 | dispatchEvent = (event: Event) => { 80 | if (this.listeners[event.type]) { 81 | this.listeners[event.type].forEach((callback) => { 82 | callback(event); 83 | }); 84 | } 85 | return event.cancelable; 86 | }; 87 | getTransceivers = () => [{ direction: "" }]; 88 | localDescription?: RTCSessionDescription; 89 | setLocalDescription = (description: RTCSessionDescription) => 90 | new Promise((resolve, reject) => { 91 | if (options?.RTCPeerConnection?.setLocalDescription?.error) { 92 | return reject(options.RTCPeerConnection.setLocalDescription.error); 93 | } 94 | this.localDescription = 95 | options?.RTCPeerConnection?.setLocalDescription?.response || 96 | description; 97 | return resolve(void 0); 98 | }); 99 | setRemoteDescription = () => null; 100 | }, 101 | writable: true, 102 | }); 103 | } 104 | -------------------------------------------------------------------------------- /src/hooks/useViewer.spec.ts: -------------------------------------------------------------------------------- 1 | import { act, cleanup, renderHook } from "@testing-library/react-hooks"; 2 | import { 3 | GetSignalingChannelEndpointCommandInput, 4 | GetSignalingChannelEndpointCommandOutput, 5 | } from "@aws-sdk/client-kinesis-video"; 6 | import { 7 | GetIceServerConfigCommandInput, 8 | GetIceServerConfigCommandOutput, 9 | } from "@aws-sdk/client-kinesis-video-signaling"; 10 | import { AwsStub } from "aws-sdk-client-mock"; 11 | import { SignalingClient } from "amazon-kinesis-video-streams-webrtc"; 12 | import * as mockConfig from "../../__test__/fixtures/config.json"; 13 | import { 14 | mockMediaDevices, 15 | mockGetUserMedia, 16 | mockMediaTrack, 17 | mockUserMediaStream, 18 | } from "../../__test__/mocks/mockNavigator"; 19 | import { mockGetSignalingChannelEndpoints } from "../../__test__/mocks/mockKinesisVideo"; 20 | import { mockGetIceServerConfig } from "../../__test__/mocks/mockKinesisVideoSignalingClient"; 21 | import { mockRTCPeerConnection } from "../../__test__/mocks/mockRTCPeerConnection"; 22 | import { mockSignalingClient } from "../../__test__/mocks/mockSignalingClient"; 23 | import { useViewer } from "./useViewer"; 24 | 25 | const mockViewerConfig = { 26 | ...mockConfig, 27 | media: { audio: true, video: true }, 28 | }; 29 | 30 | let getIceServerConfigMock: AwsStub< 31 | GetIceServerConfigCommandInput, 32 | GetIceServerConfigCommandOutput 33 | >; 34 | let getSignalingChannelEndpointsMock: AwsStub< 35 | GetSignalingChannelEndpointCommandInput, 36 | GetSignalingChannelEndpointCommandOutput 37 | >; 38 | 39 | function mockSignalingClientOpen(signalingClient: SignalingClient) { 40 | act(() => { 41 | signalingClient.emit("open", new Error()); 42 | }); 43 | } 44 | 45 | beforeEach(() => { 46 | mockMediaDevices(); 47 | mockRTCPeerConnection(); 48 | mockSignalingClient(); 49 | getIceServerConfigMock = mockGetIceServerConfig(null); 50 | getSignalingChannelEndpointsMock = mockGetSignalingChannelEndpoints(null); 51 | }); 52 | 53 | afterEach(() => { 54 | getIceServerConfigMock.reset(); 55 | getSignalingChannelEndpointsMock.reset(); 56 | jest.clearAllMocks(); 57 | }); 58 | 59 | test("returns the local media stream", async () => { 60 | const { result, waitForNextUpdate } = renderHook(() => 61 | useViewer(mockViewerConfig) 62 | ); 63 | await waitForNextUpdate(); 64 | expect(result.current.localMedia).toBeDefined(); 65 | }); 66 | 67 | test("stops the local media stream on cleanup", async () => { 68 | const mediaTrack = mockMediaTrack(); 69 | const userMediaStream = mockUserMediaStream({ 70 | mediaTracks: [mediaTrack as MediaStreamTrack], 71 | }); 72 | mockMediaDevices({ 73 | getUserMedia: mockGetUserMedia({ userMediaStream }), 74 | }); 75 | const { waitForNextUpdate } = renderHook(() => useViewer(mockViewerConfig)); 76 | await waitForNextUpdate(); 77 | await cleanup(); 78 | expect(mediaTrack.stop).toHaveBeenCalledTimes(1); 79 | }); 80 | 81 | test("closes the signaling client on cleanup", async () => { 82 | const { result, waitForNextUpdate } = renderHook(() => 83 | useViewer(mockViewerConfig) 84 | ); 85 | await waitForNextUpdate(); 86 | await cleanup(); 87 | expect( 88 | (result.current._signalingClient as SignalingClient).close 89 | ).toHaveBeenCalledTimes(1); 90 | }); 91 | 92 | test("returns an RTC peer connection", async () => { 93 | const { result, waitForNextUpdate } = renderHook(() => 94 | useViewer(mockViewerConfig) 95 | ); 96 | await waitForNextUpdate(); 97 | expect(result.current.peer?.connection).toBeDefined(); 98 | }); 99 | 100 | test("sends local media stream to the peer", async () => { 101 | const { result, waitForNextUpdate } = renderHook(() => 102 | useViewer(mockViewerConfig) 103 | ); 104 | await waitForNextUpdate(); 105 | expect(result.current.peer?.connection?.addTrack).toHaveBeenCalledTimes(1); 106 | }); 107 | 108 | test("returns the remote peer media stream", async () => { 109 | const { result, waitForNextUpdate } = renderHook(() => 110 | useViewer(mockViewerConfig) 111 | ); 112 | await waitForNextUpdate(); 113 | expect(result.current.peer?.media).toBeDefined(); 114 | }); 115 | 116 | test("returns a local media stream error", async () => { 117 | const getUserMediaError = new Error(); 118 | mockMediaDevices({ 119 | getUserMedia: mockGetUserMedia({ error: getUserMediaError }), 120 | }); 121 | const { result, waitForNextUpdate } = renderHook(() => 122 | useViewer(mockViewerConfig) 123 | ); 124 | await waitForNextUpdate(); 125 | expect(result.current.error).toBe(getUserMediaError); 126 | }); 127 | 128 | test("returns a signaling channel endpoint error", async () => { 129 | const getSignalingChannelEndpointError = new Error(); 130 | getSignalingChannelEndpointsMock = mockGetSignalingChannelEndpoints( 131 | getSignalingChannelEndpointError 132 | ); 133 | const { result, waitForNextUpdate } = renderHook(() => 134 | useViewer(mockViewerConfig) 135 | ); 136 | await waitForNextUpdate(); 137 | expect(result.current.error).toBe(getSignalingChannelEndpointError); 138 | }); 139 | 140 | test("returns an ice servers error", async () => { 141 | const getIceServerConfigError = new Error(); 142 | getIceServerConfigMock = mockGetIceServerConfig(getIceServerConfigError); 143 | const { result, waitForNextUpdate } = renderHook(() => 144 | useViewer(mockViewerConfig) 145 | ); 146 | await waitForNextUpdate(); 147 | expect(result.current.error).toBe(getIceServerConfigError); 148 | }); 149 | 150 | test("returns a signaling client error", async () => { 151 | const { result, waitForNextUpdate } = renderHook(() => 152 | useViewer(mockViewerConfig) 153 | ); 154 | await waitForNextUpdate(); 155 | const signalingClientError = new Error(); 156 | act(() => { 157 | result.current._signalingClient?.emit("error", signalingClientError); 158 | }); 159 | expect(result.current.error).toBe(signalingClientError); 160 | }); 161 | 162 | test("returns a peer error", async () => { 163 | const peerError = new Error(); 164 | mockRTCPeerConnection({ 165 | RTCPeerConnection: { 166 | setLocalDescription: { 167 | error: peerError, 168 | }, 169 | }, 170 | }); 171 | const { result, waitForNextUpdate } = renderHook(() => 172 | useViewer(mockViewerConfig) 173 | ); 174 | await waitForNextUpdate(); 175 | mockSignalingClientOpen(result.current._signalingClient as SignalingClient); 176 | await waitForNextUpdate(); 177 | expect(result.current.error).toBe(peerError); 178 | }); 179 | 180 | test("does not initialize the peer connection until local media is created", async () => { 181 | mockMediaDevices({ 182 | getUserMedia: jest.fn( 183 | () => 184 | new Promise((resolve) => 185 | setTimeout(() => { 186 | resolve(mockUserMediaStream()); 187 | }, 200) 188 | ) 189 | ), 190 | }); 191 | const { result, waitForNextUpdate } = renderHook(() => 192 | useViewer(mockViewerConfig) 193 | ); 194 | await waitForNextUpdate(); 195 | expect(result.current.peer?.connection).toBeUndefined(); 196 | }); 197 | 198 | test("does not initialize local media when media is omitted from config options", async () => { 199 | const getUserMedia = mockGetUserMedia(); 200 | mockMediaDevices({ getUserMedia }); 201 | const { result, waitForNextUpdate } = renderHook(() => 202 | useViewer({ ...mockViewerConfig, media: undefined }) 203 | ); 204 | await waitForNextUpdate(); 205 | expect(result.current.localMedia).toBeUndefined(); 206 | expect(getUserMedia).toHaveBeenCalledTimes(0); 207 | }); 208 | -------------------------------------------------------------------------------- /src/hooks/useViewer.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from "react"; 2 | import { KinesisVideo } from "@aws-sdk/client-kinesis-video"; 3 | import { Role, SignalingClient } from "amazon-kinesis-video-streams-webrtc"; 4 | import { v4 as uuid } from "uuid"; 5 | import { useIceServers } from "./useIceServers"; 6 | import { useLocalMedia } from "./useLocalMedia"; 7 | import { useSignalingChannelEndpoints } from "./useSignalingChannelEndpoints"; 8 | import { useSignalingClient } from "./useSignalingClient"; 9 | import { 10 | ERROR_ICE_CANDIDATE_NOT_FOUND, 11 | ERROR_PEER_CONNECTION_LOCAL_DESCRIPTION_REQUIRED, 12 | ERROR_PEER_CONNECTION_NOT_INITIALIZED, 13 | ERROR_SIGNALING_CLIENT_NOT_CONNECTED, 14 | } from "../constants"; 15 | import { PeerConfigOptions } from "../ConfigOptions"; 16 | import { Peer } from "../Peer"; 17 | import { getLogger } from "../logger"; 18 | 19 | /** 20 | * @description Opens a viewer connection to an active master signaling channel. 21 | **/ 22 | export function useViewer( 23 | config: Omit & { 24 | media?: PeerConfigOptions["media"]; 25 | } 26 | ): { 27 | _signalingClient: SignalingClient | undefined; 28 | error: Error | undefined; 29 | localMedia: MediaStream | undefined; 30 | peer: Peer | undefined; 31 | } { 32 | const { channelARN, credentials, debug, region, media } = config; 33 | const { error: streamError, media: localMedia } = useLocalMedia( 34 | media || { audio: false, video: false } 35 | ); 36 | 37 | const logger = useRef(getLogger({ debug })); 38 | const role = Role.VIEWER; 39 | const clientId = useRef(uuid()); 40 | 41 | const kinesisVideoClientRef = useRef( 42 | new KinesisVideo({ 43 | region, 44 | credentials, 45 | }) 46 | ); 47 | 48 | const kinesisVideoClient = kinesisVideoClientRef.current; 49 | const [peerConnection, setPeerConnection] = useState(); 50 | const [peerMedia, setPeerMedia] = useState(); 51 | const [peerError, setPeerError] = useState(); 52 | const viewerOnly = !Boolean(media); 53 | const localMediaIsActive = Boolean(localMedia); 54 | 55 | const { error: signalingChannelEndpointsError, signalingChannelEndpoints } = 56 | useSignalingChannelEndpoints({ 57 | channelARN, 58 | kinesisVideoClient, 59 | role, 60 | }); 61 | 62 | const { error: iceServersError, iceServers } = useIceServers({ 63 | channelARN, 64 | channelEndpoint: signalingChannelEndpoints?.HTTPS, 65 | credentials, 66 | region, 67 | }); 68 | 69 | const { error: signalingClientError, signalingClient } = useSignalingClient({ 70 | channelARN, 71 | channelEndpoint: signalingChannelEndpoints?.WSS, 72 | clientId: clientId.current, 73 | credentials, 74 | region, 75 | role, 76 | systemClockOffset: kinesisVideoClient.config.systemClockOffset, 77 | }); 78 | 79 | const depsError = 80 | signalingChannelEndpointsError || iceServersError || signalingClientError; 81 | 82 | const peer = { 83 | id: clientId.current, 84 | connection: peerConnection, 85 | media: peerMedia, 86 | }; 87 | 88 | /** Initialize the peer connection with ice servers. */ 89 | useEffect(() => { 90 | if (!iceServers) { 91 | return; 92 | } 93 | 94 | // in order to prevent certain race conditions, ensure the local media stream is active 95 | // before initializing the peer connection (one-way viewers are exempt) 96 | if (!viewerOnly && !localMediaIsActive) { 97 | return; 98 | } 99 | 100 | setPeerConnection( 101 | new RTCPeerConnection({ 102 | iceServers, 103 | iceTransportPolicy: "all", 104 | }) 105 | ); 106 | }, [localMediaIsActive, iceServers, viewerOnly]); 107 | 108 | /** Handle signaling client and remote peer lifecycle. */ 109 | useEffect(() => { 110 | if (!peerConnection || !signalingClient) { 111 | return; 112 | } 113 | 114 | async function handleSignalingClientOpen() { 115 | logger.current.logViewer(`[${clientId.current}] signaling client opened`); 116 | 117 | if (viewerOnly) { 118 | peerConnection?.addTransceiver("video"); 119 | peerConnection 120 | ?.getTransceivers() 121 | .forEach((t) => (t.direction = "recvonly")); 122 | } 123 | 124 | const sessionDescription = await peerConnection?.createOffer({ 125 | offerToReceiveAudio: true, 126 | offerToReceiveVideo: true, 127 | }); 128 | 129 | try { 130 | await peerConnection?.setLocalDescription(sessionDescription); 131 | } catch (error) { 132 | console.error(error); 133 | return setPeerError(error as Error); 134 | } 135 | 136 | if (!peerConnection?.localDescription) { 137 | return setPeerError( 138 | new Error(ERROR_PEER_CONNECTION_LOCAL_DESCRIPTION_REQUIRED) 139 | ); 140 | } 141 | 142 | logger.current.logViewer(`[${clientId.current}] sending sdp offer`); 143 | 144 | signalingClient?.sendSdpOffer(peerConnection.localDescription); 145 | } 146 | 147 | async function handleSignalingClientSdpAnswer( 148 | answer: RTCSessionDescriptionInit 149 | ) { 150 | logger.current.logViewer(`[${clientId.current}] received sdp answer`); 151 | 152 | if (!peerConnection) { 153 | throw new Error(ERROR_PEER_CONNECTION_NOT_INITIALIZED); 154 | } 155 | 156 | await peerConnection.setRemoteDescription(answer); 157 | } 158 | 159 | function handleSignalingChannelIceCandidate(candidate: RTCIceCandidate) { 160 | logger.current.logViewer( 161 | `[${clientId.current}] received signaling channel ice candidate` 162 | ); 163 | 164 | if (!candidate) { 165 | throw new Error(ERROR_ICE_CANDIDATE_NOT_FOUND); 166 | } 167 | 168 | if (!peerConnection) { 169 | throw new Error(ERROR_PEER_CONNECTION_NOT_INITIALIZED); 170 | } 171 | 172 | peerConnection?.addIceCandidate(candidate); 173 | } 174 | 175 | function handlePeerIceCandidate({ candidate }: RTCPeerConnectionIceEvent) { 176 | logger.current.logViewer( 177 | `[${clientId.current}] received peer ice candidate` 178 | ); 179 | 180 | if (!signalingClient) { 181 | throw new Error(ERROR_SIGNALING_CLIENT_NOT_CONNECTED); 182 | } 183 | 184 | if (candidate) { 185 | signalingClient.sendIceCandidate(candidate); 186 | } 187 | } 188 | 189 | function handlePeerTrack({ streams = [] }: RTCTrackEvent) { 190 | logger.current.logViewer(`[${clientId.current}] received peer track`); 191 | 192 | setPeerMedia(streams[0]); 193 | } 194 | 195 | signalingClient.on("open", handleSignalingClientOpen); 196 | signalingClient.on("sdpAnswer", handleSignalingClientSdpAnswer); 197 | signalingClient.on("iceCandidate", handleSignalingChannelIceCandidate); 198 | signalingClient.open(); 199 | 200 | peerConnection.addEventListener("icecandidate", handlePeerIceCandidate); 201 | peerConnection.addEventListener("track", handlePeerTrack); 202 | 203 | return function cleanup() { 204 | logger.current.logViewer(`[${clientId.current}] cleanup`); 205 | 206 | signalingClient.off("open", handleSignalingClientOpen); 207 | signalingClient.off("sdpAnswer", handleSignalingClientSdpAnswer); 208 | signalingClient.off("iceCandidate", handleSignalingChannelIceCandidate); 209 | signalingClient.close(); 210 | 211 | peerConnection.removeEventListener( 212 | "icecandidate", 213 | handlePeerIceCandidate 214 | ); 215 | peerConnection.removeEventListener("track", handlePeerTrack); 216 | peerConnection.close(); 217 | }; 218 | }, [ 219 | clientId, 220 | localMediaIsActive, 221 | logger, 222 | peerConnection, 223 | signalingClient, 224 | viewerOnly, 225 | ]); 226 | 227 | /** Handle peer media lifecycle. */ 228 | useEffect(() => { 229 | return function cleanup() { 230 | peerMedia?.getTracks().forEach((track) => track.stop()); 231 | }; 232 | }, [peerMedia]); 233 | 234 | /** Send local media stream to remote peer. */ 235 | useEffect(() => { 236 | if (!localMedia || !peer.connection) { 237 | return; 238 | } 239 | 240 | localMedia.getTracks().forEach((track: MediaStreamTrack) => { 241 | (peer.connection as RTCPeerConnection).addTrack(track, localMedia); 242 | }); 243 | }, [localMedia, peer.connection]); 244 | 245 | return { 246 | _signalingClient: signalingClient, 247 | error: depsError || streamError || peerError, 248 | localMedia, 249 | peer, 250 | }; 251 | } 252 | -------------------------------------------------------------------------------- /src/hooks/useMaster.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef, useState } from "react"; 2 | import { Role, SignalingClient } from "amazon-kinesis-video-streams-webrtc"; 3 | import { KinesisVideo } from "@aws-sdk/client-kinesis-video"; 4 | import { useIceServers } from "./useIceServers"; 5 | import { useLocalMedia } from "./useLocalMedia"; 6 | import { usePeerReducer } from "./usePeerReducer"; 7 | import { useSignalingChannelEndpoints } from "./useSignalingChannelEndpoints"; 8 | import { useSignalingClient } from "./useSignalingClient"; 9 | import { PeerConfigOptions } from "../ConfigOptions"; 10 | import { getLogger } from "../logger"; 11 | import { Peer } from "../Peer"; 12 | 13 | /** 14 | * @description Opens a master connection using an existing signaling channel. 15 | **/ 16 | export function useMaster(config: PeerConfigOptions): { 17 | _signalingClient: SignalingClient | undefined; 18 | error: Error | undefined; 19 | isOpen: boolean; 20 | localMedia: MediaStream | undefined; 21 | peers: Array; 22 | } { 23 | const { 24 | channelARN, 25 | credentials, 26 | debug = false, 27 | region, 28 | media = { audio: true, video: true }, 29 | } = config; 30 | 31 | const logger = useRef(getLogger({ debug })); 32 | const role = Role.MASTER; 33 | const { 34 | error: mediaError, 35 | media: localMedia, 36 | cancel: cancelLocalMedia, 37 | } = useLocalMedia(media); 38 | const [peers, dispatch] = usePeerReducer({}); 39 | const [sendIceCandidateError, setSendIceCandidateError] = useState(); 40 | const [isOpen, setIsOpen] = useState(false); 41 | const localMediaIsActive = Boolean(localMedia); 42 | 43 | const kinesisVideoClient = useRef( 44 | new KinesisVideo({ 45 | region, 46 | credentials, 47 | }) 48 | ); 49 | 50 | const { error: signalingChannelEndpointsError, signalingChannelEndpoints } = 51 | useSignalingChannelEndpoints({ 52 | channelARN, 53 | kinesisVideoClient: kinesisVideoClient.current, 54 | role, 55 | }); 56 | 57 | const { error: iceServersError, iceServers } = useIceServers({ 58 | channelARN, 59 | channelEndpoint: signalingChannelEndpoints?.HTTPS, 60 | credentials, 61 | region, 62 | }); 63 | 64 | const { error: signalingClientError, signalingClient } = useSignalingClient({ 65 | channelARN, 66 | channelEndpoint: signalingChannelEndpoints?.WSS, 67 | credentials, 68 | region, 69 | role, 70 | systemClockOffset: kinesisVideoClient.current.config.systemClockOffset, 71 | }); 72 | 73 | // this dict. is used to perform cleanup tasks 74 | const peerCleanup = useRef void>>({}); 75 | 76 | const addPeer = useCallback( 77 | (id, peer) => { 78 | dispatch({ 79 | type: "add", 80 | payload: { ...peer, isWaitingForMedia: true }, 81 | }); 82 | }, 83 | [dispatch] 84 | ); 85 | 86 | const removePeer = useCallback( 87 | (id) => { 88 | dispatch({ type: "remove", payload: { id } }); 89 | }, 90 | [dispatch] 91 | ); 92 | 93 | const updatePeer = useCallback( 94 | (id, update) => dispatch({ type: "update", payload: { id, ...update } }), 95 | [dispatch] 96 | ); 97 | 98 | const externalError = 99 | signalingChannelEndpointsError || 100 | signalingClientError || 101 | iceServersError || 102 | sendIceCandidateError; 103 | 104 | /* Cancel the local media stream when an error occurs */ 105 | useEffect(() => { 106 | if (!externalError) { 107 | return; 108 | } 109 | logger.current.logMaster("cancelling local media stream"); 110 | cancelLocalMedia(); 111 | }, [externalError, cancelLocalMedia]); 112 | 113 | /** 114 | * Handle signaling client events. 115 | * 116 | * - This effect is designed to be invoked once per master session. 117 | * */ 118 | useEffect(() => { 119 | if (!signalingClient || !iceServers || !localMediaIsActive) { 120 | return; 121 | } 122 | 123 | if (externalError) { 124 | logger.current.logMaster( 125 | `cleaning up signaling client after error: ${externalError.message}` 126 | ); 127 | return cleanup(); 128 | } 129 | 130 | function cleanup() { 131 | logger.current.logMaster("cleaning up peer connections"); 132 | 133 | signalingClient?.close(); 134 | signalingClient?.off("sdpOffer", handleSignalingClientSdpOffer); 135 | signalingClient?.off("open", handleSignalingClientOpen); 136 | 137 | setIsOpen(false); 138 | 139 | for (const [id, fn] of Object.entries(peerCleanup.current)) { 140 | fn(); 141 | removePeer(id); 142 | delete peerCleanup.current[id]; 143 | } 144 | } 145 | 146 | /* sdp offer = new peer connection */ 147 | async function handleSignalingClientSdpOffer( 148 | offer: RTCSessionDescription, 149 | id: string 150 | ) { 151 | logger.current.logMaster("received sdp offer"); 152 | 153 | const connection = new RTCPeerConnection({ 154 | iceServers, 155 | iceTransportPolicy: "all", 156 | }); 157 | 158 | // this reference is used for cleanup 159 | let media: MediaStream; 160 | 161 | function handleIceCandidate({ candidate }: RTCPeerConnectionIceEvent) { 162 | logger.current.logMaster("received ice candidate"); 163 | 164 | if (candidate) { 165 | try { 166 | signalingClient?.sendIceCandidate(candidate, id); 167 | } catch (error) { 168 | setSendIceCandidateError(error as Error); 169 | } 170 | } 171 | } 172 | 173 | function handleIceConnectionStateChange() { 174 | logger.current.logMaster( 175 | `ice connection state change: ${connection.iceConnectionState}` 176 | ); 177 | 178 | if ( 179 | ["closed", "disconnected", "failed"].includes( 180 | connection.iceConnectionState 181 | ) 182 | ) { 183 | removePeer(id); 184 | peerCleanup.current[id]?.(); 185 | delete peerCleanup.current[id]; 186 | } 187 | } 188 | 189 | function handleTrack({ streams = [] }: RTCTrackEvent) { 190 | logger.current.logMaster("received peer track"); 191 | 192 | media = streams[0]; 193 | updatePeer(id, { media }); 194 | } 195 | 196 | connection.addEventListener("icecandidate", handleIceCandidate); 197 | connection.addEventListener("track", handleTrack); 198 | connection.addEventListener( 199 | "iceconnectionstatechange", 200 | handleIceConnectionStateChange 201 | ); 202 | 203 | addPeer(id, { id, connection }); 204 | peerCleanup.current[id] = () => { 205 | logger.current.logMaster(`cleaning up peer ${id}`); 206 | 207 | media?.getTracks().forEach((track: MediaStreamTrack) => { 208 | track.stop(); 209 | }); 210 | 211 | connection.close(); 212 | connection.removeEventListener("icecandidate", handleIceCandidate); 213 | connection.removeEventListener("track", handleTrack); 214 | connection.removeEventListener( 215 | "iceconnectionstatechange", 216 | handleIceConnectionStateChange 217 | ); 218 | }; 219 | 220 | await connection.setRemoteDescription(offer); 221 | await connection.setLocalDescription( 222 | await connection.createAnswer({ 223 | offerToReceiveAudio: true, 224 | offerToReceiveVideo: true, 225 | }) 226 | ); 227 | 228 | signalingClient?.sendSdpAnswer( 229 | connection.localDescription as RTCSessionDescription, 230 | id 231 | ); 232 | } 233 | 234 | function handleSignalingClientOpen() { 235 | setIsOpen(true); 236 | } 237 | 238 | signalingClient.on("sdpOffer", handleSignalingClientSdpOffer); 239 | signalingClient.on("open", handleSignalingClientOpen); 240 | signalingClient.open(); 241 | 242 | return cleanup; 243 | }, [ 244 | addPeer, 245 | externalError, 246 | iceServers, 247 | localMediaIsActive, 248 | logger, 249 | peerCleanup, 250 | removePeer, 251 | signalingClient, 252 | updatePeer, 253 | ]); 254 | 255 | /* Handle peer side effects */ 256 | useEffect(() => { 257 | for (const peer of Object.values(peers)) { 258 | if (peer.isWaitingForMedia) { 259 | if (!localMedia) { 260 | continue; 261 | } 262 | localMedia.getTracks().forEach((track: MediaStreamTrack) => { 263 | peer.connection?.addTrack(track, localMedia); 264 | }); 265 | dispatch({ 266 | type: "update", 267 | payload: { id: peer.id, isWaitingForMedia: false }, 268 | }); 269 | } 270 | } 271 | }, [dispatch, localMedia, peers]); 272 | 273 | logger.current.logMaster({ peers }); 274 | 275 | return { 276 | _signalingClient: signalingClient, 277 | error: mediaError || externalError, 278 | isOpen, 279 | localMedia, 280 | peers: Object.values(peers), 281 | }; 282 | } 283 | -------------------------------------------------------------------------------- /src/hooks/useMaster.spec.ts: -------------------------------------------------------------------------------- 1 | import { act, cleanup, renderHook } from "@testing-library/react-hooks"; 2 | import { 3 | GetSignalingChannelEndpointCommandInput, 4 | GetSignalingChannelEndpointCommandOutput, 5 | } from "@aws-sdk/client-kinesis-video"; 6 | import { 7 | GetIceServerConfigCommandInput, 8 | GetIceServerConfigCommandOutput, 9 | } from "@aws-sdk/client-kinesis-video-signaling"; 10 | import { AwsStub } from "aws-sdk-client-mock"; 11 | import { SignalingClient } from "amazon-kinesis-video-streams-webrtc"; 12 | import * as config from "../../__test__/fixtures/config.json"; 13 | import { 14 | mockMediaDevices, 15 | mockGetUserMedia, 16 | mockMediaTrack, 17 | mockUserMediaStream, 18 | } from "../../__test__/mocks/mockNavigator"; 19 | import { mockGetSignalingChannelEndpoints } from "../../__test__/mocks/mockKinesisVideo"; 20 | import { mockGetIceServerConfig } from "../../__test__/mocks/mockKinesisVideoSignalingClient"; 21 | import { 22 | mockRTCPeerConnection, 23 | MockRTCPeerConnection, 24 | } from "../../__test__/mocks/mockRTCPeerConnection"; 25 | import { mockSignalingClient } from "../../__test__/mocks/mockSignalingClient"; 26 | import { useMaster } from "./useMaster"; 27 | import { randomUUID } from "crypto"; 28 | 29 | const masterConfig = { 30 | ...config, 31 | media: { audio: true, video: true }, 32 | }; 33 | 34 | let getIceServerConfigMock: AwsStub< 35 | GetIceServerConfigCommandInput, 36 | GetIceServerConfigCommandOutput 37 | >; 38 | let getSignalingChannelEndpointsMock: AwsStub< 39 | GetSignalingChannelEndpointCommandInput, 40 | GetSignalingChannelEndpointCommandOutput 41 | >; 42 | 43 | beforeEach(() => { 44 | mockMediaDevices(); 45 | mockRTCPeerConnection(); 46 | mockSignalingClient(); 47 | getIceServerConfigMock = mockGetIceServerConfig(null); 48 | getSignalingChannelEndpointsMock = mockGetSignalingChannelEndpoints(null); 49 | }); 50 | 51 | afterEach(() => { 52 | getIceServerConfigMock.reset(); 53 | getSignalingChannelEndpointsMock.reset(); 54 | jest.clearAllMocks(); 55 | }); 56 | 57 | function mockNewPeerConnection( 58 | signalingClient: SignalingClient, 59 | peerId = randomUUID() 60 | ): string { 61 | act(() => { 62 | signalingClient.emit("sdpOffer", {}, peerId); 63 | }); 64 | return peerId; 65 | } 66 | 67 | function mockPeerDisconnect(peerConnection: RTCPeerConnection) { 68 | act(() => { 69 | // @ts-expect-error - MockRTCPeerConnection 70 | (peerConnection as MockRTCPeerConnection).iceConnectionState = 71 | "disconnected"; 72 | peerConnection.dispatchEvent(new Event("iceconnectionstatechange")); 73 | }); 74 | } 75 | 76 | function mockSignalingClientOpen(signalingClient: SignalingClient) { 77 | act(() => { 78 | signalingClient?.emit("open"); 79 | }); 80 | } 81 | 82 | function mockSignalingClientError(signalingClient: SignalingClient) { 83 | act(() => { 84 | signalingClient.emit("error", new Error()); 85 | }); 86 | } 87 | 88 | function mockMediaDevicesWithDelay(delay = 200) { 89 | return mockMediaDevices({ 90 | getUserMedia: jest.fn( 91 | () => 92 | new Promise((resolve) => 93 | setTimeout(() => { 94 | resolve(mockUserMediaStream()); 95 | }, delay) 96 | ) 97 | ), 98 | }); 99 | } 100 | 101 | test("opens the signaling client", async () => { 102 | const { result, waitForNextUpdate } = renderHook(() => 103 | useMaster(masterConfig) 104 | ); 105 | await waitForNextUpdate(); 106 | expect( 107 | (result.current._signalingClient as SignalingClient).open 108 | ).toHaveBeenCalledTimes(1); 109 | }); 110 | 111 | test("toggles isOpen when signaling client opens", async () => { 112 | const { result, waitForNextUpdate } = renderHook(() => 113 | useMaster(masterConfig) 114 | ); 115 | await waitForNextUpdate(); 116 | mockSignalingClientOpen(result.current._signalingClient as SignalingClient); 117 | expect(result.current.isOpen).toBe(true); 118 | }); 119 | 120 | test("closes the signaling client on cleanup", async () => { 121 | const { result, waitForNextUpdate } = renderHook(() => 122 | useMaster(masterConfig) 123 | ); 124 | await waitForNextUpdate(); 125 | await cleanup(); 126 | expect( 127 | (result.current._signalingClient as SignalingClient).close 128 | ).toHaveBeenCalledTimes(1); 129 | }); 130 | 131 | test("returns the local media stream", async () => { 132 | const { result, waitForNextUpdate } = renderHook(() => 133 | useMaster(masterConfig) 134 | ); 135 | await waitForNextUpdate(); 136 | expect(result.current.localMedia).toBeDefined(); 137 | }); 138 | 139 | test("stops the local media stream on cleanup", async () => { 140 | const mediaTrack = mockMediaTrack(); 141 | const userMediaStream = mockUserMediaStream({ 142 | mediaTracks: [mediaTrack as MediaStreamTrack], 143 | }); 144 | mockMediaDevices({ 145 | getUserMedia: mockGetUserMedia({ userMediaStream }), 146 | }); 147 | const { waitForNextUpdate } = renderHook(() => useMaster(masterConfig)); 148 | await waitForNextUpdate(); 149 | await cleanup(); 150 | expect(mediaTrack.stop).toHaveBeenCalledTimes(1); 151 | }); 152 | 153 | test("returns a list of connected peers", async () => { 154 | const { result, waitForNextUpdate } = renderHook(() => 155 | useMaster(masterConfig) 156 | ); 157 | await waitForNextUpdate(); 158 | const peerId = mockNewPeerConnection( 159 | result.current._signalingClient as SignalingClient 160 | ); 161 | expect(result.current.peers).toHaveLength(1); 162 | expect(result.current.peers[0].id).toBe(peerId); 163 | }); 164 | 165 | test("sends local media stream to a new peer", async () => { 166 | const { result, waitForNextUpdate } = renderHook(() => 167 | useMaster(masterConfig) 168 | ); 169 | await waitForNextUpdate(); 170 | mockNewPeerConnection(result.current._signalingClient as SignalingClient); 171 | expect(result.current.peers[0].connection?.addTrack).toHaveBeenCalledTimes(1); 172 | }); 173 | 174 | test("receives media from a peer", async () => { 175 | const { result, waitForNextUpdate } = renderHook(() => 176 | useMaster(masterConfig) 177 | ); 178 | await waitForNextUpdate(); 179 | mockNewPeerConnection(result.current._signalingClient as SignalingClient); 180 | expect(result.current.peers[0].media).toBeDefined(); 181 | }); 182 | 183 | test("removes a peer when the peer disconnects", async () => { 184 | const { result, waitForNextUpdate } = renderHook(() => 185 | useMaster(masterConfig) 186 | ); 187 | await waitForNextUpdate(); 188 | mockNewPeerConnection(result.current._signalingClient as SignalingClient); 189 | mockPeerDisconnect(result.current.peers[0].connection as RTCPeerConnection); 190 | expect(result.current.peers).toHaveLength(0); 191 | }); 192 | 193 | test("handles a peer re-connection", async () => { 194 | const { result, waitForNextUpdate } = renderHook(() => 195 | useMaster(masterConfig) 196 | ); 197 | await waitForNextUpdate(); 198 | const peerId = randomUUID(); 199 | mockNewPeerConnection( 200 | result.current._signalingClient as SignalingClient, 201 | peerId 202 | ); 203 | mockPeerDisconnect(result.current.peers[0].connection as RTCPeerConnection); 204 | mockNewPeerConnection( 205 | result.current._signalingClient as SignalingClient, 206 | peerId 207 | ); 208 | expect(result.current.peers).toHaveLength(1); 209 | }); 210 | 211 | test("handles multiple peer connections", async () => { 212 | const { result, waitForNextUpdate } = renderHook(() => 213 | useMaster(masterConfig) 214 | ); 215 | await waitForNextUpdate(); 216 | new Array(3) 217 | .fill(null) 218 | .forEach(() => 219 | mockNewPeerConnection(result.current._signalingClient as SignalingClient) 220 | ); 221 | expect(result.current.peers).toHaveLength(3); 222 | result.current.peers.forEach((peer) => { 223 | expect(peer.connection?.addTrack).toHaveBeenCalledTimes(1); 224 | expect(peer.media).toBeDefined(); 225 | }); 226 | result.current.peers.forEach((peer) => { 227 | mockPeerDisconnect(peer.connection as RTCPeerConnection); 228 | }); 229 | expect(result.current.peers).toHaveLength(0); 230 | }); 231 | 232 | test("removes peers when there is a signaling client error", async () => { 233 | const { result, waitForNextUpdate } = renderHook(() => 234 | useMaster(masterConfig) 235 | ); 236 | await waitForNextUpdate(); 237 | mockNewPeerConnection(result.current._signalingClient as SignalingClient); 238 | mockNewPeerConnection(result.current._signalingClient as SignalingClient); 239 | mockSignalingClientError(result.current._signalingClient as SignalingClient); 240 | expect(result.current.peers).toHaveLength(0); 241 | }); 242 | 243 | test("does not open the signaling client until the local media stream is created", async () => { 244 | mockMediaDevicesWithDelay(); 245 | const { result, waitForNextUpdate } = renderHook(() => 246 | useMaster(masterConfig) 247 | ); 248 | await waitForNextUpdate(); 249 | expect(result.current._signalingClient?.open).toHaveBeenCalledTimes(0); 250 | }); 251 | 252 | test("cancels the local media stream when there is an error", async () => { 253 | mockMediaDevicesWithDelay(); 254 | const { result, waitForNextUpdate } = renderHook(() => 255 | useMaster(masterConfig) 256 | ); 257 | await waitForNextUpdate(); 258 | mockSignalingClientError(result.current._signalingClient as SignalingClient); 259 | expect(result.current.localMedia).toBeUndefined(); 260 | }); 261 | --------------------------------------------------------------------------------