livekit-client
Version:
JavaScript/TypeScript client SDK for LiveKit
403 lines (342 loc) • 13.1 kB
text/typescript
import { Mutex } from '@livekit/mutex';
import { SignalTarget } from '@livekit/protocol';
import log, { LoggerNames, getLogger } from '../logger';
import TypedPromise from '../utils/TypedPromise';
import PCTransport, { PCEvents } from './PCTransport';
import { roomConnectOptionDefaults } from './defaults';
import { ConnectionError, NegotiationError } from './errors';
import CriticalTimers from './timers';
import type { LoggerOptions } from './types';
import { sleep } from './utils';
export enum PCTransportState {
NEW,
CONNECTING,
CONNECTED,
FAILED,
CLOSING,
CLOSED,
}
type PCMode = 'subscriber-primary' | 'publisher-primary' | 'publisher-only';
export class PCTransportManager {
public publisher: PCTransport;
public subscriber?: PCTransport;
public peerConnectionTimeout: number = roomConnectOptionDefaults.peerConnectionTimeout;
public get needsPublisher() {
return this.isPublisherConnectionRequired;
}
public get needsSubscriber() {
return this.isSubscriberConnectionRequired;
}
public get currentState() {
return this.state;
}
public onStateChange?: (
state: PCTransportState,
pubState: RTCPeerConnectionState,
subState?: RTCPeerConnectionState,
) => void;
public onIceCandidate?: (ev: RTCIceCandidate, target: SignalTarget) => void;
public onDataChannel?: (ev: RTCDataChannelEvent) => void;
public onTrack?: (ev: RTCTrackEvent) => void;
public onPublisherOffer?: (offer: RTCSessionDescriptionInit, offerId: number) => void;
private isPublisherConnectionRequired: boolean;
private isSubscriberConnectionRequired: boolean;
private state: PCTransportState;
private connectionLock: Mutex;
private remoteOfferLock: Mutex;
private log = log;
private loggerOptions: LoggerOptions;
private _mode: PCMode;
get mode(): PCMode {
return this._mode;
}
constructor(mode: PCMode, loggerOptions: LoggerOptions, rtcConfig?: RTCConfiguration) {
this.log = getLogger(loggerOptions.loggerName ?? LoggerNames.PCManager);
this.loggerOptions = loggerOptions;
this.isPublisherConnectionRequired = mode !== 'subscriber-primary';
this.isSubscriberConnectionRequired = mode === 'subscriber-primary';
this.publisher = new PCTransport(rtcConfig, loggerOptions);
this._mode = mode;
if (mode !== 'publisher-only') {
this.subscriber = new PCTransport(rtcConfig, loggerOptions);
this.subscriber.onConnectionStateChange = this.updateState;
this.subscriber.onIceConnectionStateChange = this.updateState;
this.subscriber.onSignalingStatechange = this.updateState;
this.subscriber.onIceCandidate = (candidate) => {
this.onIceCandidate?.(candidate, SignalTarget.SUBSCRIBER);
};
// in subscriber primary mode, server side opens sub data channels.
this.subscriber.onDataChannel = (ev) => {
this.onDataChannel?.(ev);
};
this.subscriber.onTrack = (ev) => {
this.onTrack?.(ev);
};
}
this.publisher.onConnectionStateChange = this.updateState;
this.publisher.onIceConnectionStateChange = this.updateState;
this.publisher.onSignalingStatechange = this.updateState;
this.publisher.onIceCandidate = (candidate) => {
this.onIceCandidate?.(candidate, SignalTarget.PUBLISHER);
};
this.publisher.onTrack = (ev) => {
this.onTrack?.(ev);
};
this.publisher.onOffer = (offer, offerId) => {
this.onPublisherOffer?.(offer, offerId);
};
this.state = PCTransportState.NEW;
this.connectionLock = new Mutex();
this.remoteOfferLock = new Mutex();
}
private get logContext() {
return {
...this.loggerOptions.loggerContextCb?.(),
};
}
requirePublisher(require = true) {
this.isPublisherConnectionRequired = require;
this.updateState();
}
createAndSendPublisherOffer(options?: RTCOfferOptions) {
return this.publisher.createAndSendOffer(options);
}
setPublisherAnswer(sd: RTCSessionDescriptionInit, offerId: number) {
return this.publisher.setRemoteDescription(sd, offerId);
}
removeTrack(sender: RTCRtpSender) {
return this.publisher.removeTrack(sender);
}
async close() {
if (this.publisher && this.publisher.getSignallingState() !== 'closed') {
const publisher = this.publisher;
for (const sender of publisher.getSenders()) {
try {
// TODO: react-native-webrtc doesn't have removeTrack yet.
if (publisher.canRemoveTrack()) {
publisher.removeTrack(sender);
}
} catch (e) {
this.log.warn('could not removeTrack', { ...this.logContext, error: e });
}
}
}
await Promise.all([this.publisher.close(), this.subscriber?.close()]);
this.updateState();
}
async triggerIceRestart() {
if (this.subscriber) {
this.subscriber.restartingIce = true;
}
// only restart publisher if it's needed
if (this.needsPublisher) {
await this.createAndSendPublisherOffer({ iceRestart: true });
}
}
async addIceCandidate(candidate: RTCIceCandidateInit, target: SignalTarget) {
if (target === SignalTarget.PUBLISHER) {
await this.publisher.addIceCandidate(candidate);
} else {
await this.subscriber?.addIceCandidate(candidate);
}
}
async createSubscriberAnswerFromOffer(sd: RTCSessionDescriptionInit, offerId: number) {
this.log.debug('received server offer', {
...this.logContext,
RTCSdpType: sd.type,
sdp: sd.sdp,
signalingState: this.subscriber?.getSignallingState().toString(),
});
const unlock = await this.remoteOfferLock.lock();
try {
const success = await this.subscriber?.setRemoteDescription(sd, offerId);
if (!success) {
return undefined;
}
// answer the offer
const answer = await this.subscriber?.createAndSetAnswer();
return answer;
} finally {
unlock();
}
}
updateConfiguration(config: RTCConfiguration, iceRestart?: boolean) {
this.publisher.setConfiguration(config);
this.subscriber?.setConfiguration(config);
if (iceRestart) {
this.triggerIceRestart();
}
}
async ensurePCTransportConnection(abortController?: AbortController, timeout?: number) {
const unlock = await this.connectionLock.lock();
try {
if (
this.isPublisherConnectionRequired &&
this.publisher.getConnectionState() !== 'connected' &&
this.publisher.getConnectionState() !== 'connecting'
) {
this.log.debug('negotiation required, start negotiating', this.logContext);
this.publisher.negotiate();
}
await Promise.all(
this.requiredTransports?.map((transport) =>
this.ensureTransportConnected(transport, abortController, timeout),
),
);
} finally {
unlock();
}
}
async negotiate(abortController: AbortController) {
return new TypedPromise<void, NegotiationError | Error>(async (resolve, reject) => {
let negotiationTimeout = setTimeout(() => {
reject(new NegotiationError('negotiation timed out'));
}, this.peerConnectionTimeout);
const cleanup = () => {
clearTimeout(negotiationTimeout);
this.publisher.off(PCEvents.NegotiationStarted, onNegotiationStarted);
abortController.signal.removeEventListener('abort', abortHandler);
};
const abortHandler = () => {
cleanup();
reject(new NegotiationError('negotiation aborted'));
};
// Reset the timeout each time a renegotiation cycle starts. This
// prevents premature timeouts when the negotiation machinery is
// actively renegotiating (offers going out, answers coming back) but
// NegotiationComplete hasn't fired yet because new requirements keep
// arriving between offer/answer round-trips.
const onNegotiationStarted = () => {
if (abortController.signal.aborted) {
return;
}
clearTimeout(negotiationTimeout);
negotiationTimeout = setTimeout(() => {
cleanup();
reject(new NegotiationError('negotiation timed out'));
}, this.peerConnectionTimeout);
};
abortController.signal.addEventListener('abort', abortHandler);
this.publisher.on(PCEvents.NegotiationStarted, onNegotiationStarted);
this.publisher.once(PCEvents.NegotiationComplete, () => {
cleanup();
resolve();
});
await this.publisher.negotiate((e) => {
cleanup();
if (e instanceof Error) {
reject(e);
} else {
reject(new Error(String(e)));
}
});
});
}
addPublisherTransceiver(track: MediaStreamTrack, transceiverInit: RTCRtpTransceiverInit) {
return this.publisher.addTransceiver(track, transceiverInit);
}
addPublisherTransceiverOfKind(kind: 'audio' | 'video', transceiverInit: RTCRtpTransceiverInit) {
return this.publisher.addTransceiverOfKind(kind, transceiverInit);
}
getMidForReceiver(receiver: RTCRtpReceiver): string | null | undefined {
const transceivers = this.subscriber
? this.subscriber.getTransceivers()
: this.publisher.getTransceivers();
const matchingTransceiver = transceivers.find(
(transceiver) => transceiver.receiver === receiver,
);
return matchingTransceiver?.mid;
}
addPublisherTrack(track: MediaStreamTrack) {
return this.publisher.addTrack(track);
}
createPublisherDataChannel(label: string, dataChannelDict: RTCDataChannelInit) {
return this.publisher.createDataChannel(label, dataChannelDict);
}
/**
* Returns the first required transport's address if no explicit target is specified
*/
getConnectedAddress(target?: SignalTarget) {
if (target === SignalTarget.PUBLISHER) {
return this.publisher.getConnectedAddress();
} else if (target === SignalTarget.SUBSCRIBER) {
return this.publisher.getConnectedAddress();
}
return this.requiredTransports[0].getConnectedAddress();
}
private get requiredTransports() {
const transports: PCTransport[] = [];
if (this.isPublisherConnectionRequired) {
transports.push(this.publisher);
}
if (this.isSubscriberConnectionRequired && this.subscriber) {
transports.push(this.subscriber);
}
return transports;
}
private updateState = () => {
const previousState = this.state;
const connectionStates = this.requiredTransports.map((tr) => tr.getConnectionState());
if (connectionStates.every((st) => st === 'connected')) {
this.state = PCTransportState.CONNECTED;
} else if (connectionStates.some((st) => st === 'failed')) {
this.state = PCTransportState.FAILED;
} else if (connectionStates.some((st) => st === 'connecting')) {
this.state = PCTransportState.CONNECTING;
} else if (connectionStates.every((st) => st === 'closed')) {
this.state = PCTransportState.CLOSED;
} else if (connectionStates.some((st) => st === 'closed')) {
this.state = PCTransportState.CLOSING;
} else if (connectionStates.every((st) => st === 'new')) {
this.state = PCTransportState.NEW;
}
if (previousState !== this.state) {
this.log.debug(
`pc state change: from ${PCTransportState[previousState]} to ${
PCTransportState[this.state]
}`,
this.logContext,
);
this.onStateChange?.(
this.state,
this.publisher.getConnectionState(),
this.subscriber?.getConnectionState(),
);
}
};
private async ensureTransportConnected(
pcTransport: PCTransport,
abortController?: AbortController,
timeout: number = this.peerConnectionTimeout,
) {
const connectionState = pcTransport.getConnectionState();
if (connectionState === 'connected') {
return;
}
return new Promise<void>(async (resolve, reject) => {
const abortHandler = () => {
this.log.warn('abort transport connection', this.logContext);
CriticalTimers.clearTimeout(connectTimeout);
reject(ConnectionError.cancelled('room connection has been cancelled'));
};
if (abortController?.signal.aborted) {
abortHandler();
}
abortController?.signal.addEventListener('abort', abortHandler);
const connectTimeout = CriticalTimers.setTimeout(() => {
abortController?.signal.removeEventListener('abort', abortHandler);
reject(ConnectionError.internal('could not establish pc connection'));
}, timeout);
while (this.state !== PCTransportState.CONNECTED) {
await sleep(50); // FIXME we shouldn't rely on `sleep` in the connection paths, as it invokes `setTimeout` which can be drastically throttled by browser implementations
if (abortController?.signal.aborted) {
reject(ConnectionError.cancelled('room connection has been cancelled'));
return;
}
}
CriticalTimers.clearTimeout(connectTimeout);
abortController?.signal.removeEventListener('abort', abortHandler);
resolve();
});
}
}