Wasm and the playground in detail#

The playground runs the whole compiler in a browser tab, with no server: type Temper, and see diagnostics, the interpreter's run and tests, the generated JavaScript's run beside it, and all seven backends' code, as you type, with hover, go to definition, completion, references, rename, and signature help in the editor. Two parts make it: temper-wasm, the compiler as a wasm module with a small C ABI, and site/playground/, a TypeScript page on CodeMirror 6 with two kinds of web worker. This page is the long companion to 8. Wasm, the playground, watch, and a language server: the decisions behind them, with what they cost, the main types and functions, the tests, and what's weak or planned, including watch.

Contents#

Where it sits#

temper-wasmPage: main.tsgenerated JS and std'slog, tests, donesource, idsdiagnostics, then resultcompiler-worker.tswasm instanceCodeMirror: lint, hover,completion, keysBackend tabs, Run,Errorsanalyze: settle, Analysisfinish: run, tests,backendshover, definition,completions, references,rename, signature_helpKEPT: the analysis andits sourcejs-worker.ts: one per run
temper-wasmPage: main.tsgenerated JS and std'slog, tests, donesource, idsdiagnostics, then resultcompiler-worker.tswasm instanceCodeMirror: lint, hover,completion, keysBackend tabs, Run,Errorsanalyze: settle, Analysisfinish: run, tests,backendshover, definition,completions, references,rename, signature_helpKEPT: the analysis andits sourcejs-worker.ts: one per run
Diagram source
flowchart LR
  subgraph page["Page: main.ts"]
    cm["CodeMirror: lint, hover, completion, keys"]
    tabs["Backend tabs, Run, Errors"]
  end
  subgraph cw["compiler-worker.ts"]
    inst["wasm instance"]
  end
  subgraph wasm["temper-wasm"]
    analyze["analyze: settle, Analysis"]
    finish["finish: run, tests, backends"]
    queries["hover, definition, completions, references, rename, signature_help"]
    kept["KEPT: the analysis and its source"]
  end
  jw["js-worker.ts: one per run"]
  cm -->|"source, ids"| cw
  cw --> analyze --> kept
  kept --> finish
  kept --> queries
  cw -->|"diagnostics, then result"| page
  tabs -->|"generated JS and std's"| jw
  jw -->|"log, tests, done"| tabs

The wasm sits on the same crates as tempest: it settles with the frontend, answers queries with temper-analysis, and compiles through temper-driver's compile path, with the same Registry.

Decisions#

The whole compiler in the browser#

Chosen: compile temper-wasm for wasm32-unknown-unknown and run everything client-side: settling, the interpreter, and all seven backends.

Alternatives: a compile server behind the page; a port of parts to JavaScript.

Pros: nothing to host or secure beyond static files; it works offline once loaded; source never leaves the browser; answers come in milliseconds with no network round trip. It also proves the compiler is portable: no threads, no file system, no process spawning in the core.

Cons: a download of about 4 MB (1.3 MB gzipped), compiled once per visit; generated code can't be run except JavaScript (in the page) and Temper (on the interpreter); std's net can't make requests.

Kotlin: Kotlin Temper's playground, play.temperlang.dev, is a hosted service outside its repository, so there's no code here to compare. A JVM compiler can't run in a tab without a heavy port.

Status: done.

A plain C ABI, not wasm-bindgen#

Chosen: #[unsafe(no_mangle)] extern "C" functions on numbers. JavaScript calls alloc for a buffer, writes UTF-8 into the module's memory, calls a function, and reads the result as UTF-8 from output_ptr and output_len. Input and output live in two thread-locals, INPUT and OUTPUT; with_source accepts only the buffer alloc gave out. The module imports two functions from the page, the clock and the REPL's log stream (below).

Alternatives: wasm-bindgen and its generated glue; the component model.

Pros: no dependency and no generated JavaScript; the module loads with new WebAssembly.Instance(m, imports), two functions in env; the ABI is about twenty functions, easy to read in compiler-worker.ts, and also what compat/measure_size.py loads.

Cons: every result is a string to parse; the page narrows the JSON by hand (api.ts); one output buffer means one call at a time, which a worker gives anyway.

Status: tempest's choice, from the dependency policy (notes/code-style.md).

JSON written by hand#

Chosen: results are JSON strings built with write! and a small json_string, not serde.

Pros: no serde in the wasm, which stays smaller and builds faster.

Cons: a second JSON writer beside temper-tmpl's source maps' (and serde in the language server), and shapes kept in step by hand with api.ts and its narrowing functions. A typo in a key is caught only when the page reads it.

Analyze first, then finish#

Chosen: compile does everything in one call, but the page calls it in two halves: analyze settles the source and returns only {"diagnostics": [...]}, then finish runs the program and its tests (options RUN and TESTS) and translates for each backend in a mask, over what analyze kept. compile_into glues the halves for tests and for anyone wanting one call.

Pros: errors show as soon as settling ends, before the slower run and seven translations.

Cons: the worker still makes both calls in one turn (compile in compiler-worker.ts), so an editor query sent after the diagnostics waits for the backends. And every pause in typing translates all seven, whichever tab is showing.

One kept analysis, queries over it#

Chosen: analyze keeps an Analysis and the source in a thread-local, KEPT (a Kept). The query exports, hover, definition, completions, references, rename, and signature_help, each take a UTF-16 offset, convert it with byte_offset, ask the analysis, and convert spans back (Position, utf16_span). Nothing is settled again for a query.

Pros: answers in microseconds to milliseconds; the playground and the language server share every query (page 9's detail).

Cons: one source, one file: answers for other files (std's) are filtered out, except a definition in std or core, which comes with that file's text so the page can show it. A second copy of the edge conversions lives in the language server.

std's tmpl kept, its imported modules settled again#

Chosen: std as a tmpl program, which backends need for std's names and std_library translates, is settled once and kept for the page's life (std_program, leaked to 'static).

Cons: the source's own imports of std's modules are loaded into its library and settled with it on every compile (load_imports in analyze_into), as in every other tool: the same root as imported libraries settling again in each importer (page 7's detail). The library cache would fix both.

Limits: fuel, a stale timer, a fresh instance#

Chosen: three guards, from the inside out.

Plus a big stack: build.rs links the module with a 16 MiB stack instead of 1 MiB, for the settle loop's and the interpreter's recursion, as the native binaries use a 64 MiB thread; and a memory cap of 1 GiB rather than wasm32's 4 GiB, so a runaway allocation traps, as a panic does, rather than taking a phone's tab down.

Cons: a slow compile with nothing waiting runs to the end; the stale timer can't tell slow from stuck.

Small wasm: no runtimes, tools, or web client#

Chosen: each backend's BACKEND has no runtime files and no tools under wasm (cfg!(target_family = "wasm")), and temper-frontend is built without net, so std's requests fail with "web client not supported here". Backends are cargo features here too.

Pros: the runtimes' sources and the HTTP stack would add megabytes for nothing the page uses; the JavaScript runtime the page needs is bundled by esbuild from vendor/temper/be-js/ instead.

A worker for the compiler, one per JavaScript run#

Chosen: the page compiles the wasm module once (WebAssembly.compileStreaming) and posts it to a module worker, compiler-worker.ts, which instantiates it (instantiate). Generated JavaScript runs in a new worker per run, js-worker.ts, stopped after JS_RUN_MS (5 s) (runJs). Its modules become blob URLs with imports rewritten to the library's, std's (from std_library, sent once), and the bundled runtime's; tests register with an it of its own, as under Mocha. The page shows both runs and whether they agree (compareRuns).

Pros: typing never waits on the compiler; a runaway program can be killed by terminating its worker; the two runs check the interpreter against a backend on every keystroke.

Cons: the JavaScript run uses its own small test runner, not Mocha, so it's a third way tests run besides the interpreter and tempest test -b js.

CodeMirror 6, with a stream language#

Chosen: CodeMirror 6's modules, pinned in package.json: lint (setDiagnostics) for errors, hoverTooltip, autocompletion with the wasm's completions as its only source, two StateFields for reference marks and the signature tooltip, a keymap for F12, Shift+F12, F2, and Escape, and Ctrl or Cmd and click for definitions. Temper is highlighted by a small stream language, temper; the generated code by CodeMirror's legacy modes, in one read-only viewer whose language is swapped per file.

Alternatives: Monaco, VS Code's editor, would bring its LSP-shaped APIs but is several times larger; the notes don't record weighing it. A Lezer grammar, or tempest's own tokens from the wasm, would highlight more exactly than a stream language.

Pros: small and modular; every feature is an extension added in one place in main.ts.

Cons: the stream language is a third list of keywords beside the VS Code grammar and temper-analysis; editors/vscode/check_grammar.py checks all three agree.

Queries wait for the text on screen#

Chosen: before a query, ask waits (settledNow) until the worker's last analysis is of the editor's current text, compiling soon if not, for at most QUERY_WAIT_MS (3 s); it gives up with no answer rather than answering at the wrong positions. A replaced worker answers null to what it had pending (dropQueries).

Pros: positions always match; no stale hover.

Cons: right after a keystroke, a hover waits for a whole compile (debounce, settle, and today the backends too).

Chosen: the source goes into the URL's fragment, #c= and base64url of its UTF-8 compressed with deflate-raw where the browser has CompressionStream, else #s= and the UTF-8 alone (encode, decode). It updates a second after typing (updateHash), with replaceState so history isn't flooded; an example's own source clears it. Share copies the link.

Alternatives: short links stored on a server; the query string.

Pros: no server and nothing stored; the fragment isn't sent to the host, so shared code stays between the people sharing it.

Cons: long URLs for long programs; a link names no compiler version, so an old link compiles with today's tempest, which may differ.

One build script, published by Pages#

Chosen: build.py builds the wasm (small-wasm profile), installs the pinned npm packages with npm ci, type-checks the page and the workers with tsc separately, bundles and minifies with esbuild (examples as text, the JS runtime aliased in, the build's commit and wasm sizes defined in), writes the bundled packages' licenses, and stamps every URL the page loads with ?v= and a hash of the built files, so a normal reload picks up a new build. CI runs it with --skip-wasm after its own wasm build. The Pages workflow builds and publishes it on each push to main that touches the site, the crates, vendored Temper, or the README, at playground/ beside the docs site.

Pros: one script locally and in CI; caches never serve a mix of builds.

The wasm API#

Export Takes Gives
alloc a length a buffer for the next input
analyze source, options {"diagnostics": [...]}, keeping the analysis
finish options, backend mask {"run": ..., "backends": {...}}
compile source, options, mask both halves in one object
hover, definition, completions, references, signature_help a UTF-16 offset the answer, or null
rename an offset, the new name (empty to check) edits, or {"error": ...}
backends the built-in backends' names, in mask order
std_library a backend's place in backends std's files for it, kept
parse, run, generate source (and a backend for generate) the older single-purpose calls
output_ptr, output_len where the last result is

Options are bits: MARKDOWN for literate source, RUN, and TESTS. Positions are UTF-16 code units, with line and column from 1 for diagnostics; diagnostics in std's files carry no position.

The REPL's API#

repl keeps one ReplSession in the instance, for the playground's REPL tab (notes/online-repl.md), with the CLI REPL's transcript text:

Export Takes Gives
repl_start(ptr, len, fuel) source for input 0 (the editor's), steps a new session; input 0's answer, or null if the source has no code
repl_input(ptr, len, fuel) one input, steps its answer, below; starts a session if there's none
repl_complete(ptr, len) -> u32 the input cell's text 1 if it's whole, so Enter runs it; {"complete": true, "inputs": [...], "brackets": 0}
repl_query(kind, at, ptr, len) 0, 1, or 2; a UTF-16 offset; the cell's text completion, {"from": 3, "items": [...]}; hover; or signature help, as the editor's queries give them; null with no session
repl_reset() drops the session

An input's answer lists what it reported, in order, each with the text tempest repl prints, then its value (null if it failed):

{"input": 2,
 "events": [{"kind": "diagnostic", "level": "error", "message": "...",
             "location": "interactive#2", "line": 1, "column": 7, "text": "..."},
            {"kind": "log", "text": "hi"},
            {"kind": "result", "text": "interactive#2: fail"}],
 "value": null, "out_of_steps": false}

Kinds are diagnostic, log, failure, output (the REPL's functions'), note, unresolved, interrupted, internal, truncated, and result. Input 0's tests, if it has any, run after it: a failure for each that fails, then a note with the summary, as Tests passed: 2 of 3. repl_complete splits a typed or pasted buffer into inputs as the CLI splits piped lines (Chunker); the page feeds each to repl_input in turn, and uses brackets for the next line's indent.

repl_query reads the cell's text as if it were the next input, with temper-analysis's pending_* queries, as tempest repl's Tab and hint do: no tree for the text, just its tokens (the brackets open, the name before ( or .), with names looked up from the latest input's scope, through members' declared types. Nothing is settled or run, so it's quick on every keystroke and can't run user code. Completion offers the REPL's own functions too, and in a string argument, describe's steps, translate's languages, or help's names, of kind text. A name the text declares itself isn't hovered, as it isn't declared yet.

runTests() asks the session to run input 0's tests again once the input calling it ends, as nothing can settle while an input runs: input 0's source with all but its tests blanked (lines and byte offsets kept) is settled as a hidden round after the latest input, so the tests' names mean the newest declarations, then run and reported as input 0's were, in the same answer, before its result.

Limits: fuel is the steps one input may take (less than 1 for none); the page gives 50 million, as for a compile, and when an answer says out_of_steps, "keep going" runs the same text again, as the next input, with more. Logs past 64 KiB per input are dropped, with a truncated event where they were cut. Memory is the module's cap. A panic traps, as everywhere in the module, so the page restores a session by replaying its inputs into a new instance, as Stop does.

Imports, in env, which every instance needs (temper-os's host module, the only unsafe code they take):

Two numberings live here: compile's mask and std_library count by the backends built in (backends), but generate counts by KNOWN, all seven. They agree only in a build with every backend, which is the only one the page uses. Names in the JSON would remove the question.

The page's messages#

temper-wasmcompiler-worker.tsEditor (main.ts)temper-wasmcompiler-worker.tsEditor (main.ts)200 ms after typing stopsinit: the compiled modulebackends()ready: backend namescompile: id, sourceanalyzediagnostics: id, msfinish: RUN, TESTS, every backendresult: id, run, backendsstd_library (once, for JS)stdquery: qid, hover, offsethover(offset)answer: qid, value
temper-wasmcompiler-worker.tsEditor (main.ts)temper-wasmcompiler-worker.tsEditor (main.ts)200 ms after typing stopsinit: the compiled modulebackends()ready: backend namescompile: id, sourceanalyzediagnostics: id, msfinish: RUN, TESTS, every backendresult: id, run, backendsstd_library (once, for JS)stdquery: qid, hover, offsethover(offset)answer: qid, value
Diagram source
sequenceDiagram
  participant E as Editor (main.ts)
  participant W as compiler-worker.ts
  participant M as temper-wasm
  E->>W: init: the compiled module
  W->>M: backends()
  W-->>E: ready: backend names
  Note over E: 200 ms after typing stops
  E->>W: compile: id, source
  W->>M: analyze
  W-->>E: diagnostics: id, ms
  W->>M: finish: RUN, TESTS, every backend
  W-->>E: result: id, run, backends
  W->>M: std_library (once, for JS)
  W-->>E: std
  E->>W: query: qid, hover, offset
  W->>M: hover(offset)
  W-->>E: answer: qid, value

The page keeps one request busy and at most one waiting; a newer request replaces the waiting one, and answers for older ids are dropped (onCompiler). Message types are in api.ts, with a narrowing function for each JSON shape (toCompileResult, toDiagnostics, toHover, and the rest).

The REPL tab#

The REPL tab (repl.ts) takes the place of the editor and its tabs, and starts its own worker (repl-worker.ts) the first time it's shown, with the same compiled module and a second instance, which holds the session. The compiler's worker gets replaced as you type, which would throw a session away.

temper-wasmrepl-worker.tsREPL tab (repl.ts)temper-wasmrepl-worker.tsREPL tab (repl.ts)Entertyping in the cellinit: the compiled modulestart: the editor's source, replay (none yet)repl_start, then repl_input for each replayed input, quietlystarted: input 0's answercomplete: the cell's textrepl_completecomplete: whole?, inputs, bracketsinput: id, text, 50M stepsrepl_inputhost_log, a line at a timelog: id, lineanswer: id, eventsquery: id, completions, text, offsetrepl_queryquery: id, value
temper-wasmrepl-worker.tsREPL tab (repl.ts)temper-wasmrepl-worker.tsREPL tab (repl.ts)Entertyping in the cellinit: the compiled modulestart: the editor's source, replay (none yet)repl_start, then repl_input for each replayed input, quietlystarted: input 0's answercomplete: the cell's textrepl_completecomplete: whole?, inputs, bracketsinput: id, text, 50M stepsrepl_inputhost_log, a line at a timelog: id, lineanswer: id, eventsquery: id, completions, text, offsetrepl_queryquery: id, value
Diagram source
sequenceDiagram
  participant P as REPL tab (repl.ts)
  participant R as repl-worker.ts
  participant M as temper-wasm
  P->>R: init: the compiled module
  P->>R: start: the editor's source, replay (none yet)
  R->>M: repl_start, then repl_input for each replayed input, quietly
  R-->>P: started: input 0's answer
  Note over P: Enter
  P->>R: complete: the cell's text
  R->>M: repl_complete
  R-->>P: complete: whole?, inputs, brackets
  P->>R: input: id, text, 50M steps
  R->>M: repl_input
  M->>R: host_log, a line at a time
  R-->>P: log: id, line
  R-->>P: answer: id, events
  Note over P: typing in the cell
  P->>R: query: id, completions, text, offset
  R->>M: repl_query
  R-->>P: query: id, value

Main types and functions#

Rust, in temper-wasm:

TypeScript, in site/playground/src/:

Sizes#

compat/measure_size.py records the wasm's size in notes/size-and-speed.csv as wasm-small and wasm-small-gzip: 4,214,709 bytes and 1,363,094 gzipped at 75e0d51 (2026-11-06), up from 434 KiB on 2026-10-22, when it only settled and ran. The backends are most of it: on 2026-11-03, the seven came to about 1.8 MiB and what only they use (much of tmpl) to about 0.5 MiB, against 1.25 MiB for the wasm without them (backend-NAME-wasm, backends-shared-wasm, wasm-small-no-backends). The page's About, in the site's header, gives the wasm's size.

Watch (planned)#

watch doesn't exist yet. The design (notes/design.md) leans to the simplest thing: if building a library takes tens of milliseconds, watch is a loop that reruns build or test on each change, printing what Analysis::diagnostics gives, and keeps nothing in memory between runs.

a file changeswait for a pausebuild or test, as thecommands dodiagnostics and results
a file changeswait for a pausebuild or test, as thecommands dodiagnostics and results
Diagram source
flowchart LR
  change["a file changes"] --> debounce["wait for a pause"]
  debounce --> run["build or test, as the commands do"]
  run --> print["diagnostics and results"]
  print --> change

Why it might be enough: each build allocates its own arenas and drops them, so memory stays flat by construction; Kotlin's watch mode kept earlier builds alive, which design.md names as a reason not to keep anything. What it needs: a way to notice changes (a file-watching crate, which the dependency policy names as a likely candidate, or polling), and test orchestration callable from the driver rather than spread over three crates (page 7's detail). If it isn't fast enough: rebuild only the changed library and those that import it, keeping unchanged libraries' exported summaries, which is the library cache again.

Kotlin: temper watch keeps a BuildHarness and a Watcher alive, rebuilding on file events and optionally testing with --test-backend, with --limit on builds.

Tests#

Not covered: worker replacement, query waiting, and the other editor features (completion, rename, go to definition) are tested in the browser only by hand, though the wasm tests cover their answers; so are the REPL's history, Restart, the touch keys, and a trap's restore.

Known weaknesses and planned changes#