Compiling a sketch in the browser
A board's files are compiled by a real clang and a real lld, running as
WebAssembly inside the page. There is no build server, nothing is uploaded, and
the ELF that comes out is the same kind of image firmware/build.sh produces:
it is the same compiler driven by the same command list.
The proof that this works, with the numbers that justified building it, is
proofs/compile/REPORT.md. This document is how the product does it.
The shape of it
web/src/compile/
bundles.ts which toolchain and which runtime each board family needs
cache.ts the 74 MB download, and the Cache API entry that avoids it
client.ts the page's half: one worker, and the rule for "does this need building?"
diagnostics.ts clang's stderr -> { file, line, col, severity, message }
hash.ts a cheap content hash for the two cache keys
protocol.ts the messages between the page and the worker
toolchain.ts the command list, and one wasm instantiation per tool run
wasi.ts a WASI preview 1 host with an in-memory filesystem
worker.ts the Web Worker that runs all of the above
web/src/ui/build-panel.ts the steps, the diagnostics and the one success line
web/scripts/bundle-runtime.mjs firmware/ -> web/public/firmware/runtime/<family>.json
Everything except the build panel runs in the worker, so a four second first build never blocks a frame of the canvas.
What has to reach the browser
Two things, from two places.
The toolchain is one clang.wasm (clang, ld.lld and llvm-objcopy in a
single LLVM driver that dispatches on argv[0]) plus a bundle.json whose
/usr/include/... entries are clang's own resource headers, pruned to the ten a
freestanding build opens. Both come from the Cloudflare R2 bucket
mokxi-toolchain, served by the Worker at /toolchain/<version>/<file>. In
development deploy/fetch-toolchain.sh puts the same files under
web/public/toolchain/<version>/ (gitignored) and Vite serves the same paths,
so the client code is identical either way.
The runtime is our own firmware: the sources under firmware/src, the
headers, crt0.S and the linker script, together with the exact flags
firmware/build.sh compiles them with. It also carries firmware/lib, the
shared header-only drivers (mokxi_i2c.h, mokxi_ssd1306.h, mokxi_max7219.h,
and the rest): one copy, mounted at /firmware/lib for every family with
-I/firmware/lib on the command line, exactly where firmware/build.sh and
firmware/uno/build.sh already point their own -I at the same directory. A
sketch that includes one of these headers (every OLED, SPI display, WS2812,
tone or joystick example) compiles on any board because of this, not despite
it. web/scripts/bundle-runtime.mjs packs all of it into
web/public/firmware/runtime/<family>.json, which is committed. Regenerate it
whenever the firmware (including firmware/lib) changes:
node web/scripts/bundle-runtime.mjs # write the files
node web/scripts/bundle-runtime.mjs --check # fail if they are out of date
src/compile/bundles.test.ts runs the --check form, so a change to
firmware/ that nobody carried over fails the test suite rather than a user's
build.
Every family's firmware is in the tree now, so the script writes every
bundle.
It still holds the rule it was written with: a family whose firmware is not
there (the script looks for its link.ld) is skipped with a note, not an
error, so neither a developer's build nor CI fails while the next family is
being written. esp32c3 and uno name their sources explicitly, in the order
build.sh links them, and so does attiny, whose sources are the Uno's list
on a different -mmcu. pico, stm32, bluepill and microbit use
discover, which
reads the lists off the directory: every .S at the family root with crt0
first, then src/*.c, then src/*.cpp with main.cpp last, everything else
alphabetical, which is the order their build.sh links in, so nothing has had
to be pinned. Pin explicit lists in the script if a link order ever has to be
something else.
The ESP32, ESP32-S3 and ESP8266 bundles are written too. Their flags are the
clang translation of their GCC build.sh lists (see "Proven natively" below),
and they are what clang-v3 compiles in the browser.
The toolchain version is pinned per board family
The version is a property of the family, not of the app, because the two builds
are not one compiler: clang-rv32-v1 was configured with
LLVM_TARGETS_TO_BUILD=RISCV alone, so it can compile for the ESP32-C3 and it
physically cannot compile for the Uno. clang-v2 is that same build script with
LLVM_TARGETS_TO_BUILD=RISCV;AVR;ARM (same layout, same bucket, same URL
shape), and every family that compiles today is pinned to it (DECISIONS.md,
2026-09-14: verified on all three backends and uploaded to R2). Its RISC-V
output is byte-identical to clang-rv32-v1's, so the ESP32-C3 moved onto it
too and every such board shares one download. The Xtensa families are pinned
to clang-v3 (XTENSA_TOOLCHAIN), published 2026-09-28:
| family | catalog program |
target | toolchain | ready |
|---|---|---|---|---|
esp32c3 |
elf32 riscv32imc |
riscv32-unknown-elf -march=rv32imc -mabi=ilp32 |
clang-v2 |
yes |
c6 |
elf32 riscv32imc esp32c6 |
riscv32-unknown-elf -march=rv32imc -mabi=ilp32 |
clang-v2 |
yes |
uno |
elf32 avr atmega328p |
avr -mmcu=atmega328p |
clang-v2 |
yes |
nano |
elf32 avr atmega328p nano |
avr -mmcu=atmega328p |
clang-v2 |
yes |
mega2560 |
elf32 avr atmega2560 |
avr -mmcu=atmega2560 |
clang-v2 |
yes |
leonardo |
elf32 avr atmega32u4 |
avr -mmcu=atmega32u4 |
clang-v2 |
yes |
attiny |
elf32 avr attiny85 |
avr -mmcu=attiny85 |
clang-v2 |
yes |
pico |
elf32 arm rp2040 |
thumbv6m-none-eabi -mcpu=cortex-m0plus -mthumb |
clang-v2 |
yes |
xiao |
elf32 arm samd21 |
thumbv6m-none-eabi -mcpu=cortex-m0plus -mthumb |
clang-v2 |
yes |
stm32 |
elf32 arm stm32f411 |
thumbv7em-none-eabi -mcpu=cortex-m4 -mthumb -mfloat-abi=soft -mfpu=none |
clang-v2 |
yes |
bluepill |
elf32 arm stm32f103 |
thumbv7m-none-eabi -mcpu=cortex-m3 -mthumb -mfloat-abi=soft |
clang-v2 |
yes |
microbit |
elf32 arm nrf52833 |
thumbv7em-none-eabi -mcpu=cortex-m4 -mthumb -mfloat-abi=soft -mfpu=none |
clang-v2 |
yes |
unor4 |
elf32 arm ra4m1 |
thumbv7em-none-eabi -mcpu=cortex-m4 -mthumb -mfloat-abi=soft -mfpu=none |
clang-v2 |
yes |
esp32 |
elf32 xtensa lx6 esp32 |
xtensa-esp-elf -mcpu=esp32 -fintegrated-as |
clang-v3 |
yes |
esp32s3 |
elf32 xtensa lx7 esp32s3 |
xtensa-esp-elf -mcpu=esp32s3 -fintegrated-as |
clang-v3 |
yes |
esp8266 |
elf32 xtensa lx106 esp8266 |
xtensa-esp-elf -mcpu=esp8266 -fintegrated-as |
clang-v3 |
yes |
The last three rows are the next section. web/src/compile/bundles.ts is the
list this table is read from, and bundles.test.ts keeps them in step.
The Xtensa boards, on clang-v3
The classic ESP32 is an Xtensa LX6, the ESP8266 NodeMCU an Xtensa LX106 and the
ESP32-S3 an Xtensa LX7. All three compile in the browser on clang-v3 since
2026-09-28 ("Published and checked" below). Until then they ran prebuilt ELFs
and said so. The rest of this section is why it took a fork, and what the
proof found.
Why it is a separate toolchain (checked 2026-09-27):
clang-v2has no Xtensa back end. It was built withLLVM_TARGETS_TO_BUILD=RISCV;AVR;ARM(DECISIONS.md, 2026-09-14). The wasm the browser downloads physically cannot emit an Xtensa object.- Upstream LLVM has half of what is needed, and the wrong half. Clang has
carried an Xtensa front end since LLVM 20
(Phoronix), and
LLVM 22's
XtensaProcessors.tdknowsesp32andesp8266(upstreammainaddsesp32s2andesp32s3). But upstreamld.lldhas no Xtensa target at all: there is nolld/ELF/Arch/Xtensa.cpponmainor onrelease/22.x, so nothing upstream can link an ESP32 image. The upstream backend also still emits literal pools inline in.text, which breaksl32ralignment under-ffunction-sections(llvm/llvm-project#190204, open). Xtensa is still inLLVM_ALL_EXPERIMENTAL_TARGETSupstream. - Espressif's fork has all of it.
esp-clang(espressif/llvm-project, latestesp-22.1.4_20260825, LLVM 22.1.4, Apache-2.0 with LLVM exception) carries the Xtensa front end with-mcpu=esp32,esp8266,esp32s2andesp32s3, and anlld/ELF/Arch/Xtensa.cppthat handlesR_XTENSA_32,R_XTENSA_SLOT0_OP(L32R,CALL8, branches) and theDIFF/ASM_EXPANDrelocations. Xtensa is experimental there too, so it has to go inLLVM_EXPERIMENTAL_TARGETS_TO_BUILD;build-wasm-clang.shnow splits that out of the target list by itself. The fork does not carry the WASI portability commit, soproofs/compile/patches/llvm-wasi-portability.patch(the same commit clang-v2 is built with, from YoWASP) is applied on top. It applies cleanly toesp-22.1.4_20260825(one hunk at an offset of one line). That commit's WASIclose()returns a staleerrnoand fails clean writes ("IO failure on output stream: No such file or directory"), sobuild-wasm-clang.shalways appliesproofs/compile/patches/llvm-wasi-close-errno.patchafter it, on either tree. The clang-v2 served today predates that fix;ERRNO_RESET_HEADERandruntimeFilesinweb/src/compile/toolchain.tswork around it until it is rebuilt (docs/clang-v2-rebuild.mdhas the plan).
So this is a fork to track rather than a flag to add, but the fork is
Espressif's own, released every few months, and under the same license as the
LLVM clang-v2 comes from.
The prebuilt ELFs are still built by Espressif's GCC, which
deploy/fetch-xtensa.sh puts in tools/xtensa/: xtensa-esp32-elf-gcc 8.4.0
for the LX6, xtensa-esp32s3-elf-gcc 8.4.0 for the LX7 and
xtensa-lx106-elf-gcc 8.4.0 for the LX106. The S3's runtime reuses the
classic ESP32's crt0.S and support64.c: for code a compiler emits from C
the LX7 is the LX6, and the S3 compiler's output for the same test program is
byte for byte the ESP32 compiler's. That directory is gitignored. GCC is GPL, and nothing it builds here links against libgcc
(the runtimes are -nostdlib with their own support.c, and the ESP8266's
support32.c supplies the 32-bit divides the LX106 has no instruction for), so
no GPL code reaches a shipped ELF.
Proven natively
Before any wasm exists, the compiler side is proven with the native Linux
build of the same fork (clang-esp-22.1.4_20260825-x86_64-linux-gnu.tar.xz,
411 MB, sha256 4700ed4e...eb0a1, unpacks to 2.7 GB with ld.lld in it):
proofs/compile/xtensa/run-native.sh /some/scratch/dir
It fetches that release, then runs proofs/compile/xtensa/plan.ts --native,
which does not transcribe any commands: it takes runtimeSteps and
projectPlan from web/src/compile/toolchain.ts and the committed runtime
bundles web/public/firmware/runtime/esp32.json, esp32s3.json and
esp8266.json, so what
runs is the browser's command list, crt0.S split into its preprocess and
assemble halves, ld.lld called directly, llvm-objcopy to stdout. Result on
2026-09-27:
- every example of every board compiles and links with
-Wall -Wextra -Werror: nine for the ESP32 (19 steps each), eleven for the ESP32-S3 (21 steps,rainbowandusbserialincluded) and nine for the ESP8266 (20 steps), about 1 s each natively; ld.lldresolves every relocation (in the ESP32 objects: 3,407R_XTENSA_32and 430R_XTENSA_SLOT0_OP);MOKXI_ESP32_ELF_DIR=... cargo test -p esp32 --test runtime --test wifi: 11 + 4 passed;MOKXI_ESP32S3_ELF_DIR=... cargo test -p esp32s3 --test runtime --test wifi: 13 + 4 passed;MOKXI_ESP8266_ELF_DIR=... cargo test -p esp8266 --test runtime --test wifi: 11 + 4 passed. These are the same tests the GCC ELFs pass: blink's 500 ms half period, LEDC and software PWM at 1 kHz,millis()against the kernel clock, UART0 bit spacing, the ADC, the S3's WS2812 frames on its RGB LED (white, then the rainbow green first on the wire) and its USB serial packets, WiFi connect and the lamp's web page. The three environment variables only point the existing tests at another directory; a corrupt file there makes them panic, so they really loaded what was built.
Three things the proof found, all handled in the bundles:
-fintegrated-asis required. For Xtensa the Espressif driver hands a.sfile to an external GNU assembler (xtensa-esp32-elf-clang-as) by default, which WASI cannot spawn. With-fintegrated-asclang's own assembler takes crt0.S as written. It is in the family's target flags, so the assemble half gets it too. Espressif turns it off for assembly input on purpose (EspBareMetal.cpp: "TODO: Add full support for Xtensa to integrated asm, LLVM-290, LLVM-291"), so it is not complete for every hand-written file; it is for both of ourcrt0.Sfiles, which is what the simulator tests show, and a project's own files are never assembly (only.c,.cppand.inoare compiled). C and C++ always go through the integrated assembler, in this fork as upstream.- No multilib warning in the wasm. The native install warns that it has no
esp8266 multilib (an error under
-Werror) because itsmultilib.yamlhas no such entry.BareMetal::findMultilibsonly reads amultilib.yamlthat exists, and the wasm filesystem has none, so only the native run needs-Wno-missing-multilib, and onlyplan.ts --nativepasses it. - GCC's
-mlongcallsand-mtext-section-literalsgo. Clang reports-mlongcallsas an unused argument, an error under-Werror. Without-mtext-section-literalsclang emits.literal.<function>sections, which all threelink.ldfiles already put at the start of.text, well insideL32R's 256 KB reach.
Code size, the executable segment of every example against the shipped GCC
image (same sources, both -Os):
| board | GCC, all examples | clang | per example |
|---|---|---|---|
| ESP32 | 29,984 bytes | 37,760 bytes | +15% (fade) to +37% (wifilamp), +26% overall |
| ESP32-S3 | 41,584 bytes | 53,920 bytes | +18% (fade) to +38% (wifilamp), +30% overall |
| ESP8266 | 34,592 bytes | 49,104 bytes | +33% (button) to +49% (wifilamp), +42% overall |
Data is within a few dozen bytes either way. The largest clang image is the
ESP8266's wifilamp at 8,944 bytes of code, against a 32 KB IRAM and the
24 KB limit its elfcheck.py holds examples to, so the growth costs nothing a
sketch here would notice. Where the bytes go has not been broken down.
Published and checked
- Built by
.github/workflows/clang-v3.yml, run 36358476287 (attempt 3,mainat4578344f):espressif/llvm-projectatesp-22.1.4_20260825withRISCV;AVR;ARM;Xtensa, both WASI patches, thenplan.ts --wasmfor all three boards and the simulator's tests on what the wasm built. Artifactclang-v3, sha256 of the zipef27b7aa…3eafd;clang.wasmis 80,735,841 bytes, sha2567546c54c07c392e231ea450f8e3aaa6680346ffeb08e13d97d970b26d88666e5. - Published 2026-09-28: the artifact unzipped into
web/public/toolchain/clang-v3/, thendeploy/fetch-toolchain.sh --publish clang-v3.https://mokxi.com/toolchain/clang-v3/servesclang.wasm,bundle.jsonandinclude.tgzwith the right types, the right lengths and the same sha256 as the artifact. Thebundle.jsonis unpruned (16 MB, every clang resource header); only its/usr/includeentries are used, and pruning it the way clang-v2's was would save most of that download. - Checked in Chromium (headless, against
npm run devwith the published files inweb/public/toolchain/clang-v3/): the real editor opened a diagram with its own sketch, pressed Run, and watched the build panel, the serial monitor and the LED. The ESP32 sketch joined the simulated WiFi (192.168.4.2) and blinked GPIO 4; the ESP32-S3 sketch blinked GPIO 2 and printed; the ESP8266 sketch blinked D1 and printed a 32-bit divide each loop; the ESP8266's own Blink example, edited, built and blinked too. The first build of a board took 5 to 7 s including the runtime, a later one about 1.5 s. The ELF the page built for the ESP8266's Blink is byte for byte the workflow'sproof/esp8266/blink.elf. - The ESP8266 check found an engine bug, fixed in the same change. The
clang Blink writes its pins in the same slice it parks in, so the pin's net
moves inside time the core has already run, and the board part nudged the
parked core before the end of that slice.
CCOUNTwent backwards, the runtime's 64-bit cycle count read that as a wrap of 2^32 cycles, and everydelay()after the first returned at once: the LED lit and stayed lit. GCC's Blink never writes a pin in the slice it parks in, which is why no shipped example showed it, and the native tests never showed it because they run the SoC without a circuit. The ESP32, ESP32-S3 and ESP8266 board parts now never run the core earlier than where its last slice ended (ran_toincrates/parts/src/parts/esp8266.rsand its two siblings), andblink_keeps_its_half_period_when_a_pin_moves_as_the_core_parksholds it (run it withMOKXI_ESP8266_ELF_DIRpointing at the clang ELFs). - Floating point faults, as documented. A
floatmultiply on the ESP32 or ESP32-S3 compiles to an FPU instruction, and this model has no FPU, so the board stops with "a floating-point instruction: this board is soft float". That is the runtime's stated rule (arduino.h), the same for a GCC build; the board pages and help articles now say it where they say the board compiles.
Next steps
- Decide whether every family moves to clang-v3. Until they do, a
user who switches between an ESP32 and any other board swaps two 75 MB-plus
toolchains in the Cache API, because
isStaleKey(cache.ts) drops every version but the one in use. Moving them all is one string per family, as the move to clang-v2 was, but it is LLVM 22.1.4 from a fork rather than 22.1.0, so runcrash.test.tsand the stock-library tests against it first, and do not assume byte-identical output. - Check the stock libraries on the Xtensa families.
firmware/libis on their include path as on every board, butstock-libraries.test.tsdoes not list them, soServo.h,Wire.hand the rest are untested there. - Prune
bundle.jsonto the headers a freestanding build opens.
Never claim an in-browser compile that does not work: a build button that silently ran yesterday's binary would be the worst thing in this product.
clang-rv32-v1 is still the value of RV32_TOOLCHAIN and no family names it.
It stays as the way back: bumping a family's toolchain string is the whole of
rolling a build out or back, since the URLs, the module cache key and the
runtime object cache all hang off it.
ready is not dead code either. Two things have to be true before a family can
carry it, and bundles.test.ts checks the second of them:
- The toolchain has the back end, is published, and a sketch it built has run on the simulator.
- The family has a runtime bundle, which means its firmware directory
exists and
bundle-runtime.mjshas been run.
A family that is not ready is not an error: chooseProgram sends its boards
to the prebuilt ELF from web/public/firmware/, the build panel and the line
beside the board picker say why, and nothing fails. No family takes that path
today; the Xtensa families did until clang-v3 was published.
The STM32 and the micro:bit are soft float on purpose: DECISIONS.md, 2026-09-14, puts the FPU and the M4 DSP out of scope until an M4F board is wanted, so neither runtime may emit VFP instructions or a hard-float ABI.
What happens when you press Run
App.buildPrograms asks chooseProgram (src/compile/client.ts) about each
board, and gets one of three answers:
elf: run the image the Firmware panel names and compile nothing. An uploaded ELF was not built from these files; an untouched built-in example is already built. A family whose toolchain is unpublished lands here too.reuse: run the ELF already built from exactly these files. The decision is a hash of every file name and every byte in it, so a keystroke and an undo leave you back onreuse.build: compile, and run the result.
A build that fails stops the run: the button stays on Run, the previous ELF is dropped rather than quietly used, and the diagnostics stay in the panel. The built ELF lives in memory for the session only. It is a fact about this session, not part of the document, so it is never saved, exported or shared.
The command list
The same fourteen commands firmware/build.sh runs, with two differences that
WASI forces (both worked out in the proof):
-c crt0.Smakes clang want two jobs, a preprocess and an assemble, and clang turns its integrated cc1 off as soon as there is more than one, which on WASI is "unable to execute command: WASI does not support spawning subprocesses". The two halves are listed separately.- The link goes through
ld.llddirectly rather than the clang driver, so the ESP32-C3 link flags lose their-Wl,prefixes.firmware/uno/build.shalready callsld.lldfor the same reason.
The filesystem the tools see:
| path | what |
|---|---|
/usr/include |
clang's resource headers, from the toolchain bundle |
/firmware for the ESP32-C3, /firmware/<family> for the rest |
our runtime sources, headers, crt0.S, link.ld |
/firmware/lib |
the shared header-only drivers, same path for every family |
/project |
the board's files, under their own names, so #include "pins.h" works |
/build |
objects, the wrapped sketch, the ELF |
A tool that traps
clang and lld both run down an abort path on some fatal errors (a missing
#include, mainly) that prints the diagnostic to stderr and then hits
unreachable instead of calling exit. Inside WASI that surfaces as a real
WebAssembly.RuntimeError, not a proc_exit, and it would otherwise escape as
an uncaught rejection with no diagnostic attached to it: a crash, not a build
error.
Toolchain.run (src/compile/toolchain.ts) catches exactly that: a trap after
the tool has already written something to stderr comes back as an ordinary
failed step, result.trapped set and result.code non-zero, with everything
written before the trap still in result.err. runSteps
(src/compile/worker.ts) then treats it like any other non-zero exit:
whatever parseDiagnostics found is what the build panel shows, clickable
like every other diagnostic, and only falls back to "the compiler crashed
(<tool> on <step>)" when the trap carried nothing to say. Each tool run
already gets its own fresh WebAssembly.Instance from the one compiled
Module (toolchainFor compiles the module once; Toolchain.run instantiates
it every call), so a trap in one run has nothing to leave behind for the next
one. There is no instance to poison and nothing here re-instantiates the
module itself.
The .ino tabs are joined and wrapped the way the Arduino IDE does it
(src/compile/prototypes.ts): #include "arduino.h", then the main tab and the
other .ino tabs alphabetically, each behind #line 1 "<tab>.ino", with a
prototype for every function the tabs define written just ahead of the first
function, each behind a #line pointing at its definition, and a #line after
the block. So a sketch that calls a function defined below loop() builds, and
the compiler still reports a problem at sketch.ino:12:3 so the build panel can
jump the editor there. Templates, class members, anything in a namespace, a
function the sketch declares itself and a function whose signature names
something declared further down get no prototype; a function inside #if
blocks gets its prototype inside a copy of the same conditions; default
arguments move into the prototype and are blanked, column for column, out of
the definition. .c compiles as C11, .cpp/.cc/.ino as C++17; anything
else is written into /project for the includes and not compiled.
The user's files are compiled with the runtime's warning flags minus -Werror
(userWarnings in bundles.ts): -Wall -Wextra warnings are reported in the
build panel, and a cached object keeps its warnings so they are shown again on
the next Run, but a warning does not stop the build, as in the Arduino IDE. The
runtime itself, and projectPlan(..., { strict: true }) (the Xtensa proof, the
stock-library and communication-module tests), keep -Werror, which is what
holds Mokxi's own code and examples warning-clean.
What is cached, and where
| what | where | key | lost when |
|---|---|---|---|
clang.wasm bytes |
Cache API, mokxi-toolchain-v1 |
/toolchain/<version>/clang.wasm |
the version changes, storage is cleared |
| the runtime's object files | Cache API, same store | /toolchain/<version>/runtime/<family>-<bundle hash>.objects |
the version changes, the firmware changes, storage is cleared |
the compiled WebAssembly.Module |
the worker, in memory | toolchain version | the page closes |
| the runtime's object files | the worker, in memory | toolchain version + family | the page closes |
| each project source's object file | the worker, in memory | source text + the exact command | the page closes |
| the built ELF | the app, in memory | board id + file signature | the page closes |
The runtime objects are a dozen files of a few kilobytes each, packed into one
cache entry rather than a dozen (MOKO, a header length, a JSON index of
[path, length] pairs, then the bytes end to end), so a hit is one match and
one arrayBuffer instead of twelve of each. The key carries a hash of the
runtime bundle exactly as it was fetched, every source and header and flag in
it, so editing firmware/src/gpio.c and regenerating produces a different key
rather than a stale object file. Anything that cannot be unpacked is deleted and
rebuilt, because a blob we cannot read is worse than no blob.
What is no longer reachable goes on the way past: every entry of an older toolchain, and a runtime for a family whose sources have moved on. A family this page says nothing about is left alone: a user with an Uno runtime cached who spends today on an ESP32-C3 keeps it.
Every part of this degrades: no Cache API, a cache that throws, a put that
hits a quota: each means "do the work again", never "no compiler".
Measured
In Chromium 141 on the build container, against npm run dev on loopback
(which serves clang.wasm uncompressed, so the download is the pessimistic
case; production serves it from R2 with Content-Encoding: br):
| click to running | what it did | |
|---|---|---|
| first build, cold | 6.1 to 7.8 s | 74 MB download, WebAssembly.compile, 12 runtime objects, sketch, link, strip |
| second visit and after | 1.5 to 1.8 s | no clang.wasm request; runtime out of the Cache API in 3 to 9 ms |
| edit and Run again, same session | 0.21 s | sketch.ino 51 ms, link 10 ms, strip 5 ms |
| Run with no edit | no compile at all | |
| the ELF | 1.5 KB | blinks GPIO 8 at the rate the sketch asks for, on the real engine at 1.00x |
Four visits in one browser profile made one request for clang.wasm, and
the second and every later one skipped the runtime entirely:
visit 1: 7758 ms Building… -> compiling the esp32c3 runtime… -> compiling your files…
crt0.S 124+54 ms | gpio.c 826 | time.c 454 | uart.c 195 | interrupt.c 148
| support.c 429 | format.c 483 | math.c 80 | print.cpp 498 | main.cpp 58
| sketch.ino 65 | link 39 | strip 11
visit 2: 1754 ms loading the compiler… -> compiling your files…
esp32c3 runtime (cached) 9 ms | sketch.ino 676 | link 35 | strip 11
visit 3: 1464 ms esp32c3 runtime (cached) 3 ms | sketch.ino 550 | link 43 | strip 11
visit 4: 1540 ms esp32c3 runtime (cached) 6 ms | sketch.ino 514 | link 38 | strip 14
What is left of a return visit is two things, neither of them the runtime: about
0.7 s to read 74 MB back out of the Cache API and compileStreaming it, and
about 0.5 s for the first tool run in a freshly compiled module, which is the
engine warming up; the second edit in the same session is the 51 ms above.
The build panel shows the download percentage, every step with its timing, and
one line at the end: Built esp1.elf, 1.5 KB, 0.21 s.
What the Worker has to serve
/toolchain/<version>/<file> must come from the R2 bucket mokxi-toolchain
(objects <version>/<file>), with a long Cache-Control (the path is
versioned, so the objects are immutable) and Content-Encoding: br for the
wasm. The response needs Content-Type: application/wasm and a
Content-Length: the panel's progress bar reads the length, and
WebAssembly.compileStreaming refuses anything that is not application/wasm.
Limits
- Only C and C++. No MicroPython, no other language.
- A project is flat: file names carry no directories, so the include path is one directory deep.
- A board whose catalog
programstring names no family has no family at all, sofamilyForfinds nothing and it runs its prebuilt ELF. A board whose family is notreadydoes the same, with the family'snoteshown in the panel. - The Xtensa boards have no floating-point arithmetic: the ESP32 and ESP32-S3 fault on an FPU instruction, and the ESP8266 has no soft-float library to link.
- A return visit still pays about 0.7 s to get 74 MB back out of the Cache API
and compile it.
compileStreamingfrom a cachedResponseis what lets the engine reuse its own code cache, but nothing here can make that a certainty. - In development the toolchain is served by Vite out of
web/public/toolchain/; in production the Worker serves it from R2.worker/assets.tsanswers 404 for/toolchain/on purpose, so the dev middleware inweb/vite.config.tshas to leave that prefix alone. It does, and breaking that is how the compiler ends up downloading the 404 page.