UNPKG

livekit-client

Version:

JavaScript/TypeScript client SDK for LiveKit

1,453 lines (1,325 loc) 103 kB
import { Mutex } from '@livekit/mutex'; import { ChatMessage as ChatMessageModel, ConnectionQualityUpdate, type DataPacket, DataPacket_Kind, DisconnectReason, Encryption_Type, JoinResponse, LeaveRequest, LeaveRequest_Action, MetricsBatch, ParticipantInfo, ParticipantInfo_State, ParticipantPermission, Room as RoomModel, ServerInfo, SimulateScenario, SipDTMF, SpeakerInfo, StreamStateUpdate, SubscriptionError, SubscriptionPermissionUpdate, SubscriptionResponse, TrackInfo, TrackSource, TrackType, Transcription as TranscriptionModel, TranscriptionSegment as TranscriptionSegmentModel, UserPacket, protoInt64, } from '@livekit/protocol'; import { EventEmitter } from 'events'; import 'webrtc-adapter'; import type TypedEmitter from 'typed-emitter'; import { ensureTrailingSlash } from '../api/utils'; import { EncryptionEvent } from '../e2ee'; import { type BaseE2EEManager, E2EEManager } from '../e2ee/E2eeManager'; import log, { LoggerNames, getLogger } from '../logger'; import type { InternalRoomConnectOptions, InternalRoomOptions, RoomConnectOptions, RoomOptions, } from '../options'; import TypedPromise from '../utils/TypedPromise'; import { getBrowser } from '../utils/browserParser'; import { BackOffStrategy } from './BackOffStrategy'; import DeviceManager from './DeviceManager'; import RTCEngine, { DataChannelKind } from './RTCEngine'; import { RegionUrlProvider } from './RegionUrlProvider'; import IncomingDataStreamManager from './data-stream/incoming/IncomingDataStreamManager'; import { type ByteStreamHandler, type TextStreamHandler, } from './data-stream/incoming/StreamReader'; import OutgoingDataStreamManager from './data-stream/outgoing/OutgoingDataStreamManager'; import type LocalDataTrack from './data-track/LocalDataTrack'; import type RemoteDataTrack from './data-track/RemoteDataTrack'; import IncomingDataTrackManager from './data-track/incoming/IncomingDataTrackManager'; import OutgoingDataTrackManager from './data-track/outgoing/OutgoingDataTrackManager'; import { DataTrackInfo, type DataTrackSid } from './data-track/types'; import { audioDefaults, publishDefaults, roomConnectOptionDefaults, roomOptionDefaults, videoDefaults, } from './defaults'; import { ConnectionError, ConnectionErrorReason, UnexpectedConnectionState, UnsupportedServer, } from './errors'; import { EngineEvent, ParticipantEvent, RoomEvent, TrackEvent } from './events'; import LocalParticipant from './participant/LocalParticipant'; import Participant from './participant/Participant'; import { type ConnectionQuality, ParticipantKind } from './participant/Participant'; import RemoteParticipant from './participant/RemoteParticipant'; import { MAX_PAYLOAD_BYTES, RpcError, type RpcInvocationData, byteLength } from './rpc'; import CriticalTimers from './timers'; import LocalAudioTrack from './track/LocalAudioTrack'; import type LocalTrack from './track/LocalTrack'; import LocalTrackPublication from './track/LocalTrackPublication'; import LocalVideoTrack from './track/LocalVideoTrack'; import type RemoteTrack from './track/RemoteTrack'; import RemoteTrackPublication from './track/RemoteTrackPublication'; import { Track } from './track/Track'; import type { TrackPublication } from './track/TrackPublication'; import type { TrackProcessor } from './track/processor/types'; import type { AdaptiveStreamSettings } from './track/types'; import { getNewAudioContext, kindToSource, sourceToKind } from './track/utils'; import { type ChatMessage, type SimulationOptions, type SimulationScenario, type TranscriptionSegment, } from './types'; import { Future, createDummyVideoStreamTrack, extractChatMessage, extractTranscriptionSegments, getDisconnectReasonFromConnectionError, getEmptyAudioStreamTrack, isBrowserSupported, isCloud, isLocalAudioTrack, isLocalParticipant, isReactNative, isRemotePub, isSafariBased, isWeb, numberToBigInt, sleep, supportsSetSinkId, toHttpUrl, unpackStreamId, unwrapConstraint, } from './utils'; export enum ConnectionState { Disconnected = 'disconnected', Connecting = 'connecting', Connected = 'connected', Reconnecting = 'reconnecting', SignalReconnecting = 'signalReconnecting', } const CONNECTION_RECONCILE_FREQUENCY_MS = 4 * 1000; /** * In LiveKit, a room is the logical grouping for a list of participants. * Participants in a room can publish tracks, and subscribe to others' tracks. * * a Room fires [[RoomEvent | RoomEvents]]. * * @noInheritDoc */ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>) { state: ConnectionState = ConnectionState.Disconnected; /** * map of identity: [[RemoteParticipant]] */ remoteParticipants: Map<string, RemoteParticipant>; /** * list of participants that are actively speaking. when this changes * a [[RoomEvent.ActiveSpeakersChanged]] event is fired */ activeSpeakers: Participant[] = []; /** @internal */ engine!: RTCEngine; /** the current participant */ localParticipant: LocalParticipant; /** options of room */ options: InternalRoomOptions; /** reflects the sender encryption status of the local participant */ isE2EEEnabled: boolean = false; serverInfo?: Partial<ServerInfo>; private roomInfo?: RoomModel; private sidToIdentity: Map<string, string>; /** connect options of room */ private connOptions?: InternalRoomConnectOptions; private audioEnabled = true; private audioContext?: AudioContext; /** used for aborting pending connections to a LiveKit server */ private abortController?: AbortController; /** future holding client initiated connection attempt */ private connectFuture?: Future<void, Error>; private disconnectLock: Mutex; private e2eeManager: BaseE2EEManager | undefined; private connectionReconcileInterval?: ReturnType<typeof setInterval>; private regionUrlProvider?: RegionUrlProvider; private regionUrl?: string; private isVideoPlaybackBlocked: boolean = false; private log = log; private bufferedEvents: Array<any> = []; private isResuming: boolean = false; /** * map to store first point in time when a particular transcription segment was received */ private transcriptionReceivedTimes: Map<string, number>; private incomingDataStreamManager: IncomingDataStreamManager; private outgoingDataStreamManager: OutgoingDataStreamManager; private incomingDataTrackManager: IncomingDataTrackManager; private outgoingDataTrackManager: OutgoingDataTrackManager; private rpcHandlers: Map<string, (data: RpcInvocationData) => Promise<string>> = new Map(); get hasE2EESetup(): boolean { return this.e2eeManager !== undefined; } /** * Creates a new Room, the primary construct for a LiveKit session. * @param options */ constructor(options?: RoomOptions) { super(); this.setMaxListeners(100); this.remoteParticipants = new Map(); this.sidToIdentity = new Map(); this.options = { ...roomOptionDefaults, ...options }; this.log = getLogger(this.options.loggerName ?? LoggerNames.Room); this.transcriptionReceivedTimes = new Map(); this.options.audioCaptureDefaults = { ...audioDefaults, ...options?.audioCaptureDefaults, }; this.options.videoCaptureDefaults = { ...videoDefaults, ...options?.videoCaptureDefaults, }; this.options.publishDefaults = { ...publishDefaults, ...options?.publishDefaults, }; this.maybeCreateEngine(); this.incomingDataStreamManager = new IncomingDataStreamManager(); this.outgoingDataStreamManager = new OutgoingDataStreamManager(this.engine, this.log); this.incomingDataTrackManager = new IncomingDataTrackManager({ e2eeManager: this.e2eeManager }); this.incomingDataTrackManager .on('sfuUpdateSubscription', (event) => { this.engine.client.sendUpdateDataSubscription(event.sid, event.subscribe); }) .on('trackPublished', (event) => { if (event.track.publisherIdentity === this.localParticipant.identity) { // Only advertize tracks from other participants return; } this.emit(RoomEvent.DataTrackPublished, event.track); this.remoteParticipants.get(event.track.publisherIdentity)?.addRemoteDataTrack(event.track); }) .on('trackUnpublished', (event) => { if (event.publisherIdentity === this.localParticipant.identity) { // Only advertize tracks from other participants return; } this.emit(RoomEvent.DataTrackUnpublished, event.sid); this.remoteParticipants.get(event.publisherIdentity)?.removeRemoteDataTrack(event.sid); }); this.outgoingDataTrackManager = new OutgoingDataTrackManager({ e2eeManager: this.e2eeManager }); this.outgoingDataTrackManager .on('sfuPublishRequest', (event) => { this.engine.client.sendPublishDataTrackRequest(event.handle, event.name, event.usesE2ee); }) .on('sfuUnpublishRequest', (event) => { this.engine.client.sendUnPublishDataTrackRequest(event.handle); }) .on('trackPublished', (event) => { this.emit(RoomEvent.LocalDataTrackPublished, event.track); }) .on('trackUnpublished', (event) => { this.emit(RoomEvent.LocalDataTrackUnpublished, event.sid); }) .on('packetAvailable', ({ bytes }) => { this.engine.sendLossyBytes(bytes, DataChannelKind.DATA_TRACK_LOSSY, 'wait'); }); this.disconnectLock = new Mutex(); this.localParticipant = new LocalParticipant( '', '', this.engine, this.options, this.rpcHandlers, this.outgoingDataStreamManager, this.outgoingDataTrackManager, ); if (this.options.e2ee || this.options.encryption) { this.setupE2EE(); } this.engine.e2eeManager = this.e2eeManager; this.incomingDataTrackManager.updateE2eeManager(this.e2eeManager ?? null); this.outgoingDataTrackManager.updateE2eeManager(this.e2eeManager ?? null); if (this.options.videoCaptureDefaults.deviceId) { this.localParticipant.activeDeviceMap.set( 'videoinput', unwrapConstraint(this.options.videoCaptureDefaults.deviceId), ); } if (this.options.audioCaptureDefaults.deviceId) { this.localParticipant.activeDeviceMap.set( 'audioinput', unwrapConstraint(this.options.audioCaptureDefaults.deviceId), ); } if (this.options.audioOutput?.deviceId) { this.switchActiveDevice( 'audiooutput', unwrapConstraint(this.options.audioOutput.deviceId), ).catch((e) => this.log.warn(`Could not set audio output: ${e.message}`, this.logContext)); } if (isWeb()) { const abortController = new AbortController(); // in order to catch device changes prior to room connection we need to register the event in the constructor navigator.mediaDevices?.addEventListener?.('devicechange', this.handleDeviceChange, { signal: abortController.signal, }); if (Room.cleanupRegistry) { Room.cleanupRegistry.register(this, () => { abortController.abort(); }); } } } registerTextStreamHandler(topic: string, callback: TextStreamHandler) { return this.incomingDataStreamManager.registerTextStreamHandler(topic, callback); } unregisterTextStreamHandler(topic: string) { return this.incomingDataStreamManager.unregisterTextStreamHandler(topic); } registerByteStreamHandler(topic: string, callback: ByteStreamHandler) { return this.incomingDataStreamManager.registerByteStreamHandler(topic, callback); } unregisterByteStreamHandler(topic: string) { return this.incomingDataStreamManager.unregisterByteStreamHandler(topic); } /** * Establishes the participant as a receiver for calls of the specified RPC method. * * @param method - The name of the indicated RPC method * @param handler - Will be invoked when an RPC request for this method is received * @returns A promise that resolves when the method is successfully registered * @throws {Error} If a handler for this method is already registered (must call unregisterRpcMethod first) * * @example * ```typescript * room.localParticipant?.registerRpcMethod( * 'greet', * async (data: RpcInvocationData) => { * console.log(`Received greeting from ${data.callerIdentity}: ${data.payload}`); * return `Hello, ${data.callerIdentity}!`; * } * ); * ``` * * The handler should return a Promise that resolves to a string. * If unable to respond within `responseTimeout`, the request will result in an error on the caller's side. * * You may throw errors of type `RpcError` with a string `message` in the handler, * and they will be received on the caller's side with the message intact. * Other errors thrown in your handler will not be transmitted as-is, and will instead arrive to the caller as `1500` ("Application Error"). */ registerRpcMethod(method: string, handler: (data: RpcInvocationData) => Promise<string>) { if (this.rpcHandlers.has(method)) { throw Error( `RPC handler already registered for method ${method}, unregisterRpcMethod before trying to register again`, ); } this.rpcHandlers.set(method, handler); } /** * Unregisters a previously registered RPC method. * * @param method - The name of the RPC method to unregister */ unregisterRpcMethod(method: string) { this.rpcHandlers.delete(method); } /** * @experimental */ async setE2EEEnabled(enabled: boolean) { if (this.e2eeManager) { await Promise.all([this.localParticipant.setE2EEEnabled(enabled)]); if (this.localParticipant.identity !== '') { this.e2eeManager.setParticipantCryptorEnabled(enabled, this.localParticipant.identity); } } else { throw Error('e2ee not configured, please set e2ee settings within the room options'); } } private setupE2EE() { // when encryption is enabled via `options.encryption`, we enable data channel encryption const dcEncryptionEnabled = !!this.options.encryption; const e2eeOptions = this.options.encryption || this.options.e2ee; if (e2eeOptions) { if ('e2eeManager' in e2eeOptions) { this.e2eeManager = e2eeOptions.e2eeManager; this.e2eeManager.isDataChannelEncryptionEnabled = dcEncryptionEnabled; } else { this.e2eeManager = new E2EEManager(e2eeOptions, dcEncryptionEnabled); } this.e2eeManager.on( EncryptionEvent.ParticipantEncryptionStatusChanged, (enabled, participant) => { if (isLocalParticipant(participant)) { this.isE2EEEnabled = enabled; } this.emit(RoomEvent.ParticipantEncryptionStatusChanged, enabled, participant); }, ); this.e2eeManager.on(EncryptionEvent.EncryptionError, (error, participantIdentity) => { const participant = participantIdentity ? this.getParticipantByIdentity(participantIdentity) : undefined; this.emit(RoomEvent.EncryptionError, error, participant); }); this.e2eeManager?.setup(this); } } private get logContext() { return { room: this.name, roomID: this.roomInfo?.sid, participant: this.localParticipant.identity, participantID: this.localParticipant.sid, }; } /** * if the current room has a participant with `recorder: true` in its JWT grant **/ get isRecording(): boolean { return this.roomInfo?.activeRecording ?? false; } /** * server assigned unique room id. * returns once a sid has been issued by the server. */ getSid(): TypedPromise<string, UnexpectedConnectionState> { if (this.state === ConnectionState.Disconnected) { return TypedPromise.resolve(''); } if (this.roomInfo && this.roomInfo.sid !== '') { return TypedPromise.resolve(this.roomInfo.sid); } return new TypedPromise<string, UnexpectedConnectionState>((resolve, reject) => { const handleRoomUpdate = (roomInfo: RoomModel) => { if (roomInfo.sid !== '') { this.engine.off(EngineEvent.RoomUpdate, handleRoomUpdate); resolve(roomInfo.sid); } }; this.engine.on(EngineEvent.RoomUpdate, handleRoomUpdate); this.once(RoomEvent.Disconnected, () => { this.engine.off(EngineEvent.RoomUpdate, handleRoomUpdate); reject( new UnexpectedConnectionState('Room disconnected before room server id was available'), ); }); }); } /** user assigned name, derived from JWT token */ get name(): string { return this.roomInfo?.name ?? ''; } /** room metadata */ get metadata(): string | undefined { return this.roomInfo?.metadata; } get numParticipants(): number { return this.roomInfo?.numParticipants ?? 0; } get numPublishers(): number { return this.roomInfo?.numPublishers ?? 0; } private maybeCreateEngine() { if (this.engine && (this.engine.isNewlyCreated || !this.engine.isClosed)) { return; } this.engine = new RTCEngine(this.options); this.engine.e2eeManager = this.e2eeManager; this.engine .on(EngineEvent.ParticipantUpdate, this.handleParticipantUpdates) .on(EngineEvent.RoomUpdate, this.handleRoomUpdate) .on(EngineEvent.SpeakersChanged, this.handleSpeakersChanged) .on(EngineEvent.StreamStateChanged, this.handleStreamStateUpdate) .on(EngineEvent.ConnectionQualityUpdate, this.handleConnectionQualityUpdate) .on(EngineEvent.SubscriptionError, this.handleSubscriptionError) .on(EngineEvent.SubscriptionPermissionUpdate, this.handleSubscriptionPermissionUpdate) .on( EngineEvent.MediaTrackAdded, (mediaTrack: MediaStreamTrack, stream: MediaStream, receiver: RTCRtpReceiver) => { this.onTrackAdded(mediaTrack, stream, receiver); }, ) .on(EngineEvent.Disconnected, (reason?: DisconnectReason) => { this.handleDisconnect(this.options.stopLocalTrackOnUnpublish, reason); }) .on(EngineEvent.ActiveSpeakersUpdate, this.handleActiveSpeakersUpdate) .on(EngineEvent.DataPacketReceived, this.handleDataPacket) .on(EngineEvent.Resuming, () => { this.clearConnectionReconcile(); this.isResuming = true; this.log.info('Resuming signal connection', this.logContext); if (this.setAndEmitConnectionState(ConnectionState.SignalReconnecting)) { this.emit(RoomEvent.SignalReconnecting); } }) .on(EngineEvent.Resumed, () => { this.registerConnectionReconcile(); this.isResuming = false; this.log.info('Resumed signal connection', this.logContext); this.updateSubscriptions(); this.emitBufferedEvents(); if (this.setAndEmitConnectionState(ConnectionState.Connected)) { this.emit(RoomEvent.Reconnected); } }) .on(EngineEvent.SignalResumed, () => { this.bufferedEvents = []; if (this.state === ConnectionState.Reconnecting || this.isResuming) { this.sendSyncState(); } }) .on(EngineEvent.Restarting, this.handleRestarting) .on(EngineEvent.Restarted, this.handleRestarted) .on(EngineEvent.SignalRestarted, this.handleSignalRestarted) .on(EngineEvent.Offline, () => { if (this.setAndEmitConnectionState(ConnectionState.Reconnecting)) { this.emit(RoomEvent.Reconnecting); } }) .on(EngineEvent.DCBufferStatusChanged, (status, kind) => { this.emit(RoomEvent.DCBufferStatusChanged, status, kind); }) .on(EngineEvent.LocalTrackSubscribed, (subscribedSid) => { const trackPublication = this.localParticipant .getTrackPublications() .find(({ trackSid }) => trackSid === subscribedSid) as LocalTrackPublication | undefined; if (!trackPublication) { this.log.warn( 'could not find local track subscription for subscribed event', this.logContext, ); return; } this.localParticipant.emit(ParticipantEvent.LocalTrackSubscribed, trackPublication); this.emitWhenConnected( RoomEvent.LocalTrackSubscribed, trackPublication, this.localParticipant, ); }) .on(EngineEvent.RoomMoved, (roomMoved) => { this.log.debug('room moved', roomMoved); if (roomMoved.room) { this.handleRoomUpdate(roomMoved.room); } this.remoteParticipants.forEach((participant, identity) => { this.handleParticipantDisconnected(identity, participant); }); this.emit(RoomEvent.Moved, roomMoved.room!.name); if (roomMoved.participant) { this.handleParticipantUpdates([roomMoved.participant, ...roomMoved.otherParticipants]); } else { this.handleParticipantUpdates(roomMoved.otherParticipants); } }) .on(EngineEvent.PublishDataTrackResponse, (event) => { if (!event.info) { this.log.warn( `received PublishDataTrackResponse, but event.info was ${event.info}, so skipping.`, this.logContext, ); return; } this.outgoingDataTrackManager.receivedSfuPublishResponse(event.info.pubHandle, { type: 'ok', data: { sid: event.info.sid, pubHandle: event.info.pubHandle, name: event.info.name, usesE2ee: event.info.encryption !== Encryption_Type.NONE, }, }); }) .on(EngineEvent.UnPublishDataTrackResponse, (event) => { if (!event.info) { this.log.warn( `received UnPublishDataTrackResponse, but event.info was ${event.info}, so skipping.`, this.logContext, ); return; } this.outgoingDataTrackManager.receivedSfuUnpublishResponse(event.info.pubHandle); }) .on(EngineEvent.DataTrackSubscriberHandles, (event) => { const handleToSidMapping = new Map( Object.entries(event.subHandles).map(([key, value]) => { return [parseInt(key, 10), value.trackSid]; }), ); this.incomingDataTrackManager.receivedSfuSubscriberHandles(handleToSidMapping); }) .on(EngineEvent.DataTrackPacketReceived, (packetBytes) => { try { this.incomingDataTrackManager.packetReceived(packetBytes); } catch (err) { // NOTE: wrapping in the bare try/catch like this means that the Throws<...> type doesn't // propagate upwards into the public interface. throw err; } }) .on(EngineEvent.Joined, (joinResponse) => { // Ingest data track publication updates into data tracks infrastructure const mapped = new Map( joinResponse.otherParticipants.map((participant) => { return [ participant.identity, participant.dataTracks.map((info) => DataTrackInfo.from(info)), ]; }), ); this.incomingDataTrackManager.receiveSfuPublicationUpdates(mapped); }); if (this.localParticipant) { this.localParticipant.setupEngine(this.engine); } if (this.e2eeManager) { this.e2eeManager.setupEngine(this.engine); } if (this.outgoingDataStreamManager) { this.outgoingDataStreamManager.setupEngine(this.engine); } } /** * getLocalDevices abstracts navigator.mediaDevices.enumerateDevices. * In particular, it requests device permissions by default if needed * and makes sure the returned device does not consist of dummy devices * @param kind * @returns a list of available local devices */ static getLocalDevices( kind?: MediaDeviceKind, requestPermissions: boolean = true, ): Promise<MediaDeviceInfo[]> { return DeviceManager.getInstance().getDevices(kind, requestPermissions); } static cleanupRegistry = typeof FinalizationRegistry !== 'undefined' && new FinalizationRegistry((cleanup: () => void) => { cleanup(); }); /** * prepareConnection should be called as soon as the page is loaded, in order * to speed up the connection attempt. This function will * - perform DNS resolution and pre-warm the DNS cache * - establish TLS connection and cache TLS keys * * With LiveKit Cloud, it will also determine the best edge data center for * the current client to connect to if a token is provided. */ async prepareConnection(url: string, token?: string) { if (this.state !== ConnectionState.Disconnected) { return; } this.log.debug(`prepareConnection to ${url}`, this.logContext); try { if (isCloud(new URL(url)) && token) { this.regionUrlProvider = new RegionUrlProvider(url, token); const regionUrl = await this.regionUrlProvider.getNextBestRegionUrl(); // we will not replace the regionUrl if an attempt had already started // to avoid overriding regionUrl after a new connection attempt had started if (regionUrl && this.state === ConnectionState.Disconnected) { this.regionUrl = regionUrl; await fetch(toHttpUrl(regionUrl), { method: 'HEAD' }); this.log.debug(`prepared connection to ${regionUrl}`, this.logContext); } } else { await fetch(toHttpUrl(url), { method: 'HEAD' }); } } catch (e) { this.log.warn('could not prepare connection', { ...this.logContext, error: e }); } } connect = async (url: string, token: string, opts?: RoomConnectOptions): Promise<void> => { if (!isBrowserSupported()) { if (isReactNative()) { throw Error("WebRTC isn't detected, have you called registerGlobals?"); } else { throw Error( "LiveKit doesn't seem to be supported on this browser. Try to update your browser and make sure no browser extensions are disabling webRTC.", ); } } // In case a disconnect called happened right before the connect call, make sure the disconnect is completed first by awaiting its lock const unlockDisconnect = await this.disconnectLock.lock(); if (this.state === ConnectionState.Connected) { // when the state is reconnecting or connected, this function returns immediately this.log.info(`already connected to room ${this.name}`, this.logContext); unlockDisconnect(); return Promise.resolve(); } if (this.connectFuture) { unlockDisconnect(); return this.connectFuture.promise; } this.setAndEmitConnectionState(ConnectionState.Connecting); if (this.regionUrlProvider?.getServerUrl().toString() !== ensureTrailingSlash(url)) { this.regionUrl = undefined; this.regionUrlProvider = undefined; } if (isCloud(new URL(url))) { if (this.regionUrlProvider === undefined) { this.regionUrlProvider = new RegionUrlProvider(url, token); } else { this.regionUrlProvider.updateToken(token); } // trigger the first fetch without waiting for a response // if initial connection fails, this will speed up picking regional url // on subsequent runs this.regionUrlProvider .fetchRegionSettings() .then((settings) => { this.regionUrlProvider?.setServerReportedRegions(settings); }) .catch((e) => { this.log.warn('could not fetch region settings', { ...this.logContext, error: e }); }); } const connectFn = async ( resolve: () => void, reject: (reason: any) => void, regionUrl?: string, ) => { if (this.abortController) { this.abortController.abort(); } // explicit creation as local var needed to satisfy TS compiler when passing it to `attemptConnection` further down const abortController = new AbortController(); this.abortController = abortController; // at this point the intention to connect has been signalled so we can allow cancelling of the connection via disconnect() again unlockDisconnect?.(); try { await BackOffStrategy.getInstance().getBackOffPromise(url); if (abortController.signal.aborted) { throw ConnectionError.cancelled('Connection attempt aborted'); } await this.attemptConnection(regionUrl ?? url, token, opts, abortController); this.abortController = undefined; resolve(); } catch (error) { if ( this.regionUrlProvider && error instanceof ConnectionError && error.reason !== ConnectionErrorReason.Cancelled && error.reason !== ConnectionErrorReason.NotAllowed ) { let nextUrl: string | null = null; try { this.log.debug('Fetching next region'); nextUrl = await this.regionUrlProvider.getNextBestRegionUrl( this.abortController?.signal, ); } catch (regionFetchError) { if ( regionFetchError instanceof ConnectionError && (regionFetchError.status === 401 || regionFetchError.reason === ConnectionErrorReason.Cancelled) ) { this.handleDisconnect(this.options.stopLocalTrackOnUnpublish); reject(regionFetchError); return; } } if ( // making sure we only register failed attempts on things we actually care about [ ConnectionErrorReason.InternalError, ConnectionErrorReason.ServerUnreachable, ConnectionErrorReason.Timeout, ].includes(error.reason) ) { this.log.debug('Adding failed connection attempt to back off'); BackOffStrategy.getInstance().addFailedConnectionAttempt(url); } if (nextUrl && !this.abortController?.signal.aborted) { this.log.info( `Initial connection failed with ConnectionError: ${error.message}. Retrying with another region: ${nextUrl}`, this.logContext, ); this.recreateEngine(true); await connectFn(resolve, reject, nextUrl); } else { this.handleDisconnect( this.options.stopLocalTrackOnUnpublish, getDisconnectReasonFromConnectionError(error), ); reject(error); } } else { let disconnectReason = DisconnectReason.UNKNOWN_REASON; if (error instanceof ConnectionError) { disconnectReason = getDisconnectReasonFromConnectionError(error); } this.handleDisconnect(this.options.stopLocalTrackOnUnpublish, disconnectReason); reject(error); } } }; const regionUrl = this.regionUrl; this.regionUrl = undefined; this.connectFuture = new Future( (resolve, reject) => { connectFn(resolve, reject, regionUrl); }, () => { this.clearConnectionFutures(); }, ); return this.connectFuture.promise; }; private connectSignal = async ( url: string, token: string, engine: RTCEngine, connectOptions: InternalRoomConnectOptions, roomOptions: InternalRoomOptions, abortController: AbortController, ): Promise<JoinResponse> => { const joinResponse = await engine.join( url, token, { autoSubscribe: connectOptions.autoSubscribe, adaptiveStream: typeof roomOptions.adaptiveStream === 'object' ? true : roomOptions.adaptiveStream, maxRetries: connectOptions.maxRetries, e2eeEnabled: !!this.e2eeManager, websocketTimeout: connectOptions.websocketTimeout, }, abortController.signal, !roomOptions.singlePeerConnection, ); let serverInfo: Partial<ServerInfo> | undefined = joinResponse.serverInfo; if (!serverInfo) { serverInfo = { version: joinResponse.serverVersion, region: joinResponse.serverRegion }; } this.serverInfo = serverInfo; this.log.debug( `connected to Livekit Server ${Object.entries(serverInfo) .map(([key, value]) => `${key}: ${value}`) .join(', ')}`, { room: joinResponse.room?.name, roomSid: joinResponse.room?.sid, identity: joinResponse.participant?.identity, }, ); if (!serverInfo.version) { throw new UnsupportedServer('unknown server version'); } if (serverInfo.version === '0.15.1' && this.options.dynacast) { this.log.debug('disabling dynacast due to server version', this.logContext); // dynacast has a bug in 0.15.1, so we cannot use it then roomOptions.dynacast = false; } return joinResponse; }; private applyJoinResponse = (joinResponse: JoinResponse) => { const pi = joinResponse.participant!; this.localParticipant.sid = pi.sid; this.localParticipant.identity = pi.identity; this.localParticipant.setEnabledPublishCodecs(joinResponse.enabledPublishCodecs); if (this.e2eeManager) { try { this.e2eeManager.setSifTrailer(joinResponse.sifTrailer); } catch (e: any) { this.log.error(e instanceof Error ? e.message : 'Could not set SifTrailer', { ...this.logContext, error: e, }); } } // populate remote participants, these should not trigger new events this.handleParticipantUpdates([pi, ...joinResponse.otherParticipants]); if (joinResponse.room) { this.handleRoomUpdate(joinResponse.room); } }; private attemptConnection = async ( url: string, token: string, opts: RoomConnectOptions | undefined, abortController: AbortController, ) => { if ( this.state === ConnectionState.Reconnecting || this.isResuming || this.engine?.pendingReconnect ) { this.log.info('Reconnection attempt replaced by new connection attempt', this.logContext); // make sure we close and recreate the existing engine in order to get rid of any potentially ongoing reconnection attempts this.recreateEngine(true); } else { // create engine if previously disconnected this.maybeCreateEngine(); } if (this.regionUrlProvider?.isCloud()) { this.engine.setRegionUrlProvider(this.regionUrlProvider); } this.acquireAudioContext(); this.connOptions = { ...roomConnectOptionDefaults, ...opts } as InternalRoomConnectOptions; if (this.connOptions.rtcConfig) { this.engine.rtcConfig = this.connOptions.rtcConfig; } if (this.connOptions.peerConnectionTimeout) { this.engine.peerConnectionTimeout = this.connOptions.peerConnectionTimeout; } try { const joinResponse = await this.connectSignal( url, token, this.engine, this.connOptions, this.options, abortController, ); this.applyJoinResponse(joinResponse); // forward metadata changed for the local participant this.setupLocalParticipantEvents(); this.emit(RoomEvent.SignalConnected); } catch (err) { await this.engine.close(); this.recreateEngine(); const resultingError = abortController.signal.aborted ? ConnectionError.cancelled('Signal connection aborted') : ConnectionError.serverUnreachable('could not establish signal connection'); if (err instanceof Error) { resultingError.message = `${resultingError.message}: ${err.message}`; } if (err instanceof ConnectionError) { resultingError.reason = err.reason; resultingError.status = err.status; } this.log.debug(`error trying to establish signal connection`, { ...this.logContext, error: err, }); throw resultingError; } if (abortController.signal.aborted) { await this.engine.close(); this.recreateEngine(); throw ConnectionError.cancelled(`Connection attempt aborted`); } try { await this.engine.waitForPCInitialConnection( this.connOptions.peerConnectionTimeout, abortController, ); } catch (e) { await this.engine.close(); this.recreateEngine(); throw e; } // also hook unload event if (isWeb() && this.options.disconnectOnPageLeave) { // capturing both 'pagehide' and 'beforeunload' to capture broadest set of browser behaviors window.addEventListener('pagehide', this.onPageLeave); window.addEventListener('beforeunload', this.onPageLeave); } if (isWeb()) { window.addEventListener('freeze', this.onPageLeave); } this.setAndEmitConnectionState(ConnectionState.Connected); this.emit(RoomEvent.Connected); BackOffStrategy.getInstance().resetFailedConnectionAttempts(url); this.registerConnectionReconcile(); // Notify region provider about successful connection if (this.regionUrlProvider) { this.regionUrlProvider.notifyConnected(); } }; /** * disconnects the room, emits [[RoomEvent.Disconnected]] */ disconnect = async (stopTracks = true) => { const unlock = await this.disconnectLock.lock(); try { if (this.state === ConnectionState.Disconnected) { this.log.debug('already disconnected', this.logContext); return; } this.log.info('disconnect from room', { ...this.logContext, }); if ( this.state === ConnectionState.Connecting || this.state === ConnectionState.Reconnecting || this.isResuming ) { // try aborting pending connection attempt const msg = 'Abort connection attempt due to user initiated disconnect'; this.log.warn(msg, this.logContext); this.abortController?.abort(msg); // in case the abort controller didn't manage to cancel the connection attempt, reject the connect promise explicitly this.connectFuture?.reject?.(ConnectionError.cancelled('Client initiated disconnect')); this.connectFuture = undefined; } // close engine (also closes client) if (this.engine) { // send leave if (!this.engine.client.isDisconnected) { await this.engine.client.sendLeave(); } await this.engine.close(); } this.handleDisconnect(stopTracks, DisconnectReason.CLIENT_INITIATED); /* @ts-ignore */ this.engine = undefined; } finally { unlock(); } }; /** * retrieves a participant by identity * @param identity * @returns */ getParticipantByIdentity(identity: string): Participant | undefined { if (this.localParticipant.identity === identity) { return this.localParticipant; } return this.remoteParticipants.get(identity); } private clearConnectionFutures() { this.connectFuture = undefined; } /** * @internal for testing */ async simulateScenario(scenario: SimulationScenario, arg?: any) { let postAction = async () => {}; let req: SimulateScenario | undefined; switch (scenario) { case 'signal-reconnect': // @ts-expect-error function is private await this.engine.client.handleOnClose('simulate disconnect'); break; case 'speaker': req = new SimulateScenario({ scenario: { case: 'speakerUpdate', value: 3, }, }); break; case 'node-failure': req = new SimulateScenario({ scenario: { case: 'nodeFailure', value: true, }, }); break; case 'server-leave': req = new SimulateScenario({ scenario: { case: 'serverLeave', value: true, }, }); break; case 'migration': req = new SimulateScenario({ scenario: { case: 'migration', value: true, }, }); break; case 'resume-reconnect': this.engine.failNext(); // @ts-expect-error function is private await this.engine.client.handleOnClose('simulate resume-disconnect'); break; case 'disconnect-signal-on-resume': postAction = async () => { // @ts-expect-error function is private await this.engine.client.handleOnClose('simulate resume-disconnect'); }; req = new SimulateScenario({ scenario: { case: 'disconnectSignalOnResume', value: true, }, }); break; case 'disconnect-signal-on-resume-no-messages': postAction = async () => { // @ts-expect-error function is private await this.engine.client.handleOnClose('simulate resume-disconnect'); }; req = new SimulateScenario({ scenario: { case: 'disconnectSignalOnResumeNoMessages', value: true, }, }); break; case 'full-reconnect': this.engine.fullReconnectOnNext = true; // @ts-expect-error function is private await this.engine.client.handleOnClose('simulate full-reconnect'); break; case 'force-tcp': case 'force-tls': req = new SimulateScenario({ scenario: { case: 'switchCandidateProtocol', value: scenario === 'force-tls' ? 2 : 1, }, }); postAction = async () => { const onLeave = this.engine.client.onLeave; if (onLeave) { onLeave( new LeaveRequest({ reason: DisconnectReason.CLIENT_INITIATED, action: LeaveRequest_Action.RECONNECT, }), ); } }; break; case 'subscriber-bandwidth': if (arg === undefined || typeof arg !== 'number') { throw new Error('subscriber-bandwidth requires a number as argument'); } req = new SimulateScenario({ scenario: { case: 'subscriberBandwidth', value: numberToBigInt(arg), }, }); break; case 'leave-full-reconnect': req = new SimulateScenario({ scenario: { case: 'leaveRequestFullReconnect', value: true, }, }); default: } if (req) { await this.engine.client.sendSimulateScenario(req); await postAction(); } } private onPageLeave = async () => { this.log.info('Page leave detected, disconnecting', this.logContext); await this.disconnect(); }; /** * Browsers have different policies regarding audio playback. Most requiring * some form of user interaction (click/tap/etc). * In those cases, audio will be silent until a click/tap triggering one of the following * - `startAudio` * - `getUserMedia` */ startAudio = async () => { const elements: Array<HTMLMediaElement> = []; const browser = getBrowser(); if (browser && browser.os === 'iOS') { /** * iOS blocks audio element playback if * - user is not publishing audio themselves and * - no other audio source is playing * * as a workaround, we create an audio element with an empty track, so that * silent audio is always playing */ const audioId = 'livekit-dummy-audio-el'; let dummyAudioEl = document.getElementById(audioId) as HTMLAudioElement | null; if (!dummyAudioEl) { dummyAudioEl = document.createElement('audio'); dummyAudioEl.id = audioId; dummyAudioEl.autoplay = true; dummyAudioEl.hidden = true; const track = getEmptyAudioStreamTrack(); track.enabled = true; const stream = new MediaStream([track]); dummyAudioEl.srcObject = stream; document.addEventListener('visibilitychange', () => { if (!dummyAudioEl) { return; } // set the srcObject to null on page hide in order to prevent lock screen controls to show up for it dummyAudioEl.srcObject = document.hidden ? null : stream; if (!document.hidden) { this.log.debug( 'page visible again, triggering startAudio to resume playback and update playback status', this.logContext, ); this.startAudio(); } }); document.body.append(dummyAudioEl); this.once(RoomEvent.Disconnected, () => { dummyAudioEl?.remove(); dummyAudioEl = null; }); } elements.push(dummyAudioEl); } this.remoteParticipants.forEach((p) => { p.audioTrackPublications.forEach((t) => { if (t.track) { t.track.attachedElements.forEach((e) => { elements.push(e); }); } }); }); try { await Promise.all([ this.acquireAudioContext(), ...elements.map((e) => { e.muted = false; return e.play(); }), ]); this.handleAudioPlaybackStarted(); } catch (err) { this.handleAudioPlaybackFailed(err); throw err; } }; startVideo = async () => { const elements: HTMLMediaElement[] = []; for (const p of this.remoteParticipants.values()) { p.videoTrackPublications.forEach((tr) => { tr.track?.attachedElements.forEach((el) => { if (!elements.includes(el)) { elements.push(el); } }); }); } await Promise.all(elements.map((el) => el.play())) .then(() => { this.handleVideoPlaybackStarted(); }) .catch((e) => { if (e.name === 'NotAllowedError') { this.handleVideoPlaybackFailed(); } else { this.log.warn( 'Resuming video playback failed, make sure you call `startVideo` directly in a user gesture handler', this.logContext, ); } }); }; /** * Returns true if audio playback is enabled */ get canPlaybackAudio(): boolean { return this.audioEnabled; } /** * Returns true if video playback is enabled */ get canPlaybackVideo(): boolean { return !this.isVideoPlaybackBlocked; } getActiveDevice(kind: MediaDeviceKind): string | undefined { return this.localParticipant.activeDeviceMap.get(kind); } /** * Switches all active devices used in this room to the given device. * * Note: setting AudioOutput is not supported on some browsers. See [setSinkId](https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/setSinkId#browser_compatibility) * * @param kind use `videoinput` for camera track, * `audioinput` for microphone track, * `audiooutput` to set speaker for all incoming audio tracks * @param deviceId */ async switchActiveDevice(kind: MediaDeviceKind, deviceId: string, exact: boolean = true) { let success = true; let shouldTriggerImmediateDeviceChange = false; const deviceConstraint = exact ? { exact: deviceId } : deviceId; if (kind === 'audioinput') { shouldTriggerImmediateDeviceChange = this.localParticipant.audioTrackPublications.size === 0; const prevDeviceId = this.getActiveDevice(kind) ?? this.options.audioCaptureDefaults!.deviceId; this.options.audioCaptureDefaults!.deviceId = deviceConstraint; const tracks = Array.from(this.localParticipant.audioTrackPublications.values()).filter( (track) => track.source === Track.Source.Microphone, ); try { success = ( await Promise.all(tracks.map((t) => t.audioTrack?.setDeviceId(deviceConstraint))) ).every((val) => val === true); } catch (e) { this.options.audioCaptureDefaults!.deviceId = prevDeviceId; throw e; } const isMuted = tracks.some((t) => t.track?.isMuted ?? false); if (success && isMuted) shouldTriggerImmediateDeviceChange = true; } else if (kind === 'videoinput') { shouldTriggerImmediateDeviceChange = this.localParticipant.videoTrackPublications.size === 0; const prevDeviceId = this.getActiveDevice(kind) ?? this.options.videoCaptureDefaults!.deviceId; this.options.videoCaptureDefaults!.deviceId = deviceConstraint; const tracks = Array.from(this.localParticipant.videoTrackPublications.values()).filter( (track) => track.source === Track.Source.Camera, ); try { success = ( await Promise.all(tracks.map((t) => t.videoTrack?.setDeviceId(deviceConstraint))) ).every((val) => val === true); } catch (e) { this.options.videoCaptureDefaults!.deviceId = prevDeviceId; throw e; } const isMuted = tracks.some((t) => t.track?.isMuted ?? false); if (success && isMuted) shouldTriggerImmediateDeviceChange = true; } else if (kind === 'audiooutput') { shouldTriggerImmediateDeviceChange = true; if ( (!supportsSetSinkId() && !this.options.webAudioMix) || (this.options.webAudioMix && this.audioContext && !('setSinkId' in this.audioContext)) ) { throw new Error('cannot switch audio output, the current browser does not support it'); } if (this.options.webAudioMix) { // setting `default` for web audio output doesn't work, so we need to normalize the id before deviceId = (await DeviceManager.getInstance().normalizeDeviceId('audiooutput', deviceId)) ?? ''; } this.options.audioOutput ??= {}; const prevDeviceId = this.getActiveDevice(kind) ?? this.options.audioOutput.deviceId; this.options.audioOutput.deviceId = deviceId; try { i