Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,16 @@
- `yarn install` — install dependencies (also runs `yarn build` via `prepare`)
- `yarn build` — compile TypeScript
- `yarn lint` — ESLint
- `yarn test` — vitest (unit + contract tests pass without a running API; e2e/integration tests need `HYPERBROWSER_API_KEY`)
- `yarn test` — unit + local integration tests; no API credentials or Docker daemon needed
- `yarn test:unit` / `yarn test:integration` — run either local test group
- `yarn test:e2e` — live API tests; requires `HYPERBROWSER_API_KEY`
- `yarn format` — Prettier

### Gotchas

- `yarn install` triggers the `prepare` script which runs `yarn build`. If the
build fails on install, check for TypeScript errors in `src/`.
- Integration and e2e tests (`tests/sandbox/e2e/`, `tests/integration/`) require
a running Hyperbrowser API and a valid `HYPERBROWSER_API_KEY`. Without these,
only the contract/unit tests in the suite will pass; the e2e tests fail with
`ECONNREFUSED`.
- `vitest.config.ts` defines unit, integration, and e2e projects. The default
selection (including watch mode) runs only unit and integration tests.
- Only live tests in `tests/e2e/` load env files and require a running
Hyperbrowser API and a valid `HYPERBROWSER_API_KEY`.
190 changes: 190 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,70 @@ await sandbox.stop();

`connect()` refreshes runtime auth and throws if the sandbox is no longer running.

Run commands and stream complete output. `exec()` and `processes.start()` open a
single streamed request and collect stdout/stderr from process start, so output is
complete even beyond the receiver's replay window. `wait()` timeouts are local and
keep collecting; `disconnect()` stops collecting without killing the process.

```typescript
const result = await sandbox.exec("npm test", {
cwd: "/workspace",
maxOutputBytes: 128 * 1024 * 1024, // default 64 MiB; exceeding it fails, never truncates
});
console.log(result.exitCode, result.stdout);

const proc = await sandbox.processes.start("tail -f /var/log/app.log");
try {
await proc.wait({ timeoutSec: 5 });
} catch (error) {
// Local wait timeout: the process is still running and output is still collected.
}
for await (const event of proc.stream()) {
if (event.type === "stdout") process.stdout.write(event.data);
if (event.type === "exit") console.log(event.result.exitCode);
}
proc.disconnect();
```

Build a custom sandbox image from a Dockerfile or a local Docker image. `getOrBuildImage()`
derives a content-based image name, reuses a ready image with the same identity, joins a
matching in-progress build, or creates a new one. Dockerfile builds package the effective
build context (Dockerfile sources, `.dockerignore`) and build remotely; no local Docker is
required. `dockerImage` imports a local `linux/amd64` image via the Docker CLI.

```typescript
const resolved = await client.sandboxes.getOrBuildImage({
contextPath: "./services/api",
dockerfile: "Dockerfile", // relative to contextPath
imageInit: { env: { NODE_ENV: "production" }, workingDir: "/app" },
builderCpus: 4,
builderMemoryMiB: 8192,
builderScratchMiB: 20480,
});
console.log(resolved.outcome, resolved.imageName, resolved.imageId); // "reused" | "joined" | "created"

const sandbox = await client.sandboxes.create({ imageName: resolved.imageName });

// Import a local Docker image instead (requires docker CLI, linux/amd64 image):
const imported = await client.sandboxes.getOrBuildImage({ dockerImage: "myorg/app:1.2.3" });

// Detached build: return immediately and poll later.
const pending = await client.sandboxes.getOrBuildImage({ contextPath: ".", wait: false });
if (pending.build) {
const build = await client.sandboxes.waitForImageBuild(pending.build.id, {
pollInterval: 3,
timeout: 35 * 60,
});
console.log(build.status, build.imageId);
}
```

Lower-level helpers are also available: `buildImageFromDockerfile()`,
`buildImageFromDockerImage()`, `findReadyImage()`, `reuseDockerImage()`, plus the raw
`createImageBuild()` / `completeImageBuild()` / `getImageBuild()` / `listImageBuilds()` /
`cancelImageBuild()` APIs. Control-plane `GET` requests retry transient failures
(429/502/503/504 and network errors) up to three times with jittered backoff.

Create a sandbox with pre-exposed ports:

```typescript
Expand Down Expand Up @@ -339,3 +403,129 @@ const terminal = await sandbox.terminal.create({

const connection = await terminal.attach(10);
```

### Streaming file transfers and watches

`read({ format: "stream" })` retains its buffered behavior. Use `uploadStream()`
and `downloadStream()` for large transfers with backpressure and bounded memory:

```typescript
import { createReadStream, createWriteStream } from "node:fs";
import { pipeline } from "node:stream/promises";

await sandbox.files.withRunAs("root").uploadStream(
"/tmp/archive.tar", createReadStream("./archive.tar"),
);
await pipeline(sandbox.files.downloadStream("/tmp/archive.tar"), createWriteStream("./copy.tar"));
```

Uploads accept a Node readable or iterable of string/byte chunks and optional
`{ contentLength, signal }`. Downloads return an async generator of buffers and
accept `{ signal }`. Breaking download iteration closes the request. After an
authentication failure, a consumed upload is rejected with `stream_not_replayable`;
open a fresh source to retry it. `files.stat`, `mkdir`, `move`, and `delete` are
aliases; `rename(oldPath, newPath, { overwrite: false })` forwards overwrite policy.

```typescript
const watch = await sandbox.files.watch("/workspace", { recursive: true });
try {
for await (const message of watch.events({ cursor: 0, route: "ws" })) {
if (message.type === "done") break;
console.log(message.event.seq, message.event.path, message.event.op);
break; // Close this connection; the remote watch remains active.
}
// Persist watch.id and the last sequence to resume an active watch:
const resumed = await sandbox.files.getWatch(watch.id, true);
await resumed.refresh(true);
console.log(resumed.current, resumed.toJSON());
} finally {
await watch.stop();
}
```

Both watch routes (`ws` and `stream`) use WebSocket transport. Watch events include
an explicit `done` envelope. `watchDir()` remains the callback convenience API.
Stopping a watcher stops the remote watch; breaking event iteration only closes
that connection. Watch timestamps remain milliseconds; existing file metadata
continues to expose `modifiedTime` as a `Date`.

### Image identities, snapshots, and runtime sessions

Offline identity helpers are available from the package root and
`@hyperbrowser/sdk/image-builds`:

```typescript
import {
dockerBuildContextFingerprint, imageBuildName, DockerBuildContextChangedError,
} from "@hyperbrowser/sdk/image-builds";

const fingerprint = await dockerBuildContextFingerprint("./app");
const imageName = imageBuildName({ source: "dockerfile", fingerprint });
try {
await client.sandboxes.buildImageFromDockerfile({
contextPath: "./app", imageName, expectedContextFingerprint: fingerprint,
builderCpus: 4, builderMemoryMiB: 8192, builderScratchMiB: 20480,
});
} catch (error) {
if (error instanceof DockerBuildContextChangedError) {
// Recompute identity after a local edit, then retry explicitly.
} else throw error;
}

const restored = await client.sandboxes.startFromSnapshot({ snapshotName: "saved" });
const session = await restored.createRuntimeSession({ forceRefresh: true });
// Also available without keeping a handle:
await client.sandboxes.getRuntimeSession(restored.id);
```

Remote Dockerfile builds need no local Docker. Local builds/imports require Docker;
platform-specific digest lookup requires Docker API 1.49+ (Docker 28.1+). Imports
preserve image `PATH`, entrypoint/CMD, working directory, and supported environment
variables; explicit image initialization overrides take precedence. Names include
context/digest, platform, and initialization overrides. Mutable base tags and
network downloads are not resolved by fingerprinting; use `forceBuild` when needed.

Only `linux/amd64` image builds are supported. Sandbox creation preserves
`runtimeClass: "firecracker" | "gvisor-cpu"`; use response capabilities when choosing
snapshot, volume, or exposure operations. Snapshot launches cannot override image
resources. Invalid or conflicting launch sources fail before a network request.

### Timeouts, cancellation, and compatibility

- Client `timeout` is milliseconds. Normal runtime requests have separate header
and body budgets. Streaming transfers reset inactivity budgets as data moves.
- Process `timeoutMs` / `timeoutSec` limit remote execution. `wait({ timeoutMs })`
only limits local waiting and leaves collection running.
- `exec()` / `processes.start()` accept an `AbortSignal`. Aborting stops local
collection and closes its connection; the detached remote process continues.
Use `signal()` or `kill()` when you intend to stop the command.
- Process stream inactivity is limited to 60 seconds; receiver keepalives reset it.
Incomplete output, exceeded output limits, and unsupported receivers produce
structured errors. A failed streaming start is never automatically re-executed.
- Image polling `pollInterval`, `waitTimeout`, and `uploadTimeout` are seconds.
`getOrBuildImage()` defaults uploads to 600 seconds of inactivity and polling to
35 minutes. Explicit `null` disables the corresponding timeout. Polling deadlines
include control GET retries. A polling `signal` or timeout leaves accepted builds
running for other callers. Lower-level build helpers leave upload timeouts unset
unless explicitly supplied.
- `HYPERBROWSER_BASE_URL` supplies the API base URL when `baseUrl` is not provided.

Public handle classes are available from `@hyperbrowser/sdk/sandbox`; request and
response types remain under `@hyperbrowser/sdk/types`.

### Development checks

The supported Node baseline is 20.20.2. Run `yarn build`, `yarn typecheck`,
`yarn lint`, and `yarn test` for local verification. Tests share `vitest.config.ts`:

- `tests/unit`: isolated logic and mocked contracts (`yarn test:unit`).
- `tests/integration`: local HTTP, WebSocket, filesystem, and subprocess tests
(`yarn test:integration`).
- `tests/e2e`: live Hyperbrowser API tests (`yarn test:e2e`).

`yarn test` and `yarn test:watch` select unit and integration tests by default;
neither requires API credentials or a Docker daemon.

Live tests are opt-in: set `HYPERBROWSER_API_KEY` and `HYPERBROWSER_BASE_URL`, then
run `yarn test:e2e`. They create remote resources and require a receiver supporting
streamed process starts.
27 changes: 23 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,15 @@
"license": "MIT",
"scripts": {
"build": "tsc",
"lint": "eslint src/**/*.ts",
"lint": "eslint 'src/**/*.ts'",
"prepare": "yarn build",
"test": "vitest run",
"test:e2e": "vitest run tests/sandbox/e2e",
"test:integration": "vitest run tests/integration",
"test:unit": "vitest run --project unit",
"test:e2e": "vitest run --project e2e",
"test:integration": "vitest run --project integration",
"test:watch": "vitest",
"format": "prettier --write 'src/**/*.ts'"
"format": "prettier --write 'src/**/*.ts'",
"typecheck": "tsc --noEmit && tsc -p tsconfig.tests.json"
},
"files": [
"dist",
Expand Down Expand Up @@ -73,6 +75,14 @@
"./tools": {
"types": "./dist/tools/index.d.ts",
"default": "./dist/tools/index.js"
},
"./image-builds": {
"types": "./dist/image-builds.d.ts",
"default": "./dist/image-builds.js"
},
"./sandbox": {
"types": "./dist/sandbox/index.d.ts",
"default": "./dist/sandbox/index.js"
}
},
"typesVersions": {
Expand All @@ -82,7 +92,16 @@
],
"tools": [
"./dist/tools/index.d.ts"
],
"image-builds": [
"./dist/image-builds.d.ts"
],
"sandbox": [
"./dist/sandbox/index.d.ts"
]
}
},
"engines": {
"node": ">=20.20.2"
}
}
42 changes: 5 additions & 37 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,42 +19,9 @@ import { WebService } from "./services/web";
import { SandboxesService } from "./services/sandboxes";
import { VolumesService } from "./services/volumes";

export type HyperbrowserService = "control" | "runtime";

export interface HyperbrowserErrorOptions {
statusCode?: number;
code?: string;
requestId?: string;
retryable?: boolean;
service?: HyperbrowserService;
details?: unknown;
cause?: unknown;
}

export class HyperbrowserError extends Error {
public readonly statusCode?: number;
public readonly code?: string;
public readonly requestId?: string;
public readonly retryable: boolean;
public readonly service?: HyperbrowserService;
public readonly details?: unknown;
public readonly cause?: unknown;

constructor(message: string, options: number | HyperbrowserErrorOptions = {}) {
super(`[Hyperbrowser]: ${message}`);
this.name = "HyperbrowserError";

const normalized = typeof options === "number" ? { statusCode: options } : options;

this.statusCode = normalized.statusCode;
this.code = normalized.code;
this.requestId = normalized.requestId;
this.retryable = normalized.retryable ?? false;
this.service = normalized.service;
this.details = normalized.details;
this.cause = normalized.cause;
}
}
import { HyperbrowserError } from "./error";
export { HyperbrowserError } from "./error";
export type { HyperbrowserErrorOptions, HyperbrowserService } from "./error";

export class HyperbrowserClient {
public readonly sessions: SessionsService;
Expand All @@ -81,7 +48,8 @@ export class HyperbrowserClient {

constructor(config: HyperbrowserConfig = {}) {
const apiKey = config.apiKey || process.env["HYPERBROWSER_API_KEY"];
const baseUrl = config.baseUrl || "https://api.hyperbrowser.ai";
const baseUrl =
config.baseUrl || process.env["HYPERBROWSER_BASE_URL"] || "https://api.hyperbrowser.ai";
const timeout = config.timeout || 30000;
const runtimeProxyOverride = config.runtimeProxyOverride?.trim() || undefined;
if (!apiKey) {
Expand Down
54 changes: 54 additions & 0 deletions src/error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
export type HyperbrowserService = "control" | "runtime";

/** Keep diagnostics useful without retaining query credentials or URL hosts. */
export const errorRequestPath = (target: string): string => {
try {
return new URL(target, "http://sdk.invalid").pathname;
} catch {
return target.split("?", 1)[0];
}
};

export const networkErrorMessage = (error: unknown, fallback: string): string =>
error instanceof Error ? error.message || `${fallback} (${error.name || "Error"})` : fallback;

export interface HyperbrowserErrorOptions {
statusCode?: number;
code?: string;
requestId?: string;
retryable?: boolean;
service?: HyperbrowserService;
details?: unknown;
cause?: unknown;
method?: string;
path?: string;
}

export class HyperbrowserError extends Error {
public readonly statusCode?: number;
public readonly code?: string;
public readonly requestId?: string;
public readonly retryable: boolean;
public readonly service?: HyperbrowserService;
public readonly details?: unknown;
public readonly cause?: unknown;
public readonly method?: string;
public readonly path?: string;

constructor(message: string, options: number | HyperbrowserErrorOptions = {}) {
super(`[Hyperbrowser]: ${message}`);
this.name = "HyperbrowserError";

const normalized = typeof options === "number" ? { statusCode: options } : options;

this.statusCode = normalized.statusCode;
this.code = normalized.code;
this.requestId = normalized.requestId;
this.retryable = normalized.retryable ?? false;
this.service = normalized.service;
this.details = normalized.details;
this.cause = normalized.cause;
this.method = normalized.method;
this.path = normalized.path;
}
}
9 changes: 9 additions & 0 deletions src/image-builds.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/** Public, offline image identity helpers. */
export {
dockerBuildContextFingerprint,
DockerBuildContextChangedError,
} from "./sandbox/image-build/context";
export type { DockerBuildContextOptions } from "./sandbox/image-build/context";
export { imageBuildName } from "./sandbox/image-build/resolution";
export type { ImageBuildNameOptions, ImageBuildSource } from "./sandbox/image-build/resolution";
export { dockerImageDigest, DockerCommandError } from "./sandbox/image-build/docker-image";
Loading