/** * Persistent WebSocket hook — replaces useSSE. * * Protocol: * 1. Connect to ws[s]://host/api/ws/connect?token= * 2. Send { cursor: } immediately (0 = first connect) * 3. Receive replayed missed events, then { type: "ready" } * 4. Live events: { seq, type, data } * 5. Keepalive: server sends { type: "ping" } every 25s * * The hook stores `lastSeq` in IndexedDB so it survives page reloads. * On every clean reconnect, missed events are replayed automatically. */ import { useCallback, useEffect, useRef } from 'react' import db from '../db/posdb' import { wsUrl } from '../config/server' const INITIAL_RECONNECT_DELAY = 2_000 const MAX_RECONNECT_DELAY = 30_000 const CURSOR_KEY = 'ws_last_seq' // ── Cursor persistence (IDB) ────────────────────────────────────────────────── async function loadCursor() { try { const row = await db.kv?.get(CURSOR_KEY) return row?.value ?? 0 } catch { return 0 } } async function saveCursor(seq) { try { await db.kv?.put({ key: CURSOR_KEY, value: seq }) } catch {} } // ── Hook ────────────────────────────────────────────────────────────────────── export function useWebSocket({ token, onEvent, onConnect, onDisconnect, enabled = true }) { const onEventRef = useRef(onEvent) const onConnectRef = useRef(onConnect) const onDisconnectRef = useRef(onDisconnect) useEffect(() => { onEventRef.current = onEvent }, [onEvent]) useEffect(() => { onConnectRef.current = onConnect }, [onConnect]) useEffect(() => { onDisconnectRef.current = onDisconnect }, [onDisconnect]) const wsRef = useRef(null) const reconnectTimer = useRef(null) const reconnectDelay = useRef(INITIAL_RECONNECT_DELAY) const unmounted = useRef(false) const connectRef = useRef(null) const lastSeq = useRef(0) useEffect(() => { if (!token || !enabled) return unmounted.current = false // Load persisted cursor before first connect loadCursor().then(seq => { lastSeq.current = seq if (!unmounted.current) connectRef.current?.() }) async function connect() { if (unmounted.current) return if (wsRef.current) { wsRef.current.onclose = null // prevent double-reconnect wsRef.current.close() wsRef.current = null } // Same host as the page (web) or the active venue's server (native) const url = wsUrl(`/api/ws/connect?token=${encodeURIComponent(token)}`) const ws = new WebSocket(url) wsRef.current = ws ws.onopen = () => { reconnectDelay.current = INITIAL_RECONNECT_DELAY // Send cursor so server can replay missed events ws.send(JSON.stringify({ cursor: lastSeq.current })) } ws.onmessage = (e) => { let msg try { msg = JSON.parse(e.data) } catch { return } if (msg.type === 'ping') return // keepalive — no response needed if (msg.type === 'ready') { // Replay complete — we're now on the live stream onConnectRef.current?.() return } // Live or replayed event if (msg.seq && msg.seq > lastSeq.current) { lastSeq.current = msg.seq saveCursor(msg.seq) } if (msg.type && msg.data !== undefined) { onEventRef.current?.(msg.type, msg.data) } } ws.onerror = () => { // onerror always fires before onclose — let onclose handle reconnect } ws.onclose = () => { wsRef.current = null onDisconnectRef.current?.() if (unmounted.current) return // Schedule next attempt with current delay, then increase for the one after reconnectTimer.current = setTimeout(() => { reconnectDelay.current = Math.min( reconnectDelay.current * 1.5, MAX_RECONNECT_DELAY, ) connect() }, reconnectDelay.current) } } connectRef.current = connect // When the browser detects network restoration, immediately reset backoff and reconnect. // This fires as soon as the OS reports connectivity — no need to wait for the // backed-off timer or for the user to switch tabs. function onNetworkOnline() { clearTimeout(reconnectTimer.current) reconnectDelay.current = INITIAL_RECONNECT_DELAY connect() } window.addEventListener('online', onNetworkOnline) return () => { unmounted.current = true clearTimeout(reconnectTimer.current) window.removeEventListener('online', onNetworkOnline) if (wsRef.current) { wsRef.current.onclose = null wsRef.current.close() wsRef.current = null } } }, [token, enabled]) const reconnect = useCallback(() => { clearTimeout(reconnectTimer.current) reconnectDelay.current = INITIAL_RECONNECT_DELAY connectRef.current?.() }, []) return { reconnect } }