Files
Mangalord/frontend/src/lib/api/client.ts
MechaCat02 0afd202164 fix: let AbortError propagate through the API client
The network-error wrapping added in the error-boundary work also caught
deliberate AbortController cancellations and turned them into a network_error
ApiError, breaking callers that distinguish "cancelled" from "failed" (the
cancellable admin fetches). Rethrow DOMException AbortError unchanged; only
genuine network failures become ApiError(status 0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 15:25:01 +02:00

195 lines
7.2 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.
// 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 `<img src>` 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 15 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<T>(
path: string,
init?: RequestInit,
opts?: RequestOptions
): Promise<T> {
// 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<void>` 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;
};