@telnyx/react-native-voice-sdk
Version:
Telnyx React Native Voice SDK - A complete WebRTC voice calling solution
426 lines (367 loc) • 14.3 kB
text/typescript
import log from 'loglevel';
import {
mediaDevices,
RTCPeerConnection,
RTCSessionDescription,
MediaStream,
} from 'react-native-webrtc';
import type { MediaTrackConstraints } from 'react-native-webrtc/lib/typescript/Constraints';
import type RTCIceCandidateEvent from 'react-native-webrtc/lib/typescript/RTCIceCandidateEvent';
import type { RTCSessionDescriptionInit } from 'react-native-webrtc/lib/typescript/RTCSessionDescription';
import type RTCTrackEvent from 'react-native-webrtc/lib/typescript/RTCTrackEvent';
import type { CallOptions } from './call-options';
import type { DeferredPromise } from './promise';
import { createDeferredPromise } from './promise';
import type { WebRTCReporter } from './webrtc-reporter';
type MediaConstraints = {
audio?: boolean | MediaTrackConstraints;
video?: boolean | MediaTrackConstraints;
};
export type TrickleIceCandidate = {
candidate: string;
sdpMid?: string | null;
sdpMLineIndex?: number | null;
};
export class Peer {
public static createOffer = async (callOptions: CallOptions) => {
const peer = await new Peer(callOptions)
.createPeerConnection()
.attachLocalStream({ audio: true, video: false })
.then((peer) => peer.createOffer());
if (!peer.useTrickleIce) {
await peer.waitForIceGatheringComplete();
}
return peer;
};
public mediaConstraints: MediaConstraints;
private instance: RTCPeerConnection | null;
private options: CallOptions;
private iceGatheringComplete: DeferredPromise<boolean> | null;
private reporter: WebRTCReporter | null = null;
private pendingRemoteCandidates: TrickleIceCandidate[] = [];
private pendingLocalCandidates: TrickleIceCandidate[] = [];
private pendingLocalEndOfCandidates = false;
private localCandidateDispatchEnabled = false;
/** Callback for local Trickle ICE candidates. Call owns signaling transport. */
public onLocalCandidate: ((candidate: TrickleIceCandidate) => void) | null = null;
/** Callback for local ICE completion when Trickle ICE is enabled. */
public onLocalEndOfCandidates: (() => void) | null = null;
/** Callback for logging peer events to call report collector */
public onPeerEventLog: ((event: string, context: Record<string, unknown>) => void) | null = null;
constructor(options: CallOptions) {
this.instance = null;
this.iceGatheringComplete = null;
this.mediaConstraints = { audio: true, video: false };
this.options = options;
}
/**
* Set the WebRTC reporter for debug stats collection
*/
public setReporter(reporter: WebRTCReporter | null): void {
this.reporter = reporter;
}
/**
* Get the underlying RTCPeerConnection instance
*/
public getPeerConnection(): RTCPeerConnection | null {
return this.instance;
}
/**
* Get the ICE servers configuration
*/
public getIceServers(): RTCIceServer[] {
return this.getIceServersInternal();
}
public close = () => {
log.debug('[Peer] Closing peer connection');
if (this.instance) {
this.instance.close();
this.instance = null;
}
if (this.iceGatheringComplete) {
// Inbound pre-accept flows may never await ICE completion. Attach a handler
// before rejecting so close() is marked handled while awaited callers still reject.
this.iceGatheringComplete.promise.catch(() => {});
this.iceGatheringComplete.reject(new Error('Peer connection closed'));
this.iceGatheringComplete = null;
}
this.options.localStream?.getTracks().forEach((track) => {
track.stop();
});
this.options.localStream = undefined;
this.options.remoteStream?.getTracks().forEach((track) => {
track.stop();
});
this.options.remoteStream = undefined;
this.pendingRemoteCandidates = [];
this.pendingLocalCandidates = [];
this.pendingLocalEndOfCandidates = false;
this.localCandidateDispatchEnabled = false;
return this;
};
public setRemoteDescription = async (session: RTCSessionDescriptionInit) => {
if (!this.instance) {
throw new Error('[Peer] Peer connection not created');
}
log.debug('[Peer] Setting remote description', session);
await this.instance.setRemoteDescription(new RTCSessionDescription(session)).catch((error) => {
log.error('[Peer] Error setting remote description', error);
});
await this.flushPendingRemoteCandidates();
return this;
};
public get remoteDescription() {
if (!this.instance) {
throw new Error('[Peer] Peer connection not created');
}
return this.instance.remoteDescription;
}
public get localDescription() {
if (!this.instance) {
throw new Error('[Peer] Peer connection not created');
}
return this.instance.localDescription;
}
public createPeerConnection = () => {
log.debug('[Peer] Creating peer connection');
this.iceGatheringComplete = createDeferredPromise(1000);
const instance = new RTCPeerConnection({
bundlePolicy: this.options.peerConnectionOptions?.bundlePolicy || 'max-compat',
iceServers: this.getIceServers(),
iceTransportPolicy: this.options.peerConnectionOptions?.iceTransportPolicy || 'all',
rtcpMuxPolicy: this.options.peerConnectionOptions?.rtcpMuxPolicy || 'require',
iceCandidatePoolSize: this.options.peerConnectionOptions?.prefetchIceCandidates ? 10 : 0,
});
this.instance = instance;
this.instance.addEventListener('icecandidate', this.onIceCandidate);
this.instance.addEventListener('icecandidateerror', this.onIceCandidateError);
this.instance.addEventListener('icegatheringstatechange', this.onIceGatheringStateChange);
this.instance.addEventListener('connectionstatechange', this.onConnectionStateChange);
this.instance.addEventListener('iceconnectionstatechange', this.onIceConnectionStateChange);
this.instance.addEventListener('signalingstatechange', this.onSignalingStateChange);
this.instance.addEventListener('track', this.onTrackEvent);
return this;
};
public createAnswer = async () => {
if (!this.instance) {
throw new Error('[Peer] Cannot create answer before peer connection');
}
const answer = await this.instance.createAnswer();
await this.instance.setLocalDescription(answer).catch((error) => {
log.error('[Peer] Error setting local description', error);
});
return this;
};
public createOffer = async () => {
if (!this.instance) {
throw new Error('[Peer] Cannot create offer before peer connection');
}
const offer = await this.instance.createOffer({
offerToReceiveAudio: true,
offerToReceiveVideo: false,
});
await this.instance.setLocalDescription(offer).catch((error) => {
log.error('[Peer] Error setting local description', error);
});
return this;
};
public attachLocalStream = async (constraints: MediaConstraints) => {
log.debug('[Peer] Attaching local stream', constraints);
if (!this.instance) {
throw new Error('[Peer] Peer connection not created');
}
this.mediaConstraints = constraints;
if (this.options.localStream == null) {
this.options.localStream = await mediaDevices.getUserMedia(constraints);
}
this.options.localStream.getTracks().forEach((track) => {
log.debug('[Peer] Adding track', track);
this.instance?.addTrack(track, this.options.localStream!);
});
return this;
};
public waitForIceGatheringComplete = async () => {
if (!this.iceGatheringComplete) {
throw new Error('Ice gathering not started');
}
await this.iceGatheringComplete.promise;
log.debug('[Peer] ICE gathering complete');
return this;
};
public get useTrickleIce(): boolean {
return Boolean(this.options.peerConnectionOptions?.useTrickleIce);
}
public flushPendingLocalCandidates = () => {
this.localCandidateDispatchEnabled = true;
while (this.pendingLocalCandidates.length > 0) {
const candidate = this.pendingLocalCandidates.shift()!;
this.onLocalCandidate?.(candidate);
}
if (this.pendingLocalEndOfCandidates) {
this.pendingLocalEndOfCandidates = false;
this.onLocalEndOfCandidates?.();
}
return this;
};
public clearPendingLocalCandidates = () => {
this.pendingLocalCandidates = [];
this.pendingLocalEndOfCandidates = false;
return this;
};
public addRemoteCandidate = async (candidate: TrickleIceCandidate) => {
if (!this.instance) {
throw new Error('[Peer] Peer connection not created');
}
if (!this.instance.remoteDescription) {
log.debug('[Peer] Queueing remote ICE candidate until remote description is set');
this.pendingRemoteCandidates.push(candidate);
return this;
}
// react-native-webrtc accepts RTCIceCandidateInit here, but its TS surface
// can lag the native implementation.
await (this.instance as any).addIceCandidate(candidate).catch((error: unknown) => {
log.error('[Peer] Error adding remote ICE candidate', error);
});
return this;
};
public addRemoteEndOfCandidates = async () => {
if (!this.instance) {
throw new Error('[Peer] Peer connection not created');
}
if (!this.instance.remoteDescription) {
return this;
}
if (typeof (this.instance as any).addIceCandidate === 'function') {
// Some RN WebRTC versions ignore or reject null end-of-candidates.
// Treat that as non-fatal because candidates have already been delivered.
await (this.instance as any).addIceCandidate(null).catch((error: unknown) => {
log.debug('[Peer] Remote end-of-candidates ignored by peer connection', error);
});
}
return this;
};
private flushPendingRemoteCandidates = async () => {
while (this.pendingRemoteCandidates.length > 0) {
const candidate = this.pendingRemoteCandidates.shift()!;
await this.addRemoteCandidate(candidate);
}
};
private emitOrQueueLocalCandidate(candidate: TrickleIceCandidate) {
if (this.localCandidateDispatchEnabled) {
this.onLocalCandidate?.(candidate);
return;
}
this.pendingLocalCandidates.push(candidate);
}
private emitOrQueueLocalEndOfCandidates() {
if (this.localCandidateDispatchEnabled) {
this.onLocalEndOfCandidates?.();
return;
}
this.pendingLocalEndOfCandidates = true;
}
public setMediaStreamState = (stream: MediaStream, enabled: boolean) => {
log.debug('[Peer] Setting media stream state', { enabled, stream });
if (!stream) {
throw new Error('[Peer] No media stream to set state');
}
stream.getTracks().forEach((track) => {
log.debug('[Peer] Track state set', { track, enabled });
track.enabled = enabled;
});
return this;
};
private onIceGatheringStateChange = () => {
const iceGatheringState = this.instance?.iceGatheringState;
log.debug('[Peer] ICE gathering state change', iceGatheringState);
// Report to WebRTCReporter if available
if (this.reporter && iceGatheringState) {
this.reporter.onIceGatheringStateChange(iceGatheringState);
}
// Log to call report collector
if (iceGatheringState) {
this.onPeerEventLog?.('ICE gathering state changed', { state: iceGatheringState });
}
if (iceGatheringState === 'complete') {
log.debug('[Peer] ICE gathering complete');
this.iceGatheringComplete?.resolve(true);
this.iceGatheringComplete = null;
}
};
private onIceCandidate = (ev: RTCIceCandidateEvent<'icecandidate'>) => {
log.debug('[Peer] ICE candidate', ev);
// Report to WebRTCReporter if available
if (this.reporter && ev.candidate) {
this.reporter.onIceCandidate(ev.candidate as unknown as RTCIceCandidate);
}
if (this.useTrickleIce) {
if (ev.candidate) {
const candidate = ev.candidate as unknown as TrickleIceCandidate;
this.emitOrQueueLocalCandidate({
candidate: candidate.candidate,
sdpMid: candidate.sdpMid,
sdpMLineIndex: candidate.sdpMLineIndex,
});
} else {
this.emitOrQueueLocalEndOfCandidates();
this.iceGatheringComplete?.resolve(true);
}
return;
}
this.iceGatheringComplete?.resolve(true);
return;
};
private onIceCandidateError = (event: RTCIceCandidateEvent<'icecandidateerror'>) => {
log.error('[Peer] ICE candidate error', event);
};
private getIceServersInternal() {
if (
this.options.peerConnectionOptions?.iceServers &&
this.options.peerConnectionOptions?.iceServers.length > 0
) {
return this.options.peerConnectionOptions.iceServers;
}
return [
{
urls: 'stun:stun.l.google.com:19302',
},
];
}
private onTrackEvent = (event: RTCTrackEvent<'track'>) => {
log.debug('[Peer] Remote track event', event);
if (event.streams && event.streams.length > 0) {
this.options.remoteStream = event.streams[0];
}
// Report to WebRTCReporter if available
if (this.reporter && event.track) {
this.reporter.onTrack(event.track as unknown as MediaStreamTrack);
}
};
private onSignalingStateChange = () => {
const signalingState = this.instance?.signalingState;
log.debug('[Peer] Signaling state change', signalingState);
// Report to WebRTCReporter if available
if (this.reporter && signalingState) {
this.reporter.onSignalingStateChange(signalingState);
}
// Log to call report collector
if (signalingState) {
this.onPeerEventLog?.('Signaling state changed', { state: signalingState });
}
};
private onIceConnectionStateChange = () => {
const iceConnectionState = this.instance?.iceConnectionState;
log.debug('[Peer] ICE connection state change', iceConnectionState);
// Report to WebRTCReporter if available
if (this.reporter && iceConnectionState) {
this.reporter.onIceConnectionStateChange(iceConnectionState);
}
// Log to call report collector
if (iceConnectionState) {
this.onPeerEventLog?.('ICE connection state changed', { state: iceConnectionState });
}
};
private onConnectionStateChange = () => {
log.debug('[Peer] Connection state change', this.instance?.connectionState);
};
}