All checks were successful
Deploy to Production / deploy (push) Successful in 1m20s
apiFetch threw new Error(api_error_<status>), so any UI fallback to (e).message showed a cryptic code, and some flows surfaced bare codes like slug_taken. Added a central humanizeError() in lib/api.ts: prefers the backend detail sentence, then a mapped friendly message per known code, then a status-based fallback - never a raw code. apiFetch now sets the thrown error message via it, so every (e).message fallback across the app becomes a real sentence. Wizard analyze/build now use humanizeError directly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
187 lines
6.8 KiB
TypeScript
187 lines
6.8 KiB
TypeScript
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<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.';
|
||
}
|
||
|
||
export async function apiFetch<T = unknown>(path: string, init: RequestInit = {}): Promise<T> {
|
||
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<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);
|
||
}
|
||
}
|