- Update generated types from new openapi.yaml (capsule stats, usage, metrics, pause/resume lifecycle, host/channel management, auth flow) - Add Capsule pause/resume/ping/getMetrics lifecycle methods - Add Capsule.waitForReady abort signal support - Add PtyManager.connect and PtySession disposal - Fix HttpClient empty-body response handling (content-length: 0) - Add streamProcess() to CommandManager for background process streams - Add integration tests for capsule lifecycle, git, and PTY features - Add unit tests for AsyncQueue error paths, PtySession.close, Git.checkout without create, Git.add single string, Notebook.execCell error case, and PtyStartOptions fields
319 lines
9.7 KiB
TypeScript
319 lines
9.7 KiB
TypeScript
import { TimeoutError, throwErrorFromResponse } from "../exceptions.js";
|
|
|
|
/** Configuration for the low-level HTTP client. */
|
|
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.
|
|
*
|
|
* @param path - API path relative to the configured base URL.
|
|
* @param opts - Optional query, header, auth, timeout, and cancellation settings.
|
|
* @returns Parsed JSON response.
|
|
* @throws TimeoutError when the configured timeout aborts the request.
|
|
* @throws WrennError subclasses for unsuccessful responses.
|
|
*/
|
|
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.
|
|
*
|
|
* @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 JSON response.
|
|
* @throws TimeoutError when the configured timeout aborts the request.
|
|
* @throws WrennError subclasses for unsuccessful responses.
|
|
*/
|
|
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.
|
|
*
|
|
* @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 JSON response.
|
|
* @throws TimeoutError when the configured timeout aborts the request.
|
|
* @throws WrennError subclasses for unsuccessful responses.
|
|
*/
|
|
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.
|
|
*
|
|
* @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 JSON response.
|
|
* @throws TimeoutError when the configured timeout aborts the request.
|
|
* @throws WrennError subclasses for unsuccessful responses.
|
|
*/
|
|
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.
|
|
*
|
|
* @param path - API path relative to the configured base URL.
|
|
* @param opts - Optional query, header, auth, timeout, and cancellation settings.
|
|
* @returns Resolves when the response succeeds.
|
|
* @throws TimeoutError when the configured timeout aborts the request.
|
|
* @throws WrennError subclasses for unsuccessful responses.
|
|
*/
|
|
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.ok) {
|
|
await throwErrorFromResponse(res);
|
|
}
|
|
|
|
if (res.status === 204 || res.headers.get("content-length") === "0") {
|
|
return undefined as T;
|
|
}
|
|
|
|
if (opts?.asText) {
|
|
return (await res.text()) as T;
|
|
}
|
|
|
|
const text = await res.text();
|
|
if (!text) return undefined as T;
|
|
|
|
return JSON.parse(text) 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();
|
|
}
|
|
}
|