How this is builtGroundworkHow this is built
12 / 26 written chapters

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.

10 min read1 diagram11 sections
11On this page
  1. Runnable and write-only
  2. The JavaScript worker
  3. The loop guard, and why only React has one
  4. TypeScript
  5. Python
  6. SQL
  7. The script worker: Lua, Ruby, PHP, C and C++
  8. Warm-up
  9. What gets downloaded
  10. Timeouts at a glance
  11. Sandboxing

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.

LanguagesWhere the code runsRuntime
JavaScriptModule worker, lib/jsWorker.tsThe browser's own engine
TypeScriptCompiled on the main thread, then the same workerThe typescript 5.9.3 package
PythonModule worker, lib/pyodideWorker.tsPyodide 314.0.6
SQLThe main threadsql.js 1.14.2 (SQLite)
LuaModule worker, lib/scriptWorker.tswasmoon 1.16.0 (Lua 5.4)
RubyThe same script workerruby.wasm 2.10.1 (Ruby 3.4)
PHPThe same script workerphp-wasm 0.1.0
C, C++The same script workerYoWASP clang 22.0.0-git plus a WASI shim
HTML, CSSA sandboxed iframeThe browser
React componentsA sandboxed iframepublic/wasm/react-sandbox.js
MAIN THREAD The page SQL runs here, no worker JavaScript worker JavaScript and TypeScript, 5 s Pyodide worker Python, 20 s Script worker Lua, Ruby, PHP, C, C++, 20 s React iframe component exercises, 8 s Preview iframe HTML and CSS pages jsDelivr Pyodide, sql.js wasmoon, ruby.wasm php-wasm, clang loaded on first use
Every runtime is fetched the first time someone picks its language, and never before. The TypeScript lib files and the React sandbox are the exceptions: they are served from the site's own /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 print report its line through debug.getinfo, and an io.read that reads the Input tab.
  • Ruby compiles the ruby.wasm module once with WebAssembly.compileStreaming, then starts a new VM for every run. A prelude redefines puts, p and gets. The reader's code is evaluated with the file name eval, so errors come back with real line numbers.
  • PHP creates a new PhpWeb for every run and adds <?php if the reader left it off. php-wasm expects a page, so the worker first installs a stub window and document with 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-exceptions and gets a generated bits/stdc++.h that 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_shim 0.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.

FileRawCompressed
Pyodide pyodide.asm.wasm9.6 MB3.4 MB
Pyodide python_stdlib.zip2.5 MB2.5 MB
Pyodide pyodide.asm.mjs1.3 MBnot measured
sql.js sql-wasm.wasm658 KB323 KB
wasmoon glue.wasm272 KBnot measured
ruby.wasm ruby+stdlib.wasm30.6 MB7.9 MB
php-wasm, the PHP 8.4 binary13.8 MB3.0 MB
clang llvm.core.wasm75.5 MB19.4 MB
clang llvm-resources.tar29.7 MB3.6 MB
TypeScript lib files, from the site1.0 MBnot measured
react-sandbox.js, from the site205 KBnot 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

RunLimitWhat happens at the limit
JavaScript, TypeScript5 sWorker terminated and replaced
JavaScript debug run8 sWorker terminated and replaced
Python20 s, including the first loadWorker terminated; Pyodide loads again
Lua, Ruby, PHP, C, C++180 s to start, then 20 sWorker terminated; every loaded engine goes with it
React component3 s per loop, 8 s overallTest fails, or the iframe is removed
SQLnoneRuns 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.

What the sandbox does not stop

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.