Files
xenia-pos-local/waiter_pwa/src/hooks/useWebSocket.js
T
bonaminandClaude Opus 5.5 18012c2c95 feat(waiter): configurable server/venue layer for native app and plain-HTTP browser mode
New waiter_pwa/src/config/server.js is the single place that knows where the
backend is. Served by the venue's server (https domain or http://<LAN IP>)
nothing changes: same-origin URLs and the original storage keys, so existing
installs keep their token and unsynced offline queue. With an active venue
(native app, or dev builds with VITE_SERVER_URL) URLs become absolute and all
venue data is namespaced by siteId: token/savedUsername keys, the Dexie DB
(pos_snapshot__<siteId>, which also covers the WS cursor), favorites and
table-view prefs. Switching venue reloads the app.

- api client baseURL, WebSocket and SSE URLs routed through the layer
- product images / waiter avatars rendered via assetUrl()
- service-worker update prompt skipped in native builds
- InstallAppBanner: shown only in plain-HTTP browser mode and only when
  VITE_APP_DOWNLOAD_URL is set at build time (dismiss for 7 days)
- VITE_SERVER_URL override is DEV-only (a URL-controlled server in prod would
  let a crafted link capture PINs)
- pack README: rule CS-8 on never assuming same-origin

Verified with Playwright/Edge against a local backend: prod build same-origin,
prod build via LAN IP over plain HTTP (insecure context, no SW, banner shown),
and dev build pointed at the backend by URL - all three log in, reach /tables
and receive the WebSocket 'ready' frame; storage keys and IndexedDB names are
as expected. Lint: no new problems (103 before/after). Build passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 11:38:42 +03:00

160 lines
5.0 KiB
JavaScript

/**
* 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'
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 }
}