Skip to main content

The JavaScript engine

Kog runs your compiled app on MQuickJS (Fabrice Bellard's MicroQuickJS, vendored and forked in native/vendor/mquickjs) — a single embedded JavaScript engine tuned for the ESP32. It is Kog's only engine: your app compiles to MQuickJS bytecode, which runs in place from read-only flash on the device.

Everything above the __kog host interface is the same regardless of the engine — the same compiler, the same signals runtime, the same @kog/ui and reconciler, the same bytecode-only device model. MQuickJS defines only how JavaScript is executed and where the bytecode lives in memory. There is nothing to configure: kog dev and kog deploy read the connected board's bytecode version over the dev-link handshake and confirm it matches the MQuickJS bytecode the toolchain produces.

Migrated from a dual-engine build

Earlier Kog builds shipped a second engine, quickjs-ng, as the default. Kog has since unified on MQuickJS and retired quickjs-ng. A board still running the old firmware reports the retired bytecode version; kog dev / kog deploy and the browser Run on your board page detect it and ask for a one-time reflash (kog flash) to the MQuickJS runtime. KOG_JS_ENGINE survives only as a build guard — it accepts mquickjs and errors on anything else.

Why MQuickJS

The CYD (a bare ESP32 with no PSRAM, ~220 KB of DRAM) is critically RAM-starved, and it drove the engine choice. quickjs-ng — the engine Kog previously used — copies the compiled bytecode bundle into the JS heap to run it, so once an app is loaded that board has only ~3.6 KB of free heap. Add the zero-import networking prelude (spec/13), which is ~29 KB, and the JS heap simply overflows. On that class of board, quickjs-ng could render a UI but had no headroom left for networking or the hot-reload dev-link.

MQuickJS attacks exactly those costs:

  • Bytecode executes in place from flash — it is never copied into the JS heap.
  • The standard library lives in ROM — baked into const C tables at build time, not built in RAM at boot.
  • A ~10 KB-RAM engine with a compacting GC — no libc malloc, no reference counting, no fragmentation; it allocates only inside a caller-provided buffer.

The engine's own code is also far smaller: on a host -Os build the core is ~144 KB vs quickjs-ng's ~634 KB (~4.4× smaller). In the real firmware image that shows up as a CYD build of 1.38 MB — about 394 KB (~22%) smaller than the equivalent quickjs-ng build was.

Run in place from flash

This is the whole RAM thesis, and it is the load-bearing difference from the engine Kog replaced. quickjs-ng loaded bytecode by copying it into the JS heap; MQuickJS's JS_LoadBytecode references a flash-resident buffer directly, so the bundle never enters the heap.

The catch is that MQuickJS bytecode is base-specific: relocation bakes absolute pointers for the exact address the buffer will be mapped at, and the loader asserts the buffer sits at that base (see spec/16). Kog resolves this with a fixed-virtual-address mapping:

  • Each A/B app slot is designed to map to a fixed virtual address via the ESP32 flash MMU, so a bundle relocated for that VA is bootable from either slot — preserving A/B rollback at zero runtime cost (base-specific stays base-specific; no load-time pointer fix-up). Single-slot run-in-place is what's proven on-glass today; from-either-slot A/B rollback is the spec/16 design.
  • The boot preludes (the signals runtime shim + the networking prelude) are firmware-baked into a dedicated read-only flash region mapped at a canonical VA (Vp). They are relocated once for Vp and loaded in place — never source-eval'd, never copied to RAM.
  • Relocation happens device-side, at stage time (kog deploy), against [ROM stdlib + prelude@Vp]. Doing it on the device keeps the host bundle engine-agnostic — a single signed .kogb with no engine-specific atom addresses baked in by the host.
  • Because the prelude region is provisioned to its fixed VA once and shared, boot is a single load-all-then-run-all pass: map the prelude and app in place, JS_LoadBytecode both, then run prelude → app.

The payoff, measured on the working ROM-stdlib harness: the minimum JS heap needed to mount an app drops from 128 KB (source-eval) to 48 KB (run-from-bytecode), because the bytecode body stays in the flash buffer instead of the heap.

An honest, important finding: source-eval on MQuickJS actually needs more heap than quickjs-ng's source-eval did. The RAM win is entirely the bytecode-in-flash path — so on device that path is mandatory, not an optimization. The networking prelude in particular must be flash-resident bytecode; source-eval'ing it (the way the old engine ran everything) is a big part of why the CYD OOMs.

The ES5-lowering pipeline

MQuickJS is a strict-mode ES5 subset: no let/const, arrow functions, template literals, class, destructuring, spread, optional chaining, or nullish coalescing at the syntax level, and no Promise/async/Map/Set/Symbol in the library. Kog's compiler emits modern ES2020, so the build adds a lowering stage after bundling:

esbuild (iife) → tsc target ES5 → Babel elision-fill

  1. esbuild emits an IIFE, not an ES module — MQuickJS has no module system, so the bundle must be a self-contained script (it self-boots via a top-level if (globalThis.__kog) mount(); there is no export to strip).
  2. tsc with target: ES5, downlevelIteration: false lowers all syntax in one tool — including async/await into TypeScript's Symbol-free __awaiter/__generator state machines. This is deliberately not Babel's regenerator: regenerator-runtime pulls in Symbol and per-app RAM the CYD can't spare. downlevelIteration: false keeps array for/of as a plain index loop (no Symbol.iterator).
  3. A ~5-line Babel plugin fills array elisions. tsc's __generator emits array holes like _.trys.push([0, 3, , 4]), and MQuickJS's stricter parser rejects array elisions — so the pass replaces each hole with undefined.

The same pipeline runs everywhere Kog compiles an app: the CLI's kog dev/kog deploy, and the in-browser docs playground (which lowers with the browser twin of the CLI pass). The syntax gap therefore closes almost entirely on the compiler side, at zero runtime cost. The small genuinely-needed runtime pieces (Map, Set, Promise, and a handful of ES2015 Object/Array/String methods) are supplied by a baked JS prelude — plain ES5, zero C — that runs before your app; they are not part of the ROM stdlib (which is the C __kog/console host surface plus the engine's own builtins).

The ROM standard library

A conventional embedding builds the __kog host surface at runtime with JS_NewCFunction. MQuickJS has no such call — the entire ~60-function host surface must be declared at build time as JSPropDef tables and baked into ROM by mquickjs_build's codegen; only the C bodies are linked in. Two consequences worth knowing:

  • Nested host objects can't be baked inside another baked object, so __kog.hw / .flatlist / .net are declared as top-level ROM objects and the boot prelude assembles the tree (__kog.hw = __kogHw; …).
  • The 32-bit device word size matters: the ROM JSWord tables are word-size-specific, so the host codegen runs with -m32 to emit the tables the xtensa build links against.

Engine fork divergences

Per the fork charter (optimize for the ESP32; diverge freely, record everything), the vendored engine carries five deliberate in-tree changes to mquickjs.c itself. Each is diffable against the pinned upstream baseline and documented in full in native/vendor/mquickjs/PROVENANCE.md and spec/16.

  1. Catch-scope relaxation. MQuickJS has no block scope and rejects a duplicate catch binding name in one function. Minified bundles reuse short catch names (e/t/n) across sibling catch clauses, so the parser reuses the existing local slot instead of erroring. (The binding still leaks — but our minified input never relies on a catch value persisting across sibling clauses.)
  2. ArrayBuffer C API. MQuickJS exposes no ArrayBuffer accessors, which the networking byte-marshaling needs, so JS_GetArrayBuffer / JS_NewArrayBufferCopy were added (moving-GC caveat: copy the pointer out before the next allocation).
  3. ROM atom-table sizing. Each JS_LoadBytecode pushes one ROM atom table; upstream sized the array for stdlib + one module. Kog loads several in place from flash (runtime prelude + net prelude + app), so N_ROM_ATOM_TABLES_MAX was raised 2 → 6. The cost is a few extra pointers in the context; atom lookups walk the actual table count, so there's no per-lookup penalty.
  4. Remote-VA relocation deref fix. Device provisioning relocates a RAM copy of the combined prelude for the flash VA it will be mapped at later — an address not yet mapped. Upstream's relocation dereferenced the not-yet-mapped new base and hung the watchdog on-glass; the fix dereferences the referenced block at its current address while storing the new-base pointer. For the in-place case the two addresses coincide, so behavior is byte-identical there.
  5. Copy-on-promote string interning. Interning a not-yet-unique string upstream mutates the input string in place. For bytecode run in place from read-only flash, an app that uses a constant-pool string as a property key would write into flash → an ESP32 LoadStoreError on-glass. The fix canonicalizes into a fresh RAM copy and returns that — the flash-resident analogue of the "don't mutate ROM" rule.

Adapter & prelude conventions (not engine changes). Three behaviors shape the boot but live in the C adapter or the JS prelude, not the vendored engine:

  • Load-all-then-run-all boot ordering. Running a loaded prelude interns atoms into RAM, after which JS_LoadBytecode refuses to load more ("no atom must be defined in RAM"). So the boot maps and loads every module in place first, then runs them — an invariant enforced in the adapter (paired with the atom-table sizing above).
  • The eval_bundle base dispatch. The adapter's kog_js_eval_bundle branches on the bytecode header's base_addr: relocated-for-where-it-sits → load in place (don't rewrite read-only flash); base_addr == 0 → a base-0 image in writable RAM (the simulator and the kog dev hot-reload push) → self-relocate for the current address; any other base → a deploy base mismatch → fail loud rather than fault on read-only flash.
  • The runtime-prelude Symbol shim. The net prelude installs Symbol.iterator/asyncIterator on its streams; a small shim in the JS prelude no-ops those installs (real iterator-protocol iteration isn't supported, and the fetch/JSON path doesn't need it).

Status

MQuickJS ships in every prebuilt image now (it is the only engine):

  • Desktop simulator: all six example apps (counter, gallery, tic-tac-toe, flatlist-stress, hardware-demo, net-dashboard) mount and render real LVGL widget trees on MQuickJS.
  • Live CYD hardware: gallery and net-dashboard render, running in place from read-only flash — the first Kog apps to do so on a bare ESP32. Post-init free heap is 96,796 B (~95 KB) with net-dashboard running stably, versus 3,636 B under the retired quickjs-ng runtime — the run-in-place win that makes networking and the ~16 KB hot-reload dev-link fit on the CYD at all.
  • Pending: the live networking smoke test (net-dashboard renders its offline/error branch on the bench because that board had no Wi-Fi credentials provisioned; a fetch against real data needs Wi-Fi creds), and hardware bring-up of the ESP32-S3/Waveshare prebuilt (built and link-clean from the current source, but no S3 board was on the bench at release). Final RAM slimming and the broader on-device convergence are ongoing.

For the full derivation, the RAM benchmarks, the deploy/relocation layout, and the complete gap analysis, see native/vendor/mquickjs/PROVENANCE.md and spec/16-rom-bytecode-inplace.md in the repository.