import WebSocket from "isomorphic-ws"; import { Logger } from "ts-log"; const PING_TIMEOUT_DURATION = 30000 + 3000; // It is 30s on the server and 3s is added for delays /** * This class wraps websocket to provide a resilient web socket client. * * It will reconnect if connection fails with exponential backoff. Also, in node, it will reconnect * if it receives no ping request from server within a while as indication of timeout (assuming * the server sends it regularly). * * This class also logs events if logger is given and by replacing onError method you can handle * connection errors yourself (e.g: do not retry and close the connection). */ export class ResilientWebSocket { private endpoint: string; private wsClient: undefined | WebSocket; private wsUserClosed: boolean; private wsFailedAttempts: number; private pingTimeout: undefined | NodeJS.Timeout; private logger: undefined | Logger; onError: (error: Error) => void; onMessage: (data: WebSocket.Data) => void; onReconnect: () => void; constructor(endpoint: string, logger?: Logger) { this.endpoint = endpoint; this.logger = logger; this.wsFailedAttempts = 0; this.onError = (error: Error) => { this.logger?.error(error); }; this.wsUserClosed = true; this.onMessage = () => {}; this.onReconnect = () => {}; } async send(data: any) { this.logger?.info(`Sending ${data}`); await this.waitForMaybeReadyWebSocket(); if (this.wsClient === undefined) { this.logger?.error( "Couldn't connect to the websocket server. Error callback is called." ); } else { this.wsClient?.send(data); } } async startWebSocket() { if (this.wsClient !== undefined) { return; } this.logger?.info(`Creating Web Socket client`); this.wsClient = new WebSocket(this.endpoint); this.wsUserClosed = false; this.wsClient.onopen = () => { this.wsFailedAttempts = 0; // Ping handler is undefined in browser side so heartbeat is disabled. if (this.wsClient!.on !== undefined) { this.heartbeat(); } }; this.wsClient.onerror = (event) => { this.onError(event.error); }; this.wsClient.onmessage = (event) => { this.onMessage(event.data); }; this.wsClient.onclose = async () => { if (this.pingTimeout !== undefined) { clearInterval(this.pingTimeout); } if (this.wsUserClosed === false) { this.wsFailedAttempts += 1; this.wsClient = undefined; const waitTime = expoBackoff(this.wsFailedAttempts); this.logger?.error( `Connection closed unexpectedly or because of timeout. Reconnecting after ${waitTime}ms.` ); await sleep(waitTime); this.restartUnexpectedClosedWebsocket(); } else { this.logger?.info("The connection has been closed successfully."); } }; if (this.wsClient.on !== undefined) { // Ping handler is undefined in browser side this.wsClient.on("ping", this.heartbeat.bind(this)); } } /** * Heartbeat is only enabled in node clients because they support handling * ping-pong events. * * This approach only works when server constantly pings the clients which. * Otherwise you might consider sending ping and acting on pong responses * yourself. */ private heartbeat() { this.logger?.info("Heartbeat"); if (this.pingTimeout !== undefined) { clearTimeout(this.pingTimeout); } this.pingTimeout = setTimeout(() => { this.logger?.warn(`Connection timed out. Reconnecting...`); this.wsClient?.terminate(); this.restartUnexpectedClosedWebsocket(); }, PING_TIMEOUT_DURATION); } private async waitForMaybeReadyWebSocket() { let waitedTime = 0; while ( this.wsClient !== undefined && this.wsClient.readyState !== this.wsClient.OPEN ) { if (waitedTime > 5000) { this.wsClient.close(); return; } else { waitedTime += 10; await sleep(10); } } } private async restartUnexpectedClosedWebsocket() { if (this.wsUserClosed === true) { return; } await this.startWebSocket(); await this.waitForMaybeReadyWebSocket(); if (this.wsClient === undefined) { this.logger?.error( "Couldn't reconnect to websocket. Error callback is called." ); return; } this.onReconnect(); } closeWebSocket() { if (this.wsClient !== undefined) { const client = this.wsClient; this.wsClient = undefined; client.close(); } this.wsUserClosed = true; } } async function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } function expoBackoff(attempts: number): number { return 2 ** attempts * 100; }