@xeonr/containers
v0.5.0
Published
Client SDK for the xeonr container-platform — jobs, sandboxes, workspaces, environments and apps over ConnectRPC.
Readme
@xeonr/containers
TypeScript client for the container-platform — the sandbox runtime service at
containers.xeonr.io. Jobs, environments, workspaces, sandboxes and apps,
without writing the same 500 lines of ConnectRPC glue every consumer has written
so far.
Design and rationale: container-platform/docs/SDK-DESIGN.md (internal).
pnpm add @xeonr/containers @bufbuild/protobuf @connectrpc/connectShips ESM and CommonJS. A CommonJS project imports it the same way and needs no
tsconfig change — require() of an ESM-only package needs TypeScript 5.8+, and
charging a consumer a module-system migration for adopting a client library is
not a trade worth making. The peer dependencies stay external to both halves, so
there is only ever one protobuf-es schema registry in a process.
import { createClient, egress } from "@xeonr/containers";
const cp = createClient({
endpoint: "https://containers.xeonr.io",
apiKey: process.env.CP_API_KEY, // namespace-scoped key (cpk_…)
namespace: "amelia",
});
const envId = await cp.environments.ensure(
"reg.xeonr.io/xeonr/runner-node@sha256:…",
);
const run = await cp.jobs.run({
environment: envId,
argv: ["node", "build.js"],
resources: { cpu: "500m", memory: "256Mi" },
timeout: "10m",
egress: egress.presets("npm"),
});
if (!run.ok) console.error(run.terminationName, run.exitCode, run.logs);What it gives you
The transport, correct. Always HTTP/2 in Node — over an http:// in-cluster
URL that is h2c, which is what the platform serves. There is no knob, because
every other value is wrong: client and bidi streaming over HTTP/1.1 deadlock
with no error on either side.
Errors that split the two kinds of failure. Transport and control-plane
failures throw (NotFoundError, PermissionDeniedError, QuotaExceededError,
…). A workload that exits non-zero is a value — run.ok === false — because
the RPC succeeded and told you the exit code. run.check() escalates it if you
want the exception.
Cancellation that cancels. The api runs jobs under context.WithoutCancel,
so dropping a RunJob stream leaves the pod running. Pass an AbortSignal and
the SDK issues CancelJob before letting go. { onAbort: "detach" } opts out.
environments.ensure(). RegisterStaticEnvironment always creates — by
design — so resolving a digest to an environment id needs a cache, a look-before-
register, and single-flight or a deploy mints duplicates. That is this call.
Human units in, protocol units out. "10m", "500m", "256Mi" — and
unparseable input throws rather than becoming 0, which the platform reads as
"no limit".
Streams drained once. run() returns a typed outcome and takes onLog /
onState / onAccepted callbacks. Log text is decoded across chunk boundaries,
so multi-byte characters survive; a stream that closes without its terminal
event is a named error carrying the job id, not a silent success.
Escape hatch. cp.raw.<service> is the generated client for every RPC,
modelled or not; @xeonr/containers/proto is the schema it was generated from.
Status
| | |
|---|---|
| L1 core — transport, auth, namespace, errors, retry, pagination, cancellation | ✅ |
| cp.jobs — run, stream, get, list, cancel, tailLogs | ✅ |
| cp.environments — ensure, register, build, get, list, pin, delete | ✅ |
| cp.workspaces — files, snapshots, zip import/export, presigned upload | ✅ |
| Multi-mount: mounts on sandboxes, jobs and app versions; sandbox.mount() | ✅ |
| Ephemeral scratch, seeded forks, workspaces.snapshots() across a namespace | ✅ |
| workingDir on sandboxes, jobs and app versions | ✅ |
| cp.sandboxes — exec, files, ports, egress grants, sync, lifecycle, heartbeat | ✅ |
| sandbox.processes — persistent console sessions | ✅ |
| ./recipes — ensureSandbox, agent toolkit | ✅ |
| ./testing — fakePlatform() | ✅ |
| cp.apps — versions, promotion, rollback, activation, logs | ✅ |
| Browser entrypoint — connect-web, cookie auth, bidi refused with a pointer to the WS bridge | ✅ |
The one thing deliberately not built is an apps.deploy() recipe — ensure-App-by-key,
create a version, promote. Its shape is recorded in the design doc (§6.6)
because it was expensive to learn, but the only consumer that deploys Apps today is
functions, which is Go. Build it when something in TypeScript needs it.
Sandboxes, briefly
const sandbox = await cp.sandboxes.create({
environment: envId,
mounts: [
{ workspace: ws.id }, // durable, read-write, outlives the sandbox
{ ephemeral: true, at: "/scratch" }, // created and deleted with the sandbox
{ snapshot: snapId, at: "/ref" }, // immutable, read-only, safe to share
{ cache: "npm", sizeMb: 4096, at: "/cache" }, // a real disk, kept across sandboxes, evictable
],
});
// Silence, not wall-clock, is what says a process is stuck. Partial output is kept.
const out = await sandbox.exec(["npm", "ci"], { idleTimeout: "2m" });
// The grant closes with the scope — no id to remember, no window left open.
{
await using grant = await sandbox.grantEgress(egress.presets("npm"), { reason: "install" });
await sandbox.shell("npm ci");
}
// A dev server that outlives the call, checked for an instant crash before it returns.
const dev = await sandbox.processes.start("dev", "npm run dev", { cwd: "/workspace" });
if (dev.stateName === "EXITED") console.error(dev.startupOutput);
console.log((await sandbox.expose(5173, { auth: "public" })).url);Filesystems
A sandbox carries a list of mounts, not one workspace. sandbox.files addresses
the primary mount — the first writable one, where the workload's working
directory is — so single-mount code reads exactly as it always did.
sandbox.mount(pathOrId) reaches a specific one, by its path in the pod or by the
id of the tree behind it. A snapshot mount comes back read-only in the type: a
snapshot cannot be written by anyone, so the write half is absent rather than
present-and-failing.
An ephemeral mount is scratch that dies with the sandbox — but it is a real
workspace while it lives, so the whole file API works on it and it can be
snapshotted. That is the only way to keep anything in it: snapshot before you
delete, because nothing else survives the owner.
There is no implicit /work any more. A sandbox that declares no mounts starts in
its image's own working directory with only /tmp writable, which on a read-only
rootfs is a terminal you cannot work in — [{ ephemeral: true }] is the usual
intent.
Disk-backed mounts
scratch, cache and volume are backed by a disk image rather than by the
content store, and they exist for the trees the store is the wrong shape for. The
store syncs file by file, so staging cost scales with file count rather than
bytes — which is why a node_modules was slow enough to be excluded from sync
entirely, and so vanished on every restart. A disk is one image, so the count stops
mattering.
{ scratch: true, sizeMb: 8192, at: "/work" } // dies with the pod; no sync, no bookkeeping
{ cache: "npm", sizeMb: 4096, at: "/cache" } // same key, same disk, next time — but evictable
{ volume: vol.id, at: "/workspace" } // durable, archived on a scheduleThey are not workspaces, and kindOf reports them as themselves for that
reason: nothing syncs them to the store, so the workspace file API does not address
them and treeIdOf is empty. Reach their files through the volume file API.
A volume mount takes no sizeMb — the size belongs to the volume, so set it at
creation and change it with resize. The server refuses one here rather than
honouring it, so that a mount cannot become the authority on a property the volume
owns.
sizeMb is a hard cap enforced by the filesystem inside the image — a write past it
fails with ENOSPC. That is the point of it: an ephemeral mount is charged to the
container's ephemeral-storage limit, where an overrun evicts the pod with no
error attributable to the write. The image is sparse, so the cap is a ceiling rather
than an allocation and costs nothing until it is used.
Only a volume takes from. Seeding a scratch or a cache is refused rather
than ignored: one is destroyed with the pod and the other may be evicted, so the
seed would be thrown away and the caller would be left believing their content was
in a disk that is empty.
workingDir overrides where the workload starts. Unset, a sandbox or job lands in
its first writable mount and an app gets its image's own WORKDIR — an app's
image is built elsewhere and that WORKDIR is part of its contract, so a cache or
scratch mount must not silently move it. Set it for the case no default can
express: a monorepo mounted at /workspace whose build runs in
/workspace/apps/web. sandbox.workingDir reads back resolved, so it answers
where a terminal will actually open rather than echoing the field.
Reads hit the durable store. A mount is a materialised copy synced back on a ~10s
tick: a file that has never reached the store is flushed on demand by the api, but
a file changed inside the sandbox is stale until the tick — pass { fresh: true }
to force a reconcile first. It costs a whole-tree walk and hash, which is why it
is opt-in. sandbox.sync() flushes every writable mount;
sandbox.sync({ workspace }) narrows it to one.
Apps, briefly
An App and a version have different lifetimes. The App owns the hostname and the key and lives as long as the thing it serves; a version is an immutable (image, port, filesystems) triple beneath it. Promotion is a pointer flip, which is what makes rollback free — and what makes creating an App per deploy rather than per thing the mistake that churns the URL on every push.
const app = await cp.apps.get(appId);
await app.createVersion({
environment: envId, port: 8080, promote: true,
mounts: [{ snapshot, at: "/site" }],
});
await app.rollback(); // promote the version that was active before
await app.update({ env: { KEY: "v" } }); // mask derived from the keys you passAn ephemeral mount on a version is per-replica scratch owned by the pod, not
shared state: an AppVersion is immutable and shared by every replica, and N
replicas writing one workspace is the race the single-writer rule exists to
prevent. It dies on every roll, scale-down, restart and reschedule.
update() sends only the fields present on the patch. A full message would
re-apply access and autoStart on every deploy, silently re-widening an App
that was deliberately locked down.
Recipes
@xeonr/containers/recipes is the opinionated layer — separately imported, built
only out of the core, and free to change its mind faster than the core does.
import { ensureSandbox, createAgentTools, memoryStore } from "@xeonr/containers/recipes";
// "a sandbox for this key, usable right now" — creates, resumes, or recreates
// against the RETAINED workspaces, and re-exposes the ports on every branch.
const { sandbox, workspaces, previewUrls, status } = await ensureSandbox(cp, guildId, {
store, // two methods: get(key) / set(key, value)
environment: envId,
mounts: [
{ retain: "src" }, // created once, reused forever after
{ retain: "home", at: "/home/dev" }, // a second slot, its own lifetime
{ ephemeral: true, at: "/cache" }, // fresh on every recreate, by design
],
ports: [5173],
});
// The file/command/process tools an agent needs, as provider-agnostic descriptors.
const tools = createAgentTools(cp, { scope: guildId, store, environment: envId });A retain slot creates a durable workspace on first use and reuses it forever
after — including across a sandbox that had to be recreated, which is the whole
point of the recipe. Slots are named rather than counted because source, $HOME
and a package cache want different lifetimes and ignore rules, and nesting them
under one mount forces one policy on all three. workspaces maps slot name →
workspace id.
A sandbox can be suspended, recreated or lost; from where a model sits, none of that ever happens. Every tool ensures first, and a failure comes back as text it can act on rather than as a throw that ends the turn.
Testing
import { fakePlatform } from "@xeonr/containers/testing";
const { client, execScript, sandboxes } = fakePlatform();
execScript.stdout = "ok";Not a stub library — a real in-memory api the real client speaks the real protocol to, so framing, streaming and error mapping are exercised on the way through. It has already caught one bug the SDK's own tests could not.
Developing
pnpm install
pnpm generate # regenerate src/proto from the BSR; commit the result
pnpm test # node --test, no test framework
pnpm typecheck
pnpm buildThe generated code is committed and published inside this package. Every TS
consumer used to run its own buf generate and commit ~8k lines of output — three
copies, three regeneration rituals, three chances to be pinned to a different
schema revision than the server. Here the package version pins one revision, and
cp.raw is that revision's client.
Source imports carry .ts extensions so Node can run this tree directly with no
test dependencies; tsc rewrites them to .js on emit.
build emits ESM via tsc (dist/esm) and CommonJS via esbuild (dist/cjs),
then runs scripts/check-dist.mjs — which loads both halves, asserts they export
the same names, and fails if a peer was bundled rather than required. The unit
tests run against source and can see none of that.
