2026-05-19 00:30:20 +02:00
|
|
|
|
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000';
|
|
|
|
|
|
|
2026-05-31 21:08:39 +02:00
|
|
|
|
// 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<string, string> = {
|
|
|
|
|
|
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.';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 00:26:44 +02:00
|
|
|
|
export async function apiFetch<T = unknown>(path: string, init: RequestInit = {}): Promise<T> {
|
2026-05-19 00:30:20 +02:00
|
|
|
|
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 {}
|
2026-05-31 21:08:39 +02:00
|
|
|
|
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);
|
2026-05-19 00:30:20 +02:00
|
|
|
|
throw err;
|
|
|
|
|
|
}
|
|
|
|
|
|
return (await res.json()) as T;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 00:26:44 +02:00
|
|
|
|
/** Absolute API URL for a path — use for full-page navigations (OAuth redirects). */
|
|
|
|
|
|
export function apiUrl(path: string): string {
|
|
|
|
|
|
return `${API_BASE}${path}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-19 00:30:20 +02:00
|
|
|
|
export function apiWebSocketURL(path: string): string {
|
|
|
|
|
|
const httpBase = API_BASE;
|
|
|
|
|
|
const wsBase = httpBase.replace(/^http/, 'ws');
|
|
|
|
|
|
return `${wsBase}${path}`;
|
|
|
|
|
|
}
|
2026-05-28 21:11:05 +02:00
|
|
|
|
|
|
|
|
|
|
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<void> {
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|