Run wasm32-wasip1 command-line apps in the browser
Not every tool needs an emulated CPU. NanoVM's wasm runner executes wasm32-wasip1 command-line programs directly on the browser's own WebAssembly engine, while they read and write the same in-memory filesystem as the RISC-V VM. Compile a Rust or C tool for WASI, register its bytes, and call it like any other command.
Why a separate wasm runner
The RISC-V VM is the fidelity path: it runs unmodified Linux ELF binaries instruction by instruction. That is exactly right for Node.js or BusyBox, but slow for a tool that already compiles to WebAssembly. The wasm runner skips the emulation: the module is compiled once by the browser (and cached by content hash), then each invocation runs in its own Web Worker with a WASI preview 1 shim backed by the kernel's VFS.
| RISC-V VM runner | wasm runner | |
|---|---|---|
| Runs | RV64GC Linux ELF binaries | wasm32-wasip1 modules |
| Execution | Interpreted, one instruction at a time | Compiled by the browser's wasm engine |
| Filesystem | Shared kernel VFS | Shared kernel VFS (cwd preopened as /) |
| Networking | Sockets inside the VM | None — WASI p1 sock_* calls return ENOTSUP |
| Isolation | Guest process in the emulator | One Web Worker per run, capability-scoped preopens |
Image processing with Photon
photon is a small Rust CLI over the Photon image library, built for wasm32-wasip1. Its usage is photon <in> <out> [--filter grayscale|invert|blur|brighten|sepia|threshold] [--strength N]. The sample image is seeded into /work, the output PNG is read back from the VFS and shown under the output.
Grayscale a JPEG on the wasm runner
Each line runs with execWasmApp with /work as the working directory, so paths are relative to it.
photon in.jpg out.png --filter grayscale
photon: grayscale 360x240 -> out.png (360x240 PNG)
Chain two filters through the filesystem
The second run reads the file the first one wrote. Nothing leaves the tab.
photon in.jpg blurred.png --filter blur photon blurred.png edges.png --filter threshold
photon: blur 360x240 -> blurred.png (360x240 PNG) photon: threshold 360x240 -> edges.png (360x240 PNG)
Searching code with ripgrep
rg here is a real ripgrep front-end built on ripgrep's own ignore, regex and globset crates, compiled to wasm32-wasip1 (single-threaded walk, std-only I/O). It honours .gitignore, so the file under target/ is skipped. Supported flags include --files, -i/-S/-w/-F/-v, -n, -l, -c, -o, -A/-B/-C, -g/--glob, --hidden, --no-ignore and --json; PCRE2 (-P), multiline and --type are not built in.
Find TODOs in a small project
A Rust-looking project with a .gitignore is seeded into /work/proj; rg walks it.
rg -n TODO proj rg --files proj
proj/src/main.rs:2: // TODO: parse arguments proj/src/lib.rs:2: a + b // TODO: overflow? proj/src/main.rs proj/src/lib.rs proj/notes.md
Counts, globs and ignored files
-c counts matches per file, -g restricts the walk to a glob, and --no-ignore brings back the file .gitignore hides.
rg -c TODO -g '*.rs' proj rg -l --no-ignore TODO proj
proj/src/main.rs:1 proj/src/lib.rs:1 proj/src/main.rs proj/src/lib.rs proj/target/debug/build.log
The SDK calls behind it
Two calls do the work. registerWasmApp(name, bytes) pins a command name to the wasm-app tier; execWasmApp(argv, { cwd, timeoutMs }) runs it with the VM's capabilities and returns { exitCode, stdout, stderr }. Binary output goes through files: the app writes into cwd, you read the bytes back with nano.fs.readFile.
registerWasmApp + execWasmApp by hand
Fetch the module, register it under a name, seed a file, run it. nano, print and sdk are in scope; the page VM is shared by every sample.
const bytes = new Uint8Array(await (await fetch("/nano/rg.wasm")).arrayBuffer());
await nano.registerWasmApp("rg", bytes);
nano.fs.writeFile("/work/api/todo.txt", "write docs\nship it\nwrite tests\n");
const r = await nano.execWasmApp(["rg", "-n", "^write"], { cwd: "/work/api" });
print("exit:", r.exitCode);
print(r.stdout.trim());exit: 0 todo.txt:1:write docs todo.txt:3:write tests
A run that exceeds its timeout
timeoutMs (default 60 000) bounds each run; on expiry the worker is terminated and the run reports exit code 124, the same convention as timeout(1).
const bytes = new Uint8Array(await (await fetch("/nano/photon.wasm")).arrayBuffer());
await nano.registerWasmApp("photon", bytes);
nano.fs.writeFile("/work/t/in.jpg", new Uint8Array(await (await fetch("/demo-files/photon/sample.jpg")).arrayBuffer()));
const r = await nano.execWasmApp(["photon", "in.jpg", "out.png", "--filter", "blur"], { cwd: "/work/t", timeoutMs: 1 });
print("exit code:", r.exitCode);exit code: 124
Shipping your own WASI tool
- Build for
wasm32-wasip1(for Rust:cargo build --release --target wasm32-wasip1). Single-threaded builds are the tested path. - Treat the working directory as the root: the runner preopens
cwdas/, so relative paths and.resolve inside it. - Use stdout/stderr for text and files for binary data —
stdoutis decoded as UTF-8. - Publish it to the catalog as
kind: "wasm-app"andnano.installApp(name)registers it automatically after verifying the signature.
| Option | Type | Default | Description |
|---|---|---|---|
registerWasmApp(name, bytes) | Promise<() => void> | — | Register a module as a named command on the wasm-app tier. Returns an unregister function. |
execWasmApp(argv, opts?) | Promise<{ exitCode, stdout, stderr }> | — | Run a registered app directly, bypassing the guest shell. |
opts.cwd | string | "/" | Working directory, preopened as the app's /. |
opts.timeoutMs | number | 60000 | Watchdog per run; on expiry the run ends with exit code 124. |
installApp(ref) | Promise<Manifest> | — | Catalog wasm-app apps are installed eagerly and registered for you. |
FAQ
Is this WASI preview 2 / the component model?
No. The runner implements WASI preview 1 (wasi_snapshot_preview1) imports, which is what wasm32-wasip1 targets produce.
Can a wasm app see the whole filesystem?
It sees what is preopened. When run through execWasmApp, that is the working directory, mounted as / inside the app.
How is this different from running the same tool on the RISC-V VM?
The catalog also ships many tools as RV64 ELF binaries; those run on the emulator with full Linux semantics. A WASI build of the same tool runs on the browser's wasm engine without CPU emulation, at the cost of WASI's narrower system interface.