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