Compare commits
2 Commits
main
...
5b3f2741a3
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b3f2741a3 | |||
| db7fccbaed |
2
.gitignore
vendored
2
.gitignore
vendored
@ -136,3 +136,5 @@ dist
|
|||||||
.yarn/install-state.gz
|
.yarn/install-state.gz
|
||||||
.pnp.*
|
.pnp.*
|
||||||
|
|
||||||
|
# AI agents
|
||||||
|
.opencode
|
||||||
|
|||||||
67
AGENTS.md
Normal file
67
AGENTS.md
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
## Project
|
||||||
|
|
||||||
|
Wrenn JavaScript SDK — a client library for the Wrenn microVM platform. e2b drop-in replacement.
|
||||||
|
Package name: `@wrenn/sdk`. Node.js 18+, TypeScript 5.5+, managed with [pnpm](https://pnpm.io/).
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install # install deps
|
||||||
|
make lint # biome check + format check (no auto-fix)
|
||||||
|
make test # unit tests only (vitest)
|
||||||
|
make test-integration # all tests including integration (needs live server)
|
||||||
|
make generate # regenerate types from OpenAPI spec (openapi-typescript)
|
||||||
|
make check # lint + unit test
|
||||||
|
make build # tsup build (CJS + ESM + DTS)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `make test` runs all non-integration tests. To run a specific test file: `pnpm vitest run tests/commands.test.ts`
|
||||||
|
- No separate typecheck step — `vitest` and `tsup` handle type checking during test/build. `tsc --noEmit` is available but not wired up in CI.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- `src/` — the library package
|
||||||
|
- `capsule.ts` — high-level `Capsule` class (main user-facing class)
|
||||||
|
- `client.ts` — low-level `WrennClient` with `CapsulesResource` and `SnapshotsResource`
|
||||||
|
- `commands.ts` — command execution, streaming, and background process management
|
||||||
|
- `files.ts` — filesystem operations
|
||||||
|
- `pty.ts` — interactive terminal (PTY) over WebSocket
|
||||||
|
- `exceptions.ts` — typed error hierarchy (`WrennError` base)
|
||||||
|
- `models/generated.ts` — **auto-generated** from OpenAPI spec via `openapi-typescript` (never edit directly; run `make generate`)
|
||||||
|
- `git/` — git operations inside capsules (clone, push, pull, status, branches, etc.)
|
||||||
|
- `code-interpreter/` — specialized capsule for stateful Jupyter kernel execution
|
||||||
|
- `_shared/http.ts` — thin `fetch` wrapper with auth headers, base URL, and error mapping
|
||||||
|
- `_shared/websocket.ts` — WebSocket helper wrapping `ws`
|
||||||
|
- `config.ts` — constants (`DEFAULT_BASE_URL`, env var names)
|
||||||
|
- `tests/` — unit tests use `msw` to mock HTTP; integration tests are in `tests/integration/`
|
||||||
|
- `api/openapi.yaml` — OpenAPI spec used for type generation
|
||||||
|
|
||||||
|
## Key Conventions
|
||||||
|
|
||||||
|
- Generated types live in `src/models/generated.ts`. Never edit them. Run `make generate` to update.
|
||||||
|
- No sync/async split — JS is naturally async. One `Capsule` class, all methods return `Promise`.
|
||||||
|
- `Sandbox` is a deprecated alias for `Capsule`. New code should use `Capsule`.
|
||||||
|
- Uses native `fetch` for HTTP (Node 18+), `ws` for WebSockets, `zod` for runtime validation.
|
||||||
|
- Resource disposal via `Symbol.asyncDispose` (`await using`). Also supports manual `.close()`.
|
||||||
|
- Streaming methods return `AsyncGenerator` (e.g. `commands.stream()`, `files.downloadStream()`).
|
||||||
|
- Static + instance method pattern: `capsule.destroy()` (instance) and `Capsule.destroy(id, opts)` (static).
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Unit tests mock HTTP via `msw` (Mock Service Worker for Node).
|
||||||
|
- Integration tests require env vars: `WRENN_API_KEY` (or `WRENN_TOKEN`), optionally `WRENN_BASE_URL`.
|
||||||
|
- Integration test fixtures in `tests/integration/setup.ts` create real capsules and clean them up.
|
||||||
|
- Tests use `vitest` — no `@jest` globals. Use `import { describe, it, expect } from 'vitest'`.
|
||||||
|
|
||||||
|
## CI
|
||||||
|
|
||||||
|
Woodpecker CI (`.woodpecker/check.yml`) runs on push to `main` and `dev`:
|
||||||
|
1. `make lint`
|
||||||
|
2. `make test`
|
||||||
|
3. `make test-integration`
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
Runtime: `ws` (WebSocket), `zod` (validation). Everything else is dev-only.
|
||||||
29
Makefile
Normal file
29
Makefile
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
# Makefile
|
||||||
|
.PHONY: generate lint test test-integration check build
|
||||||
|
|
||||||
|
SPEC_URL = "https://raw.githubusercontent.com/wrennhq/wrenn/refs/heads/main/internal/api/openapi.yaml"
|
||||||
|
SPEC_PATH = "api/openapi.yaml"
|
||||||
|
|
||||||
|
generate:
|
||||||
|
@echo "Fetching latest OpenAPI spec from Git repo..."
|
||||||
|
mkdir -p api
|
||||||
|
curl -fsSL $(SPEC_URL) -o $(SPEC_PATH)
|
||||||
|
@echo "Generating TypeScript types..."
|
||||||
|
mkdir -p src/models
|
||||||
|
pnpm generate
|
||||||
|
|
||||||
|
lint:
|
||||||
|
pnpm exec biome check .
|
||||||
|
|
||||||
|
test:
|
||||||
|
pnpm vitest run --exclude tests/integration
|
||||||
|
|
||||||
|
test-integration:
|
||||||
|
pnpm test:integration
|
||||||
|
|
||||||
|
check:
|
||||||
|
$(MAKE) lint
|
||||||
|
$(MAKE) test
|
||||||
|
|
||||||
|
build:
|
||||||
|
pnpm build
|
||||||
3174
api/openapi.yaml
Normal file
3174
api/openapi.yaml
Normal file
File diff suppressed because it is too large
Load Diff
35
biome.json
Normal file
35
biome.json
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
|
||||||
|
"vcs": {
|
||||||
|
"enabled": true,
|
||||||
|
"clientKind": "git",
|
||||||
|
"useIgnoreFile": true
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"ignoreUnknown": false,
|
||||||
|
"includes": ["**", "!src/models/generated.ts", "!dist"]
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true,
|
||||||
|
"indentStyle": "tab"
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": {
|
||||||
|
"recommended": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"javascript": {
|
||||||
|
"formatter": {
|
||||||
|
"quoteStyle": "double"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"assist": {
|
||||||
|
"enabled": true,
|
||||||
|
"actions": {
|
||||||
|
"source": {
|
||||||
|
"organizeImports": "on"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
44
package.json
Normal file
44
package.json
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"name": "js-sdk",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Wrenn JavaScript SDK — a client library for the Wrenn microVM platform.",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.cjs",
|
||||||
|
"module": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js",
|
||||||
|
"require": "./dist/index.cjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsup",
|
||||||
|
"check": "make lint && make test",
|
||||||
|
"test": "vitest run",
|
||||||
|
"lint": "make lint",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:integration": "vitest run --config vitest.integration.config.ts",
|
||||||
|
"generate": "openapi-typescript api/openapi.yaml --output src/models/generated.ts",
|
||||||
|
"format": "biome format --write ."
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"packageManager": "pnpm@10.26.1",
|
||||||
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "^2.4.14",
|
||||||
|
"@types/node": "^25.6.0",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
|
"msw": "^2.14.3",
|
||||||
|
"openapi-typescript": "^7.13.0",
|
||||||
|
"tsup": "^8.5.1",
|
||||||
|
"typescript": "^6.0.3",
|
||||||
|
"vitest": "^4.1.5"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"ws": "^8.20.0",
|
||||||
|
"zod": "^4.4.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
2321
pnpm-lock.yaml
generated
Normal file
2321
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
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";
|
||||||
4252
src/models/generated.ts
Normal file
4252
src/models/generated.ts
Normal file
File diff suppressed because it is too large
Load Diff
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
45
tsconfig.json
Normal file
45
tsconfig.json
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
// Visit https://aka.ms/tsconfig to read more about this file
|
||||||
|
"compilerOptions": {
|
||||||
|
// File Layout
|
||||||
|
// "rootDir": "./src",
|
||||||
|
// "outDir": "./dist",
|
||||||
|
|
||||||
|
// Environment Settings
|
||||||
|
// See also https://aka.ms/tsconfig/module
|
||||||
|
"module": "nodenext",
|
||||||
|
"target": "esnext",
|
||||||
|
"types": [],
|
||||||
|
// For nodejs:
|
||||||
|
// "lib": ["esnext"],
|
||||||
|
// "types": ["node"],
|
||||||
|
// and npm install -D @types/node
|
||||||
|
|
||||||
|
// Other Outputs
|
||||||
|
"sourceMap": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
|
||||||
|
// Stricter Typechecking Options
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"exactOptionalPropertyTypes": true,
|
||||||
|
|
||||||
|
// Style Options
|
||||||
|
// "noImplicitReturns": true,
|
||||||
|
// "noImplicitOverride": true,
|
||||||
|
// "noUnusedLocals": true,
|
||||||
|
// "noUnusedParameters": true,
|
||||||
|
// "noFallthroughCasesInSwitch": true,
|
||||||
|
// "noPropertyAccessFromIndexSignature": true,
|
||||||
|
|
||||||
|
// Recommended Options
|
||||||
|
"strict": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noUncheckedSideEffectImports": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"ignoreDeprecations": "6.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
14
tsup.config.ts
Normal file
14
tsup.config.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { defineConfig } from "tsup";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
entry: ["src/index.ts"],
|
||||||
|
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