Node.js Addon
The Node.js addon (packages/@mikrojs/native/addon/) allows the Mikro.js runtime to run on desktop as a native Node.js module. This powers the CLI's mikro dev command, the simulator, and host-side testing.
Architecture
Node.js process
┌────────────────────────────────────────┐
│ MikroRuntime (TypeScript) │
│ │ │
│ ├── evalModule() / evalScript() │
│ ├── loopOnce() │
│ ├── postMessage() ──┐ │
│ └── drainMessages() │ │
│ │ │
│ RuntimeWrap (C++, Node-API) │
│ │ │ │
│ ├── MIKRuntime │ │
│ └── HostBridge ◄────┘ │
│ │ │
│ └── native:host (C module) │
└────────────────────────────────────────┘MikroRuntime class
The public TypeScript interface:
class MikroRuntime {
// Execute a module from the filesystem
evalModule(filename: string, isMain?: boolean): void
// Execute a module from a source string
evalModuleContent(filename: string, content: string): void
// Execute a script (not a module) and return the result
evalScript(code: string): string | undefined
// Run a single loop iteration synchronously
loopOnce(): number
// Microseconds until the loop next has work (0 = now, -1 = nothing scheduled)
nextWakeUs(): number
// Clean up all resources
dispose(): void
// Register a JS source string as a virtual module
registerModuleSource(name: string, source: string): void
// Read pending messages from QuickJS → Node.js
drainMessages(): HostMessage[]
// Send a message from Node.js → QuickJS
postMessage(type: string, data: string): void
// Set a preprocessor for module source (e.g., strip TypeScript types)
setPreprocessor(fn: (filename: string, source: string) => string | undefined): void
// Set an RPC handler for sync/async calls from QuickJS
setRpcHandler(handler: (method: string, argsJson: string) => string | Promise<string>): void
}Host bridge
The host bridge enables bidirectional communication between the QuickJS runtime and the Node.js host. Inside QuickJS, the native:host native module provides:
import {send, onMessage, call, callAsync} from 'native:host'
// Send a message to the Node.js host
send('log', JSON.stringify({level: 'info', msg: 'hello'}))
// Receive messages from the host
onMessage((type, data) => {
console.log('Got message: %s %s', type, data)
})
// Synchronous RPC call to the host
const result = call('readFile', JSON.stringify({path: '/tmp/foo'}))
// Async RPC call (returns a Promise)
const data = await callAsync('fetch', JSON.stringify({url: '...'}))On the Node.js side:
const rt = new MikroRuntime()
// Send a message into QuickJS
rt.postMessage('config', JSON.stringify({debug: true}))
// Read messages from QuickJS
const messages = rt.drainMessages()
// → [{type: 'log', data: '{"level":"info","msg":"hello"}'}]
// Handle RPC calls from QuickJS
rt.setRpcHandler((method, argsJson) => {
if (method === 'readFile') {
return JSON.stringify(fs.readFileSync(JSON.parse(argsJson).path, 'utf8'))
}
})Messages are string pairs (type + data). The bridge stores outbound messages in a vector, drained by the host after each loop iteration. Inbound messages are delivered on the next loop tick.
Preprocessor
The preprocessor hook transforms module source before QuickJS compiles it:
rt.setPreprocessor((filename, source) => {
if (filename.endsWith('.ts')) {
return stripTypes(source) // Remove TypeScript syntax
}
return undefined // No transformation
})This is called from the C module loader via a callback. The preprocessor receives the filename and source, and returns either transformed source or undefined (no change). The CLI uses this to enable direct .ts file execution during development.
Virtual modules
Virtual modules let the host override any module with a source string:
rt.registerModuleSource(
'native:mikro/sleep',
`
export function deepSleep() { /* mock */ }
export function lightSleep() { /* mock */ }
`,
)Virtual modules have the highest resolution priority (checked before native modules and builtins). This is how the simulator provides mock hardware APIs on desktop without compiling any ESP-IDF code.