// The ONE place the app calls `fetch` (lint-enforced: eslint.config.js). Every request gets an // `X-Request-Id`, and every answer — or network failure — becomes one `http.request` event in the // client log, the browser's copy of the server's access line with the same id. // // Deliberately nothing else: no error dialog, no connection notice, no account header. `req` and // `beacon` (lib/api/core.ts) add the app's policy on top; the public pages (/watch, the legal pages) // use this directly, because they render outside the app and must not pull its notification and // dialog machinery into their chunks. import { log, newRequestId, type Level } from "./log"; /** The route TEMPLATE the server matched (`/api/videos/{video_id}`), which it sends back in `X-Route`. * Grouping by it is what makes a client line comparable, and it is safe by construction: a token in * the path (`/api/public/watch/{token}`) arrives already replaced by its parameter name. */ export const ROUTE_HEADER = "X-Route"; /** Set when the server logged this request at DEBUG (a polled read, the log console's own reads): the * browser's copy of the line goes to DEBUG too, from the server's one list (`logs._POLLED_ROUTES`). */ export const QUIET_HEADER = "X-Log-Quiet"; export const REQUEST_ID_HEADER = "X-Request-Id"; // A static path word: kept. Anything else (an id, a channel id, a share token) becomes `{}`. const STATIC_SEGMENT = /^[a-z]+(?:[-_.][a-z]+)*$/; /** The path to log when the server named no template (a network failure, a gateway's own answer): * the path alone, with every segment that is not a plain lowercase word replaced — never a query, * never an id or a token. */ export function pathTemplate(url: string): string { const path = url.split(/[?#]/, 1)[0] ?? ""; return path .split("/") .map((seg) => (seg === "" || STATIC_SEGMENT.test(seg) ? seg : "{}")) .join("/"); } /** Level of a request line — the server's own rule (`logs.log_requests`), so both sides agree on * what a 404 is, and on which successes are too routine for INFO (`quiet`, the server's say). */ function statusLevel(status: number, quiet: boolean): Level { return status >= 500 ? "ERROR" : status >= 400 ? "WARNING" : quiet ? "DEBUG" : "INFO"; } /** Record one request's outcome. `route` is the server's template when it sent one. */ export function logRequest( requestId: string, method: string, url: string, started: number, outcome: | { status: number; route?: string | null; quiet?: boolean } | { failed: "network" | "aborted" }, extra: Record = {}, ): void { const duration_ms = Math.round((performance.now() - started) * 10) / 10; if ("failed" in outcome) { const path = pathTemplate(url); log.request( outcome.failed === "aborted" ? "INFO" : "WARNING", `${method} ${path} ${outcome.failed}`, { method, path, failed: outcome.failed, duration_ms, ...extra }, requestId, ); return; } const path = outcome.route || pathTemplate(url); log.request( statusLevel(outcome.status, outcome.quiet === true), `${method} ${path} ${outcome.status}`, { method, path, status: outcome.status, duration_ms, ...extra }, requestId, ); } function headerOf(r: Response, name: string): string | null { // Test doubles hand back plain objects; a real Response always has Headers. return typeof r.headers?.get === "function" ? r.headers.get(name) : null; } /** `fetch`, with a request id and one log event. Rejects exactly as `fetch` does. */ export async function send( url: string, init: RequestInit = {}, extra: Record = {}, ): Promise { const requestId = newRequestId(); const method = (init.method ?? "GET").toUpperCase(); const headers = new Headers(init.headers); headers.set(REQUEST_ID_HEADER, requestId); const started = performance.now(); let r: Response; try { r = await fetch(url, { ...init, headers }); } catch (e) { const aborted = e instanceof DOMException && e.name === "AbortError"; logRequest(requestId, method, url, started, { failed: aborted ? "aborted" : "network" }, extra); throw e; } logRequest( requestId, method, url, started, { status: r.status, route: headerOf(r, ROUTE_HEADER), quiet: headerOf(r, QUIET_HEADER) === "1", }, extra, ); return r; }