userland.rundocs70Try the demo
SDK

Provisioning and recipes

provision(catalog, ref, opts) boots a worker-hosted VM, installs a catalog app with its recipe's dependencies, warms a snapshot and returns run, evalCode, serve and sh. Everything app-specific, such as how Node.js is snapshotted, is signed data in the app's recipe.

javascript
import { Catalog, provision } from "@userland-run/nano-sdk";

const node = await provision(new Catalog(), "node", {
  wasm: await (await fetch("/nano/nano.wasm")).arrayBuffer(),
  swUrl: "/nano-sw.js",
  workerFactory: () => new Worker(new URL("@userland-run/nano-sdk/worker", import.meta.url), { type: "module" }),
  extraApps: ["busybox"],
});
await node.evalCode("console.log(process.version)");

Options

OptionTypeDefaultDescription
wasmArrayBuffer—The slim nano.wasm bytes.
swUrlstring—Service worker URL (isolation and previews). provision calls registerNanoServiceWorker, which reloads the page once if the worker isn't controlling it yet.
workerFactory() => Worker—Required: creates the SDK worker in your bundler.
ramMBnumber1800Worker VM memory.
extraAppsstring[][]More catalog apps to install, e.g. ["busybox"] for sh().
recipeAppRecipe—Used only when the published manifest has no recipe.
snapshotFetcher(name) => Promise<ArrayBuffer>—Load a prebuilt gzipped snapshot named by recipe.warmup.snapshot instead of building one live.
onProgress(message: string) => void—Human-readable progress.

The provisioned app

OptionTypeDefaultDescription
run(file, opts?)Promise<ExecResult>maxSteps 2e9Run a file through recipe.run.fileScript on the warm snapshot. Options: onStdout, extraFiles, maxSteps.
evalCode(code, opts?)Promise<ExecResult>—Run source through recipe.run.evalScript.
serve(file, { port, readyPattern?, onListening?, … })Promise<{ url(path?), stop(), done }>readyPattern /listening|running|started|ready/iStart a server from the snapshot and preview it; it holds the VM until stop().
sh(line, opts?)Promise<ExecResult>—A raw BusyBox run (needs BusyBox, e.g. via extraApps).
clientNanoWorkerClient—The worker client.

Results pass through the recipe: lines matching outputFilters and everything after them are stripped, and benignExitCodes count as success.

SDK · JavaScript

Provision Node.js and run it

The site's bundler emitted the SDK worker as a chunk, so the sample captures that URL once and passes a workerFactory for it; in your app use new URL("@userland-run/nano-sdk/worker", import.meta.url). It also registers the service worker and waits for it to control the page, so provision() doesn't reload. The first run downloads Node.js (62 MB) and builds the snapshot.

javascript
await sdk.registerNanoServiceWorker({ swUrl: "/nano-sw.js", reloadIfUncontrolled: false });
if (!navigator.serviceWorker.controller) await new Promise((r) => navigator.serviceWorker.addEventListener("controllerchange", r, { once: true }));
const Native = globalThis.Worker;
let workerUrl, workerOpts;
globalThis.Worker = class extends Native { constructor(u, o) { workerUrl = u; workerOpts = o; super(u, o); } };
try { (await sdk.createNanoWorker({ image: { wasm: "/nano/console.wasm" }, ramMB: 256, warmup: false })).destroy(); } finally { globalThis.Worker = Native; }

const app = await sdk.provision(new sdk.Catalog(), "node", {
  wasm: await (await fetch("/nano/nano.wasm")).arrayBuffer(),
  swUrl: "/nano-sw.js",
  workerFactory: () => new Worker(workerUrl, workerOpts),
  extraApps: ["busybox"],
  onProgress: (m) => print("[provision]", m),
});
print((await app.evalCode("console.log('eval:', process.version, process.arch)")).stdout.trim());
const r = await app.run("/work/main.js", {
  extraFiles: [
    { path: "/work/main.js", content: "console.log('run:', require('fs').readFileSync('/work/in.txt', 'utf8'))" },
    { path: "/work/in.txt", content: "per-run input" },
  ],
});
print(r.stdout.trim(), "| exit", r.exitCode);
print("sh:", (await app.sh("uname -m")).stdout.trim());
globalThis.__provisioned = app;
loading…
Expected output
[provision] preparing cross-origin isolation…
[provision] starting worker VM…
[provision] installing node…
[provision] installing busybox…
[provision] warming up…
[provision] ready
eval: v25.4.0 riscv64
run: per-run input | exit 0
sh: riscv64
SDK · JavaScript

serve() a file from the snapshot

Uses the app provisioned by the sample above (run it first). serve() returns at once; onListening tells you when the server is up. While it runs, other calls on the same worker reject.

javascript
const app = globalThis.__provisioned;
if (!app) throw new Error("run the provision sample above first");
let up;
const listening = new Promise((r) => (up = r));
const srv = await app.serve("/work/server.js", {
  port: 3000,
  extraFiles: [{ path: "/work/server.js", content: "require('http').createServer((q, r) => r.end('<h1>provisioned server</h1>')).listen(3000, () => console.log('listening on 3000'))" }],
  onListening: () => up(),
});
await listening;
const frame = Object.assign(document.createElement("iframe"), { hidden: true, src: await srv.url("/") });
const loaded = new Promise((r) => (frame.onload = r));
document.body.append(frame);
await loaded;
print(await srv.url("/"), "→", frame.contentDocument.body.textContent);
frame.remove();
print("while serving:", await app.evalCode("1").then(() => "ran", (e) => e.message.slice(0, 32)));
srv.stop();
await srv.done.catch(() => {});
app.client.destroy();
delete globalThis.__provisioned;
loading…
Expected output
/sw/3000/ → provisioned server
while serving: nano-sdk: the VM is busy serving
Caution · Runs restore the snapshot taken at warmup, including its filesystem: files you write with app.client.fs afterwards are not visible to run() or serve(). Pass them as extraFiles, as above. sh(line) runs one BusyBox applet with whitespace-split argv (no shell parsing), despite its name.

What a recipe is

A recipe is the [recipe] table of an app in the catalog repository, published inside the app's signed manifest. The SDK has no Node-specific code: this is the whole Node.js recipe (from recipes/node/recipe.toml):

toml
[recipe]
warmup = { elfPath = "/usr/bin/node", launcherPath = "/launcher.js",
  launcher = "const fs = require('fs');\nfs.writeFileSync('/dev/__snapshot__', 'snap');\nconst __s = fs.readFileSync('/dev/__run__', 'utf8');\n(new Function(__s))();",
  argv = ["node", "/launcher.js"], env = { UV_THREADPOOL_SIZE = "0" }, maxSteps = 2000000000 }
run = { fileScript = "process.mainModule.require('${file}')", evalScript = "${code}" }
benignExitCodes = [134]
outputFilters = ["WorkerThreadsTaskRunner", "Native stack trace", "Assertion failed: \\(0\\)"]
Field
Meaning
deps
catalog apps installed first
warmup
ELF, launcher and argv/env booted until the guest writes /dev/__snapshot__; ready snapshots a server when a probe returns HTTP 200; snapshot names a prebuilt artifact
run.fileScript / run.evalScript
templates that turn a request into the /dev/__run__ payload (${file}, ${code})
benignExitCodes
exit codes treated as success (Node's shutdown 134)
outputFilters
regexes; the first matching line and everything after it are dropped

The layering

  • nano provides a generic snapshot/restore primitive and knows nothing about Node.
  • The SDK provides the worker transport, the verified installer, the preview bridge and provision(); it is app-agnostic.
  • The catalog carries the app and its recipe as signed data.
  • Your app calls provision("node") and uses run / serve.
provision() calls registerNanoServiceWorker() with its default reloadIfUncontrolled: true, so on a page the service worker doesn't control yet it reloads once. The sample above waits for the worker to take control first.

See also: Node.js, the node app and the node-dev collection.