// 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 } }; 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. } 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; };