import WebSocket from "isomorphic-ws"; function environmentIsNodeJs(): boolean { return ( typeof process !== "undefined" && typeof process.versions !== "undefined" && typeof process.versions.node !== "undefined" ); } export interface SocketWrapperCloseEvent { readonly wasClean: boolean; readonly code: number; } export interface SocketWrapperErrorEvent { // fields available in browsers readonly isTrusted?: boolean; // fields available in node readonly type?: string; readonly message?: string; } export interface SocketWrapperMessageEvent { readonly data: string; readonly type: string; } /** * A thin wrapper around isomorphic-ws' WebSocket class that adds * - constant message/error/open/close handlers * - explict connection via a connect() method * - type support for events * - handling of corner cases in the open and close behaviour */ export class SocketWrapper { public readonly connected: Promise; private connectedResolver: (() => void) | undefined; private connectedRejecter: ((reason: any) => void) | undefined; private socket: WebSocket | undefined; private timeoutId: NodeJS.Timeout | undefined; private closed = false; private readonly url: string; private readonly messageHandler: (event: SocketWrapperMessageEvent) => void; private readonly errorHandler: (event: SocketWrapperErrorEvent) => void; private readonly openHandler?: () => void; private readonly closeHandler?: (event: SocketWrapperCloseEvent) => void; private readonly timeout: number; public constructor( url: string, messageHandler: (event: SocketWrapperMessageEvent) => void, errorHandler: (event: SocketWrapperErrorEvent) => void, openHandler?: () => void, closeHandler?: (event: SocketWrapperCloseEvent) => void, timeout = 10_000, ) { this.connected = new Promise((resolve, reject) => { this.connectedResolver = resolve; this.connectedRejecter = reject; }); this.url = url; this.messageHandler = messageHandler; this.errorHandler = errorHandler; this.openHandler = openHandler; this.closeHandler = closeHandler; this.timeout = timeout; } /** * returns a promise that resolves when connection is open */ public connect(): void { const socket = new WebSocket(this.url); socket.onerror = (error) => { this.clearTimeout(); if (this.errorHandler) { this.errorHandler(error); } }; socket.onmessage = (messageEvent) => { this.messageHandler({ type: messageEvent.type, data: messageEvent.data as string, }); }; socket.onopen = (_) => { this.clearTimeout(); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion this.connectedResolver!(); if (this.openHandler) { this.openHandler(); } }; socket.onclose = (closeEvent) => { this.closed = true; if (this.closeHandler) { this.closeHandler(closeEvent); } }; const started = Date.now(); this.timeoutId = setTimeout(() => { socket.onmessage = () => 0; socket.onerror = () => 0; socket.onopen = () => 0; socket.onclose = () => 0; socket.close(); this.socket = undefined; const elapsed = Math.floor(Date.now() - started); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion this.connectedRejecter!(`Connection attempt timed out after ${elapsed} ms`); }, this.timeout); this.socket = socket; } /** * Closes an established connection and aborts other connection states */ public disconnect(): void { if (!this.socket) { throw new Error("Socket undefined. This must be called after connecting."); } this.clearTimeout(); switch (this.socket.readyState) { case WebSocket.OPEN: this.socket.close(1000 /* Normal Closure */); break; case WebSocket.CLOSED: // nothing to be done break; case WebSocket.CONNECTING: // imitate missing abort API this.socket.onopen = () => 0; this.socket.onclose = () => 0; this.socket.onerror = () => 0; this.socket.onmessage = () => 0; this.socket = undefined; if (this.closeHandler) { this.closeHandler({ wasClean: false, code: 4001 }); } break; case WebSocket.CLOSING: // already closing. Let it proceed break; default: throw new Error(`Unknown readyState: ${this.socket.readyState}`); } } public async send(data: string): Promise { return new Promise((resolve, reject) => { if (!this.socket) { throw new Error("Socket undefined. This must be called after connecting."); } if (this.closed) { throw new Error("Socket was closed, so no data can be sent anymore."); } // this exception should be thrown by send() automatically according to // https://developer.mozilla.org/de/docs/Web/API/WebSocket#send() but it does not work in browsers if (this.socket.readyState !== WebSocket.OPEN) { throw new Error("Websocket is not open"); } if (environmentIsNodeJs()) { this.socket.send(data, (err) => (err ? reject(err) : resolve())); } else { // Browser websocket send method does not accept a callback this.socket.send(data); resolve(); } }); } /** * Clears the timeout function, such that no timeout error will be raised anymore. This should be * called when the connection is established, a connection error occurred or the socket is disconnected. * * This method must not be called before `connect()`. * This method is idempotent. */ private clearTimeout(): void { if (!this.timeoutId) { throw new Error( "Timeout ID not set. This should not happen and usually means connect() was not called.", ); } // Note: do not unset this.timeoutId to allow multiple calls to this function clearTimeout(this.timeoutId); } }