| .. | .. |
|---|
| 1 | | -import { WebSocketMessage } from "../types"; |
|---|
| 1 | +import { WsOutgoing } from "../types"; |
|---|
| 2 | 2 | |
|---|
| 3 | +type WebSocketMessage = Record<string, unknown>; |
|---|
| 3 | 4 | type MessageCallback = (data: WebSocketMessage) => void; |
|---|
| 4 | 5 | type StatusCallback = () => void; |
|---|
| 5 | 6 | type ErrorCallback = (error: Event) => void; |
|---|
| .. | .. |
|---|
| 14 | 15 | const INITIAL_RECONNECT_DELAY = 1000; |
|---|
| 15 | 16 | const MAX_RECONNECT_DELAY = 30000; |
|---|
| 16 | 17 | const RECONNECT_MULTIPLIER = 2; |
|---|
| 18 | +const LOCAL_TIMEOUT = 2500; |
|---|
| 17 | 19 | |
|---|
| 18 | 20 | export class WebSocketClient { |
|---|
| 19 | 21 | private ws: WebSocket | null = null; |
|---|
| 20 | | - private url: string = ""; |
|---|
| 22 | + private urls: string[] = []; |
|---|
| 23 | + private urlIndex: number = 0; |
|---|
| 21 | 24 | private reconnectDelay: number = INITIAL_RECONNECT_DELAY; |
|---|
| 22 | 25 | private reconnectTimer: ReturnType<typeof setTimeout> | null = null; |
|---|
| 26 | + private localTimer: ReturnType<typeof setTimeout> | null = null; |
|---|
| 23 | 27 | private shouldReconnect: boolean = false; |
|---|
| 28 | + private connected: boolean = false; |
|---|
| 24 | 29 | private callbacks: WebSocketClientOptions = {}; |
|---|
| 25 | 30 | |
|---|
| 26 | 31 | setCallbacks(callbacks: WebSocketClientOptions) { |
|---|
| 27 | 32 | this.callbacks = callbacks; |
|---|
| 28 | 33 | } |
|---|
| 29 | 34 | |
|---|
| 30 | | - connect(url: string) { |
|---|
| 31 | | - this.url = url; |
|---|
| 35 | + connect(urls: string[]) { |
|---|
| 36 | + this.urls = urls.filter(Boolean); |
|---|
| 37 | + if (this.urls.length === 0) return; |
|---|
| 32 | 38 | this.shouldReconnect = true; |
|---|
| 33 | 39 | this.reconnectDelay = INITIAL_RECONNECT_DELAY; |
|---|
| 34 | | - this.openConnection(); |
|---|
| 40 | + this.urlIndex = 0; |
|---|
| 41 | + this.connected = false; |
|---|
| 42 | + this.tryUrl(); |
|---|
| 35 | 43 | } |
|---|
| 36 | 44 | |
|---|
| 37 | | - private openConnection() { |
|---|
| 45 | + private cleanup() { |
|---|
| 46 | + if (this.localTimer) { clearTimeout(this.localTimer); this.localTimer = null; } |
|---|
| 47 | + if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } |
|---|
| 38 | 48 | if (this.ws) { |
|---|
| 39 | | - this.ws.close(); |
|---|
| 49 | + const old = this.ws; |
|---|
| 40 | 50 | this.ws = null; |
|---|
| 51 | + old.onopen = null; |
|---|
| 52 | + old.onclose = null; |
|---|
| 53 | + old.onerror = null; |
|---|
| 54 | + old.onmessage = null; |
|---|
| 55 | + try { old.close(); } catch { /* ignore */ } |
|---|
| 56 | + } |
|---|
| 57 | + } |
|---|
| 58 | + |
|---|
| 59 | + private tryUrl() { |
|---|
| 60 | + this.cleanup(); |
|---|
| 61 | + |
|---|
| 62 | + const url = this.urls[this.urlIndex]; |
|---|
| 63 | + if (!url) return; |
|---|
| 64 | + |
|---|
| 65 | + const ws = new WebSocket(url); |
|---|
| 66 | + this.ws = ws; |
|---|
| 67 | + |
|---|
| 68 | + // If trying local (index 0) and we have a remote fallback, |
|---|
| 69 | + // give local 2.5s before switching to remote |
|---|
| 70 | + if (this.urlIndex === 0 && this.urls.length > 1) { |
|---|
| 71 | + this.localTimer = setTimeout(() => { |
|---|
| 72 | + this.localTimer = null; |
|---|
| 73 | + if (this.connected) return; // already connected, ignore |
|---|
| 74 | + // Local didn't connect in time — try remote |
|---|
| 75 | + this.urlIndex = 1; |
|---|
| 76 | + this.tryUrl(); |
|---|
| 77 | + }, LOCAL_TIMEOUT); |
|---|
| 41 | 78 | } |
|---|
| 42 | 79 | |
|---|
| 43 | | - try { |
|---|
| 44 | | - this.ws = new WebSocket(this.url); |
|---|
| 80 | + ws.onopen = () => { |
|---|
| 81 | + if (ws !== this.ws) return; // stale |
|---|
| 82 | + this.connected = true; |
|---|
| 83 | + if (this.localTimer) { clearTimeout(this.localTimer); this.localTimer = null; } |
|---|
| 84 | + this.reconnectDelay = INITIAL_RECONNECT_DELAY; |
|---|
| 85 | + this.callbacks.onOpen?.(); |
|---|
| 86 | + }; |
|---|
| 45 | 87 | |
|---|
| 46 | | - this.ws.onopen = () => { |
|---|
| 47 | | - this.reconnectDelay = INITIAL_RECONNECT_DELAY; |
|---|
| 48 | | - this.callbacks.onOpen?.(); |
|---|
| 49 | | - }; |
|---|
| 88 | + ws.onmessage = (event) => { |
|---|
| 89 | + if (ws !== this.ws) return; // stale |
|---|
| 90 | + try { |
|---|
| 91 | + const data = JSON.parse(event.data) as WebSocketMessage; |
|---|
| 92 | + this.callbacks.onMessage?.(data); |
|---|
| 93 | + } catch { |
|---|
| 94 | + this.callbacks.onMessage?.({ type: "text", content: String(event.data) }); |
|---|
| 95 | + } |
|---|
| 96 | + }; |
|---|
| 50 | 97 | |
|---|
| 51 | | - this.ws.onmessage = (event) => { |
|---|
| 52 | | - try { |
|---|
| 53 | | - const data = JSON.parse(event.data) as WebSocketMessage; |
|---|
| 54 | | - this.callbacks.onMessage?.(data); |
|---|
| 55 | | - } catch { |
|---|
| 56 | | - // Non-JSON message — treat as plain text |
|---|
| 57 | | - const data: WebSocketMessage = { |
|---|
| 58 | | - type: "text", |
|---|
| 59 | | - content: String(event.data), |
|---|
| 60 | | - }; |
|---|
| 61 | | - this.callbacks.onMessage?.(data); |
|---|
| 62 | | - } |
|---|
| 63 | | - }; |
|---|
| 64 | | - |
|---|
| 65 | | - this.ws.onclose = () => { |
|---|
| 66 | | - this.callbacks.onClose?.(); |
|---|
| 67 | | - if (this.shouldReconnect) { |
|---|
| 68 | | - this.scheduleReconnect(); |
|---|
| 69 | | - } |
|---|
| 70 | | - }; |
|---|
| 71 | | - |
|---|
| 72 | | - this.ws.onerror = (error) => { |
|---|
| 73 | | - this.callbacks.onError?.(error); |
|---|
| 74 | | - }; |
|---|
| 75 | | - } catch { |
|---|
| 98 | + ws.onclose = () => { |
|---|
| 99 | + if (ws !== this.ws) return; // stale |
|---|
| 100 | + this.connected = false; |
|---|
| 101 | + this.callbacks.onClose?.(); |
|---|
| 76 | 102 | if (this.shouldReconnect) { |
|---|
| 77 | 103 | this.scheduleReconnect(); |
|---|
| 78 | 104 | } |
|---|
| 79 | | - } |
|---|
| 105 | + }; |
|---|
| 106 | + |
|---|
| 107 | + ws.onerror = () => { |
|---|
| 108 | + if (ws !== this.ws) return; // stale |
|---|
| 109 | + // Don't do anything here — onclose always fires after onerror |
|---|
| 110 | + // and handles reconnect. Just swallow the error event. |
|---|
| 111 | + }; |
|---|
| 80 | 112 | } |
|---|
| 81 | 113 | |
|---|
| 82 | 114 | private scheduleReconnect() { |
|---|
| 83 | | - if (this.reconnectTimer) { |
|---|
| 84 | | - clearTimeout(this.reconnectTimer); |
|---|
| 85 | | - } |
|---|
| 115 | + if (this.reconnectTimer) clearTimeout(this.reconnectTimer); |
|---|
| 86 | 116 | this.reconnectTimer = setTimeout(() => { |
|---|
| 117 | + this.reconnectTimer = null; |
|---|
| 87 | 118 | this.reconnectDelay = Math.min( |
|---|
| 88 | 119 | this.reconnectDelay * RECONNECT_MULTIPLIER, |
|---|
| 89 | 120 | MAX_RECONNECT_DELAY |
|---|
| 90 | 121 | ); |
|---|
| 91 | | - this.openConnection(); |
|---|
| 122 | + // Alternate between URLs on each reconnect attempt |
|---|
| 123 | + if (this.urls.length > 1) { |
|---|
| 124 | + this.urlIndex = this.urlIndex === 0 ? 1 : 0; |
|---|
| 125 | + } |
|---|
| 126 | + this.tryUrl(); |
|---|
| 92 | 127 | }, this.reconnectDelay); |
|---|
| 93 | 128 | } |
|---|
| 94 | 129 | |
|---|
| 95 | 130 | disconnect() { |
|---|
| 96 | 131 | this.shouldReconnect = false; |
|---|
| 97 | | - if (this.reconnectTimer) { |
|---|
| 98 | | - clearTimeout(this.reconnectTimer); |
|---|
| 99 | | - this.reconnectTimer = null; |
|---|
| 100 | | - } |
|---|
| 101 | | - if (this.ws) { |
|---|
| 102 | | - this.ws.close(); |
|---|
| 103 | | - this.ws = null; |
|---|
| 104 | | - } |
|---|
| 132 | + this.connected = false; |
|---|
| 133 | + this.cleanup(); |
|---|
| 105 | 134 | } |
|---|
| 106 | 135 | |
|---|
| 107 | | - send(message: WebSocketMessage) { |
|---|
| 136 | + send(message: WsOutgoing) { |
|---|
| 108 | 137 | if (this.ws && this.ws.readyState === WebSocket.OPEN) { |
|---|
| 109 | 138 | this.ws.send(JSON.stringify(message)); |
|---|
| 110 | 139 | return true; |
|---|
| .. | .. |
|---|
| 117 | 146 | } |
|---|
| 118 | 147 | |
|---|
| 119 | 148 | get isConnected(): boolean { |
|---|
| 120 | | - return this.ws?.readyState === WebSocket.OPEN; |
|---|
| 149 | + return this.connected; |
|---|
| 150 | + } |
|---|
| 151 | + |
|---|
| 152 | + get currentUrl(): string { |
|---|
| 153 | + return this.urls[this.urlIndex] ?? ""; |
|---|
| 121 | 154 | } |
|---|
| 122 | 155 | } |
|---|
| 123 | 156 | |
|---|