const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000'; export async function apiFetch(path: string, init: RequestInit = {}): Promise { const res = await fetch(`${API_BASE}${path}`, { credentials: 'include', cache: 'no-store', ...init, headers: { 'Content-Type': 'application/json', ...(init.headers ?? {}), }, }); if (!res.ok) { let detail: unknown = undefined; try { detail = await res.json(); } catch {} const err = new Error(`api_error_${res.status}`); (err as unknown as { detail?: unknown }).detail = detail; (err as unknown as { status?: number }).status = res.status; throw err; } return (await res.json()) as T; } /** Absolute API URL for a path — use for full-page navigations (OAuth redirects). */ export function apiUrl(path: string): string { return `${API_BASE}${path}`; } export function apiWebSocketURL(path: string): string { const httpBase = API_BASE; const wsBase = httpBase.replace(/^http/, 'ws'); return `${wsBase}${path}`; } export interface SseHandlers { onEvent: (event: string, data: unknown) => void; onError?: (err: Error) => void; } /** * POST with SSE response. Used by the streaming preview where Cloudflare's * sync edge timeout (~100s) is fatal — every server-side chunk resets it, * so as long as the model emits text we never hit the cap. * * Native EventSource doesn't support POST or auth-cookie credentials, hence * the manual fetch + ReadableStream consumer. Caller controls aborting via * the supplied AbortSignal (we attach it to the underlying fetch). */ export async function apiSseStream( path: string, body: unknown, handlers: SseHandlers, signal?: AbortSignal, ): Promise { let res: Response; try { res = await fetch(`${API_BASE}${path}`, { method: 'POST', credentials: 'include', cache: 'no-store', headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream', }, body: JSON.stringify(body), signal, }); } catch (err) { handlers.onError?.(err as Error); return; } if (!res.ok) { let detail: unknown = undefined; try { detail = await res.json(); } catch {} const err = new Error(`api_error_${res.status}`); (err as unknown as { detail?: unknown }).detail = detail; (err as unknown as { status?: number }).status = res.status; handlers.onError?.(err); return; } const reader = res.body?.getReader(); if (!reader) { handlers.onError?.(new Error('no_response_body')); return; } const decoder = new TextDecoder(); let buffer = ''; try { while (true) { const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); // SSE events are separated by a blank line. Parse one event at a time. let idx = buffer.indexOf('\n\n'); while (idx >= 0) { const raw = buffer.slice(0, idx); buffer = buffer.slice(idx + 2); let event = 'message'; const dataLines: string[] = []; for (const line of raw.split('\n')) { if (line.startsWith(':')) continue; // SSE comment if (line.startsWith('event:')) event = line.slice(6).trim(); else if (line.startsWith('data:')) dataLines.push(line.slice(5).trim()); } if (dataLines.length > 0) { const data = dataLines.join('\n'); let parsed: unknown = data; try { parsed = JSON.parse(data); } catch { // Treat as a raw string event. } handlers.onEvent(event, parsed); } idx = buffer.indexOf('\n\n'); } } } catch (err) { handlers.onError?.(err as Error); } }