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