Files
parking_solution/apps/web/src/lib/use-live-feed.ts
T
julian 439b11d16d
Build desktop / desktop (push) Successful in 4m33s
Build & push images / images (push) Successful in 2m50s
CI / check (push) Successful in 43s
Release desktop / bundle (push) Successful in 5m13s
fix(desktop): route fetch + WebSocket through native Tauri plugins (mixed-content)
Fixing VITE_API_BASE got login to build a correct absolute URL, but it still
failed with WebKit's generic "Load failed" — WebKitGTK treats tauri://localhost
as a secure origin, so http://127.0.0.1:3000 (and ws://) from inside it is
blocked as mixed content, a WebKit limitation CSP's connect-src can't override.

Added tauri-plugin-http (genuine fetch() drop-in, wired via a new
platformFetch() in origin.ts, used by api.ts + logger.ts) and
tauri-plugin-websocket (not a drop-in — adapted behind a native-WebSocket-
shaped interface in the new platform-ws.ts so use-live-feed.ts needed no
changes). Both route through Tauri's Rust side instead of the webview's own
fetch/WebSocket. Capabilities scoped to 127.0.0.1:3000/localhost:3000, matching
the existing CSP allowlist.
2026-09-03 14:57:45 +02:00

137 lines
5.7 KiB
TypeScript

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<PlatformSocket | null>(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]);
}