Files
Mangalord/frontend/src/lib/api/client.ts
MechaCat02 f57ca8e45c
Some checks failed
deploy / test-backend (push) Failing after 1m37s
deploy / test-frontend (push) Failing after 16m31s
deploy / build-and-push (push) Has been skipped
deploy / deploy (push) Has been skipped
feat: harden auth, shutdown, and session bundle (0.35.0)
Three features bundled into one release:

- rate-limit /auth/login, /register, /me/password (token bucket,
  5 req/sec sustained with 10-request burst by default; 429 +
  Retry-After header on hit; tracing::warn! per hit so operators
  see attack patterns; AUTH_RATE_PER_SEC / AUTH_RATE_BURST env knobs)
- handle SIGTERM for graceful container stops (replaces bare
  ctrl_c() with a select over ctrl_c + SignalKind::terminate() so
  docker compose stop runs the daemon shutdown path instead of
  letting Chromium leak past SIGKILL)
- clear session.user on 401 from any API call (setOn401Hook in
  api/client.ts, registered from session.svelte.ts gated on
  $app/environment::browser so the SSR bundle never installs it;
  fixes "logged in but no bookmarks/collections" mid-session
  expiry state)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 20:27:21 +02:00

116 lines
3.9 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.
*/
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<T>(path: string, init?: RequestInit): 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.
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<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;
};