@on-the-ground/io-nodejs
v0.2.0
Published
Node.js bridge for the Io language WASM runtime
Maintainers
Readme
io-nodejs-bridge
Node.js bridge for the Io language WASM runtime (io_browser.wasm).
Io is a small, prototype-based language with first-class actors, coroutines, and heap-based execution frames. This package lets you embed the Io VM in a Node.js process and call Io domain logic from JS adapters — including Express routes, NestJS controllers, or any other Node.js framework.
Each worker thread runs an independent WASM instance. Concurrent calls from multiple requests are queued and drained automatically, so the Node.js event loop is never starved.
What you can build
Concurrent domain logic without async/await
Io supports actors and coroutines at the language level, backed by heap-based
execution frames. There is no function coloring — every method is just a method,
regardless of whether it touches async I/O. Concurrency is expressed with @,
not with keywords scattered across the call stack.
result := SlowActor @compute(payload) // returns FutureProxy immediately
// other work here ...
result await println // blocks only when the value is neededUnder the hood, await suspends the entire WASM instance (status=2) and
returns control to the JS event loop. The execution state survives intact on the
heap — no stack to unwind, no async to propagate upward. When the awaited
Promise settles, Node.js calls io_resume_eval() and the Io world resumes
exactly where it left off.
Push Node.js infrastructure behind actor boundaries
ORM queries, HTTP calls, message queues — any npm package can be wrapped in an actor that speaks the Io bridge protocol. Domain code sends messages; it never sees a Promise or a callback.
// domain only sends messages
result := UserRepo @findById(42)
user := result await
// the actor handles the JS side
UserRepo findById := method(id,
jsfunction("return orm.users.findOne({ where: { id: arguments[0] } })") call(id)
)JS errors become Io exceptions
Rejected Promises cross the bridge as catchable Io exceptions. Adapters catch at the boundary and return domain values — the domain never deals with raw JS errors.
run := method(
e := try(jsfunction("return Promise.reject(new Error('db timeout'))") call await)
e catch(Exception, exception := "ERR:" .. e error)
exception
)Based on io.js
This bridge is derived from the official io.js browser loader bundled with
io_browser.wasm (located at browser/io.js in the Io WASM distribution).
io.js provides the canonical WASM import implementations (WASI shim, JS bridge
serialization protocol, IoProxy factory) that io_browser.wasm depends on.
Key differences from io.js
| | io.js (browser) | io-nodejs-bridge (this package) |
|---|---|---|
| WASM loading | fetch + instantiateStreaming | fs.readFileSync + instantiate |
| loadIo signature | loadIo() — URL hardcoded | loadIo(wasmPath, options) — path + worker pool config |
| clock_time_get | performance.now() * 1e6 (ms → ns) | process.hrtime.bigint() (native ns precision) |
| ioEval return | {status, output} sync only; async wired manually into DOM | Promise<{status, output}> for both sync and async paths |
| Exports | Browser globals (window.io, etc.) | ES Module named exports |
| Concurrency | Single VM, no isolation | Worker thread pool — each worker owns a WASM instance |
| REPL / DOM | Full REPL UI, keyboard history, boot() | Removed — pure VM bridge |
| getHandle null check | \|\| (falsy coercion) | ?? (nullish only — 0 and false handled correctly) |
Installation
npm install @on-the-ground/io-nodejsYou also need the io_browser.wasm binary. It is not bundled in this
package — supply your own path when calling loadIo.
Usage
import { loadIo, ioCall, ioEval, closeIo } from '@on-the-ground/io-nodejs';
// Initialize the worker pool (once per process)
await loadIo('/path/to/io_browser.wasm', {
workers: 4, // one WASM instance per worker thread (default: 1)
ioFiles: ['js-yielder.io'], // Io files evaluated at startup in each worker
});
// Call a Lobby method — dispatched to the next idle worker
const result = await ioCall('placeOrder', { userId: 1, items: [] });
// Evaluate raw Io code — also queued through the pool
const { status, output } = await ioEval('"hello" .. " world"');
console.log(output); // hello world
await closeIo();Using with Express
import express from 'express';
import { loadIo, ioCall, closeIo } from '@on-the-ground/io-nodejs';
const app = express();
app.use(express.json());
await loadIo('/path/to/io.wasm', { workers: 4, ioFiles: ['js-yielder.io'] });
app.post('/orders', async (req, res) => {
try {
const result = await ioCall('placeOrder', req.body);
res.json({ result });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
await closeIo();Multiple concurrent requests each get a Promise immediately. Workers process
them as they become available; the queue absorbs bursts up to maxQueue.
API
loadIo(wasmPath, options?): Promise<void>
Initializes the worker pool. Must be called once before any other API.
| Option | Type | Default | Description |
|---|---|---|---|
| workers | number | 1 | Worker threads to spawn. Each owns a WASM instance. |
| maxQueue | number | 1000 | Max pending jobs. Excess calls reject immediately. |
| ioFiles | string[] | [] | Io source files evaluated at startup in every worker. |
// minimal
await loadIo('/path/to/io.wasm');
// production
await loadIo('/path/to/io.wasm', {
workers: 4,
maxQueue: 500,
ioFiles: ['js-yielder.io', 'domain.io'],
});ioCall(method, args?): Promise<string>
Calls a Lobby method with a single structured argument object. Dispatched to the next idle worker; queued if all workers are busy.
Returns the last Io expression value with the "==> " prefix stripped.
Throws if the Io method raises an uncaught exception, or if the queue is full.
Both sides must follow the single-object args convention:
// JS caller (adapter layer)
const result = await ioCall('placeOrder', { userId: 1, items: [{ sku: 'A', qty: 2 }] });// Io implementer (domain layer)
Lobby placeOrder := method(args,
userId := args get("userId")
items := args get("items")
...
)Args are serialized through the bridge-buffer binary format, so nested objects, arrays, numbers, and strings all round-trip correctly.
closeIo(): void
Terminates all worker threads and drains the pool. Call this during process shutdown to avoid keeping the Node.js process alive.
process.on('SIGTERM', closeIo);
// or in tests / scripts:
test().catch(console.error).finally(closeIo);Pending jobs still in the queue will reject. In-flight jobs may not complete.
ioEval(code): Promise<{ status, output }>
Evaluates a string of Io code on any available worker. Queued if all workers are busy; rejects if the queue is full.
status—0= success,1= uncaught exception,-1= input too longoutput— captured stdout; the leading==>prefix and trailing newline are stripped automatically
Handles FRAME_STATE_AWAIT_JS (status=2) transparently: if Io suspends waiting
for a JS Promise, the returned Promise resolves only after the full async chain
completes and io_resume_eval finishes.
Concurrency model
loadIo({ workers: N })
→ N Worker threads, each with an independent WASM instance
ioCall() / ioEval()
→ enqueue job, return Promise immediately
→ idle worker picks job, processes it
→ resolves Promise on completion
back-pressure
→ queue.length >= maxQueue → reject("Queue full")Each WASM instance is single-threaded internally. Multiple workers provide parallelism across CPU cores. All workers load the same Io code, so any worker can serve any request — no routing or consistent hashing required as long as domain state is backed by external storage (the recommended pattern).
Actor async contract
The Io WASM runtime has two independent suspension mechanisms. Understanding how they interact is essential for writing correct actor methods that touch async JS work.
Mechanism 1 — Io coroutine scheduler (inside the VM)
@ sends a message asynchronously by creating a new coroutine inside the
WASM VM. The JS side is not involved at all. The Io scheduler decides which
coroutine runs next.
io_eval_input() running ──────────────────────────────────────
main coroutine: (actor @method(args)) await ← yields, waits for actor
actor coroutine: actor method(args) executing ← scheduler switches hereMechanism 2 — FRAME_STATE_AWAIT_JS (whole-VM suspend)
When Io awaits a JS Promise, the entire WASM instance pauses:
io_eval_input() returns status=2. Node.js holds control until the Promise
settles, then calls io_resume_eval() to continue.
io_eval_input() → status=2 (WASM paused)
Node.js: waiting for Promise...
5 s later: io_resume_eval() → WASM resumesWhy they must not be combined
If an actor uses await internally on a JS Promise, both mechanisms
activate at once:
io_eval_input() ─────────────────────────────────────────────────
main coroutine: (actor @sleep(ms)) await ← [1] yields for actor
actor coroutine: jsfunction(...) call(ms) await ← [2] FRAME_STATE_AWAIT_JS
io_eval_input() → status=2After 5 s, io_resume_eval() fires:
io_resume_eval() ────────────────────────────────────────────────
actor coroutine resumes → computes result → actor finishes
[3] scheduler must now switch back to main coroutine and continue it
← io_resume_eval() cannot perform this second scheduler hop → status=1io_resume_eval() resumes from exactly the point where FRAME_STATE_AWAIT_JS
was triggered (inside the actor coroutine). After the actor finishes and the
FutureProxy must propagate its result back to the suspended main coroutine, the
VM throws — status=1, no output.
The rule
Actor methods must not
awaita JS Promise directly. Return the Promise; let the caller'sawaittriggerFRAME_STATE_AWAIT_JSin the main coroutine.
// WRONG — await inside the actor coroutine
Actor sleep := method(ms,
jsfunction("return new Promise(r => setTimeout(r, arguments[0]))") call(ms) await
"done"
)
start := Date now
(Actor @sleep(5000)) await println // → status=1, no output// CORRECT — actor returns the Promise; caller awaits
Actor sleep := method(ms,
jsfunction("return new Promise(r => setTimeout(r, arguments[0]))") call(ms)
// ↑ Promise returned as-is — no await here
)
start := Date now
(Actor @sleep(5000)) await // ← FRAME_STATE_AWAIT_JS fires here,
// in the main coroutine, as io_eval_input
// can directly handle it
start secondsSinceNow println // → ~5.001When an actor method's last expression is a JS Promise (or any object that wraps
one), Io automatically chains the FutureProxy to wait for that Promise before
resolving. The await in the caller triggers FRAME_STATE_AWAIT_JS at the
correct level — the main coroutine — so io_eval_input() / io_resume_eval()
can manage the full lifecycle without the extra scheduler hop.
Bridge serialization
Values crossing the JS↔Io boundary are serialized via a shared 64 KB buffer
using the binary protocol defined in Bridge.md (part of the Io WASM
distribution). Supported types:
| JS type | Wire type |
|---|---|
| null | TYPE_NIL |
| undefined | TYPE_UNDEFINED |
| boolean | TYPE_TRUE / TYPE_FALSE |
| number | TYPE_NUMBER (float64) |
| string | TYPE_STRING |
| bigint | TYPE_BIGINT (decimal string) |
| Array | TYPE_ARRAY |
| Map | TYPE_OBJECT |
| Set | TYPE_ARRAY |
| TypedArray | TYPE_TYPEDARRAY |
| Promise / thenable | TYPE_FUTURE → IoFuture |
| other objects | TYPE_JSREF (handle) |
Cyclic structures throw. Symbol throws.
License
See the Io language project for the io_browser.wasm license.
This bridge code is MIT.
