How it works
The full path from a click on Run to a program executing on the user's own machine — and back to the terminal.
The idea#
A programming language is just a way to hand instructions to a computer. The computer that runs student code does not have to be a server we own — it can be the user's own machine, reached through the one runtime every device already ships with: the web browser.
Browsers run native-speed code through WebAssembly (WASM). So AutoLabDocs ships the toolchains themselves — a Python interpreter, a C/C++ compiler — compiled to WASM, plus a thin JavaScript host, and runs everything client-side. The old model (a Node.js node-pty service spawning Docker containers over WebSocket) has been removed entirely.
Architecture#
The IDE asks a factory for a runner, hands it code and a callback, and forgets about the language. Each run gets its own dedicated Web Worker.
components/IdeWorkspace.tsx (IDE / terminal UI)
│ getRunnerForFile(filename).run({ code, onEvent })
▼
lib/runtime/index.ts (engine selector + Runner)
│ spawns one Web Worker per run
▼
js/worker | python/worker | cpp/worker
new Function| Pyodide (WASM) | clang+lld+libc++ → program.wasm → WASI
│ postMessage(RunEvent) ▲ Atomics + SharedArrayBuffer
▼ │ (blocking stdin)
terminal output user keystrokesEvery engine speaks one message protocol, so the UI is identical regardless of language. The protocol is the contract; the engines are interchangeable implementations behind it.
The event protocol#
A Runner is the entire interface the IDE talks to:
interface Runner {
run(opts: { code: string; filename: string;
onEvent: (e: RunEvent) => void }): Promise<void>;
sendInput(data: string): void; // user typed a line
stop(): void; // kill the program
}
type RunEvent =
| { type: "output"; data: string } // stdout / stderr
| { type: "input_request"; prompt?: string } // blocked on stdin
| { type: "exit"; code: number } // finished
| { type: "error"; data: string }; // exception / compile errorgetRunnerForFile(name) selects the engine by extension (.js → JS, .py → Python, .c/.cpp/… → C/C++) and returns a worker-backed runner. Because this protocol matches what the terminal already consumed from the old WebSocket, swapping the server for the browser was a near drop-in change.
Blocking stdin — the hard part#
Interactive programs (input(), std::cin >>, prompt()) must block until the user types something. You can't block the main thread — the page would freeze — and postMessage is asynchronous, yet the runtimes call stdin synchronously. The fix is SharedArrayBuffer + Atomics:
- The program runs inside a Web Worker (a real second thread).
- Worker and main thread share one
SharedArrayBuffer(lib/runtime/stdinChannel.ts). - On a stdin read, the worker posts
input_request(UI shows a prompt) then callsAtomics.wait()— blocking the worker thread, not the UI. - The user submits; the main thread writes the bytes into the buffer and calls
Atomics.notify(). - The worker wakes, decodes the bytes, and returns them to the program.
Int32[0] = control flag (0 = waiting, 1 = ready)
Int32[1] = payload byte length
bytes[8..] = UTF-8 inputCross-origin isolation#
SharedArrayBuffer is only available on cross-origin-isolated pages, so next.config.ts sends:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: credentiallesscredentialless is chosen over require-corp so cross-origin assets — the Pyodide CDN, the banner image, analytics — keep loading without each needing CORP headers.The language engines#
JavaScript
Zero downloads, instant. The worker redirects console.* to output, exposes a synchronous prompt()/readline() backed by the stdin channel, and runs user code wrapped in an async IIFE so top-level await works.
Python
Real CPython via Pyodide (CPython compiled to WASM), loaded from its CDN on first run. setStdout/setStderr stream to the terminal; setStdin is wired to the blocking channel; loadPackagesFromImports() auto-installs numpy, pandas, matplotlib and friends on demand.
C / C++
A real clang + lld + libc++ toolchain compiled to WASM compiles and links the program on the client; the resulting program.wasm runs through a WASI runtime wired to stdout/stderr/stdin. The toolchain (~tens of MB) is not bundled — a setup step installs it under public/runtime/clang/ behind a small adapter with a stable build()/run() contract.
scripts/fetch-runtimes.sh) and a real-browser validation pass.Run lifecycle#
handleRunresolves the file's language; an unsupported extension shows a friendly message and stops.- A fresh worker is spawned for the run.
- Output chunks append to the terminal and buffer into the file's
lastOutputso the exported lab document captures the run. input_requestfocuses the terminal input; the submitted line returns viasendInput→ the stdin channel.- On
exit, buffers flush onto the file (lastOutput/lastInput/lastError). - Stop calls
worker.terminate()— killing even infinite loops, which the old container model couldn't do cleanly.
Security model#
- Code runs in the browser's Web Worker sandbox: no DOM, no parent-page access, no filesystem, no path into our infrastructure.
- There is no execution server to attack.
- Stopping a run terminates the worker and frees all its memory immediately.
Extending it — add a language#
Implement one worker that:
- listens for
{ type: 'run', code, filename, sab }, - emits
output/input_request/exit/error, - (optionally) uses
readStdinBlocking(sab, …)for input.
Then register the extension in languageForFile() and add a new Worker(new URL("./<lang>/worker.ts", import.meta.url)) branch in createWorker(). The UI needs no changes.