8fa66c9911
The v0.1.4 Origin fix cleared only the first of two gates in /api/ws's preHandler. The second, req.jwtVerify(), reads the HttpOnly cookie — which tauri-plugin-websocket (a bare tungstenite client, no cookie jar) can never send. Every desktop handshake 401'd and use-live-feed reconnected every 10s (confirmed in the park-2 server log). - routes/ws.ts: POST /api/ws/ticket (cookie + CSRF auth) mints a 30s, single-use, in-memory ticket; the WS preHandler accepts it via an x-ws-ticket header after the Origin check, then the same report:read role check. Browser cookie path unchanged; JWT stays out of JS. - platform-ws.ts: fetch a ticket before connect, send it with the Origin header; connect failures now go through logClient (rate-limited). - logger.ts: flush read the CSRF token from document.cookie, null on desktop, so every desktop POST /api/logs 403'd and was dropped silently — no desktop client log had ever reached app_logs. Stash moved to a dependency-free lib/desktop-csrf.ts shared by api.ts and logger.ts. - backend-config.ts: ConnectScreen probe uses the unauthenticated /health (now also returns app: "parking-system") instead of accepting any 401. - README: local-AppImage release gate — tauri dev runs at http://localhost:5173, not tauri://localhost, so none of these origin-dependent bugs reproduce there. - wiki: new section + log entry; four citation corrections. Requires the server image with this commit deployed before the new desktop build connects (the ticket endpoint must exist). Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
209 lines
7.8 KiB
TypeScript
209 lines
7.8 KiB
TypeScript
// Frontend error/log collector. Ships failed requests, uncaught errors, and rejected
|
|
// promises to the backend (POST /api/logs → app_logs), so a booth problem is
|
|
// diagnosable from the host instead of needing the operator's devtools. See
|
|
// wiki/concepts/app-logs.md.
|
|
//
|
|
// Design notes:
|
|
// - BATCHED + THROTTLED: entries queue and flush on a short timer (and on page hide
|
|
// via sendBeacon), so a burst of errors is one request, not hundreds.
|
|
// - LOOP-SAFE: a failure of the /api/logs request itself is NEVER re-logged (that
|
|
// would be an infinite error → log → error spiral). We also never recurse through
|
|
// apiFetch — the flush uses raw fetch/sendBeacon.
|
|
// - LEVEL-GATED noise: console.warn/error are only forwarded when the client log
|
|
// level is debug/trace (off by default) — they're noisy (3rd-party chatter). The
|
|
// high-signal sources (failed requests, uncaught errors) are always captured.
|
|
|
|
import { LOG_LEVEL_ORDER, type ClientLogInput, type LogLevel } from "@parking/shared";
|
|
import { getDesktopCsrfToken } from "./desktop-csrf.js";
|
|
import { apiUrl, platformFetch } from "./origin.js";
|
|
import { inTauri } from "./tauri-env.js";
|
|
|
|
const ENDPOINT = "/api/logs";
|
|
const FLUSH_MS = 4000;
|
|
const MAX_QUEUE = 100; // drop oldest beyond this (bounded memory on a long-lived booth)
|
|
const CSRF_COOKIE = "parking_csrf";
|
|
const CSRF_HEADER = "X-CSRF-Token";
|
|
|
|
/** The client capture threshold. Entries below this level are dropped before queueing.
|
|
* Default `info`: failed requests (error) + uncaught errors (error) always pass;
|
|
* console.warn/error forwarding is wired separately and only ON at debug/trace. */
|
|
let clientLevel: LogLevel = (import.meta.env.VITE_LOG_LEVEL as LogLevel) || "info";
|
|
|
|
export function setClientLogLevel(level: LogLevel): void {
|
|
clientLevel = level;
|
|
}
|
|
export function getClientLogLevel(): LogLevel {
|
|
return clientLevel;
|
|
}
|
|
/** Are console.warn/error forwarded? Only when the client level is debug or trace. */
|
|
function consoleForwardEnabled(): boolean {
|
|
return LOG_LEVEL_ORDER[clientLevel] <= LOG_LEVEL_ORDER.debug;
|
|
}
|
|
|
|
const queue: ClientLogInput[] = [];
|
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
/** Set true only while flushing, so the flush's own network activity is never logged. */
|
|
let flushing = false;
|
|
|
|
function readCookie(name: string): string | null {
|
|
const m = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
|
|
return m ? decodeURIComponent(m[1]!) : null;
|
|
}
|
|
|
|
function scheduleFlush(): void {
|
|
if (timer != null) return;
|
|
timer = setTimeout(() => {
|
|
timer = null;
|
|
void flush();
|
|
}, FLUSH_MS);
|
|
}
|
|
|
|
/** Enqueue an entry. Drops it if below the client level or if it concerns the log
|
|
* endpoint itself (loop guard). */
|
|
export function logClient(entry: ClientLogInput): void {
|
|
if (LOG_LEVEL_ORDER[entry.level] < LOG_LEVEL_ORDER[clientLevel]) return;
|
|
if (flushing) return; // don't log anything produced by the flush itself
|
|
if (entry.path && entry.path.startsWith(ENDPOINT)) return; // never log the log call
|
|
queue.push({ ...entry, at: entry.at ?? new Date().toISOString() });
|
|
if (queue.length > MAX_QUEUE) queue.splice(0, queue.length - MAX_QUEUE);
|
|
scheduleFlush();
|
|
}
|
|
|
|
/** POST the queued entries. Raw fetch (not apiFetch) so a failure can't recurse. A
|
|
* failed flush silently re-queues nothing — diagnostics are best-effort, never fatal. */
|
|
async function flush(): Promise<void> {
|
|
if (queue.length === 0) return;
|
|
const entries = queue.splice(0, queue.length);
|
|
flushing = true;
|
|
try {
|
|
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
// /api/logs is behind requireAuth → assertCsrf on POST. On desktop the
|
|
// cookie is unreadable (see desktop-csrf.ts) — without this branch every
|
|
// desktop flush 403'd and was dropped here, silently, by design (found
|
|
// 2026-09-04: no desktop client log had EVER reached app_logs).
|
|
const csrf = inTauri() ? getDesktopCsrfToken() : readCookie(CSRF_COOKIE);
|
|
if (csrf) headers[CSRF_HEADER] = csrf;
|
|
await platformFetch(apiUrl(ENDPOINT), {
|
|
method: "POST",
|
|
headers,
|
|
credentials: "include",
|
|
body: JSON.stringify({ entries }),
|
|
keepalive: true,
|
|
});
|
|
} catch {
|
|
// Drop on failure — we must not re-log (loop) nor grow unbounded.
|
|
} finally {
|
|
flushing = false;
|
|
}
|
|
}
|
|
|
|
/** Best-effort synchronous flush on page hide (sendBeacon survives unload). Browser
|
|
* only — sendBeacon is a native browser API with no Tauri-HTTP-plugin equivalent,
|
|
* so this drops silently in the desktop shell (unload is rare there; the regular
|
|
* 4s-interval flush above covers the common case). */
|
|
function flushBeacon(): void {
|
|
if (queue.length === 0) return;
|
|
const entries = queue.splice(0, queue.length);
|
|
try {
|
|
const blob = new Blob([JSON.stringify({ entries })], { type: "application/json" });
|
|
// sendBeacon can't set the CSRF header; the server accepts the ingest for any
|
|
// signed-in session (cookie sent automatically). If CSRF later guards it strictly,
|
|
// this path degrades to "lost on unload" — acceptable for diagnostics.
|
|
navigator.sendBeacon(apiUrl(ENDPOINT), blob);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
/** Record a FAILED API request (called from apiFetch's error path). Always high-signal. */
|
|
export function logFailedRequest(info: {
|
|
path: string;
|
|
method: string;
|
|
status: number;
|
|
error?: string;
|
|
requestId?: string;
|
|
}): void {
|
|
logClient({
|
|
level: "error",
|
|
message: `${info.method} ${info.path} → ${info.status}${info.error ? `: ${info.error}` : ""}`,
|
|
httpStatus: info.status,
|
|
path: info.path,
|
|
context: { kind: "request_failed", method: info.method, requestId: info.requestId },
|
|
});
|
|
}
|
|
|
|
let installed = false;
|
|
|
|
/** Wire global handlers once, at app startup. Idempotent. */
|
|
export function installClientLogging(): void {
|
|
if (installed || typeof window === "undefined") return;
|
|
installed = true;
|
|
|
|
// Uncaught runtime errors.
|
|
window.addEventListener("error", (e: ErrorEvent) => {
|
|
logClient({
|
|
level: "error",
|
|
message: e.message || "uncaught error",
|
|
stack: e.error?.stack,
|
|
path: location.pathname,
|
|
context: {
|
|
kind: "window_error",
|
|
filename: e.filename,
|
|
line: e.lineno,
|
|
col: e.colno,
|
|
},
|
|
});
|
|
});
|
|
|
|
// Unhandled promise rejections.
|
|
window.addEventListener("unhandledrejection", (e: PromiseRejectionEvent) => {
|
|
const reason = e.reason;
|
|
const message =
|
|
reason instanceof Error ? reason.message : typeof reason === "string" ? reason : "unhandled rejection";
|
|
logClient({
|
|
level: "error",
|
|
message,
|
|
stack: reason instanceof Error ? reason.stack : undefined,
|
|
path: location.pathname,
|
|
context: { kind: "unhandled_rejection" },
|
|
});
|
|
});
|
|
|
|
// console.warn / console.error → only forwarded at debug/trace (noisy otherwise).
|
|
const origWarn = console.warn.bind(console);
|
|
const origError = console.error.bind(console);
|
|
console.warn = (...args: unknown[]) => {
|
|
origWarn(...args);
|
|
if (consoleForwardEnabled()) {
|
|
logClient({ level: "warn", message: stringifyArgs(args), path: location.pathname, context: { kind: "console" } });
|
|
}
|
|
};
|
|
console.error = (...args: unknown[]) => {
|
|
origError(...args);
|
|
if (consoleForwardEnabled()) {
|
|
logClient({ level: "error", message: stringifyArgs(args), path: location.pathname, context: { kind: "console" } });
|
|
}
|
|
};
|
|
|
|
// Flush on tab hide / unload.
|
|
window.addEventListener("visibilitychange", () => {
|
|
if (document.visibilityState === "hidden") flushBeacon();
|
|
});
|
|
window.addEventListener("pagehide", flushBeacon);
|
|
}
|
|
|
|
function stringifyArgs(args: unknown[]): string {
|
|
return args
|
|
.map((a) => (a instanceof Error ? a.message : typeof a === "string" ? a : safeStringify(a)))
|
|
.join(" ")
|
|
.slice(0, 2000);
|
|
}
|
|
|
|
function safeStringify(v: unknown): string {
|
|
try {
|
|
return JSON.stringify(v);
|
|
} catch {
|
|
return String(v);
|
|
}
|
|
}
|