twilio-video
Version:
Twilio Video JavaScript Library
659 lines (595 loc) • 19.1 kB
JavaScript
'use strict';
const StateMachine = require('./statemachine');
const { buildLogLevels, makeUUID } = require('./util');
const Log = require('./util/log');
const NetworkMonitor = require('./util/networkmonitor');
const Timeout = require('./util/timeout');
let nInstances = 0;
/*
TwilioConnection states
-----------------------
------------------------------------------
| |
| v
+---------+ +--------------+ +----------+
| early | ----> | connecting | ----> | closed |
+---------+ +--------------+ +----------+
^ | ^ | ^ ^
| --------------------- | | | |
| | --------------------- | | |
| | | --------------------|------------------ |
| v | | v |
+----------+ +--------+ |
| waiting | --------> | open | ---------------
+----------+ +--------+
*/
const states = {
closed: [],
connecting: ['closed', 'open', 'waiting'],
early: ['closed', 'connecting'],
open: ['closed'],
waiting: ['closed', 'connecting', 'early', 'open']
};
const events = {
closed: 'close',
open: 'open',
waiting: 'waiting'
};
const TCMP_VERSION = 2;
const DEFAULT_MAX_CONSECUTIVE_MISSED_HEARTBEATS = 3;
const DEFAULT_MAX_CONSECUTIVE_FAILED_HELLOS = 3;
const DEFAULT_MAX_REQUESTED_HEARTBEAT_TIMEOUT = 5000;
const DEFAULT_OPEN_TIMEOUT = 15000;
const DEFAULT_WELCOME_TIMEOUT = 5000;
const OUTGOING_HEARTBEAT_OFFSET = 200;
const WS_CLOSE_NORMAL = 1000;
const WS_CLOSE_WELCOME_TIMEOUT = 3000;
const WS_CLOSE_HEARTBEATS_MISSED = 3001;
const WS_CLOSE_HELLO_FAILED = 3002;
const WS_CLOSE_SEND_FAILED = 3003;
const WS_CLOSE_NETWORK_CHANGED = 3004;
const WS_CLOSE_BUSY_WAIT = 3005;
const WS_CLOSE_SERVER_BUSY = 3006;
const 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.
const toplevel = globalThis;
const WebSocket = toplevel.WebSocket ? toplevel.WebSocket : require('ws');
const CloseReason = {
BUSY: 'busy',
FAILED: 'failed',
LOCAL: 'local',
REMOTE: 'remote',
TIMEOUT: 'timeout'
};
const 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
*/
class TwilioConnection extends StateMachine {
/**
* Construct a {@link TwilioConnection}.
* @param {string} serverUrl - TCMP server url
* @param {TwilioConnectionOptions} options - {@link TwilioConnection} options
*/
constructor(serverUrl, options) {
super('early', states);
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,
WebSocket
}, options);
const logLevels = buildLogLevels(options.logLevel);
const log = new options.Log('default', this, logLevels, options.loggerName);
const networkMonitor = options.networkMonitor ? new NetworkMonitor(() => {
const { type } = networkMonitor;
const reason = `Network changed${type ? ` to ${type}` : ''}`;
log.debug(reason);
this._close({ code: WS_CLOSE_NETWORK_CHANGED, 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
}
});
const eventsToLevels = {
connecting: 'info',
early: 'info',
open: 'info',
waiting: 'warning',
closed: 'info'
};
this.on('stateChanged', (state, ...args) => {
if (state in events) {
this.emit(events[state], ...args);
}
const event = { name: state, group: 'signaling', level: eventsToLevels[this.state] };
if (state === 'closed') {
const [reason] = args;
event.payload = { 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();
}
toString() {
return `[TwilioConnection #${this._instanceId}: ${this._ws.url}]`;
}
/**
* Close the {@link TwilioConnection}.
* @param {{code: number, reason: string}} event
* @private
*/
_close({ code, 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);
const 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
]);
}
}
const { readyState } = this._ws;
const { WebSocket } = this._options;
if (readyState !== WebSocket.CLOSING && readyState !== WebSocket.CLOSED) {
this._ws.close(code, reason);
}
}
/**
* Connect to the TCMP server.
* @private
*/
_connect() {
const 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);
const ws = this._ws;
log.debug('Created a new WebSocket:', ws);
ws.addEventListener('close', event => this._close(event));
const { openTimeout } = this._options;
// 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(() => {
const reason = `Failed to open in ${openTimeout} ms`;
this._close({ code: WS_CLOSE_OPEN_TIMEOUT, reason });
}, openTimeout);
ws.addEventListener('open', () => {
log.debug('WebSocket opened:', ws);
this._openTimeout.clear();
this._startHandshake();
if (this._networkMonitor) {
this._networkMonitor.start();
}
});
ws.addEventListener('message', 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
*/
_handleBad({ reason }) {
const 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 });
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
*/
_handleBusy({ cookie, keepAlive, retryAfter }) {
const 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();
}
const 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 });
return;
}
const { maxConsecutiveFailedHellos } = this._options;
this._hellosLeft = maxConsecutiveFailedHellos;
this._cookie = cookie || null;
if (keepAlive) {
log.warn(reason);
this._busyWaitTimeout = new Timeout(() => this._startHandshake(), retryAfter);
} else {
log.warn(`Closing: ${WS_CLOSE_BUSY_WAIT} - ${reason}`);
this._close({ code: WS_CLOSE_BUSY_WAIT, reason });
this._busyWaitTimeout = new Timeout(() => this._connect(), retryAfter);
}
this.transition('waiting', null, [keepAlive, retryAfter]);
}
/**
* Handle an incoming "heartbeat" message.
* @private
*/
_handleHeartbeat() {
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
*/
_handleHeartbeatTimeout() {
if (this.state !== 'open') {
return;
}
const log = this._log;
const { maxConsecutiveMissedHeartbeats } = this._options;
log.debug(`Consecutive heartbeats missed: ${maxConsecutiveMissedHeartbeats}`);
const reason = `Missed ${maxConsecutiveMissedHeartbeats} "heartbeat" messages`;
log.warn(`Closing: ${WS_CLOSE_HEARTBEATS_MISSED} - ${reason}`);
this._close({ code: WS_CLOSE_HEARTBEATS_MISSED, reason });
}
/**
* Handle an incoming "msg" message.
* @param {{body: object}} message
* @private
*/
_handleMessage({ 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
*/
_handleWelcome({ negotiatedTimeout }) {
const 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();
}
const { maxConsecutiveMissedHeartbeats } = this._options;
const heartbeatTimeout = negotiatedTimeout * maxConsecutiveMissedHeartbeats;
const outgoingHeartbeatTimeout = negotiatedTimeout - OUTGOING_HEARTBEAT_OFFSET;
this._welcomeTimeout.clear();
this._heartbeatTimeout = new Timeout(() => this._handleHeartbeatTimeout(), heartbeatTimeout);
this._messageQueue.splice(0).forEach(message => this._send(message));
this._sendHeartbeatTimeout = new Timeout(() => this._sendHeartbeat(), outgoingHeartbeatTimeout);
this.transition('open');
}
/**
* Handle a missed "welcome" message.
* @private
*/
_handleWelcomeTimeout() {
if (this.state !== 'connecting') {
return;
}
const log = this._log;
if (this._hellosLeft <= 0) {
const reason = 'All handshake attempts failed';
log.warn(`Closing: ${WS_CLOSE_WELCOME_TIMEOUT} - ${reason}`);
this._close({ code: WS_CLOSE_WELCOME_TIMEOUT, reason });
return;
}
const { maxConsecutiveFailedHellos } = this._options;
log.warn(`Handshake attempt ${maxConsecutiveFailedHellos - this._hellosLeft} failed`);
this._startHandshake();
}
/**
* Send a message to the TCMP server.
* @param {*} message
* @private
*/
_send(message) {
const { readyState } = this._ws;
const { WebSocket } = this._options;
if (readyState === WebSocket.OPEN) {
const 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) {
const reason = 'Failed to send message';
this._log.warn(`Closing: ${WS_CLOSE_SEND_FAILED} - ${reason}`);
this._close({ code: WS_CLOSE_SEND_FAILED, reason });
}
}
}
/**
* Send a "heartbeat" message.
* @private
*/
_sendHeartbeat() {
if (this.state === 'closed') {
return;
}
this._send({ type: 'heartbeat' });
}
/**
* Send a "hello" message.
* @private
*/
_sendHello() {
const { helloBody, requestedHeartbeatTimeout: timeout } = this._options;
const hello = {
id: makeUUID(),
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
*/
_sendOrEnqueue(message) {
if (this.state === 'closed') {
return;
}
const sendOrEnqueue = this.state === 'open'
? message => this._send(message)
: message => this._messageQueue.push(message);
sendOrEnqueue(message);
}
/**
* Start the TCMP handshake.
* @private
*/
_startHandshake() {
if (['early', 'waiting'].includes(this.state)) {
this.transition('connecting');
}
if (this.state !== 'connecting') {
return;
}
this._hellosLeft--;
this._sendHello();
const { welcomeTimeout } = this._options;
this._welcomeTimeout = new Timeout(() => this._handleWelcomeTimeout(), welcomeTimeout);
}
/**
* Close the {@link TwilioConnection}.
* @returns {void}
*/
close() {
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}
*/
sendMessage(body) {
this._sendOrEnqueue({ body, type: 'msg' });
}
}
/**
* 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;