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