UNPKG

@100mslive/hms-video-store

Version:

@100mslive Core SDK which abstracts the complexities of webRTC while providing a reactive store for data management with a unidirectional data flow

660 lines (568 loc) • 22.6 kB
import { v4 as uuid } from 'uuid'; import { convertSignalMethodtoErrorAction, HMSSignalMethod, JsonRpcRequest, JsonRpcResponse } from './models'; import AnalyticsEvent from '../../analytics/AnalyticsEvent'; import { HMSConnectionRole, HMSTrickle } from '../../connection/model'; import { ErrorFactory } from '../../error/ErrorFactory'; import { HMSAction } from '../../error/HMSAction'; import { HMSException } from '../../error/HMSException'; import { PeerNotificationInfo, SendMessage } from '../../notification-manager'; import { DEFAULT_SIGNAL_PING_INTERVAL, DEFAULT_SIGNAL_PING_TIMEOUT, LEAVE_REASON, PONG_RESPONSE_TIMES_SIZE, } from '../../utils/constants'; import HMSLogger from '../../utils/logger'; import { PromiseCallbacks } from '../../utils/promise'; import { Queue } from '../../utils/queue'; import { isPageHidden } from '../../utils/support'; import { workerSleep } from '../../utils/timer-utils'; import { AcceptRoleChangeParams, BroadcastResponse, CreateWhiteboardResponse, FindPeerByNameRequestParams, FindPeerByNameResponse, FindPeersRequestParams, GetPeerRequestParams, GetSessionMetadataResponse, GetWhiteboardResponse, HLSRequestParams, HLSTimedMetadataParams, HMSPermissionType, HMSWhiteboardCreateOptions, JoinLeaveGroupResponse, MultiTrackUpdateRequestParams, PeerIterRequestParams, PeersIterationResponse, PollInfoGetParams, PollInfoGetResponse, PollInfoSetParams, PollInfoSetResponse, PollLeaderboardGetParams, PollLeaderboardGetResponse, PollListParams, PollListResponse, PollQuestionsGetParams, PollQuestionsGetResponse, PollQuestionsSetParams, PollQuestionsSetResponse, PollResponseSetParams, PollResponseSetResponse, PollResponsesGetParams, PollResponsesGetResponse, PollResultParams, PollResultResponse, PollStartParams, PollStartResponse, PollStopParams, PollStopResponse, RemovePeerRequest, RequestForBulkRoleChangeParams, RequestForRoleChangeParams, SetSessionMetadataParams, SetSessionMetadataResponse, StartRTMPOrRecordingRequestParams, StartTranscriptionRequestParams, Track, TrackUpdateRequestParams, UpdatePeerRequestParams, } from '../interfaces'; import { ISignalEventsObserver } from '../ISignalEventsObserver'; export default class JsonRpcSignal { readonly TAG = '[SIGNAL]: '; readonly observer: ISignalEventsObserver; readonly pongResponseTimes = new Queue<number>(PONG_RESPONSE_TIMES_SIZE); /** * Sometimes before [join] is completed, there could be a lot of trickles * Sending [HMSTrickle]` before [join] web socket message leads to * error: [500] no rtc transport exists for this Peer * * We keep a list of pending trickles and send them immediately after [join] * is done. */ private isJoinCompleted = false; private pendingTrickle: Array<HMSTrickle> = []; private socket: WebSocket | null = null; private callbacks = new Map<string, PromiseCallbacks<string, { method: HMSSignalMethod }>>(); private _isConnected = false; private id = 0; private sfuNodeId: string | undefined; private onCloseHandler: (event: CloseEvent) => void = () => {}; public get isConnected(): boolean { return this._isConnected; } setSfuNodeId(sfuNodeId?: string) { this.sfuNodeId = sfuNodeId; } public setIsConnected(newValue: boolean, reason = '') { HMSLogger.d(this.TAG, `isConnected set id: ${this.id}, oldValue: ${this._isConnected}, newValue: ${newValue}`); if (this._isConnected === newValue) { return; } if (this._isConnected && !newValue) { // went offline this._isConnected = newValue; this.rejectPendingCalls(reason); this.observer.onOffline(reason); } else if (!this._isConnected && newValue) { // went online this._isConnected = newValue; this.observer.onOnline(); } } constructor(observer: ISignalEventsObserver) { this.observer = observer; window.addEventListener('offline', this.offlineListener); window.addEventListener('online', this.onlineListener); this.onMessageHandler = this.onMessageHandler.bind(this); } getPongResponseTimes() { return this.pongResponseTimes.toList(); } private async internalCall<T>(method: string, params: any): Promise<T> { const id = uuid(); const message = { method, params, id, jsonrpc: '2.0' } as JsonRpcRequest; this.socket?.send(JSON.stringify(message)); try { const response = await new Promise<any>((resolve, reject) => { this.callbacks.set(id, { resolve, reject, metadata: { method: method as HMSSignalMethod } }); }); return response; } catch (ex) { if (ex instanceof HMSException) { throw ex; } const error = ex as JsonRpcResponse['error']; throw ErrorFactory.WebsocketMethodErrors.ServerErrors( Number(error.code), convertSignalMethodtoErrorAction(method as HMSSignalMethod), error.message, ); } } private notify(method: string, params: any) { const message = { method, params }; if (this.socket?.readyState === WebSocket.OPEN) { this.socket?.send(JSON.stringify(message)); } } open(uri: string): Promise<void> { return new Promise((resolve, reject) => { let promiseSettled = false; // cleanup if (this.socket) { this.socket.close(); this.socket.removeEventListener('close', this.onCloseHandler); this.socket.removeEventListener('message', this.onMessageHandler); } this.socket = new WebSocket(uri); // @DISCUSS: Inject WebSocket as a dependency so that it can be easier to mock and test const errorListener = () => { /** * there was an error received from websocket leading to disconnection, this can happen either if server * disconnects the websocket for some reason, there is a network disconnect or a firewall/antivirus on user's * device is breaking the websocket connecting(which can happen even after a successful connect). */ HMSLogger.e(this.TAG, 'Error from websocket'); promiseSettled = true; // above error does not contain any description hence not sent here reject( ErrorFactory.WebSocketConnectionErrors.FailedToConnect(HMSAction.JOIN, `Error opening websocket connection`), ); }; this.onCloseHandler = (event: CloseEvent) => { HMSLogger.w(`Websocket closed code=${event.code}`); if (promiseSettled) { this.setIsConnected(false, `code: ${event.code}${event.code !== 1000 ? ', unexpected websocket close' : ''}`); } else { promiseSettled = true; reject( ErrorFactory.WebSocketConnectionErrors.AbnormalClose( HMSAction.JOIN, `Error opening websocket connection - websocket closed unexpectedly with code=${event.code}`, ), ); } }; this.socket.addEventListener('error', errorListener); const openHandler = () => { promiseSettled = true; resolve(); this.setIsConnected(true); this.id++; this.socket?.removeEventListener('open', openHandler); this.socket?.removeEventListener('error', errorListener); this.pingPongLoop(this.id); }; this.socket.addEventListener('open', openHandler); this.socket.addEventListener('close', this.onCloseHandler); this.socket.addEventListener('message', this.onMessageHandler); }); } async close(): Promise<void> { window.removeEventListener('offline', this.offlineListener); window.removeEventListener('online', this.onlineListener); // For `1000` Refer: https://tools.ietf.org/html/rfc6455#section-7.4.1 if (this.socket) { this.socket.close(1000, 'Normal Close'); this.setIsConnected(false, 'code: 1000, normal websocket close'); this.socket.removeEventListener('close', this.onCloseHandler); this.socket.removeEventListener('message', this.onMessageHandler); } else { this.setIsConnected(false, 'websocket not connected yet'); } } async join( name: string, data: string, disableVidAutoSub: boolean, serverSubDegrade: boolean, simulcast: boolean, onDemandTracks: boolean, offer?: RTCSessionDescriptionInit, ): Promise<RTCSessionDescriptionInit & { sfu_node_id: string | undefined }> { if (!this.isConnected) { throw ErrorFactory.WebSocketConnectionErrors.WebSocketConnectionLost( HMSAction.JOIN, 'Failed to send join over WS connection', ); } const params = { name, disableVidAutoSub, data, offer, server_sub_degrade: serverSubDegrade, simulcast, onDemandTracks, }; const response: RTCSessionDescriptionInit & { sfu_node_id: string | undefined } = await this.internalCall( HMSSignalMethod.JOIN, params, ); this.isJoinCompleted = true; this.pendingTrickle.forEach(({ target, candidate }) => this.trickle(target, candidate)); this.pendingTrickle.length = 0; HMSLogger.d(this.TAG, `join: response=${JSON.stringify(response, null, 1)}`); return response; } trickle(target: HMSConnectionRole, candidate: RTCIceCandidateInit) { if (this.isJoinCompleted) { this.notify(HMSSignalMethod.TRICKLE, { target, candidate, sfu_node_id: this.sfuNodeId }); } else { this.pendingTrickle.push({ target, candidate }); } } async offer(desc: RTCSessionDescriptionInit, tracks: Map<string, any>): Promise<RTCSessionDescriptionInit> { const response = await this.call(HMSSignalMethod.OFFER, { desc, tracks: Object.fromEntries(tracks), sfu_node_id: this.sfuNodeId, }); return response as RTCSessionDescriptionInit; } answer(desc: RTCSessionDescriptionInit) { this.notify(HMSSignalMethod.ANSWER, { desc, sfu_node_id: this.sfuNodeId }); } trackUpdate(tracks: Map<string, Track>) { this.notify(HMSSignalMethod.TRACK_UPDATE, { tracks: Object.fromEntries(tracks) }); } async broadcast(message: SendMessage) { return await this.call<BroadcastResponse>(HMSSignalMethod.BROADCAST, message); } leave(reason: LEAVE_REASON) { this.notify(HMSSignalMethod.LEAVE, { client_reason: reason }); } async endRoom(lock: boolean, reason: string) { await this.call(HMSSignalMethod.END_ROOM, { lock, reason }); } sendEvent(event: AnalyticsEvent) { if (!this.isConnected) { throw Error(`${this.TAG} not connected. Could not send event ${event}`); } this.notify(HMSSignalMethod.ANALYTICS, event.toSignalParams()); } ping(timeout: number): Promise<number> { const pingTime = Date.now(); const timer: Promise<number> = new Promise(resolve => { setTimeout(() => { resolve(Date.now() - pingTime); }, timeout + 1); }); const pongTimeDiff = this.internalCall(HMSSignalMethod.PING, { timestamp: pingTime }) .then(() => Date.now() - pingTime) .catch(() => Date.now() - pingTime); return Promise.race([timer, pongTimeDiff]); } async requestRoleChange(params: RequestForRoleChangeParams) { await this.call(HMSSignalMethod.ROLE_CHANGE_REQUEST, params); } async requestBulkRoleChange(params: RequestForBulkRoleChangeParams) { await this.call(HMSSignalMethod.ROLE_CHANGE_REQUEST, params); } async acceptRoleChangeRequest(params: AcceptRoleChangeParams) { await this.call(HMSSignalMethod.ROLE_CHANGE, params); } async requestTrackStateChange(params: TrackUpdateRequestParams) { await this.call(HMSSignalMethod.TRACK_UPDATE_REQUEST, params); } async requestMultiTrackStateChange(params: MultiTrackUpdateRequestParams) { await this.call(HMSSignalMethod.CHANGE_TRACK_MUTE_STATE_REQUEST, params); } async removePeer(params: RemovePeerRequest) { await this.call(HMSSignalMethod.PEER_LEAVE_REQUEST, params); } async startRTMPOrRecording(params: StartRTMPOrRecordingRequestParams) { await this.call(HMSSignalMethod.START_RTMP_OR_RECORDING_REQUEST, { ...params }); } async stopRTMPAndRecording() { await this.call(HMSSignalMethod.STOP_RTMP_AND_RECORDING_REQUEST, {}); } async startHLSStreaming(params: HLSRequestParams): Promise<void> { await this.call(HMSSignalMethod.START_HLS_STREAMING, { ...params }); } async stopHLSStreaming(params?: HLSRequestParams): Promise<void> { await this.call(HMSSignalMethod.STOP_HLS_STREAMING, { ...params }); } async startTranscription(params: StartTranscriptionRequestParams) { await this.call(HMSSignalMethod.START_TRANSCRIPTION, { ...params }); } async stopTranscription(params: StartTranscriptionRequestParams) { await this.call(HMSSignalMethod.STOP_TRANSCRIPTION, { ...params }); } async sendHLSTimedMetadata(params?: HLSTimedMetadataParams): Promise<void> { await this.call(HMSSignalMethod.HLS_TIMED_METADATA, { ...params }); } async updatePeer(params: UpdatePeerRequestParams) { await this.call(HMSSignalMethod.UPDATE_PEER_METADATA, { ...params }); } async getPeer(params: GetPeerRequestParams): Promise<PeerNotificationInfo | undefined> { return await this.call(HMSSignalMethod.GET_PEER, { ...params }); } async joinGroup(name: string): Promise<JoinLeaveGroupResponse> { return await this.call(HMSSignalMethod.GROUP_JOIN, { name }); } async leaveGroup(name: string): Promise<JoinLeaveGroupResponse> { return await this.call(HMSSignalMethod.GROUP_LEAVE, { name }); } async addToGroup(peerId: string, name: string) { await this.call(HMSSignalMethod.GROUP_ADD, { name, peer_id: peerId }); } async removeFromGroup(peerId: string, name: string): Promise<void> { await this.call(HMSSignalMethod.GROUP_REMOVE, { name, peer_id: peerId }); } async peerIterNext(params: PeerIterRequestParams): Promise<PeersIterationResponse> { return await this.call(HMSSignalMethod.PEER_ITER_NEXT, params); } async findPeers(params: FindPeersRequestParams): Promise<PeersIterationResponse> { return await this.call(HMSSignalMethod.FIND_PEER, params); } async findPeerByName(params: FindPeerByNameRequestParams): Promise<FindPeerByNameResponse> { return await this.call(HMSSignalMethod.SEARCH_BY_NAME, params); } setSessionMetadata(params: SetSessionMetadataParams) { if (!this.isConnected) { throw ErrorFactory.WebSocketConnectionErrors.WebSocketConnectionLost( HMSAction.RECONNECT_SIGNAL, 'Failed to set session store value due to network disconnection', ); } return this.call<SetSessionMetadataResponse>(HMSSignalMethod.SET_METADATA, { ...params }); } listenMetadataChange(keys: string[]): Promise<void> { if (!this.isConnected) { throw ErrorFactory.WebSocketConnectionErrors.WebSocketConnectionLost( HMSAction.RECONNECT_SIGNAL, 'Failed to observe session store key due to network disconnection', ); } return this.call(HMSSignalMethod.LISTEN_METADATA_CHANGE, { keys }); } getSessionMetadata(key?: string) { if (!this.isConnected) { throw ErrorFactory.WebSocketConnectionErrors.WebSocketConnectionLost( HMSAction.RECONNECT_SIGNAL, 'Failed to set session store value due to network disconnection', ); } return this.call<GetSessionMetadataResponse>(HMSSignalMethod.GET_METADATA, { key }); } setPollInfo(params: PollInfoSetParams) { return this.call<PollInfoSetResponse>(HMSSignalMethod.POLL_INFO_SET, { ...params }); } getPollInfo(params: PollInfoGetParams) { return this.call<PollInfoGetResponse>(HMSSignalMethod.POLL_INFO_GET, { ...params }); } setPollQuestions(params: PollQuestionsSetParams) { return this.call<PollQuestionsSetResponse>(HMSSignalMethod.POLL_QUESTIONS_SET, { ...params }); } startPoll(params: PollStartParams) { return this.call<PollStartResponse>(HMSSignalMethod.POLL_START, { ...params }); } stopPoll(params: PollStopParams) { return this.call<PollStopResponse>(HMSSignalMethod.POLL_STOP, { ...params }); } getPollQuestions(params: PollQuestionsGetParams): Promise<PollQuestionsGetResponse> { return this.call<PollQuestionsGetResponse>(HMSSignalMethod.POLL_QUESTIONS_GET, { ...params }); } setPollResponses(params: PollResponseSetParams): Promise<PollResponseSetResponse> { return this.call<PollResponseSetResponse>(HMSSignalMethod.POLL_RESPONSE_SET, { ...params }); } getPollResponses(params: PollResponsesGetParams): Promise<PollResponsesGetResponse> { return this.call<PollResponsesGetResponse>(HMSSignalMethod.POLL_RESPONSES, { ...params }); } getPollsList(params: PollListParams): Promise<PollListResponse> { return this.call<PollListResponse>(HMSSignalMethod.POLL_LIST, { ...params }); } getPollResult(params: PollResultParams): Promise<PollResultResponse> { return this.call<PollResultResponse>(HMSSignalMethod.POLL_RESULT, { ...params }); } createWhiteboard(params: HMSWhiteboardCreateOptions) { this.validateConnection(); return this.call<CreateWhiteboardResponse>(HMSSignalMethod.WHITEBOARD_CREATE, { ...params }); } getWhiteboard(params: { id: string; permission?: Array<HMSPermissionType> }) { this.validateConnection(); return this.call<GetWhiteboardResponse>(HMSSignalMethod.WHITEBOARD_GET, { ...params }); } fetchPollLeaderboard(params: PollLeaderboardGetParams): Promise<PollLeaderboardGetResponse> { return this.call<PollLeaderboardGetResponse>(HMSSignalMethod.POLL_LEADERBOARD, { ...params }); } private validateConnection() { if (!this.isConnected) { throw ErrorFactory.WebSocketConnectionErrors.WebSocketConnectionLost( HMSAction.RECONNECT_SIGNAL, 'Failed to send message due to network disconnection', ); } } private onMessageHandler(event: MessageEvent) { const text: string = event.data; const response = JSON.parse(text); this.resolvePingOnAnyResponse(); if (response.id) { this.handleResponseWithId(response); } else if (response.method) { this.handleResponseWithMethod(response); } else { throw Error(`WebSocket message has no 'method' or 'id' field, message=${response}`); } } private handleResponseWithId(response: any) { /** This is a response to [call] */ const typedResponse = response as JsonRpcResponse; const id: string = typedResponse.id; if (this.callbacks.has(id)) { const cb = this.callbacks.get(id)!; this.callbacks.delete(id); if (typedResponse.result) { cb.resolve(typedResponse.result); } else { cb.reject(typedResponse.error); } } else { this.observer.onNotification(typedResponse); } } private handleResponseWithMethod(response: any) { switch (response.method) { case HMSSignalMethod.OFFER: this.observer.onOffer(response.params); break; case HMSSignalMethod.TRICKLE: this.observer.onTrickle(response.params); break; case HMSSignalMethod.SERVER_ERROR: this.observer.onServerError( ErrorFactory.WebsocketMethodErrors.ServerErrors( Number(response.params.code), HMSSignalMethod.SERVER_ERROR, response.params.message, ), ); break; case HMSSignalMethod.SERVER_WARNING: HMSLogger.w(this.TAG, response.params); break; default: this.observer.onNotification(response); break; } } private resolvePingOnAnyResponse = () => { this.callbacks.forEach((callback, key) => { if (callback.metadata?.method === HMSSignalMethod.PING) { //@ts-ignore callback.resolve({ timestamp: Date.now() }); this.callbacks.delete(key); } }); }; private rejectPendingCalls(reason = '') { this.callbacks.forEach((callback, id) => { if (callback.metadata?.method !== HMSSignalMethod.PING) { HMSLogger.e(this.TAG, `rejecting pending callback ${callback.metadata?.method}, id=${id}`); callback.reject( ErrorFactory.WebSocketConnectionErrors.WebSocketConnectionLost( callback.metadata?.method ? convertSignalMethodtoErrorAction(callback.metadata?.method) : HMSAction.RECONNECT_SIGNAL, reason, ), ); this.callbacks.delete(id); } }); } private async pingPongLoop(id: number) { const pingTimeout = window.HMS?.PING_TIMEOUT || DEFAULT_SIGNAL_PING_TIMEOUT; if (this.isConnected) { const pongTimeDiff = await this.ping(pingTimeout); this.pongResponseTimes.enqueue(pongTimeDiff); if (pongTimeDiff > pingTimeout) { HMSLogger.d(this.TAG, `Pong timeout ${id}, pageHidden=${isPageHidden()}`); if (this.id === id) { this.setIsConnected(false, 'ping pong failure'); } } else { setTimeout(() => this.pingPongLoop(id), window.HMS?.PING_INTERVAL || DEFAULT_SIGNAL_PING_INTERVAL); } } } private async call<T>(method: HMSSignalMethod, params: Record<string, any>): Promise<T> { const MAX_RETRIES = 3; let error: HMSException = ErrorFactory.WebsocketMethodErrors.ServerErrors(500, method, `Default ${method} error`); let retry; for (retry = 1; retry <= MAX_RETRIES; retry++) { try { this.validateConnection(); HMSLogger.d(this.TAG, `Try number ${retry} sending ${method}`, params); return await this.internalCall(method, params); } catch (err) { error = err as HMSException; HMSLogger.e(this.TAG, `Failed sending ${method} try: ${retry}`, { method, params, error }); // 1003 is websocket disconnect - could be because you are offline - retry with delay in this case as well const shouldRetry = parseInt(`${error.code / 100}`) === 5 || error.code === 429 || error.code === 1003; if (!shouldRetry) { break; } const delay = (2 + Math.random() * 2) * 1000; await workerSleep(delay); } } HMSLogger.e(`Sending ${method} over WS failed after ${Math.min(retry, MAX_RETRIES)} retries`, { method, params, error, }); throw error; } private offlineListener = () => { HMSLogger.d(this.TAG, 'Window network offline'); this.setIsConnected(false, 'Window network offline'); }; private onlineListener = () => { HMSLogger.d(this.TAG, 'Window network online'); this.observer.onNetworkOnline(); }; }