janode
Version:
Meetecho adapter for the Janus WebRTC Server
414 lines (362 loc) • 12.7 kB
JavaScript
'use strict';
/**
* This module contains the WebSocket transport implementation.
* @module transport-ws
* @private
*/
/* Isomorphic implementation of WebSocket */
/* It uses ws on Node and global.WebSocket in browsers */
import WebSocket from 'isomorphic-ws';
import Logger from './utils/logger.js';
const LOG_NS = '[transport-ws.js]';
import { delayOp } from './utils/utils.js';
/* Janus API ws subprotocol */
const API_WS = 'janus-protocol';
/* Janus Admin API ws subprotocol */
const ADMIN_WS = 'janus-admin-protocol';
/* Default ws ping interval */
const PING_TIME_SECS = 10;
/* Default pong wait timeout */
const PING_TIME_WAIT_SECS = 5;
/**
* Class representing a connection through WebSocket transport.<br>
*
* In case of failure a connection will be retried according to the configuration (time interval and
* times to attempt). At every attempt, if multiple addresses are available for Janus, the next address
* will be tried. An error will be raised only if the maxmimum number of attempts have been reached.<br>
*
* Internally uses WebSockets API to establish a connection with Janus and uses ws ping/pong as keepalives.<br>
*
* @private
*/
class TransportWs {
/**
* Create a connection through WebSocket.
*
* @param {module:connection~Connection} connection - The parent Janode connection
*/
constructor(connection) {
/**
* The parent Janode connection.
*
* @type {module:connection~Connection}
*/
this._connection = connection;
/**
* The internal WebSocket connection.
*
* @type {WebSocket}
*/
this._ws = null;
/**
* Internal counter for connection attempts.
*
* @type {number}
*/
this._attempts = 0;
/**
* A boolean flag indicating that the connection is being opened.
*
* @type {boolean}
*/
this._opening = false;
/**
* A boolean flag indicating that the connection has been opened.
*
* @type {boolean}
*/
this._opened = false;
/**
* A boolean flag indicating that the connection is being closed.
*
* @type {boolean}
*/
this._closing = false;
/**
* A boolean flag indicating that the connection has been closed.
*
* @type {boolean}
*/
this._closed = false; // true if websocket has been closed after being opened
/**
* The task of the peridic ws ping.
*
* @type {Object}
*/
this._ping_task = null;
/**
* A numerical identifier assigned for logging purposes.
*
* @type {number}
*/
this.id = connection.id;
/**
* A more descriptive, not unique string (used for logging).
*
* @type {string}
*/
this.name = `[${this.id}]`;
}
/**
* Initialize the internal WebSocket.
* Wraps with a promise the standard WebSocket API opening.
*
* @returns {Promise<module:connection~Connection>}
*/
async _initWebSocket() {
Logger.info(`${LOG_NS} ${this.name} trying connection with ${this._connection._address_iterator.currElem().url}`);
return new Promise((resolve, reject) => {
const wsOptions = this._connection._config.wsOptions() || {};
if (!wsOptions.handshakeTimeout) wsOptions.handshakeTimeout = 5000;
const ws = new WebSocket(
this._connection._address_iterator.currElem().url,
[this._connection._config.isAdmin() ? ADMIN_WS : API_WS],
wsOptions);
/* Register an "open" listener */
ws.addEventListener('open', _ => {
Logger.info(`${LOG_NS} ${this.name} websocket connected`);
/* Set the ping/pong task */
this._setPingTask(PING_TIME_SECS * 1000);
/* Resolve the promise and return this connection */
resolve(this);
}, { once: true });
/* Register a "close" listener */
ws.addEventListener('close', ({ code, reason, wasClean }) => {
Logger.info(`${LOG_NS} ${this.name} websocket closed code=${code} reason=${reason} clean=${wasClean}`);
/* Start cleanup */
/* Cancel the KA task */
this._unsetPingTask();
const wasClosing = this._closing;
this._closing = false;
this._closed = true;
this._connection._signalClose(wasClosing);
/* removeAllListeners is only supported on the node ws module */
if (typeof this._ws.removeAllListeners === 'function') this._ws.removeAllListeners();
}, { once: true });
/* Register an "error" listener */
/*
* The "error" event is fired when a ws connection has been closed due
* to an error (some data couldn't be sent for example)
*/
ws.addEventListener('error', error => {
Logger.error(`${LOG_NS} ${this.name} websocket error (${error.message})`);
reject(error);
}, { once: true });
/* Register a "message" listener */
ws.addEventListener('message', ({ data }) => {
Logger.debug(`${LOG_NS} ${this.name} <ws RCV OK> ${data}`);
this._connection._handleMessage(JSON.parse(data));
});
this._ws = ws;
});
}
/**
* Internal helper to open a websocket connection.
* In case of error retry the connection with another address from the available pool.
* If maximum number of attempts is reached, throws an error.
*
* @returns {WebSocket} The websocket connection
*/
async _attemptOpen() {
/* Reset status at every attempt, opening should be true at this step */
this._opened = false;
this._closing = false;
this._closed = false;
try {
const conn = await this._initWebSocket();
this._opening = false;
this._opened = true;
return conn;
}
catch (error) {
/* In case of error notifies the user, but try with another address */
this._attempts++;
/* Get the max number of attempts from the configuration */
if (this._attempts >= this._connection._config.getMaxRetries() + 1) {
this._opening = false;
const err = new Error('attempt limit exceeded');
Logger.error(`${LOG_NS} ${this.name} connection failed, ${err.message}`);
throw error;
}
Logger.error(`${LOG_NS} ${this.name} connection failed, will try again in ${this._connection._config.getRetryTimeSeconds()} seconds...`);
/* Wait an amount of seconds specified in the configuration */
await delayOp(this._connection._config.getRetryTimeSeconds() * 1000);
/* Make shift the circular iterator */
this._connection._address_iterator.nextElem();
return this._attemptOpen();
}
}
/**
* Open a transport connection. This is called from parent connection.
*
* @returns {Promise<module:connection~Connection>} A promise resolving with the Janode connection
*/
async open() {
/* Check the flags before attempting a connection */
let error;
if (this._opening) error = new Error('unable to open, websocket is already being opened');
else if (this._opened) error = new Error('unable to open, websocket has already been opened');
else if (this._closed) error = new Error('unable to open, websocket has already been closed');
if (error) {
Logger.error(`${LOG_NS} ${this.name} ${error.message}`);
throw error;
}
/* Set the starting status */
this._opening = true;
this._attempts = 0;
/* Use internal helper */
return this._attemptOpen();
}
/**
* Send a ws ping frame.
* This API is only available when the library is not used in a browser.
*
* @returns {Promise<void>}
*/
async _ping() {
/* ws.ping is only supported on the node "ws" module */
if (typeof this._ws.ping !== 'function') {
Logger.warn('ws ping not supported');
return;
}
let timeout;
/* Set a promise that will reject in PING_TIME_WAIT_SECS seconds */
const timeout_ping = new Promise((_, reject) => {
timeout = setTimeout(_ => reject(new Error('timeout')), PING_TIME_WAIT_SECS * 1000);
});
/* Set a promise that will resolve once "pong" has been received */
const ping_op = new Promise((resolve, reject) => {
/* Send current timestamp in the ping */
const ping_data = '' + Date.now();
this._ws.ping(ping_data, error => {
if (error) {
Logger.error(`${LOG_NS} ${this.name} websocket PING send error (${error.message})`);
clearTimeout(timeout);
return reject(error);
}
Logger.verbose(`${LOG_NS} ${this.name} websocket PING sent (${ping_data})`);
});
/* Resolve on pong */
this._ws.once('pong', data => {
Logger.verbose(`${LOG_NS} ${this.name} websocket PONG received (${data.toString()})`);
clearTimeout(timeout);
return resolve();
});
});
/* Race between timeout and pong */
return Promise.race([ping_op, timeout_ping]);
}
/**
* Set a ws ping-pong task.
*
* @param {number} delay - The ping interval in milliseconds
* @returns {void}
*/
_setPingTask(delay) {
/* ws "ping" is only supported on the node ws module */
if (typeof this._ws.ping !== 'function') {
Logger.warn('ws ping not supported');
return;
}
if (this._ping_task) return;
/* Set a periodic task to send a ping */
/* In case of error, terminate the ws */
this._ping_task = setInterval(async _ => {
try {
await this._ping();
} catch ({ message }) {
Logger.error(`${LOG_NS} ${this.name} websocket PING error (${message})`);
/* ws "terminate" is only supported on the node ws module */
this._ws.terminate();
}
}, delay);
Logger.info(`${LOG_NS} ${this.name} websocket ping task scheduled every ${PING_TIME_SECS} seconds`);
}
/**
* Remove the ws ping task.
*
* @returns {void}
*/
_unsetPingTask() {
if (!this._ping_task) return;
clearInterval(this._ping_task);
this._ping_task = null;
Logger.info(`${LOG_NS} ${this.name} websocket ping task disabled`);
}
/**
* Get the remote Janus hostname.
* It is called from the parent connection.
*
* @returns {string} The hostname of the Janus server
*/
getRemoteHostname() {
if (this._ws && this._ws.url) {
return (new URL(this._ws.url)).hostname;
}
return null;
}
/**
* Gracefully close the connection.
* Wraps with a promise the standard WebSocket API "close".
* It is called from the parent connection.
*
* @returns {Promise<void>}
*/
async close() {
/* Check the status flags before */
let error;
if (!this._opened) error = new Error('unable to close, websocket has never been opened');
else if (this._closing) error = new Error('unable to close, websocket is already being closed');
else if (this._closed) error = new Error('unable to close, websocket has already been closed');
if (error) {
Logger.error(`${LOG_NS} ${this.name} ${error.message}`);
throw error;
}
this._closing = true;
return new Promise((resolve, reject) => {
Logger.info(`${LOG_NS} ${this.name} closing websocket`);
try {
this._ws.close();
/* Add a listener to resolve the promise */
this._ws.addEventListener('close', resolve, { once: true });
} catch (e) {
Logger.error(`${LOG_NS} ${this.name} error while closing websocket (${e.message})`);
this._closing = false;
reject(e);
return;
}
});
}
/**
* Send a request from this connection.
* Wraps with a promise the standard WebSocket API "send".
* It is called from the parent connection.
*
* @param {Object} request - The request to be sent
* @returns {Promise<Object>} A promise resolving with a response from Janus
*/
async send(request) {
/* Check connection status */
let error;
if (!this._opened) error = new Error('unable to send request because connection has not been opened');
else if (this._closed) error = new Error('unable to send request because connection has been closed');
if (error) {
Logger.error(`${LOG_NS} ${this.name} ${error.message}`);
throw error;
}
/* Stringify the message */
const string_req = JSON.stringify(request);
return new Promise((resolve, reject) => {
this._ws.send(string_req, { compress: false, binary: false }, error => {
if (error) {
Logger.error(`${LOG_NS} ${this.name} websocket send error (${error.message})`);
reject(error);
return;
}
Logger.debug(`${LOG_NS} ${this.name} <ws SND OK> ${string_req}`);
resolve();
});
});
}
}
export default TransportWs;