import { TimeoutError, throwErrorFromResponse } from "../exceptions.js"; /** Configuration for the low-level HTTP client. */ export interface HttpClientConfig { /** API origin used for all relative request paths. */ baseUrl: string; /** API key sent as `X-API-Key`. */ apiKey?: string; /** Bearer JWT sent as `Authorization: Bearer ...`. */ token?: string; /** Host token sent as `X-Host-Token`. */ hostToken?: string; } /** Per-request options accepted by the low-level HTTP client. */ export interface RequestOptions { /** Query parameters appended to the request URL. */ params?: Record; /** Additional headers merged after default authentication headers. */ headers?: Record; /** Request-scoped API key override. */ apiKey?: string; /** Request-scoped bearer JWT override. */ token?: string; /** Request-scoped host token override. */ hostToken?: string; /** Timeout in milliseconds for requests that do not provide a signal. */ timeoutMs?: number; /** Caller-provided cancellation signal. Takes precedence over `timeoutMs`. */ signal?: AbortSignal; /** Return response text instead of parsing JSON. */ asText?: boolean; /** Fetch redirect behavior. Defaults to the runtime fetch default. */ redirect?: RequestRedirect; } /** Thin `fetch` wrapper with Wrenn authentication and error mapping. */ export class HttpClient { private readonly baseUrl: string; private readonly defaultHeaders: Record; /** * Creates a low-level HTTP client. * * @param config - Base URL and optional authentication credentials. */ constructor(config: HttpClientConfig) { this.baseUrl = config.baseUrl.replace(/\/+$/, ""); this.defaultHeaders = { "Content-Type": "application/json", Accept: "application/json", }; if (config.apiKey) { this.defaultHeaders["X-API-Key"] = config.apiKey; } if (config.token) { this.defaultHeaders.Authorization = `Bearer ${config.token}`; } if (config.hostToken) { this.defaultHeaders["X-Host-Token"] = config.hostToken; } } /** * Sends a GET request and parses the JSON response. * * @param path - API path relative to the configured base URL. * @param opts - Optional query, header, auth, timeout, and cancellation settings. * @returns Parsed JSON response. * @throws TimeoutError when the configured timeout aborts the request. * @throws WrennError subclasses for unsuccessful responses. */ get(path: string, opts?: RequestOptions): Promise { return this.request("GET", path, undefined, opts); } /** * Sends a POST request with an optional JSON body. * * @param path - API path relative to the configured base URL. * @param body - Optional JSON request body. * @param opts - Optional query, header, auth, timeout, and cancellation settings. * @returns Parsed JSON response. * @throws TimeoutError when the configured timeout aborts the request. * @throws WrennError subclasses for unsuccessful responses. */ post(path: string, body?: unknown, opts?: RequestOptions): Promise { return this.request("POST", path, body, opts); } /** * Sends a PATCH request with an optional JSON body. * * @param path - API path relative to the configured base URL. * @param body - Optional JSON request body. * @param opts - Optional query, header, auth, timeout, and cancellation settings. * @returns Parsed JSON response. * @throws TimeoutError when the configured timeout aborts the request. * @throws WrennError subclasses for unsuccessful responses. */ patch(path: string, body?: unknown, opts?: RequestOptions): Promise { return this.request("PATCH", path, body, opts); } /** * Sends a PUT request with an optional JSON body. * * @param path - API path relative to the configured base URL. * @param body - Optional JSON request body. * @param opts - Optional query, header, auth, timeout, and cancellation settings. * @returns Parsed JSON response. * @throws TimeoutError when the configured timeout aborts the request. * @throws WrennError subclasses for unsuccessful responses. */ put(path: string, body?: unknown, opts?: RequestOptions): Promise { return this.request("PUT", path, body, opts); } /** * Sends a DELETE request and expects no response body. * * @param path - API path relative to the configured base URL. * @param opts - Optional query, header, auth, timeout, and cancellation settings. * @returns Resolves when the response succeeds. * @throws TimeoutError when the configured timeout aborts the request. * @throws WrennError subclasses for unsuccessful responses. */ delete(path: string, opts?: RequestOptions): Promise { return this.request("DELETE", path, undefined, opts); } /** * Uploads multipart form data. * * @param path - API path relative to the configured base URL. * @param formData - Multipart payload to send. * @param opts - Optional query, header, auth, and cancellation settings. * @throws WrennError subclasses for unsuccessful responses. */ async upload( path: string, formData: FormData, opts?: RequestOptions, ): Promise { const url = this.buildUrl(path, opts?.params); const headers: Record = { ...this.defaultHeaders }; delete headers["Content-Type"]; if (opts?.apiKey) headers["X-API-Key"] = opts.apiKey; if (opts?.token) headers.Authorization = `Bearer ${opts.token}`; if (opts?.hostToken) headers["X-Host-Token"] = opts.hostToken; const init: RequestInit = { method: "POST", headers: { ...headers, ...opts?.headers }, body: formData, }; const res = await this.fetchWithSignal(url, init, opts); if (!res.ok) { await throwErrorFromResponse(res); } } /** * Downloads a binary response body as a web `ReadableStream`. * * @param path - API path relative to the configured base URL. * @param body - Optional JSON request body. * @param opts - Optional query, header, auth, timeout, and cancellation settings. * @returns Response body stream. * @throws WrennError subclasses for unsuccessful responses. */ async download( path: string, body?: unknown, opts?: RequestOptions, ): Promise> { const res = await this.rawRequest("POST", path, body, opts); if (!res.body) { throw new Error("Response body is null"); } return res.body; } /** * Sends a request and returns the raw `Response` object. * * This is intended for endpoints that intentionally return non-JSON responses, * such as OAuth redirects. Unlike {@link request}, this method does not map * non-OK statuses to SDK errors. * * @param method - HTTP method to use. * @param path - API path relative to the configured base URL. * @param body - Optional JSON request body. * @param opts - Optional query, header, auth, timeout, and redirect settings. * @returns Raw fetch response. * @throws TimeoutError When the configured timeout aborts the request. */ response( method: string, path: string, body?: unknown, opts?: RequestOptions, ): Promise { return this.rawRequest(method, path, body, opts); } /** * Sends a request and parses the response as JSON unless `asText` is set. * * @param method - HTTP method to use. * @param path - API path relative to the configured base URL. * @param body - Optional JSON request body. * @param opts - Optional query, header, auth, timeout, and cancellation settings. * @returns Parsed response body. * @throws TimeoutError When the configured timeout aborts the request. * @throws WrennError subclasses for unsuccessful responses. */ async request( method: string, path: string, body?: unknown, opts?: RequestOptions, ): Promise { const res = await this.rawRequest(method, path, body, opts); if (res.status === 204) { return undefined as T; } if (!res.ok) { await throwErrorFromResponse(res); } if (opts?.asText) { return (await res.text()) as T; } return (await res.json()) as T; } private async rawRequest( method: string, path: string, body?: unknown, opts?: RequestOptions, ): Promise { const url = this.buildUrl(path, opts?.params); const headers: Record = { ...this.defaultHeaders }; if (body === undefined) { delete headers["Content-Type"]; } const init: RequestInit = { method, headers: { ...headers, ...opts?.headers }, }; if (opts?.redirect) init.redirect = opts.redirect; if (body !== undefined) { init.body = JSON.stringify(body); } return this.fetchWithSignal(url, init, opts); } private async fetchWithSignal( url: string, init: RequestInit, opts?: RequestOptions, ): Promise { const requestInit: RequestInit = { ...init }; if (opts?.signal) { requestInit.signal = opts.signal; return fetch(url, requestInit); } if (opts?.timeoutMs) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), opts.timeoutMs); requestInit.signal = controller.signal; try { const res = await fetch(url, requestInit); clearTimeout(timeout); return res; } catch (err) { clearTimeout(timeout); if (err instanceof DOMException && err.name === "AbortError") { throw new TimeoutError(`Request timed out after ${opts.timeoutMs}ms`); } throw err; } } return fetch(url, requestInit); } private buildUrl( path: string, params?: Record, ): string { const url = new URL(`${this.baseUrl}${path}`); if (params) { for (const [key, value] of Object.entries(params)) { if (value !== undefined) { url.searchParams.set(key, String(value)); } } } return url.toString(); } }