A single HTML page that takes Rust source, runs it entirely in the browser, and reports stdout, stderr (including compiler diagnostics) and an exit code. No backend, no network round-trip for the code you write.
This is the minimal demo requested for
live-codes/livecodes#739,
intended as the stepping stone towards a rust language module in LiveCodes.
npm install --include=dev # esbuild is a devDependency; omit --include=dev if NODE_ENV=production
npm start # builds the worker, then serves on http://localhost:8888Set PORT to change it. A server is required: file:// cannot load workers or
use Cache Storage. Any static server works (npx serve,
python -m http.server, ...).
The first load downloads ~55 MB of toolchain (compressed). It is stored in Cache Storage, so later loads are effectively instant.
npm run build bundles the worker into a single self-contained IIFE:
| File | Purpose |
|---|---|
dist/worker.iife.js |
minified (~28 KB) — what consumers load |
dist/worker.iife.debug.js |
unminified, for debugging |
Because the bundle has no imports, it can be loaded as a classic worker
(new Worker(url)), which is more widely supported than a module worker. From a
page on another origin, fetch it first and hand the text to a blob URL, since
classic workers must be same-origin:
const source = await fetch('https://unpkg.com/@live-codes/rust-wasm/dist/worker.iife.js').then(
(r) => r.text(),
);
const url = URL.createObjectURL(new Blob([source], { type: 'text/javascript' }));
const worker = new Worker(url);
URL.revokeObjectURL(url);
// Optionally point the runtime at a specific toolchain host before it loads.
worker.postMessage({ type: 'init', toolchain: { baseUrl, gzipped } });
worker.postMessage({ type: 'run', id: 1, code: 'fn main() { println!("hi"); }' });The worker replies with progress and ready while loading, then result
({ id, stdout, stderr, exitCode, trap, durationMs }) or run-error.
A run message may carry an input string, which becomes the program's stdin:
worker.postMessage({ type: 'run', id: 1, code, input: '10\n20\n30\n' });Every run starts with a fresh stdin, so the same code can be re-run with
different input. There is no interactive terminal — the whole input is supplied
up front, and reads return EOF once it is consumed. This is ordinary behaviour
for a program whose input is redirected from a file:
use std::io::BufRead;
fn main() {
let total: i64 = std::io::stdin()
.lock()
.lines()
.filter_map(|line| line.ok()?.trim().parse::<i64>().ok())
.sum();
println!("total = {total}");
}Mechanically, Miri reads stdin from WASI file descriptor 0, and the runtime
backs that descriptor with a file holding the input. No interpreter support or
shimming is involved, so std::io::stdin(), read_line, read_to_string,
lines() and BufRead all behave normally.
any CDN ────────► Web Worker ──► WASI shim ──► Miri (wasm) ──► your program
(e.g. unpkg) IIFE bundle @bjorn3/ rustc's MIR
+ gunzip browser_ interpreter
+ cache wasi_shim
src/toolchain.js– the toolchain manifest and URL builder. Everything is derived from a singlebaseUrl, so the host is swappable.src/loader.js– concurrent fetch, gzip decompression, Cache Storage, retry with backoff, progress reporting.src/miri.js– the actual runner: builds a WASI filesystem holdingmain.rsand the sysroot, instantiates the compiler, captures output.src/worker.js– the entry point that is bundled into the IIFE.scripts/build.mjs– esbuild bundling (IIFE, no imports).src/main.js/index.html– the demo page's editor and output panes.
A snippet that does not define fn main is wrapped in one, so
println!("hi"); runs on its own.
The CDN is not hard-coded. Anything that mirrors the same files works, which lets you move off jsDelivr without editing code:
http://localhost:8888/?baseUrl=https://fastly.jsdelivr.net/gh/live-codes/rubri@<sha>/example/public/wasm-rustc
| Parameter | Meaning |
|---|---|
baseUrl |
Root the artifact paths are appended to. |
gzip |
0/false if the host stores files raw instead of .gz. |
From JavaScript, baseUrl is passed to the worker at initialisation, before it
loads anything:
const worker = new Worker(new URL('./src/worker.js', import.meta.url), {
type: 'module',
});
worker.postMessage({ type: 'init', toolchain: { baseUrl, gzipped } });resolveToolchain() in src/toolchain.js merges overrides over the defaults.
gzipped describes how the host stores the files, not the toolchain itself —
jsDelivr serves the GitHub fork as pre-gzipped .gz files, whereas the npm
endpoint brotli-compresses raw files in transit, so a different value is needed
per host.
Every file name is content-hashed by rustc, so switching hosts is free while
switching to a different Miri build also requires overriding miri, libDir
and syslibs:
resolveToolchain({
baseUrl: 'https://cdn.example.org/rust',
gzipped: false,
miri: 'bin/rustc.wasm',
libDir: 'lib/rustlib/wasm32-wasi/lib',
syslibs: ['libcore-aaaa.rlib'],
});Cache keys are absolute URLs, so two hosts cache side by side rather than overwriting one another.
Verified with a logging proxy standing in as baseUrl: the compiler wasm and
every sysroot library were requested from that host, and pointing baseUrl at
an unreachable path fails with the offending URL named.
The obvious design — compile Rust to a real .wasm, then run that in the result
page the way the WebAssembly Text and AssemblyScript languages do — is not
possible today:
rustccompiled to WASI cannot link. WASI has no way to spawn a process and rustc has no built-in linker, so compilation stops at object files. This is stated plainly in bjorn3's own demo, and is whywasm-rustcships.ooutput rather than runnable modules.- The experimental
rustc+lldroute needsSharedArrayBufferand therefore COOP/COEP headers, which a CDN cannot provide, and still only produces object files.
So this demo interprets the program instead: Miri, Rust's mid-level IR
interpreter, compiled to wasm32-wasip1-threads and driven through
@bjorn3/browser_wasi_shim. That yields real program output and a real exit
code with no cross-origin-isolation requirements.
The artifacts are rustc/Miri built for wasm32-wasip1-threads, originally
produced by bjorn3 (patched rustc, see
rust-lang/miri#722), packaged by
LyonSyonII/rubri, and consumed here via
the live-codes/rubri fork at a pinned
commit.
Licensing: Miri and rustc are dual MIT OR Apache-2.0, rubri is MIT. Note
that this build of Miri has its undefined-behaviour checks compiled out and the
-Zmiri-disable-* flags turned on, because a playground wants "does my program
work?" rather than "is my program sound?".
Provenance is the open problem. These are a 2024-era build we did not
produce. Republishing them to our own npm scope would move the hosting without
fixing reproducibility. The durable fix is to build the artifacts in CI from
bjorn3/rust@compile_rustc_for_wasm* (as
Mearman/wasm-rustc does weekly) and
publish those.
The default baseUrl points at the GitHub fork, which means the artifacts are
committed pre-gzipped and decompressed in the browser. Publishing to npm would
be better:
/gh/ (current) |
/npm/ |
|
|---|---|---|
45 MB miri.wasm |
rejected — 20 MB per-file cap | served |
| Compression | none added; we ship .gz and gunzip |
automatic brotli (40 MB wasm → 7 MB) |
| Caching | max-age=604800 |
max-age=31536000, immutable |
| Version pinning | commit SHA required | semver is immutable |
To switch, point baseUrl at the package and set gzipped: false, since
jsDelivr serves raw npm files (applying Content-Encoding: br for .wasm,
.rlib and .so itself). The npm endpoint also lifts the 20 MB per-file cap
that makes the GitHub endpoint reject the largest artifacts outright.
These are inherent to running Miri in a browser, not bugs in this demo:
- Slow. It is an interpreter, not native code. Expect hundreds of milliseconds for small programs and seconds for output-heavy loops.
- Single file. No
Cargo.toml, no crates, nomodtrees beyond one file. - Non-interactive stdin. Input is supplied up front, not typed live.
- No threads.
std::thread::spawnis unsupported. - Panics are noisy. The runtime cannot unwind
(
_Unwind_RaiseExceptiondoes not exist here), so a panic aborts and Miri prints its internal abort trace. The exit code is reported as101. - Runaway programs cannot be interrupted. WebAssembly has no preemption, so the demo terminates the worker after 20 s and starts a fresh one; that worker reloads the toolchain from cache.
Checked in headless Chrome against the real CDN artifacts:
| Case | Exit | Output |
|---|---|---|
Vec + iterator + Debug format |
0 | sum 1..=10 = 55, doubled list |
Snippet without fn main |
0 | auto-wrapped, prints |
| Type error | 1 | error[E0308] diagnostics on stderr |
std::process::exit(3) |
3 | stdout preserved |
panic!("boom") |
101 | panic message on stderr |
println! + eprintln! |
0 | correctly separated |