import { EventTarget } from "../../utils/event-target.js"; export type WebSocketClientEventMap = { connected: () => void; disconnected: () => void; reconnecting: () => void; error: (error: Event) => void; message: (data: ArrayBuffer | string) => void; }; export type WebSocketClientState = | "connecting" | "connected" | "reconnecting" | "disconnected" | "disposed"; export interface WebSocketClientConfig { url: string; initialDelay?: number; maxDelay?: number; jitterMultiplier?: number; } export class WebSocketClient { readonly #config: Required; #state: WebSocketClientState = "disconnected"; #ws: WebSocket | null = null; #backoffCount = 0; #reconnectTimeoutId: ReturnType | null = null; readonly #eventTarget = new EventTarget(); constructor(config: WebSocketClientConfig) { const initialDelay = Math.max(100, config.initialDelay ?? 1000); this.#config = { url: config.url, initialDelay, // Ensure maxDelay is never less than initialDelay maxDelay: Math.max(initialDelay, config.maxDelay ?? 30000), jitterMultiplier: Math.max(0, config.jitterMultiplier ?? 0.2), }; } public get state(): WebSocketClientState { return this.#state; } public addEventListener( eventName: K, listener: WebSocketClientEventMap[K], ): void { this.#eventTarget.addEventListener(eventName, listener); } public removeEventListener( eventName: K, listener: WebSocketClientEventMap[K], ): void { this.#eventTarget.removeEventListener(eventName, listener); } public connect(): void { if ( this.#state === "connected" || this.#state === "connecting" || this.#state === "disposed" ) { return; } this.#state = "connecting"; this.#clearReconnectTimeout(); try { this.#ws = new WebSocket(this.#config.url); this.#ws.binaryType = "arraybuffer"; this.#ws.onopen = this.#onOpen; this.#ws.onclose = this.#onClose; this.#ws.onerror = this.#onError; this.#ws.onmessage = this.#onMessage; } catch (error) { this.#state = "disconnected"; const errorEvent = new ErrorEvent("error", { message: error instanceof Error ? error.message : "Unknown WebSocket creation error", error, }); this.#eventTarget.dispatchEvent("error", errorEvent); } } public send( // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-arguments data: string | ArrayBuffer | Blob | ArrayBufferView, ): void { if (this.#state !== "connected" || !this.#ws) { throw new Error("WebSocketClient: Cannot send data when not connected"); } this.#ws.send(data); } public dispose(): void { this.#state = "disposed"; this.#clearReconnectTimeout(); if (this.#ws) { this.#ws.onopen = null; this.#ws.onclose = null; this.#ws.onerror = null; this.#ws.onmessage = null; this.#ws.close(); this.#ws = null; } this.#eventTarget.clear(); } #onOpen = (): void => { if (this.#state === "disposed") return; this.#state = "connected"; this.#backoffCount = 0; this.#eventTarget.dispatchEvent("connected"); }; #onClose = (): void => { if (this.#state === "disposed") return; if (this.#ws) { this.#ws.onopen = null; this.#ws.onclose = null; this.#ws.onerror = null; this.#ws.onmessage = null; this.#ws = null; } this.#scheduleReconnect(); this.#eventTarget.dispatchEvent("disconnected"); }; #onError = (event: Event): void => { if (this.#state === "disposed") return; this.#eventTarget.dispatchEvent("error", event); }; #onMessage = (event: MessageEvent): void => { if (this.#state === "disposed") return; this.#eventTarget.dispatchEvent("message", event.data); }; #scheduleReconnect(): void { if (this.#state === "disposed") return; this.#state = "reconnecting"; const baseDelay = Math.min( this.#config.initialDelay * Math.pow(2, this.#backoffCount), this.#config.maxDelay, ); const jitter = baseDelay * this.#config.jitterMultiplier; const randomJitter = Math.random() * 2 * jitter - jitter; const delay = Math.max(0, baseDelay + randomJitter); if (baseDelay < this.#config.maxDelay) { this.#backoffCount++; } this.#reconnectTimeoutId = setTimeout(() => { if (this.#state !== "disposed") { this.connect(); } }, delay); this.#eventTarget.dispatchEvent("reconnecting"); } #clearReconnectTimeout(): void { if (this.#reconnectTimeoutId !== null) { clearTimeout(this.#reconnectTimeoutId); this.#reconnectTimeoutId = null; } } }