| .. | .. |
|---|
| 1 | +import { AppState, AppStateStatus } from "react-native"; |
|---|
| 1 | 2 | import { WsOutgoing } from "../types"; |
|---|
| 2 | 3 | |
|---|
| 3 | 4 | type WebSocketMessage = Record<string, unknown>; |
|---|
| .. | .. |
|---|
| 16 | 17 | const MAX_RECONNECT_DELAY = 30000; |
|---|
| 17 | 18 | const RECONNECT_MULTIPLIER = 2; |
|---|
| 18 | 19 | const LOCAL_TIMEOUT = 2500; |
|---|
| 20 | +const HEARTBEAT_INTERVAL = 20000; // 20s ping to detect zombie sockets |
|---|
| 19 | 21 | |
|---|
| 20 | 22 | export class WebSocketClient { |
|---|
| 21 | 23 | private ws: WebSocket | null = null; |
|---|
| .. | .. |
|---|
| 24 | 26 | private reconnectDelay: number = INITIAL_RECONNECT_DELAY; |
|---|
| 25 | 27 | private reconnectTimer: ReturnType<typeof setTimeout> | null = null; |
|---|
| 26 | 28 | private localTimer: ReturnType<typeof setTimeout> | null = null; |
|---|
| 29 | + private heartbeatTimer: ReturnType<typeof setInterval> | null = null; |
|---|
| 30 | + private pongReceived: boolean = true; |
|---|
| 27 | 31 | private shouldReconnect: boolean = false; |
|---|
| 28 | 32 | private connected: boolean = false; |
|---|
| 29 | 33 | 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 | + } |
|---|
| 30 | 51 | |
|---|
| 31 | 52 | setCallbacks(callbacks: WebSocketClientOptions) { |
|---|
| 32 | 53 | this.callbacks = callbacks; |
|---|
| .. | .. |
|---|
| 45 | 66 | private cleanup() { |
|---|
| 46 | 67 | if (this.localTimer) { clearTimeout(this.localTimer); this.localTimer = null; } |
|---|
| 47 | 68 | if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } |
|---|
| 69 | + this.stopHeartbeat(); |
|---|
| 48 | 70 | if (this.ws) { |
|---|
| 49 | 71 | const old = this.ws; |
|---|
| 50 | 72 | this.ws = null; |
|---|
| .. | .. |
|---|
| 53 | 75 | old.onerror = null; |
|---|
| 54 | 76 | old.onmessage = null; |
|---|
| 55 | 77 | 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 | + } |
|---|
| 56 | 115 | } |
|---|
| 57 | 116 | } |
|---|
| 58 | 117 | |
|---|
| .. | .. |
|---|
| 82 | 141 | this.connected = true; |
|---|
| 83 | 142 | if (this.localTimer) { clearTimeout(this.localTimer); this.localTimer = null; } |
|---|
| 84 | 143 | this.reconnectDelay = INITIAL_RECONNECT_DELAY; |
|---|
| 144 | + this.startHeartbeat(); |
|---|
| 85 | 145 | this.callbacks.onOpen?.(); |
|---|
| 86 | 146 | }; |
|---|
| 87 | 147 | |
|---|
| .. | .. |
|---|
| 89 | 149 | if (ws !== this.ws) return; // stale |
|---|
| 90 | 150 | try { |
|---|
| 91 | 151 | 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 | + } |
|---|
| 92 | 157 | this.callbacks.onMessage?.(data); |
|---|
| 93 | 158 | } catch { |
|---|
| 94 | 159 | this.callbacks.onMessage?.({ type: "text", content: String(event.data) }); |
|---|