buildmymcpserver/apps/web/lib/api.ts

187 lines
6.8 KiB
TypeScript
Raw Normal View History

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);
}
}