import { URL } from "node:url"; export class ApiRequestError extends Error { status: number; details?: unknown; body?: unknown; constructor(status: number, message: string, details?: unknown, body?: unknown) { super(message); this.status = status; this.details = details; this.body = body; } } export class ApiConnectionError extends Error { url: string; method: string; causeMessage?: string; constructor(input: { apiBase: string; path: string; method: string; cause?: unknown; }) { const url = buildUrl(input.apiBase, input.path); const causeMessage = formatConnectionCause(input.cause); super(buildConnectionErrorMessage({ apiBase: input.apiBase, url, method: input.method, causeMessage })); this.url = url; this.method = input.method; this.causeMessage = causeMessage; } } interface RequestOptions { ignoreNotFound?: boolean; } interface RecoverAuthInput { path: string; method: string; error: ApiRequestError; } interface ApiClientOptions { apiBase: string; apiKey?: string; runId?: string; recoverAuth?: (input: RecoverAuthInput) => Promise; } export class PaperclipApiClient { readonly apiBase: string; apiKey?: string; readonly runId?: string; readonly recoverAuth?: (input: RecoverAuthInput) => Promise; constructor(opts: ApiClientOptions) { this.apiBase = opts.apiBase.replace(/\/+$/, ""); this.apiKey = opts.apiKey?.trim() || undefined; this.runId = opts.runId?.trim() || undefined; this.recoverAuth = opts.recoverAuth; } get(path: string, opts?: RequestOptions): Promise { return this.request(path, { method: "GET" }, opts); } post(path: string, body?: unknown, opts?: RequestOptions): Promise { return this.request(path, { method: "POST", body: body === undefined ? undefined : JSON.stringify(body), }, opts); } patch(path: string, body?: unknown, opts?: RequestOptions): Promise { return this.request(path, { method: "PATCH", body: body === undefined ? undefined : JSON.stringify(body), }, opts); } delete(path: string, opts?: RequestOptions): Promise { return this.request(path, { method: "DELETE" }, opts); } setApiKey(apiKey: string | undefined) { this.apiKey = apiKey?.trim() || undefined; } private async request( path: string, init: RequestInit, opts?: RequestOptions, hasRetriedAuth = false, ): Promise { const url = buildUrl(this.apiBase, path); const method = String(init.method ?? "GET").toUpperCase(); const headers: Record = { accept: "application/json", ...toStringRecord(init.headers), }; if (init.body !== undefined) { headers["content-type"] = headers["content-type"] ?? "application/json"; } if (this.apiKey) { headers.authorization = `Bearer ${this.apiKey}`; } if (this.runId) { headers["x-paperclip-run-id"] = this.runId; } let response: Response; try { response = await fetch(url, { ...init, headers, }); } catch (error) { throw new ApiConnectionError({ apiBase: this.apiBase, path, method, cause: error, }); } if (opts?.ignoreNotFound && response.status === 404) { return null; } if (!response.ok) { const apiError = await toApiError(response); if (!hasRetriedAuth && this.recoverAuth) { const recoveredToken = await this.recoverAuth({ path, method, error: apiError, }); if (recoveredToken) { this.setApiKey(recoveredToken); return this.request(path, init, opts, true); } } throw apiError; } if (response.status === 204) { return null; } const text = await response.text(); if (!text.trim()) { return null; } return safeParseJson(text) as T; } } function buildUrl(apiBase: string, path: string): string { const normalizedPath = path.startsWith("/") ? path : `/${path}`; const [pathname, query] = normalizedPath.split("?"); const url = new URL(apiBase); url.pathname = `${url.pathname.replace(/\/+$/, "")}${pathname}`; if (query) url.search = query; return url.toString(); } function safeParseJson(text: string): unknown { try { return JSON.parse(text); } catch { return text; } } async function toApiError(response: Response): Promise { const text = await response.text(); const parsed = safeParseJson(text); if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { const body = parsed as Record; const message = (typeof body.error === "string" && body.error.trim()) || (typeof body.message === "string" && body.message.trim()) || `Request failed with status ${response.status}`; return new ApiRequestError(response.status, message, body.details, parsed); } return new ApiRequestError(response.status, `Request failed with status ${response.status}`, undefined, parsed); } function buildConnectionErrorMessage(input: { apiBase: string; url: string; method: string; causeMessage?: string; }): string { const healthUrl = buildHealthCheckUrl(input.url); const lines = [ "Could not reach the Paperclip API.", "", `Request: ${input.method} ${input.url}`, ]; if (input.causeMessage) { lines.push(`Cause: ${input.causeMessage}`); } lines.push( "", "This usually means the Paperclip server is not running, the configured URL is wrong, or the request is being blocked before it reaches Paperclip.", "", "Try:", "- Start Paperclip with `pnpm dev` or `pnpm paperclipai run`.", `- Verify the server is reachable with \`curl ${healthUrl}\`.`, `- If Paperclip is running elsewhere, pass \`--api-base ${input.apiBase.replace(/\/+$/, "")}\` or set \`PAPERCLIP_API_URL\`.`, ); return lines.join("\n"); } function buildHealthCheckUrl(requestUrl: string): string { const url = new URL(requestUrl); url.pathname = `${url.pathname.replace(/\/+$/, "").replace(/\/api(?:\/.*)?$/, "")}/api/health`; url.search = ""; url.hash = ""; return url.toString(); } function formatConnectionCause(error: unknown): string | undefined { if (!error) return undefined; if (error instanceof Error) { return error.message.trim() || error.name; } const message = String(error).trim(); return message || undefined; } function toStringRecord(headers: HeadersInit | undefined): Record { if (!headers) return {}; if (Array.isArray(headers)) { return Object.fromEntries(headers.map(([key, value]) => [key, String(value)])); } if (headers instanceof Headers) { return Object.fromEntries(headers.entries()); } return Object.fromEntries( Object.entries(headers).map(([key, value]) => [key, String(value)]), ); }