diff --git a/.gitignore b/.gitignore index 7540ffd..48e02d9 100644 --- a/.gitignore +++ b/.gitignore @@ -138,4 +138,3 @@ dist # AI agents .opencode -PLAN.md diff --git a/package.json b/package.json index 5032bb5..7c87e7b 100644 --- a/package.json +++ b/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": { diff --git a/src/_shared/http.ts b/src/_shared/http.ts new file mode 100644 index 0000000..a3c76df --- /dev/null +++ b/src/_shared/http.ts @@ -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; + /** 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; +} + +/** 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. */ + get(path: string, opts?: RequestOptions): Promise { + return this.request("GET", path, undefined, opts); + } + + /** Sends a POST request with an optional JSON body. */ + post(path: string, body?: unknown, opts?: RequestOptions): Promise { + return this.request("POST", path, body, opts); + } + + /** Sends a PATCH request with an optional JSON body. */ + patch(path: string, body?: unknown, opts?: RequestOptions): Promise { + return this.request("PATCH", path, body, opts); + } + + /** Sends a PUT request with an optional JSON body. */ + put(path: string, body?: unknown, opts?: RequestOptions): Promise { + return this.request("PUT", path, body, opts); + } + + /** Sends a DELETE request and expects no response body. */ + 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 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 (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(); + } +} diff --git a/src/_shared/websocket.ts b/src/_shared/websocket.ts new file mode 100644 index 0000000..292aea2 --- /dev/null +++ b/src/_shared/websocket.ts @@ -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 { + 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 = {}; + 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 | 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()}`, + ), + ); + }); + }); + } +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..300dd0d --- /dev/null +++ b/src/config.ts @@ -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; +} diff --git a/src/exceptions.ts b/src/exceptions.ts new file mode 100644 index 0000000..f46772d --- /dev/null +++ b/src/exceptions.ts @@ -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 { + 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); +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..7795d8d --- /dev/null +++ b/src/index.ts @@ -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"; diff --git a/tests/foundation.test.ts b/tests/foundation.test.ts new file mode 100644 index 0000000..d5965eb --- /dev/null +++ b/tests/foundation.test.ts @@ -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((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((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(); + }); +}); diff --git a/tests/integration/foundation.integration.test.ts b/tests/integration/foundation.integration.test.ts new file mode 100644 index 0000000..7e052b4 --- /dev/null +++ b/tests/integration/foundation.integration.test.ts @@ -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 { + 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): Promise { + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const address = server.address() as AddressInfo; + resolve(address.port); + }); + }); +} + +function closeHttpServer( + server: ReturnType, +): Promise { + return new Promise((resolve, reject) => { + server.close((error) => { + if (error) reject(error); + else resolve(); + }); + }); +} + +function closeWebSocketServer(server: WebSocketServer): Promise { + return new Promise((resolve, reject) => { + server.close((error) => { + if (error) reject(error); + else resolve(); + }); + }); +} + +describe("foundation integration", () => { + const cleanup: Array<() => Promise> = []; + + 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((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((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(); + }); +}); diff --git a/tsup.config.ts b/tsup.config.ts index 3206ba6..36004ff 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -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 }, }); diff --git a/vitest.integration.config.ts b/vitest.integration.config.ts new file mode 100644 index 0000000..7bc446a --- /dev/null +++ b/vitest.integration.config.ts @@ -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, + }, +});