import { WsOutgoing } from "../types"; type WebSocketMessage = Record; type MessageCallback = (data: WebSocketMessage) => void; type StatusCallback = () => void; type ErrorCallback = (error: Event) => void; interface WebSocketClientOptions { onMessage?: MessageCallback; onOpen?: StatusCallback; onClose?: StatusCallback; onError?: ErrorCallback; } const INITIAL_RECONNECT_DELAY = 1000; const MAX_RECONNECT_DELAY = 30000; const RECONNECT_MULTIPLIER = 2; const LOCAL_TIMEOUT = 2500; export class WebSocketClient { private ws: WebSocket | null = null; private urls: string[] = []; private urlIndex: number = 0; private reconnectDelay: number = INITIAL_RECONNECT_DELAY; private reconnectTimer: ReturnType | null = null; private localTimer: ReturnType | null = null; private shouldReconnect: boolean = false; private connected: boolean = false; private callbacks: WebSocketClientOptions = {}; setCallbacks(callbacks: WebSocketClientOptions) { this.callbacks = callbacks; } connect(urls: string[]) { this.urls = urls.filter(Boolean); if (this.urls.length === 0) return; this.shouldReconnect = true; this.reconnectDelay = INITIAL_RECONNECT_DELAY; this.urlIndex = 0; this.connected = false; this.tryUrl(); } private cleanup() { if (this.localTimer) { clearTimeout(this.localTimer); this.localTimer = null; } if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } if (this.ws) { const old = this.ws; this.ws = null; old.onopen = null; old.onclose = null; old.onerror = null; old.onmessage = null; try { old.close(); } catch { /* ignore */ } } } private tryUrl() { this.cleanup(); const url = this.urls[this.urlIndex]; if (!url) return; const ws = new WebSocket(url); this.ws = ws; // If trying local (index 0) and we have a remote fallback, // give local 2.5s before switching to remote if (this.urlIndex === 0 && this.urls.length > 1) { this.localTimer = setTimeout(() => { this.localTimer = null; if (this.connected) return; // already connected, ignore // Local didn't connect in time — try remote this.urlIndex = 1; this.tryUrl(); }, LOCAL_TIMEOUT); } ws.onopen = () => { if (ws !== this.ws) return; // stale this.connected = true; if (this.localTimer) { clearTimeout(this.localTimer); this.localTimer = null; } this.reconnectDelay = INITIAL_RECONNECT_DELAY; this.callbacks.onOpen?.(); }; ws.onmessage = (event) => { if (ws !== this.ws) return; // stale try { const data = JSON.parse(event.data) as WebSocketMessage; this.callbacks.onMessage?.(data); } catch { this.callbacks.onMessage?.({ type: "text", content: String(event.data) }); } }; ws.onclose = () => { if (ws !== this.ws) return; // stale this.connected = false; this.callbacks.onClose?.(); if (this.shouldReconnect) { this.scheduleReconnect(); } }; ws.onerror = () => { if (ws !== this.ws) return; // stale // Don't do anything here — onclose always fires after onerror // and handles reconnect. Just swallow the error event. }; } private scheduleReconnect() { if (this.reconnectTimer) clearTimeout(this.reconnectTimer); this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null; this.reconnectDelay = Math.min( this.reconnectDelay * RECONNECT_MULTIPLIER, MAX_RECONNECT_DELAY ); // Alternate between URLs on each reconnect attempt if (this.urls.length > 1) { this.urlIndex = this.urlIndex === 0 ? 1 : 0; } this.tryUrl(); }, this.reconnectDelay); } disconnect() { this.shouldReconnect = false; this.connected = false; this.cleanup(); } send(message: WsOutgoing) { if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify(message)); return true; } return false; } get readyState(): number { return this.ws?.readyState ?? WebSocket.CLOSED; } get isConnected(): boolean { return this.connected; } get currentUrl(): string { return this.urls[this.urlIndex] ?? ""; } } export const wsClient = new WebSocketClient();