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):

  1. clang-v2 has no Xtensa back end. It was built with LLVM_TARGETS_TO_BUILD=RISCV;AVR;ARM (DECISIONS.md, 2026-09-14). The wasm the browser downloads physically cannot emit an Xtensa object.
  2. 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.td knows esp32 and esp8266 (upstream main adds esp32s2 and esp32s3). But upstream ld.lld has no Xtensa target at all: there is no lld/ELF/Arch/Xtensa.cpp on main or on release/22.x, so nothing upstream can link an ESP32 image. The upstream backend also still emits literal pools inline in .text, which breaks l32r alignment under -ffunction-sections (llvm/llvm-project#190204, open). Xtensa is still in LLVM_ALL_EXPERIMENTAL_TARGETS upstream.
  3. Espressif's fork has all of it. esp-clang (espressif/llvm-project, latest esp-22.1.4_20260825, LLVM 22.1.4, Apache-2.0 with LLVM exception) carries the Xtensa front end with -mcpu=esp32, esp8266, esp32s2 and esp32s3, and an lld/ELF/Arch/Xtensa.cpp that handles R_XTENSA_32, R_XTENSA_SLOT0_OP (L32R, CALL8, branches) and the DIFF/ASM_EXPAND relocations. Xtensa is experimental there too, so it has to go in LLVM_EXPERIMENTAL_TARGETS_TO_BUILD; build-wasm-clang.sh now splits that out of the target list by itself. The fork does not carry the WASI portability commit, so proofs/compile/patches/llvm-wasi-portability.patch (the same commit clang-v2 is built with, from YoWASP) is applied on top. It applies cleanly to esp-22.1.4_20260825 (one hunk at an offset of one line). That commit's WASI close() returns a stale errno and fails clean writes ("IO failure on output stream: No such file or directory"), so build-wasm-clang.sh always applies proofs/compile/patches/llvm-wasi-close-errno.patch after it, on either tree. The clang-v2 served today predates that fix; ERRNO_RESET_HEADER and runtimeFiles in web/src/compile/toolchain.ts work around it until it is rebuilt (docs/clang-v2-rebuild.md has 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, rainbow and usbserial included) and nine for the ESP8266 (20 steps), about 1 s each natively;
  • ld.lld resolves every relocation (in the ESP32 objects: 3,407 R_XTENSA_32 and 430 R_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-as is required. For Xtensa the Espressif driver hands a .s file to an external GNU assembler (xtensa-esp32-elf-clang-as) by default, which WASI cannot spawn. With -fintegrated-as clang'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 our crt0.S files, which is what the simulator tests show, and a project's own files are never assembly (only .c, .cpp and .ino are 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 its multilib.yaml has no such entry. BareMetal::findMultilibs only reads a multilib.yaml that exists, and the wasm filesystem has none, so only the native run needs -Wno-missing-multilib, and only plan.ts --native passes it.
  • GCC's -mlongcalls and -mtext-section-literals go. Clang reports -mlongcalls as an unused argument, an error under -Werror. Without -mtext-section-literals clang emits .literal.<function> sections, which all three link.ld files already put at the start of .text, well inside L32R'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

  1. Built by .github/workflows/clang-v3.yml, run 36358476287 (attempt 3, main at 4578344f): espressif/llvm-project at esp-22.1.4_20260825 with RISCV;AVR;ARM;Xtensa, both WASI patches, then plan.ts --wasm for all three boards and the simulator's tests on what the wasm built. Artifact clang-v3, sha256 of the zip ef27b7aa…3eafd; clang.wasm is 80,735,841 bytes, sha256 7546c54c07c392e231ea450f8e3aaa6680346ffeb08e13d97d970b26d88666e5.
  2. Published 2026-09-28: the artifact unzipped into web/public/toolchain/clang-v3/, then deploy/fetch-toolchain.sh --publish clang-v3. https://mokxi.com/toolchain/clang-v3/ serves clang.wasm, bundle.json and include.tgz with the right types, the right lengths and the same sha256 as the artifact. The bundle.json is unpruned (16 MB, every clang resource header); only its /usr/include entries are used, and pruning it the way clang-v2's was would save most of that download.
  3. Checked in Chromium (headless, against npm run dev with the published files in web/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's proof/esp8266/blink.elf.
  4. 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. CCOUNT went backwards, the runtime's 64-bit cycle count read that as a wrap of 2^32 cycles, and every delay() 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_to in crates/parts/src/parts/esp8266.rs and its two siblings), and blink_keeps_its_half_period_when_a_pin_moves_as_the_core_parks holds it (run it with MOKXI_ESP8266_ELF_DIR pointing at the clang ELFs).
  5. Floating point faults, as documented. A float multiply 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

  1. 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 run crash.test.ts and the stock-library tests against it first, and do not assume byte-identical output.
  2. Check the stock libraries on the Xtensa families. firmware/lib is on their include path as on every board, but stock-libraries.test.ts does not list them, so Servo.h, Wire.h and the rest are untested there.
  3. Prune bundle.json to 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:

  1. The toolchain has the back end, is published, and a sketch it built has run on the simulator.
  2. The family has a runtime bundle, which means its firmware directory exists and bundle-runtime.mjs has 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 on reuse.
  • 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):

  1. -c crt0.S makes 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.
  2. The link goes through ld.lld directly rather than the clang driver, so the ESP32-C3 link flags lose their -Wl, prefixes. firmware/uno/build.sh already calls ld.lld for 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 program string names no family has no family at all, so familyFor finds nothing and it runs its prebuilt ELF. A board whose family is not ready does the same, with the family's note shown 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. compileStreaming from a cached Response is 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.ts answers 404 for /toolchain/ on purpose, so the dev middleware in web/vite.config.ts has to leave that prefix alone. It does, and breaking that is how the compiler ends up downloading the 404 page.

Questions

Where does the build happen?

On your machine, in the tab. There is no build server, no queue and no build minutes.

How big is the toolchain?

About seventy megabytes. It streams down once and the browser caches it, so after the first build everything is local.