// ── SDK v2 — Universal HTTP fetcher ─────────────────────────────────────────── // // Wraps globalThis.fetch with: // - Tenant + auth headers injected automatically // - Timeout via AbortSignal.any() (or manual composition on older runtimes) // - RFC 9457 error mapping // - Zero dependencies beyond the Fetch API import type { ClientConfig, RequestOptions } from "./types"; import { AbortedError, TimeoutError, mapStatusToError, type ProblemDetail, } from "./errors"; import type { ListParams, Paginated } from "../types/index"; const DEFAULT_BASE_URL = "https://agi.kaiban.io/api/v2"; const DEFAULT_TIMEOUT = 30_000; // ── Query string builder ─────────────────────────────────────────────────────── function buildQuery(params?: Record): string { if (!params) return ""; const entries = Object.entries(params).filter( ([, v]) => v !== undefined && v !== null && v !== "", ); if (entries.length === 0) return ""; const qs = new URLSearchParams( entries.map(([k, v]) => [k, String(v)]), ).toString(); return `?${qs}`; } // ── AbortSignal composition ──────────────────────────────────────────────────── // AbortSignal.any() is available in Node 20+ and modern browsers. // For older environments we fall back to a manual approach. function combineSignals(a: AbortSignal, b?: AbortSignal): AbortSignal { if (!b) return a; if (typeof AbortSignal.any === "function") return AbortSignal.any([a, b]); // Fallback: first abort wins const ctrl = new AbortController(); const abort = () => ctrl.abort(); a.addEventListener("abort", abort, { once: true }); b.addEventListener("abort", abort, { once: true }); return ctrl.signal; } // ── Fetcher class ────────────────────────────────────────────────────────────── export class Fetcher { private readonly config: Required< Omit > & Pick; constructor(config: ClientConfig) { this.config = { tenant: config.tenant, baseUrl: (config.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, ""), timeoutMs: config.timeoutMs ?? DEFAULT_TIMEOUT, fetch: config.fetch ?? globalThis.fetch.bind(globalThis), apiKey: config.apiKey, token: config.token, getToken: config.getToken, }; } // ── Auth resolution ────────────────────────────────────────────────────────── // Returns the correct header key + value for the configured auth method. // API key takes precedence; Bearer (static token or getToken factory) is fallback. private async resolveAuth(): Promise< | { header: "X-Api-Key"; value: string } | { header: "Authorization"; value: string } | null > { if (this.config.apiKey) { return { header: "X-Api-Key", value: this.config.apiKey }; } const token = this.config.getToken ? await this.config.getToken() : this.config.token; if (token) return { header: "Authorization", value: `Bearer ${token}` }; return null; } // ── Core request ───────────────────────────────────────────────────────────── async request( method: string, path: string, options: { query?: Record; body?: unknown; request?: RequestOptions; } = {}, ): Promise { const auth = await this.resolveAuth(); const timeoutMs = options.request?.timeoutMs ?? this.config.timeoutMs; const timeoutSignal = AbortSignal.timeout(timeoutMs); const signal = combineSignals(timeoutSignal, options.request?.signal); const url = `${this.config.baseUrl}${path}${buildQuery(options.query)}`; const headers: Record = { "Content-Type": "application/json", "x-tenant": this.config.tenant, ...options.request?.headers, }; if (auth) headers[auth.header] = auth.value; let response: Response; try { response = await this.config.fetch(url, { method, headers, body: options.body !== undefined ? JSON.stringify(options.body) : undefined, signal, }); } catch (err: unknown) { if (err instanceof Error) { if (err.name === "AbortError") throw new AbortedError(); if (err.name === "TimeoutError") throw new TimeoutError(); } throw err; } if (!response.ok) { let problem: ProblemDetail | undefined; try { problem = (await response.json()) as ProblemDetail; } catch { // non-JSON error body — leave problem undefined } throw mapStatusToError(response.status, problem); } // 204 No Content if (response.status === 204) return undefined as T; return response.json() as Promise; } // ── Convenience methods ─────────────────────────────────────────────────────── get( path: string, query?: Record, opts?: RequestOptions, ): Promise { return this.request("GET", path, { query, request: opts }); } post(path: string, body?: unknown, opts?: RequestOptions): Promise { return this.request("POST", path, { body, request: opts }); } patch(path: string, body?: unknown, opts?: RequestOptions): Promise { return this.request("PATCH", path, { body, request: opts }); } delete(path: string, opts?: RequestOptions): Promise { return this.request("DELETE", path, { request: opts }); } // ── List helper — maps ListParams to query object ───────────────────────────── list( path: string, params?: ListParams, opts?: RequestOptions, ): Promise> { return this.get>( path, params as Record, opts, ); } // ── SSE streaming ───────────────────────────────────────────────────────────── // Returns the raw Response so resource clients can parse the event stream. // Callers are responsible for aborting via the signal in opts. async stream( path: string, query?: Record, opts?: RequestOptions, ): Promise { const auth = await this.resolveAuth(); const url = `${this.config.baseUrl}${path}${buildQuery(query)}`; const headers: Record = { Accept: "text/event-stream", "x-tenant": this.config.tenant, "Cache-Control": "no-cache", ...opts?.headers, }; if (auth) headers[auth.header] = auth.value; let response: Response; try { response = await this.config.fetch(url, { method: "GET", headers, signal: opts?.signal, // Keep the connection alive — no timeout for streaming }); } catch (err: unknown) { if (err instanceof Error && err.name === "AbortError") throw new AbortedError(); throw err; } if (!response.ok) { let problem: ProblemDetail | undefined; try { problem = (await response.json()) as ProblemDetail; } catch { /* noop */ } throw mapStatusToError(response.status, problem); } return response; } }