Matthias Nott
2026-03-07 af1543135d42adc2e97dc5243aeef7418cd3b00d
services/websocket.ts
....@@ -1,5 +1,6 @@
1
-import { WebSocketMessage } from "../types";
1
+import { WsOutgoing } from "../types";
22
3
+type WebSocketMessage = Record<string, unknown>;
34 type MessageCallback = (data: WebSocketMessage) => void;
45 type StatusCallback = () => void;
56 type ErrorCallback = (error: Event) => void;
....@@ -14,97 +15,125 @@
1415 const INITIAL_RECONNECT_DELAY = 1000;
1516 const MAX_RECONNECT_DELAY = 30000;
1617 const RECONNECT_MULTIPLIER = 2;
18
+const LOCAL_TIMEOUT = 2500;
1719
1820 export class WebSocketClient {
1921 private ws: WebSocket | null = null;
20
- private url: string = "";
22
+ private urls: string[] = [];
23
+ private urlIndex: number = 0;
2124 private reconnectDelay: number = INITIAL_RECONNECT_DELAY;
2225 private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
26
+ private localTimer: ReturnType<typeof setTimeout> | null = null;
2327 private shouldReconnect: boolean = false;
28
+ private connected: boolean = false;
2429 private callbacks: WebSocketClientOptions = {};
2530
2631 setCallbacks(callbacks: WebSocketClientOptions) {
2732 this.callbacks = callbacks;
2833 }
2934
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;
3238 this.shouldReconnect = true;
3339 this.reconnectDelay = INITIAL_RECONNECT_DELAY;
34
- this.openConnection();
40
+ this.urlIndex = 0;
41
+ this.connected = false;
42
+ this.tryUrl();
3543 }
3644
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; }
3848 if (this.ws) {
39
- this.ws.close();
49
+ const old = this.ws;
4050 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);
4178 }
4279
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
+ };
4587
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
+ };
5097
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?.();
76102 if (this.shouldReconnect) {
77103 this.scheduleReconnect();
78104 }
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
+ };
80112 }
81113
82114 private scheduleReconnect() {
83
- if (this.reconnectTimer) {
84
- clearTimeout(this.reconnectTimer);
85
- }
115
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
86116 this.reconnectTimer = setTimeout(() => {
117
+ this.reconnectTimer = null;
87118 this.reconnectDelay = Math.min(
88119 this.reconnectDelay * RECONNECT_MULTIPLIER,
89120 MAX_RECONNECT_DELAY
90121 );
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();
92127 }, this.reconnectDelay);
93128 }
94129
95130 disconnect() {
96131 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();
105134 }
106135
107
- send(message: WebSocketMessage) {
136
+ send(message: WsOutgoing) {
108137 if (this.ws && this.ws.readyState === WebSocket.OPEN) {
109138 this.ws.send(JSON.stringify(message));
110139 return true;
....@@ -117,7 +146,11 @@
117146 }
118147
119148 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] ?? "";
121154 }
122155 }
123156