Matthias Nott
2026-03-07 8cdf33e27c633ac30e8851c4617f6063c141660d
services/websocket.ts
....@@ -1,3 +1,4 @@
1
+import { AppState, AppStateStatus } from "react-native";
12 import { WsOutgoing } from "../types";
23
34 type WebSocketMessage = Record<string, unknown>;
....@@ -16,6 +17,7 @@
1617 const MAX_RECONNECT_DELAY = 30000;
1718 const RECONNECT_MULTIPLIER = 2;
1819 const LOCAL_TIMEOUT = 2500;
20
+const HEARTBEAT_INTERVAL = 20000; // 20s ping to detect zombie sockets
1921
2022 export class WebSocketClient {
2123 private ws: WebSocket | null = null;
....@@ -24,9 +26,28 @@
2426 private reconnectDelay: number = INITIAL_RECONNECT_DELAY;
2527 private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
2628 private localTimer: ReturnType<typeof setTimeout> | null = null;
29
+ private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
30
+ private pongReceived: boolean = true;
2731 private shouldReconnect: boolean = false;
2832 private connected: boolean = false;
2933 private callbacks: WebSocketClientOptions = {};
34
+
35
+ constructor() {
36
+ // When app comes back to foreground, check if socket is still alive
37
+ AppState.addEventListener("change", (state: AppStateStatus) => {
38
+ if (state === "active" && this.shouldReconnect) {
39
+ if (!this.connected || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
40
+ // Socket is dead — force immediate reconnect
41
+ this.reconnectDelay = INITIAL_RECONNECT_DELAY;
42
+ this.urlIndex = 0;
43
+ this.tryUrl();
44
+ } else {
45
+ // Socket looks open but might be zombie — send a ping to verify
46
+ this.sendPing();
47
+ }
48
+ }
49
+ });
50
+ }
3051
3152 setCallbacks(callbacks: WebSocketClientOptions) {
3253 this.callbacks = callbacks;
....@@ -45,6 +66,7 @@
4566 private cleanup() {
4667 if (this.localTimer) { clearTimeout(this.localTimer); this.localTimer = null; }
4768 if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
69
+ this.stopHeartbeat();
4870 if (this.ws) {
4971 const old = this.ws;
5072 this.ws = null;
....@@ -53,6 +75,43 @@
5375 old.onerror = null;
5476 old.onmessage = null;
5577 try { old.close(); } catch { /* ignore */ }
78
+ }
79
+ }
80
+
81
+ private startHeartbeat() {
82
+ this.stopHeartbeat();
83
+ this.pongReceived = true;
84
+ this.heartbeatTimer = setInterval(() => {
85
+ if (!this.pongReceived) {
86
+ // No pong since last ping — socket is zombie, force reconnect
87
+ this.connected = false;
88
+ this.callbacks.onClose?.();
89
+ this.reconnectDelay = INITIAL_RECONNECT_DELAY;
90
+ this.urlIndex = 0;
91
+ this.tryUrl();
92
+ return;
93
+ }
94
+ this.sendPing();
95
+ }, HEARTBEAT_INTERVAL);
96
+ }
97
+
98
+ private stopHeartbeat() {
99
+ if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null; }
100
+ }
101
+
102
+ private sendPing() {
103
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
104
+ this.pongReceived = false;
105
+ try {
106
+ this.ws.send(JSON.stringify({ type: "ping" }));
107
+ } catch {
108
+ // Send failed — socket is dead
109
+ this.connected = false;
110
+ this.callbacks.onClose?.();
111
+ this.reconnectDelay = INITIAL_RECONNECT_DELAY;
112
+ this.urlIndex = 0;
113
+ this.tryUrl();
114
+ }
56115 }
57116 }
58117
....@@ -82,6 +141,7 @@
82141 this.connected = true;
83142 if (this.localTimer) { clearTimeout(this.localTimer); this.localTimer = null; }
84143 this.reconnectDelay = INITIAL_RECONNECT_DELAY;
144
+ this.startHeartbeat();
85145 this.callbacks.onOpen?.();
86146 };
87147
....@@ -89,6 +149,11 @@
89149 if (ws !== this.ws) return; // stale
90150 try {
91151 const data = JSON.parse(event.data) as WebSocketMessage;
152
+ // Handle pong responses from heartbeat
153
+ if (data.type === "pong") {
154
+ this.pongReceived = true;
155
+ return;
156
+ }
92157 this.callbacks.onMessage?.(data);
93158 } catch {
94159 this.callbacks.onMessage?.({ type: "text", content: String(event.data) });