Running commands
Four ways to start a process: run for one BusyBox applet, exec for an exact argv, shExec for a full shell line, node for Node.js. All take ExecOptions and resolve to an ExecResult.
run(command)$VAR)/exec(argv)sh/shExec(line)sh <file>&&, loops, variables/node(args)node)opts.cwd, else /run
run(): one applet, no shell
The line is split on whitespace and handed to BusyBox, so | is just an argument here.
const r = await nano.run("echo a | b");
print(JSON.stringify(r));{"exitCode":0,"stdout":"a | b\n"}exec
exec(): an exact argv
Each element is single-quoted, so spaces, $ and quotes reach the program unchanged. Use it for user-supplied arguments.
const r = await nano.exec(["printf", "[%s]\\n", "a b", "$HOME", "it's"]); print(r.stdout.trim());
[a b] [$HOME] [it's]
shExec
shExec(): a full shell line
Pipes, redirects and variables work. stderr is merged into stdout in write order.
nano.fs.writeFile("/work/data.txt", "3\n10\n7\n");
const r = await nano.shExec("sort -n /work/data.txt | tail -1; pwd; echo to-stderr >&2; exit 4");
print(JSON.stringify(r));{"exitCode":4,"stdout":"10\n/\nto-stderr\n"}node
node(args, opts) runs on the runner chosen by engines; this page uses the host engine. cwd and env are honored by node() only. On the RISC-V runner they are applied through an sh wrapper that needs /usr/bin/node in the filesystem (true after installApp("node")).
node() with cwd and env
nano.fs.writeFile("/work/app/main.js", "console.log(process.cwd(), process.env.GREETING, require('path').basename(__filename))");
const r = await nano.node(["/work/app/main.js"], { cwd: "/work/app", env: { GREETING: "hi" } });
print(JSON.stringify(r));{"exitCode":0,"stdout":"/work/app hi main.js\n"}node(["main.js"], { cwd })) exits with code 1 and no output.ExecOptions
| Option | Type | Default | Description |
|---|---|---|---|
onData | (chunk: string) => void | — | Streams combined stdout+stderr as the guest writes it. |
maxSteps | number | 2,000,000; 2e9 for VM `node()` | Instruction budget. When it runs out the run ends with exitCode: -1. |
cwd | string | — | Working directory. node() only; run/exec/shExec always start at / (use cd in the line). |
env | Record<string, string> | — | Extra environment variables. node() only. |
onData: stream output
const chunks = [];
const r = await nano.shExec("for i in 1 2 3; do echo line $i; done", { onData: (c) => chunks.push(c) });
print(JSON.stringify(chunks), "exit", r.exitCode);["line 1\n","line 2\n","line 3\n"] exit 0
maxSteps: bound a runaway program
An infinite loop with a 1,000,000-instruction budget. Raise the budget for heavy work, lower it for untrusted input.
const r = await nano.shExec("while true; do :; done", { maxSteps: 1_000_000 });
print(JSON.stringify(r));{"exitCode":-1,"stdout":""}cwd/env don't apply to shExec
shExec ignores cwd and env; put cd and export in the line, or use a Shell, which tracks both for you.
print((await nano.shExec("pwd; echo [$GREETING]", { cwd: "/work", env: { GREETING: "hi" } })).stdout.trim());
print((await nano.shExec("cd /work && GREETING=hi && echo $(pwd) [$GREETING]")).stdout.trim());/ [] /work [hi]
ExecResult
| Option | Type | Default | Description |
|---|---|---|---|
exitCode | number | — | The process exit status. -1 when the instruction budget ran out or the run was cancelled. |
stdout | string | — | Combined stdout and stderr. |
cancelled | boolean | — | true when cancel() stopped the run. |
snapshotReady | boolean | — | Set by snapshot runs (see NodeRuntime). |
VM operations are serialized
A NanoVM runs one thing at a time: each run resets the CPU state. The SDK queues every VM-occupying call (run, exec, shExec, VM node(), fs mutations, snapshots) in FIFO order, so you can fire calls concurrently without corrupting each other. Host-engine node() doesn't occupy the VM. withVmLock(fn) runs your own code under the same lock, which you need when you call nano.raw directly. It is not re-entrant: don't call another queued nano method inside fn.
Concurrent calls queue in order
const order = [];
const a = nano.run("echo A").then(() => order.push("A"));
const b = nano.withVmLock(async () => {
order.push("lock");
return (await nano.raw.run("echo raw")).stdout.trim();
});
const c = nano.run("echo C").then(() => order.push("C"));
print("raw run:", await b);
await Promise.all([a, c]);
print("order:", order.join(" → "));raw run: raw order: A → lock → C
While a server started with startServer() runs, the VM is held: other VM calls reject at once with "the VM is busy serving" until stop(). Use a second instance for concurrent work.
Standard input
| Option | Type | Default | Description |
|---|---|---|---|
setInteractiveStdin(on = true) | void | off | Interactive mode: reads block until you writeStdin() more data or closeStdin(). |
writeStdin(data) | string | Uint8Array | — | Append bytes to the guest's stdin. |
closeStdin() | void | — | Signal end of input (EOF). |
A blocking read on the main thread blocks the page, so use interactive stdin with the worker transport (or the terminal, which does this for you). For one-shot input, redirect from a file instead.
One-shot input from a file
nano.fs.writeFile("/tmp/in.txt", "alpha\nbeta\ngamma\n");
print((await nano.shExec("sort -r < /tmp/in.txt")).stdout.trim());gamma beta alpha
Interactive stdin over a worker
The VM runs in a Web Worker, so the page keeps running while read waits. Input arrives 200 ms and 400 ms later; closeStdin() ends cat.
const vm = await sdk.createNanoWorker({ image: { wasm: "/nano/console.wasm" }, ramMB: 256 });
await vm.setInteractiveStdin(true);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const run = vm.shExec("read name; echo hello $name; cat", { onData: (d) => print("[out]", d.trim()) });
await sleep(200);
await vm.writeStdin("nano\n");
await sleep(200);
await vm.writeStdin("more input\n");
await sleep(200);
await vm.closeStdin();
print("exit", (await run).exitCode);
vm.destroy();[out] hello nano [out] more input exit 0
Cancel and destroy
cancel() stops the current run; it resolves with { exitCode: -1, cancelled: true } and the VM stays usable. destroy() frees the VM; any later call on it fails.
cancel() a long run
const t0 = performance.now();
const running = nano.shExec("while true; do :; done", { maxSteps: 1e12 });
setTimeout(() => nano.cancel(), 300);
const r = await running;
print(JSON.stringify(r), "after ~" + Math.round((performance.now() - t0) / 100) * 100, "ms");
print((await nano.run("echo still usable")).stdout.trim());{"exitCode":-1,"stdout":"","cancelled":true} after ~300 ms
still usabledestroy() frees a VM
const vm = await sdk.createNano({ image: { wasm: "/nano/console.wasm" }, ramMB: 256 });
print((await vm.run("echo alive")).stdout.trim());
vm.destroy();
const after = await vm.run("echo again").then(() => "ran", () => "rejected");
print("after destroy:", after);alive after destroy: rejected
Ready-made command-line tools to run this way are in the app catalog, for example ripgrep and jaq.
Error codes
Runs resolve with an exit code rather than throwing. Errors you can catch: ERR_NODE_HOST_UNSUPPORTED and ERR_NODE_HOST_RUNTIME_UNAVAILABLE (on err.code, see engines), "the VM is busy serving" while a server holds the VM, nano-sdk: fs op failed (exit N) from fs mutations, and catalog: … errors from installs.