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.
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
| Option | Type | Default | Description |
|---|---|---|---|
wasm | ArrayBuffer | — | The slim nano.wasm bytes. |
swUrl | string | — | 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. |
ramMB | number | 1800 | Worker VM memory. |
extraApps | string[] | [] | More catalog apps to install, e.g. ["busybox"] for sh(). |
recipe | AppRecipe | — | 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
| Option | Type | Default | Description |
|---|---|---|---|
run(file, opts?) | Promise<ExecResult> | maxSteps 2e9 | Run 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/i | Start 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). |
client | NanoWorkerClient | — | The worker client. |
Results pass through the recipe: lines matching outputFilters and everything after them are stripped, and benignExitCodes count as success.
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.
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;[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
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.
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;/sw/3000/ → provisioned server while serving: nano-sdk: the VM is busy serving
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):
[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\\)"]depswarmup/dev/__snapshot__; ready snapshots a server when a probe returns HTTP 200; snapshot names a prebuilt artifactrun.fileScript / run.evalScript/dev/__run__ payload (${file}, ${code})benignExitCodesoutputFiltersThe 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 usesrun/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.