Bridge Integration — Dart + JS + WASM Worker
How Python code runs inside a browser tab: three layers, zero servers.
Three-Layer Architecture
Dart (main thread) JS Bridge (main thread) WASM Worker
────────────────── ────────────────────── ────────────
@JS() interop bindings ──> window.DartMontyBridge ──> Web Worker
dart:js_interop postMessage + promise map @pydantic/monty WASM
<── <──
resolve pending promise postMessage response
Dart calls @JS()-annotated external functions that resolve to methods on
window.DartMontyBridge. The JS bridge serializes each call into a
postMessage to the Web Worker, tracked by a unique integer ID. The Worker
runs the Monty WASM interpreter and posts results back. The bridge resolves the
matching promise, which Dart awaits via .toDart.
Layer 1: Dart JS Interop Bindings
Dart declares external functions using dart:js_interop annotations that map
directly to window.DartMontyBridge.* methods:
@JS('DartMontyBridge.init')
external JSPromise<JSBoolean> _bridgeInit();
@JS('DartMontyBridge.start')
external JSPromise<JSString> _bridgeStart(
JSString code, [JSString? extFnsJson]);
@JS('DartMontyBridge.resume')
external JSPromise<JSString> _bridgeResume(JSString valueJson);
@JS('DartMontyBridge.resumeWithError')
external JSPromise<JSString> _bridgeResumeWithError(JSString errorJson);
@JS('DartMontyBridge.snapshot')
external JSPromise<JSString> _bridgeSnapshot();
@JS('DartMontyBridge.restore')
external JSPromise<JSString> _bridgeRestore(JSString dataBase64);
Every function returns JSPromise<JSString>. Dart converts to a native Future
with .toDart, then parses the JSON string result:
final resultJson = (await _bridgeStart(code.toJS, extFnsJson.toJS).toDart).toDart;
final result = jsonDecode(resultJson) as Map<String, dynamic>;
The dart_monty_wasm package wraps this pattern in WasmBindingsJs
(packages/dart_monty_wasm/lib/src/wasm_bindings_js.dart), which adapts
all calls to the WasmBindings abstract interface.
Layer 2: JS Bridge (dart_monty_core_bridge.js)
The bridge is a self-contained IIFE that creates the Worker and manages request/response correlation:
var worker = null;
var nextId = 1;
var pending = new Map(); // id -> { resolve, reject }
function callWorker(msg) {
return new Promise((resolve, reject) => {
const id = nextId++;
pending.set(id, { resolve, reject });
worker.postMessage({ ...msg, id });
});
}
Every bridge method follows the same pattern:
- Guard: if
!worker, return an error JSON string - Call
callWorker(msg)with a typed message - Serialize the response as JSON string
The Worker's onmessage handler resolves the matching pending promise:
worker.onmessage = (e) => {
const msg = e.data;
if (msg.type === "ready") { resolve(true); return; }
if (msg.id && pending.has(msg.id)) {
const { resolve: res } = pending.get(msg.id);
pending.delete(msg.id);
res(msg);
}
};
Bridge API Surface
| Method | Worker message type | Purpose |
|---|---|---|
init() |
(creates Worker) | Initialize WASM runtime |
run(code, limitsJson, scriptName) |
run |
One-shot execution |
start(code, extFnsJson, limitsJson, scriptName) |
start |
Begin iterative execution |
resume(valueJson) |
resume |
Continue with host function return value |
resumeWithError(errorJson) |
resumeWithError |
Continue with error |
snapshot() |
snapshot |
Capture interpreter state (base64) |
restore(dataBase64) |
restore |
Restore interpreter from snapshot |
discover() |
(synchronous) | Check if Worker is loaded |
dispose() |
dispose |
Terminate Worker |
Layer 3: WASM Worker
The Worker loads @pydantic/monty-wasm32-wasi and handles messages
dispatched from the bridge. Key state:
monty— the Monty interpreter instanceactiveSnapshot— set when execution pauses (pending), used for resume
On start, the Worker compiles and runs the code. If the code calls an
external function, execution pauses and the Worker posts a pending message
back with the function name and arguments. On resume, the Worker feeds the
return value back into the snapshot and continues.
Why a Worker?
Chrome's synchronous WebAssembly.compile() limit is 8 MB. The Monty WASM
module exceeds this. Inside a Worker, WebAssembly.compileStreaming works
asynchronously without this limit.
The Execution Loop
The core pattern for running Python that calls host functions:
Dart JS Bridge Worker
│ │ │
│ _bridgeStart(code, fns)│ │
│───────────────────────>│ postMessage(start) │
│ │─────────────────────>│
│ │ │ ── Python runs ──
│ │ │ ── hits dom_create() ──
│ │ postMessage(pending)│
│ │<─────────────────────│
│ { state: "pending", │ │
│ functionName: ... │ │
│ args: [...] } │ │
│<───────────────────────│ │
│ │ │
│ (Dart handles the call)│ │
│ │ │
│ _bridgeResume(value) │ │
│───────────────────────>│ postMessage(resume) │
│ │─────────────────────>│
│ │ │ ── Python resumes ──
│ │ │ ── hits dom_text() ──
│ │ postMessage(pending)│
│ │<─────────────────────│
│ │ │
│ (repeat until complete)│ │
│ │ │
│ { state: "complete", │ │
│ value: ..., ok: true}│ │
│<───────────────────────│ │
In Dart, this loop is implemented as:
Future<Map<String, dynamic>> _runWithHostFunctions(String code) async {
final extFnsJson = jsonEncode(_hostFunctions);
var result = _parse(
(await _bridgeStart(code.toJS, extFnsJson.toJS).toDart).toDart,
);
while (result['state'] == 'pending') {
final fnName = result['functionName'] as String;
final fnArgs = (result['args'] as List<dynamic>?) ?? [];
try {
final value = await _dispatch(fnName, fnArgs);
result = _parse(
(await _bridgeResume(jsonEncode(value).toJS).toDart).toDart,
);
} on Exception catch (e) {
result = _parse(
(await _bridgeResumeWithError(
jsonEncode(e.toString()).toJS).toDart).toDart,
);
}
}
return result;
}
Host Function Implementation Patterns
Pattern 1: Synchronous — return a value immediately
case 'dom_create':
final tag = args.isNotEmpty ? args[0] as String : 'div';
final el = document.createElement(tag);
return _store(el); // returns int handle
case 'now':
return DateTime.now().toIso8601String();
case 'json_dumps':
return jsonEncode(args[0]);
These compute a result synchronously and return it. The dispatch function
wraps the return value in jsonEncode() and feeds it to _bridgeResume().
Pattern 2: Blocking — await a Future, Python suspends
case 'dom_on_click':
final h = args[0] as int;
final el = _get(h);
if (el == null) return null;
final completer = Completer<String>();
el.addEventListener('click', (Event e) {
if (!completer.isCompleted) completer.complete('clicked');
}.toJS);
return await completer.future; // Python blocks here
The dispatch function is async — it can await Dart Futures. Python
execution is already suspended (the Worker is waiting). When the click
fires, the Completer resolves, the dispatch function returns, and
_bridgeResume() continues Python.
Pattern 3: Multi-button wait — dom_await_click_any
case 'dom_await_click_any':
final handles = (args[0] as List<dynamic>).cast<int>();
final completer = Completer<int>();
for (final h in handles) {
final el = _get(h);
if (el != null) {
el.addEventListener('click', (Event e) {
if (!completer.isCompleted) completer.complete(h);
}.toJS);
}
}
return await completer.future; // returns which handle was clicked
This enables while True event loops in Python — the script blocks on
dom_await_click_any([btn1, btn2, btn3]), gets back which button was
clicked, dispatches the action, and loops.
Pattern 4: Browser API — file upload
case 'upload_file':
final input = document.createElement('input') as HTMLInputElement;
input.type = 'file';
final completer = Completer<String?>();
input.addEventListener('change', (Event e) {
final file = input.files!.item(0)!;
final reader = FileReader();
reader.addEventListener('load', (Event ev) {
completer.complete((reader.result as JSString?)?.toDart ?? '');
}.toJS);
reader.readAsText(file);
}.toJS);
input.click();
return await completer.future;
Python calls upload_file() and blocks. Dart opens a file picker, reads
the selected file, and returns its text content. Python resumes with the
file data as a string.
Opaque Handle System
DOM elements cannot cross the WASM boundary. Instead, Dart maintains a handle map:
int _nextHandle = 1;
final Map<int, Element> _handles = {};
int _store(Element el) {
final h = _nextHandle++;
_handles[h] = el;
return h;
}
Element? _get(int h) => _handles[h];
void _free(int h) => _handles.remove(h);
Python works exclusively with integer handles:
btn = dom_create("button") # returns 1
dom_text(btn, "Click me") # passes handle 1
dom_append(app, btn) # passes handles
dom_on_click(btn) # blocks on handle 1
JSON Wire Format
All data between layers is JSON. Key message shapes:
Start request (bridge -> worker):
{ "type": "start", "code": "...", "extFns": ["dom_create", "log"], "id": 1 }
Pending response (worker -> bridge -> Dart):
{ "state": "pending", "functionName": "dom_create", "args": ["button"], "id": 1 }
Resume request (bridge -> worker):
{ "type": "resume", "value": 42, "id": 2 }
Complete response (worker -> bridge -> Dart):
{ "state": "complete", "ok": true, "value": "done", "id": 3 }
COOP/COEP Requirements
The web server must set these headers for SharedArrayBuffer support:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
The showcase uses coi-serviceworker.js to inject these headers when
the server doesn't set them natively.
Comparison with Native Path
| Concern | Web (bridge) | Native (FFI) |
|---|---|---|
| Transport | postMessage + JSON |
dart:ffi + C pointers |
| Threading | Web Worker | Dart Isolate |
| WASM compile | Async in Worker | N/A (native binary) |
| Memory | Structured clone | Shared memory + manual alloc/free |
| Snapshot format | base64 string | Uint8List via raw pointer |
| Latency per call | ~5-20ms (postMessage overhead) | <1ms (FFI is near-zero) |