const BASE = '/api'; const CSRF_HEADER = 'X-Nyx-CSRF'; let csrfTokenPromise: Promise | null = null; export class ApiError extends Error { constructor( public status: number, message: string, ) { super(message); this.name = 'ApiError'; } } async function getCsrfToken(): Promise { if (!csrfTokenPromise) { csrfTokenPromise = fetch(`${BASE}/session`) .then(async (res) => { if (!res.ok) { throw new ApiError( res.status, await res.text().catch(() => res.statusText), ); } 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'); } 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'; } const res = await fetch(url, { ...rest, headers, }); if (!res.ok) { const text = await res.text().catch(() => res.statusText); throw new ApiError(res.status, text); } // 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 apiDelete(path: string, signal?: AbortSignal): Promise { return request(path, { method: 'DELETE', signal }); }