From 49f8c57c89eab831f4dedd04ff6dd422d725b49b Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 27 Jul 2026 23:08:48 +0200 Subject: [PATCH] =?UTF-8?q?refactor(api):=20extract=20the=20shared=20HTTP?= =?UTF-8?q?=20machinery=20to=20api/core.ts=20(R7=20S3b=C2=B71)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First step of splitting the api.ts god module behind its façade. Move req, HttpError, localizeDetail, the connectivity toast, ReqConfig, the per-tab active-account plumbing (getActiveAccount/setActiveAccount/clearActiveAccount/ beacon), setupHeaders, and setUnauthorizedHandler into lib/api/core.ts. api.ts imports the machinery it uses and re-exports the public surface, so the 53 call sites importing from "./api" are unchanged. api.ts 1793 -> 1583 lines; no runtime change (pure move). Gate green: typecheck, eslint, prettier, 74 vitest. --- frontend/src/lib/api.ts | 249 +++-------------------------------- frontend/src/lib/api/core.ts | 232 ++++++++++++++++++++++++++++++++ 2 files changed, 251 insertions(+), 230 deletions(-) create mode 100644 frontend/src/lib/api/core.ts diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 0ee8e16..2f01f17 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,7 +1,22 @@ -import { notify, remove } from "./notifications"; -import i18n from "../i18n"; -import { reportError } from "./errorDialog"; -import { activeAccountId, ACTIVE_ACCOUNT_KEY } from "./storage"; +import { + req, + beacon, + setupHeaders, + getActiveAccount, + HttpError, + setActiveAccount, + clearActiveAccount, + setUnauthorizedHandler, +} from "./api/core"; + +// Re-export the shared HTTP machinery so consumers keep importing these from "./api" unchanged. +export { + HttpError, + getActiveAccount, + setActiveAccount, + clearActiveAccount, + setUnauthorizedHandler, +}; export interface Me { id: number; @@ -219,230 +234,6 @@ export interface VersionInfo { db_revision: string | null; } -class HttpError extends Error { - status: number; - detail?: string; // server-provided reason (FastAPI's `detail`), when present - 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; - } -} - -// Localize a STRUCTURED server error ({ code, reason, ... }) into the user's language. FastAPI -// usually returns a plain string `detail`, but some routes (e.g. download quota/edit) return a -// structured object with an i18n reason key + numbers so HU/DE users don't get hardcoded English -// with a dot-decimal separator. Falls back to a generic message for unknown shapes. -function localizeDetail(d: unknown): string { - const t = i18n.t.bind(i18n); - // FastAPI request-validation errors: `detail` is an ARRAY of { loc, msg, type }. The UI sends - // well-formed requests, so this only fires on genuinely malformed input. The per-field `msg` and - // `loc` field names are English, snake_case internal tokens — surface a clean, fully localized - // "check your input" message rather than leaking them into a translated sentence. - if (Array.isArray(d)) return t("errors.validation"); - const o = d as { code?: string; reason?: string; limit?: number } | null | undefined; - if (o?.code === "quota") { - if (o.reason === "max_bytes") { - const gb = new Intl.NumberFormat(i18n.language, { maximumFractionDigits: 1 }).format( - (o.limit || 0) / 1_073_741_824, - ); - return t("downloads.errors.quota.max_bytes", { size: gb }); - } - if (o.reason === "max_jobs") return t("downloads.errors.quota.max_jobs", { limit: o.limit }); - return t("downloads.errors.quota.generic"); - } - if (o?.code === "edit") - return t(`downloads.errors.edit.${o.reason}`, t("downloads.errors.edit.generic")); - return t("errors.generic"); -} - -// 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 { - if (connectivityNotifId !== null) return; - connectivityNotifId = notify({ - level: "error", - title: i18n.t("errors.offline.title"), - message: i18n.t("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)); - -// `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. -interface ReqConfig { - idempotent?: 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: (() => void) | null = null; -export function setUnauthorizedHandler(fn: (() => 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). -const setupHeaders = (token: string) => ({ - "Content-Type": "application/json", - "X-Setup-Token": token, -}); - -// --- Per-tab active account -------------------------------------------------------------- -// The signed session cookie is the browser's account "wallet"; which account a given TAB acts -// as is chosen here and sent per-request, so two tabs can run two accounts at once. Stored in -// sessionStorage (per-tab, survives F5, not shared across tabs). null = use the session default. -// The storage key + reader live in storage.ts (so its per-account helpers can use them too). -const ACTIVE_ACCOUNT_HEADER = "X-Siftlode-Account"; - -export const getActiveAccount = activeAccountId; - -// Fire-and-forget POST that survives page unload (keepalive) — shared by the resume-position -// beacons (YT + Plex players). 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. -function beacon(url: string, body: Record): void { - const active = getActiveAccount(); - try { - void fetch(url, { - method: "POST", - keepalive: true, - credentials: "include", - headers: { - "Content-Type": "application/json", - ...(active != null ? { [ACTIVE_ACCOUNT_HEADER]: String(active) } : {}), - }, - body: JSON.stringify(body), - }).catch(() => {}); - } catch { - /* ignore */ - } -} - -export function setActiveAccount(id: number): void { - try { - sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, String(id)); - } catch { - /* ignore */ - } -} -export function clearActiveAccount(): void { - try { - sessionStorage.removeItem(ACTIVE_ACCOUNT_KEY); - } catch { - /* ignore */ - } -} - -async function req( - url: string, - opts: RequestInit = {}, - cfg: ReqConfig = {}, -): Promise { - const method = opts.method ?? "GET"; - const canRetry = cfg.idempotent ?? method === "GET"; - let attempt = 0; - - for (;;) { - let r: Response; - try { - const active = getActiveAccount(); - r = await fetch(url, { - credentials: "include", - headers: { - "Content-Type": "application/json", - // Per-tab identity override (only setup calls pass their own headers, and those are - // pre-auth, so this is safe to put in the defaults that `...opts` may replace). - ...(active != null ? { [ACTIVE_ACCOUNT_HEADER]: String(active) } : {}), - }, - ...opts, - }); - } catch (e) { - // Network-level failure (incl. a keepalive connection reset surfacing as a TypeError). - if (canRetry && attempt === 0) { - attempt++; - await delay(250); - continue; - } - markConnectivityLost(); - 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(); - if (body && typeof body.detail === "string") detail = body.detail; - else if (body && body.detail && typeof body.detail === "object") { - detailData = body.detail; - detail = localizeDetail(body.detail); // structured error → localized message - } - } catch { - /* no JSON body */ - } - // 502/503/504 mean the gateway couldn't reach the app (restarting / connection reset), - // not a request the app refused — treat them like a network blip with the soft, - // self-clearing toast rather than a blocking modal the user must acknowledge. - // A genuine 500 (real fault, carries a JSON detail) still gets the modal, as do - // 400/409/422 (validation/conflict). 401/403/404 are caller-handled control flow. - if (RETRIABLE_GATEWAY.has(r.status)) { - markConnectivityLost(); - } else if (r.status === 401) { - // Session ended server-side (e.g. the account was suspended or deleted mid-visit). Let the - // app shell decide what to do (drop to the login page if we were signed in). Not a modal. - onUnauthorized?.(); - } else if (cfg.quiet) { - /* caller handles the error inline — no global modal */ - } else if (r.status >= 500) { - reportError(detail || `${i18n.t("errors.server")} (${r.status})`); - } else if (r.status === 400 || r.status === 409 || r.status === 422) { - reportError(detail); - } - throw new HttpError(r.status, detail, detailData); - } - // 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; - } -} - // The filter half of the query (everything except paging/sort), shared by the feed and // the facet-count endpoint so both see exactly the same filter context. function filterParams(f: FeedFilters): URLSearchParams { @@ -1789,5 +1580,3 @@ export const api = { adminResetDownloadQuota: (userId: number): Promise => req(`/api/admin/downloads/quota/${userId}`, { method: "DELETE" }), }; - -export { HttpError }; diff --git a/frontend/src/lib/api/core.ts b/frontend/src/lib/api/core.ts new file mode 100644 index 0000000..815dae6 --- /dev/null +++ b/frontend/src/lib/api/core.ts @@ -0,0 +1,232 @@ +// The shared HTTP machinery behind every api.* domain module: the fetch wrapper (`req`), the typed +// error, structured-error localization, the connectivity toast, the per-tab active-account plumbing, +// 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 { notify, remove } from "../notifications"; +import i18n from "../../i18n"; +import { reportError } from "../errorDialog"; +import { activeAccountId, ACTIVE_ACCOUNT_KEY } from "../storage"; + +export class HttpError extends Error { + status: number; + detail?: string; // server-provided reason (FastAPI's `detail`), when present + 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; + } +} + +// Localize a STRUCTURED server error ({ code, reason, ... }) into the user's language. FastAPI +// usually returns a plain string `detail`, but some routes (e.g. download quota/edit) return a +// structured object with an i18n reason key + numbers so HU/DE users don't get hardcoded English +// with a dot-decimal separator. Falls back to a generic message for unknown shapes. +function localizeDetail(d: unknown): string { + const t = i18n.t.bind(i18n); + // FastAPI request-validation errors: `detail` is an ARRAY of { loc, msg, type }. The UI sends + // well-formed requests, so this only fires on genuinely malformed input. The per-field `msg` and + // `loc` field names are English, snake_case internal tokens — surface a clean, fully localized + // "check your input" message rather than leaking them into a translated sentence. + if (Array.isArray(d)) return t("errors.validation"); + const o = d as { code?: string; reason?: string; limit?: number } | null | undefined; + if (o?.code === "quota") { + if (o.reason === "max_bytes") { + const gb = new Intl.NumberFormat(i18n.language, { maximumFractionDigits: 1 }).format( + (o.limit || 0) / 1_073_741_824, + ); + return t("downloads.errors.quota.max_bytes", { size: gb }); + } + if (o.reason === "max_jobs") return t("downloads.errors.quota.max_jobs", { limit: o.limit }); + return t("downloads.errors.quota.generic"); + } + if (o?.code === "edit") + return t(`downloads.errors.edit.${o.reason}`, t("downloads.errors.edit.generic")); + return t("errors.generic"); +} + +// 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 { + if (connectivityNotifId !== null) return; + connectivityNotifId = notify({ + level: "error", + title: i18n.t("errors.offline.title"), + message: i18n.t("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)); + +// `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; + // 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: (() => void) | null = null; +export function setUnauthorizedHandler(fn: (() => 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, +}); + +// --- Per-tab active account -------------------------------------------------------------- +// The signed session cookie is the browser's account "wallet"; which account a given TAB acts +// as is chosen here and sent per-request, so two tabs can run two accounts at once. Stored in +// sessionStorage (per-tab, survives F5, not shared across tabs). null = use the session default. +// The storage key + reader live in storage.ts (so its per-account helpers can use them too). +const ACTIVE_ACCOUNT_HEADER = "X-Siftlode-Account"; + +export const getActiveAccount = activeAccountId; + +// Fire-and-forget POST that survives page unload (keepalive) — shared by the resume-position +// beacons (YT + Plex players). 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. +export function beacon(url: string, body: Record): void { + const active = getActiveAccount(); + try { + void fetch(url, { + method: "POST", + keepalive: true, + credentials: "include", + headers: { + "Content-Type": "application/json", + ...(active != null ? { [ACTIVE_ACCOUNT_HEADER]: String(active) } : {}), + }, + body: JSON.stringify(body), + }).catch(() => {}); + } catch { + /* ignore */ + } +} + +export function setActiveAccount(id: number): void { + try { + sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, String(id)); + } catch { + /* ignore */ + } +} +export function clearActiveAccount(): void { + try { + sessionStorage.removeItem(ACTIVE_ACCOUNT_KEY); + } catch { + /* ignore */ + } +} + +export async function req( + url: string, + opts: RequestInit = {}, + cfg: ReqConfig = {}, +): Promise { + const method = opts.method ?? "GET"; + const canRetry = cfg.idempotent ?? method === "GET"; + let attempt = 0; + + for (;;) { + let r: Response; + try { + const active = getActiveAccount(); + r = await fetch(url, { + credentials: "include", + headers: { + "Content-Type": "application/json", + // Per-tab identity override (only setup calls pass their own headers, and those are + // pre-auth, so this is safe to put in the defaults that `...opts` may replace). + ...(active != null ? { [ACTIVE_ACCOUNT_HEADER]: String(active) } : {}), + }, + ...opts, + }); + } catch (e) { + // Network-level failure (incl. a keepalive connection reset surfacing as a TypeError). + if (canRetry && attempt === 0) { + attempt++; + await delay(250); + continue; + } + markConnectivityLost(); + 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(); + if (body && typeof body.detail === "string") detail = body.detail; + else if (body && body.detail && typeof body.detail === "object") { + detailData = body.detail; + detail = localizeDetail(body.detail); // structured error → localized message + } + } catch { + /* no JSON body */ + } + // 502/503/504 mean the gateway couldn't reach the app (restarting / connection reset), + // not a request the app refused — treat them like a network blip with the soft, + // self-clearing toast rather than a blocking modal the user must acknowledge. + // A genuine 500 (real fault, carries a JSON detail) still gets the modal, as do + // 400/409/422 (validation/conflict). 401/403/404 are caller-handled control flow. + if (RETRIABLE_GATEWAY.has(r.status)) { + markConnectivityLost(); + } else if (r.status === 401) { + // Session ended server-side (e.g. the account was suspended or deleted mid-visit). Let the + // app shell decide what to do (drop to the login page if we were signed in). Not a modal. + onUnauthorized?.(); + } else if (cfg.quiet) { + /* caller handles the error inline — no global modal */ + } else if (r.status >= 500) { + reportError(detail || `${i18n.t("errors.server")} (${r.status})`); + } else if (r.status === 400 || r.status === 409 || r.status === 422) { + reportError(detail); + } + throw new HttpError(r.status, detail, detailData); + } + // 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; + } +}