claude-code

This commit is contained in:
ashutoshpythoncs@gmail.com
2026-03-31 18:58:05 +05:30
parent a2a44a5841
commit b564857c0b
2148 changed files with 564518 additions and 2 deletions

271
web/lib/api/client.ts Normal file
View File

@@ -0,0 +1,271 @@
import { ApiError } from "./types";
import type { RequestOptions } from "./types";
const DEFAULT_TIMEOUT_MS = 30_000;
const MAX_RETRY_ATTEMPTS = 3;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function getBaseUrl(): string {
return (
(typeof process !== "undefined" && process.env.NEXT_PUBLIC_API_URL) ||
"http://localhost:3001"
);
}
function getApiKey(): string | undefined {
return typeof process !== "undefined"
? process.env.NEXT_PUBLIC_API_KEY
: undefined;
}
function isAbortError(err: unknown): boolean {
return (
err instanceof Error &&
(err.name === "AbortError" || err.message.toLowerCase().includes("aborted"))
);
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function backoffMs(attempt: number): number {
return Math.min(500 * Math.pow(2, attempt), 8_000);
}
/** Combine multiple AbortSignals into one. Aborts if any source aborts. */
function combineSignals(...signals: (AbortSignal | undefined)[]): {
signal: AbortSignal;
cleanup: () => void;
} {
const controller = new AbortController();
const listeners: Array<() => void> = [];
for (const sig of signals) {
if (!sig) continue;
if (sig.aborted) {
controller.abort(sig.reason);
break;
}
const listener = () => controller.abort(sig.reason);
sig.addEventListener("abort", listener, { once: true });
listeners.push(() => sig.removeEventListener("abort", listener));
}
return {
signal: controller.signal,
cleanup: () => listeners.forEach((fn) => fn()),
};
}
async function toApiError(res: Response): Promise<ApiError> {
let message = `Request failed with status ${res.status}`;
try {
const body = (await res.json()) as { error?: string; message?: string };
message = body.error ?? body.message ?? message;
} catch {
// ignore parse errors
}
const type =
res.status === 401
? "auth"
: res.status === 404
? "not_found"
: res.status === 429
? "rate_limit"
: res.status >= 500
? "server"
: "server";
const retryAfterMs =
res.status === 429
? (parseInt(res.headers.get("Retry-After") ?? "60", 10) || 60) * 1_000
: undefined;
return new ApiError(res.status, message, type, retryAfterMs);
}
// ---------------------------------------------------------------------------
// ApiClient
// ---------------------------------------------------------------------------
class ApiClient {
readonly baseUrl: string;
private readonly apiKey: string | undefined;
/** In-flight GET requests keyed by path — for deduplication. */
private readonly inflight = new Map<string, Promise<Response>>();
constructor() {
this.baseUrl = getBaseUrl();
this.apiKey = getApiKey();
}
private buildHeaders(extra?: Record<string, string>): Record<string, string> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
...extra,
};
if (this.apiKey) {
headers["Authorization"] = `Bearer ${this.apiKey}`;
}
return headers;
}
private async fetchWithRetry(
url: string,
init: RequestInit,
attempt = 0
): Promise<Response> {
try {
if (attempt > 0) {
console.debug(`[api] retry ${attempt}/${MAX_RETRY_ATTEMPTS - 1} ${init.method ?? "GET"} ${url}`);
} else {
console.debug(`[api] → ${init.method ?? "GET"} ${url}`);
}
const res = await fetch(url, init);
console.debug(`[api] ← ${res.status} ${url}`);
if (res.status >= 500 && attempt < MAX_RETRY_ATTEMPTS - 1 && !init.signal?.aborted) {
await sleep(backoffMs(attempt));
return this.fetchWithRetry(url, init, attempt + 1);
}
return res;
} catch (err) {
if (!isAbortError(err) && attempt < MAX_RETRY_ATTEMPTS - 1) {
await sleep(backoffMs(attempt));
return this.fetchWithRetry(url, init, attempt + 1);
}
throw err;
}
}
/**
* Core fetch method. Applies auth headers, timeout, and retry logic.
* Pass `timeout: 0` to disable timeout (e.g. for streaming responses).
*/
async request(
path: string,
init: RequestInit & { timeout?: number; extraHeaders?: Record<string, string> } = {}
): Promise<Response> {
const {
timeout = DEFAULT_TIMEOUT_MS,
signal: userSignal,
extraHeaders,
...rest
} = init;
const url = `${this.baseUrl}${path}`;
const headers = this.buildHeaders(extraHeaders);
const timeoutSignals: (AbortSignal | undefined)[] = [userSignal];
let timeoutController: AbortController | undefined;
let timeoutId: ReturnType<typeof setTimeout> | undefined;
if (timeout > 0) {
timeoutController = new AbortController();
timeoutId = setTimeout(() => timeoutController!.abort(), timeout);
timeoutSignals.push(timeoutController.signal);
}
const { signal, cleanup } = combineSignals(...timeoutSignals);
try {
return await this.fetchWithRetry(url, { ...rest, headers, signal }, 0);
} catch (err) {
if (isAbortError(err)) {
const didTimeout = timeoutController?.signal.aborted ?? false;
throw new ApiError(
408,
didTimeout ? "Request timed out" : "Request cancelled",
didTimeout ? "timeout" : "abort"
);
}
throw new ApiError(
0,
err instanceof Error ? err.message : "Network error",
"network"
);
} finally {
cleanup();
if (timeoutId) clearTimeout(timeoutId);
}
}
// ---------------------------------------------------------------------------
// Convenience methods
// ---------------------------------------------------------------------------
/** GET with request deduplication. */
async get<T>(path: string, opts?: RequestOptions): Promise<T> {
const key = path;
const existing = this.inflight.get(key);
if (existing) {
const res = await existing;
if (!res.ok) throw await toApiError(res.clone());
return res.clone().json() as Promise<T>;
}
const promise = this.request(path, { method: "GET", signal: opts?.signal });
this.inflight.set(key, promise);
promise.finally(() => this.inflight.delete(key));
const res = await promise;
if (!res.ok) throw await toApiError(res);
return res.json() as Promise<T>;
}
async post<T>(path: string, body: unknown, opts?: RequestOptions): Promise<T> {
const res = await this.request(path, {
method: "POST",
body: JSON.stringify(body),
signal: opts?.signal,
extraHeaders: opts?.headers,
});
if (!res.ok) throw await toApiError(res);
return res.json() as Promise<T>;
}
async patch<T>(path: string, body: unknown, opts?: RequestOptions): Promise<T> {
const res = await this.request(path, {
method: "PATCH",
body: JSON.stringify(body),
signal: opts?.signal,
});
if (!res.ok) throw await toApiError(res);
return res.json() as Promise<T>;
}
async delete(path: string, opts?: RequestOptions): Promise<void> {
const res = await this.request(path, {
method: "DELETE",
signal: opts?.signal,
});
if (!res.ok) throw await toApiError(res);
}
/**
* POST that returns the raw Response (for streaming). Timeout is disabled
* automatically.
*/
async postStream(
path: string,
body: unknown,
opts?: RequestOptions
): Promise<Response> {
const res = await this.request(path, {
method: "POST",
body: JSON.stringify(body),
signal: opts?.signal,
timeout: 0,
extraHeaders: opts?.headers,
});
if (!res.ok) throw await toApiError(res);
return res;
}
}
export const apiClient = new ApiClient();

View File

@@ -0,0 +1,109 @@
import { ApiError } from "./types";
import { extractTextContent } from "../utils";
import type { Conversation } from "../types";
// Lazy import to avoid circular deps at module init time
function getStore() {
// eslint-disable-next-line @typescript-eslint/no-var-requires
return require("../store").useChatStore as import("../store").UseChatStore;
}
// ---------------------------------------------------------------------------
// ConversationAPI — backed by the Zustand client-side store.
// ---------------------------------------------------------------------------
//
// Conversations are not persisted on the backend; they live in localStorage
// via Zustand persist middleware. This API provides a consistent async
// interface so callers don't need to know about the store directly.
export interface ListOptions {
limit?: number;
offset?: number;
}
export interface CreateOptions {
title?: string;
model?: string;
}
export interface ConversationAPI {
list(options?: ListOptions): Promise<Conversation[]>;
get(id: string): Promise<Conversation>;
create(options?: CreateOptions): Promise<Conversation>;
update(id: string, updates: Partial<Pick<Conversation, "title" | "model">>): Promise<Conversation>;
delete(id: string): Promise<void>;
export(id: string, format: "json" | "markdown"): Promise<Blob>;
}
function findConversation(id: string): Conversation {
const store = getStore();
const conv = store.getState().conversations.find((c) => c.id === id);
if (!conv) throw new ApiError(404, `Conversation ${id} not found`, "not_found");
return conv;
}
export const conversationAPI: ConversationAPI = {
async list(opts) {
const { limit = 20, offset = 0 } = opts ?? {};
const { conversations } = getStore().getState();
return conversations.slice(offset, offset + limit);
},
async get(id) {
return findConversation(id);
},
async create(opts) {
const store = getStore();
const id = store.getState().createConversation();
if (opts?.title || opts?.model) {
store.getState().updateConversation(id, {
...(opts.title ? { title: opts.title } : {}),
...(opts.model ? { model: opts.model } : {}),
});
}
return findConversation(id);
},
async update(id, updates) {
findConversation(id); // throws 404 if missing
getStore().getState().updateConversation(id, updates);
return findConversation(id);
},
async delete(id) {
findConversation(id); // throws 404 if missing
getStore().getState().deleteConversation(id);
},
async export(id, format) {
const conv = findConversation(id);
if (format === "json") {
return new Blob([JSON.stringify(conv, null, 2)], {
type: "application/json",
});
}
// Markdown export
const lines: string[] = [`# ${conv.title}`, ""];
const created = new Date(conv.createdAt).toISOString();
lines.push(`> Exported from Claude Code · ${created}`, "");
for (const msg of conv.messages) {
const heading =
msg.role === "user"
? "**You**"
: msg.role === "assistant"
? "**Claude**"
: `**${msg.role}**`;
lines.push(heading, "");
lines.push(extractTextContent(msg.content), "");
lines.push("---", "");
}
return new Blob([lines.join("\n")], { type: "text/markdown" });
},
};

249
web/lib/api/files.ts Normal file
View File

@@ -0,0 +1,249 @@
import { nanoid } from "nanoid";
import { ApiError } from "./types";
import type {
FileEntry,
SearchResult,
UploadResult,
McpRequest,
McpResponse,
McpToolResult,
} from "./types";
// ---------------------------------------------------------------------------
// MCP JSON-RPC client for the file system tools
// ---------------------------------------------------------------------------
//
// The MCP server exposes tools at POST /mcp (Streamable HTTP transport).
// We maintain a single session per page load and reinitialize if it expires.
function getBaseUrl(): string {
return (
(typeof process !== "undefined" && process.env.NEXT_PUBLIC_API_URL) ||
"http://localhost:3001"
);
}
function getApiKey(): string | undefined {
return typeof process !== "undefined"
? process.env.NEXT_PUBLIC_API_KEY
: undefined;
}
class McpClient {
private sessionId: string | null = null;
private initPromise: Promise<void> | null = null;
private buildHeaders(extra?: Record<string, string>): Record<string, string> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
...extra,
};
const key = getApiKey();
if (key) headers["Authorization"] = `Bearer ${key}`;
if (this.sessionId) headers["mcp-session-id"] = this.sessionId;
return headers;
}
private async doInitialize(): Promise<void> {
const res = await fetch(`${getBaseUrl()}/mcp`, {
method: "POST",
headers: this.buildHeaders(),
body: JSON.stringify({
jsonrpc: "2.0",
id: "init",
method: "initialize",
params: {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "claude-code-web", version: "1.0.0" },
},
} satisfies McpRequest),
});
if (!res.ok) {
this.initPromise = null;
throw new ApiError(res.status, "MCP session initialization failed");
}
this.sessionId = res.headers.get("mcp-session-id");
// Send "initialized" notification (fire-and-forget)
if (this.sessionId) {
fetch(`${getBaseUrl()}/mcp`, {
method: "POST",
headers: this.buildHeaders(),
body: JSON.stringify({
jsonrpc: "2.0",
method: "notifications/initialized",
}),
}).catch(() => {
// non-critical
});
}
}
private initialize(): Promise<void> {
if (!this.initPromise) {
this.initPromise = this.doInitialize().catch((err) => {
this.initPromise = null;
throw err;
});
}
return this.initPromise;
}
/** Parse an SSE-streamed MCP response and return the first result text. */
private async parseSseResponse(res: Response): Promise<string> {
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buffer = "";
let result = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
try {
const json = JSON.parse(line.slice(6)) as McpResponse<McpToolResult>;
if (json.result?.content?.[0]?.text != null) {
result = json.result.content[0].text;
}
} catch {
// skip
}
}
}
} finally {
reader.releaseLock();
}
return result;
}
async callTool(name: string, args: Record<string, unknown>): Promise<string> {
await this.initialize();
const res = await fetch(`${getBaseUrl()}/mcp`, {
method: "POST",
headers: this.buildHeaders(),
body: JSON.stringify({
jsonrpc: "2.0",
id: nanoid(),
method: "tools/call",
params: { name, arguments: args },
} satisfies McpRequest),
});
// Session expired — reinitialize once and retry
if (res.status === 400 || res.status === 404) {
this.sessionId = null;
this.initPromise = null;
await this.initialize();
return this.callTool(name, args);
}
if (!res.ok) {
throw new ApiError(res.status, `MCP tool "${name}" failed`);
}
const contentType = res.headers.get("content-type") ?? "";
if (contentType.includes("text/event-stream")) {
return this.parseSseResponse(res);
}
const json = (await res.json()) as McpResponse<McpToolResult>;
if (json.error) {
throw new ApiError(500, json.error.message);
}
const toolResult = json.result;
if (toolResult?.isError) {
throw new ApiError(500, toolResult.content[0]?.text ?? "Tool error");
}
return toolResult?.content?.[0]?.text ?? "";
}
}
const mcpClient = new McpClient();
// ---------------------------------------------------------------------------
// Parsers
// ---------------------------------------------------------------------------
function parseDirectoryListing(basePath: string, text: string): FileEntry[] {
const entries: FileEntry[] = [];
for (const line of text.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
const isDir = trimmed.endsWith("/");
const name = isDir ? trimmed.slice(0, -1) : trimmed;
const joinedPath = `${basePath.replace(/\/$/, "")}/${name}`;
entries.push({ name, path: joinedPath, type: isDir ? "directory" : "file" });
}
return entries;
}
// Matches: path/to/file.ts:42: some content
const GREP_LINE_RE = /^([^:]+):(\d+):(.*)$/;
function parseSearchResults(text: string): SearchResult[] {
const results: SearchResult[] = [];
for (const line of text.split("\n")) {
const match = GREP_LINE_RE.exec(line.trim());
if (!match) continue;
const [, path, lineStr, content] = match;
results.push({ path, line: parseInt(lineStr, 10), content: content.trim() });
}
return results;
}
// ---------------------------------------------------------------------------
// FileAPI
// ---------------------------------------------------------------------------
export interface ReadOptions {
offset?: number;
limit?: number;
}
export interface SearchOptions {
glob?: string;
}
export interface FileAPI {
list(path: string): Promise<FileEntry[]>;
read(path: string, options?: ReadOptions): Promise<string>;
search(query: string, options?: SearchOptions): Promise<SearchResult[]>;
upload(file: File): Promise<UploadResult>;
}
export const fileAPI: FileAPI = {
async list(path) {
const text = await mcpClient.callTool("list_directory", { path });
return parseDirectoryListing(path, text);
},
async read(path, opts) {
const args: Record<string, unknown> = { path };
if (opts?.offset != null) args.offset = opts.offset;
if (opts?.limit != null) args.limit = opts.limit;
return mcpClient.callTool("read_source_file", args);
},
async search(query, opts) {
const args: Record<string, unknown> = { pattern: query };
if (opts?.glob) args.glob = opts.glob;
const text = await mcpClient.callTool("search_source", args);
return parseSearchResults(text);
},
async upload(_file) {
// The MCP server does not expose a file upload endpoint.
throw new ApiError(501, "File upload is not supported", "server");
},
};

156
web/lib/api/messages.ts Normal file
View File

@@ -0,0 +1,156 @@
import { apiClient } from "./client";
import { parseStream } from "./stream";
import { ApiError } from "./types";
import type { StreamEvent, StreamProgress } from "./types";
import type { Message } from "../types";
import { DEFAULT_MODEL } from "../constants";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Combine any number of AbortSignals into one. */
function combineSignals(...signals: (AbortSignal | undefined)[]): AbortSignal {
const controller = new AbortController();
for (const sig of signals) {
if (!sig) continue;
if (sig.aborted) {
controller.abort(sig.reason);
return controller.signal;
}
sig.addEventListener("abort", () => controller.abort(sig.reason), { once: true });
}
return controller.signal;
}
function toApiMessages(
messages: Message[]
): Array<{ role: string; content: unknown }> {
return messages
.filter((m) => m.role === "user" || m.role === "assistant")
.map((m) => ({ role: m.role, content: m.content }));
}
// ---------------------------------------------------------------------------
// Per-conversation abort controllers (for stop())
// ---------------------------------------------------------------------------
const activeControllers = new Map<string, AbortController>();
// ---------------------------------------------------------------------------
// MessageAPI
// ---------------------------------------------------------------------------
export interface SendOptions {
model?: string;
maxTokens?: number;
files?: File[];
signal?: AbortSignal;
onProgress?: (progress: StreamProgress) => void;
}
export interface MessageAPI {
/**
* Send a user message in a conversation and stream the assistant response.
* The caller is responsible for reading the conversation history from the
* store and providing it in `history`.
*/
send(
conversationId: string,
content: string,
history: Message[],
opts?: SendOptions
): AsyncGenerator<StreamEvent>;
/**
* Retry an assistant message. Sends everything up to and including the
* user message that preceded `messageId`.
*/
retry(
conversationId: string,
messagesUpToAssistant: Message[],
opts?: SendOptions
): AsyncGenerator<StreamEvent>;
/**
* Edit a user message and regenerate the assistant response.
* `historyBefore` should contain only the messages before the edited one.
*/
edit(
conversationId: string,
newContent: string,
historyBefore: Message[],
opts?: SendOptions
): AsyncGenerator<StreamEvent>;
/** Cancel any in-progress stream for this conversation. */
stop(conversationId: string): Promise<void>;
}
async function* streamRequest(
conversationId: string,
body: Record<string, unknown>,
opts?: SendOptions
): AsyncGenerator<StreamEvent> {
// Cancel any existing stream for this conversation
activeControllers.get(conversationId)?.abort();
const controller = new AbortController();
activeControllers.set(conversationId, controller);
const signal = combineSignals(opts?.signal, controller.signal);
try {
const res = await apiClient.postStream("/api/chat", body, { signal });
yield* parseStream(res, { signal, onProgress: opts?.onProgress });
} catch (err) {
if (err instanceof ApiError && err.type === "abort") return; // stop() was called
throw err;
} finally {
if (activeControllers.get(conversationId) === controller) {
activeControllers.delete(conversationId);
}
}
}
export const messageAPI: MessageAPI = {
async *send(conversationId, content, history, opts) {
const model = opts?.model ?? DEFAULT_MODEL;
const messages = [
...toApiMessages(history),
{ role: "user", content },
];
yield* streamRequest(
conversationId,
{ messages, model, stream: true, max_tokens: opts?.maxTokens },
opts
);
},
async *retry(conversationId, messagesUpToAssistant, opts) {
const model = opts?.model ?? DEFAULT_MODEL;
const messages = toApiMessages(messagesUpToAssistant);
yield* streamRequest(
conversationId,
{ messages, model, stream: true },
opts
);
},
async *edit(conversationId, newContent, historyBefore, opts) {
const model = opts?.model ?? DEFAULT_MODEL;
const messages = [
...toApiMessages(historyBefore),
{ role: "user", content: newContent },
];
yield* streamRequest(
conversationId,
{ messages, model, stream: true },
opts
);
},
async stop(conversationId) {
activeControllers.get(conversationId)?.abort();
activeControllers.delete(conversationId);
},
};

228
web/lib/api/stream.ts Normal file
View File

@@ -0,0 +1,228 @@
import { ApiError } from "./types";
import type { StreamEvent, StreamProgress } from "./types";
// ---------------------------------------------------------------------------
// Parse a fetch streaming response body as SSE events
// ---------------------------------------------------------------------------
export interface ParseStreamOptions {
signal?: AbortSignal;
onProgress?: (progress: StreamProgress) => void;
}
/**
* Parses a streaming HTTP response body formatted as server-sent events.
* Yields typed StreamEvent objects as they arrive. Handles burst buffering —
* events are never dropped regardless of how fast they arrive.
*/
export async function* parseStream(
response: Response,
opts?: ParseStreamOptions
): AsyncGenerator<StreamEvent> {
const reader = response.body?.getReader();
if (!reader) throw new ApiError(0, "No response body", "network");
const decoder = new TextDecoder();
let buffer = "";
const startTime = Date.now();
let tokensReceived = 0;
try {
while (true) {
if (opts?.signal?.aborted) break;
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Process all complete lines. The last (potentially incomplete) line
// stays in the buffer.
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (!line.trim()) continue; // blank line = SSE event separator
if (!line.startsWith("data: ")) continue;
const data = line.slice(6).trim();
if (data === "[DONE]") return;
try {
const event = JSON.parse(data) as StreamEvent;
if (
event.type === "content_block_delta" &&
event.delta.type === "text_delta"
) {
tokensReceived += event.delta.text.length;
}
opts?.onProgress?.({
tokensReceived,
elapsedMs: Date.now() - startTime,
isComplete: false,
});
yield event;
} catch {
// skip malformed JSON — keep going
}
}
}
} finally {
reader.releaseLock();
opts?.onProgress?.({
tokensReceived,
elapsedMs: Date.now() - startTime,
isComplete: true,
});
}
}
// ---------------------------------------------------------------------------
// Long-lived SSE connection with automatic reconnection
// ---------------------------------------------------------------------------
export interface SSEConnectionOptions {
onEvent: (event: StreamEvent) => void;
onError: (error: ApiError) => void;
onConnect?: () => void;
onDisconnect?: () => void;
/** Max reconnect attempts before giving up (default: 5) */
maxReconnects?: number;
/** Extra request headers (not supported by EventSource — use fetchSSE instead) */
headers?: Record<string, string>;
}
/**
* Manages a long-lived SSE connection to a URL with automatic exponential
* backoff reconnection. Uses the native EventSource API.
*
* For endpoints that require custom headers, use FetchSSEConnection instead.
*/
export class SSEConnection {
private eventSource: EventSource | null = null;
private reconnectAttempts = 0;
private readonly maxReconnects: number;
private closed = false;
private reconnectTimer: ReturnType<typeof setTimeout> | undefined;
constructor(
private readonly url: string,
private readonly opts: SSEConnectionOptions
) {
this.maxReconnects = opts.maxReconnects ?? 5;
}
connect(): void {
if (this.closed) return;
this.eventSource = new EventSource(this.url);
this.eventSource.onopen = () => {
this.reconnectAttempts = 0;
this.opts.onConnect?.();
};
this.eventSource.onmessage = (e: MessageEvent) => {
if (typeof e.data !== "string") return;
if (e.data === "[DONE]") return;
try {
const event = JSON.parse(e.data as string) as StreamEvent;
this.opts.onEvent(event);
} catch {
// skip malformed events
}
};
this.eventSource.onerror = () => {
this.eventSource?.close();
this.eventSource = null;
this.opts.onDisconnect?.();
this.scheduleReconnect();
};
}
disconnect(): void {
this.closed = true;
clearTimeout(this.reconnectTimer);
this.eventSource?.close();
this.eventSource = null;
}
private scheduleReconnect(): void {
if (this.closed || this.reconnectAttempts >= this.maxReconnects) {
if (!this.closed) {
this.opts.onError(
new ApiError(0, "SSE connection permanently lost", "network")
);
}
return;
}
const delay = Math.min(1_000 * Math.pow(2, this.reconnectAttempts), 30_000);
this.reconnectAttempts++;
this.reconnectTimer = setTimeout(() => this.connect(), delay);
}
}
/**
* SSE connection backed by fetch (supports custom headers and auth).
* Streams an async generator of StreamEvents with reconnection.
*/
export async function* fetchSSE(
url: string,
opts?: {
signal?: AbortSignal;
headers?: Record<string, string>;
maxReconnects?: number;
onProgress?: (progress: StreamProgress) => void;
}
): AsyncGenerator<StreamEvent> {
const maxReconnects = opts?.maxReconnects ?? 5;
let attempt = 0;
while (true) {
if (opts?.signal?.aborted) break;
try {
const res = await fetch(url, {
headers: {
Accept: "text/event-stream",
"Cache-Control": "no-cache",
...opts?.headers,
},
signal: opts?.signal,
});
if (!res.ok) {
throw new ApiError(res.status, `SSE connection failed: ${res.status}`);
}
// Reset attempt counter on successful connection
attempt = 0;
yield* parseStream(res, { signal: opts?.signal, onProgress: opts?.onProgress });
// Clean disconnect — don't reconnect
break;
} catch (err) {
if (err instanceof ApiError && err.type === "abort") break;
if (opts?.signal?.aborted) break;
if (attempt >= maxReconnects) throw err;
const delay = Math.min(1_000 * Math.pow(2, attempt), 30_000);
attempt++;
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(resolve, delay);
opts?.signal?.addEventListener(
"abort",
() => {
clearTimeout(timer);
reject(new ApiError(0, "Cancelled", "abort"));
},
{ once: true }
);
});
}
}
}

124
web/lib/api/types.ts Normal file
View File

@@ -0,0 +1,124 @@
import type { ContentBlock } from "../types";
// ---------------------------------------------------------------------------
// SSE stream events (Anthropic message streaming protocol)
// ---------------------------------------------------------------------------
export type ContentDelta =
| { type: "text_delta"; text: string }
| { type: "input_json_delta"; partial_json: string };
export type StreamEvent =
| {
type: "message_start";
message: {
id: string;
role: string;
model: string;
usage: { input_tokens: number; output_tokens: number };
};
}
| { type: "content_block_start"; index: number; content_block: ContentBlock }
| { type: "content_block_delta"; index: number; delta: ContentDelta }
| { type: "content_block_stop"; index: number }
| {
type: "message_delta";
delta: { stop_reason: string; stop_sequence: string | null };
usage: { output_tokens: number };
}
| { type: "message_stop" }
| { type: "error"; error: { type: string; message: string } }
| { type: "ping" };
// ---------------------------------------------------------------------------
// API errors
// ---------------------------------------------------------------------------
export type ApiErrorType =
| "network"
| "auth"
| "rate_limit"
| "server"
| "timeout"
| "abort"
| "not_found";
export class ApiError extends Error {
constructor(
public readonly status: number,
message: string,
public readonly type: ApiErrorType = "server",
/** Retry-After seconds for rate limit errors */
public readonly retryAfterMs?: number
) {
super(message);
this.name = "ApiError";
}
get isRetryable(): boolean {
return this.type === "network" || this.type === "server" || this.status >= 500;
}
}
// ---------------------------------------------------------------------------
// Shared request / response types
// ---------------------------------------------------------------------------
export interface RequestOptions {
signal?: AbortSignal;
timeout?: number;
headers?: Record<string, string>;
}
export interface StreamProgress {
tokensReceived: number;
elapsedMs: number;
isComplete: boolean;
}
// ---------------------------------------------------------------------------
// File API types
// ---------------------------------------------------------------------------
export interface FileEntry {
name: string;
path: string;
type: "file" | "directory";
size?: number;
modifiedAt?: number;
}
export interface SearchResult {
path: string;
line: number;
content: string;
context?: string[];
}
export interface UploadResult {
path: string;
size: number;
}
// ---------------------------------------------------------------------------
// MCP JSON-RPC types
// ---------------------------------------------------------------------------
export interface McpRequest {
jsonrpc: "2.0";
id?: string | number;
method: string;
params?: Record<string, unknown>;
}
export interface McpResponse<T = unknown> {
jsonrpc: "2.0";
id?: string | number;
result?: T;
error?: { code: number; message: string; data?: unknown };
}
export interface McpToolResult {
content: Array<{ type: "text"; text: string }>;
isError?: boolean;
}