livekit-client
Version:
JavaScript/TypeScript client SDK for LiveKit
1,262 lines (1,128 loc) • 37.5 kB
text/typescript
import { Mutex } from '@livekit/mutex';
import {
AddTrackRequest,
AudioTrackFeature,
ClientInfo,
ConnectionQualityUpdate,
ConnectionSettings,
DataTrackSubscriberHandles,
DisconnectReason,
Encryption_Type,
JoinRequest,
JoinResponse,
LeaveRequest,
LeaveRequest_Action,
MediaSectionsRequirement,
MuteTrackRequest,
ParticipantInfo,
Ping,
PublishDataTrackRequest,
PublishDataTrackResponse,
ReconnectReason,
ReconnectResponse,
RequestResponse,
Room,
RoomMovedResponse,
SessionDescription,
SignalRequest,
SignalResponse,
SignalTarget,
SimulateScenario,
SpeakerInfo,
StreamStateUpdate,
SubscribedQualityUpdate,
SubscriptionPermission,
SubscriptionPermissionUpdate,
SubscriptionResponse,
SyncState,
TrackPermission,
TrackPublishedResponse,
TrackUnpublishedResponse,
TrickleRequest,
UnpublishDataTrackRequest,
UnpublishDataTrackResponse,
UpdateDataSubscription,
UpdateDataSubscription_Update,
UpdateLocalAudioTrack,
UpdateParticipantMetadata,
UpdateSubscription,
UpdateTrackSettings,
UpdateVideoLayers,
VideoLayer,
WrappedJoinRequest,
WrappedJoinRequest_Compression,
protoInt64,
} from '@livekit/protocol';
import log, { LoggerNames, getLogger } from '../logger';
import type { DataTrackHandle } from '../room/data-track/handle';
import { type DataTrackSid } from '../room/data-track/types';
import { ConnectionError } from '../room/errors';
import CriticalTimers from '../room/timers';
import type { LoggerOptions } from '../room/types';
import { getClientInfo, isCompressionStreamSupported, isReactNative, sleep } from '../room/utils';
import { AsyncQueue } from '../utils/AsyncQueue';
import { type WebSocketConnection, WebSocketStream } from './WebSocketStream';
import {
createRtcUrl,
createValidateUrl,
getAbortReasonAsString,
parseSignalResponse,
} from './utils';
// internal options
interface ConnectOpts extends SignalOptions {
/** internal */
reconnect?: boolean;
/** internal */
reconnectReason?: number;
/** internal */
sid?: string;
}
// public options
export interface SignalOptions {
autoSubscribe: boolean;
adaptiveStream?: boolean;
maxRetries: number;
e2eeEnabled: boolean;
websocketTimeout: number;
}
type SignalMessage = SignalRequest['message'];
type SignalKind = NonNullable<SignalMessage>['case'];
const passThroughQueueSignals: Array<SignalKind> = [
'syncState',
'trickle',
'offer',
'answer',
'simulate',
'leave',
];
function canPassThroughQueue(req: SignalMessage): boolean {
const canPass = passThroughQueueSignals.indexOf(req!.case) >= 0;
log.trace('request allowed to bypass queue:', { canPass, req });
return canPass;
}
export enum SignalConnectionState {
CONNECTING,
CONNECTED,
RECONNECTING,
DISCONNECTING,
DISCONNECTED,
}
/** specifies how much time (in ms) we allow for the ws to close its connection gracefully before continuing */
const MAX_WS_CLOSE_TIME = 250;
/** @internal */
export class SignalClient {
requestQueue: AsyncQueue;
queuedRequests: Array<() => Promise<void>>;
useJSON: boolean;
/** signal rtt in milliseconds */
rtt: number = 0;
/** simulate signaling latency by delaying messages */
signalLatency?: number;
onClose?: (reason: string) => void;
onAnswer?: (
sd: RTCSessionDescriptionInit,
offerId: number,
midToTrackId: { [key: string]: string },
) => void;
onOffer?: (
sd: RTCSessionDescriptionInit,
offerId: number,
midToTrackId: { [key: string]: string },
) => void;
// when a new ICE candidate is made available
onTrickle?: (sd: RTCIceCandidateInit, target: SignalTarget) => void;
onParticipantUpdate?: (updates: ParticipantInfo[]) => void;
onLocalTrackPublished?: (res: TrackPublishedResponse) => void;
onNegotiateRequested?: () => void;
onSpeakersChanged?: (res: SpeakerInfo[]) => void;
onRemoteMuteChanged?: (trackSid: string, muted: boolean) => void;
onRoomUpdate?: (room: Room) => void;
onConnectionQuality?: (update: ConnectionQualityUpdate) => void;
onStreamStateUpdate?: (update: StreamStateUpdate) => void;
onSubscribedQualityUpdate?: (update: SubscribedQualityUpdate) => void;
onSubscriptionPermissionUpdate?: (update: SubscriptionPermissionUpdate) => void;
onSubscriptionError?: (update: SubscriptionResponse) => void;
onLocalTrackUnpublished?: (res: TrackUnpublishedResponse) => void;
onTokenRefresh?: (token: string) => void;
onLeave?: (leave: LeaveRequest) => void;
onRequestResponse?: (response: RequestResponse) => void;
onLocalTrackSubscribed?: (trackSid: string) => void;
onRoomMoved?: (res: RoomMovedResponse) => void;
onMediaSectionsRequirement?: (requirement: MediaSectionsRequirement) => void;
onPublishDataTrackResponse?: (event: PublishDataTrackResponse) => void;
onUnPublishDataTrackResponse?: (event: UnpublishDataTrackResponse) => void;
onDataTrackSubscriberHandles?: (event: DataTrackSubscriberHandles) => void;
onJoined?: (event: JoinResponse) => void;
connectOptions?: ConnectOpts;
ws?: WebSocketStream;
get currentState() {
return this.state;
}
get isDisconnected() {
return (
this.state === SignalConnectionState.DISCONNECTING ||
this.state === SignalConnectionState.DISCONNECTED
);
}
private get isEstablishingConnection() {
return (
this.state === SignalConnectionState.CONNECTING ||
this.state === SignalConnectionState.RECONNECTING
);
}
private getNextRequestId() {
this._requestId += 1;
return this._requestId;
}
private options?: SignalOptions;
private pingTimeout: ReturnType<typeof setTimeout> | undefined;
private pingTimeoutDuration: number | undefined;
private pingIntervalDuration: number | undefined;
private pingInterval: ReturnType<typeof setInterval> | undefined;
private closingLock: Mutex;
private state: SignalConnectionState = SignalConnectionState.DISCONNECTED;
private connectionLock: Mutex;
private log = log;
private loggerContextCb?: LoggerOptions['loggerContextCb'];
private _requestId = 0;
private streamWriter: WritableStreamDefaultWriter<ArrayBuffer | string> | undefined;
private useV0SignalPath = false;
constructor(useJSON: boolean = false, loggerOptions: LoggerOptions = {}) {
this.log = getLogger(loggerOptions.loggerName ?? LoggerNames.Signal);
this.loggerContextCb = loggerOptions.loggerContextCb;
this.useJSON = useJSON;
this.requestQueue = new AsyncQueue();
this.queuedRequests = [];
this.closingLock = new Mutex();
this.connectionLock = new Mutex();
this.state = SignalConnectionState.DISCONNECTED;
}
private get logContext() {
return this.loggerContextCb?.() ?? {};
}
async join(
url: string,
token: string,
opts: SignalOptions,
abortSignal?: AbortSignal,
useV0Path: boolean = false,
publisherOffer?: SessionDescription,
): Promise<JoinResponse> {
// during a full reconnect, we'd want to start the sequence even if currently
// connected
this.state = SignalConnectionState.CONNECTING;
this.options = opts;
const res = await this.connect(url, token, opts, abortSignal, useV0Path, publisherOffer);
return res as JoinResponse;
}
async reconnect(
url: string,
token: string,
sid?: string,
reason?: ReconnectReason,
): Promise<ReconnectResponse | undefined> {
if (!this.options) {
this.log.warn(
'attempted to reconnect without signal options being set, ignoring',
this.logContext,
);
return;
}
this.state = SignalConnectionState.RECONNECTING;
// clear ping interval and restart it once reconnected
this.clearPingInterval();
const res = (await this.connect(
url,
token,
{
...this.options,
reconnect: true,
sid,
reconnectReason: reason,
},
undefined,
this.useV0SignalPath,
)) as ReconnectResponse | undefined;
return res;
}
private async connect(
url: string,
token: string,
opts: ConnectOpts,
abortSignal?: AbortSignal,
/** setting this to true results in dual peer connection mode being used */
useV0Path: boolean = false,
publisherOffer?: SessionDescription,
): Promise<JoinResponse | ReconnectResponse | undefined> {
const unlock = await this.connectionLock.lock();
this.connectOptions = opts;
this.useV0SignalPath = useV0Path;
const clientInfo = getClientInfo();
const params = useV0Path
? createConnectionParams(token, clientInfo, opts)
: await createJoinRequestConnectionParams(token, clientInfo, opts, publisherOffer);
const rtcUrl = createRtcUrl(url, params, useV0Path).toString();
const validateUrl = createValidateUrl(rtcUrl).toString();
return new Promise<JoinResponse | ReconnectResponse | undefined>(async (resolve, reject) => {
try {
let alreadyAborted = false;
const abortHandler = async (eventOrError: Event | Error) => {
if (alreadyAborted) {
return;
}
alreadyAborted = true;
const target = eventOrError instanceof Event ? eventOrError.currentTarget : eventOrError;
const reason = getAbortReasonAsString(target, 'Abort handler called');
// send leave if we have an active stream writer (connection is open)
if (this.streamWriter && !this.isDisconnected) {
this.sendLeave()
.then(() => this.close(reason))
.catch((e) => {
this.log.error(e);
this.close();
});
} else {
this.close();
}
cleanupAbortHandlers();
reject(ConnectionError.cancelled(reason));
};
abortSignal?.addEventListener('abort', abortHandler);
const cleanupAbortHandlers = () => {
clearTimeout(wsTimeout);
abortSignal?.removeEventListener('abort', abortHandler);
};
const wsTimeout = setTimeout(() => {
abortHandler(ConnectionError.timeout('room connection has timed out (signal)'));
}, opts.websocketTimeout);
const handleSignalConnected = (
connection: WebSocketConnection,
firstMessage?: SignalResponse,
) => {
this.handleSignalConnected(connection, wsTimeout, firstMessage);
};
const redactedUrl = new URL(rtcUrl);
if (redactedUrl.searchParams.has('access_token')) {
redactedUrl.searchParams.set('access_token', '<redacted>');
}
this.log.debug(`connecting to ${redactedUrl}`, {
reconnect: opts.reconnect,
reconnectReason: opts.reconnectReason,
...this.logContext,
});
if (this.ws) {
await this.close(false);
}
this.ws = new WebSocketStream<ArrayBuffer>(rtcUrl);
try {
this.ws.closed
.then((closeInfo) => {
if (this.isEstablishingConnection) {
reject(
ConnectionError.internal(
`Websocket got closed during a (re)connection attempt: ${closeInfo.reason}`,
),
);
}
if (closeInfo.closeCode !== 1000) {
this.log.warn(`websocket closed`, {
...this.logContext,
reason: closeInfo.reason,
code: closeInfo.closeCode,
wasClean: closeInfo.closeCode === 1000,
state: this.state,
});
if (this.state === SignalConnectionState.CONNECTED) {
this.handleOnClose(closeInfo.reason ?? 'Unexpected WS error');
}
}
return;
})
.catch((reason) => {
if (this.isEstablishingConnection) {
reject(
ConnectionError.internal(
`Websocket error during a (re)connection attempt: ${reason}`,
),
);
}
});
const connection = await this.ws.opened.catch(async (reason: unknown) => {
if (this.state !== SignalConnectionState.CONNECTED) {
this.state = SignalConnectionState.DISCONNECTED;
clearTimeout(wsTimeout);
const error = await this.handleConnectionError(reason, validateUrl);
reject(error);
return;
}
// other errors, handle
this.handleWSError(reason);
reject(reason);
return;
});
clearTimeout(wsTimeout);
if (!connection) {
return;
}
const signalReader = connection.readable.getReader();
this.streamWriter = connection.writable.getWriter();
const firstMessage = await signalReader.read();
signalReader.releaseLock();
if (!firstMessage.value) {
throw ConnectionError.internal('no message received as first message');
}
const firstSignalResponse = parseSignalResponse(firstMessage.value);
// Validate the first message
const validation = this.validateFirstMessage(
firstSignalResponse,
opts.reconnect ?? false,
);
if (!validation.isValid) {
reject(validation.error);
return;
}
// Handle join response
if (firstSignalResponse.message?.case === 'join') {
// Set up ping configuration
this.pingTimeoutDuration = firstSignalResponse.message.value.pingTimeout;
this.pingIntervalDuration = firstSignalResponse.message.value.pingInterval;
if (this.pingTimeoutDuration && this.pingTimeoutDuration > 0) {
this.log.debug('ping config', {
...this.logContext,
timeout: this.pingTimeoutDuration,
interval: this.pingIntervalDuration,
});
}
if (this.onJoined) {
this.onJoined(firstSignalResponse.message.value);
}
}
// Handle successful connection
const firstMessageToProcess = validation.shouldProcessFirstMessage
? firstSignalResponse
: undefined;
handleSignalConnected(connection, firstMessageToProcess);
resolve(validation.response);
} catch (e) {
reject(e);
} finally {
cleanupAbortHandlers();
}
} finally {
unlock();
}
});
}
async startReadingLoop(
signalReader: ReadableStreamDefaultReader<string | ArrayBuffer>,
firstMessage?: SignalResponse,
) {
if (firstMessage) {
this.handleSignalResponse(firstMessage);
}
while (true) {
if (this.signalLatency) {
await sleep(this.signalLatency);
}
const { done, value } = await signalReader.read();
if (done) {
break;
}
const resp = parseSignalResponse(value);
this.handleSignalResponse(resp);
}
}
/** @internal */
resetCallbacks = () => {
this.onAnswer = undefined;
this.onLeave = undefined;
this.onLocalTrackPublished = undefined;
this.onLocalTrackUnpublished = undefined;
this.onNegotiateRequested = undefined;
this.onOffer = undefined;
this.onRemoteMuteChanged = undefined;
this.onSubscribedQualityUpdate = undefined;
this.onTokenRefresh = undefined;
this.onTrickle = undefined;
this.onClose = undefined;
this.onMediaSectionsRequirement = undefined;
};
async close(updateState: boolean = true, reason = 'Close method called on signal client') {
if (
[SignalConnectionState.DISCONNECTING || SignalConnectionState.DISCONNECTED].includes(
this.state,
)
) {
this.log.debug(`ignoring signal close as it's already in disconnecting state`);
return;
}
const unlock = await this.closingLock.lock();
try {
this.clearPingInterval();
if (updateState) {
this.state = SignalConnectionState.DISCONNECTING;
}
if (this.ws) {
this.ws.close({ closeCode: 1000, reason });
// calling `ws.close()` only starts the closing handshake (CLOSING state), prefer to wait until state is actually CLOSED
const closePromise = this.ws.closed;
this.ws = undefined;
this.streamWriter = undefined;
await Promise.race([closePromise, sleep(MAX_WS_CLOSE_TIME)]);
}
} catch (e) {
this.log.debug('websocket error while closing', { ...this.logContext, error: e });
} finally {
if (updateState) {
this.state = SignalConnectionState.DISCONNECTED;
}
unlock();
}
}
// initial offer after joining
sendOffer(offer: RTCSessionDescriptionInit, offerId: number) {
this.log.debug('sending offer', { ...this.logContext, offerSdp: offer.sdp });
this.sendRequest({
case: 'offer',
value: toProtoSessionDescription(offer, offerId),
});
}
// answer a server-initiated offer
sendAnswer(answer: RTCSessionDescriptionInit, offerId: number) {
this.log.debug('sending answer', { ...this.logContext, answerSdp: answer.sdp });
return this.sendRequest({
case: 'answer',
value: toProtoSessionDescription(answer, offerId),
});
}
sendIceCandidate(candidate: RTCIceCandidateInit, target: SignalTarget) {
this.log.debug('sending ice candidate', { ...this.logContext, candidate });
return this.sendRequest({
case: 'trickle',
value: new TrickleRequest({
candidateInit: JSON.stringify(candidate),
target,
}),
});
}
sendMuteTrack(trackSid: string, muted: boolean) {
return this.sendRequest({
case: 'mute',
value: new MuteTrackRequest({
sid: trackSid,
muted,
}),
});
}
sendAddTrack(req: AddTrackRequest) {
return this.sendRequest({
case: 'addTrack',
value: req,
});
}
async sendUpdateLocalMetadata(
metadata: string,
name: string,
attributes: Record<string, string> = {},
) {
const requestId = this.getNextRequestId();
await this.sendRequest({
case: 'updateMetadata',
value: new UpdateParticipantMetadata({
requestId,
metadata,
name,
attributes,
}),
});
return requestId;
}
sendUpdateTrackSettings(settings: UpdateTrackSettings) {
this.sendRequest({
case: 'trackSetting',
value: settings,
});
}
sendUpdateSubscription(sub: UpdateSubscription) {
return this.sendRequest({
case: 'subscription',
value: sub,
});
}
sendSyncState(sync: SyncState) {
return this.sendRequest({
case: 'syncState',
value: sync,
});
}
sendUpdateVideoLayers(trackSid: string, layers: VideoLayer[]) {
return this.sendRequest({
case: 'updateLayers',
value: new UpdateVideoLayers({
trackSid,
layers,
}),
});
}
sendUpdateSubscriptionPermissions(allParticipants: boolean, trackPermissions: TrackPermission[]) {
return this.sendRequest({
case: 'subscriptionPermission',
value: new SubscriptionPermission({
allParticipants,
trackPermissions,
}),
});
}
sendSimulateScenario(scenario: SimulateScenario) {
return this.sendRequest({
case: 'simulate',
value: scenario,
});
}
sendPing() {
/** send both of ping and pingReq for compatibility to old and new server */
return Promise.all([
this.sendRequest({
case: 'ping',
value: protoInt64.parse(Date.now()),
}),
this.sendRequest({
case: 'pingReq',
value: new Ping({
timestamp: protoInt64.parse(Date.now()),
rtt: protoInt64.parse(this.rtt),
}),
}),
]);
}
sendUpdateLocalAudioTrack(trackSid: string, features: AudioTrackFeature[]) {
return this.sendRequest({
case: 'updateAudioTrack',
value: new UpdateLocalAudioTrack({ trackSid, features }),
});
}
sendLeave() {
return this.sendRequest({
case: 'leave',
value: new LeaveRequest({
reason: DisconnectReason.CLIENT_INITIATED,
// server doesn't process this field, keeping it here to indicate the intent of a full disconnect
action: LeaveRequest_Action.DISCONNECT,
}),
});
}
sendPublishDataTrackRequest(handle: DataTrackHandle, name: string, usesE2ee: boolean) {
return this.sendRequest({
case: 'publishDataTrackRequest',
value: new PublishDataTrackRequest({
pubHandle: handle,
name: name,
encryption: usesE2ee ? Encryption_Type.GCM : Encryption_Type.NONE,
}),
});
}
sendUnPublishDataTrackRequest(handle: DataTrackHandle) {
return this.sendRequest({
case: 'unpublishDataTrackRequest',
value: new UnpublishDataTrackRequest({ pubHandle: handle }),
});
}
sendUpdateDataSubscription(sid: DataTrackSid, subscribe: boolean) {
return this.sendRequest({
case: 'updateDataSubscription',
value: new UpdateDataSubscription({
// FIXME: consider refactoring to allow caller to pass an array of events through
updates: [
new UpdateDataSubscription_Update({
trackSid: sid,
subscribe,
}),
],
}),
});
}
private async sendRequest(message: SignalMessage, fromQueue: boolean = false) {
// capture all requests while reconnecting and put them in a queue
// unless the request originates from the queue, then don't enqueue again
const canQueue = !fromQueue && !canPassThroughQueue(message);
if (canQueue && this.state === SignalConnectionState.RECONNECTING) {
this.queuedRequests.push(async () => {
await this.sendRequest(message, true);
});
return;
}
// make sure previously queued requests are being sent first
if (!fromQueue) {
await this.requestQueue.flush();
}
if (this.signalLatency) {
await sleep(this.signalLatency);
}
if (this.isDisconnected) {
// Skip requests if the signal layer is disconnected
// This can happen if an event is sent in the mist of room.connect() initializing
this.log.debug(`skipping signal request (type: ${message.case}) - SignalClient disconnected`);
return;
}
if (!this.streamWriter) {
this.log.error(
`cannot send signal request before connected, type: ${message?.case}`,
this.logContext,
);
return;
}
const req = new SignalRequest({ message });
try {
if (this.useJSON) {
await this.streamWriter.write(req.toJsonString());
} else {
await this.streamWriter.write(req.toBinary());
}
} catch (e) {
this.log.error('error sending signal message', { ...this.logContext, error: e });
}
}
private handleSignalResponse(res: SignalResponse) {
const msg = res.message;
if (msg == undefined) {
this.log.debug('received unsupported message', this.logContext);
return;
}
let pingHandled = false;
if (msg.case === 'answer') {
const sd = fromProtoSessionDescription(msg.value);
if (this.onAnswer) {
this.onAnswer(sd, msg.value.id, msg.value.midToTrackId);
}
} else if (msg.case === 'offer') {
const sd = fromProtoSessionDescription(msg.value);
if (this.onOffer) {
this.onOffer(sd, msg.value.id, msg.value.midToTrackId);
}
} else if (msg.case === 'trickle') {
const candidate: RTCIceCandidateInit = JSON.parse(msg.value.candidateInit!);
if (this.onTrickle) {
this.onTrickle(candidate, msg.value.target);
}
} else if (msg.case === 'update') {
if (this.onParticipantUpdate) {
this.onParticipantUpdate(msg.value.participants ?? []);
}
} else if (msg.case === 'trackPublished') {
if (this.onLocalTrackPublished) {
this.onLocalTrackPublished(msg.value);
}
} else if (msg.case === 'speakersChanged') {
if (this.onSpeakersChanged) {
this.onSpeakersChanged(msg.value.speakers ?? []);
}
} else if (msg.case === 'leave') {
if (this.onLeave) {
this.onLeave(msg.value);
}
} else if (msg.case === 'mute') {
if (this.onRemoteMuteChanged) {
this.onRemoteMuteChanged(msg.value.sid, msg.value.muted);
}
} else if (msg.case === 'roomUpdate') {
if (this.onRoomUpdate && msg.value.room) {
this.onRoomUpdate(msg.value.room);
}
} else if (msg.case === 'connectionQuality') {
if (this.onConnectionQuality) {
this.onConnectionQuality(msg.value);
}
} else if (msg.case === 'streamStateUpdate') {
if (this.onStreamStateUpdate) {
this.onStreamStateUpdate(msg.value);
}
} else if (msg.case === 'subscribedQualityUpdate') {
if (this.onSubscribedQualityUpdate) {
this.onSubscribedQualityUpdate(msg.value);
}
} else if (msg.case === 'subscriptionPermissionUpdate') {
if (this.onSubscriptionPermissionUpdate) {
this.onSubscriptionPermissionUpdate(msg.value);
}
} else if (msg.case === 'refreshToken') {
if (this.onTokenRefresh) {
this.onTokenRefresh(msg.value);
}
} else if (msg.case === 'trackUnpublished') {
if (this.onLocalTrackUnpublished) {
this.onLocalTrackUnpublished(msg.value);
}
} else if (msg.case === 'subscriptionResponse') {
if (this.onSubscriptionError) {
this.onSubscriptionError(msg.value);
}
} else if (msg.case === 'pong') {
} else if (msg.case === 'pongResp') {
this.rtt = Date.now() - Number.parseInt(msg.value.lastPingTimestamp.toString());
this.resetPingTimeout();
pingHandled = true;
} else if (msg.case === 'requestResponse') {
if (this.onRequestResponse) {
this.onRequestResponse(msg.value);
}
} else if (msg.case === 'trackSubscribed') {
if (this.onLocalTrackSubscribed) {
this.onLocalTrackSubscribed(msg.value.trackSid);
}
} else if (msg.case === 'roomMoved') {
if (this.onTokenRefresh) {
this.onTokenRefresh(msg.value.token);
}
if (this.onRoomMoved) {
this.onRoomMoved(msg.value);
}
} else if (msg.case === 'mediaSectionsRequirement') {
if (this.onMediaSectionsRequirement) {
this.onMediaSectionsRequirement(msg.value);
}
} else if (msg.case === 'publishDataTrackResponse') {
if (this.onPublishDataTrackResponse) {
this.onPublishDataTrackResponse(msg.value);
}
} else if (msg.case === 'unpublishDataTrackResponse') {
if (this.onUnPublishDataTrackResponse) {
this.onUnPublishDataTrackResponse(msg.value);
}
} else if (msg.case === 'dataTrackSubscriberHandles') {
if (this.onDataTrackSubscriberHandles) {
this.onDataTrackSubscriberHandles(msg.value);
}
} else {
this.log.debug('unsupported message', { ...this.logContext, msgCase: msg.case });
}
if (!pingHandled) {
this.resetPingTimeout();
}
}
setReconnected() {
while (this.queuedRequests.length > 0) {
const req = this.queuedRequests.shift();
if (req) {
this.requestQueue.run(req);
}
}
}
private async handleOnClose(reason: string) {
if (this.state === SignalConnectionState.DISCONNECTED) return;
const onCloseCallback = this.onClose;
await this.close(undefined, reason);
this.log.debug(`websocket connection closed: ${reason}`, { ...this.logContext, reason });
if (onCloseCallback) {
onCloseCallback(reason);
}
}
private handleWSError(error: unknown) {
this.log.error('websocket error', { ...this.logContext, error });
}
/**
* Resets the ping timeout and starts a new timeout.
* Call this after receiving a pong message
*/
private resetPingTimeout() {
this.clearPingTimeout();
if (!this.pingTimeoutDuration) {
this.log.warn('ping timeout duration not set', this.logContext);
return;
}
this.pingTimeout = CriticalTimers.setTimeout(() => {
this.log.warn(
`ping timeout triggered. last pong received at: ${new Date(
Date.now() - this.pingTimeoutDuration! * 1000,
).toUTCString()}`,
this.logContext,
);
this.handleOnClose('ping timeout');
}, this.pingTimeoutDuration * 1000);
}
/**
* Clears ping timeout (does not start a new timeout)
*/
private clearPingTimeout() {
if (this.pingTimeout) {
CriticalTimers.clearTimeout(this.pingTimeout);
}
}
private startPingInterval() {
this.clearPingInterval();
this.resetPingTimeout();
if (!this.pingIntervalDuration) {
this.log.warn('ping interval duration not set', this.logContext);
return;
}
this.log.debug('start ping interval', this.logContext);
this.pingInterval = CriticalTimers.setInterval(() => {
this.sendPing();
}, this.pingIntervalDuration * 1000);
}
private clearPingInterval() {
this.log.debug('clearing ping interval', this.logContext);
this.clearPingTimeout();
if (this.pingInterval) {
CriticalTimers.clearInterval(this.pingInterval);
}
}
/**
* Handles the successful connection to the signal server
* @param connection The WebSocket connection
* @param timeoutHandle The timeout handle to clear
* @param firstMessage Optional first message to process
* @internal
*/
private handleSignalConnected(
connection: WebSocketConnection,
timeoutHandle: ReturnType<typeof setTimeout>,
firstMessage?: SignalResponse,
) {
this.state = SignalConnectionState.CONNECTED;
clearTimeout(timeoutHandle);
this.startPingInterval();
this.startReadingLoop(connection.readable.getReader(), firstMessage);
}
/**
* Validates the first message received from the signal server
* @param firstSignalResponse The first signal response received
* @param isReconnect Whether this is a reconnection attempt
* @returns Validation result with response or error
* @internal
*/
private validateFirstMessage(
firstSignalResponse: SignalResponse,
isReconnect: boolean,
): {
isValid: boolean;
response?: JoinResponse | ReconnectResponse;
error?: ConnectionError;
shouldProcessFirstMessage?: boolean;
} {
if (firstSignalResponse.message?.case === 'join') {
return {
isValid: true,
response: firstSignalResponse.message.value,
};
} else if (
this.state === SignalConnectionState.RECONNECTING &&
firstSignalResponse.message?.case !== 'leave'
) {
if (firstSignalResponse.message?.case === 'reconnect') {
return {
isValid: true,
response: firstSignalResponse.message.value,
};
} else {
// in reconnecting, any message received means signal reconnected and we still need to process it
this.log.debug(
'declaring signal reconnected without reconnect response received',
this.logContext,
);
return {
isValid: true,
response: undefined,
shouldProcessFirstMessage: true,
};
}
} else if (this.isEstablishingConnection && firstSignalResponse.message?.case === 'leave') {
return {
isValid: false,
error: ConnectionError.leaveRequest(
'Received leave request while trying to (re)connect',
firstSignalResponse.message.value.reason,
),
};
} else if (!isReconnect) {
// non-reconnect case, should receive join response first
return {
isValid: false,
error: ConnectionError.internal(
`did not receive join response, got ${firstSignalResponse.message?.case} instead`,
),
};
}
return {
isValid: false,
error: ConnectionError.internal('Unexpected first message'),
};
}
/**
* Handles WebSocket connection errors by validating with the server
* @param reason The error that occurred
* @param validateUrl The URL to validate the connection with
* @returns A ConnectionError with appropriate reason and status
* @internal
*/
private async handleConnectionError(
reason: unknown,
validateUrl: string,
): Promise<ConnectionError> {
try {
const resp = await fetch(validateUrl);
switch (resp.status) {
case 404:
return ConnectionError.serviceNotFound(
'v1 RTC path not found. Consider upgrading your LiveKit server version',
'v0-rtc',
);
case 401:
case 403:
const msg = await resp.text();
return ConnectionError.notAllowed(msg, resp.status);
default:
break;
}
if (reason instanceof ConnectionError) {
return reason;
} else {
return ConnectionError.internal(
`Encountered unknown websocket error during connection: ${reason}`,
{ status: resp.status, statusText: resp.statusText },
);
}
} catch (e) {
return e instanceof ConnectionError
? e
: ConnectionError.serverUnreachable(
e instanceof Error ? e.message : 'server was not reachable',
);
}
}
}
function fromProtoSessionDescription(sd: SessionDescription): RTCSessionDescriptionInit {
const rsd: RTCSessionDescriptionInit = {
type: 'offer',
sdp: sd.sdp,
};
switch (sd.type) {
case 'answer':
case 'offer':
case 'pranswer':
case 'rollback':
rsd.type = sd.type;
break;
default:
break;
}
return rsd;
}
export function toProtoSessionDescription(
rsd: RTCSessionDescription | RTCSessionDescriptionInit,
id?: number,
): SessionDescription {
const sd = new SessionDescription({
sdp: rsd.sdp!,
type: rsd.type!,
id,
});
return sd;
}
function createConnectionParams(
token: string,
info: ClientInfo,
opts: ConnectOpts,
): URLSearchParams {
const params = new URLSearchParams();
params.set('access_token', token);
// opts
if (opts.reconnect) {
params.set('reconnect', '1');
if (opts.sid) {
params.set('sid', opts.sid);
}
}
params.set('auto_subscribe', opts.autoSubscribe ? '1' : '0');
// ClientInfo
params.set('sdk', isReactNative() ? 'reactnative' : 'js');
params.set('version', info.version!);
params.set('protocol', info.protocol!.toString());
if (info.deviceModel) {
params.set('device_model', info.deviceModel);
}
if (info.os) {
params.set('os', info.os);
}
if (info.osVersion) {
params.set('os_version', info.osVersion);
}
if (info.browser) {
params.set('browser', info.browser);
}
if (info.browserVersion) {
params.set('browser_version', info.browserVersion);
}
if (opts.adaptiveStream) {
params.set('adaptive_stream', '1');
}
if (opts.reconnectReason) {
params.set('reconnect_reason', opts.reconnectReason.toString());
}
// @ts-ignore
if (navigator.connection?.type) {
// @ts-ignore
params.set('network', navigator.connection.type);
}
return params;
}
async function createJoinRequestConnectionParams(
token: string,
info: ClientInfo,
opts: ConnectOpts,
publisherOffer?: SessionDescription,
): Promise<URLSearchParams> {
const params = new URLSearchParams();
params.set('access_token', token);
const joinRequest = new JoinRequest({
clientInfo: info,
connectionSettings: new ConnectionSettings({
autoSubscribe: !!opts.autoSubscribe,
adaptiveStream: !!opts.adaptiveStream,
}),
reconnect: !!opts.reconnect,
participantSid: opts.sid ? opts.sid : undefined,
publisherOffer: publisherOffer,
});
if (opts.reconnectReason) {
joinRequest.reconnectReason = opts.reconnectReason;
}
const joinRequestBytes = joinRequest.toBinary();
let requestBytes: Uint8Array;
let compression: WrappedJoinRequest_Compression;
if (isCompressionStreamSupported()) {
const stream = new CompressionStream('gzip');
const writer = stream.writable.getWriter();
writer.write(new Uint8Array(joinRequestBytes));
writer.close();
const chunks: Uint8Array[] = [];
const reader = stream.readable.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
}
const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.length;
}
requestBytes = result;
compression = WrappedJoinRequest_Compression.GZIP;
} else {
requestBytes = joinRequestBytes;
compression = WrappedJoinRequest_Compression.NONE;
}
const wrappedJoinRequest = new WrappedJoinRequest({
joinRequest: requestBytes,
compression,
});
const wrappedBytes = wrappedJoinRequest.toBinary();
const bytesToBase64 = (bytes: Uint8Array) => {
const binString = Array.from(bytes, (byte) => String.fromCodePoint(byte)).join('');
return btoa(binString);
};
params.set('join_request', bytesToBase64(wrappedBytes).replace(/\+/g, '-').replace(/\//g, '_'));
return params;
}