Sandbox API reference

Every option and method on Sandbox — create, run commands, the fs helper, snapshots, and direct kernel/shell access.

Sandbox is the high-level entry point. Sandbox.create() builds a fully wired box; the returned object exposes commands, fs, env, cwd, and direct kernel / shell handles.

Sandbox.create(options?)

const sandbox = await Sandbox.create(options);

SandboxOptions:

OptionTypeDefaultNotes
persistbooleanfalsePersist the disk to IndexedDB (browser).
envRecord<string, string>Extra env, merged with defaults.
cwdstring/home/userInitial working directory.
filesRecord<string, string | Uint8Array>Pre-populate the disk.
terminalITerminal | HTMLElement | stringAttach a terminal (element/selector lazily creates xterm.js).
mountsArray<{ virtualPath; hostPath; readOnly?; fsModule? }>Mount host directories (Node).

sandbox.commands

sandbox.commands.run(cmd: string, options?: RunOptions): Promise<CommandResult>;
sandbox.commands.register(name: string, handler: Command): void;

RunOptions: cwd, env, signal (AbortSignal), timeout (ms), onStdout(chunk), onStderr(chunk), stdin (string).

CommandResult: { stdout: string; stderr: string; exitCode: number }.

const { stdout, exitCode } = await sandbox.commands.run("ls -la", { cwd: "/tmp" });

sandbox.commands.register("greet", async (ctx) => {
  ctx.stdout.write("hi\n");
  return 0;
});

See Commands for the Command / CommandContext contract.

sandbox.fs

A promise-based, node:fs-shaped helper over the VFS:

sandbox.fs.readFile(path): Promise<string>;
sandbox.fs.readFile(path, null): Promise<Uint8Array>;    // binary
sandbox.fs.writeFile(path, content): Promise<void>;
sandbox.fs.readdir(path): Promise<Array<{ name; type }>>;
sandbox.fs.stat(path): Promise<{ type; size; mtime }>;
sandbox.fs.mkdir(path, { recursive? }): Promise<void>;
sandbox.fs.rm(path, { recursive? }): Promise<void>;
sandbox.fs.exists(path): Promise<boolean>;
sandbox.fs.rename(oldPath, newPath): Promise<void>;
sandbox.fs.cp(src, dest): Promise<void>;
sandbox.fs.writeFiles(files): Promise<void>;             // batch

Snapshots

sandbox.exportSnapshot(options?: SnapshotOptions): Promise<Uint8Array>;   // .tar.gz
sandbox.importSnapshot(data: Uint8Array): Promise<SnapshotMetadata | null>;

SnapshotOptions: exclude (path segments to skip, e.g. ['node_modules']) and metadata (session state to embed; defaults to the box's cwd + env, or false for a files-only archive).

The archive carries cwd and env in a lifo-snapshot.json manifest, so the same file restores in the browser, in Node and through the CLI. importSnapshot applies them and returns the manifest, or null for a files-only archive. See Snapshots.

sandbox.fetch(input, init?)

Make an HTTP request to a server running inside the box. No service worker, no port forwarding, no host networking.

sandbox.fetch(input: string | URL, init?: SandboxFetchInit): Promise<Response>;
await sandbox.waitForPort(54321);

const res = await sandbox.fetch("http://localhost:54321/rest/v1/todos", {
  headers: { apikey: ANON_KEY },
});
const todos = await res.json();

The return value is a real Response, so .json(), .text(), .arrayBuffer() and .headers all work as usual.

SandboxFetchInit:

OptionTypeDefaultNotes
methodstringGET
headersHeadersInit | Record<string, string>content-length is added for you (in-VM body parsers need it).
bodystring | Uint8Array | ArrayBuffer
portnumberRequired when input is a bare path.
timeoutnumber120000ms to wait for the server.

The port picks the server. The URL's port selects which in-VM server answers, so the host must be loopback (localhost, 127.0.0.1). A bare path needs an explicit port — there is no ambient "current port", and guessing one would send requests to the wrong server:

await sandbox.fetch("/rest/v1/todos", { port: 54321 });   // ok
await sandbox.fetch("/rest/v1/todos");                     // throws
await sandbox.fetch("https://example.com/");               // throws — use global fetch

Transport problems are responses, not exceptions. This matches the service worker, so the host and the browser see the same thing for the same box:

SituationResult
Nothing listening on the port404 with x-lifo: no-server
Server didn't answer in time504 with x-lifo: timeout
Handler threw500 with x-lifo: handler-error

The x-lifo header is what distinguishes these from a status the app itself produced — an app's own 404 carries no such header.

Gotcha

sandbox.fetch is a host → VM call. It is unrelated to the fetch that code inside the box sees, which is provided by the Node runtime.

sandbox.connect(port, url?, options?)

Open a WebSocket to a server running inside the box — the other half of fetch, for HMR and realtime.

sandbox.connect(port: number, url?: string, options?: SandboxConnectOptions): Promise<VmWebSocket>;
await sandbox.waitForPort(5173);

const ws = await sandbox.connect(5173, "/hot");   // Vite HMR
ws.onmessage = (e) => console.log(e.data);
ws.send("ping");

const first = await ws.nextMessage();             // convenient in tests

The promise resolves after the server's handshake, so a send() immediately afterwards is safe. options takes protocol (a sub-protocol to offer) and timeout (handshake wait, default 30s).

VmWebSocket is WebSocket-shapedsend, close, readyState, onopen/onmessage/onclose/onerror, addEventListener — plus nextMessage(timeoutMs?), which resolves with the next message. It is not a real WebSocket: there is no socket and no URL a browser could open.

  • Text frames arrive as string, binary as Uint8Array. There is no binaryType to switch, since nothing here is a Blob.
  • A handler attached after connecting still sees earlier messages. Servers often send a greeting in the same write as the handshake, which lands before your code has the socket back — so message events are held until a handler exists. Unusual for an EventTarget, and deliberate: the alternative is silently losing it.
  • Rejects if nothing on the port handles upgrades. Use waitForPort first if the server is still starting.

Tip

Building your own transport rather than calling this? openWsPipe from @lifo-sh/core is the primitive underneath — it forges the upgrade, splits the handshake from frames that share a write, reassembles fragments and auto-pongs.

sandbox.waitForPort(port, options?)

sandbox.waitForPort(port: number, options?: { timeout?: number }): Promise<void>;

Resolves once something is listening on port inside the box; rejects on timeout (default 30s). "Listening" is all the port registry knows — a server that binds before it can serve will still need a retried request, which is why the preview transports poll a real request rather than just waiting for the bind.

sandbox.env, sandbox.cwd

env is the box's environment object; cwd is the current working directory (reflecting cd within the shell session).

Escape hatches: kernel and shell

For anything the high-level API doesn't cover, sandbox.kernel and sandbox.shell are exposed directly:

sandbox.kernel.vfs;               // synchronous VFS
sandbox.kernel.processRegistry;   // the process table
sandbox.kernel.networkStack;      // interfaces, ports, DNS
sandbox.kernel.serviceManager;    // systemctl-backed services

See The kernel, Processes, and Networking for what those expose.

Tip

Prefer commands.run() and fs for most work — they're stable and environment-agnostic. Reach into kernel / shell only for lower-level control.