dashboard/shell/utils/socket.js

410 lines
10 KiB
JavaScript

import { EventTarget } from 'event-target-shim';
import { isSafari } from '@shell/utils/platform';
import { addParam } from '@shell/utils/url';
let sockId = 1;
let warningShown = false;
let wasConnected = false;
const INSECURE = 'ws://';
const SECURE = 'wss://';
const STATE_DISCONNECTED = 'disconnected';
export const addEventListener = EventTarget.addEventListener;
export const STATE_CONNECTING = 'connecting';
export const STATE_CONNECTED = 'connected';
const STATE_CLOSING = 'closing';
const STATE_RECONNECTING = 'reconnecting';
export const EVENT_CONNECTING = STATE_CONNECTING;
export const EVENT_CONNECTED = STATE_CONNECTED;
export const EVENT_DISCONNECTED = STATE_DISCONNECTED;
export const EVENT_MESSAGE = 'message';
export const EVENT_FRAME_TIMEOUT = 'frame_timeout';
export const EVENT_CONNECT_ERROR = 'connect_error';
export const EVENT_DISCONNECT_ERROR = 'disconnect_error';
export const NO_WATCH = 'NO_WATCH';
export const NO_SCHEMA = 'NO_SCHEMA';
export const NO_PERMS = 'NO_PERMS';
export const REVISION_TOO_OLD = 'TOO_OLD';
export default class Socket extends EventTarget {
url;
autoReconnect = true;
frameTimeout = 35000;
metadata = {};
hasBeenOpen = false;
hasReconnected = false;
protocol = null;
maxTries = null;
tries = 0;
idAsTimestamp = false;
// "Private"
socket = null;
state = STATE_DISCONNECTED;
framesReceived = 0;
frameTimer;
reconnectTimer;
disconnectCallBacks = [];
disconnectedAt = 0;
closingId = 0;
autoReconnectUrl = null;
constructor(url, autoReconnect = true, frameTimeout = null, protocol = null, maxTries = null, idAsTimestamp = false) {
super();
this.setUrl(url);
this.autoReconnect = autoReconnect;
this.protocol = protocol;
// maxTries = null === never stop trying to reconnect
// allow maxTries to be defined on individual sockets bc not all will clearly warn the user that we've stopped trying
this.maxTries = maxTries;
this.idAsTimestamp = idAsTimestamp;
if ( frameTimeout !== null ) {
this.frameTimeout = frameTimeout;
}
}
setUrl(url) {
if ( !url.match(/wss?:\/\//) ) {
url = self.location.origin.replace(/^http/, 'ws') + url;
}
if ( self.location.protocol === 'https:' && url.startsWith(INSECURE) ) {
url = SECURE + url.substr(INSECURE.length);
}
this.url = url;
}
connect(metadata = {}) {
if ( this.socket ) {
console.error('Socket refusing to connect while another socket exists'); // eslint-disable-line no-console
return;
}
if (this.state !== STATE_RECONNECTING) {
this.state = STATE_CONNECTING;
}
Object.assign(this.metadata, metadata);
const id = this.idAsTimestamp ? new Date().getTime() : sockId++;
const url = addParam(this.url, 'sockId', id);
this._baseLog('connecting', { id, url: url.replace(/\?.*/, '') });
let socket;
this.tries++;
if ( this.protocol ) {
socket = new WebSocket(url, this.protocol);
} else {
socket = new WebSocket(url);
}
socket.sockId = id;
socket.metadata = this.metadata;
socket.onmessage = this._onmessage.bind(this);
socket.onopen = this._opened.bind(this);
socket.onerror = this._error.bind(this);
socket.onclose = this._closed.bind(this);
this.socket = socket;
this.state = STATE_CONNECTING;
this.dispatchEvent(new CustomEvent(EVENT_CONNECTING));
}
send(data) {
if ( this.socket && this.state === STATE_CONNECTED ) {
this.socket.send(data);
return true;
}
return false;
}
disconnect(callBack) {
if ( callBack ) {
this.disconnectCallBacks.push(callBack);
}
const self = this;
const promise = new Promise((resolve, reject) => {
if ( this.state === STATE_DISCONNECTED ) {
resolve();
}
function onError(e) {
reject(e);
self.removeEventListener(EVENT_CONNECT_ERROR, onError);
}
this.addEventListener(EVENT_CONNECT_ERROR, onError);
this.disconnectCallBacks.push(() => {
this.removeEventListener(EVENT_CONNECT_ERROR, onError);
resolve();
});
});
this.autoReconnect = false;
this._close();
return promise;
}
reconnect(metadata = {}) {
Object.assign(this.metadata, metadata);
if ( this.state === STATE_CONNECTING ) {
this._log('Ignoring reconnect for socket in connecting');
return;
}
if ( this.socket ) {
this._close();
} else {
this.connect(metadata);
}
}
getMetadata() {
if ( this.socket ) {
return this.socket.metadata;
} else {
return {};
}
}
getId() {
if ( this.socket ) {
return this.socket.sockId;
} else {
return 0;
}
}
isConnected() {
return this.state === STATE_CONNECTED;
}
setAutoReconnect(autoReconnect) {
this.autoReconnect = autoReconnect;
}
/**
* Supply an async fn that will provide a new url to reconnect to
*/
setAutoReconnectUrl(autoReconnectUrl) {
this.autoReconnectUrl = autoReconnectUrl;
}
// "Private"
_close() {
const socket = this.socket;
if ( !socket ) {
return;
}
try {
this._log('closing');
this.closingId = socket.sockId;
socket.onopen = null;
socket.onerror = null;
socket.onmessage = null;
socket.close();
} catch (e) {
this._log('exception', { e: e.toString() });
// Continue anyway...
}
this.state = STATE_CLOSING;
}
_opened() {
this._log('opened');
const now = (new Date()).getTime();
const atTime = this.disconnectedAt;
let afterMilliseconds = 0;
if ( atTime ) {
afterMilliseconds = now - atTime;
}
if ( this.hasBeenOpen ) {
this.hasReconnected = true;
}
this.hasBeenOpen = true;
this.state = STATE_CONNECTED;
this.framesReceived = 0;
this.disconnectedAt = 0;
this.dispatchEvent(new CustomEvent(EVENT_CONNECTED, { detail: { tries: this.tries, afterMilliseconds } }));
this.tries = 0;
this._resetWatchdog();
clearTimeout(this.reconnectTimer);
}
_onmessage(event) {
this._resetWatchdog();
this.tries = 0;
this.framesReceived++;
this.dispatchEvent(new CustomEvent(EVENT_MESSAGE, { detail: event }));
}
_resetWatchdog() {
clearTimeout(this.frameTimer);
const timeout = this.frameTimeout;
if ( timeout && this.state === STATE_CONNECTED) {
this.frameTimer = setTimeout(() => {
this._log(`watchdog expired after${ timeout }. Closing`);
this._close();
this.dispatchEvent(new CustomEvent(EVENT_FRAME_TIMEOUT));
}, timeout);
}
}
_error() {
this.closingId = (this.socket ? this.socket.sockId : 0);
this._log('error');
}
_closed(event) {
const { code, reason, wasClean } = event;
this._baseLog('closed', {
id: this.closingId || this.socket?.sockId || 'unknown', code, reason, clean: wasClean
});
this.closingId = 0;
this.socket = null;
clearTimeout(this.reconnectTimer);
clearTimeout(this.frameTimer);
const callBacks = this.disconnectCallBacks;
while ( callBacks.length ) {
const fn = callBacks.pop();
if ( fn ) {
fn.apply(this);
}
}
if ( [STATE_CONNECTED, STATE_CLOSING].includes(this.state) ) {
wasConnected = true;
}
if ( !this.disconnectedAt ) {
this.disconnectedAt = (new Date()).getTime();
}
if ( !warningShown && !wasConnected ) {
this.autoReconnect = false;
this.state = STATE_DISCONNECTED;
const e = new CustomEvent(EVENT_CONNECT_ERROR, { detail: { isSafari } });
this.dispatchEvent(e);
warningShown = true;
} else if ( this.autoReconnect ) {
this.state = STATE_RECONNECTING;
if (this.maxTries && this.tries > 1 && this.tries <= this.maxTries) {
// dispatch an event which will trigger a growl from steve-plugin sockets warning users that we've lost connection and are attempting to reconnect
const e = new CustomEvent(EVENT_CONNECT_ERROR);
this.dispatchEvent(e);
}
if (this.maxTries && this.tries > this.maxTries) {
this._log('closed. Will not reconnect (hit max attempts)');
this.state = STATE_DISCONNECTED;
// dispatch an event which will trigger a growl from steve-plugin sockets warning users that we've given up trying to reconnect
this.dispatchEvent(new CustomEvent(EVENT_DISCONNECT_ERROR));
} else {
const reconnect = () => {
this._log('closed. Attempting to reconnect');
const delay = Math.max(1000, Math.min(1000 * this.tries, 30000));
this.reconnectTimer = setTimeout(() => {
this.connect();
}, delay);
};
if (this.autoReconnectUrl) {
this.autoReconnectUrl()
.then((url) => {
this.setUrl(url);
reconnect();
})
.catch((e) => {
console.error('Failed to fetch socket auto reconnect url', e); // eslint-disable-line no-console
});
} else {
reconnect();
}
}
} else {
this.state = STATE_DISCONNECTED;
}
if ( this.state === STATE_DISCONNECTED ) {
this.dispatchEvent(new CustomEvent(EVENT_DISCONNECTED));
} else if ( this.state === STATE_RECONNECTING ) {
this.dispatchEvent(new CustomEvent(EVENT_CONNECTING));
}
}
/**
* `console.log` the provided summary statement, with default information to identify the socket and the provided props
*/
_log(summary, props) {
this._baseLog(summary, {
state: this.state, id: this.socket?.sockId || 0, ...props
});
}
/**
* `console.log` the provided summary statement and props
*
* This does not contain information to identify the socket and can be used in scenarios where it's not known or default
*/
_baseLog(summary, props) {
const message = [summary];
const values = Object.entries(props || {});
message.unshift('Socket ');
if (values.length) {
message.push(' (');
values.forEach(([key, value], index) => {
if (index !== 0) {
message.push(`, `);
}
message.push(`${ key }=${ value }`);
});
message.push(')');
}
console.log(message.join('')); // eslint-disable-line no-console
}
}