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>
160 lines
5.0 KiB
JavaScript
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 }
|
|
}
|