Skip to content

Latest commit

 

History

5 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Client-side Rust playground

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.

Run it

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:8888

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

Using the runtime

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.

Standard input

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.

How it works

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 single baseUrl, 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 holding main.rs and 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.

Configuring the toolchain host

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.

Why Miri and not rustc

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:

  • rustc compiled 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 why wasm-rustc ships .o output rather than runnable modules.
  • The experimental rustc + lld route needs SharedArrayBuffer and 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.

Toolchain provenance

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.

Serving the artifacts

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.

Limitations

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, no mod trees beyond one file.
  • Non-interactive stdin. Input is supplied up front, not typed live.
  • No threads. std::thread::spawn is unsupported.
  • Panics are noisy. The runtime cannot unwind (_Unwind_RaiseException does not exist here), so a panic aborts and Miri prints its internal abort trace. The exit code is reported as 101.
  • 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.

Verified behaviour

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

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages