buildmymcpserver/apps/web/lib/api.ts
Marco Sadjadi 3dc65e4f4d
All checks were successful
Deploy to Production / deploy (push) Successful in 1m20s
fix(ux): human-readable API errors instead of raw api_error_NNN codes
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>
2026-05-31 21:08:39 +02:00

187 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 dont have access to that.',
not_found: 'We couldnt find that.',
org_not_found: 'We couldnt find your workspace.',
user_not_found: 'Account not found.',
invalid_input: 'Some details werent valid — please check and try again.',
invalid_id: 'That link looks invalid.',
invalid_query: 'That request wasnt valid.',
rate_limited: 'Youve hit a rate limit — wait a moment and try again.',
plan_limit_reached: 'Youve reached your plans 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 arent configured yet.',
no_active_subscription: 'No active subscription found.',
checkout_failed: 'Checkout couldnt start — please try again in a moment.',
google_oauth_not_configured: 'Google sign-in isnt available right now.',
taken_down: 'This template was taken down and cant be used.',
};
function statusFallback(status: number): string {
if (status === 400) return 'That request wasnt 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 dont have access to that.';
if (status === 404) return 'We couldnt 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);
}
}