Architecture
This page explains how the pieces fit together. The goal is "enough to understand what goes where" when building drivers and board packages.
Three layers
┌─────────────────────────────────────────────┐
│ Board packages │
│ Pin maps, sdkconfig, re-export drivers │
├─────────────────────────────────────────────┤
│ Driver packages │
│ Native C or pure JS hardware abstractions │
├─────────────────────────────────────────────┤
│ Core runtime (@mikrojs/native) │
│ QuickJS engine, event loop, builtins │
└─────────────────────────────────────────────┘Core runtime is a standalone C++ library with zero ESP-IDF dependencies. It can be built and tested on a desktop.
Driver packages come in two flavors:
- Native drivers: ESP-IDF components with C/C++ code, registered via
MIK_REGISTER_MODULEandMIK_REGISTER_BUILTIN. Compiled into firmware. Use for QSPI displays and other peripherals needing direct hardware access. - Pure JS drivers: Regular npm packages that use core APIs like
mikro/spi. Bundled and deployed with the user's app. No native code, nocmake.js.
Board packages are thin layers that depend on drivers and provide board-specific pin assignments. Native boards include sdkconfig and ESP-IDF components. Pure JS boards are just TypeScript re-exports.
Native module registration
Native modules (C/C++ functions callable from JavaScript) self-register using the MIK_REGISTER_MODULE macro:
MIK_REGISTER_MODULE(bme280, "native:@my-scope/bme280/sensor", mik__bme280_init, nullptr, nullptr)The macro takes five arguments: a unique C identifier, the module name, an init function, an optional loop consumer, and an optional destroy function. It works via a GCC/Clang constructor attribute. At program startup, before main() runs, each registered module adds itself to a global linked list. When JavaScript code imports native:@my-scope/bme280/sensor, the module loader walks this list and calls the module's init function.
Native module names are package-qualified: native:<package-name>/<module>. The bare native:mikro/* namespace is reserved for the core runtime. The build defines MIK_PACKAGE_NAME per component and the macro enforces the prefix at compile time, so a package cannot claim or shadow another's native: name.
The same reservation applies to NVS: namespace names starting with mik. belong to the runtime (mik.env, mik.sec, mik.kv, mik.sys). Inside mik.sys, runtime subsystems name their keys with a <subsystem>.<key> dotted prefix under the 15-character NVS limit.
Modules are initialized lazily: the factory function runs on first import, not at startup. This keeps boot time and memory usage low when a module is compiled in but not used.
Linker considerations
Because the module registration symbols live in static library archives, the linker may discard them if nothing else in the program references them. The mikrojs_force_include_modules() CMake function adds the necessary linker flags (-u on GCC/Clang) to force-include specific symbols:
mikrojs_force_include_modules(bme280)Bytecode builtin registration
TypeScript wrapper modules are pre-compiled to QuickJS bytecode and embedded in the firmware binary. They register with MIK_REGISTER_BUILTIN:
MIK_REGISTER_BUILTIN("@my-scope/bme280/sensor",
qjsc_bme280, qjsc_bme280_size)This uses the same constructor/linked-list pattern as native modules. When JavaScript imports @my-scope/bme280/sensor, the loader finds the matching builtin, deserializes the bytecode with JS_ReadObject, and returns the module.
The builtin name matches the npm package path that user code imports. This is how import {readSensor} from '@my-scope/bme280/sensor' resolves without a filesystem lookup.
Like native modules, builtins need force-include linker flags:
mikrojs_force_include_builtins(bme280)Bytecode pipeline
TypeScript source files become C headers containing bytecode arrays:
runtime/bme280/bme280.ts
│
▼
esbuild bundle (1 JS file, externals preserved)
│
▼
qjsc compile (bytecode → C uint8_t array)
│
▼
gen/bme280.bytecode.h (#include'd in .cpp, linked into firmware)The mikrojs_generate_bytecode() CMake function orchestrates both steps:
mikrojs_generate_bytecode(
RUNTIME_DIR "${CMAKE_CURRENT_LIST_DIR}/../runtime"
MODULES sensor
MODULE_PREFIX "@my-scope/bme280"
SYMBOL_PREFIX "bme280"
)esbuild bundles each TypeScript module into a single JavaScript file. All mikro/* and @mikrojs/* imports are marked as external since they resolve at runtime inside the firmware.
qjsc (the QuickJS bytecode compiler) takes the bundled JS and produces a C header with a const uint8_t[] array. This is built from the same QuickJS source as the runtime engine, guaranteeing bytecode version compatibility.
Module resolution at runtime
When JavaScript executes an import statement, the module loader checks several sources in order:
- Native modules: Names starting with
native:are looked up in the native module registry (the linked list populated byMIK_REGISTER_MODULE). - Bytecode builtins: Names matching registered builtins (for example
@my-scope/bme280/sensorormikro/result) are deserialized from embedded bytecode. - Filesystem: Relative paths (starting with
.or/) are loaded from the device filesystem (LittleFS). JSON files are auto-wrapped..bjsfiles are loaded as pre-compiled bytecode.
The module normalizer passes through native:, mikro/, and @mikrojs/ prefixed names unchanged. Relative paths are resolved against the importing module's directory.
How board packages wire in
The build system discovers driver and board packages automatically:
- Package discovery: During CMake configure, the build system reads
esp32/package.json, finds all@mikrojs/*dependencies, and callsrequire('<pkg>/cmake')on each. - Component registration: Each package's
cmake.jsexports acomponentPathpointing to its ESP-IDF component directory. CMake adds these as extra component directories. - sdkconfig merge: If a package exports
sdkconfigDefaultsPath, the file is appended to the sdkconfig defaults list. Board-specific values override the base project defaults. - Dependency wiring: ESP-IDF's component system handles the rest.
REQUIRESdeclarations in each component'sCMakeLists.txtset up include paths and link order.
Key CMake functions
| Function | Purpose |
|---|---|
mikrojs_generate_bytecode() | Run the TS -> esbuild -> qjsc pipeline for runtime modules |
mikrojs_force_include_modules() | Add -u linker flags so self-registered native modules survive dead-code elimination |
mikrojs_force_include_builtins() | Same, but for bytecode builtins |
All three are defined in the CMake module exported by @mikrojs/native (accessed via require('@mikrojs/native/cmake').cmakePath).