userland.rundocs70Try the demo
SDK

App catalog

Install command-line apps (ripgrep, fd, jaq, numbat, Node.js and more) into a VM on demand. Every index, manifest and chunk is signature- or hash-verified against the bundled catalog key before it touches the filesystem; chunks are cached in OPFS.

Browse the apps on the app catalog pages; each has its own install-and-run sample. This page documents the API.

catalog app

Install ripgrep and search

installApp("ripgrep") fetches, verifies and writes /usr/bin/rg (mode 0755), then rg runs on the RISC-V runner.

bash
rg --version | head -1
rg -n --color never nano notes.txt
loading…
Expected output
installed ripgrep@14.1.0 (1 file, verified)
$ rg --version | head -1
ripgrep 14.1.0 (rev e50df40a19)
$ rg -n --color never nano notes.txt
2:nanovm runs in a tab

installApp

nano.installApp(ref, opts) takes "name" (latest) or "name@version" and resolves to the verified Manifest. It is also on the worker client, createHeadless and the terminal handle.

OptionTypeDefaultDescription
lazybooleanfalseRegister the files without fetching them; each file's chunks download the first time a guest program opens it. wasm-app and wasm-service apps are always installed eagerly.
onProgress(e: InstallProgress) => void—Events { phase: "index" | "manifest" | "chunk" | "write" | "done", file?, chunk?, fetched?, total? }.
SDK · JavaScript

onProgress events

javascript
const events = [];
const m = await nano.installApp("hexyl", { onProgress: (e) => events.push(e) });
for (const e of events) print(e.phase, e.file ?? "", e.total != null ? `${e.fetched}/${e.total}` : "");
print(m.name, m.version, m.files.map((f) => `${f.path} ${f.mode}`).join(", "));
loading…
Expected output
index
manifest
chunk /usr/bin/hexyl 0/1
write /usr/bin/hexyl
done
hexyl 0.14.0 /usr/bin/hexyl 0755
SDK · JavaScript

lazy: download on first use

With lazy: true there are no chunk or write events: the file is registered with registerLazyFile and fetched when fd first runs.

javascript
const phases = [];
await nano.installApp("fd", { lazy: true, onProgress: (e) => phases.push(e.phase) });
print("install phases:", phases.join(" → "));
print((await nano.shExec("fd --version")).stdout.trim());
loading…
Expected output
install phases: index → manifest → done
fd 10.2.0

installBundle

nano.installBundle(slug, opts) installs every app in a topic (a signed bundle manifest) and resolves to { topic, installed, failed }. A member that fails is listed in failed rather than throwing. Members share chunks in the content-addressed store, so a bundle costs little more than its largest app. Topics are the categories on the app pages, for example math.

SDK · JavaScript

Install a topic bundle

javascript
const r = await nano.installBundle("math");
print(r.topic, "→", r.installed.map((m) => `${m.name}@${m.version}`).join(" "), "| failed:", JSON.stringify(r.failed));
print((await nano.shExec("numbat --version", { maxSteps: 1e10 })).stdout.trim());
loading…
Expected output
Math → numbat@1.23.0 | failed: []
numbat 1.23.0

The Catalog class

nano.catalog(opts?) returns the instance's Catalog (created lazily; passing different options creates a new one). You can also use new Catalog(opts) on its own, for example to browse without a VM or to install into any { writeFile } target.

OptionTypeDefaultDescription
index()Promise<SignedIndex>—The verified index: generation, apps (name@version → manifest hash), bundles, categories, collections.
manifest(ref)Promise<Manifest>—A verified manifest: files, modes, chunk hashes, kind, abi, entrypoint, recipe, conformance.
install(target, ref, opts?)Promise<Manifest>—Install into a target such as nano.fs.
installBundle(target, slug, opts?)Promise<BundleInstallResult>—Install a topic bundle into a target.
bundleManifest(slug)Promise<BundleManifest>—The verified member list of a topic.
categories()Promise<Record<string, string[]>>—Topic slug → member refs.
collections()Promise<Record<string, Collection>>—Curated sets: slug → { title, description, members }.
SDK · JavaScript

index, categories, collections, manifest

javascript
const cat = nano.catalog();
const idx = await cat.index();
print("generation", idx.generation, "|", Object.keys(idx.apps).length, "app versions");
print("categories:", Object.keys(await cat.categories()).join(", "));
const cols = await cat.collections();
for (const [slug, col] of Object.entries(cols)) print("collection", slug, "→", col.members.join(" "));
const m = await cat.manifest("ripgrep");
print(m.name, m.version, m.abi, "|", m.files.map((f) => `${f.path} ${f.mode} ${f.size} bytes in ${f.chunks.length} chunks`).join(", "));
loading…
Expected output
generation 12 | 32 app versions
categories: text, search, encryption, data, version-control, markup, documents, images, networking, math, showcase
collection node-dev → node typescript eslint prettier
collection git → gix delta
ripgrep 14.1.0 riscv64gc-linux-musl | /usr/bin/rg 0755 37337880 bytes in 5 chunks

Catalog options

OptionTypeDefaultDescription
cdn.baseUrlstringjsDelivrA flat, immutable origin serving <baseUrl>/index.json and <baseUrl>/cas/<sha256> (an R2 mirror, a local directory for tests). No generation lookup.
cdn.fetchFntypeof fetchglobalThis.fetchCustom transport.
cacheChunkCacheOPFS, memory fallback{ get(sha), put(sha, bytes), delete?(sha) }. Hits are re-verified.
publicKeyB64stringthe bundled keyRaw base64 Ed25519 key that index, manifests and bundles must be signed with.
generationsGenerationStorelocalStorage, memory fallbackRemembers the highest verified index generation per origin and key, and rejects older ones (rollback protection).

By default the client asks jsDelivr's versions API for the newest generation of @userland-run/nano-catalog, then fetches nano-catalog@0.0.<gen>/index.json and blobs from nano-cas@0.0.<gen>/cas/<sha256>, all immutable URLs.

SDK · JavaScript

cdn.fetchFn: see every request

javascript
const seen = [];
const cat = new sdk.Catalog({ cdn: { fetchFn: (url, init) => (seen.push(String(url).replace(/[0-9a-f]{64}/, "<sha256>")), fetch(url, init)) } });
await cat.manifest("hexyl");
print(seen.join("\n"));
loading…
Expected output
https://data.jsdelivr.com/v1/packages/npm/@userland-run/nano-catalog
https://cdn.jsdelivr.net/npm/@userland-run/nano-catalog@0.0.12/index.json
https://cdn.jsdelivr.net/npm/@userland-run/nano-cas@0.0.12/cas/<sha256>
SDK · JavaScript

A mirror with baseUrl, and rollback protection

A pretend mirror at https://mirror.example/catalog, served by a fetchFn that maps its flat layout onto jsDelivr. A generations store that has already seen a newer generation makes the same, validly signed index fail.

javascript
const gen = (await nano.catalog().index()).generation;
const mirror = (url, init) => fetch(String(url)
  .replace("https://mirror.example/catalog/index.json", `https://cdn.jsdelivr.net/npm/@userland-run/nano-catalog@0.0.${gen}/index.json`)
  .replace("https://mirror.example/catalog/cas/", `https://cdn.jsdelivr.net/npm/@userland-run/nano-cas@0.0.${gen}/cas/`), init);
const cdn = { baseUrl: "https://mirror.example/catalog", fetchFn: mirror };
const store = new Map();
const generations = { get: (scope) => store.get(scope) ?? null, set: (scope, g) => void store.set(scope, g) };
const cat = new sdk.Catalog({ cdn, generations });
print("mirror index generation:", (await cat.index()).generation === gen);
print("rg via mirror:", (await cat.manifest("ripgrep")).name);
for (const scope of store.keys()) store.set(scope, gen + 1); // pretend we saw a newer one
print(await new sdk.Catalog({ cdn, generations }).index().then(() => "accepted", (e) => e.message.replace(/\d+/g, "N")));
loading…
Expected output
mirror index generation: true
rg via mirror: ripgrep
catalog: index generation N is older than the newest already seen (N) — possible rollback
SDK · JavaScript

cache and publicKeyB64

A memory cache shows one network fetch per chunk; the second install is served from it. A different key makes the signed index fail verification.

javascript
const mem = new Map();
const stats = { get: 0, put: 0 };
const cache = { async get(k) { stats.get++; return mem.get(k) ?? null; }, async put(k, v) { stats.put++; mem.set(k, v); } };
const cat = new sdk.Catalog({ cache });
await cat.install(nano.fs, "hexyl");
await cat.install(nano.fs, "hexyl");
print("cache gets:", stats.get, "puts:", stats.put);
print("bundled key:", sdk.CATALOG_PUBLIC_KEY_B64.length, "chars of base64");
const otherKey = btoa(String.fromCharCode(...new Uint8Array(32).fill(7)));
print(await new sdk.Catalog({ publicKeyB64: otherKey }).index().then(() => "accepted", (e) => e.message));
loading…
Expected output
cache gets: 2 puts: 1
bundled key: 44 chars of base64
catalog: index signature invalid (bad signature)

Verification

  • The index and every manifest and bundle are Ed25519-signed (verifySigned, canonicalize, catalogPublicKey).
  • Every chunk is checked against its SHA-256 before use, including cache hits; the reassembled file is checked against the manifest's file hash.
  • The signed index generation must equal the generation requested and must not be lower than the highest one seen (resolveIndex).
  • Failures throw catalog: … errors (signature invalid, hash mismatch, app not found, possible rollback). Nothing unverified reaches the filesystem.

The low-level pieces are exported for custom installers: resolveIndex, resolveManifest, fetchVerifiedChunk, installApp(target, ref, ctx, opts), CdnClient, openChunkCache, defaultGenerationStore, sha256Hex, publicKeyFromB64.

Kinds and tiers

`manifest.kind`
Runs on
Installed as
elf-app (default)
riscv
files on PATH (/usr/bin)
wasm-app
wasm
a registered wasm command (execWasmApp)
wasm-service
wasm
a Kernel service on the svc.* bus, not PATH
wasm-component
wasm
reserved, not runnable yet
node-app
node
a program for the host engine
boa-app
boa
a sandboxed script
SDK · JavaScript

manifestKind, isWasmKind, kindToTier, manifestTier

javascript
const m = await nano.catalog().manifest("ripgrep");
print("ripgrep:", sdk.manifestKind(m), "→", sdk.manifestTier(m));
for (const k of ["wasm-app", "wasm-service", "node-app", "boa-app"]) print(k, "→", sdk.kindToTier(k), "| wasm kind:", sdk.isWasmKind(k));
loading…
Expected output
ripgrep: elf-app → riscv
wasm-app → wasm | wasm kind: true
wasm-service → wasm | wasm kind: true
node-app → node | wasm kind: false
boa-app → boa | wasm kind: false

Wasm apps

wasm32-wasip1 programs run on the wasm runner (the browser's WebAssembly engine) instead of the emulator. A catalog wasm-app is registered automatically by installApp; any other module can be registered from bytes.

OptionTypeDefaultDescription
registerWasmApp(name, bytes)Promise<() => void>—Register a module as a named command; resolves to an unregister function.
execWasmApp(argv, { cwd?, timeoutMs? })Promise<{ exitCode, stdout, stderr }>cwd "/", 60000 msRun a registered command. cwd is the app's only preopened directory, so paths are relative to it and .. can't escape. Exit code 124 on timeout.
registerWasmAppFromManifest(manifest)Promise<() => void>—What installApp calls for wasm-app manifests.
registerWasmService(manifest, { methods? })Promise<() => void>—Register an installed wasm-service with the Kernel service registry.
SDK · JavaScript

registerWasmApp and execWasmApp

javascript
const bytes = new Uint8Array(await (await fetch("/nano/photon.wasm")).arrayBuffer());
const unregister = await nano.registerWasmApp("photon", bytes);
nano.fs.writeFile("/work/in.jpg", new Uint8Array(await (await fetch("/demo-files/photon/sample.jpg")).arrayBuffer()));
const r = await nano.execWasmApp(["photon", "in.jpg", "out.png", "--filter", "invert"], { cwd: "/work" });
print(JSON.stringify({ exitCode: r.exitCode, stdout: r.stdout, stderr: r.stderr }), "| out.png:", nano.fs.readFile("/work/out.png").length > 0);
const sh = await nano.shExec("cd /work && photon in.jpg g.png --filter grayscale > log.txt; echo rc=$?; cat log.txt");
print("shell:", sh.stdout.trim().split("\n").join(" / "));
const slow = await nano.execWasmApp(["photon", "in.jpg", "b.png", "--filter", "blur"], { cwd: "/work", timeoutMs: 1 });
print("timeout exit code:", slow.exitCode);
unregister();
print("after unregister:", (await nano.execWasmApp(["photon"])).stderr.trim());
loading…
Expected output
{"exitCode":0,"stdout":"photon: invert 360x240 -> out.png (360x240 PNG)\n","stderr":""} | out.png: true
shell: rc=0 / photon: grayscale 360x240 -> g.png (360x240 PNG)
timeout exit code: 124
after unregister: photon: not found
wasm app

The same tool as a wasm sample

Each line is tokenized and run with execWasmApp in /work; image files it writes are shown below.

bash
photon in.jpg sepia.png --filter sepia
photon in.jpg bright.png --filter brighten --strength 40
loading…
Expected output
$ photon in.jpg sepia.png --filter sepia
photon: sepia 360x240 -> sepia.png (360x240 PNG)
$ photon in.jpg bright.png --filter brighten --strength 40
photon: brighten 360x240 -> bright.png (360x240 PNG)
Since SDK 0.3.0 a registered wasm app is also a shell command: nano.run("photon …") and shell lines exec it with real pipes, redirects and exit codes, and it sees the VM's filesystem like any process (relative paths against its working directory). The current catalog (generation 12) publishes no wasm-app or wasm-service apps, so registerWasmService has no live sample here.

More: WASI apps feature and the photon example.