wasm-vm · embedding guide RISC-V Linux + OCI, client-side wasm ← live demo

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.

Theoretical consumption model. wasm-vm is not yet a published npm package. The code shapes below are real — they mirror the module's actual exports (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, wvrun runs 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.

# your static host, at any origin /pkg/wasm_vm_wasm.js # + _bg.wasm — the wasm-pack module /loader.js # the boot engine (vendored) /artifacts-alpine.json # boot manifest (kernel + rootfs sha256) /releases/kernel/6.6.63/Image # the RISC-V kernel (~22 MiB) /releases/chunked-alpine/ manifest.json # chunk list + geometry boot-profile.json # optional: prefetch order chunks/<hash>.bin # content-addressed 128 KiB blocks

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

Cloudflare Pages caps files at 25 MiB. The kernel and each 128 KiB chunk are fine, but the full 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:

MemberWhat 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.
whenDoneA 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");
CommandBehavior
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.
What "container" means here. For each run, 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.

riscv64 only. The guest is RISC-V, so an image must publish a 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 setTimeout on the main thread. There is no requirement for SharedArrayBuffer or 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/riscv64 build; 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 and loader.js into 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.