| .. | .. |
|---|
| 17 | 17 | const MAX_RECONNECT_DELAY = 30000; |
|---|
| 18 | 18 | const RECONNECT_MULTIPLIER = 2; |
|---|
| 19 | 19 | const LOCAL_TIMEOUT = 2500; |
|---|
| 20 | | -const HEARTBEAT_INTERVAL = 20000; // 20s ping to detect zombie sockets |
|---|
| 20 | +const HEARTBEAT_INTERVAL = 30000; // 30s ping to detect zombie sockets |
|---|
| 21 | 21 | |
|---|
| 22 | 22 | export class WebSocketClient { |
|---|
| 23 | 23 | private ws: WebSocket | null = null; |
|---|
| .. | .. |
|---|
| 27 | 27 | private reconnectTimer: ReturnType<typeof setTimeout> | null = null; |
|---|
| 28 | 28 | private localTimer: ReturnType<typeof setTimeout> | null = null; |
|---|
| 29 | 29 | private heartbeatTimer: ReturnType<typeof setInterval> | null = null; |
|---|
| 30 | | - private pongReceived: boolean = true; |
|---|
| 30 | + private lastMessageAt: number = 0; // timestamp of last received message (any type) |
|---|
| 31 | 31 | private shouldReconnect: boolean = false; |
|---|
| 32 | 32 | private connected: boolean = false; |
|---|
| 33 | 33 | private callbacks: WebSocketClientOptions = {}; |
|---|
| 34 | 34 | |
|---|
| 35 | 35 | constructor() { |
|---|
| 36 | | - // When app comes back to foreground, check if socket is still alive |
|---|
| 36 | + // When app comes back to foreground, verify the connection |
|---|
| 37 | 37 | AppState.addEventListener("change", (state: AppStateStatus) => { |
|---|
| 38 | 38 | if (state === "active" && this.shouldReconnect) { |
|---|
| 39 | | - if (!this.connected || !this.ws || this.ws.readyState !== WebSocket.OPEN) { |
|---|
| 40 | | - // Socket is dead — force immediate reconnect |
|---|
| 39 | + if (!this.ws || this.ws.readyState === WebSocket.CLOSED || this.ws.readyState === WebSocket.CLOSING) { |
|---|
| 40 | + // Socket is definitively dead — force immediate reconnect |
|---|
| 41 | 41 | this.reconnectDelay = INITIAL_RECONNECT_DELAY; |
|---|
| 42 | 42 | this.urlIndex = 0; |
|---|
| 43 | 43 | this.tryUrl(); |
|---|
| 44 | | - } else { |
|---|
| 45 | | - // Socket looks open but might be zombie — send a ping to verify |
|---|
| 44 | + } else if (this.connected) { |
|---|
| 45 | + // Socket might be alive — send a ping and restart heartbeat |
|---|
| 46 | + // to give it a fresh 2-interval window to prove liveness |
|---|
| 47 | + this.startHeartbeat(); |
|---|
| 46 | 48 | this.sendPing(); |
|---|
| 47 | 49 | } |
|---|
| 48 | 50 | } |
|---|
| .. | .. |
|---|
| 80 | 82 | |
|---|
| 81 | 83 | private startHeartbeat() { |
|---|
| 82 | 84 | this.stopHeartbeat(); |
|---|
| 83 | | - this.pongReceived = true; |
|---|
| 85 | + this.lastMessageAt = Date.now(); |
|---|
| 84 | 86 | this.heartbeatTimer = setInterval(() => { |
|---|
| 85 | | - if (!this.pongReceived) { |
|---|
| 86 | | - // No pong since last ping — socket is zombie, force reconnect |
|---|
| 87 | + const silentMs = Date.now() - this.lastMessageAt; |
|---|
| 88 | + if (silentMs > HEARTBEAT_INTERVAL * 2) { |
|---|
| 89 | + // No message (including pong) for 2 full intervals — zombie socket |
|---|
| 87 | 90 | this.connected = false; |
|---|
| 88 | 91 | this.callbacks.onClose?.(); |
|---|
| 89 | 92 | this.reconnectDelay = INITIAL_RECONNECT_DELAY; |
|---|
| .. | .. |
|---|
| 91 | 94 | this.tryUrl(); |
|---|
| 92 | 95 | return; |
|---|
| 93 | 96 | } |
|---|
| 97 | + // Send a ping so the server has something to respond to |
|---|
| 94 | 98 | this.sendPing(); |
|---|
| 95 | 99 | }, HEARTBEAT_INTERVAL); |
|---|
| 96 | 100 | } |
|---|
| .. | .. |
|---|
| 101 | 105 | |
|---|
| 102 | 106 | private sendPing() { |
|---|
| 103 | 107 | if (this.ws && this.ws.readyState === WebSocket.OPEN) { |
|---|
| 104 | | - this.pongReceived = false; |
|---|
| 105 | 108 | try { |
|---|
| 106 | 109 | this.ws.send(JSON.stringify({ type: "ping" })); |
|---|
| 107 | 110 | } catch { |
|---|
| .. | .. |
|---|
| 147 | 150 | |
|---|
| 148 | 151 | ws.onmessage = (event) => { |
|---|
| 149 | 152 | if (ws !== this.ws) return; // stale |
|---|
| 153 | + this.lastMessageAt = Date.now(); // any message proves the connection is alive |
|---|
| 150 | 154 | try { |
|---|
| 151 | 155 | 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 | | - } |
|---|
| 156 | + if (data.type === "pong") return; // heartbeat response, don't forward |
|---|
| 157 | 157 | this.callbacks.onMessage?.(data); |
|---|
| 158 | 158 | } catch { |
|---|
| 159 | 159 | this.callbacks.onMessage?.({ type: "text", content: String(event.data) }); |
|---|