feat: bump client-services (accumulated feature work + deploy fixes)
Snapshot of in-progress work across local_backend, manager_dashboard, and waiter_pwa (pricing, chat, fiscal, prep zones, recovery codes, CRM, inventory, permissions), plus the nginx/docker-compose deploy fixes for the Unraid + NPM reverse-proxy setup. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Persistent WebSocket hook — replaces useSSE.
|
||||
*
|
||||
* Protocol:
|
||||
* 1. Connect to ws[s]://host/api/ws/connect?token=<jwt>
|
||||
* 2. Send { cursor: <last_seq> } 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'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Build WS URL — same host, swap http(s) → ws(s)
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const url = `${protocol}//${window.location.host}/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 }
|
||||
}
|
||||
Reference in New Issue
Block a user