twilio-video
Version:
Twilio Video JavaScript Library
502 lines • 26.1 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.PreflightTest = void 0;
exports.runPreflight = runPreflight;
const tslib_1 = require("tslib");
const constants_1 = require("../util/constants");
const timer_1 = require("./timer");
const mos_1 = require("./mos");
const getCombinedConnectionStats_1 = require("./getCombinedConnectionStats");
const getturncredentials_1 = require("./getturncredentials");
const makestat_1 = require("./makestat");
const syntheticaudio_1 = require("./syntheticaudio");
const syntheticvideo_1 = require("./syntheticvideo");
const index_1 = require("../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'
};
function notEmpty(value) {
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
*/
class PreflightTest extends EventEmitter {
/**
* Constructs {@link PreflightTest}.
* @param {string} token
* @param {?PreflightOptions} [options]
*/
constructor(token, options) {
super();
this._testTiming = new timer_1.Timer();
this._dtlsTiming = new timer_1.Timer();
this._iceTiming = new timer_1.Timer();
this._peerConnectionTiming = new timer_1.Timer();
this._mediaTiming = new timer_1.Timer();
this._connectTiming = new timer_1.Timer();
this._sentBytesMovingAverage = new MovingAverageDelta();
this._packetLossMovingAverage = new MovingAverageDelta();
this._progressEvents = [];
this._receivedBytesMovingAverage = new MovingAverageDelta();
const internalOptions = options;
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, constants_1.DEFAULT_LOG_LEVEL, constants_1.DEFAULT_LOGGER_NAME);
this._testDuration = duration;
this._instanceId = nInstances++;
this._testTiming.start();
this._runPreflightTest(token, environment, wsServer);
}
toString() {
return `[Preflight #${this._instanceId}]`;
}
/**
* stops ongoing tests and emits error
*/
stop() {
this._stopped = true;
}
_generatePreflightReport(collectedStats) {
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: (0, makestat_1.makeStat)(collectedStats === null || collectedStats === void 0 ? void 0 : collectedStats.jitter),
rtt: (0, makestat_1.makeStat)(collectedStats === null || collectedStats === void 0 ? void 0 : collectedStats.rtt),
packetLoss: (0, makestat_1.makeStat)(collectedStats === null || collectedStats === void 0 ? void 0 : collectedStats.packetLoss),
},
selectedIceCandidatePairStats: collectedStats ? collectedStats.selectedIceCandidatePairStats : null,
iceCandidateStats: collectedStats ? collectedStats.iceCandidateStats : [],
progressEvents: this._progressEvents,
// NOTE(mpatwardhan): internal properties.
mos: (0, makestat_1.makeStat)(collectedStats === null || collectedStats === void 0 ? void 0 : collectedStats.mos),
};
}
_executePreflightStep(stepName, step, timeoutError) {
return tslib_1.__awaiter(this, void 0, void 0, function* () {
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 = null;
const timeoutPromise = new Promise((_resolve, reject) => {
timer = setTimeout(() => {
reject(timeoutError || new Error(`${stepName} timeout.`));
}, MAX_STEP_DURATION);
});
try {
const result = yield Promise.race([timeoutPromise, stepPromise]);
return result;
}
finally {
if (timer !== null) {
clearTimeout(timer);
}
}
});
}
_collectNetworkTimings(pc) {
return new Promise(resolve => {
let dtlsTransport;
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;
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();
}
}
});
}
});
}
_setupInsights({ token, environment = constants_1.DEFAULT_ENVIRONMENT, realm = constants_1.DEFAULT_REALM }) {
const eventPublisherOptions = {};
const eventPublisher = new InsightsPublisher(token, constants_1.SDK_NAME, constants_1.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 }) => {
var _a, _b;
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();
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: (_a = report.selectedIceCandidatePairStats) === null || _a === void 0 ? void 0 : _a.localCandidate,
selectedRemoteCandidate: (_b = report.selectedIceCandidatePairStats) === null || _b === void 0 ? void 0 : _b.remoteCandidate,
iceCandidateStats,
jitterStats,
rttStats,
packetLossStats,
mosStats,
error: report.error
}
};
eventObserver.emit('event', insightsReport);
setTimeout(() => eventPublisher.disconnect(), 2000);
}
};
}
_runPreflightTest(token, environment, wsServer) {
return tslib_1.__awaiter(this, void 0, void 0, function* () {
let localTracks = [];
let pcs = [];
const { reportToInsights } = this._setupInsights({ token, environment });
try {
let elements = [];
localTracks = yield this._executePreflightStep('Acquire media', () => [(0, syntheticaudio_1.syntheticAudio)(), (0, syntheticvideo_1.syntheticVideo)({ width: 640, height: 480 })]);
this._updateProgress(PreflightProgress.mediaAcquired);
this.emit('debug', { localTracks });
this._connectTiming.start();
let iceServers = yield this._executePreflightStep('Get turn credentials', () => (0, getturncredentials_1.getTurnCredentials)(token, wsServer), new SignalingConnectionTimeoutError());
this._connectTiming.stop();
this._updateProgress(PreflightProgress.connected);
const senderPC = new RTCPeerConnection({ iceServers, iceTransportPolicy: 'relay', bundlePolicy: 'max-bundle' });
const receiverPC = new RTCPeerConnection({ iceServers, bundlePolicy: 'max-bundle' });
pcs.push(senderPC);
pcs.push(receiverPC);
this._mediaTiming.start();
const remoteTracks = yield this._executePreflightStep('Setup Peer Connections', () => tslib_1.__awaiter(this, void 0, void 0, function* () {
senderPC.addEventListener('icecandidate', (event) => event.candidate && receiverPC.addIceCandidate(event.candidate));
receiverPC.addEventListener('icecandidate', (event) => event.candidate && senderPC.addIceCandidate(event.candidate));
localTracks.forEach(track => senderPC.addTrack(track));
const remoteTracksPromise = new Promise(resolve => {
let remoteTracks = [];
receiverPC.addEventListener('track', event => {
remoteTracks.push(event.track);
if (remoteTracks.length === localTracks.length) {
resolve(remoteTracks);
}
});
});
const offer = yield senderPC.createOffer();
const updatedOffer = offer;
yield senderPC.setLocalDescription(updatedOffer);
yield receiverPC.setRemoteDescription(updatedOffer);
const answer = yield receiverPC.createAnswer();
yield receiverPC.setLocalDescription(answer);
yield senderPC.setRemoteDescription(answer);
yield 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);
yield 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 = yield this._executePreflightStep('Collect stats for duration', () => this._collectRTCStatsForDuration(this._testDuration, initCollectedStats(), senderPC, receiverPC));
const report = yield this._executePreflightStep('Generate report', () => this._generatePreflightReport(collectedStats));
reportToInsights({ report });
this.emit('completed', report);
}
catch (error) {
const preflightReport = this._generatePreflightReport();
reportToInsights({ report: Object.assign(Object.assign({}, preflightReport), { error: error === null || error === void 0 ? void 0 : error.toString() }) });
this.emit('failed', error, preflightReport);
}
finally {
pcs.forEach(pc => pc.close());
localTracks.forEach(track => track.stop());
}
});
}
_collectRTCStats(collectedStats, senderPC, receiverPC) {
return tslib_1.__awaiter(this, void 0, void 0, function* () {
const combinedStats = yield (0, getCombinedConnectionStats_1.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 = (0, mos_1.calculateMOS)(roundTripTime, jitter, fractionPacketLost);
collectedStats.mos.push(score);
}
if (!collectedStats.selectedIceCandidatePairStats) {
collectedStats.selectedIceCandidatePairStats = selectedIceCandidatePairStats;
}
if (collectedStats.iceCandidateStats.length === 0) {
collectedStats.iceCandidateStats = iceCandidateStats;
}
});
}
_collectRTCStatsForDuration(duration, collectedStats, senderPC, receiverPC) {
return tslib_1.__awaiter(this, void 0, void 0, function* () {
const startTime = Date.now();
const STAT_INTERVAL = Math.min(1000, duration);
yield (0, index_1.waitForSometime)(STAT_INTERVAL);
yield this._collectRTCStats(collectedStats, senderPC, receiverPC);
const remainingDuration = duration - (Date.now() - startTime);
if (remainingDuration > 0) {
collectedStats = yield this._collectRTCStatsForDuration(remainingDuration, collectedStats, senderPC, receiverPC);
}
return collectedStats;
});
}
_updateProgress(name) {
const duration = Date.now() - this._testTiming.getTimeMeasurement().start;
this._progressEvents.push({ duration, name });
this.emit('progress', name);
}
}
exports.PreflightTest = PreflightTest;
function initCollectedStats() {
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));
* });
*/
function runPreflight(token, options = {}) {
const preflight = new PreflightTest(token, options);
return preflight;
}
//# sourceMappingURL=preflighttest.js.map