wasm-host.mjs raw

   1  // wasm-host.mjs — Main page supervisor for the app wasm Worker.
   2  // Runs on the main page thread (DOM access, window.nostr, localStorage, WebSocket).
   3  // Creates wasm-app-worker-host.mjs as a Worker and proxies bridge calls.
   4  
   5  import * as dom from './$runtime/dom.mjs';
   6  import * as localstorage from './$runtime/localstorage.mjs';
   7  import * as ws from './$runtime/ws.mjs';
   8  import * as markdown from './$runtime/markdown.mjs';
   9  // signer.mjs (extension bridge) no longer used — signing goes through signer.wasm Worker.
  10  
  11  console.log('[musiquay] wasm-host v0.6.38');
  12  const WASM_URL = '/app.wasm?v=0.6.38';
  13  const RELAY_PROXY_WASM_URL = '/relay-proxy.wasm?v=0.6.38';
  14  const STORE_WASM_URL   = '/store.wasm?v=0.6.38';
  15  const SIGNER_WASM_URL  = '/signer.wasm?v=0.6.38';
  16  const VERIFY_WASM_URL  = '/verify.wasm?v=0.6.38';
  17  const PROFILE_WASM_URL = '/profile.wasm?v=0.6.38';
  18  const FEED_WASM_URL    = '/feed.wasm?v=0.6.38';
  19  const MLS_WASM_URL      = '/mls.wasm?v=0.6.38';
  20  const NOTIF_WASM_URL   = '/notif.wasm?v=0.6.38';
  21  const _DNS_BLACKLIST = new Set([
  22    'wss://relay.nostr.band', 'wss://relay.nostr.band/',
  23  ]);
  24  function _filterRelayURLs(urls) {
  25    if (!Array.isArray(urls)) return urls;
  26    return urls.filter(u => !_DNS_BLACKLIST.has(u));
  27  }
  28  let worker = null;
  29  let relayProxyWorker = null;
  30  let relayProxyReady = false;
  31  let _relayProxyCrashCount = 0;
  32  const _pendingRelayProxyMsgs = []; // queue while relayProxyWorker boots
  33  
  34  // Store Worker state
  35  let storeWorker = null;
  36  let _storeReady = false;
  37  const _pendingStoreMsgs = [];
  38  // Maps reqID -> 'relay-proxy' | 'app' | 'mls' for routing SR_* responses back to the
  39  // correct worker. Populated by sendToStore, cleared when SR_* arrives.
  40  const _storeReqs = new Map();
  41  const _storeReqTimes = new Map(); // reqID -> timestamp
  42  // KV request tracking: reqID -> { cbID, doneCBID } for routing SR_KV_* back to app callbacks.
  43  let _kvReqID = 0;
  44  const _kvReqs = new Map();
  45  const _kvReqTimes = new Map();
  46  
  47  // Signer Worker state
  48  let signerWorker = null;
  49  let _signerWorkerReady = false;
  50  let _sigReqID = 0;
  51  const _sigReqs = new Map();        // reqID -> { cbID, cbType }
  52  const _sigReqTimes = new Map();    // reqID -> timestamp
  53  const _pendingSignerOps = [];      // queued before worker ready
  54  
  55  // Persistent signer state channel: cbIDs registered via signer_on_state_change.
  56  const _signerStateCBs = [];
  57  
  58  // ── Domain Workers (Profile, Feed, Notif, MLS, MLS-Fetch) ─────────────────
  59  let profileWorker = null, feedWorker = null, notifWorker = null;
  60  let mlsWorker = null;
  61  const _domainQueues = { profile: [], feed: [], notif: [], mls: [] };
  62  let _mlsPubkey = ''; // cached so worker gets it when lazily created
  63  let _mlsRelayURLs = []; // cached for auto-init
  64  let _mlsInitSent = false;
  65  function _domainSend(name, msg) {
  66    // MLS worker is lazy - only create on first mls-send from app, not on SET_PUBKEY.
  67    const w = name === 'profile' ? profileWorker : name === 'feed' ? feedWorker : name === 'mls' ? mlsWorker : notifWorker;
  68    if (w) { w.postMessage(msg); } else { _domainQueues[name].push(msg); }
  69  }
  70  function _flushDomainQueue(name) {
  71    const w = name === 'profile' ? profileWorker : name === 'feed' ? feedWorker : name === 'mls' ? mlsWorker : notifWorker;
  72    if (!w) return;
  73    const q = _domainQueues[name];
  74    while (q.length > 0) w.postMessage(q.shift());
  75  }
  76  function _ensureMlsWorker() {
  77    if (mlsWorker) return;
  78    _updateBootStatus('mls', 'boot');
  79    mlsWorker = _makeWorker(MLS_WASM_URL, '/mls-wasm-host.mjs', _mlsOnMsg, null, function() {
  80      _updateBootStatus('mls', 'ok');
  81      if (_mlsPubkey) mlsWorker.postMessage(JSON.stringify(['M_SET_PUBKEY', _mlsPubkey]));
  82      _flushDomainQueue('mls');
  83      _tryMlsAutoInit();
  84    });
  85  }
  86  function _localRelayURL() {
  87    const h = location.hostname, p = location.port;
  88    if (!p || p === '443' || p === '80') return 'wss://' + h;
  89    return 'ws://' + h + ':' + p;
  90  }
  91  function _tryMlsAutoInit() {
  92    if (_mlsInitSent || !mlsWorker || !_mlsPubkey || _mlsRelayURLs.length === 0) return;
  93    _mlsInitSent = true;
  94    const mlsURLs = [_localRelayURL()];
  95    console.log('[wasm-host] MLS auto-init: pubkey + relays ready, sending M_INIT');
  96    _domainSend('mls', JSON.stringify(['M_INIT', mlsURLs]));
  97  }
  98  
  99  function _mlsRewriteURLs(msg) {
 100    try {
 101      const p = JSON.parse(msg);
 102      if (!Array.isArray(p)) return msg;
 103      const tag = p[0];
 104      if (tag === 'MLS_INIT') {
 105        p[1] = [_localRelayURL()];
 106        return JSON.stringify(p);
 107      }
 108    } catch (_) {}
 109    return msg;
 110  }
 111  function _domainFwd(w, msg) {
 112    if (w) w.postMessage(msg);
 113  }
 114  
 115  // Crash counts per domain worker — cap restarts to prevent memory accumulation.
 116  const _domainCrashCounts = {};
 117  
 118  function _makeWorker(url, hostMjs, onMsg, onErr, onBooted) {
 119    const w = new Worker(new URL(hostMjs, location.href), { type: 'module' });
 120    w.postMessage({ type: 'init', mode: 'root', wasmUrl: url });
 121    let booted = false;
 122    w.onmessage = function(e) {
 123      const d = e.data;
 124      if (typeof d === 'string' && d === '["__WASM_BOOTED"]') {
 125        if (!booted) { booted = true; if (onBooted) onBooted(); }
 126        return;
 127      }
 128      if (typeof d === 'string' && d.startsWith('["__WASM_FATAL"')) {
 129        console.error('[' + hostMjs + '] WASM fatal — restarting');
 130        try {
 131          var _cl = JSON.parse(localStorage.getItem('__musiquay_crash_log') || '[]');
 132          _cl.push({ts: Date.now(), worker: hostMjs, msg: d.slice(0, 300)});
 133          if (_cl.length > 30) _cl = _cl.slice(-30);
 134          localStorage.setItem('__musiquay_crash_log', JSON.stringify(_cl));
 135        } catch(_) {}
 136        w.terminate();
 137        const count = (_domainCrashCounts[hostMjs] || 0) + 1;
 138        _domainCrashCounts[hostMjs] = count;
 139        const short = hostMjs.replace('-wasm-host.mjs','').replace('/','');
 140        if (count <= 2) {
 141          _updateBootStatus(short, 'crash' + count);
 142          setTimeout(function() {
 143            const fresh = _makeWorker(url, hostMjs, onMsg, onErr, onBooted);
 144            _replaceDomainWorker(hostMjs, fresh);
 145            _reprovisionWorker(hostMjs);
 146          }, 2000);
 147        } else {
 148          _updateBootStatus(short, 'DEAD');
 149          console.warn('[' + hostMjs + '] crash limit reached, not restarting');
 150          if (!booted && onBooted) { booted = true; onBooted(); }
 151        }
 152        return;
 153      }
 154      onMsg(e);
 155    };
 156    w.onerror = onErr || function(e) { console.error('[' + hostMjs + ']', e.message || '(no message)'); };
 157    return w;
 158  }
 159  
 160  function _replaceDomainWorker(hostMjs, fresh) {
 161    if (hostMjs === '/profile-wasm-host.mjs') profileWorker = fresh;
 162    else if (hostMjs === '/feed-wasm-host.mjs') feedWorker = fresh;
 163    else if (hostMjs === '/notif-wasm-host.mjs') notifWorker = fresh;
 164    else if (hostMjs === '/mls-wasm-host.mjs') mlsWorker = fresh;
 165  }
 166  
 167  // Re-provision a restarted domain worker with current session state.
 168  function _reprovisionWorker(hostMjs) {
 169    if (!worker) return; // app not ready, workers will be synced on first resubscribe
 170    // Signal app worker to re-send pubkey, relays, and follow/mute lists.
 171    worker.postMessage({ type: '__relayproxy_msg', msg: '["WORKER_RESTARTED","' + hostMjs + '"]' });
 172  }
 173  
 174  function _routeDomainToApp(data) {
 175    if (worker) worker.postMessage({ type: '__relayproxy_msg', msg: data });
 176  }
 177  
 178  function _profileOnMsg(e) {
 179    const d = e.data;
 180    if (typeof d !== 'string') return;
 181    let parsed; try { parsed = JSON.parse(d); } catch { return; }
 182    if (!Array.isArray(parsed)) return;
 183    const tag = parsed[0];
 184    if (tag === 'P_SUB') {
 185      const subID = parsed[1], filter = typeof parsed[2]==='string' ? parsed[2] : JSON.stringify(parsed[2]);
 186      const urls = _filterRelayURLs(Array.isArray(parsed[3]) ? parsed[3] : JSON.parse(parsed[3]||'[]'));
 187      sendToRelayProxy(JSON.stringify(['PROXY', subID, JSON.parse(filter), urls]));
 188      return;
 189    }
 190    if (tag === 'P_CLOSE') {
 191      sendToRelayProxy(JSON.stringify(['CLOSE', parsed[1]]));
 192      return;
 193    }
 194    _routeDomainToApp(d);
 195  }
 196  
 197  function _feedOnMsg(e) {
 198    const d = e.data;
 199    if (typeof d !== 'string') return;
 200    let parsed; try { parsed = JSON.parse(d); } catch { return; }
 201    if (!Array.isArray(parsed)) return;
 202    const tag = parsed[0];
 203    if (tag === 'F_SUB') {
 204      const subID = parsed[1], filter = typeof parsed[2]==='string' ? parsed[2] : JSON.stringify(parsed[2]);
 205      const urls = _filterRelayURLs(Array.isArray(parsed[3]) ? parsed[3] : JSON.parse(parsed[3]||'[]'));
 206      const ptype = subID === 'feed' ? 'PROXY_LIVE' : 'PROXY';
 207      sendToRelayProxy(JSON.stringify([ptype, subID, JSON.parse(filter), urls]));
 208      return;
 209    }
 210    if (tag === 'F_CLOSE') {
 211      sendToRelayProxy(JSON.stringify(['CLOSE', parsed[1]]));
 212      return;
 213    }
 214    _routeDomainToApp(d);
 215  }
 216  
 217  function _startDomainWorkers(onAllDone) {
 218    _updateBootStatus('profile', 'boot');
 219    profileWorker = _makeWorker(PROFILE_WASM_URL, '/profile-wasm-host.mjs', _profileOnMsg, null, function() {
 220      _updateBootStatus('profile', 'ok');
 221      _flushDomainQueue('profile');
 222      // The kind-1 forum feed worker is not part of the musiquay base build:
 223      // feed.wasm is not built or served and nothing subscribes to kind 1. The
 224      // worker wiring (_feedOnMsg, FEED_WASM_URL, feed-send) stays for the port.
 225      _updateBootStatus('feed', 'parked');
 226      _updateBootStatus('notif', 'boot');
 227      notifWorker = _makeWorker(NOTIF_WASM_URL, '/notif-wasm-host.mjs', _notifOnMsg, null, function() {
 228        _updateBootStatus('notif', 'ok');
 229        _flushDomainQueue('notif');
 230        if (onAllDone) onAllDone();
 231      });
 232    });
 233  }
 234  
 235  function _mlsOnMsg(e) {
 236    const d = e.data;
 237    if (typeof d !== 'string') return;
 238    let parsed;
 239    try { parsed = JSON.parse(d); } catch (err) {
 240      console.error('[wasm-host] _mlsOnMsg JSON parse failed:', err.message, 'data:', d.slice(0, 120));
 241      return;
 242    }
 243    if (!Array.isArray(parsed)) return;
 244    const tag = parsed[0];
 245    if (tag === '__WASM_BOOTED' || tag === '__WASM_FATAL') {
 246      console.log('[wasm-host] MLS worker:', tag);
 247      return;
 248    }
 249    console.log('[wasm-host] _mlsOnMsg tag:', tag);
 250    if (tag === 'M_PUBLISH') {
 251      try {
 252        const evJSON = typeof parsed[1]==='string' ? parsed[1] : JSON.stringify(parsed[1]);
 253        const urls = Array.isArray(parsed[2]) ? parsed[2] : JSON.parse(parsed[2]||'[]');
 254        console.log('[wasm-host] MLS M_PUBLISH -> relay-proxy PUBLISH_TO, urls:', urls.length, 'evJSON(100):', evJSON.slice(0,100));
 255        const evObj = JSON.parse(evJSON);
 256        const ki = evJSON.indexOf('"kind"');
 257        console.log('[wasm-host] MLS M_PUBLISH kind:', evObj.kind, 'typeof:', typeof evObj.kind, 'kindRaw:', ki >= 0 ? evJSON.slice(ki, ki+20) : 'NOT_FOUND');
 258        sendToRelayProxy(JSON.stringify(['PUBLISH_TO', evObj, urls]));
 259      } catch (err) {
 260        console.error('[wasm-host] MLS M_PUBLISH error:', err.message);
 261      }
 262      return;
 263    }
 264    if (tag === 'M_SUBSCRIBE') {
 265      const urls = Array.isArray(parsed[1]) ? parsed[1] : JSON.parse(parsed[1]||'[]');
 266      const groupIDs = Array.isArray(parsed[2]) ? parsed[2] : JSON.parse(parsed[2]||'[]');
 267      console.log('[wasm-host] MLS M_SUBSCRIBE -> relay-proxy MLS_SUB, urls:', urls, 'groups:', groupIDs);
 268      sendToRelayProxy(JSON.stringify(['MLS_SUB', urls, groupIDs]));
 269      return;
 270    }
 271    if (tag === 'M_UPDATE_GROUPS') {
 272      const groupIDs = Array.isArray(parsed[1]) ? parsed[1] : JSON.parse(parsed[1]||'[]');
 273      console.log('[wasm-host] MLS M_UPDATE_GROUPS, groups:', groupIDs);
 274      sendToRelayProxy(JSON.stringify(['MLS_UPDATE_GROUPS', groupIDs]));
 275      return;
 276    }
 277    if (tag === 'M_FETCH_KP') {
 278      const peer = parsed[1];
 279      const urls = Array.isArray(parsed[2]) ? parsed[2] : JSON.parse(parsed[2]||'[]');
 280      console.log('[wasm-host] MLS M_FETCH_KP peer:', peer?.slice(0,16)+'...', 'urls:', urls);
 281      sendToRelayProxy(JSON.stringify(['MLS_FETCH_KP', peer, urls]));
 282      return;
 283    }
 284    if (tag === 'M_STORE_REQ') {
 285      const op = parsed[1], reqID = parsed[2];
 286      const args = parsed.slice(3);
 287      const storeMsg = [op, reqID].concat(args);
 288      console.log('[wasm-host] MLS M_STORE_REQ op:', op, 'reqID:', reqID);
 289      _storeReqs.set(reqID, 'mls');
 290      if (storeWorker) storeWorker.postMessage(JSON.stringify(storeMsg));
 291      return;
 292    }
 293    if (tag === 'M_CRYPTO_REQ') {
 294      const mlsReqID = parsed[1], method = parsed[2], peer = parsed[3], data = parsed[4];
 295      console.log('[wasm-host] MLS M_CRYPTO_REQ method:', method, 'reqID:', mlsReqID);
 296      const op = _mlsMethodToOp(method);
 297      const mp = op ? _sigOpToMethodParams(op, [peer, data]) : null;
 298      if (mp && signerWorker) {
 299        const sigReqID = ++_sigReqID;
 300        _sigReqs.set(sigReqID, { cbID: mlsReqID, cbType: 'cbs', origin: 'mls' });
 301        signerWorker.postMessage({ id: sigReqID, method: mp[0], params: mp[1] });
 302      } else {
 303        console.warn('[wasm-host] MLS M_CRYPTO_REQ: signer unavailable, signerWorker:', !!signerWorker);
 304        if (mlsWorker) mlsWorker.postMessage(JSON.stringify(['M_CRYPTO_RESULT', mlsReqID, '', 'signer unavailable']));
 305      }
 306      return;
 307    }
 308    // Forward MLS_* responses to app worker.
 309    console.log('[wasm-host] MLS -> app:', tag);
 310    _routeDomainToApp(d);
 311  }
 312  
 313  
 314  function _notifOnMsg(e) {
 315    const d = e.data;
 316    if (typeof d !== 'string') return;
 317    let parsed; try { parsed = JSON.parse(d); } catch { return; }
 318    if (!Array.isArray(parsed)) return;
 319    const tag = parsed[0];
 320    if (tag === 'N_SUB') {
 321      const subID = parsed[1], filter = typeof parsed[2]==='string' ? parsed[2] : JSON.stringify(parsed[2]);
 322      const urls = _filterRelayURLs(Array.isArray(parsed[3]) ? parsed[3] : JSON.parse(parsed[3]||'[]'));
 323      const ptype = subID === 'ntf' ? 'PROXY_LIVE' : 'PROXY';
 324      sendToRelayProxy(JSON.stringify([ptype, subID, JSON.parse(filter), urls]));
 325      return;
 326    }
 327    if (tag === 'N_CLOSE') {
 328      sendToRelayProxy(JSON.stringify(['CLOSE', parsed[1]]));
 329      return;
 330    }
 331    if (tag === 'N_STORE_READ_TS') {
 332      _routeDomainToApp(d);
 333      return;
 334    }
 335    _routeDomainToApp(d);
 336  }
 337  
 338  function _mlsMethodToOp(method) {
 339    switch (method) {
 340      case 'nip44.encrypt': return 'nip44_encrypt';
 341      case 'nip44.decrypt': return 'nip44_decrypt';
 342      case 'signEvent':     return 'sign_event';
 343      default: return null;
 344    }
 345  }
 346  
 347  function _routeStoreResponseToDomain(origin, data) {
 348    if (origin === 'mls' && mlsWorker) {
 349      // Parse SR_MLS_* response and forward as M_STORE_RESULT tag reqID data.
 350      let parsed; try { parsed = JSON.parse(data); } catch { return; }
 351      const tag = parsed[0], reqID = parsed[1], payload = parsed[2] !== undefined ? parsed[2] : '';
 352      mlsWorker.postMessage(JSON.stringify(['M_STORE_RESULT', tag, reqID, payload]));
 353    }
 354  }
 355  
 356  // Verify Supervisor — 4 parallel Verify Workers for BIP-340 Schnorr verification.
 357  // Intercepts raw ["EVENT", subID, evJSON] from relay-proxy, distributes across
 358  // workers, forwards only verified events to app worker.
 359  const _verifyWorkers = [];
 360  const _verifyReqs = new Map();     // reqID -> raw data string for app delivery
 361  const _verifyReqTimes = new Map(); // reqID -> timestamp
 362  let _verifyNext = 0;
 363  let _verifyReqID = 0;
 364  let _verifyReady = 0; // count of workers that have finished loading
 365  
 366  function _initVerifyWorkers() {
 367    // 2 workers is enough throughput for a feed (BIP-340 verify ~5ms/event),
 368    // and halves the per-worker memory cost vs 4. Mobile content processes have
 369    // hard memory budgets; cutting 750KB×2 of wasm code mapping helps materially.
 370    for (let i = 0; i < 2; i++) {
 371      const vw = new Worker(new URL('/verify-wasm-host.mjs', location.href), { type: 'module' });
 372      vw.postMessage({ type: 'init', mode: 'root', wasmUrl: VERIFY_WASM_URL });
 373      vw.onmessage = function(e) {
 374        const d = e.data;
 375        if (typeof d !== 'string') return;
 376        // Workers don't post a 'ready' message; they're ready as soon as _start() returns.
 377        // V_RESULT arrives when verification is done.
 378        let parsed;
 379        try { parsed = JSON.parse(d); } catch (_) { return; }
 380        if (!Array.isArray(parsed) || parsed[0] !== 'V_RESULT') return;
 381        const reqID = parsed[1];
 382        const valid = parsed[2];
 383        const req = _verifyReqs.get(reqID);
 384        if (!req) return;
 385        _verifyReqs.delete(reqID); _verifyReqTimes.delete(reqID);
 386        if (valid && worker) {
 387          worker.postMessage({ type: '__relayproxy_msg', msg: req });
 388        }
 389      };
 390      vw.onerror = function(e) {
 391        console.error('[verify-worker-' + i + '] error:', e.message);
 392      };
 393      _verifyWorkers.push(vw);
 394    }
 395  }
 396  
 397  // submitForVerification routes an EVENT message through the Verify Supervisor.
 398  // data is the raw JSON string (["EVENT","subid",{...}]) from relay-proxy.
 399  // Only valid events are forwarded to the app worker.
 400  function _submitForVerification(data) {
 401    let evJSON;
 402    try {
 403      const parsed = JSON.parse(data);
 404      if (!Array.isArray(parsed) || parsed.length < 3) return;
 405      // parsed[2] is the event object; re-stringify for the Verify Worker
 406      evJSON = JSON.stringify(parsed[2]);
 407    } catch (_) { return; }
 408    if (!evJSON) return;
 409    if (_verifyReqs.size > 500) {
 410      const oldest = _verifyReqs.keys().next().value;
 411      _verifyReqs.delete(oldest); _verifyReqTimes.delete(oldest);
 412    }
 413    const reqID = ++_verifyReqID;
 414    _verifyReqs.set(reqID, data);
 415    _verifyReqTimes.set(reqID, Date.now());
 416    const vw = _verifyWorkers[_verifyNext % _verifyWorkers.length];
 417    _verifyNext++;
 418    // V_CHECK message: ["V_CHECK", reqID, {...event object...}]
 419    vw.postMessage('["V_CHECK",' + reqID + ',' + evJSON + ']');
 420  }
 421  
 422  // Helper: signal SAB sync int result and wake Worker.
 423  // BigInt-tolerant: WASM i64 returns (e.g. NowSeconds) come back as BigInt;
 424  // `BigInt | 0` throws TypeError, so coerce to Number first. Truncation to
 425  // int32 is intentional — the SAB only has one i32 slot for the result.
 426  function syncInt(sab, val) {
 427    const i32 = new Int32Array(sab);
 428    const num = typeof val === 'bigint' ? Number(val) : val;
 429    i32[1] = num | 0;
 430    Atomics.store(i32, 0, 1);
 431    Atomics.notify(i32, 0, 1);
 432  }
 433  
 434  // Helper: signal SAB sync string result and wake Worker
 435  function syncStr(sab, str) {
 436    const i32 = new Int32Array(sab);
 437    let bytes = new TextEncoder().encode(str || '');
 438    const maxLen = sab.byteLength - 12;
 439    if (bytes.length > maxLen) bytes = bytes.subarray(0, maxLen); // truncate instead of crash
 440    i32[2] = bytes.length;
 441    if (bytes.length > 0) new Uint8Array(sab, 12).set(bytes);
 442    Atomics.store(i32, 0, 1);
 443    Atomics.notify(i32, 0, 1);
 444  }
 445  
 446  // Helper: forward a callback result to the Worker
 447  function fwdCb(cbID, type, payload) {
 448    if (!worker) return;
 449    const msg = { type, id: cbID };
 450    Object.assign(msg, payload);
 451    worker.postMessage(msg);
 452  }
 453  function cb0(id) { fwdCb(id, '__cb0', {}); }
 454  function cbs(id, str) { fwdCb(id, '__cbs', { str: String(str ?? '') }); }
 455  function cbb(id, val) { fwdCb(id, '__cbb', { val: val ? 1 : 0 }); }
 456  function cbss(id, s1, s2) { fwdCb(id, '__cbss', { s1: String(s1??''), s2: String(s2??'') }); }
 457  function cb6i(id, a,b,c,d2,e,f) { fwdCb(id, '__cb6i', {a,b,c,d:d2,e,f}); }
 458  
 459  // JS-side cbId -> WASM-side cbID mapping for callback release propagation.
 460  const _jsToWasmCb = new Map();
 461  dom.setCallbackReleaseHook(function(jsIds) {
 462    if (!worker) return;
 463    const wasmIds = [];
 464    for (let i = 0; i < jsIds.length; i++) {
 465      const wid = _jsToWasmCb.get(jsIds[i]);
 466      if (wid !== undefined) {
 467        wasmIds.push(wid);
 468        _jsToWasmCb.delete(jsIds[i]);
 469      }
 470    }
 471    if (wasmIds.length > 0) {
 472      worker.postMessage({ type: '__cb_release', ids: wasmIds });
 473    }
 474  });
 475  
 476  function sendToRelayProxy(msg) {
 477    if (relayProxyReady && relayProxyWorker) {
 478      relayProxyWorker.postMessage(msg);
 479    } else {
 480      if (_relayProxyCrashCount >= 3) return;
 481      if (_pendingRelayProxyMsgs.length < 100) _pendingRelayProxyMsgs.push(msg);
 482    }
 483  }
 484  
 485  // sendToStore forwards a JSON-array message to the Store Worker.
 486  // For request-response messages (where parsed[1] is a number reqID), tracks
 487  // the origin so SR_* responses can be routed back to the right worker.
 488  function sendToStore(msg, origin) {
 489    if (!_storeReady) { _pendingStoreMsgs.push({ msg, origin }); return; }
 490    let reqID = -1;
 491    try {
 492      const a = JSON.parse(msg);
 493      if (Array.isArray(a) && a.length >= 2 && typeof a[1] === 'number') reqID = a[1];
 494    } catch (_) {}
 495    if (reqID >= 0) { _storeReqs.set(reqID, origin); _storeReqTimes.set(reqID, Date.now()); }
 496    storeWorker.postMessage(msg);
 497  }
 498  
 499  function _sendToStoreRaw(msg) {
 500    if (!_storeReady) { _pendingStoreMsgs.push({ msg, origin: null }); return; }
 501    storeWorker.postMessage(msg);
 502  }
 503  
 504  function startStoreWorker(onReady) {
 505    storeWorker = new Worker(new URL('/store-wasm-host.mjs', location.href), { type: 'module' });
 506    storeWorker.postMessage({ type: 'init', mode: 'root', wasmUrl: STORE_WASM_URL });
 507    storeWorker.onmessage = function(e) {
 508      const data = e.data;
 509      if (typeof data !== 'string') return;
 510      let parsed;
 511      try { parsed = JSON.parse(data); } catch (_) { return; }
 512      if (!Array.isArray(parsed) || !parsed.length) return;
 513      const tag = parsed[0];
 514      if (tag === 'SR_READY') {
 515        _storeReady = true;
 516        for (const p of _pendingStoreMsgs) {
 517          if (p.origin !== null) { sendToStore(p.msg, p.origin); }
 518          else { storeWorker.postMessage(p.msg); }
 519        }
 520        _pendingStoreMsgs.length = 0;
 521        onReady();
 522        return;
 523      }
 524      if (tag === '__ERROR' || tag === '__WASM_FATAL') {
 525        console.error('[store-worker]', tag, parsed[1]);
 526        if (tag === '__ERROR' && typeof parsed[1] === 'string' && parsed[1].indexOf('boot:') >= 0 && parsed[1].indexOf('AbortError') >= 0) {
 527          storeWorker.terminate();
 528          storeWorker = null;
 529          _storeReady = false;
 530          setTimeout(function() { startStoreWorker(onReady); }, 1000);
 531        }
 532        return;
 533      }
 534      // Route SR_KV_* responses back to app worker callbacks.
 535      if (tag === 'SR_KV_GET') {
 536        const reqID = parsed[1], val = parsed[2] || '';
 537        const kv = _kvReqs.get(reqID);
 538        if (kv && worker) { cbs(kv.cbID, val); }
 539        _kvReqs.delete(reqID); _kvReqTimes.delete(reqID);
 540        return;
 541      }
 542      if (tag === 'SR_KV_GETALL_ITEM') {
 543        const reqID = parsed[1], key = parsed[2] || '', val = parsed[3] || '';
 544        const kv = _kvReqs.get(reqID);
 545        if (kv && worker) { cbss(kv.cbID, key, val); }
 546        return;
 547      }
 548      if (tag === 'SR_KV_GETALL_DONE') {
 549        const reqID = parsed[1];
 550        const kv = _kvReqs.get(reqID);
 551        if (kv && worker) { cb0(kv.doneCBID); }
 552        _kvReqs.delete(reqID); _kvReqTimes.delete(reqID);
 553        return;
 554      }
 555      // Route SR_* responses back to the originating worker.
 556      const reqID = typeof parsed[1] === 'number' ? parsed[1] : -1;
 557      if (reqID >= 0) {
 558        const origin = _storeReqs.get(reqID);
 559        _storeReqs.delete(reqID); _storeReqTimes.delete(reqID);
 560        if (origin === 'relay-proxy' && relayProxyWorker) {
 561          relayProxyWorker.postMessage(data);
 562        } else if (origin === 'app' && worker) {
 563          worker.postMessage({ type: '__relayproxy_msg', msg: data });
 564        } else if (origin === 'mls') {
 565          _routeStoreResponseToDomain('mls', data);
 566        }
 567      }
 568    };
 569    storeWorker.onerror = function(e) {
 570      console.error('[store-worker] error:', e.message, 'at', e.filename + ':' + e.lineno);
 571    };
 572  }
 573  
 574  // Extract the result value from a signer JSON response {"result":...} or {"error":...}.
 575  // For cbs: returns a string (JSON-stringifies non-string results).
 576  // For cbb: returns 1 or 0.
 577  // For cb0: returns null (ignored).
 578  function _sigExtract(resultJSON, cbType) {
 579    let r;
 580    try { r = JSON.parse(resultJSON); } catch (_) { r = {}; }
 581    if (!r || r.error) {
 582      if (cbType === 'cbs') return '';
 583      if (cbType === 'cbb') return 0;
 584      return null;
 585    }
 586    const val = r.result;
 587    if (cbType === 'cbb') return (val === true || (val && !val.error && val.result !== false)) ? 1 : 0;
 588    if (cbType === 'cbs') return typeof val === 'string' ? val : JSON.stringify(val ?? '');
 589    return null;
 590  }
 591  
 592  // Map a signer-async op + args to the method+params format the signer WASM expects.
 593  function _sigOpToMethodParams(op, args) {
 594    const a = args || [];
 595    const j = (v) => JSON.stringify(String(v || ''));
 596    switch (op) {
 597      case 'get_public_key':     return ['getPublicKey', '{}'];
 598      case 'sign_event':         return ['signEvent', String(a[1] || a[0] || '{}')];
 599      case 'get_shared_secret':  return ['getSharedSecret', '{"pubkey":' + j(a[0]) + '}'];
 600      case 'nip04_encrypt':      return ['nip04.encrypt', '{"pubkey":' + j(a[0]) + ',"plaintext":' + j(a[1]) + '}'];
 601      case 'nip04_decrypt':      return ['nip04.decrypt', '{"pubkey":' + j(a[0]) + ',"ciphertext":' + j(a[1]) + '}'];
 602      case 'nip44_encrypt':      return ['nip44.encrypt', '{"pubkey":' + j(a[0]) + ',"plaintext":' + j(a[1]) + '}'];
 603      case 'nip44_decrypt':      return ['nip44.decrypt', '{"pubkey":' + j(a[0]) + ',"ciphertext":' + j(a[1]) + '}'];
 604      case 'get_vault_status':
 605      case 'get_vault_status2':  return ['musiquay.getVaultStatus', '{}'];
 606      case 'unlock_vault':       return ['musiquay.unlockVault', '{"password":' + j(a[0]) + '}'];
 607      case 'last_unlock_error':  return ['musiquay.lastUnlockError', '{}'];
 608      case 'create_vault':       return ['musiquay.createVault', '{"password":' + j(a[0]) + '}'];
 609      case 'lock_vault':         return ['musiquay.lockVault', '{}'];
 610      case 'list_identities':    return ['musiquay.listIdentities', '{}'];
 611      case 'add_identity':       return ['musiquay.addIdentity', '{"nsec":' + j(a[0]) + '}'];
 612      case 'remove_identity':    return ['musiquay.removeIdentity', '{"pubkey":' + j(a[0]) + '}'];
 613      case 'switch_identity':    return ['musiquay.switchIdentity', '{"pubkey":' + j(a[0]) + '}'];
 614      case 'export_vault':       return ['musiquay.exportVault', '{"password":' + j(a[0]) + '}'];
 615      case 'import_vault':       return ['musiquay.importVault', '{"data":' + j(a[0]) + '}'];
 616      case 'is_hd':              return ['musiquay.isHD', '{}'];
 617      case 'get_mnemonic':       return ['musiquay.getMnemonic', '{}'];
 618      case 'create_hd_vault':    return ['musiquay.createHDVault', '{"password":' + j(a[0]) + ',"mnemonic":' + j(a[1]) + '}'];
 619      case 'restore_hd_vault':   return ['musiquay.restoreHDVault', '{"password":' + j(a[0]) + ',"mnemonic":' + j(a[1]) + ',"name":' + j(a[2]) + '}'];
 620      case 'derive_identity':    return ['musiquay.deriveIdentity', '{"name":' + j(a[0]) + '}'];
 621      case 'validate_mnemonic':  return ['musiquay.validateMnemonic', '{"mnemonic":' + j(a[0]) + '}'];
 622      case 'generate_mnemonic':  return ['musiquay.generateMnemonic', '{}'];
 623      case 'probe_account':      return ['musiquay.probeAccount', '{"index":' + (a[0] | 0) + '}'];
 624      case 'nsec_login':         return ['musiquay.nsecLogin', '{"nsec":' + j(a[0]) + '}'];
 625      case 'reset_extension':    return ['musiquay.resetExtension', '{}'];
 626      case 'nwc_list':           return ['musiquay.nwc.list', '{}'];
 627      case 'nwc_add':            return ['musiquay.nwc.add', '{"url":' + j(a[0]) + ',"alias":' + j(a[1]) + ',"created_at":' + (Number(a[2]) || 0) + '}'];
 628      case 'nwc_remove':         return ['musiquay.nwc.remove', '{"id":' + j(a[0]) + '}'];
 629      case 'nwc_build_request':  return ['musiquay.nwc.buildRequest', '{"id":' + j(a[0]) + ',"method":' + j(a[1]) + ',"params":' + String(a[2] || '{}') + ',"expiry":' + (Number(a[3]) || 0) + ',"created_at":' + (Number(a[4]) || 0) + '}'];
 630      case 'nwc_parse_response': return ['musiquay.nwc.parseResponse', '{"id":' + j(a[0]) + ',"ciphertext":' + j(a[1]) + ',"expiry":' + (Number(a[2]) || 0) + '}'];
 631      case 'musiquay.ecdhWithSecret': return ['musiquay.ecdhWithSecret', '{"secret":' + j(a[0]) + ',"pubkey":' + j(a[1]) + '}'];
 632      case 'musiquay.signWithSecret': return ['musiquay.signWithSecret', '{"secret":' + j(a[0]) + ',"event":' + String(a[1] || '{}') + '}'];
 633      case 'musiquay.pubkeyFromSecret': return ['musiquay.pubkeyFromSecret', '{"secret":' + j(a[0]) + '}'];
 634      default: return null;
 635    }
 636  }
 637  
 638  function _fireSigCb(cbID, cbType, resultJSON) {
 639    if (!worker) return;
 640    const val = _sigExtract(resultJSON, cbType);
 641    if (cbType === 'cbs') cbs(cbID, val);
 642    else if (cbType === 'cbb') cbb(cbID, val);
 643    else if (cbType === 'cb0') cb0(cbID);
 644    else if (cbType === 'cbdata') worker.postMessage({ type: '__cbdata', id: cbID, bytes: new ArrayBuffer(0) });
 645  }
 646  
 647  function _sendToSigner(op, args, cbID, cbType) {
 648    // Special cases that don't route to WASM
 649    if (op === 'is_installed') {
 650      if (cbType === 'cbb') { if (!worker) return; cbb(cbID, _signerWorkerReady ? 1 : 0); }
 651      return;
 652    }
 653    if (op === 'on_state_change') {
 654      if (_signerStateCBs.length >= 4) _signerStateCBs.shift();
 655      _signerStateCBs.push(cbID);
 656      return;
 657    }
 658    if (!_signerWorkerReady) {
 659      _pendingSignerOps.push({ op, args, cbID, cbType });
 660      return;
 661    }
 662    const mp = _sigOpToMethodParams(op, args);
 663    if (!mp) return;
 664    const reqID = ++_sigReqID;
 665    _sigReqs.set(reqID, { cbID, cbType });
 666    _sigReqTimes.set(reqID, Date.now());
 667    signerWorker.postMessage({ id: reqID, method: mp[0], params: mp[1] });
 668  }
 669  
 670  function startSignerWorker() {
 671    window.__musiquay_signer_ready = false; // reset before worker boots
 672    signerWorker = new Worker(new URL('/signer-wasm-host.mjs', location.href), { type: 'module' });
 673    signerWorker.postMessage({ type: 'init', mode: 'root', wasmUrl: SIGNER_WASM_URL });
 674    signerWorker.onmessage = function(e) {
 675      const d = e.data;
 676      if (!d) return;
 677      if (d.type === 'sig-ready') {
 678        _signerWorkerReady = true;
 679        window.__musiquay_signer_ready = true; // exposed for test readiness checks
 680        // Flush queued ops
 681        while (_pendingSignerOps.length > 0) {
 682          const { op, args, cbID, cbType } = _pendingSignerOps.shift();
 683          _sendToSigner(op, args, cbID, cbType);
 684        }
 685        return;
 686      }
 687      if (d.type === 'sig-error') {
 688        console.error('[signer-worker]', d.message);
 689        // AbortError during boot means the WASM fetch was killed (SW activation race).
 690        // Retry once after a short delay so the queue flushes on the second attempt.
 691        if (!_signerWorkerReady && typeof d.message === 'string' && d.message.indexOf('AbortError') >= 0) {
 692          signerWorker.terminate();
 693          signerWorker = null;
 694          setTimeout(startSignerWorker, 1000);
 695        }
 696        return;
 697      }
 698      // Storage proxy: signer worker needs localStorage
 699      if (d.type === 'sig-storage-get') {
 700        const val = localstorage.GetItem(d.key) || '';
 701        signerWorker.postMessage({ type: 'sig-storage-result', msgId: d.msgId, value: val });
 702        return;
 703      }
 704      if (d.type === 'sig-storage-set') { localstorage.SetItem(d.key, d.value); return; }
 705      if (d.type === 'sig-storage-remove') { localstorage.RemoveItem(d.key); return; }
 706      // sessionStorage proxy: vault key persists across same-tab navigations
 707      if (d.type === 'sig-session-get') {
 708        let val = '';
 709        try { val = sessionStorage.getItem('__musiquay_s_' + d.key) || ''; } catch (_) {}
 710        signerWorker.postMessage({ type: 'sig-session-result', msgId: d.msgId, value: val });
 711        return;
 712      }
 713      if (d.type === 'sig-session-set') {
 714        try { if (d.value) sessionStorage.setItem('__musiquay_s_' + d.key, d.value); else sessionStorage.removeItem('__musiquay_s_' + d.key); } catch (_) {}
 715        return;
 716      }
 717      // Method response: route to pending callback (or domain worker if origin set)
 718      if (d.type === 'sig-resp') {
 719        const req = _sigReqs.get(d.id);
 720        if (req) {
 721          _sigReqs.delete(d.id); _sigReqTimes.delete(d.id);
 722          if (req._resolve) {
 723            // Test-API callback — resolve the Promise directly with raw JSON.
 724            req._resolve(d.result || '{}');
 725          } else if (req.origin === 'mls' && mlsWorker) {
 726            const val = _sigExtract(d.result, 'cbs');
 727            const err = (d.result && JSON.parse(d.result||'{}').error) || '';
 728            mlsWorker.postMessage(JSON.stringify(['M_CRYPTO_RESULT', req.cbID, val, err]));
 729          } else {
 730            _fireSigCb(req.cbID, req.cbType, d.result);
 731          }
 732        }
 733      }
 734    };
 735    signerWorker.onerror = function(e) {
 736      console.error('[signer-worker] error:', e.message, 'at', e.filename + ':' + e.lineno);
 737    };
 738  }
 739  
 740  function startRelayProxyWorker() {
 741    relayProxyWorker = new Worker(new URL('/relay-proxy-wasm-host.mjs', location.href), { type: 'module' });
 742    relayProxyWorker.postMessage({ type: 'init', mode: 'root', wasmUrl: RELAY_PROXY_WASM_URL });
 743    relayProxyWorker.onmessage = function(e) {
 744      const data = e.data;
 745      if (typeof data !== 'string') return;
 746      let parsed;
 747      try { parsed = JSON.parse(data); } catch (e) { return; }
 748      if (!Array.isArray(parsed) || parsed.length === 0) return;
 749      const tag = parsed[0];
 750      if (tag === 'READY') {
 751        const wasReady = relayProxyReady;
 752        relayProxyReady = true;
 753        window.__rpReady = true;
 754        _updateBootStatus('rproxy', 'ok');
 755        while (_pendingRelayProxyMsgs.length > 0) {
 756          relayProxyWorker.postMessage(_pendingRelayProxyMsgs.shift());
 757        }
 758        // On restart (not initial start), notify the app to re-send state.
 759        if (wasReady === false && _relayProxyCrashCount > 0 && worker) {
 760          worker.postMessage({ type: '__relayproxy_msg', msg: '["RELAY_PROXY_READY"]' });
 761        }
 762        return;
 763      }
 764      if (tag === '__ERROR') {
 765        console.error('[relay-proxy-worker]', parsed[1]);
 766        window.__rpError = (window.__rpError || '') + parsed[1] + '|';
 767        return;
 768      }
 769      if (tag === '__WASM_FATAL') {
 770        console.error('[relay-proxy-worker] WASM fatal:', parsed[1]);
 771        _updateBootStatus('rproxy', 'FATAL');
 772        relayProxyReady = false;
 773        if (relayProxyWorker) { relayProxyWorker.terminate(); relayProxyWorker = null; }
 774        _relayProxyCrashCount++;
 775        // Clear any pending store requests from the crashed relay-proxy instance
 776        // so stale reqIDs don't collide with a fresh instance's IDs.
 777        for (const [reqID, origin] of _storeReqs) {
 778          if (origin === 'relay-proxy') { _storeReqs.delete(reqID); _storeReqTimes.delete(reqID); }
 779        }
 780        if (_relayProxyCrashCount <= 2) {
 781          setTimeout(function() {
 782            console.log('[relay-proxy-worker] restarting after WASM fatal', _relayProxyCrashCount);
 783            startRelayProxyWorker();
 784          }, 2000);
 785        } else {
 786          console.warn('[relay-proxy-worker] crash limit reached, not restarting');
 787        }
 788        return;
 789      }
 790      // Intercept S_* messages: relay-proxy is requesting a Store Worker operation.
 791      if (typeof tag === 'string' && tag.startsWith('S_')) {
 792        sendToStore(data, 'relay-proxy');
 793          return;
 794      }
 795      // MLS events from relay-proxy -> MLS worker.
 796      if (tag === 'MLS_EVENT') {
 797        const evJSON = typeof parsed[1]==='string' ? parsed[1] : JSON.stringify(parsed[1]);
 798        if (mlsWorker) {
 799          console.log('[wasm-host] relay-proxy -> MLS worker: M_INCOMING');
 800          mlsWorker.postMessage(JSON.stringify(['M_INCOMING', JSON.parse(evJSON)]));
 801        } else {
 802          console.warn('[wasm-host] MLS_EVENT dropped: mlsWorker not booted');
 803        }
 804        return;
 805      }
 806      if (tag === 'MLS_KP_RESULT') {
 807        const peer = parsed[1];
 808        const evObj = parsed[2];
 809        if (mlsWorker) {
 810          console.log('[wasm-host] relay-proxy -> MLS worker: M_FETCH_KP_RESULT peer:', peer?.slice(0,16)+'...');
 811          if (evObj) {
 812            const evPart = typeof evObj === 'string' ? evObj : JSON.stringify(evObj);
 813            mlsWorker.postMessage('["M_FETCH_KP_RESULT",' + JSON.stringify(peer) + ',' + evPart + ']');
 814          } else {
 815            mlsWorker.postMessage('["M_FETCH_KP_RESULT",' + JSON.stringify(peer) + ',null]');
 816          }
 817        } else {
 818          console.warn('[wasm-host] MLS_KP_RESULT dropped: mlsWorker not booted');
 819        }
 820        return;
 821      }
 822      // Intercept EVENT messages: route through Verify Supervisor before app worker.
 823      // Only verified events reach the app; invalid events are silently dropped.
 824      if (tag === 'EVENT' && _verifyWorkers.length > 0) {
 825        _submitForVerification(data);
 826        return;
 827      }
 828      // Forward all other JSON-array messages (EOSE, OK, CRYPTO_REQ, SEEN_ON, ...) to app-worker.
 829      if (worker) {
 830        worker.postMessage({ type: '__relayproxy_msg', msg: data });
 831      }
 832    };
 833    relayProxyWorker.onerror = function(e) {
 834      var msg = e.message || '(no message)';
 835      var file = e.filename || '(unknown)';
 836      var line = e.lineno || 0;
 837      window.__rpError = (window.__rpError || '') + msg + ' at ' + file + ':' + line + '|';
 838      console.error('[relay-proxy-worker] error:', msg, 'at', file + ':' + line,
 839        e.error && e.error.stack ? '\n' + e.error.stack : '');
 840      relayProxyReady = false;
 841      relayProxyWorker = null;
 842      // Each crashed Worker retains a 4GB WASM address space reservation in Firefox.
 843      // Cap restarts to prevent unbounded memory accumulation. The relay-proxy has a
 844      // known crash in _start() when the WASM heap is exhausted; the fix is upstream.
 845      _relayProxyCrashCount++;
 846      if (_relayProxyCrashCount <= 2) {
 847        setTimeout(function() {
 848          console.log('[relay-proxy-worker] restarting after crash', _relayProxyCrashCount);
 849          startRelayProxyWorker();
 850        }, 2000);
 851      } else {
 852        console.warn('[relay-proxy-worker] crash limit reached, not restarting (prevents 4GB accumulation)');
 853      }
 854    };
 855  }
 856  
 857  function startWorker() {
 858    worker = new Worker(new URL('/wasm-app-worker-host.mjs', location.href), { type: 'module' });
 859    worker.postMessage({ type: 'init', mode: 'root', wasmUrl: WASM_URL });
 860  
 861    worker.onmessage = function(e) {
 862      const d = e.data;
 863      if (d.type === 'hello') return;
 864      if (d.type === 'ready') {
 865        document.body.setAttribute('data-wasm-ready', '1');
 866        _updateBootStatus('app', 'ok');
 867        return;
 868      }
 869      if (d.type === 'exit') return;
 870      if (d.type === 'error') {
 871        console.error('[app-worker]', d.fatal ? 'fatal:' : '', d.message);
 872        // AbortError during fatal boot = SW activation race. Restart the worker.
 873        if (d.fatal && typeof d.message === 'string' && d.message.indexOf('AbortError') >= 0 && !document.body.getAttribute('data-wasm-ready')) {
 874          _updateBootStatus('app', 'crash');
 875          worker.terminate();
 876          worker = null;
 877          setTimeout(startWorker, 1000);
 878        }
 879        return;
 880      }
 881  
 882      // ── DOM sync calls (int result) ──────────────────────────────────────────
 883      if (d.type === 'dom-sync') {
 884        let result = 0;
 885        const a = d.args || [];
 886        switch (d.op) {
 887          case 'body':               result = dom.Body(); break;
 888          case 'create_element':     result = dom.CreateElement(a[0]); break;
 889          case 'create_text_node':   result = dom.CreateTextNode(a[0]); break;
 890          case 'get_element_by_id':  result = dom.GetElementById(a[0]); break;
 891          case 'query_selector':      result = dom.QuerySelector(a[0]); break;
 892          case 'query_selector_from': result = dom.QuerySelectorFrom(a[0], a[1]); break;
 893          case 'prefers_dark':       result = dom.PrefersDark() ? 1 : 0; break;
 894          case 'first_child':        result = dom.FirstChild(a[0]); break;
 895          case 'first_element_child':result = dom.FirstElementChild(a[0]); break;
 896          case 'next_sibling':       result = dom.NextSibling(a[0]); break;
 897          case 'bounding_client_left': result = dom.BoundingClientLeft(a[0]); break;
 898          case 'get_viewport_height':result = dom.GetViewportHeight(); break;
 899          case 'get_viewport_width': result = dom.GetViewportWidth(); break;
 900          case 'now_seconds':        result = Number(dom.NowSeconds()); break;
 901          case 'timezone_offset_seconds': result = dom.TimezoneOffsetSeconds(); break;
 902          case 'confirm':            result = dom.Confirm(a[0]) ? 1 : 0; break;
 903          case 'set_timeout': {
 904            // a[0]=ms, a[1]=cbID; returns timer handle
 905            result = dom.SetTimeout(function() { cb0(a[1]); }, a[0]);
 906            break;
 907          }
 908        }
 909        syncInt(d.sab, result);
 910        return;
 911      }
 912  
 913      // ── DOM sync string result (via SAB bytes area) ──────────────────────────
 914      if (d.type === 'dom-sync-str') {
 915        const a = d.args || [];
 916        let str = '';
 917        switch (d.op) {
 918          case 'hostname':     str = dom.Hostname(); break;
 919          case 'port':         str = dom.Port(); break;
 920          case 'user_agent':   str = dom.UserAgent(); break;
 921          case 'get_path':     str = dom.GetPath(); break;
 922          case 'get_hash':     str = dom.GetHash(); break;
 923          case 'get_property':  str = dom.GetProperty(a[0], a[1]); break;
 924          case 'get_attribute': str = dom.GetAttribute(a[0], a[1]); break;
 925        }
 926        syncStr(d.sab, str);
 927        return;
 928      }
 929  
 930      // ── Markdown ─────────────────────────────────────────────────────────────
 931      if (d.type === 'markdown') {
 932        const i32 = new Int32Array(d.sab);
 933        const str = d.op === 'render' ? (markdown.Render(d.args[0]) || '') : '';
 934        syncStr(d.sab, str);
 935        return;
 936      }
 937  
 938      // ── localStorage sync ───────────────────────────────────────────────────
 939      if (d.type === 'localstorage-sync') {
 940        const str = d.op === 'get_item' ? (localstorage.GetItem(d.args[0]) || '') : '';
 941        syncStr(d.sab, str);
 942        return;
 943      }
 944  
 945      // ── DOM fire-and-forget ─────────────────────────────────────────────────
 946      if (d.type === 'dom') {
 947        const a = d.args || [];
 948        switch (d.op) {
 949          case 'console_log':     dom.ConsoleLog(a[0]); break;
 950          case 'append_child':    dom.AppendChild(a[0], a[1]); break;
 951          case 'remove_child':    dom.RemoveChild(a[0], a[1]); break;
 952          case 'remove':          dom.Remove(a[0]); break;
 953          case 'insert_before':   dom.InsertBefore(a[0], a[1], a[2]); break;
 954          case 'set_attribute':    dom.SetAttribute(a[0], a[1], a[2]); break;
 955          case 'remove_attribute': dom.RemoveAttribute(a[0], a[1]); break;
 956          case 'set_text_content':dom.SetTextContent(a[0], a[1]); break;
 957          case 'set_inner_html':  dom.SetInnerHTML(a[0], a[1]); break;
 958          case 'set_style':       dom.SetStyle(a[0], a[1], a[2]); break;
 959          case 'set_property':    dom.SetProperty(a[0], a[1], a[2]); break;
 960          case 'add_class':       dom.AddClass(a[0], a[1]); break;
 961          case 'remove_class':    dom.RemoveClass(a[0], a[1]); break;
 962          case 'unobserve_resize': dom.UnobserveResize(a[0]); break;
 963          case 'focus':           dom.Focus(a[0]); break;
 964          case 'release_element': dom.ReleaseElement(a[0]); break;
 965          case 'release_all': if (Array.isArray(a[0])) a[0].forEach(id => dom.ReleaseElement(id)); break;
 966          case 'release_children': dom.ReleaseChildren(a[0]); break;
 967          case 'push_state':      dom.PushState(a[0]); break;
 968          case 'replace_state':   dom.ReplaceState(a[0]); break;
 969          case 'back':            dom.Back(); break;
 970          case 'location_reload': dom.LocationReload(); break;
 971          case 'location_assign': dom.LocationAssign(a[0]); break;
 972          case 'hard_refresh':    dom.HardRefresh(); break;
 973          case 'clear_timeout':   dom.ClearTimeout(a[0]); break;
 974          case 'clear_storage_prefix': dom.ClearStoragePrefix(a[0]); break;
 975          case 'idb_set_enc_key': _sendToStoreRaw('["S_ENC_KEY",' + JSON.stringify(a[0]) + ']'); break;
 976          case 'idb_put':         _sendToStoreRaw('["S_KV_PUT",' + JSON.stringify(a[0]) + ',' + JSON.stringify(a[1]) + ',' + JSON.stringify(a[2]) + ']'); break;
 977          case 'post_to_sw':      dom.PostToSW(a[0]); break;
 978          case 'download_text':   dom.DownloadText(a[0], a[1], a[2]); break;
 979          case 'insert_mention_chip':
 980            dom.InsertMentionChip(a[0], a[1], a[2], a[3], a[4]); break;
 981        }
 982        return;
 983      }
 984  
 985      // ── localStorage fire-and-forget ────────────────────────────────────────
 986      if (d.type === 'localstorage') {
 987        switch (d.op) {
 988          case 'set_item':    localstorage.SetItem(d.args[0], d.args[1]); break;
 989          case 'remove_item': localstorage.RemoveItem(d.args[0]); break;
 990        }
 991        return;
 992      }
 993  
 994      // ── DOM async callbacks ─────────────────────────────────────────────────
 995      if (d.type === 'dom-cb') {
 996        const a = d.args || [];
 997        const id = d.cbID;
 998        switch (d.op) {
 999          case 'add_event_listener': {
1000            const cbId = dom.RegisterCallback(function() { cb0(id); });
1001            _jsToWasmCb.set(cbId, id);
1002            dom.AddEventListener(a[0], a[1], cbId);
1003            break;
1004          }
1005          case 'add_self_event_listener': {
1006            const cbId = dom.RegisterCallback(function() { cb0(id); });
1007            _jsToWasmCb.set(cbId, id);
1008            dom.AddSelfEventListener(a[0], a[1], cbId);
1009            break;
1010          }
1011          case 'add_enter_key_listener': {
1012            const cbId = dom.RegisterCallback(function() { cb0(id); });
1013            _jsToWasmCb.set(cbId, id);
1014            dom.AddEnterKeyListener(a[0], cbId);
1015            break;
1016          }
1017          case 'remove_event_listener':
1018            dom.RemoveEventListener(a[0], a[1], id); break;
1019          case 'on_pop_state':
1020            dom.OnPopState(function(path) { cbs(id, path); }); break;
1021          case 'intercept_internal_links':
1022            dom.InterceptInternalLinks(function(path) { cbs(id, path); }); break;
1023          case 'on_sw_message':
1024            dom.OnSWMessage(function(msg) { cbs(id, msg); }); break;
1025          case 'request_animation_frame':
1026            dom.RequestAnimationFrame(function() { cb0(id); }); break;
1027          case 'observe_resize': {
1028            const cbId = dom.RegisterCallback(function() { cb0(id); });
1029            _jsToWasmCb.set(cbId, id);
1030            dom.ObserveResize(a[0], cbId);
1031            break;
1032          }
1033          case 'on_pull_refresh':
1034            dom.OnPullRefresh(a[0], a[1], function() { cb0(id); }); break;
1035          case 'on_paste_image':
1036            dom.OnPasteImage(a[0], function(b64, mime) { cbss(id, b64, mime); }); break;
1037          case 'on_drop_image':
1038            dom.OnDropImage(a[0], function(b64, mime) { cbss(id, b64, mime); }); break;
1039          case 'pick_file_text':
1040            dom.PickFileText(a[0], function(text) { cbs(id, text); }); break;
1041          case 'pick_file_base64':
1042            dom.PickFileBase64(a[0], function(b64, mime) { cbss(id, b64, mime); }); break;
1043          case 'fetch_text':
1044            dom.FetchText(a[0], function(text) { cbs(id, text); }); break;
1045          case 'fetch_relay_info':
1046            dom.FetchRelayInfo(a[0], function(text) { cbs(id, text); }); break;
1047          case 'fetch_put_blob_base64':
1048            dom.FetchPutBlobBase64(a[0], a[1], a[2], a[3], function(text) { cbs(id, text); }); break;
1049          case 'read_clipboard':
1050            dom.ReadClipboard(function(text) { cbs(id, text); }); break;
1051          case 'write_clipboard':
1052            dom.WriteClipboard(a[0], function(ok) { cbb(id, ok); }); break;
1053          case 'write_primary_selection':
1054            dom.WritePrimarySelection(a[0]); break;
1055          case 'copy_image_to_clipboard':
1056            dom.CopyImageToClipboard(a[0], function(ok) { cbb(id, ok); }); break;
1057          case 'window_open':
1058            window.open(a[0], '_blank', 'noopener,noreferrer'); break;
1059          case 'idb_get': {
1060            const reqID = ++_kvReqID;
1061            _kvReqs.set(reqID, { cbID: id, type: 'get' });
1062            _kvReqTimes.set(reqID, Date.now());
1063            _sendToStoreRaw('["S_KV_GET",' + reqID + ',' + JSON.stringify(a[0]) + ',' + JSON.stringify(a[1]) + ']');
1064            break;
1065          }
1066          case 'idb_get_all': {
1067            const reqID = ++_kvReqID;
1068            _kvReqs.set(reqID, { cbID: id, doneCBID: d.doneCB, type: 'getall' });
1069            _kvReqTimes.set(reqID, Date.now());
1070            _sendToStoreRaw('["S_KV_GETALL",' + reqID + ',' + JSON.stringify(a[0]) + ']');
1071            break;
1072          }
1073          case 'get_bounding_rect':
1074            dom.GetBoundingRect(a[0], function(l,t,r,b2,w,h) { cb6i(id,l,t,r,b2,w,h); }); break;
1075          case 'on_keydown':
1076            // Fire __keydown to Worker; preventDefault not supported in proxy.
1077            dom.OnKeydown(a[0], function(key) {
1078              worker && worker.postMessage({ type: '__keydown', id, key });
1079            });
1080            break;
1081        }
1082        return;
1083      }
1084  
1085      // ── WebSocket ────────────────────────────────────────────────────────────
1086      if (d.type === 'ws') {
1087        const a = d.args || [];
1088        switch (d.op) {
1089          case 'dial': {
1090            const connId = ws.Dial(a[0],
1091              function(connId, msg) { fwdCb(a[1], '__ws-msg', { connId, msg }); },
1092              function(connId) { fwdCb(a[2], '__ws-open', { connId }); },
1093              function(connId, code, reason) { fwdCb(a[3], '__ws-close', { connId, code, reason }); },
1094              function(connId) { fwdCb(a[4], '__ws-err', { connId }); }
1095            );
1096            syncInt(d.sab, connId);
1097            break;
1098          }
1099          case 'send': syncInt(d.sab, ws.Send(a[0], a[1]) ? 1 : 0); break;
1100          case 'close': ws.Close(a[0]); break;
1101          case 'ready_state': syncInt(d.sab, ws.ReadyState ? ws.ReadyState(a[0]) : 0); break;
1102        }
1103        return;
1104      }
1105  
1106      // ── Signer sync (HasSigner, HasMLS) ─────────────────────────────────────
1107      if (d.type === 'signer-sync') {
1108        let result = 0;
1109        switch (d.op) {
1110          case 'has_signer': result = _signerWorkerReady ? 1 : 0; break;
1111          case 'has_mls':    result = mlsWorker ? 1 : 0; break;
1112        }
1113        syncInt(d.sab, result);
1114        return;
1115      }
1116  
1117      // ── Signer async — routed through Signer Worker ─────────────────────────
1118      if (d.type === 'signer-async') {
1119        _sendToSigner(d.op, d.args, d.cbID, d.cbType);
1120        return;
1121      }
1122  
1123      // ── Relay-proxy: forward JSON-array msg from app-worker to relay-proxy worker ─
1124      if (d.type === 'relayproxy-send') {
1125        const rmsg = d.msg || '';
1126        sendToRelayProxy(rmsg);
1127        // Broadcast pubkey and relay list to domain workers too.
1128        try {
1129          const rp = JSON.parse(rmsg);
1130          if (Array.isArray(rp) && rp[0] === 'SET_PUBKEY') {
1131            const pk = rp[1] || '';
1132            // MLS/messaging is disabled until the MLS/DM fix lands: the worker is
1133            // never started and M_* messages are dropped (see the mls-send branch
1134            // below and routeMsg in web/wasm/app/main.mx). The MLS plumbing
1135            // (_ensureMlsWorker, _tryMlsAutoInit, _mlsOnMsg, mls-wasm-host.mjs)
1136            // is kept for that fix.
1137            _mlsPubkey = pk;
1138            _domainSend('notif', JSON.stringify(['N_SET_PUBKEY', pk]));
1139            _domainSend('feed', JSON.stringify(['F_SET_PUBKEY', pk]));
1140            _domainSend('profile', JSON.stringify(['P_SET_PUBKEY', pk]));
1141          } else if (Array.isArray(rp) && rp[0] === 'SET_WRITE_RELAYS') {
1142            const urls = rp[1] || [];
1143            _mlsRelayURLs = urls;
1144            _domainSend('feed', JSON.stringify(['F_SET_RELAYS', urls]));
1145            _domainSend('notif', JSON.stringify(['N_SET_RELAYS', urls]));
1146            _domainSend('profile', JSON.stringify(['P_SET_RELAYS', urls]));
1147          }
1148        } catch (_) {}
1149        return;
1150      }
1151  
1152      // ── Domain Worker sends from app worker ─────────────────────────────────
1153      if (d.type === 'profile-send') { _domainSend('profile', d.msg || ''); return; }
1154      if (d.type === 'feed-send')    { console.log('[wasm-host] feed-send dropped (forum parked):', (d.msg||'').slice(0,60)); return; }
1155      if (d.type === 'mls-send')     { console.log('[wasm-host] mls-send dropped (MLS disabled):', (d.msg||'').slice(0,80)); return; }
1156      if (d.type === 'notif-send')   { _domainSend('notif', d.msg || '');   return; }
1157  
1158      // ── Storage proxy for spawned sub-Workers ───────────────────────────────
1159      if (d.type === 'storage-get') {
1160        const val = localstorage.GetItem(d.key) || '';
1161        worker && worker.postMessage({ type: 'storage-result', msgId: d.msgId, value: val });
1162      } else if (d.type === 'storage-set') {
1163        localstorage.SetItem(d.key, d.value);
1164      } else if (d.type === 'storage-remove') {
1165        localstorage.RemoveItem(d.key);
1166      }
1167  
1168      // ── Mem stats reply from worker ──────────────────────────────────────────
1169      if (d.type === '__mem_reply') {
1170        window.__lastMemStats = d;
1171        // Proactive restart: if heap exceeds 128MB, save minimal state and restart.
1172        const HEAP_RESTART_THRESHOLD = 128 * 1024 * 1024; // 128MB
1173        if (d.heapPtr && d.heapPtr > HEAP_RESTART_THRESHOLD) {
1174          console.warn('[app-worker] heap ' + Math.round(d.heapPtr / 1024 / 1024) + 'MB — restarting WASM');
1175          const savedPage = localstorage.GetItem('__wasm_active_page') || '';
1176          localstorage.SetItem('__wasm_restart', '1');
1177          localstorage.SetItem('__wasm_restart_page', savedPage);
1178          worker.terminate();
1179          worker = null;
1180          startWorker();
1181        }
1182      }
1183    };
1184  
1185    worker.onerror = function(e) {
1186      console.error('[app-worker] error:', e.message, 'at', e.filename + ':' + e.lineno,
1187        e.error && e.error.stack ? '\n' + e.error.stack : '');
1188    };
1189  }
1190  
1191  // Poll WASM heap stats every 60s for proactive restart check.
1192  setInterval(function() {
1193    if (worker) worker.postMessage({ type: '__mem_query' });
1194  }, 60000);
1195  
1196  // Reap orphaned request map entries older than 30s.
1197  // These accumulate when a worker crashes before responding. For signer
1198  // requests we also fire the registered callback with a failure value, so the
1199  // app's UI un-sticks instead of waiting forever for a response that will
1200  // never arrive (e.g. when Argon2id crashed the signer worker on mobile).
1201  setInterval(function() {
1202    const cutoff = Date.now() - 30000;
1203    for (const [id, ts] of _storeReqTimes) {
1204      if (ts < cutoff) { _storeReqs.delete(id); _storeReqTimes.delete(id); }
1205    }
1206    for (const [id, ts] of _kvReqTimes) {
1207      if (ts < cutoff) { _kvReqs.delete(id); _kvReqTimes.delete(id); }
1208    }
1209    for (const [id, ts] of _sigReqTimes) {
1210      if (ts < cutoff) {
1211        const req = _sigReqs.get(id);
1212        if (req) {
1213          try {
1214            // Fire callback with failure sentinel so the UI un-sticks.
1215            _fireSigCb(req.cbID, req.cbType, '{"error":"signer-timeout"}');
1216          } catch (e) {
1217            console.error('[signer-reap] callback failed:', e);
1218          }
1219        }
1220        _sigReqs.delete(id); _sigReqTimes.delete(id);
1221      }
1222    }
1223    for (const [id, ts] of _verifyReqTimes) {
1224      if (ts < cutoff) { _verifyReqs.delete(id); _verifyReqTimes.delete(id); }
1225    }
1226  }, 30000);
1227  
1228  // Expose memory profiling API on window.
1229  // Guarded: only active when the WASM exports are present (instrumented build).
1230  window.__moxie_mem_stats = function() {
1231    return window.__lastMemStats || null;
1232  };
1233  window.__moxie_read_alloc_counters = function() {
1234    const s = window.__lastMemStats;
1235    if (!s) return null;
1236    return { n: s.allocN || 0, counts: s.allocCounts || [], sizes: s.allocSizes || [] };
1237  };
1238  window.__moxie_trigger_mem_stats = function() {
1239    if (worker) worker.postMessage({ type: '__mem_query' });
1240  };
1241  // Direct DOM handle counts - always available, no instrumented build needed.
1242  window.__moxie_element_count = function() { return dom.ElementCount(); };
1243  window.__moxie_callback_count = function() { return dom.CallbackCount(); };
1244  
1245  // ── Test-accessible signer API ──────────────────────────────────────────────
1246  // Routes through the in-page Signer Worker. Replaces window.nostr.musiquay for
1247  // automated tests. All methods return Promises that resolve with the result.
1248  (function() {
1249    function _sigCall(method, paramsStr) {
1250      return new Promise(function(resolve) {
1251        if (!signerWorker) { resolve(null); return; }
1252        const reqID = ++_sigReqID;
1253        _sigReqs.set(reqID, {
1254          cbID: 0, cbType: '_test',
1255          _resolve: resolve,
1256        });
1257        _sigReqTimes.set(reqID, Date.now());
1258        signerWorker.postMessage({ id: reqID, method: method, params: paramsStr || '{}' });
1259      });
1260    }
1261    // Override _fireSigCb to handle test callbacks
1262    const _origFire = _fireSigCb;
1263    // Patch the sig-resp handler to route test callbacks
1264    const _origOnMsg = signerWorker ? null : null; // patch after startSignerWorker
1265    window.__musiquay_signer_call = function(method, paramsStr) {
1266      return _sigCall(method, paramsStr);
1267    };
1268    window.__musiquay_vault_status = function() {
1269      return _sigCall('musiquay.getVaultStatus', '{}').then(function(r) {
1270        try { return JSON.parse(r).result || 'none'; } catch { return 'none'; }
1271      });
1272    };
1273    window.__musiquay_create_vault = function(pw) {
1274      return _sigCall('musiquay.createVault', JSON.stringify({password: pw})).then(function(r) {
1275        try { return JSON.parse(r).result === true; } catch { return false; }
1276      });
1277    };
1278    window.__musiquay_unlock_vault = function(pw) {
1279      return _sigCall('musiquay.unlockVault', JSON.stringify({password: pw})).then(function(r) {
1280        try { return JSON.parse(r).result === true; } catch { return false; }
1281      });
1282    };
1283    window.__musiquay_lock_vault = function() {
1284      return _sigCall('musiquay.lockVault', '{}');
1285    };
1286    window.__musiquay_add_identity = function(nsec) {
1287      // addIdentity returns the pubkey hex string on success, not boolean true.
1288      return _sigCall('musiquay.addIdentity', JSON.stringify({nsec: nsec})).then(function(r) {
1289        try {
1290          const p = JSON.parse(r);
1291          if (p.error) return false;
1292          // Success: result is the pubkey string or true
1293          return (typeof p.result === 'string' && p.result.length === 64) ? true : (p.result === true);
1294        } catch { return false; }
1295      });
1296    };
1297    window.__musiquay_nsec_login = function(nsec) {
1298      return _sigCall('musiquay.nsecLogin', JSON.stringify({nsec: nsec})).then(function(r) {
1299        try { return JSON.parse(r).result === true; } catch { return false; }
1300      });
1301    };
1302    window.__musiquay_get_public_key = function() {
1303      return _sigCall('getPublicKey', '{}').then(function(r) {
1304        try { return JSON.parse(r).result || null; } catch { return null; }
1305      });
1306    };
1307    window.__musiquay_list_identities = function() {
1308      return _sigCall('musiquay.listIdentities', '{}').then(function(r) {
1309        try { const p = JSON.parse(r); return p.result || []; } catch { return []; }
1310      });
1311    };
1312    window.__musiquay_reset_extension = function() {
1313      return _sigCall('musiquay.resetExtension', '{}');
1314    };
1315  })();
1316  // Test promise routing is handled inline in startSignerWorker's sig-resp handler.
1317  function _patchSigRespForTests() {}
1318  
1319  // Boot status diagnostic — visible on mobile where devtools aren't available.
1320  const _bootStatus = {};
1321  function _updateBootStatus(name, state) {
1322    _bootStatus[name] = state;
1323    try {
1324      localStorage.setItem('__musiquay_boot_diag', JSON.stringify({ts: Date.now(), status: _bootStatus}));
1325    } catch(_) {}
1326    var el = document.getElementById('boot-diag-' + name);
1327    var c = document.getElementById('boot-diag');
1328    if (!c) return;
1329    if (!el) {
1330      el = document.createElement('div');
1331      el.id = 'boot-diag-' + name;
1332      c.appendChild(el);
1333    }
1334    el.textContent = name + ' ' + state;
1335    el.style.color = state === 'ok' ? '#4a4' : state.indexOf('crash') >= 0 || state === 'DEAD' || state === 'FATAL' ? '#e44' : '#888';
1336  }
1337  (function() {
1338    const d = document.createElement('div');
1339    d.id = 'boot-diag';
1340    d.style.cssText = 'position:fixed;bottom:0;left:0;padding:6px 10px;background:rgba(0,0,0,0.85);font-size:11px;font-family:monospace;z-index:9999;line-height:1.6;border-radius:0 6px 0 0';
1341    document.body.appendChild(d);
1342    var _diagCheck = setInterval(function() {
1343      if (_bootStatus['rproxy'] === 'ok' && _bootStatus['domain'] === 'ok') {
1344        clearInterval(_diagCheck);
1345        setTimeout(function() { if (d.parentNode) d.parentNode.removeChild(d); }, 5000);
1346      }
1347    }, 2000);
1348    setTimeout(function() { clearInterval(_diagCheck); if (d.parentNode) d.parentNode.removeChild(d); }, 120000);
1349  })();
1350  
1351  // Delete legacy musiquay-kv database (profiles/settings/cache moved to musiquay DB).
1352  try { indexedDB.deleteDatabase('musiquay-kv'); } catch(_) {}
1353  
1354  // Boot sequence: verify -> store -> rproxy -> everything else.
1355  // Nothing starts sending subscriptions until rproxy is ready.
1356  _updateBootStatus('verify', 'boot');
1357  _initVerifyWorkers();
1358  _updateBootStatus('verify', 'ok');
1359  _updateBootStatus('store', 'boot');
1360  startStoreWorker(function() {
1361    _updateBootStatus('store', 'ok');
1362    _updateBootStatus('rproxy', 'boot');
1363    startRelayProxyWorker();
1364  });
1365  // Wait for rproxy READY before starting workers that depend on it.
1366  var _rproxyReadyCheck = setInterval(function() {
1367    if (!relayProxyReady) return;
1368    clearInterval(_rproxyReadyCheck);
1369    _updateBootStatus('signer', 'boot');
1370    startSignerWorker();
1371    _updateBootStatus('signer', 'ok');
1372    setTimeout(_patchSigRespForTests, 0);
1373    _updateBootStatus('app', 'boot');
1374    startWorker();
1375    _startDomainWorkers(function() {
1376      _updateBootStatus('domain', 'ok');
1377    });
1378  }, 50);
1379