UNPKG

twilio-video

Version:

Twilio Video JavaScript Library

655 lines (654 loc) 24.7 kB
'use strict'; var __extends = (this && this.__extends) || (function () { var extendStatics = function (d, b) { extendStatics = Object.setPrototypeOf || ({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) || function (d, b) { for (var p in b) if (Object.prototype.hasOwnProperty.call(b, p)) d[p] = b[p]; }; return extendStatics(d, b); }; return function (d, b) { if (typeof b !== "function" && b !== null) throw new TypeError("Class extends value " + String(b) + " is not a constructor or null"); extendStatics(d, b); function __() { this.constructor = d; } d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __()); }; })(); var __read = (this && this.__read) || function (o, n) { var m = typeof Symbol === "function" && o[Symbol.iterator]; if (!m) return o; var i = m.call(o), r, ar = [], e; try { while ((n === void 0 || n-- > 0) && !(r = i.next()).done) ar.push(r.value); } catch (error) { e = { error: error }; } finally { try { if (r && !r.done && (m = i["return"])) m.call(i); } finally { if (e) throw e.error; } } return ar; }; var __spreadArray = (this && this.__spreadArray) || function (to, from) { for (var i = 0, il = from.length, j = to.length; i < il; i++, j++) to[j] = from[i]; return to; }; var StateMachine = require('./statemachine'); var _a = require('./util'), buildLogLevels = _a.buildLogLevels, makeUUID = _a.makeUUID; var Log = require('./util/log'); var NetworkMonitor = require('./util/networkmonitor'); var Timeout = require('./util/timeout'); var nInstances = 0; /* TwilioConnection states ----------------------- ------------------------------------------ | | | v +---------+ +--------------+ +----------+ | early | ----> | connecting | ----> | closed | +---------+ +--------------+ +----------+ ^ | ^ | ^ ^ | --------------------- | | | | | | --------------------- | | | | | | --------------------|------------------ | | v | | v | +----------+ +--------+ | | waiting | --------> | open | --------------- +----------+ +--------+ */ var states = { closed: [], connecting: ['closed', 'open', 'waiting'], early: ['closed', 'connecting'], open: ['closed'], waiting: ['closed', 'connecting', 'early', 'open'] }; var events = { closed: 'close', open: 'open', waiting: 'waiting' }; var TCMP_VERSION = 2; var DEFAULT_MAX_CONSECUTIVE_MISSED_HEARTBEATS = 3; var DEFAULT_MAX_CONSECUTIVE_FAILED_HELLOS = 3; var DEFAULT_MAX_REQUESTED_HEARTBEAT_TIMEOUT = 5000; var DEFAULT_OPEN_TIMEOUT = 15000; var DEFAULT_WELCOME_TIMEOUT = 5000; var OUTGOING_HEARTBEAT_OFFSET = 200; var WS_CLOSE_NORMAL = 1000; var WS_CLOSE_WELCOME_TIMEOUT = 3000; var WS_CLOSE_HEARTBEATS_MISSED = 3001; var WS_CLOSE_HELLO_FAILED = 3002; var WS_CLOSE_SEND_FAILED = 3003; var WS_CLOSE_NETWORK_CHANGED = 3004; var WS_CLOSE_BUSY_WAIT = 3005; var WS_CLOSE_SERVER_BUSY = 3006; var WS_CLOSE_OPEN_TIMEOUT = 3007; // NOTE(joma): If you want to use close code 3008, please increment // the close code in test/integration/spec/docker/reconnection.js // line number 492. var toplevel = globalThis; var WebSocket = toplevel.WebSocket ? toplevel.WebSocket : require('ws'); var CloseReason = { BUSY: 'busy', FAILED: 'failed', LOCAL: 'local', REMOTE: 'remote', TIMEOUT: 'timeout' }; var wsCloseCodesToCloseReasons = new Map([ [WS_CLOSE_WELCOME_TIMEOUT, CloseReason.TIMEOUT], [WS_CLOSE_HEARTBEATS_MISSED, CloseReason.TIMEOUT], [WS_CLOSE_HELLO_FAILED, CloseReason.FAILED], [WS_CLOSE_SEND_FAILED, CloseReason.FAILED], [WS_CLOSE_NETWORK_CHANGED, CloseReason.TIMEOUT], [WS_CLOSE_SERVER_BUSY, CloseReason.BUSY], [WS_CLOSE_OPEN_TIMEOUT, CloseReason.TIMEOUT] ]); /** * A {@link TwilioConnection} represents a WebSocket connection * to a Twilio Connections Messaging Protocol (TCMP) server. * @fires TwilioConnection#close * @fires TwilioConnection#error * @fires TwilioConnection#message * @fires TwilioConnection#open * @fires TwilioConnection#waiting */ var TwilioConnection = /** @class */ (function (_super) { __extends(TwilioConnection, _super); /** * Construct a {@link TwilioConnection}. * @param {string} serverUrl - TCMP server url * @param {TwilioConnectionOptions} options - {@link TwilioConnection} options */ function TwilioConnection(serverUrl, options) { var _this = _super.call(this, 'early', states) || this; options = Object.assign({ helloBody: null, maxConsecutiveFailedHellos: DEFAULT_MAX_CONSECUTIVE_FAILED_HELLOS, maxConsecutiveMissedHeartbeats: DEFAULT_MAX_CONSECUTIVE_MISSED_HEARTBEATS, requestedHeartbeatTimeout: DEFAULT_MAX_REQUESTED_HEARTBEAT_TIMEOUT, openTimeout: DEFAULT_OPEN_TIMEOUT, welcomeTimeout: DEFAULT_WELCOME_TIMEOUT, Log: Log, WebSocket: WebSocket }, options); var logLevels = buildLogLevels(options.logLevel); var log = new options.Log('default', _this, logLevels, options.loggerName); var networkMonitor = options.networkMonitor ? new NetworkMonitor(function () { var type = networkMonitor.type; var reason = "Network changed" + (type ? " to " + type : ''); log.debug(reason); _this._close({ code: WS_CLOSE_NETWORK_CHANGED, reason: reason }); }) : null; Object.defineProperties(_this, { _busyWaitTimeout: { value: null, writable: true }, _consecutiveHeartbeatsMissed: { value: 0, writable: true }, _cookie: { value: null, writable: true }, _eventObserver: { value: options.eventObserver }, _heartbeatTimeout: { value: null, writable: true }, _hellosLeft: { value: options.maxConsecutiveFailedHellos, writable: true }, _instanceId: { value: ++nInstances }, _log: { value: log }, _messageQueue: { value: [] }, _networkMonitor: { value: networkMonitor }, _options: { value: options }, _openTimeout: { value: null, writable: true }, _sendHeartbeatTimeout: { value: null, writable: true }, _serverUrl: { value: serverUrl }, _welcomeTimeout: { value: null, writable: true }, _ws: { value: null, writable: true } }); var eventsToLevels = { connecting: 'info', early: 'info', open: 'info', waiting: 'warning', closed: 'info' }; _this.on('stateChanged', function (state) { var args = []; for (var _i = 1; _i < arguments.length; _i++) { args[_i - 1] = arguments[_i]; } if (state in events) { _this.emit.apply(_this, __spreadArray([events[state]], __read(args))); } var event = { name: state, group: 'signaling', level: eventsToLevels[_this.state] }; if (state === 'closed') { var _a = __read(args, 1), reason = _a[0]; event.payload = { reason: reason }; event.level = reason === CloseReason.LOCAL ? 'info' : 'error'; } _this._eventObserver.emit('event', event); }); _this._eventObserver.emit('event', { name: _this.state, group: 'signaling', level: eventsToLevels[_this.state] }); _this._connect(); return _this; } TwilioConnection.prototype.toString = function () { return "[TwilioConnection #" + this._instanceId + ": " + this._ws.url + "]"; }; /** * Close the {@link TwilioConnection}. * @param {{code: number, reason: string}} event * @private */ TwilioConnection.prototype._close = function (_a) { var code = _a.code, reason = _a.reason; if (this.state === 'closed') { return; } if (this._openTimeout) { this._openTimeout.clear(); } if (this._welcomeTimeout) { this._welcomeTimeout.clear(); } if (this._heartbeatTimeout) { this._heartbeatTimeout.clear(); } if (this._sendHeartbeatTimeout) { this._sendHeartbeatTimeout.clear(); } if (this._networkMonitor) { this._networkMonitor.stop(); } if (this._busyWaitTimeout && code !== WS_CLOSE_BUSY_WAIT) { this._busyWaitTimeout.clear(); } this._messageQueue.splice(0); var log = this._log; if (code === WS_CLOSE_NORMAL) { log.debug('Closed'); this.transition('closed', null, [CloseReason.LOCAL]); } else { log.warn("Closed: " + code + " - " + reason); if (code !== WS_CLOSE_BUSY_WAIT) { this.transition('closed', null, [ wsCloseCodesToCloseReasons.get(code) || CloseReason.REMOTE ]); } } var readyState = this._ws.readyState; var WebSocket = this._options.WebSocket; if (readyState !== WebSocket.CLOSING && readyState !== WebSocket.CLOSED) { this._ws.close(code, reason); } }; /** * Connect to the TCMP server. * @private */ TwilioConnection.prototype._connect = function () { var _this = this; var log = this._log; if (this.state === 'waiting') { this.transition('early'); } else if (this.state !== 'early') { log.warn("Unexpected state \"" + this.state + "\" for connecting to the" + ' TCMP server.'); return; } this._ws = new this._options.WebSocket(this._serverUrl); var ws = this._ws; log.debug('Created a new WebSocket:', ws); ws.addEventListener('close', function (event) { return _this._close(event); }); var openTimeout = this._options.openTimeout; // Add a timeout for getting the onopen event on the WebSocket (15 sec). After that, attempt to reconnect only if this is not the first attempt. this._openTimeout = new Timeout(function () { var reason = "Failed to open in " + openTimeout + " ms"; _this._close({ code: WS_CLOSE_OPEN_TIMEOUT, reason: reason }); }, openTimeout); ws.addEventListener('open', function () { log.debug('WebSocket opened:', ws); _this._openTimeout.clear(); _this._startHandshake(); if (_this._networkMonitor) { _this._networkMonitor.start(); } }); ws.addEventListener('message', function (message) { log.debug("Incoming: " + message.data); try { message = JSON.parse(message.data); } catch (error) { _this.emit('error', error); return; } switch (message.type) { case 'bad': _this._handleBad(message); break; case 'busy': _this._handleBusy(message); break; case 'bye': // Do nothing. break; case 'msg': _this._handleMessage(message); // NOTE(mpatwardhan): Each incoming message should be treated as an incoming // heartbeat intentionally falling through to 'heartbeat' case. // eslint-disable-next-line no-fallthrough case 'heartbeat': _this._handleHeartbeat(); break; case 'welcome': _this._handleWelcome(message); break; default: _this._log.debug("Unknown message type: " + message.type); _this.emit('error', new Error("Unknown message type: " + message.type)); break; } }); }; /** * Handle an incoming "bad" message. * @param {{reason: string}} message * @private */ TwilioConnection.prototype._handleBad = function (_a) { var reason = _a.reason; var log = this._log; if (!['connecting', 'open'].includes(this.state)) { log.warn("Unexpected state \"" + this.state + "\" for handling a \"bad\" message" + ' from the TCMP server.'); return; } if (this.state === 'connecting') { log.warn("Closing: " + WS_CLOSE_HELLO_FAILED + " - " + reason); this._close({ code: WS_CLOSE_HELLO_FAILED, reason: reason }); return; } log.debug("Error: " + reason); this.emit('error', new Error(reason)); }; /** * Handle an incoming "busy" message. * @param {{cookie: ?string, keepAlive: boolean, retryAfter: number}} message * @private */ TwilioConnection.prototype._handleBusy = function (_a) { var _this = this; var cookie = _a.cookie, keepAlive = _a.keepAlive, retryAfter = _a.retryAfter; var log = this._log; if (!['connecting', 'waiting'].includes(this.state)) { log.warn("Unexpected state \"" + this.state + "\" for handling a \"busy\" message" + ' from the TCMP server.'); return; } if (this._busyWaitTimeout) { this._busyWaitTimeout.clear(); } if (this._welcomeTimeout) { this._welcomeTimeout.clear(); } var reason = retryAfter < 0 ? 'Received terminal "busy" message' : "Received \"busy\" message, retrying after " + retryAfter + " ms"; if (retryAfter < 0) { log.warn("Closing: " + WS_CLOSE_SERVER_BUSY + " - " + reason); this._close({ code: WS_CLOSE_SERVER_BUSY, reason: reason }); return; } var maxConsecutiveFailedHellos = this._options.maxConsecutiveFailedHellos; this._hellosLeft = maxConsecutiveFailedHellos; this._cookie = cookie || null; if (keepAlive) { log.warn(reason); this._busyWaitTimeout = new Timeout(function () { return _this._startHandshake(); }, retryAfter); } else { log.warn("Closing: " + WS_CLOSE_BUSY_WAIT + " - " + reason); this._close({ code: WS_CLOSE_BUSY_WAIT, reason: reason }); this._busyWaitTimeout = new Timeout(function () { return _this._connect(); }, retryAfter); } this.transition('waiting', null, [keepAlive, retryAfter]); }; /** * Handle an incoming "heartbeat" message. * @private */ TwilioConnection.prototype._handleHeartbeat = function () { if (this.state !== 'open') { this._log.warn("Unexpected state \"" + this.state + "\" for handling a \"heartbeat\"" + ' message from the TCMP server.'); return; } this._heartbeatTimeout.reset(); }; /** * Handle a missed "heartbeat" message. * @private */ TwilioConnection.prototype._handleHeartbeatTimeout = function () { if (this.state !== 'open') { return; } var log = this._log; var maxConsecutiveMissedHeartbeats = this._options.maxConsecutiveMissedHeartbeats; log.debug("Consecutive heartbeats missed: " + maxConsecutiveMissedHeartbeats); var reason = "Missed " + maxConsecutiveMissedHeartbeats + " \"heartbeat\" messages"; log.warn("Closing: " + WS_CLOSE_HEARTBEATS_MISSED + " - " + reason); this._close({ code: WS_CLOSE_HEARTBEATS_MISSED, reason: reason }); }; /** * Handle an incoming "msg" message. * @param {{body: object}} message * @private */ TwilioConnection.prototype._handleMessage = function (_a) { var body = _a.body; if (this.state !== 'open') { this._log.warn("Unexpected state \"" + this.state + "\" for handling a \"msg\" message" + ' from the TCMP server.'); return; } this.emit('message', body); }; /** * Handle an incoming "welcome" message. * @param {{ negotiatedTimeout: number }} message * @private */ TwilioConnection.prototype._handleWelcome = function (_a) { var _this = this; var negotiatedTimeout = _a.negotiatedTimeout; var log = this._log; if (!['connecting', 'waiting'].includes(this.state)) { log.warn("Unexpected state \"" + this.state + "\" for handling a \"welcome\"" + ' message from the TCMP server.'); return; } if (this.state === 'waiting') { log.debug('Received "welcome" message, no need to retry connection.'); this._busyWaitTimeout.clear(); } var maxConsecutiveMissedHeartbeats = this._options.maxConsecutiveMissedHeartbeats; var heartbeatTimeout = negotiatedTimeout * maxConsecutiveMissedHeartbeats; var outgoingHeartbeatTimeout = negotiatedTimeout - OUTGOING_HEARTBEAT_OFFSET; this._welcomeTimeout.clear(); this._heartbeatTimeout = new Timeout(function () { return _this._handleHeartbeatTimeout(); }, heartbeatTimeout); this._messageQueue.splice(0).forEach(function (message) { return _this._send(message); }); this._sendHeartbeatTimeout = new Timeout(function () { return _this._sendHeartbeat(); }, outgoingHeartbeatTimeout); this.transition('open'); }; /** * Handle a missed "welcome" message. * @private */ TwilioConnection.prototype._handleWelcomeTimeout = function () { if (this.state !== 'connecting') { return; } var log = this._log; if (this._hellosLeft <= 0) { var reason = 'All handshake attempts failed'; log.warn("Closing: " + WS_CLOSE_WELCOME_TIMEOUT + " - " + reason); this._close({ code: WS_CLOSE_WELCOME_TIMEOUT, reason: reason }); return; } var maxConsecutiveFailedHellos = this._options.maxConsecutiveFailedHellos; log.warn("Handshake attempt " + (maxConsecutiveFailedHellos - this._hellosLeft) + " failed"); this._startHandshake(); }; /** * Send a message to the TCMP server. * @param {*} message * @private */ TwilioConnection.prototype._send = function (message) { var readyState = this._ws.readyState; var WebSocket = this._options.WebSocket; if (readyState === WebSocket.OPEN) { var data = JSON.stringify(message); this._log.debug("Outgoing: " + data); try { this._ws.send(data); if (this._sendHeartbeatTimeout) { // Each outgoing message is to be treated as an outgoing heartbeat. this._sendHeartbeatTimeout.reset(); } } catch (error) { var reason = 'Failed to send message'; this._log.warn("Closing: " + WS_CLOSE_SEND_FAILED + " - " + reason); this._close({ code: WS_CLOSE_SEND_FAILED, reason: reason }); } } }; /** * Send a "heartbeat" message. * @private */ TwilioConnection.prototype._sendHeartbeat = function () { if (this.state === 'closed') { return; } this._send({ type: 'heartbeat' }); }; /** * Send a "hello" message. * @private */ TwilioConnection.prototype._sendHello = function () { var _a = this._options, helloBody = _a.helloBody, timeout = _a.requestedHeartbeatTimeout; var hello = { id: makeUUID(), timeout: timeout, type: 'hello', version: TCMP_VERSION }; if (this._cookie) { hello.cookie = this._cookie; } if (helloBody) { hello.body = helloBody; } this._send(hello); }; /** * Send or enqueue a message. * @param {*} message * @private */ TwilioConnection.prototype._sendOrEnqueue = function (message) { var _this = this; if (this.state === 'closed') { return; } var sendOrEnqueue = this.state === 'open' ? function (message) { return _this._send(message); } : function (message) { return _this._messageQueue.push(message); }; sendOrEnqueue(message); }; /** * Start the TCMP handshake. * @private */ TwilioConnection.prototype._startHandshake = function () { var _this = this; if (['early', 'waiting'].includes(this.state)) { this.transition('connecting'); } if (this.state !== 'connecting') { return; } this._hellosLeft--; this._sendHello(); var welcomeTimeout = this._options.welcomeTimeout; this._welcomeTimeout = new Timeout(function () { return _this._handleWelcomeTimeout(); }, welcomeTimeout); }; /** * Close the {@link TwilioConnection}. * @returns {void} */ TwilioConnection.prototype.close = function () { if (this.state === 'closed') { return; } this._sendOrEnqueue({ type: 'bye' }); this._close({ code: WS_CLOSE_NORMAL, reason: 'Normal' }); }; /** * Send a "msg" message. * @param {*} body * @returns {void} */ TwilioConnection.prototype.sendMessage = function (body) { this._sendOrEnqueue({ body: body, type: 'msg' }); }; return TwilioConnection; }(StateMachine)); /** * A unique string depicting the reason for the {@link TwilioConnection} being closed. * @enum {string} */ TwilioConnection.CloseReason = CloseReason; /** * A {@link TwilioConnection} was closed. * @event TwilioConnection#close * @param {CloseReason} reason - The reason for the {@link TwilioConnection} being closed */ /** * A {@link TwilioConnection} received an error from the TCMP server. * @event TwilioConnection#error * @param {Error} error - The TCMP server error */ /** * A {@link TwilioConnection} received a message from the TCMP server. * @event TwilioConnection#message * @param {*} body - Message body */ /** * A {@link TwilioConnection} completed a hello/welcome handshake with the TCMP server. * @event TwilioConnection#open */ /** * A {@link TwilioConnection} received a "busy" message from the TCMP server. * @event TwilioConnection#waiting * @param {boolean} keepAlive - true if the WebSocket connection is retained * @param {number} retryAfter - delay in milliseconds after which a retry is attempted */ /** * {@link TwilioConnection} options * @typedef {object} TwilioConnectionOptions * @property {EventObserver} [eventObserver] - Optional event observer * @property {*} [helloBody=null] - Optional body for "hello" message * @property {LogLevel} [logLevel=warn] - Log level of the {@link TwilioConnection} * @property {number} [maxConsecutiveFailedHellos=3] - Max. number of consecutive failed "hello"s * @property {number} [maxConsecutiveMissedHeartbeats=3] - Max. number of (effective) consecutive "heartbeat" messages that can be missed * @property {number} [requestedHeartbeatTimeout=5000] - "heartbeat" timeout (ms) requested by the {@link TwilioConnection} * @property {number} [welcomeTimeout=5000] - Time (ms) to wait for the "welcome" message after sending the "hello" message */ module.exports = TwilioConnection; //# sourceMappingURL=twilioconnection.js.map