Compare commits
4 Commits
main
...
52282618bd
| Author | SHA1 | Date | |
|---|---|---|---|
| 52282618bd | |||
| f1522eaa0b | |||
| 5b3f2741a3 | |||
| db7fccbaed |
2
.gitignore
vendored
2
.gitignore
vendored
@ -136,3 +136,5 @@ dist
|
||||
.yarn/install-state.gz
|
||||
.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
271
src/_shared/http.ts
Normal file
271
src/_shared/http.ts
Normal file
@ -0,0 +1,271 @@
|
||||
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;
|
||||
/** Fetch redirect behavior. Defaults to the runtime fetch default. */
|
||||
redirect?: RequestRedirect;
|
||||
}
|
||||
|
||||
/** 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 returns the raw `Response` object.
|
||||
*
|
||||
* This is intended for endpoints that intentionally return non-JSON responses,
|
||||
* such as OAuth redirects. Unlike {@link request}, this method does not map
|
||||
* non-OK statuses to SDK errors.
|
||||
*
|
||||
* @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 redirect settings.
|
||||
* @returns Raw fetch response.
|
||||
* @throws TimeoutError When the configured timeout aborts the request.
|
||||
*/
|
||||
response(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
opts?: RequestOptions,
|
||||
): Promise<Response> {
|
||||
return this.rawRequest(method, path, body, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (opts?.redirect) init.redirect = opts.redirect;
|
||||
|
||||
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()}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
1412
src/client.ts
Normal file
1412
src/client.ts
Normal file
File diff suppressed because it is too large
Load Diff
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);
|
||||
}
|
||||
46
src/index.ts
Normal file
46
src/index.ts
Normal file
@ -0,0 +1,46 @@
|
||||
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 {
|
||||
FileUploadInput,
|
||||
OperationJsonBody,
|
||||
OperationJsonResponse,
|
||||
OperationQueryParams,
|
||||
OperationRequestOptions,
|
||||
} from "./client.js";
|
||||
export {
|
||||
AccountResource,
|
||||
APIKeysResource,
|
||||
AuthResource,
|
||||
CapsulesResource,
|
||||
ChannelsResource,
|
||||
FilesResource,
|
||||
HostsResource,
|
||||
SnapshotsResource,
|
||||
TeamsResource,
|
||||
UsersResource,
|
||||
WrennClient,
|
||||
} from "./client.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
492
tests/client.test.ts
Normal file
492
tests/client.test.ts
Normal file
@ -0,0 +1,492 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { WrennClient } from "../src/client.js";
|
||||
|
||||
interface CapturedRequest {
|
||||
url: string;
|
||||
init: RequestInit;
|
||||
}
|
||||
|
||||
function setupFetch(status = 200, body: unknown = { ok: true }) {
|
||||
const calls: CapturedRequest[] = [];
|
||||
const fetchMock = vi.fn(
|
||||
async (url: string | URL | Request, init?: RequestInit) => {
|
||||
calls.push({ url: String(url), init: init ?? {} });
|
||||
if (status === 204) return new Response(null, { status });
|
||||
return Response.json(body, { status });
|
||||
},
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return { calls, fetchMock };
|
||||
}
|
||||
|
||||
function expectLastCall(
|
||||
calls: CapturedRequest[],
|
||||
expected: { method: string; url: string; body?: unknown },
|
||||
) {
|
||||
const call = calls.at(-1);
|
||||
expect(call?.url).toBe(expected.url);
|
||||
expect(call?.init.method).toBe(expected.method);
|
||||
if (expected.body !== undefined) {
|
||||
expect(call?.init.body).toBe(JSON.stringify(expected.body));
|
||||
}
|
||||
}
|
||||
|
||||
describe("WrennClient", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("initializes every resource with resolved auth headers", async () => {
|
||||
const { calls } = setupFetch();
|
||||
const client = new WrennClient({
|
||||
apiKey: "api-key",
|
||||
baseUrl: "https://api.example.com/",
|
||||
hostToken: "host-token",
|
||||
token: "jwt-token",
|
||||
});
|
||||
|
||||
await client.auth.login({ email: "a@example.com", password: "password" });
|
||||
|
||||
expect(client.account).toBeDefined();
|
||||
expect(client.apiKeys).toBeDefined();
|
||||
expect(client.users).toBeDefined();
|
||||
expect(client.teams).toBeDefined();
|
||||
expect(client.capsules).toBeDefined();
|
||||
expect(client.files).toBeDefined();
|
||||
expect(client.snapshots).toBeDefined();
|
||||
expect(client.hosts).toBeDefined();
|
||||
expect(client.channels).toBeDefined();
|
||||
expect(calls.at(-1)?.init.headers).toMatchObject({
|
||||
Accept: "application/json",
|
||||
Authorization: "Bearer jwt-token",
|
||||
"Content-Type": "application/json",
|
||||
"X-API-Key": "api-key",
|
||||
"X-Host-Token": "host-token",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps auth endpoints", async () => {
|
||||
const { calls } = setupFetch();
|
||||
const client = new WrennClient({ baseUrl: "https://api.example.com" });
|
||||
|
||||
await client.auth.signup({} as never);
|
||||
expectLastCall(calls, {
|
||||
body: {},
|
||||
method: "POST",
|
||||
url: "https://api.example.com/v1/auth/signup",
|
||||
});
|
||||
|
||||
await client.auth.activate({ token: "activation-token" });
|
||||
expectLastCall(calls, {
|
||||
body: { token: "activation-token" },
|
||||
method: "POST",
|
||||
url: "https://api.example.com/v1/auth/activate",
|
||||
});
|
||||
|
||||
await client.auth.login({} as never);
|
||||
expectLastCall(calls, {
|
||||
body: {},
|
||||
method: "POST",
|
||||
url: "https://api.example.com/v1/auth/login",
|
||||
});
|
||||
|
||||
await client.auth.oauthRedirect("github", { redirect: "manual" });
|
||||
expectLastCall(calls, {
|
||||
method: "GET",
|
||||
url: "https://api.example.com/v1/auth/oauth/github",
|
||||
});
|
||||
expect(calls.at(-1)?.init.redirect).toBe("manual");
|
||||
|
||||
await client.auth.oauthCallback("github", { code: "code", state: "state" });
|
||||
expectLastCall(calls, {
|
||||
method: "GET",
|
||||
url: "https://api.example.com/v1/auth/oauth/github/callback?code=code&state=state",
|
||||
});
|
||||
|
||||
await client.auth.switchTeam({ team_id: "team_1" });
|
||||
expectLastCall(calls, {
|
||||
body: { team_id: "team_1" },
|
||||
method: "POST",
|
||||
url: "https://api.example.com/v1/auth/switch-team",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps account, API key, user, and team endpoints", async () => {
|
||||
const { calls } = setupFetch();
|
||||
const client = new WrennClient({ baseUrl: "https://api.example.com" });
|
||||
|
||||
await client.account.getMe();
|
||||
expectLastCall(calls, {
|
||||
method: "GET",
|
||||
url: "https://api.example.com/v1/me",
|
||||
});
|
||||
await client.account.updateName({ name: "New Name" });
|
||||
expectLastCall(calls, {
|
||||
body: { name: "New Name" },
|
||||
method: "PATCH",
|
||||
url: "https://api.example.com/v1/me",
|
||||
});
|
||||
await client.account.deleteAccount({ confirmation: "a@example.com" });
|
||||
expectLastCall(calls, {
|
||||
body: { confirmation: "a@example.com" },
|
||||
method: "DELETE",
|
||||
url: "https://api.example.com/v1/me",
|
||||
});
|
||||
await client.account.changePassword({} as never);
|
||||
expectLastCall(calls, {
|
||||
body: {},
|
||||
method: "POST",
|
||||
url: "https://api.example.com/v1/me/password",
|
||||
});
|
||||
await client.account.requestPasswordReset({ email: "a@example.com" });
|
||||
expectLastCall(calls, {
|
||||
body: { email: "a@example.com" },
|
||||
method: "POST",
|
||||
url: "https://api.example.com/v1/me/password/reset",
|
||||
});
|
||||
await client.account.confirmPasswordReset({
|
||||
new_password: "password",
|
||||
token: "token",
|
||||
});
|
||||
expectLastCall(calls, {
|
||||
body: { new_password: "password", token: "token" },
|
||||
method: "POST",
|
||||
url: "https://api.example.com/v1/me/password/reset/confirm",
|
||||
});
|
||||
await client.account.connectProvider("github");
|
||||
expectLastCall(calls, {
|
||||
method: "GET",
|
||||
url: "https://api.example.com/v1/me/providers/github/connect",
|
||||
});
|
||||
await client.account.disconnectProvider("github");
|
||||
expectLastCall(calls, {
|
||||
method: "DELETE",
|
||||
url: "https://api.example.com/v1/me/providers/github",
|
||||
});
|
||||
|
||||
await client.apiKeys.list();
|
||||
expectLastCall(calls, {
|
||||
method: "GET",
|
||||
url: "https://api.example.com/v1/api-keys",
|
||||
});
|
||||
await client.apiKeys.create({} as never);
|
||||
expectLastCall(calls, {
|
||||
body: {},
|
||||
method: "POST",
|
||||
url: "https://api.example.com/v1/api-keys",
|
||||
});
|
||||
await client.apiKeys.delete("key/1");
|
||||
expectLastCall(calls, {
|
||||
method: "DELETE",
|
||||
url: "https://api.example.com/v1/api-keys/key%2F1",
|
||||
});
|
||||
|
||||
await client.users.search({ email: "alice@" });
|
||||
expectLastCall(calls, {
|
||||
method: "GET",
|
||||
url: "https://api.example.com/v1/users/search?email=alice%40",
|
||||
});
|
||||
|
||||
await client.teams.list();
|
||||
expectLastCall(calls, {
|
||||
method: "GET",
|
||||
url: "https://api.example.com/v1/teams",
|
||||
});
|
||||
await client.teams.create({ name: "Team" });
|
||||
expectLastCall(calls, {
|
||||
body: { name: "Team" },
|
||||
method: "POST",
|
||||
url: "https://api.example.com/v1/teams",
|
||||
});
|
||||
await client.teams.get("team/1");
|
||||
expectLastCall(calls, {
|
||||
method: "GET",
|
||||
url: "https://api.example.com/v1/teams/team%2F1",
|
||||
});
|
||||
await client.teams.rename("team_1", { name: "New" });
|
||||
expectLastCall(calls, {
|
||||
body: { name: "New" },
|
||||
method: "PATCH",
|
||||
url: "https://api.example.com/v1/teams/team_1",
|
||||
});
|
||||
await client.teams.delete("team_1");
|
||||
expectLastCall(calls, {
|
||||
method: "DELETE",
|
||||
url: "https://api.example.com/v1/teams/team_1",
|
||||
});
|
||||
await client.teams.listMembers("team_1");
|
||||
expectLastCall(calls, {
|
||||
method: "GET",
|
||||
url: "https://api.example.com/v1/teams/team_1/members",
|
||||
});
|
||||
await client.teams.addMember("team_1", { email: "a@example.com" });
|
||||
expectLastCall(calls, {
|
||||
body: { email: "a@example.com" },
|
||||
method: "POST",
|
||||
url: "https://api.example.com/v1/teams/team_1/members",
|
||||
});
|
||||
await client.teams.updateMemberRole("team_1", "user_1", { role: "admin" });
|
||||
expectLastCall(calls, {
|
||||
body: { role: "admin" },
|
||||
method: "PATCH",
|
||||
url: "https://api.example.com/v1/teams/team_1/members/user_1",
|
||||
});
|
||||
await client.teams.removeMember("team_1", "user_1");
|
||||
expectLastCall(calls, {
|
||||
method: "DELETE",
|
||||
url: "https://api.example.com/v1/teams/team_1/members/user_1",
|
||||
});
|
||||
await client.teams.leave("team_1");
|
||||
expectLastCall(calls, {
|
||||
method: "POST",
|
||||
url: "https://api.example.com/v1/teams/team_1/leave",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps capsule, file, and snapshot endpoints", async () => {
|
||||
const { calls } = setupFetch();
|
||||
const client = new WrennClient({ baseUrl: "https://api.example.com" });
|
||||
|
||||
await client.capsules.create({} as never);
|
||||
expectLastCall(calls, {
|
||||
body: {},
|
||||
method: "POST",
|
||||
url: "https://api.example.com/v1/capsules",
|
||||
});
|
||||
await client.capsules.list();
|
||||
expectLastCall(calls, {
|
||||
method: "GET",
|
||||
url: "https://api.example.com/v1/capsules",
|
||||
});
|
||||
await client.capsules.get("cap_1");
|
||||
expectLastCall(calls, {
|
||||
method: "GET",
|
||||
url: "https://api.example.com/v1/capsules/cap_1",
|
||||
});
|
||||
await client.capsules.destroy("cap_1");
|
||||
expectLastCall(calls, {
|
||||
method: "DELETE",
|
||||
url: "https://api.example.com/v1/capsules/cap_1",
|
||||
});
|
||||
await client.capsules.exec("cap_1", {} as never);
|
||||
expectLastCall(calls, {
|
||||
body: {},
|
||||
method: "POST",
|
||||
url: "https://api.example.com/v1/capsules/cap_1/exec",
|
||||
});
|
||||
await client.capsules.listProcesses("cap_1");
|
||||
expectLastCall(calls, {
|
||||
method: "GET",
|
||||
url: "https://api.example.com/v1/capsules/cap_1/processes",
|
||||
});
|
||||
await client.capsules.killProcess("cap_1", "pid/1", { signal: "SIGTERM" });
|
||||
expectLastCall(calls, {
|
||||
method: "DELETE",
|
||||
url: "https://api.example.com/v1/capsules/cap_1/processes/pid%2F1?signal=SIGTERM",
|
||||
});
|
||||
await client.capsules.ping("cap_1");
|
||||
expectLastCall(calls, {
|
||||
method: "POST",
|
||||
url: "https://api.example.com/v1/capsules/cap_1/ping",
|
||||
});
|
||||
await client.capsules.metrics("cap_1", { range: "10m" });
|
||||
expectLastCall(calls, {
|
||||
method: "GET",
|
||||
url: "https://api.example.com/v1/capsules/cap_1/metrics?range=10m",
|
||||
});
|
||||
await client.capsules.pause("cap_1");
|
||||
expectLastCall(calls, {
|
||||
method: "POST",
|
||||
url: "https://api.example.com/v1/capsules/cap_1/pause",
|
||||
});
|
||||
await client.capsules.resume("cap_1");
|
||||
expectLastCall(calls, {
|
||||
method: "POST",
|
||||
url: "https://api.example.com/v1/capsules/cap_1/resume",
|
||||
});
|
||||
await client.capsules.stats({ range: "1h" });
|
||||
expectLastCall(calls, {
|
||||
method: "GET",
|
||||
url: "https://api.example.com/v1/capsules/stats?range=1h",
|
||||
});
|
||||
await client.capsules.usage({ from: "2026-01-01", to: "2026-01-02" });
|
||||
expectLastCall(calls, {
|
||||
method: "GET",
|
||||
url: "https://api.example.com/v1/capsules/usage?from=2026-01-01&to=2026-01-02",
|
||||
});
|
||||
|
||||
await client.files.upload("cap_1", { file: "hello", path: "/tmp/a.txt" });
|
||||
expectLastCall(calls, {
|
||||
method: "POST",
|
||||
url: "https://api.example.com/v1/capsules/cap_1/files/write",
|
||||
});
|
||||
expect(calls.at(-1)?.init.body).toBeInstanceOf(FormData);
|
||||
await client.files.download("cap_1", {} as never);
|
||||
expectLastCall(calls, {
|
||||
body: {},
|
||||
method: "POST",
|
||||
url: "https://api.example.com/v1/capsules/cap_1/files/read",
|
||||
});
|
||||
await client.files.list("cap_1", {} as never);
|
||||
expectLastCall(calls, {
|
||||
body: {},
|
||||
method: "POST",
|
||||
url: "https://api.example.com/v1/capsules/cap_1/files/list",
|
||||
});
|
||||
await client.files.mkdir("cap_1", {} as never);
|
||||
expectLastCall(calls, {
|
||||
body: {},
|
||||
method: "POST",
|
||||
url: "https://api.example.com/v1/capsules/cap_1/files/mkdir",
|
||||
});
|
||||
await client.files.remove("cap_1", {} as never);
|
||||
expectLastCall(calls, {
|
||||
body: {},
|
||||
method: "POST",
|
||||
url: "https://api.example.com/v1/capsules/cap_1/files/remove",
|
||||
});
|
||||
await client.files.streamUpload("cap_1", {
|
||||
file: "hello",
|
||||
path: "/tmp/a.txt",
|
||||
});
|
||||
expectLastCall(calls, {
|
||||
method: "POST",
|
||||
url: "https://api.example.com/v1/capsules/cap_1/files/stream/write",
|
||||
});
|
||||
await client.files.streamDownload("cap_1", {} as never);
|
||||
expectLastCall(calls, {
|
||||
body: {},
|
||||
method: "POST",
|
||||
url: "https://api.example.com/v1/capsules/cap_1/files/stream/read",
|
||||
});
|
||||
|
||||
await client.snapshots.create({} as never, { overwrite: "true" });
|
||||
expectLastCall(calls, {
|
||||
body: {},
|
||||
method: "POST",
|
||||
url: "https://api.example.com/v1/snapshots?overwrite=true",
|
||||
});
|
||||
await client.snapshots.list({ type: "base" });
|
||||
expectLastCall(calls, {
|
||||
method: "GET",
|
||||
url: "https://api.example.com/v1/snapshots?type=base",
|
||||
});
|
||||
await client.snapshots.delete("snap/1");
|
||||
expectLastCall(calls, {
|
||||
method: "DELETE",
|
||||
url: "https://api.example.com/v1/snapshots/snap%2F1",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps host and channel endpoints", async () => {
|
||||
const { calls } = setupFetch();
|
||||
const client = new WrennClient({ baseUrl: "https://api.example.com" });
|
||||
|
||||
await client.hosts.create({} as never);
|
||||
expectLastCall(calls, {
|
||||
body: {},
|
||||
method: "POST",
|
||||
url: "https://api.example.com/v1/hosts",
|
||||
});
|
||||
await client.hosts.list();
|
||||
expectLastCall(calls, {
|
||||
method: "GET",
|
||||
url: "https://api.example.com/v1/hosts",
|
||||
});
|
||||
await client.hosts.get("host_1");
|
||||
expectLastCall(calls, {
|
||||
method: "GET",
|
||||
url: "https://api.example.com/v1/hosts/host_1",
|
||||
});
|
||||
await client.hosts.delete("host_1", { force: true });
|
||||
expectLastCall(calls, {
|
||||
method: "DELETE",
|
||||
url: "https://api.example.com/v1/hosts/host_1?force=true",
|
||||
});
|
||||
await client.hosts.regenerateToken("host_1");
|
||||
expectLastCall(calls, {
|
||||
method: "POST",
|
||||
url: "https://api.example.com/v1/hosts/host_1/token",
|
||||
});
|
||||
await client.hosts.register({} as never);
|
||||
expectLastCall(calls, {
|
||||
body: {},
|
||||
method: "POST",
|
||||
url: "https://api.example.com/v1/hosts/register",
|
||||
});
|
||||
await client.hosts.heartbeat("host_1");
|
||||
expectLastCall(calls, {
|
||||
method: "POST",
|
||||
url: "https://api.example.com/v1/hosts/host_1/heartbeat",
|
||||
});
|
||||
await client.hosts.refreshToken({} as never);
|
||||
expectLastCall(calls, {
|
||||
body: {},
|
||||
method: "POST",
|
||||
url: "https://api.example.com/v1/hosts/auth/refresh",
|
||||
});
|
||||
await client.hosts.deletePreview("host_1");
|
||||
expectLastCall(calls, {
|
||||
method: "GET",
|
||||
url: "https://api.example.com/v1/hosts/host_1/delete-preview",
|
||||
});
|
||||
await client.hosts.listTags("host_1");
|
||||
expectLastCall(calls, {
|
||||
method: "GET",
|
||||
url: "https://api.example.com/v1/hosts/host_1/tags",
|
||||
});
|
||||
await client.hosts.addTag("host_1", {} as never);
|
||||
expectLastCall(calls, {
|
||||
body: {},
|
||||
method: "POST",
|
||||
url: "https://api.example.com/v1/hosts/host_1/tags",
|
||||
});
|
||||
await client.hosts.removeTag("host_1", "gpu/a");
|
||||
expectLastCall(calls, {
|
||||
method: "DELETE",
|
||||
url: "https://api.example.com/v1/hosts/host_1/tags/gpu%2Fa",
|
||||
});
|
||||
|
||||
await client.channels.create({} as never);
|
||||
expectLastCall(calls, {
|
||||
body: {},
|
||||
method: "POST",
|
||||
url: "https://api.example.com/v1/channels",
|
||||
});
|
||||
await client.channels.list();
|
||||
expectLastCall(calls, {
|
||||
method: "GET",
|
||||
url: "https://api.example.com/v1/channels",
|
||||
});
|
||||
await client.channels.test({} as never);
|
||||
expectLastCall(calls, {
|
||||
body: {},
|
||||
method: "POST",
|
||||
url: "https://api.example.com/v1/channels/test",
|
||||
});
|
||||
await client.channels.get("channel_1");
|
||||
expectLastCall(calls, {
|
||||
method: "GET",
|
||||
url: "https://api.example.com/v1/channels/channel_1",
|
||||
});
|
||||
await client.channels.update("channel_1", {} as never);
|
||||
expectLastCall(calls, {
|
||||
body: {},
|
||||
method: "PATCH",
|
||||
url: "https://api.example.com/v1/channels/channel_1",
|
||||
});
|
||||
await client.channels.delete("channel_1");
|
||||
expectLastCall(calls, {
|
||||
method: "DELETE",
|
||||
url: "https://api.example.com/v1/channels/channel_1",
|
||||
});
|
||||
await client.channels.rotateConfig("channel_1", {} as never);
|
||||
expectLastCall(calls, {
|
||||
body: {},
|
||||
method: "PUT",
|
||||
url: "https://api.example.com/v1/channels/channel_1/config",
|
||||
});
|
||||
});
|
||||
});
|
||||
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();
|
||||
});
|
||||
});
|
||||
85
tests/integration/client.integration.test.ts
Normal file
85
tests/integration/client.integration.test.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import { WrennClient } from "../../src/client.js";
|
||||
import { DEFAULT_BASE_URL } from "../../src/config.js";
|
||||
|
||||
const baseUrl = process.env.WRENN_BASE_URL ?? DEFAULT_BASE_URL;
|
||||
const apiKey = process.env.WRENN_API_KEY;
|
||||
const testEmail = process.env.WRENN_TEST_EMAIL;
|
||||
const testPassword = process.env.WRENN_TEST_PASS;
|
||||
|
||||
const describeWithApiKey = apiKey ? describe : describe.skip;
|
||||
const describeWithLogin = testEmail && testPassword ? describe : describe.skip;
|
||||
|
||||
describeWithApiKey("WrennClient live API key integration", () => {
|
||||
const client = new WrennClient({ apiKey, baseUrl });
|
||||
|
||||
it("lists capsules from the real Wrenn API", async () => {
|
||||
const capsules = await client.capsules.list();
|
||||
|
||||
expect(Array.isArray(capsules)).toBe(true);
|
||||
});
|
||||
|
||||
it("lists snapshot templates from the real Wrenn API", async () => {
|
||||
const snapshots = await client.snapshots.list();
|
||||
|
||||
expect(Array.isArray(snapshots)).toBe(true);
|
||||
});
|
||||
|
||||
it("gets capsule stats from the real Wrenn API", async () => {
|
||||
const stats = await client.capsules.stats({ range: "1h" });
|
||||
|
||||
expect(stats).toBeTypeOf("object");
|
||||
});
|
||||
|
||||
it("gets capsule usage from the real Wrenn API", async () => {
|
||||
const usage = await client.capsules.usage();
|
||||
|
||||
expect(usage).toBeTypeOf("object");
|
||||
});
|
||||
});
|
||||
|
||||
describeWithLogin("WrennClient live login integration", () => {
|
||||
let client: WrennClient;
|
||||
|
||||
beforeAll(async () => {
|
||||
const authClient = new WrennClient({ baseUrl });
|
||||
const auth = await authClient.auth.login({
|
||||
email: testEmail as string,
|
||||
password: testPassword as string,
|
||||
});
|
||||
|
||||
expect(auth.token).toBeTypeOf("string");
|
||||
client = new WrennClient({ baseUrl, token: auth.token });
|
||||
});
|
||||
|
||||
it("gets the current account profile from the real Wrenn API", async () => {
|
||||
const me = await client.account.getMe();
|
||||
|
||||
expect(me).toBeTypeOf("object");
|
||||
});
|
||||
|
||||
it("lists teams from the real Wrenn API", async () => {
|
||||
const teams = await client.teams.list();
|
||||
|
||||
expect(Array.isArray(teams)).toBe(true);
|
||||
});
|
||||
|
||||
it("lists API keys from the real Wrenn API", async () => {
|
||||
const keys = await client.apiKeys.list();
|
||||
|
||||
expect(Array.isArray(keys)).toBe(true);
|
||||
});
|
||||
|
||||
it("lists notification channels from the real Wrenn API", async () => {
|
||||
const channels = await client.channels.list();
|
||||
|
||||
expect(Array.isArray(channels)).toBe(true);
|
||||
});
|
||||
|
||||
it("lists hosts from the real Wrenn API", async () => {
|
||||
const hosts = await client.hosts.list();
|
||||
|
||||
expect(Array.isArray(hosts)).toBe(true);
|
||||
});
|
||||
});
|
||||
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