Making the engine isomorphicmeaning the exact same codebase runs in Node.js
and the browseris the absolute best architectural move you can make right now.
It allows you to use Fastify as your edge gateway, while easily embedding the
same VM directly into a browser-based dashboard or client-side agent for local
execution.
The challenge is that Node.js and modern browsers handle multi-threading
differently: Node uses worker_threads and parentPort, while browsers use the
Web Workers API (self.postMessage). Furthermore, browsers place strict
security restrictions on SharedArrayBuffer.
To solve this without cluttering the code with if (isNode) checks, we
transition to an Isomorphic Core pattern.
1. The Pure Isomorphic Core
Extract the Opcodes, analyze, compile, and executeVMWorker functions into a
single, environment-agnostic ES Module (e.g., engine.mjs). This file must have
zero require() calls and zero references to window or process.
/ engine.mjs
export const Opcodes = { ... };
export class StateBuffer {
constructor(sab) { this.int32 = new Int32Array(sab); }
writeState(status, value = 0) {
Atomics.add(this.int32, 0, 1);
Atomics.store(this.int32, 1, status);
Atomics.store(this.int32, 2, value);
Atomics.add(this.int32, 0, 1);
}
// ... readState()
}
export function compile(ast, bytecode = []) { ... }
export function executeVMWorker(mainBytecode, stateBuffer, gasLimit,
onStateChange, onComplete) {
// Same logic as before, but instead of hardcoding parentPort or
self.postMessage,
// we pass in callback functions.
const handleThrow = (errorMsg) => {
if (tryStack.length === 0) {
onComplete({ type: 'uncaught_error', data: errorMsg });
stateBuffer.writeState(4, 0);
return false;
}
// ... try/catch unwind logic ...
};
// ... main while loop ...
// At the end:
onComplete({ type: 'completed', data: dataStack.pop() });
stateBuffer.writeState(2, 0);
}
2. The Tiny Adapters
With the core isolated, you write two 10-line adapter files. The engine
remains pure, and the adapters handle the specific environment's threading
API.
For Node.js (node_worker.mjs):
import { workerData, parentPort } from 'worker_threads';
import { executeVMWorker, StateBuffer } from './engine.mjs';
const stateBuffer = new StateBuffer(workerData.sab);
executeVMWorker(
workerData.bytecode,
stateBuffer,
workerData.gasLimit,
(msg) => parentPort.postMessage(msg), // State changes
(msg) => parentPort.postMessage(msg) // Completion
);
For the Browser (web_worker.mjs):
import { executeVMWorker, StateBuffer } from './engine.mjs';
self.onmessage = (event) => {
const { bytecode, sab, gasLimit } = event.data;
const stateBuffer = new StateBuffer(sab);
executeVMWorker(
bytecode,
stateBuffer,
gasLimit,
(msg) => self.postMessage(msg),
(msg) => self.postMessage(msg)
);
};
3. The Browser Security Requirement (SharedArrayBuffer)
In 2018, due to the Spectre CPU vulnerability, all major browsers disabled
SharedArrayBuffer. It was re-enabled recently, but only for isolated origins.
To use your lock-free ring buffer in a browser, the web server delivering your
HTML (whether it's Fastify, Nginx, or a local dev server) must attach these
two exact HTTP headers to the response:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
If you serve an HTML page without these headers, the browser will throw a
ReferenceError: SharedArrayBuffer is not defined the moment your JavaScript
tries to allocate the 16-byte state buffer.
If you are serving the frontend dashboard from your Fastify gateway, you can
enable this globally with one line using the @fastify/helmet plugin:
fastify.register(require('@fastify/helmet'), { crossOriginEmbedderPolicy:
true, crossOriginOpenerPolicy: true });
With this architecture, the exact same JSON-Lisp AST can be routed to a
Node.js worker pool on your HAUKI-X86 backend, or sent over a WebSocket to be
executed locally by the client's Web Worker using their own CPU.