// All backend calls go through this module. Components and routes import // the typed helpers below — they do not call fetch directly. const BASE = import.meta.env?.VITE_API_BASE ?? '/api'; /** * Builds an absolute URL to the streaming `/files/{key}` endpoint so * components can use it directly in `` etc., without * reconstructing the API base in each call site. * * Storage keys are `/`-separated paths, so each segment is percent-encoded * individually — the slashes stay literal path separators while any * reserved character inside a segment (`?`, `#`, `%`, space, …) can't be * reinterpreted as a query/fragment delimiter. Keys are backend-generated * today, so this is defence-in-depth against a future key source. */ export function fileUrl(key: string): string { const encoded = key.split('/').map(encodeURIComponent).join('/'); return `${BASE}/v1/files/${encoded}`; } /** * URL to a width-bounded thumbnail variant of a stored image (the backend * `/files/{key}?w=` endpoint). Grids use this so a cover downloads ~KB instead * of the 1–5 MB original; the backend snaps `width` to a small allow-list and * caches the result. */ export function thumbUrl(key: string, width: number): string { return `${fileUrl(key)}?w=${width}`; } /** * A `srcset` string over the candidate `widths` for `key`, e.g. * `.../files/k?w=320 320w, .../files/k?w=480 480w`. Pair with a `sizes` * attribute so the browser picks the right variant for the rendered size. */ export function thumbSrcset(key: string, widths: number[]): string { return widths.map((w) => `${thumbUrl(key, w)} ${w}w`).join(', '); } /** * Builds an API URL for non-`fetch` consumers (e.g. `EventSource` for SSE), * applying the same `VITE_API_BASE` prefix as `request()`. `path` is the * route after the base, e.g. `/v1/admin/crawler/stream`. */ export function apiUrl(path: string): string { return `${BASE}${path}`; } export class ApiError extends Error { constructor( public readonly status: number, public readonly code: string, message: string, /** The error envelope's `details` payload, when present (e.g. the * per-field `{ fields: [...] }` of a `validation_failed` response). */ public readonly details?: unknown ) { super(message); this.name = 'ApiError'; } } type ErrorEnvelope = { error?: { code?: unknown; message?: unknown; details?: unknown }; }; /** * Optional hook fired the first moment `request()` observes a 401 on * any endpoint. Used by the session store to clear the cached user * when the server reports the session is no longer valid (expired * cookie, rotated server-side, password changed on another device). * * Set to `null` (or `undefined`) to disable. Tests that don't want * the side effect should leave it unset. */ let on401Hook: (() => void) | null = null; export function setOn401Hook(handler: (() => void) | null): void { on401Hook = handler; } /** Per-call knobs that don't belong on the native `RequestInit`. */ export type RequestOptions = { /** * Skip the module-level 401 hook for this call. Used by endpoints * where a 401 does *not* mean "session expired" — e.g. the * change-password endpoint returns 401 for a wrong *current* * password while the caller is still fully authenticated. Firing * the hook there would clear the cached user and bounce them to * /login instead of surfacing the error inline. */ suppressOn401?: boolean; }; export async function request( path: string, init?: RequestInit, opts?: RequestOptions ): Promise { // Forward credentials (session cookie) explicitly so cross-origin // deployments — those configured via CORS_ALLOWED_ORIGINS — keep // working. For same-origin requests this is a no-op compared to the // default 'same-origin', so the same-origin happy path is // unchanged. let res: Response; try { res = await fetch(`${BASE}${path}`, { credentials: 'include', ...init }); } catch (e) { // A deliberate cancellation (AbortController) must propagate unchanged so // callers can tell "cancelled" from "network failure" (e.g. the // cancellable admin fetches treat AbortError as a no-op). if (e instanceof DOMException && e.name === 'AbortError') throw e; // Any other rejection is a network-level failure (connection refused, // DNS, offline, blocked by CORS): `fetch` rejects with a bare TypeError // instead of returning a response. Normalise it to an ApiError (status 0 // = "no response reached us") so callers and SvelteKit load functions // handle it uniformly instead of crashing on an unexpected TypeError. throw new ApiError( 0, 'network_error', 'Could not reach the server. Check your connection and try again.' ); } if (!res.ok) { let code = 'http_error'; let message = `${res.status} ${res.statusText}`; let details: unknown; const ct = res.headers.get('content-type') ?? ''; try { if (ct.includes('application/json')) { const body = (await res.json()) as ErrorEnvelope; if (body?.error) { if (typeof body.error.code === 'string' && body.error.code) { code = body.error.code; } if (typeof body.error.message === 'string' && body.error.message) { message = body.error.message; } if (body.error.details !== undefined) { details = body.error.details; } } } else { const text = await res.text(); if (text) message = text; } } catch { // Body wasn't parseable; keep the http_error fallback. } if (res.status === 401 && on401Hook && !opts?.suppressOn401) { // Fire before throwing so the session store updates even // if the caller swallows the ApiError (e.g. the *OrEmpty // wrappers used by guest-rendering pages). try { on401Hook(); } catch (e) { console.error('on401 hook threw:', e); } } throw new ApiError(res.status, code, message, details); } // Any empty body (not just 204) returns undefined — the manga-add // endpoint, for instance, signals create-vs-already-present via // 201/200 with no body, and callers typed `request` would // otherwise blow up on `res.json()` parsing an empty string. if (res.status === 204) { return undefined as T; } const text = await res.text(); if (!text) { return undefined as T; } return JSON.parse(text) as T; } export type Manga = { id: string; title: string; status: MangaStatus; alt_titles: string[]; description: string | null; cover_image_path: string | null; created_at: string; updated_at: string; }; export type MangaStatus = 'ongoing' | 'completed'; export type Page = { limit: number; offset: number; total: number | null; };