// 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. */ export function fileUrl(key: string): string { return `${BASE}/v1/files/${key}`; } export class ApiError extends Error { constructor( public readonly status: number, public readonly code: string, message: string ) { super(message); this.name = 'ApiError'; } } type ErrorEnvelope = { error?: { code?: unknown; message?: 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; } export async function request(path: string, init?: RequestInit): 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. const res = await fetch(`${BASE}${path}`, { credentials: 'include', ...init }); if (!res.ok) { let code = 'http_error'; let message = `${res.status} ${res.statusText}`; 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; } } } 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) { // 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); } // 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; };