userland.run70Try the demo
Feature · wasm runner

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 runnerwasm runner
RunsRV64GC Linux ELF binarieswasm32-wasip1 modules
ExecutionInterpreted, one instruction at a timeCompiled by the browser's wasm engine
FilesystemShared kernel VFSShared kernel VFS (cwd preopened as /)
NetworkingSockets inside the VMNone — WASI p1 sock_* calls return ENOTSUP
IsolationGuest process in the emulatorOne 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.

wasm app

Grayscale a JPEG on the wasm runner

Each line runs with execWasmApp with /work as the working directory, so paths are relative to it.

bash
photon in.jpg out.png --filter grayscale
loading…
Expected output
photon: grayscale 360x240 -> out.png (360x240 PNG)
wasm app

Chain two filters through the filesystem

The second run reads the file the first one wrote. Nothing leaves the tab.

bash
photon in.jpg blurred.png --filter blur
photon blurred.png edges.png --filter threshold
loading…
Expected output
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.

wasm app

Find TODOs in a small project

A Rust-looking project with a .gitignore is seeded into /work/proj; rg walks it.

bash
rg -n TODO proj
rg --files proj
loading…
Expected output
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
wasm app

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.

bash
rg -c TODO -g '*.rs' proj
rg -l --no-ignore TODO proj
loading…
Expected output
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.

SDK · JavaScript

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.

javascript
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());
loading…
Expected output
exit: 0
todo.txt:1:write docs
todo.txt:3:write tests
SDK · JavaScript

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).

javascript
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);
loading…
Expected output
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 cwd as /, so relative paths and . resolve inside it.
  • Use stdout/stderr for text and files for binary data — stdout is decoded as UTF-8.
  • Publish it to the catalog as kind: "wasm-app" and nano.installApp(name) registers it automatically after verifying the signature.
OptionTypeDefaultDescription
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.cwdstring"/"Working directory, preopened as the app's /.
opts.timeoutMsnumber60000Watchdog 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.
WASI preview 1 has no sockets, so wasm apps can't open network connections. Use the RISC-V VM (or host-engine Node) for anything that talks to the network.

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.