const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000'; // Friendly messages for known backend error codes. Anything not listed falls // back to a status-based sentence — the user must never see a raw // "api_error_409" or a bare code like "slug_taken". const CODE_MESSAGES: Record = { slug_taken: 'That slug is already taken by one of your servers — pick a different one.', unauthorized: 'Your session expired — please sign in again.', forbidden: 'You don’t have access to that.', not_found: 'We couldn’t find that.', org_not_found: 'We couldn’t find your workspace.', user_not_found: 'Account not found.', invalid_input: 'Some details weren’t valid — please check and try again.', invalid_id: 'That link looks invalid.', invalid_query: 'That request wasn’t valid.', rate_limited: 'You’ve hit a rate limit — wait a moment and try again.', plan_limit_reached: 'You’ve reached your plan’s limit — upgrade to add more.', subscription_suspended: 'Your subscription is paused — see Settings → Billing.', secret_in_prompt: 'Your prompt looks like it contains an API key — remove it and add credentials in the next step.', stripe_not_configured: 'Payments aren’t configured yet.', no_active_subscription: 'No active subscription found.', checkout_failed: 'Checkout couldn’t start — please try again in a moment.', google_oauth_not_configured: 'Google sign-in isn’t available right now.', taken_down: 'This template was taken down and can’t be used.', }; function statusFallback(status: number): string { if (status === 400) return 'That request wasn’t valid.'; if (status === 401) return 'Your session expired — please sign in again.'; if (status === 402) return 'A billing action is required to continue.'; if (status === 403) return 'You don’t have access to that.'; if (status === 404) return 'We couldn’t find that.'; if (status === 409) return 'That conflicts with something that already exists.'; if (status === 429) return 'Too many requests — please wait a moment.'; if (status >= 500) return 'Something went wrong on our side — please try again.'; return `Request failed (HTTP ${status}).`; } /** * Turn any thrown error (from apiFetch or a network failure) into a clear, * human-readable sentence. Prefers an explicit backend `detail`, then a mapped * error code, then a status-based fallback. Never returns a raw code. */ export function humanizeError(e: unknown): string { const err = e as { detail?: { detail?: string; error?: string }; status?: number; message?: string }; const d = err?.detail; if (d && typeof d.detail === 'string' && d.detail.trim()) return d.detail; if (d?.error) { const mapped = CODE_MESSAGES[d.error]; if (mapped) return mapped; } if (typeof err?.status === 'number') return statusFallback(err.status); if (err?.message && !/^api_error_/.test(err.message)) return err.message; // network / TypeError return 'Something went wrong — please try again.'; } 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('') as Error & { detail?: unknown; status?: number }; err.detail = detail; err.status = res.status; // Human-readable message so any `(e as Error).message` fallback in the UI // shows a real sentence instead of "api_error_409". err.message = humanizeError(err); 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); } }