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:
Tasnim Kabir Sadik
2026-05-09 16:32:41 +06:00
parent db7fccbaed
commit 5b3f2741a3
11 changed files with 1127 additions and 9 deletions

245
src/_shared/http.ts Normal file
View 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();
}
}

140
src/_shared/websocket.ts Normal file
View File

@ -0,0 +1,140 @@
import WebSocket from "ws";
import { TimeoutError } from "../exceptions.js";
/** Options used to establish a Wrenn WebSocket connection. */
export interface WsConnectionOpts {
/** HTTP(S) API origin. Converted to WS(S) for the socket URL. */
baseUrl: string;
/** WebSocket path relative to the base URL. */
path: string;
/** API key sent as `X-API-Key`. */
apiKey?: string;
/** Host token sent as `X-Host-Token`. */
hostToken?: string;
/** Callback invoked for each JSON message or raw text payload. */
onMessage: (data: unknown) => void;
/** Callback invoked for socket errors after connection establishment. */
onError?: (error: Error) => void;
/** Callback invoked when the socket closes after connection establishment. */
onClose?: (code: number, reason: string) => void;
/** Connection timeout in milliseconds. Defaults to 30 seconds. */
timeoutMs?: number;
}
/** Minimal WebSocket wrapper for JSON-oriented Wrenn streaming endpoints. */
export class WsConnection {
private ws: WebSocket;
private closed = false;
private constructor(ws: WebSocket) {
this.ws = ws;
}
/** Sends a JSON-encoded message over the open WebSocket. */
send(data: unknown): void {
if (this.closed || this.ws.readyState !== WebSocket.OPEN) {
throw new Error("WebSocket is not open");
}
this.ws.send(JSON.stringify(data));
}
/** Closes the WebSocket connection if it is still open. */
close(): void {
if (this.closed) return;
this.closed = true;
this.ws.close();
}
/** Indicates whether the connection has closed or failed. */
get isClosed(): boolean {
return this.closed;
}
/**
* Opens a WebSocket connection and resolves once the socket is ready.
*
* @param opts - Connection URL, authentication, callbacks, and timeout.
* @returns An established WebSocket connection wrapper.
* @throws TimeoutError When the connection is not established before timeout.
*/
static connect(opts: WsConnectionOpts): Promise<WsConnection> {
return new Promise((resolve, reject) => {
const url = new URL(`${opts.baseUrl}${opts.path}`);
const protocol = url.protocol === "https:" ? "wss:" : "ws:";
url.protocol = protocol;
const headers: Record<string, string> = {};
if (opts.apiKey) {
headers["X-API-Key"] = opts.apiKey;
}
if (opts.hostToken) {
headers["X-Host-Token"] = opts.hostToken;
}
const ws = new WebSocket(url.toString(), {
headers,
});
const timeout = opts.timeoutMs ?? 30_000;
let settled = false;
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
const cleanup = () => {
if (timeoutHandle) clearTimeout(timeoutHandle);
settled = true;
};
timeoutHandle = setTimeout(() => {
if (!settled) {
cleanup();
ws.terminate();
reject(
new TimeoutError(
`WebSocket connection timed out after ${timeout}ms`,
),
);
}
}, timeout);
ws.on("open", () => {
if (settled) return;
cleanup();
const conn = new WsConnection(ws);
ws.on("message", (raw) => {
try {
const data = JSON.parse(raw.toString());
opts.onMessage(data);
} catch {
opts.onMessage(raw.toString());
}
});
ws.on("error", (err) => {
conn.closed = true;
opts.onError?.(err);
});
ws.on("close", (code, reason) => {
conn.closed = true;
opts.onClose?.(code, reason.toString());
});
resolve(conn);
});
ws.on("error", (err) => {
if (settled) return;
cleanup();
reject(err);
});
ws.on("close", (code, reason) => {
if (settled) return;
cleanup();
reject(
new Error(
`WebSocket closed before opening (${code}): ${reason.toString()}`,
),
);
});
});
}
}