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:
1
.gitignore
vendored
1
.gitignore
vendored
@ -138,4 +138,3 @@ dist
|
||||
|
||||
# AI agents
|
||||
.opencode
|
||||
PLAN.md
|
||||
|
||||
12
package.json
12
package.json
@ -3,14 +3,14 @@
|
||||
"version": "0.1.0",
|
||||
"description": "Wrenn JavaScript SDK — a client library for the Wrenn microVM platform.",
|
||||
"type": "module",
|
||||
"main": "./dist/cjs/index.js",
|
||||
"module": "./dist/esm/index.js",
|
||||
"types": "./dist/dts/index.d.ts",
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/dts/index.d.ts",
|
||||
"import": "./dist/esm/index.js",
|
||||
"require": "./dist/cjs/index.js"
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
140
src/_shared/websocket.ts
Normal file
140
src/_shared/websocket.ts
Normal 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()}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
62
src/config.ts
Normal file
62
src/config.ts
Normal file
@ -0,0 +1,62 @@
|
||||
/** Default Wrenn API origin used when no base URL is supplied. */
|
||||
export const DEFAULT_BASE_URL = "https://api.wrenn.dev";
|
||||
|
||||
/** Environment variable used for API-key authentication. */
|
||||
export const ENV_API_KEY = "WRENN_API_KEY";
|
||||
/** Environment variable used for bearer JWT authentication. */
|
||||
export const ENV_TOKEN = "WRENN_TOKEN";
|
||||
/** Environment variable used for host-token authentication. */
|
||||
export const ENV_HOST_TOKEN = "WRENN_HOST_TOKEN";
|
||||
/** Environment variable used to override the Wrenn API origin. */
|
||||
export const ENV_BASE_URL = "WRENN_BASE_URL";
|
||||
|
||||
/** Client configuration supplied directly by SDK callers. */
|
||||
export interface ClientConfig {
|
||||
/** API origin. Defaults to `WRENN_BASE_URL` or {@link DEFAULT_BASE_URL}. */
|
||||
baseUrl?: string;
|
||||
/** API key sent as `X-API-Key` for capsule lifecycle operations. */
|
||||
apiKey?: string;
|
||||
/** Bearer JWT sent as `Authorization: Bearer ...` for account/team operations. */
|
||||
token?: string;
|
||||
/** Host token sent as `X-Host-Token` for host-agent operations. */
|
||||
hostToken?: string;
|
||||
}
|
||||
|
||||
/** Fully resolved client configuration after applying environment fallbacks. */
|
||||
export interface ResolvedClientConfig {
|
||||
/** API origin with environment/default fallback applied. */
|
||||
baseUrl: string;
|
||||
/** Resolved API key, if one is available. */
|
||||
apiKey?: string;
|
||||
/** Resolved bearer JWT, if one is available. */
|
||||
token?: string;
|
||||
/** Resolved host token, if one is available. */
|
||||
hostToken?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves explicit client options against Wrenn environment variables.
|
||||
*
|
||||
* Explicit options always win over environment values. Empty credentials are
|
||||
* omitted from the returned object so `exactOptionalPropertyTypes` consumers do
|
||||
* not receive `undefined` credential fields.
|
||||
*
|
||||
* @param opts - Optional caller-supplied client configuration.
|
||||
* @returns A normalized configuration object ready for HTTP/WebSocket clients.
|
||||
*/
|
||||
export function resolveConfig(opts?: ClientConfig): ResolvedClientConfig {
|
||||
const config: ResolvedClientConfig = {
|
||||
baseUrl: opts?.baseUrl ?? process.env[ENV_BASE_URL] ?? DEFAULT_BASE_URL,
|
||||
};
|
||||
|
||||
const apiKey = opts?.apiKey ?? process.env[ENV_API_KEY];
|
||||
if (apiKey) config.apiKey = apiKey;
|
||||
|
||||
const token = opts?.token ?? process.env[ENV_TOKEN];
|
||||
if (token) config.token = token;
|
||||
|
||||
const hostToken = opts?.hostToken ?? process.env[ENV_HOST_TOKEN];
|
||||
if (hostToken) config.hostToken = hostToken;
|
||||
|
||||
return config;
|
||||
}
|
||||
162
src/exceptions.ts
Normal file
162
src/exceptions.ts
Normal file
@ -0,0 +1,162 @@
|
||||
/** Base class for all SDK errors raised from Wrenn API responses. */
|
||||
export class WrennError extends Error {
|
||||
/** HTTP status code associated with the failure. */
|
||||
readonly statusCode: number;
|
||||
/** Stable API error code when returned by the server. */
|
||||
readonly code?: string | undefined;
|
||||
/** Parsed response body, when available. */
|
||||
readonly body?: unknown | undefined;
|
||||
|
||||
/**
|
||||
* Creates an SDK error.
|
||||
*
|
||||
* @param statusCode - HTTP status code associated with the failure.
|
||||
* @param message - Human-readable error message.
|
||||
* @param code - Optional server-provided error code.
|
||||
* @param body - Optional parsed response body.
|
||||
*/
|
||||
constructor(
|
||||
statusCode: number,
|
||||
message: string,
|
||||
code?: string,
|
||||
body?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "WrennError";
|
||||
this.statusCode = statusCode;
|
||||
this.code = code;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
/** Error raised for malformed requests or invalid request parameters. */
|
||||
export class BadRequestError extends WrennError {
|
||||
constructor(message: string, code?: string, body?: unknown) {
|
||||
super(400, message, code, body);
|
||||
this.name = "BadRequestError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Error raised when authentication credentials are missing or invalid. */
|
||||
export class AuthenticationError extends WrennError {
|
||||
constructor(message: string, code?: string, body?: unknown) {
|
||||
super(401, message, code, body);
|
||||
this.name = "AuthenticationError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Error raised when valid credentials do not grant access to a resource. */
|
||||
export class ForbiddenError extends WrennError {
|
||||
constructor(message: string, code?: string, body?: unknown) {
|
||||
super(403, message, code, body);
|
||||
this.name = "ForbiddenError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Error raised when a requested resource cannot be found. */
|
||||
export class NotFoundError extends WrennError {
|
||||
constructor(message: string, code?: string, body?: unknown) {
|
||||
super(404, message, code, body);
|
||||
this.name = "NotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Error raised when the request conflicts with current server state. */
|
||||
export class ConflictError extends WrennError {
|
||||
constructor(message: string, code?: string, body?: unknown) {
|
||||
super(409, message, code, body);
|
||||
this.name = "ConflictError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Error raised when an upload or request payload exceeds server limits. */
|
||||
export class PayloadTooLargeError extends WrennError {
|
||||
constructor(message: string, code?: string, body?: unknown) {
|
||||
super(413, message, code, body);
|
||||
this.name = "PayloadTooLargeError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Error raised when a request or connection exceeds its configured timeout. */
|
||||
export class TimeoutError extends WrennError {
|
||||
constructor(message = "Request timed out", code?: string, body?: unknown) {
|
||||
super(408, message, code, body);
|
||||
this.name = "TimeoutError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Error raised for 5xx responses returned by the Wrenn API. */
|
||||
export class ServerError extends WrennError {
|
||||
constructor(
|
||||
statusCode: number,
|
||||
message: string,
|
||||
code?: string,
|
||||
body?: unknown,
|
||||
) {
|
||||
super(statusCode, message, code, body);
|
||||
this.name = "ServerError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Error raised when deleting a host that still owns active capsules. */
|
||||
export class HostHasCapsulesError extends ConflictError {
|
||||
/** IDs of capsules preventing host deletion. */
|
||||
readonly sandboxIds: string[];
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
sandboxIds: string[],
|
||||
code?: string,
|
||||
body?: unknown,
|
||||
) {
|
||||
super(message, code, body);
|
||||
this.name = "HostHasCapsulesError";
|
||||
this.sandboxIds = sandboxIds;
|
||||
}
|
||||
}
|
||||
|
||||
interface ApiErrorBody {
|
||||
error?: {
|
||||
code?: string;
|
||||
message?: string;
|
||||
sandbox_ids?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an unsuccessful `fetch` response into the matching SDK error type.
|
||||
*
|
||||
* @param res - Non-OK response returned by `fetch`.
|
||||
* @throws WrennError subclasses based on the HTTP status code and error body.
|
||||
*/
|
||||
export async function throwErrorFromResponse(res: Response): Promise<never> {
|
||||
const status = res.status;
|
||||
let body: unknown;
|
||||
|
||||
try {
|
||||
body = await res.json();
|
||||
} catch {
|
||||
throw new WrennError(status, res.statusText, undefined, undefined);
|
||||
}
|
||||
|
||||
const errorBody = body as ApiErrorBody | undefined;
|
||||
const code = errorBody?.error?.code;
|
||||
const message = errorBody?.error?.message ?? res.statusText;
|
||||
const sandboxIds = errorBody?.error?.sandbox_ids;
|
||||
|
||||
if (status === 400) throw new BadRequestError(message, code, body);
|
||||
if (status === 401) throw new AuthenticationError(message, code, body);
|
||||
if (status === 403) throw new ForbiddenError(message, code, body);
|
||||
if (status === 404) throw new NotFoundError(message, code, body);
|
||||
if (status === 408) throw new TimeoutError(message, code, body);
|
||||
if (status === 409) {
|
||||
if (sandboxIds?.length) {
|
||||
throw new HostHasCapsulesError(message, sandboxIds, code, body);
|
||||
}
|
||||
throw new ConflictError(message, code, body);
|
||||
}
|
||||
if (status === 413) throw new PayloadTooLargeError(message, code, body);
|
||||
if (status >= 500) throw new ServerError(status, message, code, body);
|
||||
|
||||
throw new WrennError(status, message, code, body);
|
||||
}
|
||||
26
src/index.ts
Normal file
26
src/index.ts
Normal file
@ -0,0 +1,26 @@
|
||||
export type { HttpClientConfig, RequestOptions } from "./_shared/http.js";
|
||||
export { HttpClient } from "./_shared/http.js";
|
||||
export type { WsConnectionOpts } from "./_shared/websocket.js";
|
||||
export { WsConnection } from "./_shared/websocket.js";
|
||||
export type { ClientConfig, ResolvedClientConfig } from "./config.js";
|
||||
export {
|
||||
DEFAULT_BASE_URL,
|
||||
ENV_API_KEY,
|
||||
ENV_BASE_URL,
|
||||
ENV_HOST_TOKEN,
|
||||
ENV_TOKEN,
|
||||
resolveConfig,
|
||||
} from "./config.js";
|
||||
export {
|
||||
AuthenticationError,
|
||||
BadRequestError,
|
||||
ConflictError,
|
||||
ForbiddenError,
|
||||
HostHasCapsulesError,
|
||||
NotFoundError,
|
||||
PayloadTooLargeError,
|
||||
ServerError,
|
||||
TimeoutError,
|
||||
throwErrorFromResponse,
|
||||
WrennError,
|
||||
} from "./exceptions.js";
|
||||
287
tests/foundation.test.ts
Normal file
287
tests/foundation.test.ts
Normal file
@ -0,0 +1,287 @@
|
||||
import type { AddressInfo } from "node:net";
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { WebSocketServer } from "ws";
|
||||
|
||||
import { HttpClient } from "../src/_shared/http.js";
|
||||
import { WsConnection } from "../src/_shared/websocket.js";
|
||||
import { resolveConfig } from "../src/config.js";
|
||||
import {
|
||||
AuthenticationError,
|
||||
BadRequestError,
|
||||
ConflictError,
|
||||
ForbiddenError,
|
||||
HostHasCapsulesError,
|
||||
NotFoundError,
|
||||
PayloadTooLargeError,
|
||||
ServerError,
|
||||
TimeoutError,
|
||||
throwErrorFromResponse,
|
||||
WrennError,
|
||||
} from "../src/exceptions.js";
|
||||
|
||||
describe("resolveConfig", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("uses defaults when no options or environment variables are set", () => {
|
||||
vi.stubEnv("WRENN_BASE_URL", undefined);
|
||||
vi.stubEnv("WRENN_API_KEY", undefined);
|
||||
vi.stubEnv("WRENN_TOKEN", undefined);
|
||||
vi.stubEnv("WRENN_HOST_TOKEN", undefined);
|
||||
|
||||
expect(resolveConfig()).toEqual({ baseUrl: "https://api.wrenn.dev" });
|
||||
});
|
||||
|
||||
it("prefers explicit options over environment variables", () => {
|
||||
vi.stubEnv("WRENN_BASE_URL", "https://env.example.com");
|
||||
vi.stubEnv("WRENN_API_KEY", "env-api-key");
|
||||
vi.stubEnv("WRENN_TOKEN", "env-token");
|
||||
vi.stubEnv("WRENN_HOST_TOKEN", "env-host-token");
|
||||
|
||||
expect(
|
||||
resolveConfig({
|
||||
baseUrl: "https://opts.example.com",
|
||||
apiKey: "opts-api-key",
|
||||
token: "opts-token",
|
||||
hostToken: "opts-host-token",
|
||||
}),
|
||||
).toEqual({
|
||||
baseUrl: "https://opts.example.com",
|
||||
apiKey: "opts-api-key",
|
||||
token: "opts-token",
|
||||
hostToken: "opts-host-token",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("throwErrorFromResponse", () => {
|
||||
it.each([
|
||||
[400, BadRequestError],
|
||||
[401, AuthenticationError],
|
||||
[403, ForbiddenError],
|
||||
[404, NotFoundError],
|
||||
[408, TimeoutError],
|
||||
[409, ConflictError],
|
||||
[413, PayloadTooLargeError],
|
||||
[500, ServerError],
|
||||
])("maps HTTP %i responses", async (status, ErrorClass) => {
|
||||
const response = Response.json(
|
||||
{ error: { code: "test_error", message: "Test failure" } },
|
||||
{ status, statusText: "Failed" },
|
||||
);
|
||||
|
||||
await expect(throwErrorFromResponse(response)).rejects.toMatchObject({
|
||||
name: ErrorClass.name,
|
||||
statusCode: status,
|
||||
code: "test_error",
|
||||
message: "Test failure",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps host conflict responses with capsule IDs", async () => {
|
||||
const response = Response.json(
|
||||
{
|
||||
error: {
|
||||
code: "host_has_capsules",
|
||||
message: "Host has capsules",
|
||||
sandbox_ids: ["cap_1", "cap_2"],
|
||||
},
|
||||
},
|
||||
{ status: 409, statusText: "Conflict" },
|
||||
);
|
||||
|
||||
const error = throwErrorFromResponse(response);
|
||||
|
||||
await expect(error).rejects.toBeInstanceOf(HostHasCapsulesError);
|
||||
await expect(error).rejects.toMatchObject({
|
||||
name: "HostHasCapsulesError",
|
||||
statusCode: 409,
|
||||
sandboxIds: ["cap_1", "cap_2"],
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to WrennError for non-JSON error bodies", async () => {
|
||||
const response = new Response("not json", {
|
||||
status: 418,
|
||||
statusText: "I'm a teapot",
|
||||
});
|
||||
|
||||
await expect(throwErrorFromResponse(response)).rejects.toBeInstanceOf(
|
||||
WrennError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HttpClient", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("sends default auth headers and query params", async () => {
|
||||
const fetchMock = vi.fn(async () => Response.json({ ok: true }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const client = new HttpClient({
|
||||
baseUrl: "https://api.example.com/",
|
||||
apiKey: "api-key",
|
||||
token: "jwt-token",
|
||||
hostToken: "host-token",
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.get<{ ok: boolean }>("/v1/test", {
|
||||
params: { a: "one", b: 2, c: true, skipped: undefined },
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://api.example.com/v1/test?a=one&b=2&c=true",
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: "Bearer jwt-token",
|
||||
"X-API-Key": "api-key",
|
||||
"X-Host-Token": "host-token",
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("omits content type for bodyless requests", async () => {
|
||||
const fetchMock = vi.fn(async () => new Response(null, { status: 204 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const client = new HttpClient({ baseUrl: "https://api.example.com" });
|
||||
|
||||
await client.delete("/v1/test");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://api.example.com/v1/test",
|
||||
expect.objectContaining({
|
||||
headers: { Accept: "application/json" },
|
||||
method: "DELETE",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("maps unsuccessful responses to SDK errors", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () =>
|
||||
Response.json(
|
||||
{ error: { code: "missing", message: "Not found" } },
|
||||
{ status: 404 },
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const client = new HttpClient({ baseUrl: "https://api.example.com" });
|
||||
|
||||
await expect(client.get("/v1/missing")).rejects.toBeInstanceOf(
|
||||
NotFoundError,
|
||||
);
|
||||
});
|
||||
|
||||
it("throws TimeoutError when timeoutMs aborts a request", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(
|
||||
(_url: string, init?: RequestInit) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
init?.signal?.addEventListener("abort", () => {
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
});
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const client = new HttpClient({ baseUrl: "https://api.example.com" });
|
||||
const request = client.get("/v1/slow", { timeoutMs: 100 });
|
||||
const assertion = expect(request).rejects.toBeInstanceOf(TimeoutError);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
await assertion;
|
||||
});
|
||||
|
||||
it("applies timeoutMs to multipart uploads", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(
|
||||
(_url: string, init?: RequestInit) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
init?.signal?.addEventListener("abort", () => {
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
});
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const client = new HttpClient({ baseUrl: "https://api.example.com" });
|
||||
const upload = client.upload("/v1/upload", new FormData(), {
|
||||
timeoutMs: 100,
|
||||
});
|
||||
const assertion = expect(upload).rejects.toBeInstanceOf(TimeoutError);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
await assertion;
|
||||
});
|
||||
});
|
||||
|
||||
describe("WsConnection", () => {
|
||||
it("connects, sends JSON messages, and receives parsed messages", async () => {
|
||||
const server = new WebSocketServer({ port: 0 });
|
||||
const messages: unknown[] = [];
|
||||
const receivedByServer = new Promise<unknown>((resolve) => {
|
||||
server.on("connection", (socket, request) => {
|
||||
expect(request.headers["x-api-key"]).toBe("api-key");
|
||||
expect(request.headers["x-host-token"]).toBe("host-token");
|
||||
socket.on("message", (raw) => resolve(JSON.parse(raw.toString())));
|
||||
socket.send(JSON.stringify({ type: "ready" }));
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => server.once("listening", resolve));
|
||||
const address = server.address() as AddressInfo;
|
||||
const connection = await WsConnection.connect({
|
||||
baseUrl: `http://127.0.0.1:${address.port}`,
|
||||
path: "/stream",
|
||||
apiKey: "api-key",
|
||||
hostToken: "host-token",
|
||||
onMessage: (message) => messages.push(message),
|
||||
});
|
||||
|
||||
connection.send({ type: "start" });
|
||||
|
||||
await expect(receivedByServer).resolves.toEqual({ type: "start" });
|
||||
expect(messages).toEqual([{ type: "ready" }]);
|
||||
|
||||
connection.close();
|
||||
server.close();
|
||||
});
|
||||
|
||||
it("rejects with TimeoutError if the connection does not open in time", async () => {
|
||||
vi.useFakeTimers();
|
||||
const server = new WebSocketServer({ noServer: true });
|
||||
const connection = WsConnection.connect({
|
||||
baseUrl: "http://127.0.0.1:9",
|
||||
path: "/stream",
|
||||
timeoutMs: 100,
|
||||
onMessage: () => undefined,
|
||||
});
|
||||
const assertion = expect(connection).rejects.toBeInstanceOf(TimeoutError);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
await assertion;
|
||||
server.close();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
183
tests/integration/foundation.integration.test.ts
Normal file
183
tests/integration/foundation.integration.test.ts
Normal file
@ -0,0 +1,183 @@
|
||||
import {
|
||||
createServer,
|
||||
type IncomingMessage,
|
||||
type ServerResponse,
|
||||
} from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { WebSocketServer } from "ws";
|
||||
|
||||
import { HttpClient } from "../../src/_shared/http.js";
|
||||
import { WsConnection } from "../../src/_shared/websocket.js";
|
||||
import { ConflictError, TimeoutError } from "../../src/exceptions.js";
|
||||
|
||||
interface CapturedRequest {
|
||||
method?: string;
|
||||
url?: string;
|
||||
headers: IncomingMessage["headers"];
|
||||
body: string;
|
||||
}
|
||||
|
||||
function readRequestBody(request: IncomingMessage): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = "";
|
||||
request.setEncoding("utf8");
|
||||
request.on("data", (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
request.on("end", () => resolve(body));
|
||||
request.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function listen(server: ReturnType<typeof createServer>): Promise<number> {
|
||||
return new Promise((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address() as AddressInfo;
|
||||
resolve(address.port);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function closeHttpServer(
|
||||
server: ReturnType<typeof createServer>,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function closeWebSocketServer(server: WebSocketServer): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe("foundation integration", () => {
|
||||
const cleanup: Array<() => Promise<void>> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(cleanup.splice(0).map((close) => close()));
|
||||
});
|
||||
|
||||
it("sends JSON requests through a real HTTP server", async () => {
|
||||
let captured: CapturedRequest | undefined;
|
||||
const server = createServer(async (request, response: ServerResponse) => {
|
||||
captured = {
|
||||
method: request.method,
|
||||
url: request.url,
|
||||
headers: request.headers,
|
||||
body: await readRequestBody(request),
|
||||
};
|
||||
response.writeHead(200, { "Content-Type": "application/json" });
|
||||
response.end(JSON.stringify({ ok: true }));
|
||||
});
|
||||
const port = await listen(server);
|
||||
cleanup.push(() => closeHttpServer(server));
|
||||
|
||||
const client = new HttpClient({
|
||||
baseUrl: `http://127.0.0.1:${port}`,
|
||||
apiKey: "api-key",
|
||||
token: "jwt-token",
|
||||
hostToken: "host-token",
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.post(
|
||||
"/v1/foundation",
|
||||
{ hello: "world" },
|
||||
{ params: { page: 1 } },
|
||||
),
|
||||
).resolves.toEqual({ ok: true });
|
||||
|
||||
expect(captured).toMatchObject({
|
||||
method: "POST",
|
||||
url: "/v1/foundation?page=1",
|
||||
body: JSON.stringify({ hello: "world" }),
|
||||
});
|
||||
expect(captured?.headers["content-type"]).toBe("application/json");
|
||||
expect(captured?.headers.accept).toBe("application/json");
|
||||
expect(captured?.headers.authorization).toBe("Bearer jwt-token");
|
||||
expect(captured?.headers["x-api-key"]).toBe("api-key");
|
||||
expect(captured?.headers["x-host-token"]).toBe("host-token");
|
||||
});
|
||||
|
||||
it("maps real HTTP error responses to SDK errors", async () => {
|
||||
const server = createServer((_request, response) => {
|
||||
response.writeHead(409, { "Content-Type": "application/json" });
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
error: { code: "capsule_busy", message: "Capsule is busy" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
const port = await listen(server);
|
||||
cleanup.push(() => closeHttpServer(server));
|
||||
|
||||
const client = new HttpClient({ baseUrl: `http://127.0.0.1:${port}` });
|
||||
|
||||
await expect(client.get("/v1/error")).rejects.toMatchObject({
|
||||
code: "capsule_busy",
|
||||
message: "Capsule is busy",
|
||||
statusCode: 409,
|
||||
});
|
||||
await expect(client.get("/v1/error")).rejects.toBeInstanceOf(ConflictError);
|
||||
});
|
||||
|
||||
it("aborts real HTTP requests using timeoutMs", async () => {
|
||||
const server = createServer((_request, response) => {
|
||||
setTimeout(() => {
|
||||
response.writeHead(200, { "Content-Type": "application/json" });
|
||||
response.end(JSON.stringify({ ok: true }));
|
||||
}, 200);
|
||||
});
|
||||
const port = await listen(server);
|
||||
cleanup.push(() => closeHttpServer(server));
|
||||
|
||||
const client = new HttpClient({ baseUrl: `http://127.0.0.1:${port}` });
|
||||
|
||||
await expect(
|
||||
client.get("/v1/slow", { timeoutMs: 10 }),
|
||||
).rejects.toBeInstanceOf(TimeoutError);
|
||||
});
|
||||
|
||||
it("exchanges JSON messages through a real WebSocket server", async () => {
|
||||
const server = new WebSocketServer({ port: 0, host: "127.0.0.1" });
|
||||
cleanup.push(() => closeWebSocketServer(server));
|
||||
|
||||
const receivedByServer = new Promise<unknown>((resolve) => {
|
||||
server.on("connection", (socket, request) => {
|
||||
expect(request.headers["x-api-key"]).toBe("api-key");
|
||||
expect(request.headers["x-host-token"]).toBe("host-token");
|
||||
socket.on("message", (raw) => resolve(JSON.parse(raw.toString())));
|
||||
socket.send(JSON.stringify({ type: "ready" }));
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => server.once("listening", resolve));
|
||||
const address = server.address() as AddressInfo;
|
||||
|
||||
const messages: unknown[] = [];
|
||||
const connection = await WsConnection.connect({
|
||||
baseUrl: `http://127.0.0.1:${address.port}`,
|
||||
path: "/v1/ws",
|
||||
apiKey: "api-key",
|
||||
hostToken: "host-token",
|
||||
onMessage: (message) => messages.push(message),
|
||||
});
|
||||
|
||||
connection.send({ type: "start" });
|
||||
|
||||
await expect(receivedByServer).resolves.toEqual({ type: "start" });
|
||||
expect(messages).toEqual([{ type: "ready" }]);
|
||||
expect(connection.isClosed).toBe(false);
|
||||
|
||||
connection.close();
|
||||
});
|
||||
});
|
||||
@ -2,10 +2,13 @@ import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts"],
|
||||
format: ["cjs", "esm"],
|
||||
dts: { resolve: true },
|
||||
format: ["esm", "cjs"],
|
||||
outExtension({ format }) {
|
||||
return { js: format === "esm" ? ".js" : ".cjs" };
|
||||
},
|
||||
outDir: "dist",
|
||||
clean: true,
|
||||
sourcemap: true,
|
||||
minify: false,
|
||||
dts: { resolve: true },
|
||||
});
|
||||
|
||||
11
vitest.integration.config.ts
Normal file
11
vitest.integration.config.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
exclude: ["**/node_modules/**", "**/dist/**"],
|
||||
hookTimeout: 10_000,
|
||||
include: ["tests/integration/**/*.test.ts"],
|
||||
testTimeout: 10_000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user