buildmymcpserver/apps/web/lib/api.ts

129 lines
3.7 KiB
TypeScript
Raw Normal View History

const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000';
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(`api_error_${res.status}`);
(err as unknown as { detail?: unknown }).detail = detail;
(err as unknown as { status?: number }).status = res.status;
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);
}
}