export class ApiError extends Error { status: number; details?: unknown; constructor(message: string, status: number, details?: unknown) { super(message); this.name = 'ApiError'; this.status = status; this.details = details; } } export interface FetchJsonOptions extends Omit { body?: unknown; timeoutMs?: number; } const API_BASE = import.meta.env.PUBLIC_API_URL || 'http://localhost:8001/api/'; function buildUrl(endpoint: string): string { return new URL(endpoint.replace(/^\/+/, ''), API_BASE).toString(); } function normalizeHeaders(headers: HeadersInit | undefined): Record { if (!headers) return {}; if (headers instanceof Headers) { return Object.fromEntries(headers.entries()); } if (Array.isArray(headers)) { return Object.fromEntries(headers); } return { ...headers }; } function createTimeoutSignal(signal: AbortSignal | null | undefined, timeoutMs: number): { signal: AbortSignal; clear: () => void; } { const timeoutController = new AbortController(); const timer = setTimeout(() => timeoutController.abort(), timeoutMs); const clear = () => { clearTimeout(timer); }; if (signal) { signal.addEventListener( 'abort', () => { clear(); timeoutController.abort(); }, { once: true } ); } return { signal: timeoutController.signal, clear }; } export async function fetchJson(endpoint: string, options: FetchJsonOptions = {}): Promise { const { body, headers, timeoutMs = 10000, signal, ...rest } = options; const requestHeaders: Record = { Accept: 'application/json', ...normalizeHeaders(headers), }; let requestBody: BodyInit | undefined; if (body !== undefined && body !== null) { requestHeaders['Content-Type'] = 'application/json'; requestBody = JSON.stringify(body); } const timeout = createTimeoutSignal(signal, timeoutMs); let response: Response; try { response = await fetch(buildUrl(endpoint), { ...rest, headers: requestHeaders, body: requestBody, signal: timeout.signal, }); } finally { timeout.clear(); } if (!response.ok) { let details: unknown = null; try { details = await response.json(); } catch { details = await response.text().catch(() => null); } const message = typeof details === 'object' && details && 'detail' in details ? String((details as { detail: unknown }).detail) : `API request failed (${response.status})`; throw new ApiError(message, response.status, details); } if (response.status === 204) { return null as T; } return (await response.json()) as T; }