WebAssembly
Browser client
Everything on this page is about the client side:
the HTML and JavaScript that loads and runs an already-compiled
program.wasm in a tab. For cross-compiling a
Festina program to wasm32-wasi in the first place
— festina compile --target=wasm32-wasi, setup,
design, limitations and benchmarks — see
wasm.md, whose own
"In a browser" section this
page is the worked-example counterpart to.
Three files, copied straight from
runtime/wasm/,
are all a page needs: festina_wasi_browser.js (the
WASI host itself — a dependency-free ES module),
festina_wasi_worker.js (runs a program on that host
inside a Web Worker, so the page's own thread never blocks),
and, if you want it as-is rather than writing your own page,
browser.html.
Quick start: browser.html #
The fastest way to see a compiled program run in a tab needs no custom code at all:
$ festina compile --target=wasm32-wasi program.f -o program.wasm $ cp runtime/wasm/browser.html runtime/wasm/festina_wasi_browser.js runtime/wasm/festina_wasi_worker.js . $ python3 -m http.server 8000 # any static file server -- module workers need real HTTP
Then open
http://localhost:8000/browser.html?wasm=program.wasm.
browser.html fetches the .wasm the
query string names, runs it in a Worker, and streams
stdout/stderr onto the page as plain text (stderr in red) as
the program writes them — no build step, no bundler, just the
three files above sitting next to your .wasm.
window.festinaResult holds the finished
run. Once the program exits, browser.html
sets window.festinaResult to
{code, stdout, stderr, files} — the same shape
every example on this page produces, and exactly what a
driving test (or your own outer page, via an iframe)
would read back to check the result programmatically instead
of eyeballing the rendered text.
Embedding it in your own page #
browser.html is a real page, not a black box —
its entire run loop is one festinaRun() function
you can write yourself, with your own UI around it.
festina_wasi_worker.js is the actual seam: give
it a .wasm's bytes and it posts back one message
per line of output, then one final message with the exit
code. A minimal custom page:
<!-- index.html --> <!doctype html> <html> <body> <button id="run">Run program.wasm</button> <pre id="output"></pre> <script type="module"> const output = document.getElementById('output'); document.getElementById('run').onclick = async () => { output.textContent = ''; const wasm = await fetch('program.wasm').then(r => r.arrayBuffer()); const worker = new Worker('festina_wasi_worker.js', { type: 'module' }); worker.onmessage = ({ data }) => { if (data.kind === 'stdout' || data.kind === 'stderr') { output.textContent += data.line + '\n'; } else if (data.kind === 'exit') { output.textContent += `\n[exited with code ${data.code}]`; worker.terminate(); } else if (data.kind === 'error') { output.textContent += `\n[host error] ${data.message}`; worker.terminate(); } }; // the ArrayBuffer is transferred, not copied -- cheap even for a large .wasm worker.postMessage({ wasm, args: ['program.wasm'] }, [wasm]); }; </script> </body> </html>
Served from the same directory as
festina_wasi_browser.jsfestina_wasi_worker.js
and a compiled program.wasm, this is the entire
client side of a Festina-in-the-browser app: no framework, no
bundler, no build step — festina_wasi_worker.js
imports festina_wasi_browser.js as a relative ES
module on its own, so dropping both files next to your page
is the whole integration.
Feeding files in, reading them back #
A Festina program's filesystem is sandboxed and in-memory in
the browser (see
wasm.md) — nothing it
reads or writes touches the visitor's real disk. The
files option seeds that sandbox before the
program starts; the exit message hands back
every file the program left there when it's done, as plain
Uint8Arrays:
const wasm = await fetch('program.wasm').then(r => r.arrayBuffer()); const worker = new Worker('festina_wasi_worker.js', { type: 'module' }); worker.onmessage = ({ data }) => { if (data.kind === 'exit') { const bytes = data.files['/report.txt']; // Uint8Array, or undefined if never written const text = new TextDecoder().decode(bytes); console.log('the program wrote:', text); } }; worker.postMessage({ wasm, args: ['program.wasm'], files: { '/config.json': JSON.stringify({ name: 'demo', limit: 10 }), '/data/seed.csv': 'id,value\n1,7\n2,9\n', // directories are created as needed }, }, [wasm]);
A seeded path may be a text string or bytes
(anything TextEncoder/a typed array already
handles) — the host stores it as bytes either way. On the
Festina side this is just blob/mkdir/ls
and SQLite's own database file working exactly as documented
in api.md; the browser page is simply
the one deciding what's there before the program starts, and
collecting what's there after it ends.
Arguments and environment variables #
args becomes the program's own
command-line arguments
array (args[0] is conventionally the program's
own name, matching argv[0] on every native
target); env becomes what
environment.NAME reads:
worker.postMessage({
wasm,
args: ['program.wasm', '--verbose', room],
env: { API_KEY: 'demo-key' },
}, [wasm]);
Both are ordinary JavaScript values built from whatever the
page already has — a form field, a URL query parameter, a
value read from localStorage — there is nothing
Festina-specific about constructing them; the host just
copies them into the sandboxed process's own
args/environ before it starts.
Running without a Worker #
festina_wasi_worker.js is a thin wrapper: it
imports the host directly and simply posts its callbacks back
as messages. Nothing stops calling FestinaWasi
itself from a page's own main-thread script, or from inside a
Worker you're already running for other reasons:
import { FestinaWasi } from './festina_wasi_browser.js'; const wasm = await fetch('program.wasm').then(r => r.arrayBuffer()); const host = new FestinaWasi({ args: ['program.wasm'], stdout: line => console.log(line), stderr: line => console.error(line), }); const code = await host.run(wasm); console.log('exited with', code, host.files()); // Map<path, Uint8Array>
main() (plus its own timer loop), and WebAssembly
has no way to yield back to JavaScript from inside a running
instance — so calling host.run() from the page's
own main thread freezes the tab (no scrolling, no repaint,
no other event handler) until the program exits. Fine for a
program that finishes in a few milliseconds and touches no
timers; anything longer, or anything using
setTimeout/setInterval, belongs in
a Worker via festina_wasi_worker.js — which is
why browser.html always uses one.
API reference #
The FestinaWasi class #
| new FestinaWasi(options) | options.args — arr[text]-shaped argv, default ['program.wasm']. options.env — a plain object of environment variables, default {}. options.files — initial sandbox contents, path → text/bytes, default {}. options.stdout/options.stderr — called with one complete line at a time as the program writes it (no trailing newline). |
| host.run(wasmBytes) | Instantiates and runs the module — an ArrayBuffer or Uint8Array in, a Promise<int> out, resolving to the program's real exit code (close(code), or 0/1 for a normal/failed return from main(), exactly matching every native target and Node's own node:wasi host). |
| host.files() | A Map<text, Uint8Array> of every regular file present in the sandbox once run() resolves — the seeded ones (if untouched) and anything the program wrote or created. |
| host.stdout / host.stderr | The complete captured output as one string each, newline-joined — the same text the line-by-line callbacks already delivered, kept for convenience. |
One FestinaWasi instance is good for exactly one
run() — its sandbox and captured output belong to
that one process. Running another program means constructing
another instance (or, in a Worker, letting the worker exit and
starting a fresh one — which is what posting a new
wasm message to a new Worker
amounts to).
The worker message protocol #
Everything festina_wasi_worker.js understands, in
both directions:
| → worker.postMessage({wasm, args, env, files}) | Starts a run. wasm is required (an ArrayBuffer); transfer it (the second postMessage argument, [wasm]) rather than letting it be structured-cloned, especially for anything but a tiny module. args/env/files all default the same way FestinaWasi's own constructor does. |
| ← {kind: 'stdout', line} / {kind: 'stderr', line} | One per line, as the program writes it — not batched, so a page can render output live rather than only after the program exits. |
| ← {kind: 'exit', code, stdout, stderr, files} | Sent once, when the program finishes. files is a plain object (not a Map — messages are structured-cloned) of path → Uint8Array, built from host.files(). |
| ← {kind: 'error', message} | The host itself failed before or during the run (a malformed .wasm, an import the host doesn't implement) — distinct from the program's own exit code, which is always a clean 0-255 even for a Festina-level failure (an uncaught throw, an unhandled fail()). |
browser.html's own window API #
If you're using browser.html as-is (rather than
building the custom page above), it exposes the whole run
loop as one global function, which is also what a test
harness driving it through Playwright (or a plain
iframe) calls directly:
| window.festinaRun(url, options) | Fetches url, runs it exactly like the ?wasm= query string does, and returns a Promise resolving to the same {code, stdout, stderr, files} shape. options.args/options.files pass through to the worker; args defaults to [url's own filename] if omitted. |
| window.festinaResult | null until a run finishes, then the same object festinaRun's own promise resolved with — what to poll (or await a matching page.waitForFunction against, from outside the page) if you'd rather not thread a promise through. |
Serving it and cross-origin isolation #
Every example on this page needs real HTTP, not
file:// — module workers refuse to load a script
from a file: URL in every current browser. Any
static file server works (python3 -m http.server,
npx serve, whatever the rest of your app already
uses); there's nothing server-side to configure beyond serving
plain static files.
One optional header pair changes how a Festina program's own
timers behave, not whether anything works:
Cross-Origin-Opener-Policy: same-origin and
Cross-Origin-Embedder-Policy: require-corp make a
page cross-origin isolated, which is what lets
setTimeout/setInterval sleep with a
real Atomics.wait inside the Worker instead of
spinning — see
wasm.md. A program with
no timers, or one that only cares about its final exit code
and output, needs neither header.