Files
siftlode/frontend/src/lib/http.ts
T
peter 6cb8231a9f fix(logs): the browser's copy of a polled read is quiet too
The server now logs polled reads at DEBUG, and the browser's copy of each
request line promises the server's level: at INFO the unread-count polls
filled My log within minutes and, at Detailed, the shipped log. The server
marks a line it logged at DEBUG with X-Log-Quiet, so the polled-route list
stays in one place.
2026-09-19 05:03:31 +02:00

109 lines
4.3 KiB
TypeScript

// 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<string, unknown> = {},
): 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<string, unknown> = {},
): Promise<Response> {
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;
}