livekit-client
Version:
JavaScript/TypeScript client SDK for LiveKit
1,453 lines (1,325 loc) • 103 kB
text/typescript
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