UNPKG

twilio-video

Version:

Twilio Video JavaScript Library

606 lines (531 loc) 24.5 kB
import { DEFAULT_ENVIRONMENT, DEFAULT_LOGGER_NAME, DEFAULT_LOG_LEVEL, DEFAULT_REALM, SDK_NAME, SDK_VERSION } from '../util/constants'; import { PreflightOptions, PreflightTestReport, ProgressEvent, RTCIceCandidateStats, SelectedIceCandidatePairStats, Stats } from '../../tsdef/PreflightTypes'; import { StatsReport } from '../../tsdef/types'; import { Timer } from './timer'; import { TwilioError } from '../../tsdef/TwilioError'; import { calculateMOS } from './mos'; import { getCombinedConnectionStats } from './getCombinedConnectionStats'; import { getTurnCredentials } from './getturncredentials'; import { makeStat } from './makestat'; import { syntheticAudio } from './syntheticaudio'; import { syntheticVideo } from './syntheticvideo'; import { waitForSometime } from '../util/index'; const { WS_SERVER } = require('../util/constants'); const Log = require('../util/log'); const EventEmitter = require('../eventemitter'); const MovingAverageDelta = require('../util/movingaveragedelta'); const EventObserver = require('../util/eventobserver'); const InsightsPublisher = require('../util/insightspublisher'); const { createSID, sessionSID } = require('../util/sid'); const { SignalingConnectionTimeoutError, MediaConnectionError } = require('../util/twilio-video-errors'); const SECOND = 1000; const DEFAULT_TEST_DURATION = 10 * SECOND; /** * progress values that are sent by {@link PreflightTest#event:progress} * @enum {string} */ const PreflightProgress = { /** * {@link PreflightTest} has successfully generated synthetic tracks */ mediaAcquired: 'mediaAcquired', /** * {@link PreflightTest} has successfully connected to twilio server and obtained turn credentials */ connected: 'connected', /** * SubscriberParticipant successfully subscribed to media tracks. */ mediaSubscribed: 'mediaSubscribed', /** * Media flow was detected. */ mediaStarted: 'mediaStarted', /** * Established DTLS connection. This is measured from RTCDtlsTransport `connecting` to `connected` state. * On Safari, Support for measuring this is missing, this event will be not be emitted on Safari. */ dtlsConnected: 'dtlsConnected', /** * Established a PeerConnection, This is measured from PeerConnection `connecting` to `connected` state. * On Firefox, Support for measuring this is missing, this event will be not be emitted on Firefox. */ peerConnectionConnected: 'peerConnectionConnected', /** * Established ICE connection. This is measured from ICE connection `checking` to `connected` state. */ iceConnected: 'iceConnected' }; declare interface PreflightStats { jitter: number[], rtt: number[], outgoingBitrate: number[], incomingBitrate: number[], packetLoss: number[], // fraction of packets lost. mos: number[], selectedIceCandidatePairStats: SelectedIceCandidatePairStats | null, iceCandidateStats: RTCIceCandidateStats[], } declare interface PreflightTestReportInternal extends PreflightTestReport { error?: string, mos?: Stats|null } declare interface PreflightOptionsInternal extends PreflightOptions { environment?: string; wsServer?: string; } function notEmpty<TValue>(value: TValue | null | undefined): value is TValue { return value !== null && typeof value !== 'undefined'; } let nInstances = 0; /** * A {@link PreflightTest} monitors progress of an ongoing preflight test. * <br><br> * Instance of {@link PreflightTest} is returned by calling {@link module:twilio-video.runPreflight} * @extends EventEmitter * @emits PreflightTest#completed * @emits PreflightTest#failed * @emits PreflightTest#progress */ export class PreflightTest extends EventEmitter { private _testTiming = new Timer(); private _dtlsTiming = new Timer(); private _iceTiming = new Timer(); private _peerConnectionTiming = new Timer(); private _mediaTiming = new Timer(); private _connectTiming = new Timer(); private _sentBytesMovingAverage = new MovingAverageDelta(); private _packetLossMovingAverage = new MovingAverageDelta(); private _progressEvents: ProgressEvent[] = []; private _receivedBytesMovingAverage = new MovingAverageDelta(); private _log: typeof Log; private _testDuration: number; private _instanceId: number; /** * Constructs {@link PreflightTest}. * @param {string} token * @param {?PreflightOptions} [options] */ constructor(token: string, options: PreflightOptions) { super(); const internalOptions = options as PreflightOptionsInternal; const { environment = 'prod', region = 'gll', duration = DEFAULT_TEST_DURATION } = internalOptions; // eslint-disable-next-line new-cap const wsServer = internalOptions.wsServer || WS_SERVER(environment, region); this._log = new Log('default', this, DEFAULT_LOG_LEVEL, DEFAULT_LOGGER_NAME); this._testDuration = duration; this._instanceId = nInstances++; this._testTiming.start(); this._runPreflightTest(token, environment, wsServer); } toString(): string { return `[Preflight #${this._instanceId}]`; } /** * stops ongoing tests and emits error */ stop():void { this._stopped = true; } private _generatePreflightReport(collectedStats?: PreflightStats) : PreflightTestReportInternal { this._testTiming.stop(); return { testTiming: this._testTiming.getTimeMeasurement(), networkTiming: { dtls: this._dtlsTiming.getTimeMeasurement(), ice: this._iceTiming.getTimeMeasurement(), peerConnection: this._peerConnectionTiming.getTimeMeasurement(), connect: this._connectTiming.getTimeMeasurement(), media: this._mediaTiming.getTimeMeasurement() }, stats: { jitter: makeStat(collectedStats?.jitter), rtt: makeStat(collectedStats?.rtt), packetLoss: makeStat(collectedStats?.packetLoss), }, selectedIceCandidatePairStats: collectedStats ? collectedStats.selectedIceCandidatePairStats : null, iceCandidateStats: collectedStats ? collectedStats.iceCandidateStats : [], progressEvents: this._progressEvents, // NOTE(mpatwardhan): internal properties. mos: makeStat(collectedStats?.mos), }; } private async _executePreflightStep<T>(stepName: string, step: () => T|Promise<T>, timeoutError?: TwilioError|Error) : Promise<T> { this._log.debug('Executing step: ', stepName); const MAX_STEP_DURATION = this._testDuration + 10 * SECOND; if (this._stopped) { throw new Error('stopped'); } const stepPromise = Promise.resolve().then(step); let timer: number | null = null; const timeoutPromise = new Promise((_resolve, reject) => { timer = setTimeout(() => { reject(timeoutError || new Error(`${stepName} timeout.`)); }, MAX_STEP_DURATION) as unknown as number; }); try { const result = await Promise.race([timeoutPromise, stepPromise]); return result as T; } finally { if (timer !== null) { clearTimeout(timer); } } } private _collectNetworkTimings(pc: RTCPeerConnection): Promise<void> { return new Promise(resolve => { let dtlsTransport: RTCDtlsTransport; pc.addEventListener('iceconnectionstatechange', () => { if (pc.iceConnectionState === 'checking') { this._iceTiming.start(); } if (pc.iceConnectionState === 'connected') { this._iceTiming.stop(); this._updateProgress(PreflightProgress.iceConnected); if (!dtlsTransport || dtlsTransport && dtlsTransport.state === 'connected') { resolve(); } } }); // firefox does not support connectionstatechange. pc.addEventListener('connectionstatechange', () => { if (pc.connectionState === 'connecting') { this._peerConnectionTiming.start(); } if (pc.connectionState === 'connected') { this._peerConnectionTiming.stop(); this._updateProgress(PreflightProgress.peerConnectionConnected); } }); // Safari does not expose sender.transport. let senders = pc.getSenders(); let transport = senders.map(sender => sender.transport).find(notEmpty); if (typeof transport !== 'undefined') { dtlsTransport = transport as RTCDtlsTransport; dtlsTransport.addEventListener('statechange', () => { if (dtlsTransport.state === 'connecting') { this._dtlsTiming.start(); } if (dtlsTransport.state === 'connected') { this._dtlsTiming.stop(); this._updateProgress(PreflightProgress.dtlsConnected); if (pc.iceConnectionState === 'connected') { resolve(); } } }); } }); } private _setupInsights({ token, environment = DEFAULT_ENVIRONMENT, realm = DEFAULT_REALM } : { token: string, environment?: string, realm?: string }) { const eventPublisherOptions = {}; const eventPublisher = new InsightsPublisher( token, SDK_NAME, SDK_VERSION, environment, realm, eventPublisherOptions); // event publisher requires room sid/participant sid. supply fake ones. eventPublisher.connect('PREFLIGHT_ROOM_SID', 'PREFLIGHT_PARTICIPANT'); const eventObserver = new EventObserver(eventPublisher, Date.now(), this._log); // eslint-disable-next-line no-undefined const undefinedValue = undefined; return { reportToInsights: ({ report }: { report: PreflightTestReportInternal }) => { const jitterStats = report.stats.jitter || undefinedValue; const rttStats = report.stats.rtt || undefinedValue; const packetLossStats = report.stats.packetLoss || undefinedValue; const mosStats = report.mos || undefinedValue; // stringify important info from ice candidates. const candidateTypeToProtocols = new Map<string, string[]>(); report.iceCandidateStats.forEach(candidateStats => { if (candidateStats.candidateType && candidateStats.protocol) { let protocols = candidateTypeToProtocols.get(candidateStats.candidateType) || []; if (protocols.indexOf(candidateStats.protocol) < 0) { protocols.push(candidateStats.protocol); } candidateTypeToProtocols.set(candidateStats.candidateType, protocols); } }); const iceCandidateStats = JSON.stringify(Object.fromEntries(candidateTypeToProtocols)); const insightsReport = { name: 'report', group: 'preflight', level: report.error ? 'error' : 'info', payload: { sessionSID, preflightSID: createSID('PF'), progressEvents: JSON.stringify(report.progressEvents), testTiming: report.testTiming, dtlsTiming: report.networkTiming.dtls, iceTiming: report.networkTiming.ice, peerConnectionTiming: report.networkTiming.peerConnection, connectTiming: report.networkTiming.connect, mediaTiming: report.networkTiming.media, selectedLocalCandidate: report.selectedIceCandidatePairStats?.localCandidate, selectedRemoteCandidate: report.selectedIceCandidatePairStats?.remoteCandidate, iceCandidateStats, jitterStats, rttStats, packetLossStats, mosStats, error: report.error } }; eventObserver.emit('event', insightsReport); setTimeout(() => eventPublisher.disconnect(), 2000); } }; } private async _runPreflightTest(token: string, environment: string, wsServer: string) { let localTracks: MediaStreamTrack[] = []; let pcs: RTCPeerConnection[] = []; const { reportToInsights } = this._setupInsights({ token, environment }); try { let elements = []; localTracks = await this._executePreflightStep('Acquire media', () => [syntheticAudio(), syntheticVideo({ width: 640, height: 480 })]); this._updateProgress(PreflightProgress.mediaAcquired); this.emit('debug', { localTracks }); this._connectTiming.start(); let iceServers = await this._executePreflightStep('Get turn credentials', () => getTurnCredentials(token, wsServer), new SignalingConnectionTimeoutError()); this._connectTiming.stop(); this._updateProgress(PreflightProgress.connected); const senderPC: RTCPeerConnection = new RTCPeerConnection({ iceServers, iceTransportPolicy: 'relay', bundlePolicy: 'max-bundle' }); const receiverPC: RTCPeerConnection = new RTCPeerConnection({ iceServers, bundlePolicy: 'max-bundle' }); pcs.push(senderPC); pcs.push(receiverPC); this._mediaTiming.start(); const remoteTracks = await this._executePreflightStep('Setup Peer Connections', async () => { senderPC.addEventListener('icecandidate', (event: RTCPeerConnectionIceEvent) => event.candidate && receiverPC.addIceCandidate(event.candidate)); receiverPC.addEventListener('icecandidate', (event: RTCPeerConnectionIceEvent) => event.candidate && senderPC.addIceCandidate(event.candidate)); localTracks.forEach(track => senderPC.addTrack(track)); const remoteTracksPromise: Promise<MediaStreamTrack[]> = new Promise(resolve => { let remoteTracks: MediaStreamTrack[] = []; receiverPC.addEventListener('track', event => { remoteTracks.push(event.track); if (remoteTracks.length === localTracks.length) { resolve(remoteTracks); } }); }); const offer = await senderPC.createOffer(); const updatedOffer = offer; await senderPC.setLocalDescription(updatedOffer); await receiverPC.setRemoteDescription(updatedOffer); const answer = await receiverPC.createAnswer(); await receiverPC.setLocalDescription(answer); await senderPC.setRemoteDescription(answer); await this._collectNetworkTimings(senderPC); return remoteTracksPromise; }, new MediaConnectionError()); this.emit('debug', { remoteTracks }); remoteTracks.forEach(track => { track.addEventListener('ended', () => this._log.warn(track.kind + ':ended')); track.addEventListener('mute', () => this._log.warn(track.kind + ':muted')); track.addEventListener('unmute', () => this._log.warn(track.kind + ':unmuted')); }); this._updateProgress(PreflightProgress.mediaSubscribed); await this._executePreflightStep('Wait for tracks to start', () => { return new Promise(resolve => { const element = document.createElement('video'); element.autoplay = true; element.playsInline = true; element.muted = true; element.srcObject = new MediaStream(remoteTracks); elements.push(element); this.emit('debugElement', element); element.oncanplay = resolve; }); }, new MediaConnectionError()); this._mediaTiming.stop(); this._updateProgress(PreflightProgress.mediaStarted); const collectedStats = await this._executePreflightStep('Collect stats for duration', () => this._collectRTCStatsForDuration(this._testDuration, initCollectedStats(), senderPC, receiverPC)); const report = await this._executePreflightStep('Generate report', () => this._generatePreflightReport(collectedStats)); reportToInsights({ report }); this.emit('completed', report); } catch (error) { const preflightReport = this._generatePreflightReport(); reportToInsights({ report: { ...preflightReport, error: error?.toString() } }); this.emit('failed', error, preflightReport); } finally { pcs.forEach(pc => pc.close()); localTracks.forEach(track => track.stop()); } } private async _collectRTCStats(collectedStats: PreflightStats, senderPC: RTCPeerConnection, receiverPC: RTCPeerConnection) { const combinedStats = await getCombinedConnectionStats({ publisher: senderPC, subscriber: receiverPC }); const { timestamp, bytesSent, bytesReceived, packets, packetsLost, roundTripTime, jitter, selectedIceCandidatePairStats, iceCandidateStats } = combinedStats; const hasLastData = collectedStats.jitter.length > 0; collectedStats.jitter.push(jitter); collectedStats.rtt.push(roundTripTime); this._sentBytesMovingAverage.putSample(bytesSent, timestamp); this._receivedBytesMovingAverage.putSample(bytesReceived, timestamp); this._packetLossMovingAverage.putSample(packetsLost, packets); if (hasLastData) { // convert BytesMovingAverage which is in bytes/millisecond to bits/second collectedStats.outgoingBitrate.push(this._sentBytesMovingAverage.get() * 1000 * 8); collectedStats.incomingBitrate.push(this._receivedBytesMovingAverage.get() * 1000 * 8); const fractionPacketLost = this._packetLossMovingAverage.get(); const percentPacketsLost = Math.min(100, fractionPacketLost * 100); collectedStats.packetLoss.push(percentPacketsLost); const score = calculateMOS(roundTripTime, jitter, fractionPacketLost); collectedStats.mos.push(score); } if (!collectedStats.selectedIceCandidatePairStats) { collectedStats.selectedIceCandidatePairStats = selectedIceCandidatePairStats; } if (collectedStats.iceCandidateStats.length === 0) { collectedStats.iceCandidateStats = iceCandidateStats; } } private async _collectRTCStatsForDuration(duration: number, collectedStats: PreflightStats, senderPC: RTCPeerConnection, receiverPC: RTCPeerConnection) : Promise<PreflightStats> { const startTime = Date.now(); const STAT_INTERVAL = Math.min(1000, duration); await waitForSometime(STAT_INTERVAL); await this._collectRTCStats(collectedStats, senderPC, receiverPC); const remainingDuration = duration - (Date.now() - startTime); if (remainingDuration > 0) { collectedStats = await this._collectRTCStatsForDuration(remainingDuration, collectedStats, senderPC, receiverPC); } return collectedStats; } private _updateProgress(name: string): void { const duration = Date.now() - this._testTiming.getTimeMeasurement().start; this._progressEvents.push({ duration, name }); this.emit('progress', name); } } export interface InternalStatsReport extends StatsReport { activeIceCandidatePair: { timestamp: number; bytesSent: number; bytesReceived: number; currentRoundTripTime?: number; localCandidate: RTCIceCandidateStats; remoteCandidate: RTCIceCandidateStats; } } function initCollectedStats() : PreflightStats { return { mos: [], jitter: [], rtt: [], outgoingBitrate: [], incomingBitrate: [], packetLoss: [], selectedIceCandidatePairStats: null, iceCandidateStats: [], }; } /** * Represents network timing measurements captured during preflight test * @typedef {object} NetworkTiming * @property {TimeMeasurement} [connect] - Time to establish signaling connection and acquire turn credentials * @property {TimeMeasurement} [media] - Time to start media. This is measured from calling connect to remote media getting started. * @property {TimeMeasurement} [dtls] - Time to establish dtls connection. This is measured from RTCDtlsTransport `connecting` to `connected` state. (Not available on Safari) * @property {TimeMeasurement} [ice] - Time to establish ice connectivity. This is measured from ICE connection `checking` to `connected` state. * @property {TimeMeasurement} [peerConnection] - Time to establish peer connectivity. This is measured from PeerConnection `connecting` to `connected` state. (Not available on Firefox) */ /** * Represents stats for a numerical metric. * @typedef {object} Stats * @property {number} [average] - Average value observed. * @property {number} [max] - Max value observed. * @property {number} [min] - Min value observed. */ /** * Represents stats for a numerical metric. * @typedef {object} SelectedIceCandidatePairStats * @property {RTCIceCandidateStats} [localCandidate] - Selected local ice candidate * @property {RTCIceCandidateStats} [remoteCandidate] - Selected local ice candidate */ /** * Represents RTC related stats that were observed during preflight test * @typedef {object} PreflightReportStats * @property {Stats} [jitter] - Packet delay variation in seconds * @property {Stats} [rtt] - Round trip time, to the server back to the client in milliseconds. * @property {Stats} [packetLoss] - Packet loss as a percent of total packets sent. */ /** * A {@link PreflightProgress} event with timing information. * @typedef {object} ProgressEvent * @property {number} [duration] - The duration of the event, measured from the start of the test. * @property {string} [name] - The {@link PreflightProgress} event name. */ /** * Represents report generated by {@link PreflightTest}. * @typedef {object} PreflightTestReport * @property {TimeMeasurement} [testTiming] - Time measurements of test run time. * @property {NetworkTiming} [networkTiming] - Network related time measurements. * @property {PreflightReportStats} [stats] - RTC related stats captured during the test. * @property {Array<RTCIceCandidateStats>} [iceCandidateStats] - List of gathered ice candidates. * @property {SelectedIceCandidatePairStats} selectedIceCandidatePairStats - Stats for the ice candidates that were used for the connection. * @property {Array<ProgressEvent>} [progressEvents] - {@link ProgressEvent} events detected during the test. * Use this information to determine which steps were completed and which ones were not. */ /** * You may pass these options to {@link module:twilio-video.testPreflight} in order to override the * default behavior. * @typedef {object} PreflightOptions * @property {string} [region='gll'] - Preferred signaling region; By default, you will be connected to the * nearest signaling server determined by latency based routing. Setting a value other * than <code style="padding:0 0">gll</code> bypasses routing and guarantees that signaling traffic will be * terminated in the region that you prefer. Please refer to this <a href="https://www.twilio.com/docs/video/ip-address-whitelisting#signaling-communication" target="_blank">table</a> * for the list of supported signaling regions. * @property {number} [duration=10000] - number of milliseconds to run test for. * once connected test will run for this duration before generating the stats report. */ /** * Preflight test has completed successfully. * @param {PreflightTestReport} report - Results of the test. * @event PreflightTest#completed */ /** * Preflight test has encountered a failure and is now stopped. * @param {TwilioError|Error} error - A TwilioError or a DOMException. * Possible TwilioErrors include Signaling and Media related errors which can be found * <a href="https://www.twilio.com/docs/video/build-js-video-application-recommendations-and-best-practices#connection-errors" target="_blank">here</a>. * @param {PreflightTestReport} report - Partial results gathered during the test. Use this information to help determine the cause of failure. * @event PreflightTest#failed */ /** * Emitted to indicate progress of the test * @param {PreflightProgress} progress - Indicates the status completed. * @event PreflightTest#progress */ /** * @method * @name runPreflight * @description Run a preflight test. This method will start a test to check the quality of network connection. * @memberof module:twilio-video * @param {string} token - The Access Token string * @param {PreflightOptions} options - Options for the test * @returns {PreflightTest} preflightTest - An instance to be used to monitor progress of the test. * @example * var { runPreflight } = require('twilio-video'); * var preflight = runPreflight(token, preflightOptions); * preflightTest.on('progress', progress => { * console.log('preflight progress:', progress); * }); * * preflightTest.on('failed', (error, report) => { * console.error('preflight error:', error, report); * }); * * preflightTest.on('completed', report => { * console.log('preflight completed:', report)); * }); */ export function runPreflight(token: string, options: PreflightOptions = {}): PreflightTest { const preflight = new PreflightTest(token, options); return preflight; }