Native Modules
A native module is a module written in C or C++. It is compiled into the firmware, and apps import it by its package name:
import {Epaper} from '@my-scope/epaper/panel'Native modules must be built with custom firmware
An app can't import C or C++ source the way it imports JavaScript. The module has to be compiled into the firmware, which means a custom firmware build.
Use a native module when JavaScript isn't enough: for QSPI or DMA transfers, for precise timing, or to call a vendor C library. A native module doesn't have to be a driver; a codec, for example, is packaged the same way. A driver that doesn't need C or C++ should be written in JavaScript instead; see Creating Drivers.
Examples:
examples/drivers/chip-temperature: a native driver for the chip's internal temperature sensorexamples/chip-temperature: firmware built with that driver, and an app that uses it
Declare the module in package.json
A native module is a package export whose native condition points to the C++ source. A types condition next to it gives TypeScript the declarations:
{
"name": "@my-scope/epaper",
"version": "0.1.0",
"type": "module",
"exports": {
"./panel": {
"types": "./panel.d.ts",
"native": "./panel.cpp"
}
},
"peerDependencies": {
"@mikrojs/firmware": "^0.1.0",
"mikro": "^0.1.0"
}
}The package needs nothing more. TypeScript checks imports against panel.d.ts. A firmware build compiles the folder that holds panel.cpp when the firmware project lists the module. mikro deploy uploads no code for a native module; it only makes sure that the device's firmware includes it.
The export can be the package root (".", imported as @my-scope/epaper) or a subpath. The package can export JavaScript modules too, such as a wrapper with an easier API. These import the native module and are deployed with the app like any other JavaScript.
The source folder
ESP-IDF compiles components, which are folders with a CMakeLists.txt. So the folder that contains the source file needs one:
@my-scope/epaper/
├── package.json
├── CMakeLists.txt
├── panel.cpp registers "@my-scope/epaper/panel"
└── panel.d.tsThe folder can be anywhere in the package, and several modules can share it. A package with several native modules, like @acme/drivers, usually has one folder for each.
Give each folder a unique name
ESP-IDF names a component after its folder. If two components have the same name, it uses one and ignores the other without an error. So the firmware build stops when a native module's folder has the same name as:
- the folder of another native module
mainormikrojs, which every firmware has- an ESP-IDF component, for example
jsonorconsole - a component in the firmware project's
components/ormanaged_components/folder
Name the folder after what it contains (sh8601, bme280), not native or src.
The C++ module
#include <mikrojs/mikrojs.h>
/* Epaper(options) -> Result<Epaper, EpaperError> */
static JSValue js_epaper_open(JSContext* ctx, JSValueConst this_val, int argc,
JSValueConst* argv) {
if (argc < 1 || !JS_IsObject(argv[0])) {
return MIK_ResultErrNamed(ctx, "EpaperError", "options must be an object");
}
// Read and check every option, claim pins, set up the bus, return a Result.
return MIK_ResultErrNamed(ctx, "EpaperError", "not implemented");
}
// Called when the module is evaluated: set the export values.
static int mik__epaper_module_init(JSContext* ctx, JSModuleDef* m) {
return JS_SetModuleExport(ctx, m, "Epaper",
JS_NewCFunction(ctx, js_epaper_open, "Epaper", 1));
}
// Called on first import: declare the exports.
static JSModuleDef* mik__epaper_init(JSContext* ctx) {
JSModuleDef* m = JS_NewCModule(ctx, "@my-scope/epaper/panel", mik__epaper_module_init);
if (!m) return nullptr;
JS_AddModuleExport(ctx, m, "Epaper");
return m;
}
MIK_REGISTER_PUBLIC_MODULE(epaper, "@my-scope/epaper/panel", mik__epaper_init, nullptr, nullptr)Validate every argument
Apps can import the native module directly, so check the input in C, even if a JavaScript wrapper checks it too. Check types and ranges, and check the length of a buffer before you read it.
MIK_REGISTER_PUBLIC_MODULEregisters the module when the firmware starts, somain.cppdoesn't change. The module initializes on its first import.- The second argument is the name that apps import. The compiler makes sure that it starts with the
MIK_PACKAGE_NAMEthat the component'sCMakeLists.txtsets. - The last two arguments are optional hooks. The runtime calls the first on every iteration of the event loop once the module is imported. Use it to hand results from interrupts or background tasks to JavaScript. After queuing work for it, call
MIK_Wake(), orMIK_WakeFromISR()(mikrojs_esp32.h) from an interrupt handler; otherwise the event loop can sleep for up to 100 ms before the hook runs. The runtime calls the second when it shuts down. Passnullptrfor a hook you don't need. - Each export needs two calls:
JS_AddModuleExportin the init function, andJS_SetModuleExportwhen the module is evaluated. Without the second, the export isundefined. - Follow the conventions of the public API: a PascalCase factory that returns a
Result, handle methods that returnResults, and anend()that is safe to call twice.mikrojs/mikrojs.hhasMIK_ResultOk(ctx, value),MIK_ResultOkVoid(ctx)andMIK_ResultErrNamed(ctx, "EpaperError", "reset failed: %s", reason)for this.
Apps can't import native: names; those belong to the runtime.
CMakeLists.txt
idf_component_register(
SRCS "panel.cpp"
INCLUDE_DIRS "."
REQUIRES mikrojs esp_driver_spi esp_driver_gpio
)
# The module registers itself from a static library. Without this, the linker
# drops the object file. The argument is the first argument of
# MIK_REGISTER_PUBLIC_MODULE.
mikrojs_force_include_modules(epaper)
target_compile_definitions(${COMPONENT_LIB} PRIVATE "MIK_PACKAGE_NAME=\"@my-scope/epaper\"")Put this file in the source folder, and add any other sources, such as a vendor C library, to SRCS.
Keep mikrojs in REQUIRES. The mikrojs component defines mikrojs_force_include_modules, and ESP-IDF processes a component's requirements before the component itself.
Add an idf_component.yml only if the component needs packages from the IDF Component Registry. An empty dependencies: key makes the build fail with "Input should be a valid dictionary".
panel.d.ts
import type {GpioInUse} from 'mikro/gpio'
import type {Result} from 'mikro/result'
export interface EpaperOptions {
spiHost: number
clk: number
mosi: number
cs: number
reset: number
busy: number
}
export type EpaperError = GpioInUse | {name: 'EpaperError'; message: string}
export interface Epaper {
draw(pixels: Uint8Array): Result<void, EpaperError>
end(): void
}
/** Claims the SPI bus and pins and initializes the panel. */
export declare function Epaper(options: EpaperOptions): Result<Epaper, EpaperError>Nothing checks that this file agrees with the C++, so keep the two side by side and change them together.
Adding the module to firmware
Adding the package to the app's dependencies doesn't change the firmware. A firmware build includes a native module when the custom firmware project lists it in MIKROJS_NATIVE_MODULES, and so does the firmware project of a board package. Separate several names with ;.
set(MIKROJS_NATIVE_MODULES "@my-scope/epaper/panel")Before mikro deploy uploads an app, it makes sure that the device's firmware includes every native module the app imports, and stops if one is missing:
This app imports a native module that the device's firmware (esp32c6-generic)
was not built with:
@my-scope/epaper/panel
Flash firmware that includes it: the image of a board package whose firmware
lists it,
mikro flash --board <board>
or a build of your firmware project, with it in its MIKROJS_NATIVE_MODULES:
mikro flash --build-dir <your-firmware-build>
To create a firmware project, see https://mikrojs.dev/develop/custom-firmwareOptional native modules
If the app can work without the module, load it with a dynamic import(). mikro deploy checks only static imports, so a missing module that the app loads with import() doesn't stop the upload. On firmware without the module, the import fails when it runs, and the app carries on without it:
let epaper
try {
epaper = await import('@my-scope/epaper/panel')
} catch (error) {
console.error('Cannot load the e-paper module:', error)
}The error is TypeError: Failed to resolve module specifier '@my-scope/epaper/panel'.
Native modules inside an app
An app that builds its own firmware can keep native modules of its own, without a separate package. The app is then also the custom firmware project: its package.json depends on @mikrojs/firmware, and the firmware's CMakeLists.txt is next to it. Map a # import to the source in the imports field of the app's package.json. Unlike an export, a # import is private to the app:
{
"name": "my-app",
"type": "module",
"imports": {
"#sensor": {
"types": "./native/sensor/sensor.d.ts",
"native": "./native/sensor/sensor.cpp"
}
},
"dependencies": {
"@mikrojs/firmware": "^0.1.0"
}
}my-app/
├── package.json
├── CMakeLists.txt set(MIKROJS_NATIVE_MODULES "#sensor")
├── app/main.ts import {Sensor} from '#sensor'
└── native/sensor/
├── CMakeLists.txt
├── sensor.cpp registers "#sensor"
└── sensor.d.tsThe firmware build looks up a # entry in the nearest package.json at or above the firmware project that has an imports entry for it. The firmware project can also be in a folder of the app, such as firmware/, if that folder has no package.json of its own; with one, npm would not find @mikrojs/firmware in the app's dependencies. For several modules, one pattern entry covers a folder of them. With "#native/*": {"types": "./native/*/*.d.ts", "native": "./native/*/*.cpp"}, #native/sensor is ./native/sensor/sensor.cpp.
The C++ and its CMakeLists.txt differ from a package's in two places:
- Name the module
#sensorinMIK_REGISTER_PUBLIC_MODULEandJS_NewCModule. - Leave
MIK_PACKAGE_NAMEout. Without it, the compiler accepts only names that start with#.
idf_component_register(
SRCS "sensor.cpp"
INCLUDE_DIRS "."
REQUIRES mikrojs
)
mikrojs_force_include_modules(sensor)Only the app can do this. A # name is private to one package, but the firmware has one module for each name, so mikro deploy stops when a package's own imports point at C or C++. A package exports its native modules instead.
Claiming GPIO pins
Each GPIO pin has one owner at a time. mikro/gpio, Pwm, the bus modules and the console record their pins in a shared claim table, and native code that configures a pin must claim it there too. Otherwise two modules can drive the same pin, and the app gets no error.
- Before you configure the pins, claim them with
MIK_ClaimGpios(ctx, gpios, count, "Epaper"). Pass the class name as a string literal; apps see it inGpioInUseerrors. If a pin is already in use, the call releases the pins it claimed and returns aGpioInUseerror Result for you to return to the app. Otherwise it returnsJS_UNDEFINED. - In
end()and in the finalizer, release the pins withMIK_ReleaseGpios(gpios, count, "Epaper"). A release frees only claims with the same name, so it can't free a pin that another module has claimed since. - If the handle is a native class, call
MIK_KeepHandle(ctx, obj)when you create it andMIK_DropHandle(ctx, this_val)inend(). The handle then stays alive untilend(), even when the app no longer refers to it.
const int gpios[] = {options.reset, options.busy};
JSValue in_use = MIK_ClaimGpios(ctx, gpios, 2, "Epaper");
if (!JS_IsUndefined(in_use)) return in_use;MIK_ClaimGpio, MIK_ReleaseGpio and MIK_GpioOwner do the same for a single pin.
Code that only reads or writes a pin doesn't claim it; it takes a handle to it instead. See Sharing pins.
Testing a native module
Create a custom firmware project that depends on the package, and name the module in
MIKROJS_NATIVE_MODULES.Build and flash it:
shpn mikro idf set-target esp32c6 pn mikro idf build flashWith
mikro dev, deploy a small app that imports the module.
Keep this firmware project in the package's repository and build it in CI, so that every push shows whether the module still compiles.
Notes
- Declare
mikroand@mikrojs/firmwareas peer dependencies, with the versions the module is tested against. - A module that stores data in NVS needs a namespace of its own. Names that start with
mik.belong to the runtime. - Compile vendor C libraries as
.cfiles. In.cppfiles they can fail under-Werror. - Allocate DMA buffers with
heap_caps_malloc(size, MALLOC_CAP_DMA). DMA needs internal SRAM, so buffers in PSRAM don't work.