I6 · Part 2 · How it runs
Eleven languages, no server
Three workers, two iframes, one database on the main thread, and a C compiler that downloads itself.
11On this page
Runnable and write-only
lib/codeLanguages.ts is the single list of languages the editor knows. It has 17
entries, and each one either names a RunnableKind or sets runnable: null.
There are ten kinds: js, ts, python, sql,
web, ruby, php, lua, c and
cpp. HTML and CSS share web, so eleven languages run and six are write-only:
Java, Go, Rust, Kotlin, Swift and C#. The language menus group them under "Runs here" and "Write
only", and pressing Run on a write-only file prints a sentence saying no compiler for it runs in a
browser, instead of failing silently.
| Languages | Where the code runs | Runtime |
|---|---|---|
| JavaScript | Module worker, lib/jsWorker.ts | The browser's own engine |
| TypeScript | Compiled on the main thread, then the same worker | The typescript 5.9.3 package |
| Python | Module worker, lib/pyodideWorker.ts | Pyodide 314.0.6 |
| SQL | The main thread | sql.js 1.14.2 (SQLite) |
| Lua | Module worker, lib/scriptWorker.ts | wasmoon 1.16.0 (Lua 5.4) |
| Ruby | The same script worker | ruby.wasm 2.10.1 (Ruby 3.4) |
| PHP | The same script worker | php-wasm 0.1.0 |
| C, C++ | The same script worker | YoWASP clang 22.0.0-git plus a WASI shim |
| HTML, CSS | A sandboxed iframe | The browser |
| React components | A sandboxed iframe | public/wasm/react-sandbox.js |
/wasm/ folder.
The JavaScript worker
lib/runner.ts first checks the syntax on the main thread by handing the code to
new Function, so a missing bracket is reported before a worker is involved. It then
builds one string: 'use strict', a results array, calls that set up standard input and
tracing, a marker call used to work out line numbers, the reader's code, the tests, and a final
await __settle(). The worker wraps that string in an async function and calls it.
Before the first run the worker replaces console.log, info,
debug, warn and error, plus time,
timeEnd, count, assert and table. Each posts a
formatted message straight away, so output streams while the code is still running. It also adds
readline() and prompt(), which read the Input tab one line at a time, and
compare(), a small benchmark that prints a table.
Settling. A program that calls setTimeout is not finished when its
last line returns. The worker wraps setTimeout and setInterval to keep
sets of pending ids, and __settle keeps yielding a zero-delay tick until both sets are
empty. Only then is done posted. An interval that is never cleared keeps the run open
until the timeout ends it. The same worker is reused for the next
run, so it clears any timers the previous run left behind before it starts.
The timeout. The runner starts a five-second timer beside every run. If
done has not arrived, it calls worker.terminate(), forgets the worker and
prints "stopped after 5s". The next run makes a fresh one. This is the whole defence against
while (true) {}: a worker can be killed from outside no matter what it is doing, and
the page's own thread never ran the loop. Stop uses the same path.
The loop guard, and why only React has one
React component exercises cannot use a worker, because they need a real DOM. They run in an iframe,
and an iframe can share the page's thread, so an endless loop there could stop the parent's timer
from ever firing. lib/reactSource.ts therefore compiles the reader's JSX with a
TypeScript transformer that inserts __loopGuard() at the top of every
while, do, for, for…in and for…of
body. The guard counts calls and, every 2,000 of them, checks the clock. After three seconds of one
test it throws "a loop ran for more than 3s", which fails the test instead of freezing the tab. Each
test resets it. The outer eight-second timer still removes the iframe if anything else hangs.
TypeScript
transpileTS imports the typescript package on first use. It transpiles
with transpileModule to ES2020, then type-checks with a real program over an in-memory
compiler host, with lib.es2020.d.ts and lib.webworker.d.ts as its libraries.
The host reads from 46 declaration files, 1,045,209 bytes in all, which
scripts/copy-wasm-assets.mjs copies from node_modules into
public/wasm/typescript-lib/ before every dev start and build. A type error stops the
run with a compile error. The compiled JavaScript then goes through the same worker as above.
Python
The Pyodide worker imports pyodide.mjs straight from jsDelivr with
webpackIgnore, so the bundler leaves the URL alone. On load it points standard output
and standard error at postMessage, then runs two short Python preludes of its own. One
replaces print so each line carries its source line number and adds a Python
compare(). The other defines the tracer the debugger uses. input() reads
from the Input tab.
The first run prints "loading the Python runtime" while the download happens. The loaded interpreter then stays in the worker, and code runs in its global namespace, so a name one run defines is still there in the next until the worker is replaced. The timeout is 20 seconds, and it starts when the code is sent, so on a slow connection the first download counts against it. A timeout terminates the worker, and the next run loads Pyodide again, from the browser's HTTP cache when it still has the files.
SQL
lib/sqlRunner.ts is the odd one out: it runs on the main thread. It loads sql.js once,
creates a new in-memory database for every run, executes the whole script, turns each result set
into a table in the console, and closes the database. Every run starts from nothing. There is no
worker and no timeout, which is acceptable because SQLite on a database that only exists for one
script finishes quickly. A recursive query that never ends would freeze the tab. That is a known
gap.
The script worker: Lua, Ruby, PHP, C and C++
Five languages share one worker and a map of engines, each created on first use.
- Lua uses wasmoon. Each run gets a new Lua state, a prelude that makes
printreport its line throughdebug.getinfo, and anio.readthat reads the Input tab. - Ruby compiles the ruby.wasm module once with
WebAssembly.compileStreaming, then starts a new VM for every run. A prelude redefinesputs,pandgets. The reader's code is evaluated with the file nameeval, so errors come back with real line numbers. - PHP creates a new
PhpWebfor every run and adds<?phpif the reader left it off. php-wasm expects a page, so the worker first installs a stubwindowanddocumentwith just enough methods for it to start. PHP does not read the Input tab. - C and C++ compile with clang running as WebAssembly. C uses
-O1 -std=c17 -lm; C++ uses-O1 -std=c++20 -fno-exceptionsand gets a generatedbits/stdc++.hthat includes 33 standard headers, so competitive-programming code compiles as written. Each diagnostic becomes a console line tied to its source line. The output,main.wasm, runs under@bjorn3/browser_wasi_shim0.4.2, with the Input tab as standard input and line-buffered output. A non-zero exit status or a trap is reported as an error.
The worker posts started when the reader's code actually begins. For C and C++ that is
after compiling. The runner allows 180 seconds for downloading and compiling, then switches to the
normal 20 seconds once started arrives, so a slow download does not count as an endless
loop.
Warm-up
Picking Lua, Ruby, PHP, C or C++ in the language menu calls warmScript at once. The
worker starts downloading that engine before the reader has typed anything. For C and C++ it also
compiles an empty main that includes a few common headers, so clang has loaded its
resources before the reader's first compile needs them. If a load fails, the engine is removed from
the map, so the next run tries again instead of waiting on a broken promise.
What gets downloaded
Sizes measured from node_modules and from jsDelivr for the exact pinned versions. The
compressed column is what jsDelivr sent with Brotli.
| File | Raw | Compressed |
|---|---|---|
Pyodide pyodide.asm.wasm | 9.6 MB | 3.4 MB |
Pyodide python_stdlib.zip | 2.5 MB | 2.5 MB |
Pyodide pyodide.asm.mjs | 1.3 MB | not measured |
sql.js sql-wasm.wasm | 658 KB | 323 KB |
wasmoon glue.wasm | 272 KB | not measured |
ruby.wasm ruby+stdlib.wasm | 30.6 MB | 7.9 MB |
| php-wasm, the PHP 8.4 binary | 13.8 MB | 3.0 MB |
clang llvm.core.wasm | 75.5 MB | 19.4 MB |
clang llvm-resources.tar | 29.7 MB | 3.6 MB |
| TypeScript lib files, from the site | 1.0 MB | not measured |
react-sandbox.js, from the site | 205 KB | not measured |
The hints in the editor's status bar are older than these measurements. They say about 12 MB for the C compiler, and the real transfer is about 23 MB.
Timeouts at a glance
| Run | Limit | What happens at the limit |
|---|---|---|
| JavaScript, TypeScript | 5 s | Worker terminated and replaced |
| JavaScript debug run | 8 s | Worker terminated and replaced |
| Python | 20 s, including the first load | Worker terminated; Pyodide loads again |
| Lua, Ruby, PHP, C, C++ | 180 s to start, then 20 s | Worker terminated; every loaded engine goes with it |
| React component | 3 s per loop, 8 s overall | Test fails, or the iframe is removed |
| SQL | none | Runs on the main thread |
Sandboxing
The boundaries are the ones the browser already enforces. Worker code has no DOM, no
document.cookie and no localStorage, so it cannot read the reader's saved progress or
change the page. Both iframes are sandboxed without allow-same-origin, so they get an
opaque origin: the React one has sandbox="allow-scripts", and the web preview adds
allow-modals and allow-forms. The parent checks event.source
on every message, so another frame cannot pretend to be the sandbox.
The Content-Security-Policy in next.config.ts is built from the same constants.
wasmOrigins() in lib/wasmAssets.ts returns the origins of the Pyodide,
sql.js and npm CDN bases, and those are added to script-src and
connect-src. The policy has to
allow 'unsafe-eval', because running a reader's code with new Function is
what this whole feature does.
A worker runs with the site's origin, so code in it can still call fetch against the
site's own endpoints and write to IndexedDB. That is the same power the reader already has in the
devtools console of the same tab. Since the only code that runs is code the reader typed or opened
from a link, the real risk is a share link carrying hostile code. A shared link opens as new tabs
and waits for Run, with one exception: if Live mode is already on, shared JavaScript runs 700 ms
after it appears, like any other edit.
Finished reading?
0 of 26 chapters read in this series