npm.io
0.1.7 • Published 4d ago

@boxd-sh/sdk

Licence
MIT
Version
0.1.7
Deps
2
Size
909 kB
Vulns
0
Weekly
101

@boxd-sh/sdk

TypeScript SDK for the boxd cloud VM platform.

Promise-only, ESM-only. Runs on Node 20+, Bun, and Deno.

Install

npm install @boxd-sh/sdk
# or: bun add @boxd-sh/sdk

Quick Start

import { Compute } from "@boxd-sh/sdk";

const c = new Compute({ apiKey: "bxk_..." });

const box = await c.box.create({ name: "my-vm" });
const result = await box.exec(["echo", "hello"]);
console.log(result.stdout);

await box.destroy();
await c.close();

Authentication

new Compute({ apiKey: "bxk_..." });        // API key (recommended)
new Compute({ token: "eyJ..." });          // direct JWT
new Compute();                             // reads BOXD_API_KEY or BOXD_TOKEN

Configuration

Pick a named cluster preset:

new Compute({ apiKey: "bxk_..." });                          // production (default)
new Compute({ apiKey: "bxd_...", environment: "staging" });  // boxd-stg.sh

environment also reads from the BOXD_ENVIRONMENT env var ("production" or "staging").

For custom or self-hosted endpoints, override URLs explicitly — these take precedence over environment:

new Compute({
  apiKey: "bxk_...",
  apiUrl: "http://my-boxd.example.com:9443",
  exchangeUrl: "https://my-boxd.example.com/api/v1/auth/token",
});
Environment variables

All Compute options can be supplied via env vars. Constructor args win over env vars; env vars win over the environment preset.

Variable Sets Default
BOXD_API_KEY API key (long-lived, recommended)
BOXD_TOKEN Direct JWT (short-lived)
BOXD_ENVIRONMENT Preset name (production or staging) production
BOXD_API_URL gRPC endpoint, overrides preset http://boxd.sh:9443
BOXD_EXCHANGE_URL Token-exchange URL, overrides preset https://boxd.sh/api/v1/auth/token

apiUrl accepts an optional URL scheme that controls TLS:

apiUrl value Transport
http://host:port plaintext (scheme stripped before connecting)
https://host:port TLS (scheme stripped before connecting)
bare host:port TLS, except localhost / 127.* which stay plaintext

The default http://boxd.sh:9443 matches production. Self-hosted clusters can pass apiUrl: "http://my-cluster:9443" to opt into plaintext.

VM Lifecycle

const box = await c.box.create({ name: "my-vm" });
const boxes = await c.box.list();
const found = await c.box.get("my-vm");                   // by name or id
const forked = await c.box.fork("my-vm", { name: "f1" });

await box.start();
await box.stop();
await box.reboot();
await box.destroy();
const s = await box.suspend();   // { suspendUs }
const r = await box.resume();    // { resumeUs }
Box fields

Box exposes server-returned fields. Which fields are populated depends on how the Box was obtained:

Field create fork list get
id, name, image, publicIp, status
url, bootTimeMs
forkedFrom
restartPolicy, diskBytes, autoSuspendTimeoutSecs
sshPort

If you need the URL or boot time after a list / get round-trip, the https://<name>.boxd.sh form is stable, or call box.proxies() for the full set. If you need the lifecycle fields off a Box from list / create / fork, re-fetch via c.box.get(box.name).

BoxConfig

create, fork, and template.createVm all take an optional config:

import type { BoxConfig } from "@boxd-sh/sdk";

const config: BoxConfig = {
  vcpu: 2,                                 // default 2
  memory: "4G",                            // default "8G"
  env: { API_KEY: "secret" },              // env vars exposed to the VM
  restartPolicy: "always",                 // "always" | "never"
  lifecycle: {
    autoSuspendTimeout: 300,               // idle network secs; 0 disables
    autoDestroyTimeout: 0,                 // total lifetime secs; 0 disables
  },
};

const box = await c.box.create({ name: "my-vm", config });

Exec

// Simple — collect all output
const r = await box.exec(["python", "script.py"]);
r.stdout;     // string (subprocess fd 1)
r.stderr;     // string (subprocess fd 2)
r.exitCode;   // number
r.success;    // boolean

// With env vars and timeout
await box.exec(["sh", "-c", "echo $FOO"], {
  env: { FOO: "bar" },
  timeoutMs: 30_000,
});

// Streaming — proc.stdout and proc.stderr are separate AsyncIterables
const proc = await box.exec(["cargo", "build"], { stream: true });
for await (const chunk of proc.stdout) process.stdout.write(chunk);
// (read proc.stderr concurrently if you need the warnings, e.g. in a Promise.all)
const code = await proc.wait();

// Headless one-shot — close stdin immediately so commands like
// `claude -p`, `jq`, `cat` see EOF and don't hang waiting for input.
const r2 = await box.exec(
  ["claude", "-p", "summarize this PR"],
  { stream: true, closeStdin: true },
);

// Interactive (PTY + stdin) — stdin must stay open for user input.
// `closeStdin: true` is REJECTED with this combination (throws).
const sh = await box.exec(["bash"], { stream: true, pty: true });
sh.stdin.write("echo hello\n");
await sh.stdin.end();
Streams: stdout vs stderr

proc.stderr / r.stderr is populated for non-PTY execs. The subprocess's fd 2 is delivered separately from fd 1 — useful when a tool's progress lives on stderr while the answer is on stdout (e.g. codex exec, cargo build).

Under pty: true / interactive: true, the kernel TTY layer merges stderr into stdout (that's how terminals work), so everything arrives on proc.stdout and proc.stderr stays empty. Set pty: false if you need the split.

closeStdin

Set closeStdin: true (only valid with stream: true on a non-PTY exec) to have the SDK close the client send half of the bidi stream right after the command starts. Headless one-shots that read stdin (claude -p, jq, cat file) see EOF immediately and proceed; without it they hang for several seconds (or forever) waiting on stdin. Passing it together with pty: true or interactive: true throws a clear error rather than silently dropping the flag — a PTY shell needs stdin open for user input.

PTY size + resize

For PTY/interactive execs, pass cols and rows to set the initial terminal geometry, and call proc.resize(cols, rows) to update it mid-session when the local terminal changes size. This is what makes TUI apps like claude, vim, htop render at the right width.

const proc = await box.exec(["claude"], {
  stream: true,
  pty: true,
  cols: process.stdout.columns,
  rows: process.stdout.rows,
});

// Forward local SIGWINCH so resize propagates into the VM's PTY.
process.stdout.on("resize", () => {
  proc.resize(process.stdout.columns, process.stdout.rows);
});

// Forward raw bytes both directions; the local terminal renders ANSI.
process.stdin.setRawMode?.(true);
process.stdin.on("data", (d) => proc.stdin.write(d));
for await (const chunk of proc.stdout) process.stdout.write(chunk);

Zero / unset cols and rows fall back to the server default of 80×24. resize() on a non-PTY exec is a harmless no-op.

Files

await box.writeFile("/app/file.txt", "text content");
await box.writeFile("/app/file.bin", new Uint8Array([1, 2, 3]));
await box.writeFile("/app/file.py", { fromPath: "local.py" });    // Node/Bun only
const data = await box.readFile("/app/output.json");              // Uint8Array

Proxies

await box.proxies();                              // Proxy[]
await box.createProxy("api", 3001);               // api.<vm>.boxd.sh -> port 3001
await box.setProxyPort(3000);                     // change default proxy port
await box.setProxyPort(3001, { name: "api" });    // change a named proxy
await box.deleteProxy("api");

Logs

// Snapshot of available console output
for await (const chunk of box.streamLogs()) {
  process.stdout.write(chunk);
}

// Follow (keeps the stream open for new chunks)
for await (const chunk of box.streamLogs({ follow: true })) {
  process.stdout.write(chunk);
}

Templates

Reusable image + BoxConfig frozen together.

const t = await c.template.create({
  name: "t1",
  image: "ghcr.io/org/img:tag",
  config: { vcpu: 2, memory: "4G" },
});
await c.template.list();

// createVm accepts a Template object OR a template ID string. Pass an
// optional `config` to override the template's defaults (e.g. bump memory
// for one specific VM).
const box = await c.template.createVm({ template: t, name: "from-t" });
const big = await c.template.createVm({
  template: t.id,
  name: "from-t-big",
  config: { memory: "16G" },
});
await c.template.delete(t.id);

Disks

const d = await c.disk.create("data", "10G");
d.id; d.name; d.sizeBytes; d.status;

// attach / detach take a Box instance OR a name/id string
await d.attach(box, "/mnt/data");
await d.attach("my-vm", "/mnt/data", { readOnly: true });
await d.detach("my-vm");

await d.destroy();

// list returns DiskHandle[] — same methods as above
for (const d of await c.disk.list()) {
  console.log(d.name, d.status);
}

Domains

Bind an external domain (DNS must already point at the boxd proxy).

await c.domain.bind("app.example.com", box);            // accepts Box, name, or id
await c.domain.bind("app.example.com", "my-vm");
await c.domain.list();                                   // [{ domain, vmId }]
await c.domain.unbind("app.example.com");

Networks

const n = await c.network.create();              // server assigns id
const named = await c.network.create("staging");

// `create` returns the new network's id only — `subnet` and `status` come
// back populated once provisioning settles. Re-fetch via `list` to read them.
for (const net of await c.network.list()) {
  console.log(net.id, net.subnet, net.status);
}

Tokens

Issue scoped JWTs for delegated access. The raw token string is only returned at creation — store it then.

const t = await c.token.create(3600);                    // TTL in seconds; 0 = server default
t.token;          // "eyJ..." — save this; list() will not return it
t.expiresAt;      // unix seconds

for (const info of await c.token.list()) {
  console.log(info.jti, info.createdAt, info.expiresAt);
}
await c.token.revoke(info.jti);

// Use the token to authenticate a new client
const c2 = new Compute({ token: t.token });

Identity

const me = await c.whoami();
me.userId;              // "gh-username"
me.fingerprints;        // ["SHA256:..."]
me.defaultNetworkId;    // "net-..."

const cfg = await c.config();
cfg.defaultImage;       // "ubuntu:latest"
cfg.zone;               // "boxd.sh"

The package also exports a VERSION constant matching package.json:

import { VERSION } from "@boxd-sh/sdk";
console.log("on", VERSION);

Errors

import { NotFoundError } from "@boxd-sh/sdk";

try {
  await c.box.get("nope");
} catch (e) {
  if (e instanceof NotFoundError) { /* ... */ }
}

All errors extend BoxdError. Subclasses:

Class gRPC status
AuthenticationError UNAUTHENTICATED, PERMISSION_DENIED
NotFoundError NOT_FOUND
QuotaExceededError RESOURCE_EXHAUSTED
InvalidArgumentError INVALID_ARGUMENT, ALREADY_EXISTS
TimeoutError DEADLINE_EXCEEDED
ConnectionError UNAVAILABLE
InternalError INTERNAL, UNKNOWN

Each error carries the underlying grpcCode (numeric gRPC status — see grpc.StatusCode) for finer-grained handling:

try {
  await c.box.create({ name: "my-vm" });
} catch (e) {
  if (e instanceof BoxdError && e.grpcCode === 8 /* RESOURCE_EXHAUSTED */) {
    // hit per-user quota — e.g. surface a 'wait or upgrade' UI
  }
  throw e;
}

Update notifications

Every gRPC response carries an x-boxd-ts-sdk-latest header set by the boxd proxy. The SDK's interceptor compares it to the installed version and prints a one-time stderr line via console.warn if a newer release is available:

A new version of @boxd-sh/sdk is available (v0.1.2, you have v0.1.1). Update with:
  npm install @boxd-sh/sdk@latest

The notice fires at most once per process, never causes a request to fail, and is silent if the proxy isn't advertising a newer version. Compares as semver-ish (numeric prefix, then per-component compare on -dev.N suffixes).

Async Disposal

Node 20+, Bun, and Deno support TC39 explicit-resource-management. Compute implements Symbol.asyncDispose:

{
  await using c = new Compute({ apiKey: "bxk_..." });
  const box = await c.box.create({ name: "my-vm" });
  // c.close() called automatically at scope exit
}

Development

cd sdk/typescript
bun install
bun run proto       # regenerate _generated/ from proto/api/v1/api.proto
bun run typecheck
bun run build
bun test tests/     # unit tests
BOXD_RUN_E2E=1 bun test tests/e2e.test.ts  # e2e (needs a running boxd cluster)

Architecture

sdk/typescript/
├── src/
│   ├── index.ts          # public API exports
│   ├── client.ts         # Compute (entry point) + auth/transport
│   ├── auth.ts           # API key → JWT exchange + refresh
│   ├── boxes.ts          # BoxService (create/list/get/fork)
│   ├── box.ts            # Box (lifecycle/exec/files/proxies/logs)
│   ├── exec.ts           # ExecResult, ExecProcess, byte queues
│   ├── templates.ts      # TemplateService
│   ├── disks.ts          # DiskService + DiskHandle
│   ├── domains.ts        # DomainService
│   ├── networks.ts       # NetworkService
│   ├── tokens.ts         # TokenService
│   ├── types.ts          # public type definitions
│   ├── errors.ts         # BoxdError hierarchy + gRPC mapping
│   ├── utils.ts          # parseSize, resolveEndpoint
│   └── _generated/       # ts-proto output (committed)
└── tests/                # bun:test unit + gated e2e

Keywords