wasm-app-worker-host.mjs raw

   1  // wasm-app-worker-host.mjs — Worker bootstrap for app.wasm.
   2  // Runs in a Worker; the main page thread (wasm-host.mjs) is the supervisor.
   3  //
   4  // Bridge proxy protocol (Worker → supervisor):
   5  //   { type: 'dom-sync', op, args?, sab }      — sync DOM call, Atomics.wait on sab
   6  //   { type: 'localstorage-sync', op, args, sab } — sync localStorage read
   7  //   { type: 'dom', op, args? }                — fire-and-forget DOM call
   8  //   { type: 'localstorage', op, args }         — fire-and-forget localStorage write/remove
   9  //   { type: 'dom-register', op, cbID }         — register event callback with supervisor
  10  //   { type: 'signer-async', op, args?, cbID, cbType } — async signer call
  11  //
  12  // Callback protocol (supervisor → Worker):
  13  //   { type: '__cb0', id }           — void callback
  14  //   { type: '__cbs', id, str }      — string callback
  15  //   { type: '__cbb', id, val }      — bool callback
  16  //   { type: '__cbdata', id, bytes } — bytes callback (ArrayBuffer)
  17  //
  18  // Sync SAB layout (Int32Array view):
  19  //   [0]: done flag (0=waiting, 1=done)
  20  //   [1]: int32 result
  21  //   [2]: string byte length (0 if no string result)
  22  //   bytes[12..]: string result bytes
  23  
  24  import {
  25    makeCoreHelpers, makeWasi, makeCommonBridge,
  26    computeBuildHash, createSpawnedWorker,
  27  } from './bridge-common.mjs';
  28  
  29  let mem, xp;
  30  const _sabTable = new Map();
  31  const _sabSeqRef = { value: 0 };
  32  const _spawnedWorkerChans = new Map();
  33  const _wasmUrlRef = { value: null };
  34  const _buildHashRef = { value: null };
  35  
  36  const h = makeCoreHelpers(() => mem, () => xp);
  37  const { readStr, readBytes, writeStr, writeI32, writeBytes, cb0, cbs, cbb, cbdata } = h;
  38  
  39  // ── Sync proxy helper ──────────────────────────────────────────────────────
  40  
  41  // Allocate a reusable sync SAB (12-byte header + 512KB string buffer).
  42  // Rendered note HTML can exceed 8KB for long posts; 512KB covers all cases.
  43  const _syncSab = new SharedArrayBuffer(12 + 512 * 1024);
  44  const _syncI32 = new Int32Array(_syncSab);
  45  
  46  function syncCallInt(type, op, args) {
  47    _syncI32[0] = 0;
  48    self.postMessage({ type, op, args, sab: _syncSab });
  49    Atomics.wait(_syncI32, 0, 0);
  50    return _syncI32[1];
  51  }
  52  
  53  function syncCallStr(type, op, args) {
  54    _syncI32[0] = 0;
  55    self.postMessage({ type, op, args, sab: _syncSab });
  56    Atomics.wait(_syncI32, 0, 0);
  57    const len = _syncI32[2];
  58    if (len <= 0) return '';
  59    // Copy from SAB to regular ArrayBuffer before decoding:
  60    // Firefox prohibits TextDecoder.decode on SAB-backed Uint8Array views.
  61    const copy = new Uint8Array(len);
  62    copy.set(new Uint8Array(_syncSab, 12, len));
  63    return new TextDecoder().decode(copy);
  64  }
  65  
  66  // ── Bridge ─────────────────────────────────────────────────────────────────
  67  
  68  // Helpers for the various bridge patterns
  69  function ffdom(op, args) { self.postMessage({ type: 'dom', op, args }); }
  70  function syncDomInt(op, args) { return syncCallInt('dom-sync', op, args); }
  71  function syncDomStr(op, args, rPtrAddr, rLenAddr) {
  72    const str = syncCallStr('dom-sync-str', op, args);
  73    const [ptr, len] = writeStr(str);
  74    writeI32(rPtrAddr, ptr); writeI32(rLenAddr, len);
  75  }
  76  function cbdom(op, args, cbID, extra) {
  77    self.postMessage({ type: 'dom-cb', op, args, cbID, ...extra });
  78  }
  79  function sigAsync(op, args, cbID, cbType) {
  80    self.postMessage({ type: 'signer-async', op, args, cbID, cbType });
  81  }
  82  
  83  // relayproxy: forward JSON-array messages to the relay-proxy worker via
  84  // the supervisor. Inbound messages from the worker arrive as
  85  // '__relayproxy_msg' and dispatch to the cbID registered by
  86  // relayproxy_on_message.
  87  let _relayProxyMsgCBID = 0;
  88  function relayProxySend(msg) {
  89    self.postMessage({ type: 'relayproxy-send', msg });
  90  }
  91  
  92  // Domain worker message cbIDs: set by X_on_message bridge functions.
  93  let _profileMsgCBID = 0, _feedMsgCBID = 0, _mlsMsgCBID = 0, _notifMsgCBID = 0;
  94  
  95  const bridge = {
  96    // ── DOM sync int ─────────────────────────────────────────────────────────
  97    dom_body()                                  { return syncDomInt('body', null); },
  98    dom_create_element(ptr, len)                { return syncDomInt('create_element', [readStr(ptr, len)]); },
  99    dom_create_text_node(ptr, len)              { return syncDomInt('create_text_node', [readStr(ptr, len)]); },
 100    dom_get_element_by_id(ptr, len)             { return syncDomInt('get_element_by_id', [readStr(ptr, len)]); },
 101    dom_query_selector(ptr, len)                { return syncDomInt('query_selector', [readStr(ptr, len)]); },
 102    dom_query_selector_from(root, ptr, len)     { return syncDomInt('query_selector_from', [root, readStr(ptr, len)]); },
 103    dom_prefers_dark()                          { return syncDomInt('prefers_dark', null); },
 104    dom_first_child(el)                         { return syncDomInt('first_child', [el]); },
 105    dom_first_element_child(el)                 { return syncDomInt('first_element_child', [el]); },
 106    dom_next_sibling(el)                        { return syncDomInt('next_sibling', [el]); },
 107    dom_bounding_client_left(el)                { return syncDomInt('bounding_client_left', [el]); },
 108    dom_get_viewport_height()                   { return syncDomInt('get_viewport_height', null); },
 109    dom_get_viewport_width()                    { return syncDomInt('get_viewport_width', null); },
 110    // NowSeconds returns int64 — WASM expects BigInt return value
 111    dom_now_seconds()                           { return BigInt(syncDomInt('now_seconds', null)); },
 112    dom_timezone_offset_seconds()               { return syncDomInt('timezone_offset_seconds', null); },
 113    dom_confirm(ptr, len)                       { return syncDomInt('confirm', [readStr(ptr, len)]); },
 114    dom_set_timeout(cbID, ms)                   { return syncDomInt('set_timeout', [ms, cbID]); },
 115  
 116    // ── DOM sync string (outptrs) ────────────────────────────────────────────
 117    dom_hostname(rPtrAddr, rLenAddr)            { syncDomStr('hostname', null, rPtrAddr, rLenAddr); },
 118    dom_port(rPtrAddr, rLenAddr)                { syncDomStr('port', null, rPtrAddr, rLenAddr); },
 119    dom_user_agent(rPtrAddr, rLenAddr)          { syncDomStr('user_agent', null, rPtrAddr, rLenAddr); },
 120    dom_get_path(rPtrAddr, rLenAddr)            { syncDomStr('get_path', null, rPtrAddr, rLenAddr); },
 121    dom_get_hash(rPtrAddr, rLenAddr)            { syncDomStr('get_hash', null, rPtrAddr, rLenAddr); },
 122    dom_get_property(el, pPtr, pLen, pCap, rPtrAddr, rLenAddr) {
 123      syncDomStr('get_property', [el, readStr(pPtr, pLen)], rPtrAddr, rLenAddr);
 124    },
 125    dom_get_attribute(el, nPtr, nLen, nCap, rPtrAddr, rLenAddr) {
 126      syncDomStr('get_attribute', [el, readStr(nPtr, nLen)], rPtrAddr, rLenAddr);
 127    },
 128  
 129    // ── DOM fire-and-forget ──────────────────────────────────────────────────
 130    dom_console_log(ptr, len)                   { ffdom('console_log', [readStr(ptr, len)]); },
 131    dom_append_child(parent, child)             { ffdom('append_child', [parent, child]); },
 132    dom_remove_child(parent, child)             { ffdom('remove_child', [parent, child]); },
 133    dom_remove(el)                              { ffdom('remove', [el]); },
 134    dom_insert_before(parent, n, ref)           { ffdom('insert_before', [parent, n, ref]); },
 135    dom_set_attribute(el, nPtr, nLen, nCap, vPtr, vLen) {
 136      ffdom('set_attribute', [el, readStr(nPtr, nLen), readStr(vPtr, vLen)]);
 137    },
 138    dom_remove_attribute(el, nPtr, nLen, nCap)  { ffdom('remove_attribute', [el, readStr(nPtr, nLen)]); },
 139    dom_set_text_content(el, ptr, len)          { ffdom('set_text_content', [el, readStr(ptr, len)]); },
 140    dom_set_inner_html(el, ptr, len)            { ffdom('set_inner_html', [el, readStr(ptr, len)]); },
 141    dom_set_style(el, pPtr, pLen, pCap, vPtr, vLen) {
 142      ffdom('set_style', [el, readStr(pPtr, pLen), readStr(vPtr, vLen)]);
 143    },
 144    dom_set_property(el, pPtr, pLen, pCap, vPtr, vLen) {
 145      ffdom('set_property', [el, readStr(pPtr, pLen), readStr(vPtr, vLen)]);
 146    },
 147    dom_add_class(el, ptr, len)                 { ffdom('add_class', [el, readStr(ptr, len)]); },
 148    dom_remove_class(el, ptr, len)              { ffdom('remove_class', [el, readStr(ptr, len)]); },
 149    dom_focus(el)                               { ffdom('focus', [el]); },
 150    dom_release_element(el)                     { ffdom('release_element', [el]); },
 151    dom_release_all(ptr, len)                   { ffdom('release_all', [Array.from(new Int32Array(mem.buffer, ptr, len))]); },
 152    dom_release_children(el)                    { ffdom('release_children', [el]); },
 153    dom_push_state(ptr, len)                    { ffdom('push_state', [readStr(ptr, len)]); },
 154    dom_replace_state(ptr, len)                 { ffdom('replace_state', [readStr(ptr, len)]); },
 155    dom_back()                                  { ffdom('back', null); },
 156    dom_location_reload()                       { ffdom('location_reload', null); },
 157    dom_location_assign(ptr, len)               { ffdom('location_assign', [readStr(ptr, len)]); },
 158    dom_hard_refresh()                          { ffdom('hard_refresh', null); },
 159    dom_clear_timeout(id)                       { ffdom('clear_timeout', [id]); },
 160    dom_clear_storage_prefix(ptr, len)          { ffdom('clear_storage_prefix', [readStr(ptr, len)]); },
 161    dom_idb_set_enc_key(ptr, len)               { ffdom('idb_set_enc_key', [readStr(ptr, len)]); },
 162    dom_idb_put(sPtr, sLen, sC, kPtr, kLen, kC, vPtr, vLen) {
 163      ffdom('idb_put', [readStr(sPtr, sLen), readStr(kPtr, kLen), readStr(vPtr, vLen)]);
 164    },
 165    dom_post_to_sw(ptr, len)                    { ffdom('post_to_sw', [readStr(ptr, len)]); },
 166    dom_download_text(fnPtr, fnLen, fnC, cPtr, cLen, cC, mPtr, mLen) {
 167      ffdom('download_text', [readStr(fnPtr, fnLen), readStr(cPtr, cLen), readStr(mPtr, mLen)]);
 168    },
 169    dom_insert_mention_chip(el, charCount, npubPtr, npubLen, npubC, nPtr, nLen, nC, picPtr, picLen) {
 170      ffdom('insert_mention_chip', [el, charCount, readStr(npubPtr, npubLen), readStr(nPtr, nLen), readStr(picPtr, picLen)]);
 171    },
 172  
 173    // ── DOM async callbacks ──────────────────────────────────────────────────
 174    dom_add_event_listener(el, evPtr, evLen, evC, cbID) {
 175      cbdom('add_event_listener', [el, readStr(evPtr, evLen)], cbID);
 176    },
 177    dom_add_self_event_listener(el, evPtr, evLen, evC, cbID) {
 178      cbdom('add_self_event_listener', [el, readStr(evPtr, evLen)], cbID);
 179    },
 180    dom_add_enter_key_listener(el, cbID)        { cbdom('add_enter_key_listener', [el], cbID); },
 181    dom_remove_event_listener(el, evPtr, evLen, evC, cbID) {
 182      cbdom('remove_event_listener', [el, readStr(evPtr, evLen)], cbID);
 183    },
 184    dom_on_pop_state(cbID)                      { cbdom('on_pop_state', null, cbID); },
 185    dom_intercept_internal_links(cbID)          { cbdom('intercept_internal_links', null, cbID); },
 186    dom_on_sw_message(cbID)                     { cbdom('on_sw_message', null, cbID); },
 187    dom_request_animation_frame(cbID)           { cbdom('request_animation_frame', null, cbID); },
 188    dom_observe_resize(el, cbID)                { cbdom('observe_resize', [el], cbID); },
 189    dom_unobserve_resize(el)                    { ffdom('unobserve_resize', [el]); },
 190    dom_on_pull_refresh(el, indEl, cbID)        { cbdom('on_pull_refresh', [el, indEl], cbID); },
 191    dom_on_paste_image(el, cbID)                { cbdom('on_paste_image', [el], cbID); },
 192    dom_on_drop_image(el, cbID)                 { cbdom('on_drop_image', [el], cbID); },
 193    dom_pick_file_text(aPtr, aLen, aC, cbID)    { cbdom('pick_file_text', [readStr(aPtr, aLen)], cbID); },
 194    dom_pick_file_base64(aPtr, aLen, aC, cbID)  { cbdom('pick_file_base64', [readStr(aPtr, aLen)], cbID); },
 195    dom_fetch_text(uPtr, uLen, uC, cbID)        { cbdom('fetch_text', [readStr(uPtr, uLen)], cbID); },
 196    dom_fetch_relay_info(uPtr, uLen, uC, cbID)  { cbdom('fetch_relay_info', [readStr(uPtr, uLen)], cbID); },
 197    dom_fetch_put_blob_base64(uPtr, uLen, uC, bPtr, bLen, bC, ctPtr, ctLen, ctC, ahPtr, ahLen, ahC, cbID) {
 198      cbdom('fetch_put_blob_base64', [readStr(uPtr, uLen), readStr(bPtr, bLen), readStr(ctPtr, ctLen), readStr(ahPtr, ahLen)], cbID);
 199    },
 200    dom_read_clipboard(cbID)                    { cbdom('read_clipboard', null, cbID); },
 201    dom_write_clipboard(ptr, len, cap, cbID)    { cbdom('write_clipboard', [readStr(ptr, len)], cbID); },
 202    dom_write_primary_selection(ptr, len, cap)  { cbdom('write_primary_selection', [readStr(ptr, len)], 0); },
 203    dom_copy_image_to_clipboard(el, cbID)       { cbdom('copy_image_to_clipboard', [el], cbID); },
 204    dom_window_open(ptr, len, cap)              { cbdom('window_open', [readStr(ptr, len)], 0); },
 205    dom_encode_uri_component(ptr, len, cap, rPtr, rLen) {
 206      const s = encodeURIComponent(readStr(ptr, len));
 207      const [p, l] = writeStr(s); writeI32(rPtr, p); writeI32(rLen, l);
 208    },
 209    dom_idb_get(sPtr, sLen, sC, kPtr, kLen, kC, cbID) {
 210      cbdom('idb_get', [readStr(sPtr, sLen), readStr(kPtr, kLen)], cbID);
 211    },
 212    dom_idb_get_all(sPtr, sLen, sC, eachCB, doneCB) {
 213      cbdom('idb_get_all', [readStr(sPtr, sLen)], eachCB, { doneCB });
 214    },
 215    dom_get_bounding_rect(el, cbID)             { cbdom('get_bounding_rect', [el], cbID); },
 216    dom_on_keydown(el, cbID)                    { cbdom('on_keydown', [el], cbID); },
 217  
 218    // ── Markdown ──────────────────────────────────────────────────────────────
 219    markdown_render(tPtr, tLen, tCap, rPtrAddr, rLenAddr) {
 220      // markdown_render is sync (pure computation in runtime); proxy to supervisor
 221      const html = syncCallStr('markdown', 'render', [readStr(tPtr, tLen)]);
 222      const [ptr, len] = writeStr(html);
 223      writeI32(rPtrAddr, ptr); writeI32(rLenAddr, len);
 224    },
 225  
 226    // ── localStorage ─────────────────────────────────────────────────────────
 227    localstorage_get_item(kPtr, kLen, kC, rPtrAddr, rLenAddr) {
 228      const str = syncCallStr('localstorage-sync', 'get_item', [readStr(kPtr, kLen)]);
 229      const [ptr, len] = writeStr(str);
 230      writeI32(rPtrAddr, ptr); writeI32(rLenAddr, len);
 231    },
 232    localstorage_set_item(kPtr, kLen, kC, vPtr, vLen) {
 233      self.postMessage({ type: 'localstorage', op: 'set_item', args: [readStr(kPtr, kLen), readStr(vPtr, vLen)] });
 234    },
 235    localstorage_remove_item(kPtr, kLen) {
 236      self.postMessage({ type: 'localstorage', op: 'remove_item', args: [readStr(kPtr, kLen)] });
 237    },
 238  
 239    // ── WebSocket ─────────────────────────────────────────────────────────────
 240    ws_dial(uPtr, uLen, uC, onMsgCB, onOpenCB, onCloseCB, onErrCB) {
 241      return syncCallInt('ws', 'dial', [readStr(uPtr, uLen), onMsgCB, onOpenCB, onCloseCB, onErrCB]);
 242    },
 243    ws_send(conn, ptr, len, cap) {
 244      return syncCallInt('ws', 'send', [conn, readStr(ptr, len)]);
 245    },
 246    ws_close(conn)          { self.postMessage({ type: 'ws', op: 'close', args: [conn] }); },
 247    ws_ready_state(conn)    { return syncCallInt('ws', 'ready_state', [conn]); },
 248  
 249    // ── Signer ────────────────────────────────────────────────────────────────
 250    signer_has_signer()     { return syncCallInt('signer-sync', 'has_signer', null); },
 251    signer_has_mls()        { return syncCallInt('signer-sync', 'has_mls', null); },
 252    // Persistent state channel: fires on every vault/identity state change
 253    signer_on_state_change(cbID) { sigAsync('on_state_change', null, cbID, 'cbs'); },
 254    signer_is_installed(cbID)                   { sigAsync('is_installed', null, cbID, 'cbb'); },
 255    signer_get_public_key(cbID)                 { sigAsync('get_public_key', null, cbID, 'cbs'); },
 256    signer_sign_event(ptr, len, cap, cbID)      { sigAsync('sign_event', [readStr(ptr, len)], cbID, 'cbs'); },
 257    signer_get_shared_secret(pPtr, pLen, pC, cbID) { sigAsync('get_shared_secret', [readStr(pPtr, pLen)], cbID, 'cbs'); },
 258    signer_get_vault_status(cbID)               { sigAsync('get_vault_status', null, cbID, 'cbs'); },
 259    signer_nip04_encrypt(ppPtr, ppLen, ppC, ptPtr, ptLen, ptC, cbID) {
 260      sigAsync('nip04_encrypt', [readStr(ppPtr, ppLen), readStr(ptPtr, ptLen)], cbID, 'cbs');
 261    },
 262    signer_nip04_decrypt(ppPtr, ppLen, ppC, ctPtr, ctLen, ctC, cbID) {
 263      sigAsync('nip04_decrypt', [readStr(ppPtr, ppLen), readStr(ctPtr, ctLen)], cbID, 'cbs');
 264    },
 265    signer_nip44_encrypt(ppPtr, ppLen, ppC, ptPtr, ptLen, ptC, cbID) {
 266      sigAsync('nip44_encrypt', [readStr(ppPtr, ppLen), readStr(ptPtr, ptLen)], cbID, 'cbs');
 267    },
 268    signer_nip44_decrypt(ppPtr, ppLen, ppC, ctPtr, ctLen, ctC, cbID) {
 269      sigAsync('nip44_decrypt', [readStr(ppPtr, ppLen), readStr(ctPtr, ctLen)], cbID, 'cbs');
 270    },
 271    signer_switch_identity(pPtr, pLen, pC, cbID)  { sigAsync('switch_identity', [readStr(pPtr, pLen)], cbID, 'cbb'); },
 272    signer_create_vault(pPtr, pLen, pC, cbID)      { sigAsync('create_vault', [readStr(pPtr, pLen)], cbID, 'cbb'); },
 273    signer_unlock_vault(pPtr, pLen, pC, cbID)      { sigAsync('unlock_vault', [readStr(pPtr, pLen)], cbID, 'cbb'); },
 274    signer_last_unlock_error(cbID)                 { sigAsync('last_unlock_error', null, cbID, 'cbs'); },
 275    signer_lock_vault(cbID)                        { sigAsync('lock_vault', null, cbID, 'cb0'); },
 276    signer_list_identities(cbID)                   { sigAsync('list_identities', null, cbID, 'cbs'); },
 277    signer_add_identity(nPtr, nLen, nC, cbID)      { sigAsync('add_identity', [readStr(nPtr, nLen)], cbID, 'cbb'); },
 278    signer_remove_identity(pPtr, pLen, pC, cbID)   { sigAsync('remove_identity', [readStr(pPtr, pLen)], cbID, 'cbb'); },
 279    signer_export_vault(pPtr, pLen, pC, cbID)      { sigAsync('export_vault', [readStr(pPtr, pLen)], cbID, 'cbs'); },
 280    signer_import_vault(dPtr, dLen, dC, cbID)      { sigAsync('import_vault', [readStr(dPtr, dLen)], cbID, 'cbb'); },
 281    signer_is_hd(cbID)                             { sigAsync('is_hd', null, cbID, 'cbb'); },
 282    signer_get_mnemonic(cbID)                      { sigAsync('get_mnemonic', null, cbID, 'cbs'); },
 283    signer_validate_mnemonic(mPtr, mLen, mC, cbID) { sigAsync('validate_mnemonic', [readStr(mPtr, mLen)], cbID, 'cbb'); },
 284    signer_generate_mnemonic(cbID)                 { sigAsync('generate_mnemonic', null, cbID, 'cbs'); },
 285    signer_create_hd_vault(pPtr, pLen, pC, mPtr, mLen, mC, cbID) {
 286      sigAsync('create_hd_vault', [readStr(pPtr, pLen), readStr(mPtr, mLen)], cbID, 'cbs');
 287    },
 288    signer_restore_hd_vault(pPtr, pLen, pC, mPtr, mLen, mC, nPtr, nLen, nC, cbID) {
 289      sigAsync('restore_hd_vault', [readStr(pPtr, pLen), readStr(mPtr, mLen), readStr(nPtr, nLen)], cbID, 'cbb');
 290    },
 291    signer_derive_identity(nPtr, nLen, nC, cbID)   { sigAsync('derive_identity', [readStr(nPtr, nLen)], cbID, 'cbs'); },
 292    signer_nsec_login(nPtr, nLen, nC, cbID)        { sigAsync('nsec_login', [readStr(nPtr, nLen)], cbID, 'cbb'); },
 293    // wasmProbeAccount(index int32, cbID int32) — takes int not string ptr
 294    signer_probe_account(index, cbID)              { sigAsync('probe_account', [index], cbID, 'cbs'); },
 295    signer_reset_extension(cbID)                   { sigAsync('reset_extension', null, cbID, 'cbb'); },
 296    signer_nwc_list(cbID)                          { sigAsync('nwc_list', null, cbID, 'cbs'); },
 297    signer_nwc_add(uPtr, uLen, uC, aPtr, aLen, aC, createdAt, cbID) {
 298      sigAsync('nwc_add', [readStr(uPtr, uLen), readStr(aPtr, aLen), Number(createdAt)], cbID, 'cbs');
 299    },
 300    signer_nwc_remove(iPtr, iLen, iC, cbID)        { sigAsync('nwc_remove', [readStr(iPtr, iLen)], cbID, 'cbb'); },
 301    signer_nwc_build_request(iPtr, iLen, iC, mPtr, mLen, mC, pPtr, pLen, pC, ePtr, eLen, eC, createdAt, cbID) {
 302      sigAsync('nwc_build_request', [readStr(iPtr, iLen), readStr(mPtr, mLen), readStr(pPtr, pLen), readStr(ePtr, eLen), Number(createdAt)], cbID, 'cbs');
 303    },
 304    signer_nwc_parse_response(iPtr, iLen, iC, ctPtr, ctLen, ctC, ePtr, eLen, eC, cbID) {
 305      sigAsync('nwc_parse_response', [readStr(iPtr, iLen), readStr(ctPtr, ctLen), readStr(ePtr, eLen)], cbID, 'cbs');
 306    },
 307    signer_ecdh_with_secret(skPtr, skLen, skC, pkPtr, pkLen, pkC, cbID) {
 308      sigAsync('smesh.ecdhWithSecret', [readStr(skPtr, skLen), readStr(pkPtr, pkLen)], cbID, 'cbs');
 309    },
 310    signer_sign_with_secret(skPtr, skLen, skC, evPtr, evLen, evC, cbID) {
 311      sigAsync('smesh.signWithSecret', [readStr(skPtr, skLen), readStr(evPtr, evLen)], cbID, 'cbs');
 312    },
 313    signer_pubkey_from_secret(skPtr, skLen, skC, cbID) {
 314      sigAsync('smesh.pubkeyFromSecret', [readStr(skPtr, skLen)], cbID, 'cbs');
 315    },
 316  
 317    // ── Relay-proxy worker (consumer side) ────────────────────────────────────
 318    relayproxy_send(ptr, len) {
 319      relayProxySend(readStr(ptr, len));
 320    },
 321    relayproxy_on_message(cbID) {
 322      _relayProxyMsgCBID = cbID;
 323    },
 324  
 325    // ── Domain workers (consumer side) ───────────────────────────────────────
 326    profile_send(ptr, len) { self.postMessage({ type: 'profile-send', msg: readStr(ptr, len) }); },
 327    profile_on_message(cbID) { _profileMsgCBID = cbID; },
 328    feed_send(ptr, len)    { self.postMessage({ type: 'feed-send',    msg: readStr(ptr, len) }); },
 329    feed_on_message(cbID)  { _feedMsgCBID = cbID; },
 330    mlsw_send(ptr, len)    { self.postMessage({ type: 'mls-send',     msg: readStr(ptr, len) }); },
 331    mlsw_on_message(cbID)  { _mlsMsgCBID = cbID; },
 332    notif_send(ptr, len)   { self.postMessage({ type: 'notif-send',   msg: readStr(ptr, len) }); },
 333    notif_on_message(cbID) { _notifMsgCBID = cbID; },
 334    // Worker-internal side stubs (dead-code-eliminated when linked as consumer)
 335    profile_worker_on_message() {}, profile_worker_post() {},
 336    profile_worker_set_timeout() { return 0; }, profile_worker_clear_timeout() {},
 337    profile_worker_now_seconds() { return BigInt(0); },
 338    feed_worker_on_message() {}, feed_worker_post() {},
 339    feed_worker_set_timeout() { return 0; }, feed_worker_clear_timeout() {},
 340    feed_worker_now_seconds() { return BigInt(0); },
 341    mlsw_worker_on_message() {}, mlsw_worker_post() {},
 342    mlsw_worker_set_timeout() { return 0; }, mlsw_worker_clear_timeout() {},
 343    mlsw_worker_now_seconds() { return BigInt(0); },
 344    notif_worker_on_message() {}, notif_worker_post() {},
 345    notif_worker_set_timeout() { return 0; }, notif_worker_clear_timeout() {},
 346    notif_worker_now_seconds() { return BigInt(0); },
 347  
 348    // ── Common: SAB channels, spawn, subtle_random_bytes ──────────────────────
 349    ...makeCommonBridge(h, _sabTable, _sabSeqRef, _wasmUrlRef, _buildHashRef, _spawnedWorkerChans),
 350  };
 351  
 352  const wasi = makeWasi(() => mem);
 353  
 354  // ── Callback dispatch helpers ───────────────────────────────────────────────
 355  // Wrap every WASM call in try-catch: xp.__cb* can trap (unreachable),
 356  // and inside an async onmessage the exception becomes an unhandled promise
 357  // rejection that neither worker.onerror nor the browser console catches.
 358  
 359  function _dispatch(fn) {
 360    try { fn(); } catch (err) {
 361      self.postMessage({ type: 'error', fatal: false,
 362        message: '[cb-dispatch] ' + String(err) + (err && err.stack ? '\n' + err.stack : '') });
 363    }
 364  }
 365  
 366  self.addEventListener('unhandledrejection', function(ev) {
 367    self.postMessage({ type: 'error', fatal: false,
 368      message: '[unhandledrejection] ' + String(ev.reason) });
 369  });
 370  
 371  // ── Message handler ────────────────────────────────────────────────────────
 372  
 373  self.onmessage = async function(e) {
 374    const d = e.data;
 375  
 376    // Callback dispatch from supervisor
 377    if (d.type === '__cb_release') {
 378      if (xp && xp.__cb_release) {
 379        for (let i = 0; i < d.ids.length; i++) {
 380          try { xp.__cb_release(d.ids[i]); } catch(_) {}
 381        }
 382      }
 383      return;
 384    }
 385    if (d.type === '__cb0') { _dispatch(() => cb0(d.id)); return; }
 386    if (d.type === '__cbs') { _dispatch(() => cbs(d.id, d.str)); return; }
 387    if (d.type === '__cbb') { _dispatch(() => xp.__cbb(d.id, d.val)); return; }
 388    if (d.type === '__cbdata') { _dispatch(() => cbdata(d.id, new Uint8Array(d.bytes))); return; }
 389    if (d.type === '__cbss') {
 390      _dispatch(() => {
 391        const [p1, l1] = writeStr(d.s1); const [p2, l2] = writeStr(d.s2);
 392        xp.__cbss(d.id, p1, l1, p2, l2);
 393      }); return;
 394    }
 395    if (d.type === '__cbii') { _dispatch(() => xp.__cbii(d.id, d.a, d.b)); return; }
 396    if (d.type === '__cb6i') { _dispatch(() => xp.__cb6i(d.id, d.a, d.b, d.c, d.d, d.e, d.f)); return; }
 397    if (d.type === '__keydown') {
 398      if (xp && xp.__hook_keydown) {
 399        _dispatch(() => { const [ptr, len] = writeStr(d.key); xp.__hook_keydown(d.id, ptr, len); });
 400      }
 401      return;
 402    }
 403    // WebSocket callbacks
 404    if (d.type === '__ws-msg') {
 405      _dispatch(() => { const [ptr, len] = writeStr(d.msg); xp.__cbis(d.id, d.connId, ptr, len); }); return;
 406    }
 407    if (d.type === '__ws-open') { _dispatch(() => xp.__cbi(d.id, d.connId)); return; }
 408    if (d.type === '__ws-close') {
 409      _dispatch(() => { const [ptr, len] = writeStr(d.reason || ''); xp.__cbiis(d.id, d.connId, d.code || 0, ptr, len); }); return;
 410    }
 411    if (d.type === '__ws-err') { _dispatch(() => xp.__cbi(d.id, d.connId)); return; }
 412    // Signer state push (persistent channel): supervisor fires this whenever state changes
 413    if (d.type === '__signer-state') { _dispatch(() => cbs(d.id, d.state)); return; }
 414    // Relay-proxy worker message dispatch: supervisor relays JSON-array strings.
 415    if (d.type === '__relayproxy_msg') {
 416      if (_relayProxyMsgCBID) {
 417        _dispatch(() => cbs(_relayProxyMsgCBID, d.msg || ''));
 418      }
 419      return;
 420    }
 421    // Domain worker message dispatch
 422    if (d.type === '__profile_msg') { if (_profileMsgCBID) _dispatch(() => cbs(_profileMsgCBID, d.msg || '')); return; }
 423    if (d.type === '__feed_msg')    { if (_feedMsgCBID)    _dispatch(() => cbs(_feedMsgCBID, d.msg || ''));    return; }
 424    if (d.type === '__mls_msg')     { if (_mlsMsgCBID)     _dispatch(() => cbs(_mlsMsgCBID, d.msg || ''));     return; }
 425    if (d.type === '__notif_msg')   { if (_notifMsgCBID)   _dispatch(() => cbs(_notifMsgCBID, d.msg || ''));   return; }
 426    if (d.type === '__mem_query') {
 427      if (!xp || !mem) return;
 428      const reply = { type: '__mem_reply' };
 429      if (typeof xp.__moxie_total_alloc === 'function') {
 430        reply.totalAlloc = Number(xp.__moxie_total_alloc());
 431        reply.mallocs = Number(xp.__moxie_mallocs());
 432        reply.heapPtr = xp.__moxie_heap_ptr() >>> 0;
 433      }
 434      if (typeof xp.__moxie_alloc_n_sites === 'function') {
 435        const n = xp.__moxie_alloc_n_sites();
 436        if (n > 0) {
 437          const countersPtr = xp.__moxie_alloc_counters_ptr() >>> 0;
 438          const sizesPtr = xp.__moxie_alloc_sizes_ptr() >>> 0;
 439          reply.allocN = n;
 440          reply.allocCounts = Array.from(new BigUint64Array(mem.buffer, countersPtr, n), v => Number(v));
 441          reply.allocSizes = Array.from(new BigUint64Array(mem.buffer, sizesPtr, n), v => Number(v));
 442        }
 443      }
 444      self.postMessage(reply);
 445      return;
 446    }
 447  
 448    if (d.type === 'init' && d.mode === 'root') {
 449      try {
 450        const wasmBytes = await fetch(d.wasmUrl, {cache: 'no-store'}).then(r => r.arrayBuffer());
 451        const buildHash = await computeBuildHash(wasmBytes);
 452        _wasmUrlRef.value = d.wasmUrl;
 453        _buildHashRef.value = buildHash;
 454        self.postMessage({ type: 'hello', buildHash });
 455  
 456        const { instance } = await WebAssembly.instantiate(wasmBytes,
 457          { bridge, wasi_snapshot_preview1: wasi });
 458        mem = instance.exports.memory;
 459        xp = instance.exports;
 460        xp._start();
 461        self.postMessage({ type: 'ready' });
 462      } catch (err) {
 463        self.postMessage({ type: 'error', fatal: true, message: String(err) });
 464      }
 465      return;
 466    }
 467  
 468    if (d.type === 'init' && d.mode === 'spawn') {
 469      try {
 470        const wasmBytes = await fetch(d.wasmUrl, {cache: 'no-store'}).then(r => r.arrayBuffer());
 471        const childHash = await computeBuildHash(wasmBytes);
 472        if (d.buildHash && childHash !== d.buildHash) {
 473          self.postMessage({ type: 'error', fatal: true,
 474            message: 'spawn_domain: build hash mismatch' });
 475          self.close(); return;
 476        }
 477        _wasmUrlRef.value = d.wasmUrl;
 478        _buildHashRef.value = childHash;
 479        const { instance } = await WebAssembly.instantiate(wasmBytes,
 480          { bridge, wasi_snapshot_preview1: wasi });
 481        mem = instance.exports.memory;
 482        xp = instance.exports;
 483  
 484        const chanSabs = d.chanSabs || [], argBytes = d.argBytes || new Uint8Array(0);
 485        const argPtr = argBytes.length > 0 ? xp.__alloc(argBytes.length) : 0;
 486        if (argBytes.length > 0) new Uint8Array(mem.buffer, argPtr, argBytes.length).set(argBytes);
 487        const nChans = chanSabs.length;
 488        const chanHandlesPtr = nChans > 0 ? xp.__alloc(nChans * 4) : 0;
 489        if (nChans > 0) {
 490          const dv = new DataView(mem.buffer);
 491          chanSabs.forEach((sab, idx) => {
 492            const handle = _sabSeqRef.value++;
 493            _sabTable.set(handle, sab);
 494            dv.setInt32(chanHandlesPtr + idx * 4, handle, true);
 495          });
 496        }
 497        xp.__spawn_entry(d.fnIdx, argPtr, argBytes.length, chanHandlesPtr, nChans);
 498        self.postMessage({ type: 'exit' });
 499      } catch (err) {
 500        self.postMessage({ type: 'error', fatal: true, message: String(err) });
 501      }
 502      self.close();
 503    }
 504  };
 505