Embedding guide
Embed wasm-vm in your own frontend
wasm-vm boots an unmodified RISC-V Linux guest and runs real OCI/Docker containers entirely client-side in WebAssembly — no server, no VM host, no backend. This page describes the intended integration model for consuming it as a library in a page of your own.
web/loader.js) and in-guest tooling —
but you consume them today by vendoring the built pkg/ ES module and the
loader into your own static site, not by npm install.
What it gives you
- A real Linux guest. An unmodified RISC-V (RV64GC) kernel boots to a
shell over an emulated serial console (
ttyS0). You feed it keystrokes and receive console bytes — exactly a terminal. - Real containers. Inside the Alpine guest,
wvrunruns unpacked OCI images with genuine isolation:unshare(pid/mount/uts/ipc namespaces) + an overlay rootfs +pivot_root+ a seccomp filter + per-container cgroup leaves. It is a small slice of runc, not a simulation. - No server. The CPU, MMU, virtio-blk disk, and virtio-net stack all run in wasm on the main thread. Everything you serve is a static file.
The point: you can build an in-browser IDE, a Docker-style playground, a reproducible-environment sandbox, or a teaching tool — with a genuine Linux/container backend that lives entirely in the user's tab.
Step 1
What you host
Everything is static — Cloudflare Pages / R2, GitHub Pages, S3, or any CDN. There are four kinds of artifact.
1. The wasm ES module
Built with wasm-pack, targeting the browser (no bundler required):
# produces web/pkg/wasm_vm_wasm.js + wasm_vm_wasm_bg.wasm — an ES module you import directly
wasm-pack build crates/wasm --target web --out-dir ../../web/pkg
The loader (web/loader.js) imports from this package and calls its
init() (which does WebAssembly.instantiateStreaming under the hood).
Vendor both loader.js and pkg/ into your site.
2. The kernel image + the rootfs manifest
A tiny boot manifest (JSON) names the kernel Image and the
rootfs, each pinned by sha256. The loader fetches the manifest first, then the
kernel, and integrity-checks bytes before boot — a corrupt image never boots. This is
web/artifacts-alpine.json:
{
"artifacts": {
"kernel": { "url": "releases/kernel/6.6.63/Image", "sha256": "08caa7…", "size": 22097408 },
"rootfs": { "url": "releases/rootfs/alpine-rootfs.ext4", "sha256": "e7db52…", "size": 536870912 }
}
}3. The chunked ext4 rootfs (content-addressed, lazy-fetched)
The Alpine image is 512 MiB — far too big to download whole. Instead
wasm-vm chunk splits it into fixed-size (128 KiB) blocks named by their
sha256. Boot touches only a fraction of the image, so the guest pulls only the
chunks it actually reads, one at a time over plain HTTP. A separate
image manifest lists the chunk hashes in order; identical blocks
(all-zero regions, duplicated data) collapse to one file:
// releases/chunked-alpine/manifest.json { "version": 1, "image_len": 536870912, "chunk_size": 131072, "layout": "split", "chunks": [ "509bb0f1…", "7ba7026c…", "a7810773…", /* one sha256 per 128 KiB block */ ] }
Each chunk is served as a static file at chunks/<hash>.bin under the
manifest's directory. Chunks are verified in wasm on insert (per-chunk hash
check), which is why the full-image sha256 can safely be skipped in chunked mode —
integrity is enforced block-by-block. An optional boot-profile.json (an ordered
list of chunk indices) lets you prefetch the boot working set up front.
4. Pre-baked container bundles
OCI images unpacked to riscv64 bundles are baked into the Alpine rootfs at
/opt/containers/<name>, so wvrun /opt/containers/nginx works
offline the moment the guest is up. (See Bring your own image.)
alpine-rootfs.ext4 is not — serve big monolithic artifacts from R2 (or any
object store) and keep Pages for the small stuff. Chunked mode sidesteps this entirely.
Step 2
Boot a guest
startLinuxBoot(opts) is the whole boot engine. It fetches + verifies artifacts,
instantiates the wasm module, boots Linux, and returns a controller that
drives the running machine. You wire two things: console bytes out to a terminal, and
keystrokes in via sendInput.
import { startLinuxBoot } from "./loader.js"; // Any terminal emulator works; xterm.js is what the reference demo uses. const term = new Terminal(); term.open(document.getElementById("term")); const ctl = await startLinuxBoot({ manifestUrl: "./artifacts-alpine.json", // kernel + rootfs sha256 mode: "chunked", // lazy per-chunk fetch (the production path) imageManifestUrl: "./releases/chunked-alpine/manifest.json", ramMib: 256, persist: false, // true → CoW overlay saved to IndexedDB (survives reload) // Console bytes from the guest's ttyS0 → your terminal. onOutput: (u8) => term.write(u8), // Lifecycle: "fetching" | "verifying" | "instantiating" | "booting" | "done" | "error". onState: (s) => console.debug("boot:", s), // Per-artifact byte progress; total is null when the server sends no Content-Length. onProgress: (role, loaded, total) => {}, // A specific, surfaced failure (HTTP status / hash mismatch / boot error). onError: (e) => term.write(`\r\nboot error: ${e.message}\r\n`), }); // Keystrokes → the guest's serial RX. This is exactly how a human types. term.onData((str) => ctl.sendInput(new TextEncoder().encode(str)));
The controller exposes the running machine:
| Member | What it does |
|---|---|
sendInput(bytes) | Push bytes into the guest serial console (Uint8Array). |
pause() / resume() | Idle / resume the executor. Guest monotonic time is a deterministic retire-count clock, so it freezes and continues seamlessly — no catch-up storm. The demo drives these from visibilitychange. |
persist() | Force a durable flush of the CoW overlay to IndexedDB; resolves to blocks written. No-op unless persist:true and this tab holds the writer lock. |
stop() | Halt the run loop. |
stateDigest() | SHA-256 fingerprint of architectural machine state (evidence / stale-run detection). |
fetchStats() | { fetches, bytes, error } for chunked boots — how much of the image has actually been pulled. null otherwise. |
whenDone | A Promise<string> resolving on a terminal outcome ("poweroff", "reboot", "stopped", "error"). |
Other modes: mode:"initramfs" (a small busybox rootfs as the initrd — works
anywhere, including size-limited hosts) and mode:"disk" (the whole ext4 image
downloaded up front over virtio-blk). Chunked is the production default. When
persist:true, a single-writer Web Lock guarantees exactly one tab
can write the overlay; other tabs boot read-only (surfaced via onWriterStatus),
and onStorage/onQuota callbacks let you show storage usage and handle
an out-of-quota disk.
Step 3
Run containers
Containers are a guest-side concern: the Alpine rootfs ships
wvrun, a small POSIX-sh OCI runner. You drive it exactly like a user typing at the
shell — there is no separate container API. To do it programmatically, run a shell command in
the guest and capture its stdout with a fenced RPC over the serial console.
The fenced-RPC pattern
Type <cmd>; printf '\n__END_%s\n' "$?" into the console, then read output
bytes until the unique end-marker appears — everything between the echoed command and the
marker is stdout, and the marker captures the exit code. This is exactly what the reference
demo's wvmDemo.exec() does (serialized so calls don't interleave):
// A minimal exec() over the boot controller. Real code (main.js) strips ANSI and // serializes concurrent calls on a promise chain; this is the essence. function exec(ctl, subscribe, cmd, timeoutMs = 60000) { return new Promise((resolve, reject) => { const id = Math.random().toString(36).slice(2); const end = new RegExp(`__END_${id}_(\\d+)`); let buf = ""; const off = subscribe((u8) => { // tap the console byte stream buf += new TextDecoder().decode(u8, { stream: true }); const m = buf.match(end); if (m) { off(); let out = buf.slice(0, m.index); out = out.slice(out.indexOf("\n") + 1); // drop the echoed command line resolve({ stdout: out, exit: parseInt(m[1], 10) }); } }); ctl.sendInput(new TextEncoder().encode(`${cmd}; printf '\\n__END_${id}_%s\\n' "$?"\r`)); setTimeout(() => { off(); reject(new Error("timeout")); }, timeoutMs); }); }
Driving wvrun
Once you have exec(), the container lifecycle is just shell commands. Bundles live at /opt/containers/<name>.
// Start nginx detached; wvrun prints the container id. const { stdout: id } = await exec(ctl, subscribe, "wvrun run -d --name web /opt/containers/nginx"); // List running containers — `ps` emits one JSON object per line. const { stdout: ps } = await exec(ctl, subscribe, "wvrun ps"); const containers = ps.trim().split("\n").filter(Boolean).map(JSON.parse); // → [{ id, name:"web", image:"nginx", status:"running", started, exit }] // Tail its captured stdout/stderr, run a command inside it, then stop + remove. await exec(ctl, subscribe, "wvrun logs web"); await exec(ctl, subscribe, "wvrun exec web sh -c 'nginx -v'"); await exec(ctl, subscribe, "wvrun stop web"); await exec(ctl, subscribe, "wvrun rm web");
| Command | Behavior |
|---|---|
wvrun <bundle> | Run to exit (foreground). Add --interactive for a shell, --memory B / --pids N for cgroup limits. |
wvrun run -d --name N <bundle> | Run detached; prints the container id. State is tracked under /run/wvcontainers. |
wvrun ps [-a] | List containers as JSON lines (one object per line). -a includes stopped. |
wvrun logs [-f] <ref> | Replay (or -f follow) captured stdout+stderr. |
wvrun exec [-it] <ref> <cmd…> | Enter a running container's namespaces (the real "docker exec"). |
wvrun stop <ref> / rm [-f] <ref> | SIGTERM→cgroup.kill; remove a stopped container. |
wvrun creates
a cgroup leaf, then unshares pid/mount/uts/ipc namespaces, overlay-mounts the
image (ro lower + tmpfs upper), sets up fresh proc/sys/dev,
pivot_roots into it, installs a seccomp filter, and execs the image's argv. Honest
v1 scope: containers run as root-in-guest and share the guest network namespace.
Step 4
Bring your own image
Any public Docker/OCI image with a riscv64 build can become a runnable bundle.
tools/build-container-bundle.sh wraps three real steps: pull the image-layout
(digest-verifying every blob), wasm-vm oci unpack to flatten the layers into
rootfs/ + config/, and assert the entry binary is genuinely an ELF for
the target arch — no fake bundles slip through.
# pull + digest-verify + unpack → a riscv64 bundle. Works with any standard v2 # registry: Docker Hub, ghcr.io, quay.io, gcr.io, … (anonymous pull). tools/build-container-bundle.sh nginx ./nginx-bundle riscv64 tools/build-container-bundle.sh busybox ./busybox-bundle riscv64 # The bundle is: rootfs/ + config/{argv,env,cwd,user}. Run it in-guest: # wvrun ./nginx-bundle
Then bake the bundle into the rootfs at /opt/containers/<name>
before you run wasm-vm chunk, so it ships with the image and is available offline.
(Alternatively, transfer a bundle into a running guest via the file-transfer channel exposed on
the controller.) Rebuild the chunk manifest after baking so the new blocks are addressable.
linux/riscv64 manifest. Many do (nginx, redis, postgres, busybox, alpine); some
(e.g. official node, python) currently do not — the build script fails loudly with
"no riscv64 manifest in the index" rather than baking a wrong-arch rootfs.
Step 5
Build your own frontend
Tying it together: boot the guest, wire a terminal, and add a "Run nginx" button that waits
for the shell then drives wvrun. This is a compact but realistic sketch against
the real API.
import { startLinuxBoot } from "./loader.js"; // --- a tiny console fan-out so both the terminal and exec() can read guest bytes --- const subscribers = new Set(); const subscribe = (fn) => { subscribers.add(fn); return () => subscribers.delete(fn); }; const term = new Terminal(); term.open(document.getElementById("term")); const ctl = await startLinuxBoot({ manifestUrl: "./artifacts-alpine.json", mode: "chunked", imageManifestUrl: "./releases/chunked-alpine/manifest.json", ramMib: 256, onOutput: (u8) => { term.write(u8); for (const fn of subscribers) fn(u8); }, }); term.onData((s) => ctl.sendInput(new TextEncoder().encode(s))); // --- wait for a usable shell prompt before scripting the guest --- function whenReady() { return new Promise((res) => { let tail = ""; const off = subscribe((u8) => { tail = (tail + new TextDecoder().decode(u8)).slice(-200); if (/[\w.-]+:~#\s*$|\/ #\s*$/.test(tail)) { off(); res(); } }); }); } // --- the button --- document.getElementById("run-nginx").onclick = async () => { await whenReady(); const { stdout: id } = await exec(ctl, subscribe, "wvrun run -d --name web /opt/containers/nginx"); console.log("nginx up:", id.trim()); const { stdout: ps } = await exec(ctl, subscribe, "wvrun ps"); renderTable(ps.trim().split("\n").map(JSON.parse)); };
That is the entire shape of an in-browser Docker frontend: a terminal bound to the controller,
a readiness gate, and exec() calls that parse wvrun output. Everything
else — tabs, a container table, log panes — is ordinary UI on top of these primitives.
Honest limits
Caveats & limits
- The CPU is interpreted, so it is slow. Booting Alpine to a login prompt is a minutes-scale operation, and heavy workloads crawl. A JIT (dynamic binary translation to wasm) is a later epic — it is what makes things like Node.js interactive — but it is not here yet. Set expectations accordingly in your UI.
- Single-threaded, no special headers. The run loop drives itself off
setTimeouton the main thread. There is no requirement forSharedArrayBufferor cross-origin isolation (COOP/COEP) — it runs on ordinary static hosting. (Workers/SAB are a future optimization, not a prerequisite.) - Mind file-size caps. Cloudflare Pages rejects files over 25 MiB.
The kernel and 128 KiB chunks are fine; serve any large monolithic artifact
(e.g. the full
.ext4) from R2 or another object store. - Containers are riscv64 and honest-v1. Images need a
linux/riscv64build; containers run as root-in-guest and share the guest network namespace. The guest itself is the sandbox. - This is a theoretical consumption model. wasm-vm is not published to npm.
You integrate today by vendoring the built
pkg/module andloader.jsinto your own static site. The API names here are real; the packaging is aspirational.
See the live demo for a working reference frontend (terminal + Docker + IDE tabs) built on exactly these primitives.