import { Emitter } from "@noya-app/emitter"; /* eslint-disable @shopify/prefer-early-return */ export class UserActivityDetector { constructor(public onActivity?: () => void) {} private _activityHandler = () => { this.onActivity?.(); }; addListeners(): void { if (typeof window === "undefined") return; window.addEventListener("mousemove", this._activityHandler); window.addEventListener("keydown", this._activityHandler); window.addEventListener("touchstart", this._activityHandler); window.addEventListener("click", this._activityHandler); } removeListeners(): void { if (typeof window === "undefined") return; window.removeEventListener("mousemove", this._activityHandler); window.removeEventListener("keydown", this._activityHandler); window.removeEventListener("touchstart", this._activityHandler); window.removeEventListener("click", this._activityHandler); } public setOnActivity(callback?: () => void): void { this.onActivity = callback; } } export type ReconnectingWebSocketState = | "CONNECTING" | "OPEN" | "CLOSING" | "CLOSED" | "RECONNECTING" | "MAX_ATTEMPTS_EXCEEDED" | "SHUTDOWN"; type ConnectionOptions = { reconnectInterval?: number; maxReconnectInterval?: number; reconnectDecay?: number; timeoutInterval?: number; maxReconnectAttempts?: number; }; type ReconnectingWebSocketOptions = ConnectionOptions & { onopen?: (event: Event) => void; onmessage?: (event: MessageEvent) => void; onclose?: (event: CloseEvent) => void; onerror?: (event: Event) => void; activityDetector?: UserActivityDetector; debug?: boolean; }; export class ReconnectingWebSocket extends Emitter< [ReconnectingWebSocketState] > { private websocket: WebSocket | null = null; private timeoutHandle: number | null = null; private reconnectTimeoutHandle: number | null = null; public state: ReconnectingWebSocketState = "CONNECTING"; private attempt = 0; private options: ReconnectingWebSocketOptions & Required; private url: string = ""; private protocols?: string | string[]; constructor(options: ReconnectingWebSocketOptions = {}) { super(); this.options = { reconnectInterval: 1000, maxReconnectInterval: 30000, reconnectDecay: 1.5, timeoutInterval: 5000, maxReconnectAttempts: 5, ...options, }; if (this.options.activityDetector) { this.options.activityDetector.setOnActivity(() => { if (this.state === "MAX_ATTEMPTS_EXCEEDED") { this.resetAndReconnect(); } }); } // this.transitionTo("CONNECTING"); // this.connect(); } private transitionTo(newState: ReconnectingWebSocketState): void { if (this.options.debug) { console.info(`Transitioning from ${this.state} to ${newState}`); } this.state = newState; this.emit(this.state); switch (this.state) { case "CONNECTING": this.connect(); break; case "OPEN": this.onOpen(); break; case "CLOSING": this.onClosing(); break; case "CLOSED": this.onClosed(); break; case "RECONNECTING": this.onReconnecting(); break; case "MAX_ATTEMPTS_EXCEEDED": this.onMaxAttemptsExceeded(); break; case "SHUTDOWN": this.onShutdown(); break; } } public connect( url: string = this.url, protocols?: string | string[] | undefined ): void { this.url = url; this.protocols = protocols; if (this.state !== "CONNECTING") { return; } this.websocket = new WebSocket(this.url, this.protocols); this.timeoutHandle = setTimeout(() => { if (this.websocket && this.websocket.readyState !== WebSocket.OPEN) { if (this.options.debug) { console.info("Connection attempt timed out"); } this.websocket.close(); } }, this.options.timeoutInterval) as unknown as number; this.websocket.onopen = (event: Event) => { if (this.state === "CLOSING" || this.state === "SHUTDOWN") { this.websocket?.close(); } else { this.transitionTo("OPEN"); this.options.onopen?.(event); } }; this.websocket.onmessage = (event: MessageEvent) => { this.options.onmessage?.(event); }; this.websocket.onclose = (event: CloseEvent) => { if (this.state !== "CLOSING" && this.state !== "SHUTDOWN") { this.transitionTo("CLOSED"); } this.options.onclose?.(event); }; this.websocket.onerror = (event: Event) => { this.options.onerror?.(event); }; } private onOpen(): void { if (this.timeoutHandle) { clearTimeout(this.timeoutHandle); this.timeoutHandle = null; } // Reset the reconnect attempt counter this.attempt = 0; } private onClosing(): void { this.websocket?.close(); } private onClosed(): void { if (this.timeoutHandle) { clearTimeout(this.timeoutHandle); this.timeoutHandle = null; } this.reconnect(); } private onReconnecting(): void { if (this.attempt >= this.options.maxReconnectAttempts) { if (this.options.debug) { console.info( "Max reconnect attempts reached, not attempting to reconnect." ); } this.transitionTo("MAX_ATTEMPTS_EXCEEDED"); return; } const timeout = Math.min( this.options.reconnectInterval * Math.pow(this.options.reconnectDecay, this.attempt), this.options.maxReconnectInterval ); if (this.options.debug) { console.info( `Reconnecting in ${timeout / 1000} seconds... (attempt ${this.attempt + 1}/${this.options.maxReconnectAttempts})` ); } this.reconnectTimeoutHandle = setTimeout(() => { this.attempt++; this.transitionTo("CONNECTING"); }, timeout) as unknown as number; } private onMaxAttemptsExceeded(): void { this.websocket?.close(); if (this.timeoutHandle) { clearTimeout(this.timeoutHandle); this.timeoutHandle = null; } if (this.reconnectTimeoutHandle) { clearTimeout(this.reconnectTimeoutHandle); this.reconnectTimeoutHandle = null; } this.websocket = null; this.options.activityDetector?.addListeners(); } private onShutdown(): void { this.websocket?.close(); if (this.timeoutHandle) { clearTimeout(this.timeoutHandle); this.timeoutHandle = null; } if (this.reconnectTimeoutHandle) { clearTimeout(this.reconnectTimeoutHandle); this.reconnectTimeoutHandle = null; } this.websocket = null; this.options.activityDetector?.removeListeners(); } private reconnect(): void { if (this.state !== "SHUTDOWN") { this.transitionTo("RECONNECTING"); } } private resetAndReconnect(): void { if (this.options.debug) { console.info( "User activity detected after max attempts exceeded. Resetting attempts and reconnecting." ); } this.attempt = 0; this.options.activityDetector?.removeListeners(); this.transitionTo("RECONNECTING"); } public send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void { if (this.websocket && this.websocket.readyState === WebSocket.OPEN) { this.websocket.send(data); } else if ( this.websocket && this.websocket.readyState === WebSocket.CONNECTING ) { setTimeout(() => this.send(data), this.options.timeoutInterval); } else { console.error( "WebSocket is not open. Ready state:", this.websocket?.readyState ); } } public close(): void { if ( this.state !== "CLOSING" && this.state !== "CLOSED" && this.state !== "SHUTDOWN" ) { this.transitionTo("CLOSING"); } } public shutdown(): void { if (this.state !== "SHUTDOWN") { this.transitionTo("SHUTDOWN"); } } }