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.
Install ripgrep and search
installApp("ripgrep") fetches, verifies and writes /usr/bin/rg (mode 0755), then rg runs on the RISC-V runner.
rg --version | head -1 rg -n --color never nano notes.txt
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.
| Option | Type | Default | Description |
|---|---|---|---|
lazy | boolean | false | Register 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? }. |
onProgress events
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(", "));index manifest chunk /usr/bin/hexyl 0/1 write /usr/bin/hexyl done hexyl 0.14.0 /usr/bin/hexyl 0755
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.
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());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.
Install a topic bundle
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());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.
| Option | Type | Default | Description |
|---|---|---|---|
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 }. |
index, categories, collections, manifest
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(", "));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
| Option | Type | Default | Description |
|---|---|---|---|
cdn.baseUrl | string | jsDelivr | A flat, immutable origin serving <baseUrl>/index.json and <baseUrl>/cas/<sha256> (an R2 mirror, a local directory for tests). No generation lookup. |
cdn.fetchFn | typeof fetch | globalThis.fetch | Custom transport. |
cache | ChunkCache | OPFS, memory fallback | { get(sha), put(sha, bytes), delete?(sha) }. Hits are re-verified. |
publicKeyB64 | string | the bundled key | Raw base64 Ed25519 key that index, manifests and bundles must be signed with. |
generations | GenerationStore | localStorage, memory fallback | Remembers 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.
cdn.fetchFn: see every request
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"));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>
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.
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")));mirror index generation: true rg via mirror: ripgrep catalog: index generation N is older than the newest already seen (N) — possible rollback
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.
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));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
elf-app (default)PATH (/usr/bin)wasm-appexecWasmApp)wasm-servicesvc.* bus, not PATHwasm-componentnode-appboa-appmanifestKind, isWasmKind, kindToTier, manifestTier
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));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.
| Option | Type | Default | Description |
|---|---|---|---|
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 ms | Run 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. |
registerWasmApp and execWasmApp
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());{"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 foundThe same tool as a wasm sample
Each line is tokenized and run with execWasmApp in /work; image files it writes are shown below.
photon in.jpg sepia.png --filter sepia photon in.jpg bright.png --filter brighten --strength 40
$ 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)
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.