@twilio/plugin-microvisor
Version:
Interact with your Twilio Microvisor devices
166 lines (165 loc) • 6.26 kB
JavaScript
"use strict";
const EventEmitter = require('events');
const WebSocket = require('ws');
const { TwilioCliError } = require('@twilio/cli-core').services.error;
const DEVICE_LOGGING_REFRESH_INTERVAL_MS = 10 * 60 * 1000; // 10 minutes
const INITIAL_RECONNECT_RETRY_DELAY_MS = 1000;
const MAX_RECONNECT_RETRY_ATTEMPTS = 8;
const PING_INTERVAL_MS = 300 * 1000;
const PING_TIMEOUT_MS = 60 * 1000;
class DeviceLogClient extends EventEmitter {
constructor(opts) {
super();
this.twilioClient = opts.twilioClient;
this.tokenRequestOpts = opts.tokenRequestOpts;
this.enableLoggingRequestOpts = opts.enableLoggingRequestOpts;
this.websocketUrl = opts.websocketUrl;
this.reconnectRetryCount = 0;
this.connectionEstablished = false;
this.connect();
}
async connect() {
var token = await this.getToken();
var options = { headers: { Authorization: `Bearer ${token}` } };
if (process.env.HTTP_PROXY) {
const HttpsProxyAgent = require('https-proxy-agent');
options.agent = new HttpsProxyAgent(process.env.HTTP_PROXY);
}
this.ws = new WebSocket(this.websocketUrl, options);
this.ws.on('open', () => this.onOpen());
this.ws.on('unexpected-response', (req, res) => this.onUnexpectedResponse(req, res));
this.ws.on('error', err => this.onError(err));
}
async reconnectWithBackoff() {
this.emit('error', new Error('Connection error; attempting reconnect'));
this.reconnectRetryCount++;
if (this.reconnectRetryCount > MAX_RECONNECT_RETRY_ATTEMPTS) {
this.emit('connectFailed', 'Maximum reconnect attempts exceeded. Unable to reconnect');
return;
}
if (this.reconnectRetryCount <= 1) {
// first retry should be immediate
return this.connect();
}
var delay = INITIAL_RECONNECT_RETRY_DELAY_MS * (2 ** (this.reconnectRetryCount - 2));
await new Promise(resolve => {
setTimeout(resolve, delay);
});
return this.connect();
}
async getToken() {
try {
var response = await this.twilioClient.request(this.tokenRequestOpts);
return response.body.token;
}
catch (error) {
if (error instanceof TwilioCliError && error.exitCode === 20404) {
this.emit('error', new TwilioCliError('Unknown device sid', error.exitCode, error.data));
}
else {
this.emit('error', error);
}
}
}
async enableDeviceLogging() {
try {
await this.twilioClient.request(this.enableLoggingRequestOpts);
return true;
}
catch (error) {
if (error instanceof TwilioCliError && error.exitCode === 20404) {
this.emit('error', new TwilioCliError('Unknown device sid', error.exitCode, error.data));
}
else {
this.emit('error', error);
}
}
}
async onOpen() {
this.connectionEstablished = true;
this.reconnectRetryCount = 0;
this.ws.on('pong', () => this.onPong());
this.ws.on('close', (code, reason) => this.onClose(code, reason));
this.ws.on('message', jsonLog => this.emit('log', jsonLog));
this.emit('open');
// enable logging on the device and set an interval to refresh this periodically
await this.enableDeviceLogging();
this.refreshDeviceLoggingHandle = setInterval(() => this.onRefreshDeviceLoggingInterval(), DEVICE_LOGGING_REFRESH_INTERVAL_MS);
// enable periodic websocket pings
this.pingIntervalHandle = setInterval(() => this.onPingInterval(), PING_INTERVAL_MS);
}
onClose(code, _reason) {
this.cleanup();
if (code !== 4000) {
// Device server closes with code 4000 to indicate another stream has replaced this one
// For any other close reason, we should attempt to reconnect
this.reconnectWithBackoff();
}
}
async onRefreshDeviceLoggingInterval() {
await this.enableDeviceLogging();
}
onPingInterval() {
this.ws.ping();
this.pingTimeout = setTimeout(() => this.onPingTimeout(), PING_TIMEOUT_MS);
}
onPong() {
if (this.pingTimeout) {
clearTimeout(this.pingTimeout);
this.pingTimeout = null;
}
}
onPingTimeout() {
this.onError(new Error('Ping timeout'));
// This will trigger the onClose, which will trigger reconnectWithBackoff
this.ws.terminate();
}
onUnexpectedResponse(_req, _res) {
// remove the error listener, because it'll trigger when we close the websocket
this.ws.removeAllListeners('error');
// and add a no-op listener to prevent the unhandled event from throwing an exception
this.ws.addListener('error', () => { });
// now close the websocket
this.ws.close();
}
onError(err) {
if (this.connectionEstablished) {
this.cleanup();
this.emit('error', err);
}
else {
// connection establishment failed
this.emit('error', new Error('Failed to connect'));
}
this.reconnectWithBackoff();
}
write(data) {
this.ws.send(data);
}
close() {
// this is a deliberate close so disable the onClose listener
// so we don't trigger the automatic reconnect
this.ws.removeAllListeners('close');
if (this.connectionEstablished) {
this.cleanup();
if (this.ws) {
this.ws.close();
}
}
}
cleanup() {
if (this.refreshDeviceLoggingHandle) {
clearInterval(this.refreshDeviceLoggingHandle);
delete this.refreshDeviceLoggingHandle;
}
if (this.pingIntervalHandle) {
clearInterval(this.pingIntervalHandle);
delete this.pingIntervalHandle;
}
if (this.pingTimeout) {
clearTimeout(this.pingTimeout);
this.pingTimeout = null;
}
}
}
module.exports = DeviceLogClient;