import { useEffect, useRef } from "react"; import { useQueryClient } from "@tanstack/react-query"; import type { DeviceStatus, LedgerEvent, Occupancy } from "../api.js"; import { qk } from "./query.js"; import { useLiveStore, type LaneStatus, type LanePresence } from "./live-store.js"; import { wsUrl } from "./origin.js"; import { createPlatformSocket, type PlatformSocket } from "./platform-ws.js"; // Booth WebSocket client. Opens ONE socket to /api/ws and turns server pushes into // (a) live-store updates for the ticker/occupancy and (b) Query cache invalidations // so TanStack Query remains the source of truth for durable server data. The browser // attaches the auth cookie automatically; the backend gates by cookie + Origin // (see routes/ws.ts). Auto-reconnects with capped backoff so a booth left running // recovers from a server restart without a manual refresh. /** Server → client message shapes (mirror routes/ws.ts OutMsg). */ type WsMessage = | { kind: "hello"; occupancy: Occupancy; devices: DeviceStatus[]; lanes: LaneStatus; radar: LanePresence } | { kind: "ledger"; event: LedgerEvent; occupancy: Occupancy } | { kind: "printer-status"; event: unknown } | { kind: "device-status"; event: DeviceStatus } | { kind: "lane-status"; lanes: LaneStatus } | { kind: "lane-presence"; radar: LanePresence } | { kind: "plate-recognized"; plate: { identity: string; plate: string; direction: "entry" | "exit" } }; /** * @param enabled Gate on the WATCHER permission (`report:read` — mirrors the server's * WS guard in routes/ws.ts). A user whose role lacks it (e.g. a merchant validator * with only `validation:create`) must not attempt the socket at all: the server * 403s the upgrade and the capped-backoff reconnect would otherwise hammer it * forever, filling the server log with a 403 every few seconds. */ export function useLiveFeed(enabled: boolean = true): void { const qc = useQueryClient(); const { setStatus, setOccupancy, pushEvent, setDevices, upsertDevice, setLanes, setRadar, patchPlate } = useLiveStore(); // Hold the socket + reconnect timer across renders; guard against StrictMode // double-invoke and unmount. const sockRef = useRef(null); const retryRef = useRef(0); const closedRef = useRef(false); useEffect(() => { if (!enabled) { setStatus("closed"); return; } closedRef.current = false; const connect = () => { if (closedRef.current) return; setStatus(retryRef.current === 0 ? "connecting" : "connecting"); const sock = createPlatformSocket(wsUrl("/api/ws")); sockRef.current = sock; sock.onopen = () => { retryRef.current = 0; setStatus("open"); }; sock.onmessage = (ev) => { let msg: WsMessage; try { msg = JSON.parse(ev.data as string) as WsMessage; } catch { return; // ignore malformed frames } if (msg.kind === "hello") { setOccupancy(msg.occupancy); // Initial device-status snapshot for the footer. if (Array.isArray(msg.devices)) setDevices(msg.devices); if (msg.lanes) setLanes(msg.lanes); if (msg.radar) setRadar(msg.radar); } else if (msg.kind === "device-status") { upsertDevice(msg.event); } else if (msg.kind === "lane-status") { setLanes(msg.lanes); } else if (msg.kind === "lane-presence") { setRadar(msg.radar); } else if (msg.kind === "plate-recognized") { // Backfill the badge on the already-rendered feed row, and refetch the // Query-owned active-sessions list (re-runs enrichEvents → the now-written plate). patchPlate(msg.plate.identity, msg.plate.plate); void qc.invalidateQueries({ queryKey: qk.activeSessions }); } else if (msg.kind === "ledger") { setOccupancy(msg.occupancy); pushEvent(msg.event); // Keep Query authoritative: the durable event list, occupancy totals, // and active-sessions list refetch on the next read instead of trusting // the pushed copy alone. void qc.invalidateQueries({ queryKey: qk.events }); void qc.invalidateQueries({ queryKey: qk.occupancy }); void qc.invalidateQueries({ queryKey: qk.activeSessions }); // A shift open/close (or a drawer movement) changes the header control // state and the per-shift log window — refresh the shift status too. if ( msg.event.type === "shift_open" || msg.event.type === "shift_z_report" || msg.event.type === "cash_movement" || msg.event.type === "cash_in" || msg.event.type === "cash_out" ) { void qc.invalidateQueries({ queryKey: qk.shift }); } } else if (msg.kind === "printer-status") { void qc.invalidateQueries({ queryKey: ["printers"] }); } }; const scheduleReconnect = () => { if (closedRef.current) return; setStatus("closed"); // Capped exponential backoff: 0.5s, 1s, 2s, … up to 10s. const delay = Math.min(500 * 2 ** retryRef.current, 10_000); retryRef.current += 1; window.setTimeout(connect, delay); }; sock.onclose = scheduleReconnect; // onerror fires before onclose; let onclose own the reconnect to avoid double. sock.onerror = () => sock.close(); }; connect(); return () => { closedRef.current = true; sockRef.current?.close(); sockRef.current = null; }; // qc / store setters are stable; re-run only if the permission gate flips // (login as a different role without a full reload). // eslint-disable-next-line react-hooks/exhaustive-deps }, [enabled]); }