How JSAR Implements Event Communication Between Processes

JSAR implements event communication between processes through a native-event bridge that maps numeric event types to JavaScript Event objects, enabling bidirectional RPC and document lifecycle messaging between the JavaScript runtime and native host processes.

The JSAR (JavaScript Augmented Reality) runtime, maintained at m-creativelab/jsar-runtime, requires robust event communication between processes to coordinate between JavaScript execution contexts and native engines like Unity or Unreal Engine. This article examines the three-layer architecture that handles these cross-process events, from the native C++/Rust bindings to the high-level JavaScript APIs.

The Native-Event Bridge Architecture

JSAR's event communication relies on a native-event bridge that wraps a C++/Rust channel exposed through the transmute:messaging binding. The architecture separates concerns across three distinct layers.

Native Event Target Layer

The foundation resides in lib/bindings/messaging.ts (lines 1-11) and the TypeScript declarations in types/transmute-private.d.ts (lines 185-207). This layer wraps the native transmute:messaging class and exposes dispatchEvent and dispose methods. It defines numeric EventTypes for categorizing messages as RPC requests, document requests, or document lifecycle events.

const { NativeEventTarget } = process._linkedBinding('transmute:messaging');
const nativeEventTarget = new NativeEventTarget(onNativeEventListener);

JavaScript Event Dispatcher Layer

A thin wrapper around the native target translates raw numeric events into high-level DOM-like Event objects. The onNativeEventListener callback (lines 70-102 in messaging.ts) receives native events, switches on the eventType parameter, and dispatches parsed events onto a standard JavaScript EventTarget.

For RPC responses, the listener retrieves the pending callback from RpcRequestWaitlist (lines 77-81) and resolves the promise. For document requests, it constructs a DocumentRequestEvent and dispatches it (lines 92-94).

Process-Specific Adapters

The system adapts to different execution contexts:

  • Main thread: Exposes addEventListener, addDocumentRequestListener, makeRpcCall, and reportDocumentEvent (lines 13-30 in messaging.ts).
  • WebWorker: The entry script jsar-webworkers-entry.js (bootstrapped in lib/webworkers/entry.ts, lines 31-55) creates a WorkerContext that forwards postMessage-style events while maintaining access to the same native messaging channel.

Event Communication APIs in the Main Thread

The main thread API abstracts the native bridge into Promise-based methods and standard event listeners.

RPC Calls with makeRpcCall

The makeRpcCall(method, args) function (implemented in lib/bindings/messaging.ts, lines 91-130) initiates cross-process RPC. It generates a unique request ID via dispatchEventToHost, stores the resolver in RpcRequestWaitlist, and returns a Promise that settles when the native host sends back a response event.

import { makeRpcCall } from './lib/bindings/messaging';

makeRpcCall('getSessionInfo', [])
  .then(info => console.log('Session info:', info))
  .catch(err => console.error('RPC error:', err));

Handling Document Requests

addDocumentRequestListener(cb) (lines 26-28) registers listeners for DocumentRequestEvent.Name, allowing JavaScript to respond when the host process requests document loads. The native side fires events that the dispatcher transforms into DocumentRequestEvent instances (lines 92-94).

Reporting Document Lifecycle Events

reportDocumentEvent(documentId, eventName) (lines 71-78) notifies the host of document lifecycle changes (load, error, etc.). It maps string event names to numeric DocumentEventTypes defined in the native class and dispatches through nativeEventTarget.

WebWorker Event Communication

WebWorkers in JSAR share the same native bridge, enabling consistent event communication between processes across threads.

Worker Entry Point Configuration

The worker bootstrap in lib/webworkers/entry.ts (lines 31-55) executes inside the Node.js WorkerThread. It sets up global postMessage forwarding to the parent port (lines 10-14), injects the __WorkerImpl constructor for nested worker creation (lines 21-27), and instantiates a WorkerContext bound to the native transmute:dom binding (lines 32-34).

Object.defineProperty(globalThis, 'postMessage', { 
  value: parentPort.postMessage.bind(parentPort) 
});
const workerContext = new WorkerContext(workerRequest.baseURI, workerRequest?.options);

Shared Native Bridge Access

Inside workers, makeRpcCall and document event APIs function identically to the main thread because WorkerContext maintains a connection to the same NativeEventTarget. The parentPort forwards messages from the host thread to the WorkerContext, which then dispatches events through the standard bridge.

Implementation Examples

Sending RPC Requests from JavaScript

This pattern initiates host communication and handles the asynchronous response through the native bridge:

import { makeRpcCall } from './lib/bindings/messaging';

// Call a native method named "loadDocument"
makeRpcCall('loadDocument', [{ url: 'https://example.com/model.glb', documentId: 42 }])
  .then(data => console.log('Document loaded', data))
  .catch(err => console.error('Load failed:', err));

The implementation stores the resolver in RpcRequestWaitlist (lines 91-130) and invokes it when onNativeEventListener receives the matching RpcResponse event (lines 77-81).

Handling Document Requests in Workers

Workers can listen for host-initiated document requests and report completion:

import { addDocumentRequestListener, reportDocumentEvent } from './lib/bindings/messaging';

addDocumentRequestListener(async (event) => {
  const resp = await fetch(event.url);
  const blob = await resp.blob();
  // Process the blob...
  
  // Notify host the document has loaded
  reportDocumentEvent(event.documentId, 'load');
});

addDocumentRequestListener hooks into DocumentRequestEvent.Name (lines 26-28), while reportDocumentEvent dispatches to the native target (lines 71-78).

Spawning Workers with Event Support

Create workers that maintain full event bridge access:

import { WorkerImpl } from './lib/webworkers/worker';

const worker = new WorkerImpl('worker-script.js');
worker.onmessage = ev => console.log('Worker says:', ev.data);
worker.start();

WorkerImpl.start() resolves the entry script path (lines 52-55 in worker.ts) and spawns the thread (lines 52-64), ensuring the worker initializes with the jsar-webworkers-entry.js bridge setup.

Summary

  • JSAR implements event communication between processes through a native-event bridge wrapping the transmute:messaging binding.
  • The architecture separates concerns into Native Event Target, JavaScript Event Dispatcher, and Process-Specific Adapter layers.
  • RPC calls use a waitlist pattern where makeRpcCall generates request IDs and onNativeEventListener resolves pending promises via RpcRequestWaitlist.
  • Document lifecycle events travel through standardized EventTarget interfaces, with addDocumentRequestListener and reportDocumentEvent providing high-level abstractions.
  • WebWorkers share the same native bridge through WorkerContext initialization in lib/webworkers/entry.ts, enabling consistent cross-process messaging across execution contexts.

Frequently Asked Questions

How does JSAR route RPC responses back to the correct JavaScript caller?

When makeRpcCall dispatches an RPC request to the host, it stores the promise resolver in a RpcRequestWaitlist Map keyed by request ID (lines 91-130 in messaging.ts). When the native host responds, onNativeEventListener extracts the request ID from the event, retrieves the corresponding callback from the waitlist (lines 77-81), and invokes it with the response payload, thereby resolving the original Promise.

Can WebWorkers access the same native event bridge as the main thread?

Yes. The worker entry script in lib/webworkers/entry.ts (lines 32-34) creates a WorkerContext bound to the transmute:dom native binding, which shares the same underlying transmute:messaging channel. This allows workers to call makeRpcCall, reportDocumentEvent, and other bridge APIs exactly as the main thread does.

What event types does the NativeEventTarget support?

According to types/transmute-private.d.ts (lines 185-207) and the implementation in lib/bindings/messaging.ts (lines 7-12), the native bridge supports numeric event types including RpcRequest, RpcResponse, DocumentRequest, and various DocumentEventTypes for lifecycle signaling. These constants map to symbolic names in the JavaScript EventType enum.

How does JSAR report document lifecycle events to the host process?

JavaScript code calls reportDocumentEvent(documentId, eventName), which looks up the numeric DocumentEventTypes code for the provided string name (e.g., 'load', 'error'), then dispatches a structured event through nativeEventTarget.dispatchEvent (lines 71-78 in messaging.ts). The native host receives this as a DocumentEvent type and processes it accordingly.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →