f Festina / Docs

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.js/festina_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>
This blocks whatever thread runs it for the whole program. A Festina program is an ordinary synchronous 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.stderrThe 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.festinaResultnull 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.