Skip to content

Creating Drivers ​

Drivers come in two flavors:

  1. Native drivers have a C/C++ module compiled into firmware as an ESP-IDF component. They export ./cmake and use MIK_REGISTER_MODULE / MIK_REGISTER_BUILTIN. Use this when you need direct hardware access (QSPI, DMA, custom peripherals).

  2. Pure JS drivers are regular npm packages that use existing APIs like mikro/spi or mikro/i2c. No native code, no cmake.js, no ESP-IDF component. They're bundled and deployed with the user's app. Use this when existing APIs are sufficient.

This walkthrough covers a native driver, published as a single package @my-scope/bme280 (substitute your own npm scope and chip). For pure JS drivers, create a regular npm package that imports from mikro/spi, mikro/i2c, or the other core APIs, and export a factory function.

Package structure ​

@my-scope/bme280/
  package.json
  cmake.js
  bme280/                        # ESP-IDF component
    CMakeLists.txt
    idf_component.yml            # only if using managed components (see below)
    src/
      mik_bme280.cpp             # Native module implementation
  runtime/
    sensor/
      sensor.ts                  # JS wrapper (public API)
      types.ts                   # TypeScript types
    internal.d.ts                # Type declarations for native:@my-scope/bme280/sensor
  tsconfig.json

Component directory naming

The component directory name (bme280) becomes the ESP-IDF component name. Other components reference it with REQUIRES bme280. Choose a name and stick with it.

Step 1: package.json ​

json
{
  "name": "@my-scope/bme280",
  "version": "0.0.1",
  "type": "module",
  "exports": {
    "./sensor": "./runtime/sensor/sensor.ts",
    "./cmake": "./cmake.js"
  },
  "dependencies": {
    "@mikrojs/native": "^0.14.0",
    "@mikrojs/quickjs": "^0.14.0"
  }
}

The ./cmake export is how the build system discovers your component. The ./sensor export is what app code imports; its specifier matches the builtin name registered in Step 4.

Dependencies are required

@mikrojs/native and @mikrojs/quickjs must be declared as dependencies of the driver package: the component's CMakeLists.txt resolves them with Node from the package directory (Step 5), so they have to be reachable from there.

Use a published version range as shown. workspace:* only resolves inside the mikro monorepo.

Step 2: cmake.js ​

js
import {dirname, join} from 'node:path'
import {fileURLToPath} from 'node:url'

const __dirname = dirname(fileURLToPath(import.meta.url))

export const componentPath = join(__dirname, 'bme280')

How discovery works: the firmware's project.cmake runs discover.js, which walks the firmware project's package.json dependencies, loads each dependency's ./cmake export, and adds every componentPath to EXTRA_COMPONENT_DIRS. Adding a driver to a firmware build means adding it to the firmware project's package.json dependencies. Nothing else.

Step 3: Native module (mik_bme280.cpp) ​

The native module registers itself with the runtime via macros. Here is a minimal example that reads temperature over I2C:

cpp
#include <mikrojs/mikrojs.h>
#include <mikrojs/platform.h>

#include "driver/i2c_master.h"

// Forward declaration
static JSValue js_bme280_read(JSContext* ctx, JSValueConst this_val,
                               int argc, JSValueConst* argv);

// Module init callback: called on module evaluation to set export values
static int mik__bme280_module_init(JSContext* ctx, JSModuleDef* m) {
    JSValue exports = JS_NewObject(ctx);
    JS_SetPropertyStr(ctx, exports, "read",
        JS_NewCFunction(ctx, js_bme280_read, "read", 1));
    return JS_SetModuleExport(ctx, m, "default", exports);
}

// Module constructor: lazily called on first import; declares exports
static JSModuleDef* mik__bme280_init(JSContext* ctx) {
    JSModuleDef* m = JS_NewCModule(ctx, "native:@my-scope/bme280/sensor", mik__bme280_module_init);
    if (!m) return nullptr;
    JS_AddModuleExport(ctx, m, "default");
    return m;
}

// Self-register: the runtime discovers this at link time
MIK_REGISTER_MODULE(bme280, "native:@my-scope/bme280/sensor", mik__bme280_init, nullptr, nullptr)

static JSValue js_bme280_read(JSContext* ctx, JSValueConst this_val,
                               int argc, JSValueConst* argv) {
    // ... I2C reads, return { temperature, humidity, pressure }
    JSValue obj = JS_NewObject(ctx);
    JS_SetPropertyStr(ctx, obj, "temperature", JS_NewFloat64(ctx, 22.5));
    JS_SetPropertyStr(ctx, obj, "humidity", JS_NewFloat64(ctx, 45.0));
    JS_SetPropertyStr(ctx, obj, "pressure", JS_NewFloat64(ctx, 1013.25));
    return obj;
}

Key points:

  • MIK_REGISTER_MODULE uses a constructor attribute to add the module to a linked list at startup. No manual registration step needed.
  • The two trailing nullptr arguments are optional event-loop hooks. The first is a consume callback that the runtime calls on every event loop iteration once the module is imported; use it to drain completion queues from interrupt handlers or background tasks into JS callbacks (see mik_http.cpp for an example). The second is a destroy callback called on runtime shutdown to release anything the module allocated. Pass nullptr for hooks you don't need.
  • The native module name must be package-qualified: native:<your-package-name>/<module> (here native:@my-scope/bme280/sensor). It is literally native: plus the builtin specifier registered in Step 4. The bare native:mikro/* namespace is reserved for the core runtime; a driver that needs a core peripheral imports its public module, for example mikro/i2c. This rule is enforced at build time; an unqualified name fails to compile. Your package's public API goes through its TypeScript wrapper (Step 4).

Step 4: Bytecode builtin registration ​

The TypeScript wrapper is compiled to bytecode and registered as a builtin. Add the registration in the same file or a separate .cpp:

cpp
#include <mikrojs/mikrojs.h>

// Generated by mikrojs_generate_bytecode() during build. Names follow
// <SYMBOL_PREFIX>_<module>: header gen/<prefix>_<module>.h, symbols
// <prefix>_<module>_bytecode and <prefix>_<module>_bytecode_size.
#include "gen/bme280_sensor.h"

MIK_REGISTER_BUILTIN(bme280, "@my-scope/bme280/sensor",
                     bme280_sensor_bytecode, bme280_sensor_bytecode_size)

When user code does import {readSensor} from '@my-scope/bme280/sensor', the loader finds this builtin by name.

Import specifiers are exact

A builtin is resolved only by the exact registered name. There is no package-root or index resolution for builtins: import ... from '@my-scope/bme280' fails with a resolution error. The import must be the full registered name, here @my-scope/bme280/sensor.

Step 5: CMakeLists.txt ​

cmake
# Resolve the CMake helpers from this package's dependencies. quickjs.cmake
# sets QJSC_EXECUTABLE (required by the bytecode pipeline; it is scoped per
# component, so every driver component includes it itself). The bytecode
# helpers live in @mikrojs/native's mikrojs_bytecode.cmake (bytecodeCmakePath).
set(_PKG_DIR "${CMAKE_CURRENT_LIST_DIR}/..")
execute_process(
    COMMAND node -e "import {bytecodeCmakePath} from '@mikrojs/native/cmake'; import {cmakePath} from '@mikrojs/quickjs'; process.stdout.write(JSON.stringify({bytecodeCmake: bytecodeCmakePath, quickjsCmake: cmakePath}))"
    WORKING_DIRECTORY ${_PKG_DIR}
    OUTPUT_VARIABLE _PATHS_JSON
    OUTPUT_STRIP_TRAILING_WHITESPACE
    RESULT_VARIABLE _resolve_result
)
if(NOT _resolve_result EQUAL 0)
    message(FATAL_ERROR "Failed to resolve @mikrojs/native and @mikrojs/quickjs. Run 'pnpm install' first.")
endif()
string(JSON _QUICKJS_CMAKE GET "${_PATHS_JSON}" "quickjsCmake")
string(JSON _BYTECODE_CMAKE GET "${_PATHS_JSON}" "bytecodeCmake")
include("${_QUICKJS_CMAKE}")

idf_component_register(
    SRCS "src/mik_bme280.cpp"
    INCLUDE_DIRS "."
    REQUIRES driver mikrojs
)

# Declare this component's package namespace. MIK_REGISTER_MODULE /
# MIK_REGISTER_BUILTIN enforce at build time that names are qualified with it
# (native:@my-scope/bme280/<module> and @my-scope/bme280/<module>),
# so a package can't claim or shadow another's native: name.
target_compile_definitions(${COMPONENT_LIB} PRIVATE "MIK_PACKAGE_NAME=\"@my-scope/bme280\"")

include("${_BYTECODE_CMAKE}")

# Generate bytecode from TypeScript runtime modules
mikrojs_generate_bytecode(
    RUNTIME_DIR "${_PKG_DIR}/runtime"
    MODULES sensor
    MODULE_PREFIX "@my-scope/bme280"
    SYMBOL_PREFIX "bme280"
    TARGET gen_sensor_bytecode
    WORKING_DIRECTORY "${_PKG_DIR}"
)
add_dependencies(${COMPONENT_LIB} gen_sensor_bytecode)
target_include_directories(${COMPONENT_LIB} PRIVATE "${gen_sensor_bytecode_INCLUDE_DIR}")

# Force the linker to keep the self-registered module and builtin. The
# arguments are the registration ids: the first argument passed to
# MIK_REGISTER_MODULE / MIK_REGISTER_BUILTIN in Step 3 and 4.
mikrojs_force_include_modules(bme280)
mikrojs_force_include_builtins(bme280)

mikrojs_generate_bytecode runs the full pipeline: esbuild bundles each TypeScript module (<module>/<module>.ts under RUNTIME_DIR), then qjsc compiles it to a C header containing the bytecode array.

idf_component.yml is optional

Only add an idf_component.yml if the component depends on packages from the IDF Component Registry. If it has no managed dependencies, ship no manifest at all. In particular, a dependencies: key with no entries under it (for example, only comments) parses as null and fails the build with "Invalid field 'dependencies': Input should be a valid dictionary".

Step 6: TypeScript wrapper ​

runtime/sensor/sensor.ts:

ts
import type {Result} from 'mikro/result'
import {ok, err} from 'mikro/result'
import type {Reading} from './types.js'

// Import the native module (compiled into firmware)
import native from 'native:@my-scope/bme280/sensor'

export const SensorError = {
  ReadFailed: (cause: unknown) => ({name: 'ReadFailed', cause}) as const,
}
export type SensorError = ReturnType<(typeof SensorError)[keyof typeof SensorError]>

export function readSensor(bus: number, address?: number): Result<Reading, SensorError> {
  // eslint-disable-next-line @mikrojs/no-try-catch -- native boundary: C code throws
  try {
    return ok(native.read(bus, address ?? 0x76))
  } catch (e) {
    return err(SensorError.ReadFailed(e))
  }
}

The native module throws, so the wrapper is where that becomes a Result. Return a tagged union keyed on name rather than a bare Error, so callers can switch on the variant. Keep the thrown value as cause instead of copying its message into a string: the console prints the cause with its own message, fields and stack, so nothing is lost when a caller logs the error (see Logging errors). See Defining errors for the pattern and no-try-catch for why the boundary needs the disable comment.

runtime/sensor/types.ts:

ts
export interface Reading {
  temperature: number
  humidity: number
  pressure: number
}

Step 7: internal.d.ts ​

Type declarations for the native module so TypeScript can check imports:

ts
declare module 'native:@my-scope/bme280/sensor' {
  interface Native {
    read(
      bus: number,
      address: number,
    ): {
      temperature: number
      humidity: number
      pressure: number
    }
  }
  const native: Native
  export default native
}

Step 8: tsconfig.json ​

json
{
  "extends": "../../../tsconfig.base.json",
  "compilerOptions": {
    "rootDir": "runtime",
    "outDir": "dist"
  },
  "include": ["runtime"]
}

Testing ​

  1. Add the driver to your firmware project's package.json (during development a file: path or workspace:* also works):

    json
    {
      "dependencies": {
        "@my-scope/bme280": "^0.0.1"
      }
    }
  2. Run pnpm install, then build and flash:

    sh
    cd esp32
    rm sdkconfig && idf.py set-target esp32c6
    idf.py build flash monitor
  3. Test from the REPL:

    js
    import {readSensor} from '@my-scope/bme280/sensor'
    const result = readSensor(0)
    console.log(result)

Claiming GPIO pins ​

A 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 a native driver that configures a pin must claim it there too. If it doesn't, the app gets no error when the driver and, for example, a DigitalOut drive the same pin.

  1. Claim each GPIO before you configure it, with MIK_ClaimGpio(gpio, "Epaper") from mikrojs/mikrojs.h. Pass your class name as a string literal. The table stores the pointer, and the app sees the name in GpioInUse errors.
  2. If a claim fails, release the GPIOs that the same call already claimed. Then report the owner that MIK_GpioOwner(gpio) returns.
  3. Release each GPIO in end() and in the finalizer with MIK_ReleaseGpio(gpio, "Epaper"). A release only frees a claim held under the same name, so a late release cannot free a pin that another module has claimed since.
  4. If the handle is a native class, keep it alive until end(). Call MIK_KeepHandle(ctx, obj) when you create the handle. Call MIK_DropHandle(ctx, this_val) in end(). The claim then lasts until end(), even when the app no longer refers to the handle. The finalizer runs only after end(), or when the runtime is freed.

Expose a hardware handle the way the core modules do: a factory function that shares the handle's name and returns a Result, with an end() that returns nothing and does nothing when called again. The factory claims the pins before it touches the hardware. This native claimPins() for an e-paper display claims the reset and busy pins. On a conflict it returns a GpioInUse error object instead of throwing:

cpp
#include <string>

#include <mikrojs/mikrojs.h>

// claimPins(reset, busy): returns undefined, or a GpioInUse error object.
static JSValue js_epaper_claim_pins(JSContext* ctx, JSValueConst this_val, int argc,
                                    JSValueConst* argv) {
    int32_t gpios[2];
    if (JS_ToInt32(ctx, &gpios[0], argv[0]) || JS_ToInt32(ctx, &gpios[1], argv[1]))
        return JS_EXCEPTION;

    for (int i = 0; i < 2; i++) {
        if (MIK_ClaimGpio(gpios[i], "Epaper")) continue;

        const char* owner = MIK_GpioOwner(gpios[i]);
        std::string message =
            "GPIO " + std::to_string(gpios[i]) + " is already in use by " + owner;
        for (int j = 0; j < i; j++) MIK_ReleaseGpio(gpios[j], "Epaper");

        JSValue error = JS_NewObject(ctx);
        JS_SetPropertyStr(ctx, error, "name", JS_NewString(ctx, "GpioInUse"));
        JS_SetPropertyStr(ctx, error, "owner", JS_NewString(ctx, owner));
        JS_SetPropertyStr(ctx, error, "message", JS_NewString(ctx, message.c_str()));
        return error;
    }

    // Configure both pins here. If that fails, release both and return an error object.
    return JS_UNDEFINED;
}

The TypeScript factory returns the error object as it is:

ts
import type {GpioInUse} from 'mikro/gpio'
import {err, ok, type Result} from 'mikro/result'

import native from 'native:@my-scope/epaper/display'

export interface Epaper {
  end(): void
}

export function Epaper(reset: number, busy: number): Result<Epaper, GpioInUse> {
  const inUse = native.claimPins(reset, busy)
  if (inUse !== undefined) return err(inUse)
  let ended = false
  return ok({
    end() {
      if (ended) return
      ended = true
      native.releasePins(reset, busy)
    },
  })
}

Only code that configures a pin claims it. A TypeScript driver or helper that only reads or writes a pin takes a handle instead, for example Encoder({a: DigitalIn(4), b: DigitalIn(5)}), and never calls end() on it. Don't pass a handle to code that reconfigures the pin, such as passing led to something like Pwm. JavaScript cannot enforce that handover, so two objects would drive one pin. Claims are never shared; to share a pin, share its handle.

Important notes ​

  • Firmware builtin imports are external: esbuild marks mikro/*, and any package that exports ./cmake (your driver included), as external during bundling. They resolve at runtime in the firmware rather than being bundled into the app.

  • Declare mikro as an optional peer: if a published driver declares a mikro peerDependency (useful for types during development), mark it optional; drivers don't import mikro at runtime. A required peer also breaks preview installs: semver ranges never match prerelease versions, so with autoInstallPeers enabled a stable copy of mikro and its @mikrojs/* dependencies gets installed alongside the preview.

    json
    "peerDependenciesMeta": {
      "mikro": {"optional": true}
    }
  • NVS namespaces: if your driver stores state in NVS, use your own namespace. Namespace names starting with mik. are reserved for the runtime.

  • C vs C++ source files: Compile vendor C libraries (like sensor SDKs) as .c files. Mixing them into .cpp files can cause issues with -Werror due to C++ strictness.

  • DMA buffers: If your peripheral uses DMA (SPI displays, for example), allocate buffers with heap_caps_malloc(size, MALLOC_CAP_DMA). DMA requires internal SRAM; PSRAM will not work.

Mikro.js is built with AI assistance, code and docs alike. Read the AI disclosure.