UNPKG

livekit-client

Version:

JavaScript/TypeScript client SDK for LiveKit

1,444 lines (1,259 loc) 46.7 kB
import { type AddTrackRequest, ClientConfigSetting, ClientConfiguration, type ConnectionQualityUpdate, DataChannelInfo, DataPacket, DataPacket_Kind, DisconnectReason, type JoinResponse, type LeaveRequest, LeaveRequest_Action, ParticipantInfo, ReconnectReason, type ReconnectResponse, RequestResponse, Room as RoomModel, SignalTarget, SpeakerInfo, type StreamStateUpdate, SubscribedQualityUpdate, type SubscriptionPermissionUpdate, type SubscriptionResponse, SyncState, TrackInfo, type TrackPublishedResponse, TrackUnpublishedResponse, Transcription, UpdateSubscription, type UserPacket, } from '@livekit/protocol'; import { EventEmitter } from 'events'; import type { MediaAttributes } from 'sdp-transform'; import type TypedEventEmitter from 'typed-emitter'; import type { SignalOptions } from '../api/SignalClient'; import { SignalClient, SignalConnectionState, toProtoSessionDescription, } from '../api/SignalClient'; import log, { LoggerNames, getLogger } from '../logger'; import type { InternalRoomOptions } from '../options'; import PCTransport, { PCEvents } from './PCTransport'; import { PCTransportManager, PCTransportState } from './PCTransportManager'; import type { ReconnectContext, ReconnectPolicy } from './ReconnectPolicy'; import type { RegionUrlProvider } from './RegionUrlProvider'; import { roomConnectOptionDefaults } from './defaults'; import { ConnectionError, ConnectionErrorReason, NegotiationError, TrackInvalidError, UnexpectedConnectionState, } from './errors'; import { EngineEvent } from './events'; import CriticalTimers from './timers'; import type LocalTrack from './track/LocalTrack'; import type LocalTrackPublication from './track/LocalTrackPublication'; import LocalVideoTrack from './track/LocalVideoTrack'; import type { SimulcastTrackInfo } from './track/LocalVideoTrack'; import type RemoteTrackPublication from './track/RemoteTrackPublication'; import type { Track } from './track/Track'; import type { TrackPublishOptions, VideoCodec } from './track/options'; import { getTrackPublicationInfo } from './track/utils'; import type { LoggerOptions } from './types'; import { Mutex, isVideoCodec, isWeb, sleep, supportsAddTrack, supportsTransceiver } from './utils'; const lossyDataChannel = '_lossy'; const reliableDataChannel = '_reliable'; const minReconnectWait = 2 * 1000; const leaveReconnect = 'leave-reconnect'; enum PCState { New, Connected, Disconnected, Reconnecting, Closed, } /** @internal */ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmitter<EngineEventCallbacks>) { client: SignalClient; rtcConfig: RTCConfiguration = {}; peerConnectionTimeout: number = roomConnectOptionDefaults.peerConnectionTimeout; fullReconnectOnNext: boolean = false; pcManager?: PCTransportManager; /** * @internal */ latestJoinResponse?: JoinResponse; get isClosed() { return this._isClosed; } get pendingReconnect() { return !!this.reconnectTimeout; } private lossyDC?: RTCDataChannel; // @ts-ignore noUnusedLocals private lossyDCSub?: RTCDataChannel; private reliableDC?: RTCDataChannel; private dcBufferStatus: Map<DataPacket_Kind, boolean>; // @ts-ignore noUnusedLocals private reliableDCSub?: RTCDataChannel; private subscriberPrimary: boolean = false; private pcState: PCState = PCState.New; private _isClosed: boolean = true; private pendingTrackResolvers: { [key: string]: { resolve: (info: TrackInfo) => void; reject: () => void }; } = {}; // keep join info around for reconnect, this could be a region url private url?: string; private token?: string; private signalOpts?: SignalOptions; private reconnectAttempts: number = 0; private reconnectStart: number = 0; private clientConfiguration?: ClientConfiguration; private attemptingReconnect: boolean = false; private reconnectPolicy: ReconnectPolicy; private reconnectTimeout?: ReturnType<typeof setTimeout>; private participantSid?: string; /** keeps track of how often an initial join connection has been tried */ private joinAttempts: number = 0; /** specifies how often an initial join connection is allowed to retry */ private maxJoinAttempts: number = 1; private closingLock: Mutex; private dataProcessLock: Mutex; private shouldFailNext: boolean = false; private regionUrlProvider?: RegionUrlProvider; private log = log; private loggerOptions: LoggerOptions; private publisherConnectionPromise: Promise<void> | undefined; constructor(private options: InternalRoomOptions) { super(); this.log = getLogger(options.loggerName ?? LoggerNames.Engine); this.loggerOptions = { loggerName: options.loggerName, loggerContextCb: () => this.logContext, }; this.client = new SignalClient(undefined, this.loggerOptions); this.client.signalLatency = this.options.expSignalLatency; this.reconnectPolicy = this.options.reconnectPolicy; this.registerOnLineListener(); this.closingLock = new Mutex(); this.dataProcessLock = new Mutex(); this.dcBufferStatus = new Map([ [DataPacket_Kind.LOSSY, true], [DataPacket_Kind.RELIABLE, true], ]); this.client.onParticipantUpdate = (updates) => this.emit(EngineEvent.ParticipantUpdate, updates); this.client.onConnectionQuality = (update) => this.emit(EngineEvent.ConnectionQualityUpdate, update); this.client.onRoomUpdate = (update) => this.emit(EngineEvent.RoomUpdate, update); this.client.onSubscriptionError = (resp) => this.emit(EngineEvent.SubscriptionError, resp); this.client.onSubscriptionPermissionUpdate = (update) => this.emit(EngineEvent.SubscriptionPermissionUpdate, update); this.client.onSpeakersChanged = (update) => this.emit(EngineEvent.SpeakersChanged, update); this.client.onStreamStateUpdate = (update) => this.emit(EngineEvent.StreamStateChanged, update); this.client.onRequestResponse = (response) => this.emit(EngineEvent.SignalRequestResponse, response); } /** @internal */ get logContext() { return { room: this.latestJoinResponse?.room?.name, roomID: this.latestJoinResponse?.room?.sid, participant: this.latestJoinResponse?.participant?.identity, pID: this.latestJoinResponse?.participant?.sid, }; } async join( url: string, token: string, opts: SignalOptions, abortSignal?: AbortSignal, ): Promise<JoinResponse> { this.url = url; this.token = token; this.signalOpts = opts; this.maxJoinAttempts = opts.maxRetries; try { this.joinAttempts += 1; this.setupSignalClientCallbacks(); const joinResponse = await this.client.join(url, token, opts, abortSignal); this._isClosed = false; this.latestJoinResponse = joinResponse; this.subscriberPrimary = joinResponse.subscriberPrimary; if (!this.pcManager) { await this.configure(joinResponse); } // create offer if (!this.subscriberPrimary || joinResponse.fastPublish) { this.negotiate(); } this.clientConfiguration = joinResponse.clientConfiguration; return joinResponse; } catch (e) { if (e instanceof ConnectionError) { if (e.reason === ConnectionErrorReason.ServerUnreachable) { this.log.warn( `Couldn't connect to server, attempt ${this.joinAttempts} of ${this.maxJoinAttempts}`, this.logContext, ); if (this.joinAttempts < this.maxJoinAttempts) { return this.join(url, token, opts, abortSignal); } } } throw e; } } async close() { const unlock = await this.closingLock.lock(); if (this.isClosed) { unlock(); return; } try { this._isClosed = true; this.emit(EngineEvent.Closing); this.removeAllListeners(); this.deregisterOnLineListener(); this.clearPendingReconnect(); await this.cleanupPeerConnections(); await this.cleanupClient(); } finally { unlock(); } } async cleanupPeerConnections() { await this.pcManager?.close(); this.pcManager = undefined; const dcCleanup = (dc: RTCDataChannel | undefined) => { if (!dc) return; dc.close(); dc.onbufferedamountlow = null; dc.onclose = null; dc.onclosing = null; dc.onerror = null; dc.onmessage = null; dc.onopen = null; }; dcCleanup(this.lossyDC); dcCleanup(this.lossyDCSub); dcCleanup(this.reliableDC); dcCleanup(this.reliableDCSub); this.lossyDC = undefined; this.lossyDCSub = undefined; this.reliableDC = undefined; this.reliableDCSub = undefined; } async cleanupClient() { await this.client.close(); this.client.resetCallbacks(); } addTrack(req: AddTrackRequest): Promise<TrackInfo> { if (this.pendingTrackResolvers[req.cid]) { throw new TrackInvalidError('a track with the same ID has already been published'); } return new Promise<TrackInfo>((resolve, reject) => { const publicationTimeout = setTimeout(() => { delete this.pendingTrackResolvers[req.cid]; reject( new ConnectionError('publication of local track timed out, no response from server'), ); }, 10_000); this.pendingTrackResolvers[req.cid] = { resolve: (info: TrackInfo) => { clearTimeout(publicationTimeout); resolve(info); }, reject: () => { clearTimeout(publicationTimeout); reject(new Error('Cancelled publication by calling unpublish')); }, }; this.client.sendAddTrack(req); }); } /** * Removes sender from PeerConnection, returning true if it was removed successfully * and a negotiation is necessary * @param sender * @returns */ removeTrack(sender: RTCRtpSender): boolean { if (sender.track && this.pendingTrackResolvers[sender.track.id]) { const { reject } = this.pendingTrackResolvers[sender.track.id]; if (reject) { reject(); } delete this.pendingTrackResolvers[sender.track.id]; } try { this.pcManager!.removeTrack(sender); return true; } catch (e: unknown) { this.log.warn('failed to remove track', { ...this.logContext, error: e }); } return false; } updateMuteStatus(trackSid: string, muted: boolean) { this.client.sendMuteTrack(trackSid, muted); } get dataSubscriberReadyState(): string | undefined { return this.reliableDCSub?.readyState; } async getConnectedServerAddress(): Promise<string | undefined> { return this.pcManager?.getConnectedAddress(); } /* @internal */ setRegionUrlProvider(provider: RegionUrlProvider) { this.regionUrlProvider = provider; } private async configure(joinResponse: JoinResponse) { // already configured if (this.pcManager && this.pcManager.currentState !== PCTransportState.NEW) { return; } this.participantSid = joinResponse.participant?.sid; const rtcConfig = this.makeRTCConfiguration(joinResponse); this.pcManager = new PCTransportManager( rtcConfig, joinResponse.subscriberPrimary, this.loggerOptions, ); this.emit(EngineEvent.TransportsCreated, this.pcManager.publisher, this.pcManager.subscriber); this.pcManager.onIceCandidate = (candidate, target) => { this.client.sendIceCandidate(candidate, target); }; this.pcManager.onPublisherOffer = (offer) => { this.client.sendOffer(offer); }; this.pcManager.onDataChannel = this.handleDataChannel; this.pcManager.onStateChange = async (connectionState, publisherState, subscriberState) => { this.log.debug(`primary PC state changed ${connectionState}`, this.logContext); if (['closed', 'disconnected', 'failed'].includes(publisherState)) { // reset publisher connection promise this.publisherConnectionPromise = undefined; } if (connectionState === PCTransportState.CONNECTED) { const shouldEmit = this.pcState === PCState.New; this.pcState = PCState.Connected; if (shouldEmit) { this.emit(EngineEvent.Connected, joinResponse); } } else if (connectionState === PCTransportState.FAILED) { // on Safari, PeerConnection will switch to 'disconnected' during renegotiation if (this.pcState === PCState.Connected) { this.pcState = PCState.Disconnected; this.handleDisconnect( 'peerconnection failed', subscriberState === 'failed' ? ReconnectReason.RR_SUBSCRIBER_FAILED : ReconnectReason.RR_PUBLISHER_FAILED, ); } } // detect cases where both signal client and peer connection are severed and assume that user has lost network connection const isSignalSevered = this.client.isDisconnected || this.client.currentState === SignalConnectionState.RECONNECTING; const isPCSevered = [ PCTransportState.FAILED, PCTransportState.CLOSING, PCTransportState.CLOSED, ].includes(connectionState); if (isSignalSevered && isPCSevered && !this._isClosed) { this.emit(EngineEvent.Offline); } }; this.pcManager.onTrack = (ev: RTCTrackEvent) => { this.emit(EngineEvent.MediaTrackAdded, ev.track, ev.streams[0], ev.receiver); }; if (!supportOptionalDatachannel(joinResponse.serverInfo?.protocol)) { this.createDataChannels(); } } private setupSignalClientCallbacks() { // configure signaling client this.client.onAnswer = async (sd) => { if (!this.pcManager) { return; } this.log.debug('received server answer', { ...this.logContext, RTCSdpType: sd.type }); await this.pcManager.setPublisherAnswer(sd); }; // add candidate on trickle this.client.onTrickle = (candidate, target) => { if (!this.pcManager) { return; } this.log.trace('got ICE candidate from peer', { ...this.logContext, candidate, target }); this.pcManager.addIceCandidate(candidate, target); }; // when server creates an offer for the client this.client.onOffer = async (sd) => { if (!this.pcManager) { return; } const answer = await this.pcManager.createSubscriberAnswerFromOffer(sd); this.client.sendAnswer(answer); }; this.client.onLocalTrackPublished = (res: TrackPublishedResponse) => { this.log.debug('received trackPublishedResponse', { ...this.logContext, cid: res.cid, track: res.track?.sid, }); if (!this.pendingTrackResolvers[res.cid]) { this.log.error(`missing track resolver for ${res.cid}`, { ...this.logContext, cid: res.cid, }); return; } const { resolve } = this.pendingTrackResolvers[res.cid]; delete this.pendingTrackResolvers[res.cid]; resolve(res.track!); }; this.client.onLocalTrackUnpublished = (response: TrackUnpublishedResponse) => { this.emit(EngineEvent.LocalTrackUnpublished, response); }; this.client.onLocalTrackSubscribed = (trackSid: string) => { this.emit(EngineEvent.LocalTrackSubscribed, trackSid); }; this.client.onTokenRefresh = (token: string) => { this.token = token; }; this.client.onRemoteMuteChanged = (trackSid: string, muted: boolean) => { this.emit(EngineEvent.RemoteMute, trackSid, muted); }; this.client.onSubscribedQualityUpdate = (update: SubscribedQualityUpdate) => { this.emit(EngineEvent.SubscribedQualityUpdate, update); }; this.client.onClose = () => { this.handleDisconnect('signal', ReconnectReason.RR_SIGNAL_DISCONNECTED); }; this.client.onLeave = (leave: LeaveRequest) => { this.log.debug('client leave request', { ...this.logContext, reason: leave?.reason }); if (leave.regions && this.regionUrlProvider) { this.log.debug('updating regions', this.logContext); this.regionUrlProvider.setServerReportedRegions(leave.regions); } switch (leave.action) { case LeaveRequest_Action.DISCONNECT: this.emit(EngineEvent.Disconnected, leave?.reason); this.close(); break; case LeaveRequest_Action.RECONNECT: this.fullReconnectOnNext = true; // reconnect immediately instead of waiting for next attempt this.handleDisconnect(leaveReconnect); break; case LeaveRequest_Action.RESUME: // reconnect immediately instead of waiting for next attempt this.handleDisconnect(leaveReconnect); default: break; } }; } private makeRTCConfiguration(serverResponse: JoinResponse | ReconnectResponse): RTCConfiguration { const rtcConfig = { ...this.rtcConfig }; if (this.signalOpts?.e2eeEnabled) { this.log.debug('E2EE - setting up transports with insertable streams', this.logContext); // this makes sure that no data is sent before the transforms are ready // @ts-ignore rtcConfig.encodedInsertableStreams = true; } // update ICE servers before creating PeerConnection if (serverResponse.iceServers && !rtcConfig.iceServers) { const rtcIceServers: RTCIceServer[] = []; serverResponse.iceServers.forEach((iceServer) => { const rtcIceServer: RTCIceServer = { urls: iceServer.urls, }; if (iceServer.username) rtcIceServer.username = iceServer.username; if (iceServer.credential) { rtcIceServer.credential = iceServer.credential; } rtcIceServers.push(rtcIceServer); }); rtcConfig.iceServers = rtcIceServers; } if ( serverResponse.clientConfiguration && serverResponse.clientConfiguration.forceRelay === ClientConfigSetting.ENABLED ) { rtcConfig.iceTransportPolicy = 'relay'; } // @ts-ignore rtcConfig.sdpSemantics = 'unified-plan'; // @ts-ignore rtcConfig.continualGatheringPolicy = 'gather_continually'; return rtcConfig; } private createDataChannels() { if (!this.pcManager) { return; } // clear old data channel callbacks if recreate if (this.lossyDC) { this.lossyDC.onmessage = null; this.lossyDC.onerror = null; } if (this.reliableDC) { this.reliableDC.onmessage = null; this.reliableDC.onerror = null; } // create data channels this.lossyDC = this.pcManager.createPublisherDataChannel(lossyDataChannel, { // will drop older packets that arrive ordered: true, maxRetransmits: 0, }); this.reliableDC = this.pcManager.createPublisherDataChannel(reliableDataChannel, { ordered: true, }); // also handle messages over the pub channel, for backwards compatibility this.lossyDC.onmessage = this.handleDataMessage; this.reliableDC.onmessage = this.handleDataMessage; // handle datachannel errors this.lossyDC.onerror = this.handleDataError; this.reliableDC.onerror = this.handleDataError; // set up dc buffer threshold, set to 64kB (otherwise 0 by default) this.lossyDC.bufferedAmountLowThreshold = 65535; this.reliableDC.bufferedAmountLowThreshold = 65535; // handle buffer amount low events this.lossyDC.onbufferedamountlow = this.handleBufferedAmountLow; this.reliableDC.onbufferedamountlow = this.handleBufferedAmountLow; } private handleDataChannel = async ({ channel }: RTCDataChannelEvent) => { if (!channel) { return; } if (channel.label === reliableDataChannel) { this.reliableDCSub = channel; } else if (channel.label === lossyDataChannel) { this.lossyDCSub = channel; } else { return; } this.log.debug(`on data channel ${channel.id}, ${channel.label}`, this.logContext); channel.onmessage = this.handleDataMessage; }; private handleDataMessage = async (message: MessageEvent) => { // make sure to respect incoming data message order by processing message events one after the other const unlock = await this.dataProcessLock.lock(); try { // decode let buffer: ArrayBuffer | undefined; if (message.data instanceof ArrayBuffer) { buffer = message.data; } else if (message.data instanceof Blob) { buffer = await message.data.arrayBuffer(); } else { this.log.error('unsupported data type', { ...this.logContext, data: message.data }); return; } const dp = DataPacket.fromBinary(new Uint8Array(buffer)); if (dp.value?.case === 'speaker') { // dispatch speaker updates this.emit(EngineEvent.ActiveSpeakersUpdate, dp.value.value.speakers); } else { if (dp.value?.case === 'user') { // compatibility applyUserDataCompat(dp, dp.value.value); } this.emit(EngineEvent.DataPacketReceived, dp); } } finally { unlock(); } }; private handleDataError = (event: Event) => { const channel = event.currentTarget as RTCDataChannel; const channelKind = channel.maxRetransmits === 0 ? 'lossy' : 'reliable'; if (event instanceof ErrorEvent && event.error) { const { error } = event.error; this.log.error(`DataChannel error on ${channelKind}: ${event.message}`, { ...this.logContext, error, }); } else { this.log.error(`Unknown DataChannel error on ${channelKind}`, { ...this.logContext, event }); } }; private handleBufferedAmountLow = (event: Event) => { const channel = event.currentTarget as RTCDataChannel; const channelKind = channel.maxRetransmits === 0 ? DataPacket_Kind.LOSSY : DataPacket_Kind.RELIABLE; this.updateAndEmitDCBufferStatus(channelKind); }; async createSender( track: LocalTrack, opts: TrackPublishOptions, encodings?: RTCRtpEncodingParameters[], ) { if (supportsTransceiver()) { const sender = await this.createTransceiverRTCRtpSender(track, opts, encodings); return sender; } if (supportsAddTrack()) { this.log.warn('using add-track fallback', this.logContext); const sender = await this.createRTCRtpSender(track.mediaStreamTrack); return sender; } throw new UnexpectedConnectionState('Required webRTC APIs not supported on this device'); } async createSimulcastSender( track: LocalVideoTrack, simulcastTrack: SimulcastTrackInfo, opts: TrackPublishOptions, encodings?: RTCRtpEncodingParameters[], ) { // store RTCRtpSender if (supportsTransceiver()) { return this.createSimulcastTransceiverSender(track, simulcastTrack, opts, encodings); } if (supportsAddTrack()) { this.log.debug('using add-track fallback', this.logContext); return this.createRTCRtpSender(track.mediaStreamTrack); } throw new UnexpectedConnectionState('Cannot stream on this device'); } private async createTransceiverRTCRtpSender( track: LocalTrack, opts: TrackPublishOptions, encodings?: RTCRtpEncodingParameters[], ) { if (!this.pcManager) { throw new UnexpectedConnectionState('publisher is closed'); } const streams: MediaStream[] = []; if (track.mediaStream) { streams.push(track.mediaStream); } if (track instanceof LocalVideoTrack) { track.codec = opts.videoCodec; } const transceiverInit: RTCRtpTransceiverInit = { direction: 'sendonly', streams }; if (encodings) { transceiverInit.sendEncodings = encodings; } // addTransceiver for react-native is async. web is synchronous, but await won't effect it. const transceiver = await this.pcManager.addPublisherTransceiver( track.mediaStreamTrack, transceiverInit, ); return transceiver.sender; } private async createSimulcastTransceiverSender( track: LocalVideoTrack, simulcastTrack: SimulcastTrackInfo, opts: TrackPublishOptions, encodings?: RTCRtpEncodingParameters[], ) { if (!this.pcManager) { throw new UnexpectedConnectionState('publisher is closed'); } const transceiverInit: RTCRtpTransceiverInit = { direction: 'sendonly' }; if (encodings) { transceiverInit.sendEncodings = encodings; } // addTransceiver for react-native is async. web is synchronous, but await won't effect it. const transceiver = await this.pcManager.addPublisherTransceiver( simulcastTrack.mediaStreamTrack, transceiverInit, ); if (!opts.videoCodec) { return; } track.setSimulcastTrackSender(opts.videoCodec, transceiver.sender); return transceiver.sender; } private async createRTCRtpSender(track: MediaStreamTrack) { if (!this.pcManager) { throw new UnexpectedConnectionState('publisher is closed'); } return this.pcManager.addPublisherTrack(track); } // websocket reconnect behavior. if websocket is interrupted, and the PeerConnection // continues to work, we can reconnect to websocket to continue the session // after a number of retries, we'll close and give up permanently private handleDisconnect = (connection: string, disconnectReason?: ReconnectReason) => { if (this._isClosed) { return; } this.log.warn(`${connection} disconnected`, this.logContext); if (this.reconnectAttempts === 0) { // only reset start time on the first try this.reconnectStart = Date.now(); } const disconnect = (duration: number) => { this.log.warn( `could not recover connection after ${this.reconnectAttempts} attempts, ${duration}ms. giving up`, this.logContext, ); this.emit(EngineEvent.Disconnected); this.close(); }; const duration = Date.now() - this.reconnectStart; let delay = this.getNextRetryDelay({ elapsedMs: duration, retryCount: this.reconnectAttempts, }); if (delay === null) { disconnect(duration); return; } if (connection === leaveReconnect) { delay = 0; } this.log.debug(`reconnecting in ${delay}ms`, this.logContext); this.clearReconnectTimeout(); if (this.token && this.regionUrlProvider) { // token may have been refreshed, we do not want to recreate the regionUrlProvider // since the current engine may have inherited a regional url this.regionUrlProvider.updateToken(this.token); } this.reconnectTimeout = CriticalTimers.setTimeout( () => this.attemptReconnect(disconnectReason).finally(() => (this.reconnectTimeout = undefined)), delay, ); }; private async attemptReconnect(reason?: ReconnectReason) { if (this._isClosed) { return; } // guard for attempting reconnection multiple times while one attempt is still not finished if (this.attemptingReconnect) { log.warn('already attempting reconnect, returning early', this.logContext); return; } if ( this.clientConfiguration?.resumeConnection === ClientConfigSetting.DISABLED || // signaling state could change to closed due to hardware sleep // those connections cannot be resumed (this.pcManager?.currentState ?? PCTransportState.NEW) === PCTransportState.NEW ) { this.fullReconnectOnNext = true; } try { this.attemptingReconnect = true; if (this.fullReconnectOnNext) { await this.restartConnection(); } else { await this.resumeConnection(reason); } this.clearPendingReconnect(); this.fullReconnectOnNext = false; } catch (e) { this.reconnectAttempts += 1; let recoverable = true; if (e instanceof UnexpectedConnectionState) { this.log.debug('received unrecoverable error', { ...this.logContext, error: e }); // unrecoverable recoverable = false; } else if (!(e instanceof SignalReconnectError)) { // cannot resume this.fullReconnectOnNext = true; } if (recoverable) { this.handleDisconnect('reconnect', ReconnectReason.RR_UNKNOWN); } else { this.log.info( `could not recover connection after ${this.reconnectAttempts} attempts, ${ Date.now() - this.reconnectStart }ms. giving up`, this.logContext, ); this.emit(EngineEvent.Disconnected); await this.close(); } } finally { this.attemptingReconnect = false; } } private getNextRetryDelay(context: ReconnectContext) { try { return this.reconnectPolicy.nextRetryDelayInMs(context); } catch (e) { this.log.warn('encountered error in reconnect policy', { ...this.logContext, error: e }); } // error in user code with provided reconnect policy, stop reconnecting return null; } private async restartConnection(regionUrl?: string) { try { if (!this.url || !this.token) { // permanent failure, don't attempt reconnection throw new UnexpectedConnectionState('could not reconnect, url or token not saved'); } this.log.info(`reconnecting, attempt: ${this.reconnectAttempts}`, this.logContext); this.emit(EngineEvent.Restarting); if (!this.client.isDisconnected) { await this.client.sendLeave(); } await this.cleanupPeerConnections(); await this.cleanupClient(); let joinResponse: JoinResponse; try { if (!this.signalOpts) { this.log.warn( 'attempted connection restart, without signal options present', this.logContext, ); throw new SignalReconnectError(); } // in case a regionUrl is passed, the region URL takes precedence joinResponse = await this.join(regionUrl ?? this.url, this.token, this.signalOpts); } catch (e) { if (e instanceof ConnectionError && e.reason === ConnectionErrorReason.NotAllowed) { throw new UnexpectedConnectionState('could not reconnect, token might be expired'); } throw new SignalReconnectError(); } if (this.shouldFailNext) { this.shouldFailNext = false; throw new Error('simulated failure'); } this.client.setReconnected(); this.emit(EngineEvent.SignalRestarted, joinResponse); await this.waitForPCReconnected(); // re-check signal connection state before setting engine as resumed if (this.client.currentState !== SignalConnectionState.CONNECTED) { throw new SignalReconnectError('Signal connection got severed during reconnect'); } this.regionUrlProvider?.resetAttempts(); // reconnect success this.emit(EngineEvent.Restarted); } catch (error) { const nextRegionUrl = await this.regionUrlProvider?.getNextBestRegionUrl(); if (nextRegionUrl) { await this.restartConnection(nextRegionUrl); return; } else { // no more regions to try (or we're not on cloud) this.regionUrlProvider?.resetAttempts(); throw error; } } } private async resumeConnection(reason?: ReconnectReason): Promise<void> { if (!this.url || !this.token) { // permanent failure, don't attempt reconnection throw new UnexpectedConnectionState('could not reconnect, url or token not saved'); } // trigger publisher reconnect if (!this.pcManager) { throw new UnexpectedConnectionState('publisher and subscriber connections unset'); } this.log.info(`resuming signal connection, attempt ${this.reconnectAttempts}`, this.logContext); this.emit(EngineEvent.Resuming); let res: ReconnectResponse | undefined; try { this.setupSignalClientCallbacks(); res = await this.client.reconnect(this.url, this.token, this.participantSid, reason); } catch (error) { let message = ''; if (error instanceof Error) { message = error.message; this.log.error(error.message, { ...this.logContext, error }); } if (error instanceof ConnectionError && error.reason === ConnectionErrorReason.NotAllowed) { throw new UnexpectedConnectionState('could not reconnect, token might be expired'); } if (error instanceof ConnectionError && error.reason === ConnectionErrorReason.LeaveRequest) { throw error; } throw new SignalReconnectError(message); } this.emit(EngineEvent.SignalResumed); if (res) { const rtcConfig = this.makeRTCConfiguration(res); this.pcManager.updateConfiguration(rtcConfig); } else { this.log.warn('Did not receive reconnect response', this.logContext); } if (this.shouldFailNext) { this.shouldFailNext = false; throw new Error('simulated failure'); } await this.pcManager.triggerIceRestart(); await this.waitForPCReconnected(); // re-check signal connection state before setting engine as resumed if (this.client.currentState !== SignalConnectionState.CONNECTED) { throw new SignalReconnectError('Signal connection got severed during reconnect'); } this.client.setReconnected(); // recreate publish datachannel if it's id is null // (for safari https://bugs.webkit.org/show_bug.cgi?id=184688) if (this.reliableDC?.readyState === 'open' && this.reliableDC.id === null) { this.createDataChannels(); } // resume success this.emit(EngineEvent.Resumed); } async waitForPCInitialConnection(timeout?: number, abortController?: AbortController) { if (!this.pcManager) { throw new UnexpectedConnectionState('PC manager is closed'); } await this.pcManager.ensurePCTransportConnection(abortController, timeout); } private async waitForPCReconnected() { this.pcState = PCState.Reconnecting; this.log.debug('waiting for peer connection to reconnect', this.logContext); try { await sleep(minReconnectWait); // FIXME setTimeout again not ideal for a connection critical path if (!this.pcManager) { throw new UnexpectedConnectionState('PC manager is closed'); } await this.pcManager.ensurePCTransportConnection(undefined, this.peerConnectionTimeout); this.pcState = PCState.Connected; } catch (e: any) { // TODO do we need a `failed` state here for the PC? this.pcState = PCState.Disconnected; throw new ConnectionError(`could not establish PC connection, ${e.message}`); } } waitForRestarted = () => { return new Promise<void>((resolve, reject) => { if (this.pcState === PCState.Connected) { resolve(); } const onRestarted = () => { this.off(EngineEvent.Disconnected, onDisconnected); resolve(); }; const onDisconnected = () => { this.off(EngineEvent.Restarted, onRestarted); reject(); }; this.once(EngineEvent.Restarted, onRestarted); this.once(EngineEvent.Disconnected, onDisconnected); }); }; /* @internal */ async sendDataPacket(packet: DataPacket, kind: DataPacket_Kind) { const msg = packet.toBinary(); // make sure we do have a data connection await this.ensurePublisherConnected(kind); const dc = this.dataChannelForKind(kind); if (dc) { dc.send(msg); } this.updateAndEmitDCBufferStatus(kind); } private updateAndEmitDCBufferStatus = (kind: DataPacket_Kind) => { const status = this.isBufferStatusLow(kind); if (typeof status !== 'undefined' && status !== this.dcBufferStatus.get(kind)) { this.dcBufferStatus.set(kind, status); this.emit(EngineEvent.DCBufferStatusChanged, status, kind); } }; private isBufferStatusLow = (kind: DataPacket_Kind): boolean | undefined => { const dc = this.dataChannelForKind(kind); if (dc) { return dc.bufferedAmount <= dc.bufferedAmountLowThreshold; } }; /** * @internal */ async ensureDataTransportConnected( kind: DataPacket_Kind, subscriber: boolean = this.subscriberPrimary, ) { if (!this.pcManager) { throw new UnexpectedConnectionState('PC manager is closed'); } const transport = subscriber ? this.pcManager.subscriber : this.pcManager.publisher; const transportName = subscriber ? 'Subscriber' : 'Publisher'; if (!transport) { throw new ConnectionError(`${transportName} connection not set`); } let needNegotiation = false; if (!subscriber && !this.dataChannelForKind(kind, subscriber)) { this.createDataChannels(); needNegotiation = true; } if ( !needNegotiation && !subscriber && !this.pcManager.publisher.isICEConnected && this.pcManager.publisher.getICEConnectionState() !== 'checking' ) { needNegotiation = true; } if (needNegotiation) { // start negotiation this.negotiate(); } const targetChannel = this.dataChannelForKind(kind, subscriber); if (targetChannel?.readyState === 'open') { return; } // wait until ICE connected const endTime = new Date().getTime() + this.peerConnectionTimeout; while (new Date().getTime() < endTime) { if ( transport.isICEConnected && this.dataChannelForKind(kind, subscriber)?.readyState === 'open' ) { return; } await sleep(50); } throw new ConnectionError( `could not establish ${transportName} connection, state: ${transport.getICEConnectionState()}`, ); } private async ensurePublisherConnected(kind: DataPacket_Kind) { if (!this.publisherConnectionPromise) { this.publisherConnectionPromise = this.ensureDataTransportConnected(kind, false); } await this.publisherConnectionPromise; } /* @internal */ verifyTransport(): boolean { if (!this.pcManager) { return false; } // primary connection if (this.pcManager.currentState !== PCTransportState.CONNECTED) { return false; } // ensure signal is connected if (!this.client.ws || this.client.ws.readyState === WebSocket.CLOSED) { return false; } return true; } /** @internal */ async negotiate(): Promise<void> { // observe signal state return new Promise<void>(async (resolve, reject) => { if (!this.pcManager) { reject(new NegotiationError('PC manager is closed')); return; } this.pcManager.requirePublisher(); // don't negotiate without any transceivers or data channel, it will generate sdp without ice frag then negotiate failed if ( this.pcManager.publisher.getTransceivers().length == 0 && !this.lossyDC && !this.reliableDC ) { this.createDataChannels(); } const abortController = new AbortController(); const handleClosed = () => { abortController.abort(); this.log.debug('engine disconnected while negotiation was ongoing', this.logContext); resolve(); return; }; if (this.isClosed) { reject('cannot negotiate on closed engine'); } this.on(EngineEvent.Closing, handleClosed); this.pcManager.publisher.once( PCEvents.RTPVideoPayloadTypes, (rtpTypes: MediaAttributes['rtp']) => { const rtpMap = new Map<number, VideoCodec>(); rtpTypes.forEach((rtp) => { const codec = rtp.codec.toLowerCase(); if (isVideoCodec(codec)) { rtpMap.set(rtp.payload, codec); } }); this.emit(EngineEvent.RTPVideoMapUpdate, rtpMap); }, ); try { await this.pcManager.negotiate(abortController); resolve(); } catch (e: any) { if (e instanceof NegotiationError) { this.fullReconnectOnNext = true; } this.handleDisconnect('negotiation', ReconnectReason.RR_UNKNOWN); reject(e); } finally { this.off(EngineEvent.Closing, handleClosed); } }); } dataChannelForKind(kind: DataPacket_Kind, sub?: boolean): RTCDataChannel | undefined { if (!sub) { if (kind === DataPacket_Kind.LOSSY) { return this.lossyDC; } if (kind === DataPacket_Kind.RELIABLE) { return this.reliableDC; } } else { if (kind === DataPacket_Kind.LOSSY) { return this.lossyDCSub; } if (kind === DataPacket_Kind.RELIABLE) { return this.reliableDCSub; } } } /** @internal */ sendSyncState(remoteTracks: RemoteTrackPublication[], localTracks: LocalTrackPublication[]) { if (!this.pcManager) { this.log.warn('sync state cannot be sent without peer connection setup', this.logContext); return; } const previousAnswer = this.pcManager.subscriber.getLocalDescription(); const previousOffer = this.pcManager.subscriber.getRemoteDescription(); /* 1. autosubscribe on, so subscribed tracks = all tracks - unsub tracks, in this case, we send unsub tracks, so server add all tracks to this subscribe pc and unsub special tracks from it. 2. autosubscribe off, we send subscribed tracks. */ const autoSubscribe = this.signalOpts?.autoSubscribe ?? true; const trackSids = new Array<string>(); const trackSidsDisabled = new Array<string>(); remoteTracks.forEach((track) => { if (track.isDesired !== autoSubscribe) { trackSids.push(track.trackSid); } if (!track.isEnabled) { trackSidsDisabled.push(track.trackSid); } }); this.client.sendSyncState( new SyncState({ answer: previousAnswer ? toProtoSessionDescription({ sdp: previousAnswer.sdp, type: previousAnswer.type, }) : undefined, offer: previousOffer ? toProtoSessionDescription({ sdp: previousOffer.sdp, type: previousOffer.type, }) : undefined, subscription: new UpdateSubscription({ trackSids, subscribe: !autoSubscribe, participantTracks: [], }), publishTracks: getTrackPublicationInfo(localTracks), dataChannels: this.dataChannelsInfo(), trackSidsDisabled, }), ); } /* @internal */ failNext() { // debugging method to fail the next reconnect/resume attempt this.shouldFailNext = true; } private dataChannelsInfo(): DataChannelInfo[] { const infos: DataChannelInfo[] = []; const getInfo = (dc: RTCDataChannel | undefined, target: SignalTarget) => { if (dc?.id !== undefined && dc.id !== null) { infos.push( new DataChannelInfo({ label: dc.label, id: dc.id, target, }), ); } }; getInfo(this.dataChannelForKind(DataPacket_Kind.LOSSY), SignalTarget.PUBLISHER); getInfo(this.dataChannelForKind(DataPacket_Kind.RELIABLE), SignalTarget.PUBLISHER); getInfo(this.dataChannelForKind(DataPacket_Kind.LOSSY, true), SignalTarget.SUBSCRIBER); getInfo(this.dataChannelForKind(DataPacket_Kind.RELIABLE, true), SignalTarget.SUBSCRIBER); return infos; } private clearReconnectTimeout() { if (this.reconnectTimeout) { CriticalTimers.clearTimeout(this.reconnectTimeout); } } private clearPendingReconnect() { this.clearReconnectTimeout(); this.reconnectAttempts = 0; } private handleBrowserOnLine = () => { // in case the engine is currently reconnecting, attempt a reconnect immediately after the browser state has changed to 'onLine' if (this.client.currentState === SignalConnectionState.RECONNECTING) { this.clearReconnectTimeout(); this.attemptReconnect(ReconnectReason.RR_SIGNAL_DISCONNECTED); } }; private registerOnLineListener() { if (isWeb()) { window.addEventListener('online', this.handleBrowserOnLine); } } private deregisterOnLineListener() { if (isWeb()) { window.removeEventListener('online', this.handleBrowserOnLine); } } } class SignalReconnectError extends Error {} export type EngineEventCallbacks = { connected: (joinResp: JoinResponse) => void; disconnected: (reason?: DisconnectReason) => void; resuming: () => void; resumed: () => void; restarting: () => void; restarted: () => void; signalResumed: () => void; signalRestarted: (joinResp: JoinResponse) => void; closing: () => void; mediaTrackAdded: ( track: MediaStreamTrack, streams: MediaStream, receiver: RTCRtpReceiver, ) => void; activeSpeakersUpdate: (speakers: Array<SpeakerInfo>) => void; dataPacketReceived: (packet: DataPacket) => void; transcriptionReceived: (transcription: Transcription) => void; transportsCreated: (publisher: PCTransport, subscriber: PCTransport) => void; /** @internal */ trackSenderAdded: (track: Track, sender: RTCRtpSender) => void; rtpVideoMapUpdate: (rtpMap: Map<number, VideoCodec>) => void; dcBufferStatusChanged: (isLow: boolean, kind: DataPacket_Kind) => void; participantUpdate: (infos: ParticipantInfo[]) => void; roomUpdate: (room: RoomModel) => void; connectionQualityUpdate: (update: ConnectionQualityUpdate) => void; speakersChanged: (speakerUpdates: SpeakerInfo[]) => void; streamStateChanged: (update: StreamStateUpdate) => void; subscriptionError: (resp: SubscriptionResponse) => void; subscriptionPermissionUpdate: (update: SubscriptionPermissionUpdate) => void; subscribedQualityUpdate: (update: SubscribedQualityUpdate) => void; localTrackUnpublished: (unpublishedResponse: TrackUnpublishedResponse) => void; localTrackSubscribed: (trackSid: string) => void; remoteMute: (trackSid: string, muted: boolean) => void; offline: () => void; signalRequestResponse: (response: RequestResponse) => void; }; function supportOptionalDatachannel(protocol: number | undefined): boolean { return protocol !== undefined && protocol > 13; } function applyUserDataCompat(newObj: DataPacket, oldObj: UserPacket) { const participantIdentity = newObj.participantIdentity ? newObj.participantIdentity : oldObj.participantIdentity; newObj.participantIdentity = participantIdentity; oldObj.participantIdentity = participantIdentity; const destinationIdentities = newObj.destinationIdentities.length !== 0 ? newObj.destinationIdentities : oldObj.destinationIdentities; newObj.destinationIdentities = destinationIdentities; oldObj.destinationIdentities = destinationIdentities; }