Architecture
This page explains how the parts of Mikro.js fit together, for people who build drivers and board packages.
Three layers
┌─────────────────────────────────────────────┐
│ Board packages │
│ Prebuilt firmware, pin names │
├─────────────────────────────────────────────┤
│ 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.
Drivers are of two kinds:
- Native drivers are native modules: C or C++ in an ESP-IDF component, compiled into the firmware. Apps import them by package name. None of their code is deployed with the app.
- Pure JS drivers are normal modules that use core APIs like
mikro/spi. They are bundled and deployed with the app.
Board packages ship prebuilt firmware for a development board, built as the package's boards.config.ts describes, with the board's sdkconfig defaults and the native modules it needs. They can also give apps the board's pin names and its drivers wired to its pins. See Creating Boards and Creating Drivers.
Native module registration
A native module registers itself with MIK_REGISTER_PUBLIC_MODULE, under the name that apps import:
MIK_REGISTER_PUBLIC_MODULE(epaper, "@my-scope/epaper/panel", mik__epaper_init, nullptr, nullptr)The macro has five arguments: a unique C identifier, the module name, an init function, an optional loop consumer, and an optional destroy function. It uses a GCC/Clang constructor attribute. Before main() runs, each module adds itself to a global linked list. When JavaScript imports @my-scope/epaper/panel, the loader finds the module in this list and calls its init function.
The runtime's internal modules register with MIK_REGISTER_MODULE, under native:mikro/* names. Apps cannot import these names.
Each component's CMakeLists.txt sets MIK_PACKAGE_NAME (mikro for the runtime). At compile time, the macros make sure that each module name starts with it. This catches a module name copied from another package by mistake. It doesn't stop a package that sets another package's name on purpose.
NVS namespaces that start with mik. also belong to the runtime (mik.env, mik.sec, mik.kv, mik.sys). In mik.sys, runtime subsystems start their keys with <subsystem>., within the 15-character NVS limit.
A module initializes on its first import, not at startup. This keeps boot time and memory use low when a module is compiled in but not used.
Linker considerations
The registration symbols are in static libraries, so the linker can discard them when nothing else refers to them. mikrojs_force_include_modules() adds linker flags (-u on GCC/Clang) that keep them:
mikrojs_force_include_modules(epaper)Bytecode builtins
The core runtime's own TypeScript modules (mikro/fs, mikro/result and so on) are pre-compiled to QuickJS bytecode and embedded in the firmware binary, in a table in builtins.cpp. When JavaScript imports mikro/fs, the loader finds the matching builtin, deserializes the bytecode with JS_ReadObject, and returns the module, with no filesystem lookup.
Driver and board modules do not use this path. They are normal modules, deployed with the app. Only native modules are in the firmware.
Bytecode pipeline
runtime/fs/fs.ts
│
▼
esbuild bundle (1 JS file, externals preserved)
│
▼
qjsc compile (bytecode → C uint8_t array)
│
▼
gen/<module>.h (#include'd in builtins.cpp, linked into firmware)esbuild bundles each TypeScript module into a single JavaScript file. mikro/* and native:* 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. It is built from the same QuickJS source as the runtime engine, which guarantees bytecode version compatibility.
Module resolution at runtime
When JavaScript runs an import, the loader tries these sources in order:
- Runtime internals: A
native:name is looked up in the list thatMIK_REGISTER_MODULEfills. - Bytecode builtins: A builtin name, for example
mikro/result, loads from bytecode in the firmware. - Native modules: A name that a native module registered, for example
@acme/drivers/sh8601or an app's own#sensor, runs that module's init function. - Filesystem: A relative path (starting with
.or/) loads from the device filesystem (LittleFS). JSON files load as modules, and.bjsfiles load as bytecode. Other package names resolve throughnode_modules. See Module System.
The loader does not change native: names, mikro/ names, or the names of registered native modules. It resolves relative paths against the folder of the importing module.
How native modules get into firmware
Installing a package puts nothing in the firmware. The project lists what goes in:
- The project lists its native modules with
set(MIKROJS_NATIVE_MODULES "@acme/drivers/sh8601")in itsCMakeLists.txt, or with the same variable in the environment or-D. project.cmakerunsmikro-fw inputs, which resolves each entry with thenativecondition to C or C++ source: a package export, or for a#entry, the app'simports. The source's folder becomes an ESP-IDF component.- ESP-IDF builds each component; the
REQUIRESin itsCMakeLists.txtset the include paths and the link order.
mikro deploy follows the app's imports to make sure that the device's firmware has every native module that the app needs.
Key CMake functions
| Function | Purpose |
|---|---|
mikrojs_force_include_modules() | Add -u linker flags so self-registered native modules survive dead-code elimination |
mikrojs_generate_bytecode() | Run the TS -> esbuild -> qjsc pipeline (core runtime modules) |
Both are defined in the CMake module exported by @mikrojs/native (bytecodeCmakePath from @mikrojs/native/cmake).