Files
siftlode/frontend/src/lib/api/core.ts
T

384 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// The shared HTTP machinery behind every api.* domain module: the fetch wrapper (`req`), the typed
// error, structured-error localization, the connectivity toast, the identity headers,
// and the keepalive beacon. Domain modules import from here; nothing here imports a domain module,
// so the runtime graph is a clean barrel → domain → core with no cycles.
import { getActiveToasts, msg, notify, remove } from "../notifications";
import i18n from "../../i18n";
import { localizeDetail } from "./refusalText";
import { reportError } from "../errorDialog";
import { logRequest, QUIET_HEADER, REQUEST_ID_HEADER, ROUTE_HEADER, send } from "../http";
import { currentDebugSession, newRequestId } from "../log";
import { claimedAccount } from "../storage";
/** The active UI language, as a bare code the server understands ("en"/"hu" — never "en-GB").
* Lives here so the request builders can stamp it themselves: a signal that has to be passed in by
* every caller is one every caller can forget, which is how the registration language shipped as a
* field nobody ever filled. */
export const uiLang = (): string => i18n.language.split("-")[0] || "en";
// Errors `req` has already put in front of the user for good — the blocking error dialog, or the
// sign-out a 401 triggers. A caller that toasts a failed action as well reported the same fault twice
// (a modal AND a toast); `notifyActionError` asks this instead of each caller filtering statuses by
// hand.
//
// NOT the connection notice. It says the server is unreachable, not that this action failed, and it
// removes itself on the next answer — which every caller's own resync refetch fetches at once. Marked
// as reported, a delete that hit a keepalive reset (a 502) flashed the notice for a moment and was
// then reported nowhere at all, the list still there.
//
// A caller that reports a failure itself marks it too (`notifyActionError` does), which is what lets
// the ONE app-wide mutation reporter (lib/queryClient) stay quiet for a mutation whose own `onError`
// already said something, and speak for every one that did not.
const reported = new WeakSet<object>();
export function alreadyReported(err: unknown): boolean {
return typeof err === "object" && err !== null && reported.has(err);
}
/** Record that this failure is on screen now, so nothing reports it a second time. */
export function markReported(err: unknown): void {
if (typeof err === "object" && err !== null) reported.add(err);
}
/** What an upload the USER cancelled rejects with. A cancel is not a failure to report, and the
* only other rejection carrying status 0 is a genuine network drop, which IS worth reporting — so
* the difference has to be a named thing both sides agree on, not a string literal in each. */
const ABORTED = "aborted";
/** True for the rejection of a request the user cancelled (see `ABORTED`). */
export function isAborted(err: unknown): boolean {
return err instanceof HttpError && err.status === 0 && err.detail === ABORTED;
}
export class HttpError extends Error {
status: number;
// The server's reason IN THE READER'S LANGUAGE, when it gave one — i.e. a structured detail run
// through `localizeDetail`, never the raw text of a string detail. Every refusal the app answers is
// structured (backend `app/refusal.py`, enforced by its test_http_details.py); a string can only be
// the framework's own ("Method Not Allowed"), so it is left out rather than shown. That is what
// makes reading `.detail` safe anywhere — including destructured, which no source scan could see.
detail?: string;
detailData?: unknown; // structured detail ({ code, reason, ... }) when the server sent an object
constructor(status: number, detail?: string, detailData?: unknown) {
super(detail || `HTTP ${status}`);
this.status = status;
this.detail = detail;
this.detailData = detailData;
}
}
/** The structured refusal's reason (`{ code, reason }`), when the server gave one. */
export const refusalReason = (err: unknown): string | undefined =>
err instanceof HttpError
? (err.detailData as { reason?: string } | undefined)?.reason
: undefined;
// A single, self-resolving "connection lost" status: while the server is unreachable we
// show one sticky, transient (non-persisted) notice; the moment any request reaches the
// server again we remove it. This makes good on its "clears once it's back" copy and keeps
// bursts of concurrent failures from stacking up — there's only ever one live handle.
let connectivityNotifId: number | null = null;
function markConnectivityLost(): void {
// One live notice — but a notice the user closed is not live: it used to swallow every later
// "connection lost" for the rest of the visit.
const id = connectivityNotifId;
if (id !== null && getActiveToasts().some((n) => n.id === id)) return;
if (id !== null) remove(id);
connectivityNotifId = notify({
level: "error",
title: msg("errors.offline.title"),
message: msg("errors.offline.body"),
transient: true,
});
}
function markConnectivityRestored(): void {
if (connectivityNotifId === null) return;
remove(connectivityNotifId);
connectivityNotifId = null;
}
// Gateway statuses that mean "the upstream connection failed", not "the app rejected the
// request" — typically a reverse-proxy ↔ uvicorn keepalive connection reset before the
// request was processed. Safe to retry an idempotent call once on a fresh connection.
const RETRIABLE_GATEWAY = new Set([502, 503, 504]);
const delay = (ms: number) => new Promise((res) => window.setTimeout(res, ms));
// What a failed answer puts on screen — ONE policy for `req` and `uploadReq` (the upload path kept
// its own copy, and it had drifted: a 502 there opened the blocking "Server error" dialog and
// cleared a connection notice that was still true).
//
// 502/503/504 mean the gateway couldn't reach the app (restarting / connection reset), not a
// request the app refused — the soft, self-clearing connection notice rather than a blocking modal,
// and still the caller's to report as a failed action (see `reported`). A genuine 500 (real fault,
// carries a JSON detail) gets the modal, as do 400/409/422 (validation/conflict). 401 is the app
// shell's; 403/404/429 are caller-handled control flow.
function reportFailure(err: HttpError, quiet: boolean): void {
if (RETRIABLE_GATEWAY.has(err.status)) {
markConnectivityLost();
} else if (err.status === 401) {
// Session ended server-side (e.g. the account was suspended or deleted mid-visit), or this tab
// shows another account than the one signed in now. Let the app shell decide what to do (drop to
// the login page, or reload as the account signed in, if we were signed in). Not a modal — but
// handled all the same, so not the caller's to toast either.
onUnauthorized?.(err);
reported.add(err);
} else if (quiet) {
/* caller handles the error inline — no global modal */
} else if (err.status >= 500) {
reportError(err.detail || `${i18n.t("errors.server")} (${err.status})`);
reported.add(err);
} else if (err.status === 400 || err.status === 409 || err.status === 422) {
reportError(err.detail);
reported.add(err);
}
}
// `idempotent`: may this request be replayed after a transient gateway/network failure?
// GETs always can; non-GET callers opt in (e.g. the player's progress/state writes, which
// are safe to repeat). Used to recover from the keepalive race without surfacing a 502.
export interface ReqConfig {
idempotent?: boolean;
// Return the `Response` itself instead of parsed JSON — for a file (see the return below).
raw?: boolean;
// Suppress the global error modal for 4xx/5xx so the caller can show the error inline
// (used by the auth forms — a wrong password shouldn't pop a blocking dialog).
quiet?: boolean;
}
// Set by the app shell. Invoked whenever any request returns 401 so a session that ended
// server-side (account suspended/deleted while the tab was open) can drop the user to the login
// page. The handler itself guards against firing when we were never signed in, so it's safe to
// call for every 401. (The bootstrap /api/me probe no longer 401s — it returns 200 with a null
// user when logged out — but other protected endpoints still do.)
let onUnauthorized: ((err: HttpError) => void) | null = null;
export function setUnauthorizedHandler(fn: ((err: HttpError) => void) | null): void {
onUnauthorized = fn;
}
// Headers for install-wizard calls: the one-time setup token plus the JSON content type (req
// replaces — not merges — its default headers when opts.headers is given, so include both).
export const setupHeaders = (token: string) => ({
"Content-Type": "application/json",
"X-Setup-Token": token,
});
// The debug session this tab is recording: the server tags its own lines of the request with
// it, and logs them at the session's level. Ignored by the server unless it is the caller's own.
const DEBUG_SESSION_HEADER = "X-Debug-Session";
// The account this tab was built for. The cookie alone says who is asking, and a sign-in as someone
// else in another tab replaces it under this one too: the server refuses (401) a request whose tab
// shows another account than the one signed in, and the 401 reloads the tab as the one who is. It
// never picks an account — a player checkpoint or a pref save from a tab still showing the previous
// account would otherwise land on the new one.
const EXPECTED_ACCOUNT_HEADER = "X-Siftlode-Expect-Account";
/** What this request carries besides the session cookie (which alone says who is asking) — the
* account the tab was built for, the debug session it records into — as the headers every request
* path sends (`req`, `beacon`, `uploadReq`): one definition, so a path cannot quietly go out
* without them. */
export function identityHeaders(): Record<string, string> {
const headers: Record<string, string> = {};
const account = claimedAccount();
if (account) headers[EXPECTED_ACCOUNT_HEADER] = account;
const session = currentDebugSession();
if (session) headers[DEBUG_SESSION_HEADER] = session;
return headers;
}
// Fire-and-forget POST that survives page unload (keepalive) — for every write a closing page must
// still make: the resume checkpoints of the YouTube, Plex and library players, the Plex session stop,
// the diagnostics flush. A normal fetch is cancelled on unload and React effect cleanup doesn't run
// on a full reload, so without keepalive the last position (esp. right after a seek) would be lost.
// `onSettled`: the request has been answered (or failed) — for a caller whose cached view must be
// read again once the write is on the server.
export function beacon(url: string, body: Record<string, unknown>, onSettled?: () => void): void {
try {
void send(url, {
method: "POST",
keepalive: true,
credentials: "include",
headers: { "Content-Type": "application/json", ...identityHeaders() },
body: JSON.stringify(body),
}).then(
() => onSettled?.(),
() => onSettled?.(),
);
} catch {
/* ignore */
}
}
export async function req<T = unknown>(
url: string,
opts: RequestInit = {},
cfg: ReqConfig = {},
): Promise<T> {
const method = opts.method ?? "GET";
const canRetry = cfg.idempotent ?? method === "GET";
let attempt = 0;
for (;;) {
let r: Response;
try {
r = await send(
url,
{
credentials: "include",
headers: {
"Content-Type": "application/json",
// Who the tab is (only setup calls and raw uploads pass their own headers: setup is
// pre-auth, and an upload spreads `identityHeaders()` into its own).
...identityHeaders(),
},
...opts,
},
attempt ? { attempt: attempt + 1 } : {},
);
} catch (e) {
// Network-level failure (incl. a keepalive connection reset surfacing as a TypeError).
if (canRetry && attempt === 0) {
attempt++;
await delay(250);
continue;
}
markConnectivityLost(); // the connection notice; the failed action is still the caller's
throw e;
}
// A gateway error usually means the upstream connection was reset before our request
// was processed — retry idempotent calls once on a fresh connection before surfacing it.
if (!r.ok && canRetry && attempt === 0 && RETRIABLE_GATEWAY.has(r.status)) {
attempt++;
await delay(250);
continue;
}
// The server answered (anything that isn't a gateway error means it's reachable) —
// clear a standing "connection lost" status if one is showing.
if (!RETRIABLE_GATEWAY.has(r.status)) markConnectivityRestored();
if (!r.ok) {
// Capture the server's reason (FastAPI returns `{ detail }`) so callers can react.
let detail: string | undefined;
let detailData: unknown;
try {
const body = await r.json();
// A string detail is not the app's (see `HttpError.detail`) and is not shown.
if (body && body.detail && typeof body.detail === "object") {
detailData = body.detail;
detail = localizeDetail(body.detail); // structured error → localized message
}
} catch {
/* no JSON body */
}
const err = new HttpError(r.status, detail, detailData);
reportFailure(err, !!cfg.quiet);
throw err;
}
// A file (`cfg.raw`): the answer itself, for the caller to read as a blob. For a SMALL generated
// file whose endpoint can refuse (a report, an export): sent as a plain `<a href>` it would
// carry no refusal the app could show — the browser would simply save the error page. A large
// stored file stays a plain navigation (the download centre, the Plex original), so the browser
// streams it to disk instead of buffering gigabytes into the tab.
if (cfg.raw) return r as T;
// The single audited cast in this layer: the server contract is asserted here, once, so
// every typed api.* method (T comes from its declared return type) is checked from here up.
return (r.status === 204 ? null : await r.json()) as T;
}
}
// Upload with progress. `req` rides `fetch`, which can't report upload progress, so a file upload
// uses XHR instead: the caller gets a 0–100% callback as the body streams and an optional
// AbortSignal to cancel a large upload. Everything else mirrors `req` — the identity headers,
// credentials, the structured-detail localization, the global error modal for 4xx/5xx, and an
// `HttpError` throw — so callers handle failures exactly as they do a `req` call.
//
// `body` is a `Blob`/`File` — the bytes themselves, and nothing else. `FormData` was the other
// accepted shape once and is deliberately gone: a multipart part is a DECLARED body, which
// FastAPI spools to disk before the route's auth dependency runs, and the backend dropped
// `python-multipart` altogether, so there is no longer anything on the other end that could parse
// one. `cfg.headers` is how a caller sends the metadata that used to ride as sibling form fields.
//
// Content-Type is never set here: the browser derives it from the Blob's own type, and a wrong one
// is worse than none.
export function uploadReq<T = unknown>(
url: string,
body: Blob,
cfg: {
onProgress?: (pct: number) => void;
signal?: AbortSignal;
headers?: Record<string, string>;
} = {},
): Promise<T> {
return new Promise<T>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", url);
xhr.withCredentials = true;
const requestId = newRequestId();
const started = performance.now();
xhr.setRequestHeader(REQUEST_ID_HEADER, requestId);
// `identityHeaders()` LAST, and LOWER-CASED on the way in: who the request is from is this
// layer's to state, not the caller's. Merged the other way round a caller could silently
// re-attribute the upload; merged case-sensitively, a caller spelling the same header in
// another case would not be replaced but APPENDED — `setRequestHeader` combines values for a
// name that already exists, so the wire would carry both, comma-joined. Header names are
// case-insensitive, so the merge has to be too.
const merged = new Map<string, [string, string]>(); // lower-cased name -> [as spelled, value]
for (const [name, value] of Object.entries(cfg.headers ?? {}))
merged.set(name.toLowerCase(), [name, value]);
for (const [name, value] of Object.entries(identityHeaders()))
merged.set(name.toLowerCase(), [name, value]);
for (const [name, value] of merged.values()) xhr.setRequestHeader(name, value);
if (cfg.onProgress) {
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) cfg.onProgress?.(Math.round((e.loaded / e.total) * 100));
};
}
if (cfg.signal) {
if (cfg.signal.aborted) return reject(new HttpError(0, ABORTED));
cfg.signal.addEventListener("abort", () => xhr.abort());
}
xhr.onload = () => {
logRequest(requestId, "POST", url, started, {
status: xhr.status,
// Test doubles may not implement it; a real XHR always does.
route: xhr.getResponseHeader?.(ROUTE_HEADER),
quiet: xhr.getResponseHeader?.(QUIET_HEADER) === "1",
});
if (!RETRIABLE_GATEWAY.has(xhr.status)) markConnectivityRestored(); // as in `req`
if (xhr.status >= 200 && xhr.status < 300) {
try {
resolve((xhr.responseText ? JSON.parse(xhr.responseText) : null) as T);
} catch {
resolve(null as T);
}
return;
}
let detail: string | undefined;
let detailData: unknown;
try {
const body = JSON.parse(xhr.responseText);
// A string detail is not the app's (see `HttpError.detail`) and is not shown.
if (body && body.detail && typeof body.detail === "object") {
detailData = body.detail;
detail = localizeDetail(body.detail);
}
} catch {
/* no JSON body */
}
const err = new HttpError(xhr.status, detail, detailData);
reportFailure(err, false);
reject(err);
};
// A network-level failure (server unreachable) — same soft "connection lost" status as `req`.
xhr.onerror = () => {
logRequest(requestId, "POST", url, started, { failed: "network" });
markConnectivityLost();
reject(new HttpError(0, i18n.t("errors.offline.body")));
};
xhr.onabort = () => {
logRequest(requestId, "POST", url, started, { failed: "aborted" });
reject(new HttpError(0, ABORTED));
};
xhr.send(body);
});
}