// Empty default = same-origin relative URLs. // In prod Caddy proxies /api/* to backend; in dev Next.js rewrites do the same. export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? ""; export async function apiFetch( path: string, init?: RequestInit, ): Promise { const response = await fetch(`${API_BASE_URL}${path}`, { ...init, headers: { "Content-Type": "application/json", ...(init?.headers ?? {}), }, }); if (!response.ok) { throw new Error(`API error ${response.status}: ${await response.text()}`); } return response.json() as Promise; } /** * Status-aware variant of apiFetch — возвращает status + body для случаев * когда логика зависит от HTTP кода (например 202 Accepted для async jobs). * * Throws на 4xx/5xx (используя HTTPError ниже); 2xx коды все ОК. */ export class HTTPError extends Error { status: number; body: unknown; constructor(status: number, body: unknown, msg?: string) { super(msg ?? `API error ${status}`); this.status = status; this.body = body; } } export async function apiFetchWithStatus( path: string, init?: RequestInit, ): Promise<{ status: number; body: T }> { const response = await fetch(`${API_BASE_URL}${path}`, { ...init, headers: { "Content-Type": "application/json", ...(init?.headers ?? {}), }, }); let body: unknown = null; // 204 No Content имеет пустое тело; для всех остальных читаем как text один раз // (fetch body stream consumable только один раз — нельзя сначала .json() потом .text()). if (response.status !== 204) { const rawText = await response.text(); if (rawText) { try { body = JSON.parse(rawText); } catch { // Не JSON (HTML error page от Caddy / 502 / etc) — оборачиваем в detail body = { detail: rawText }; } } } if (!response.ok) { const detail = (body as { detail?: string })?.detail ?? `API error ${response.status}`; throw new HTTPError(response.status, body, detail); } return { status: response.status, body: body as T }; }