feat: add SDK foundation layer
Implement config resolution, typed errors, HTTP and WebSocket transport helpers, and timeout handling for the SDK foundation. Add unit and local integration tests covering the SDK foundation behaviour and align package exports with the tsup output.
This commit is contained in:
245
src/_shared/http.ts
Normal file
245
src/_shared/http.ts
Normal file
@ -0,0 +1,245 @@
|
||||
import { TimeoutError, throwErrorFromResponse } from "../exceptions.js";
|
||||
|
||||
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<string, string | number | boolean | undefined>;
|
||||
/** Additional headers merged after default authentication headers. */
|
||||
headers?: Record<string, string>;
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/** Thin `fetch` wrapper with Wrenn authentication and error mapping. */
|
||||
export class HttpClient {
|
||||
private readonly baseUrl: string;
|
||||
private readonly defaultHeaders: Record<string, string>;
|
||||
|
||||
/**
|
||||
* 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. */
|
||||
get<T>(path: string, opts?: RequestOptions): Promise<T> {
|
||||
return this.request<T>("GET", path, undefined, opts);
|
||||
}
|
||||
|
||||
/** Sends a POST request with an optional JSON body. */
|
||||
post<T>(path: string, body?: unknown, opts?: RequestOptions): Promise<T> {
|
||||
return this.request<T>("POST", path, body, opts);
|
||||
}
|
||||
|
||||
/** Sends a PATCH request with an optional JSON body. */
|
||||
patch<T>(path: string, body?: unknown, opts?: RequestOptions): Promise<T> {
|
||||
return this.request<T>("PATCH", path, body, opts);
|
||||
}
|
||||
|
||||
/** Sends a PUT request with an optional JSON body. */
|
||||
put<T>(path: string, body?: unknown, opts?: RequestOptions): Promise<T> {
|
||||
return this.request<T>("PUT", path, body, opts);
|
||||
}
|
||||
|
||||
/** Sends a DELETE request and expects no response body. */
|
||||
delete(path: string, opts?: RequestOptions): Promise<void> {
|
||||
return this.request<void>("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<void> {
|
||||
const url = this.buildUrl(path, opts?.params);
|
||||
const headers: Record<string, string> = { ...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<ReadableStream<Uint8Array>> {
|
||||
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 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<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
opts?: RequestOptions,
|
||||
): Promise<T> {
|
||||
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<Response> {
|
||||
const url = this.buildUrl(path, opts?.params);
|
||||
const headers: Record<string, string> = { ...this.defaultHeaders };
|
||||
|
||||
if (body === undefined) {
|
||||
delete headers["Content-Type"];
|
||||
}
|
||||
|
||||
const init: RequestInit = {
|
||||
method,
|
||||
headers: { ...headers, ...opts?.headers },
|
||||
};
|
||||
|
||||
if (body !== undefined) {
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
return this.fetchWithSignal(url, init, opts);
|
||||
}
|
||||
|
||||
private async fetchWithSignal(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
opts?: RequestOptions,
|
||||
): Promise<Response> {
|
||||
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, string | number | boolean | undefined>,
|
||||
): 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user