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