const BASE = '/api'; const CSRF_HEADER = 'X-Nyx-CSRF'; let csrfTokenPromise: Promise | null = null; export class ApiError extends Error { /** * Stable machine-readable code (matches backend `ApiError`'s `code` field). * Falls back to a synthetic value when the response was not structured, * `network` for fetch failures, `http_` for plain-text responses. */ public code: string; public detail?: unknown; constructor( status: number, message: string, code?: string, detail?: unknown, ) { super(message); this.name = 'ApiError'; this.status = status; this.code = code ?? `http_${status}`; this.detail = detail; } public status: number; /** True when the failure was a network/abort, not an HTTP response. */ isNetwork(): boolean { return this.status === 0; } } /** Build an ApiError from a non-OK Response, parsing a JSON error body if present. */ async function errorFromResponse(res: Response): Promise { const text = await res.text().catch(() => ''); if (text) { try { const parsed = JSON.parse(text) as { error?: unknown; code?: unknown; detail?: unknown; }; const msg = typeof parsed.error === 'string' && parsed.error.length > 0 ? parsed.error : res.statusText || `HTTP ${res.status}`; const code = typeof parsed.code === 'string' ? parsed.code : undefined; return new ApiError(res.status, msg, code, parsed.detail); } catch { // Plain-text body, use as-is. return new ApiError(res.status, text); } } return new ApiError(res.status, res.statusText || `HTTP ${res.status}`); } async function getCsrfToken(): Promise { if (!csrfTokenPromise) { csrfTokenPromise = fetch(`${BASE}/session`) .then(async (res) => { if (!res.ok) { throw await errorFromResponse(res); } const text = await res.text(); const payload = text ? (JSON.parse(text) as { csrf_token?: unknown }) : {}; if ( typeof payload.csrf_token !== 'string' || payload.csrf_token.length === 0 ) { throw new ApiError(500, 'Missing CSRF token', 'missing_csrf_token'); } return payload.csrf_token; }) .catch((error) => { csrfTokenPromise = null; throw error; }); } return csrfTokenPromise; } function isMutatingMethod(method?: string): boolean { const upper = (method || 'GET').toUpperCase(); return ( upper === 'POST' || upper === 'PUT' || upper === 'PATCH' || upper === 'DELETE' ); } async function request(path: string, opts: RequestInit = {}): Promise { const { headers: rawHeaders, ...rest } = opts; const url = `${BASE}${path}`; const headers: Record = { ...(rawHeaders as Record), }; if (isMutatingMethod(rest.method)) { headers[CSRF_HEADER] = await getCsrfToken(); } if (opts.body) { headers['Content-Type'] = 'application/json'; } let res: Response; try { res = await fetch(url, { ...rest, headers, }); } catch (err) { if (err instanceof DOMException && err.name === 'AbortError') { throw err; } const message = err instanceof Error ? err.message : 'Network request failed'; throw new ApiError(0, message, 'network'); } if (!res.ok) { throw await errorFromResponse(res); } // Handle empty responses const text = await res.text(); if (!text) return undefined as T; return JSON.parse(text) as T; } export function apiGet(path: string, signal?: AbortSignal): Promise { return request(path, { signal }); } export function apiPost( path: string, body?: unknown, signal?: AbortSignal, ): Promise { return request(path, { method: 'POST', body: body != null ? JSON.stringify(body) : undefined, signal, }); } export function apiPut( path: string, body?: unknown, signal?: AbortSignal, ): Promise { return request(path, { method: 'PUT', body: body != null ? JSON.stringify(body) : undefined, signal, }); } export function apiDelete( path: string, body?: unknown, signal?: AbortSignal, ): Promise { return request(path, { method: 'DELETE', body: body != null ? JSON.stringify(body) : undefined, signal, }); }