Spaces:
Paused
Paused
| 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<string | null>; | |
| } | |
| export class PaperclipApiClient { | |
| readonly apiBase: string; | |
| apiKey?: string; | |
| readonly runId?: string; | |
| readonly recoverAuth?: (input: RecoverAuthInput) => Promise<string | null>; | |
| 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<T>(path: string, opts?: RequestOptions): Promise<T | null> { | |
| return this.request<T>(path, { method: "GET" }, opts); | |
| } | |
| post<T>(path: string, body?: unknown, opts?: RequestOptions): Promise<T | null> { | |
| return this.request<T>(path, { | |
| method: "POST", | |
| body: body === undefined ? undefined : JSON.stringify(body), | |
| }, opts); | |
| } | |
| patch<T>(path: string, body?: unknown, opts?: RequestOptions): Promise<T | null> { | |
| return this.request<T>(path, { | |
| method: "PATCH", | |
| body: body === undefined ? undefined : JSON.stringify(body), | |
| }, opts); | |
| } | |
| delete<T>(path: string, opts?: RequestOptions): Promise<T | null> { | |
| return this.request<T>(path, { method: "DELETE" }, opts); | |
| } | |
| setApiKey(apiKey: string | undefined) { | |
| this.apiKey = apiKey?.trim() || undefined; | |
| } | |
| private async request<T>( | |
| path: string, | |
| init: RequestInit, | |
| opts?: RequestOptions, | |
| hasRetriedAuth = false, | |
| ): Promise<T | null> { | |
| const url = buildUrl(this.apiBase, path); | |
| const method = String(init.method ?? "GET").toUpperCase(); | |
| const headers: Record<string, string> = { | |
| 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<T>(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<ApiRequestError> { | |
| const text = await response.text(); | |
| const parsed = safeParseJson(text); | |
| if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { | |
| const body = parsed as Record<string, unknown>; | |
| 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<string, string> { | |
| 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)]), | |
| ); | |
| } | |