import WS from 'ws'; import { Socket as EIOSocket } from 'engine.io-client'; export interface GristClientSocketOptions { headers?: Record; } export class GristClientSocket { // Exactly one of _wsSocket and _eioSocket will be set at any one time. private _wsSocket: WS.WebSocket | WebSocket | undefined; private _eioSocket: EIOSocket | undefined; // Set to true if a WebSocket connection (in _wsSocket) was succesfully // established. Errors from the underlying WebSocket are not forwarded to // the client until that point, in case we end up downgrading to Engine.IO. private _wsConnected: boolean = false; private _messageHandler: null | ((data: string) => void); private _openHandler: null | (() => void); private _errorHandler: null | ((err: Error) => void); private _closeHandler: null | (() => void); constructor(private _url: string, private _options?: GristClientSocketOptions) { this._createWSSocket(); } public set onmessage(cb: null | ((data: string) => void)) { this._messageHandler = cb; } public set onopen(cb: null | (() => void)) { this._openHandler = cb; } public set onerror(cb: null | ((err: Error) => void)) { this._errorHandler = cb; } public set onclose(cb: null | (() => void)) { this._closeHandler = cb; } public close() { if (this._wsSocket) { this._wsSocket.close(); } else { this._eioSocket!.close(); } } public send(data: string) { if (this._wsSocket) { this._wsSocket.send(data); } else { this._eioSocket!.send(data); } } // pause(), resume(), and isOpen() are only used by tests and assume // a WS.WebSocket transport. public pause() { (this._wsSocket as WS.WebSocket)?.pause(); } public resume() { (this._wsSocket as WS.WebSocket)?.resume(); } public isOpen() { return (this._wsSocket as WS.WebSocket)?.readyState === WS.OPEN; } private _createWSSocket() { if (typeof WebSocket !== 'undefined') { this._wsSocket = new WebSocket(this._url); } else { this._wsSocket = new WS(this._url, undefined, this._options); } this._wsSocket.onmessage = this._onWSMessage.bind(this); this._wsSocket.onopen = this._onWSOpen.bind(this); this._wsSocket.onerror = this._onWSError.bind(this); this._wsSocket.onclose = this._onWSClose.bind(this); } private _destroyWSSocket() { if (this._wsSocket) { this._wsSocket.onmessage = null; this._wsSocket.onopen = null; this._wsSocket.onerror = null; this._wsSocket.onclose = null; this._wsSocket = undefined; } } private _onWSMessage(event: WS.MessageEvent | MessageEvent) { // event.data is guaranteed to be a string here because we only send text frames. // https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/message_event#event_properties this._messageHandler?.(event.data); } private _onWSOpen() { // The connection was established successfully. Any future events can now // be forwarded to the client. this._wsConnected = true; this._openHandler?.(); } private _onWSError(ev: Event) { if (!this._wsConnected) { // The WebSocket connection attempt failed. Switch to Engine.IO. this._destroyWSSocket(); this._createEIOSocket(); return; } // WebSocket error events are deliberately void of information, // see https://websockets.spec.whatwg.org/#eventdef-websocket-error, // so we ignore the incoming event. this._errorHandler?.(new Error("WebSocket error")); } private _onWSClose() { this._closeHandler?.(); } private _createEIOSocket() { this._eioSocket = new EIOSocket(this._url, { path: new URL(this._url).pathname, addTrailingSlash: false, transports: ['polling'], upgrade: false, extraHeaders: this._options?.headers, withCredentials: true, }); this._eioSocket.on('message', this._onEIOMessage.bind(this)); this._eioSocket.on('open', this._onEIOOpen.bind(this)); this._eioSocket.on('error', this._onEIOError.bind(this)); this._eioSocket.on('close', this._onEIOClose.bind(this)); } private _onEIOMessage(data: string) { this._messageHandler?.(data); } private _onEIOOpen() { this._openHandler?.(); } private _onEIOError(err: string | Error) { this._errorHandler?.(typeof err === "string" ? new Error(err) : err); } private _onEIOClose() { this._closeHandler?.(); } }