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>
195 lines
7.2 KiB
TypeScript
195 lines
7.2 KiB
TypeScript
// 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 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<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;
|
||
};
|