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

287
tests/foundation.test.ts Normal file
View 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();
});
});

View 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();
});
});