sw.mjs raw
1 // TinyJS Runtime — Service Worker Bridge
2 // Provides Go-callable Service Worker, Cache, Fetch, and SSE operations.
3
4 // --- Internal state ---
5
6 const _events = new Map();
7 let _nextEventId = 1;
8 const _caches = new Map();
9 let _nextCacheId = 1;
10 const _responses = new Map();
11 let _nextRespId = 1;
12 const _clients = new Map();
13 let _nextClientId = 1;
14 const _sseConns = new Map();
15 let _nextSseId = 1;
16
17 function _storeEvent(ev) {
18 const id = _nextEventId++;
19 _events.set(id, ev);
20 return id;
21 }
22
23 function _storeResponse(resp) {
24 if (!resp) return 0;
25 const id = _nextRespId++;
26 _responses.set(id, resp);
27 return id;
28 }
29
30 function _storeClient(client) {
31 const id = _nextClientId++;
32 _clients.set(id, client);
33 return id;
34 }
35
36 // --- Lifecycle ---
37 // Firefox requires all SW event listeners to be registered during the
38 // script's initial synchronous evaluation. Register them eagerly and
39 // queue events until Go's main() calls On*(fn).
40
41 let _goInstallHandler = null;
42 const _installQueue = [];
43 const _lifecycleResolvers = new Map();
44 self.addEventListener('install', (event) => {
45 if (_goInstallHandler) {
46 const id = _storeEvent(event);
47 _goInstallHandler(id);
48 _events.delete(id);
49 } else {
50 let resolve;
51 event.waitUntil(new Promise((r) => { resolve = r; }));
52 _installQueue.push({ event, resolve });
53 }
54 });
55
56 export function OnInstall(fn) {
57 _goInstallHandler = fn;
58 while (_installQueue.length > 0) {
59 const { event, resolve } = _installQueue.shift();
60 const id = _storeEvent(event);
61 _lifecycleResolvers.set(id, resolve);
62 fn(id);
63 _events.delete(id);
64 }
65 }
66
67 let _goActivateHandler = null;
68 const _activateQueue = [];
69 self.addEventListener('activate', (event) => {
70 if (_goActivateHandler) {
71 const id = _storeEvent(event);
72 _goActivateHandler(id);
73 _events.delete(id);
74 } else {
75 let resolve;
76 event.waitUntil(new Promise((r) => { resolve = r; }));
77 _activateQueue.push({ event, resolve });
78 }
79 });
80
81 export function OnActivate(fn) {
82 _goActivateHandler = fn;
83 while (_activateQueue.length > 0) {
84 const { event, resolve } = _activateQueue.shift();
85 const id = _storeEvent(event);
86 _lifecycleResolvers.set(id, resolve);
87 fn(id);
88 _events.delete(id);
89 }
90 }
91
92 // Fetch events use a deferred promise pattern:
93 // Go calls RespondWithCache or RespondWithNetwork synchronously to pick strategy.
94 // The runtime creates the promise and calls event.respondWith() synchronously.
95 const _fetchResolvers = new Map();
96
97 let _goFetchHandler = null;
98 self.addEventListener('fetch', (event) => {
99 if (!_goFetchHandler) return; // no handler yet — let browser handle
100 const id = _storeEvent(event);
101 try {
102 _goFetchHandler(id);
103 } catch (e) {
104 _events.delete(id);
105 return;
106 }
107 const resolver = _fetchResolvers.get(id);
108 if (resolver) {
109 event.respondWith(resolver);
110 _fetchResolvers.delete(id);
111 }
112 _events.delete(id);
113 });
114
115 export function OnFetch(fn) {
116 _goFetchHandler = fn;
117 }
118
119 let _goMessageHandler = null;
120
121 // Early message queue — buffers messages arriving before main() registers OnMessage.
122 const _earlyQueue = [];
123 self.addEventListener('message', (event) => {
124 if (_goMessageHandler) {
125 const id = _storeEvent(event);
126 try {
127 _goMessageHandler(id);
128 } catch (e) { /* runtime handles logging */ }
129 _events.delete(id);
130 } else {
131 _earlyQueue.push(event);
132 }
133 });
134
135 export function OnMessage(fn) {
136 _goMessageHandler = fn;
137 while (_earlyQueue.length > 0) {
138 const id = _storeEvent(_earlyQueue.shift());
139 try {
140 fn(id);
141 } catch (e) { /* runtime handles logging */ }
142 _events.delete(id);
143 }
144 }
145
146 // --- Event methods ---
147
148 export function WaitUntil(eventId, fn) {
149 const resolver = _lifecycleResolvers.get(eventId);
150 if (resolver) {
151 _lifecycleResolvers.delete(eventId);
152 fn(resolver);
153 return;
154 }
155 const ev = _events.get(eventId);
156 if (!ev) return;
157 ev.waitUntil(new Promise((resolve) => {
158 fn(resolve);
159 }));
160 }
161
162 export function RespondWith(eventId, respId) {
163 const resp = _responses.get(respId);
164 if (resp) {
165 _fetchResolvers.set(eventId, Promise.resolve(resp));
166 }
167 }
168
169 export function RespondWithNetwork(eventId) {
170 // Don't set a resolver — browser handles the fetch.
171 }
172
173 // RespondWithCacheFirst tries cache, falls back to network.
174 // This is the common pattern and must be called synchronously from onFetch.
175 export function RespondWithCacheFirst(eventId) {
176 const ev = _events.get(eventId);
177 if (!ev) return;
178 _fetchResolvers.set(eventId,
179 caches.match(ev.request).then(cached => cached || fetch(ev.request))
180 );
181 }
182
183 // RespondWithProxyCache handles /proxy/<base64url> requests with a
184 // normalized cache key (the decoded upstream URL) so two different
185 // base64url encodings of the same upstream share one cache entry. Stamps
186 // each cached Response with X-Smesh-Cached-At for age-based prune. Cache
187 // hits younger than 30 days are returned; older or missing entries fall
188 // through to network and are re-cached on success.
189 const PROXY_CACHE_NAME = 'musiquay-proxy';
190 const PROXY_CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
191 function _decodeProxyB64Url(s) {
192 s = s.replace(/-/g, '+').replace(/_/g, '/');
193 while (s.length % 4) s += '=';
194 try { return atob(s); } catch (e) { return ''; }
195 }
196 function _proxyCacheKey(requestUrl) {
197 const idx = requestUrl.indexOf('/proxy/');
198 if (idx < 0) return requestUrl;
199 const enc = requestUrl.substr(idx + '/proxy/'.length);
200 const decoded = _decodeProxyB64Url(enc);
201 if (!decoded) return requestUrl;
202 return new URL('/__proxy/' + encodeURIComponent(decoded), requestUrl).toString();
203 }
204 async function _wrapWithCachedAt(resp) {
205 const blob = await resp.clone().blob();
206 const headers = new Headers(resp.headers);
207 headers.set('X-Smesh-Cached-At', String(Date.now()));
208 return new Response(blob, { status: resp.status, statusText: resp.statusText, headers });
209 }
210 export function RespondWithProxyCache(eventId) {
211 const ev = _events.get(eventId);
212 if (!ev) return;
213 const key = _proxyCacheKey(ev.request.url);
214 _fetchResolvers.set(eventId, (async () => {
215 const cache = await caches.open(PROXY_CACHE_NAME);
216 const cached = await cache.match(key);
217 if (cached) {
218 const stamp = Number(cached.headers.get('X-Smesh-Cached-At') || '0');
219 if (stamp && Date.now() - stamp < PROXY_CACHE_TTL_MS) {
220 return cached;
221 }
222 }
223 const resp = await fetch(ev.request);
224 if (resp.ok) {
225 const wrapped = await _wrapWithCachedAt(resp.clone());
226 cache.put(key, wrapped).catch(() => {});
227 }
228 return resp;
229 })());
230 }
231 export function PruneProxyCache(maxAgeMs, fn) {
232 (async () => {
233 try {
234 const cache = await caches.open(PROXY_CACHE_NAME);
235 const keys = await cache.keys();
236 const now = Date.now();
237 for (const req of keys) {
238 const resp = await cache.match(req);
239 if (!resp) continue;
240 const stamp = Number(resp.headers.get('X-Smesh-Cached-At') || '0');
241 if (!stamp || now - stamp > maxAgeMs) {
242 await cache.delete(req);
243 }
244 }
245 } catch (e) {
246 // best-effort
247 }
248 if (fn) fn();
249 })();
250 }
251
252 // RespondWithNetworkFirst tries network, falls back to cache.
253 // Updates cache on successful fetch so offline still works.
254 export function RespondWithNetworkFirst(eventId) {
255 const ev = _events.get(eventId);
256 if (!ev) return;
257 _fetchResolvers.set(eventId,
258 fetch(ev.request).then(resp => {
259 if (resp.ok) {
260 const clone = resp.clone();
261 caches.open('musiquay').then(c => c.put(ev.request, clone));
262 }
263 return resp;
264 }).catch(() => caches.match(ev.request).then(c => c || new Response('offline', {status: 503})))
265 );
266 }
267
268 export function GetRequestURL(eventId) {
269 const ev = _events.get(eventId);
270 return ev ? ev.request.url : '';
271 }
272
273 export function GetRequestPath(eventId) {
274 const ev = _events.get(eventId);
275 if (!ev) return '';
276 return new URL(ev.request.url).pathname;
277 }
278
279 export function GetMessageData(eventId) {
280 const ev = _events.get(eventId);
281 if (!ev) return '';
282 const d = ev.data;
283 return typeof d === 'string' ? d : JSON.stringify(d);
284 }
285
286 export function GetMessageClientID(eventId) {
287 const ev = _events.get(eventId);
288 if (!ev || !ev.source) return '';
289 return ev.source.id || '';
290 }
291
292 // --- Resource cleanup ---
293
294 export function ReleaseResponse(respId) {
295 _responses.delete(respId);
296 }
297
298 export function ReleaseClient(clientId) {
299 _clients.delete(clientId);
300 }
301
302 // --- SW globals ---
303
304 export function SkipWaiting() {
305 self.skipWaiting();
306 }
307
308 export function ClaimClients(done) {
309 self.clients.claim().then(() => { if (done) done(); });
310 }
311
312 export function MatchClients(fn) {
313 self.clients.matchAll({ type: 'window' }).then((all) => {
314 for (const c of all) {
315 fn(_storeClient(c));
316 }
317 });
318 }
319
320 export function PostMessage(clientId, msg) {
321 const c = _clients.get(clientId);
322 if (c) c.postMessage({ type: '' + msg });
323 }
324
325 export function PostMessageJSON(clientId, json) {
326 const c = _clients.get(clientId);
327 if (c) c.postMessage('' + json);
328 }
329
330 export function GetClientByID(id, fn) {
331 self.clients.get(id).then((client) => {
332 if (client) {
333 fn(_storeClient(client), true);
334 } else {
335 fn(0, false);
336 }
337 });
338 }
339
340 export function Navigate(clientId, url) {
341 const c = _clients.get(clientId);
342 if (c) c.navigate(url || c.url);
343 }
344
345 // --- Cache ---
346
347 export function CacheOpen(name, fn) {
348 caches.open(name).then((cache) => {
349 const id = _nextCacheId++;
350 _caches.set(id, cache);
351 fn(id);
352 }).catch(() => { fn(0); });
353 }
354
355 export function CacheAddAll(cacheId, urls, done) {
356 const cache = _caches.get(cacheId);
357 if (!cache) { if (done) done(); return; }
358 // Add each URL individually, skipping failures.
359 Promise.allSettled(urls.map(u =>
360 fetch(u).then(r => r.ok ? cache.put(u, r) : null).catch(() => {})
361 )).then(() => { if (done) done(); });
362 }
363
364 export function CacheFromManifests(cacheId, staticFiles, done) {
365 const cache = _caches.get(cacheId);
366 if (!cache) { if (done) done(); return; }
367 // Bypass HTTP cache when fetching manifests and assets — otherwise the
368 // browser may serve stale files, defeating the whole point of refresh.
369 const noCache = { cache: 'reload' };
370 Promise.all([
371 fetch('/$manifest.json', noCache).then(r => r.ok ? r.json() : []).catch(() => []),
372 fetch('/$sw/$manifest.json', noCache).then(r => r.ok ? r.json() : []).catch(() => []),
373 ]).then(([app, sw]) => {
374 const urls = new Set();
375 // Static files from Moxie source.
376 if (staticFiles && staticFiles.forEach) {
377 staticFiles.forEach(f => urls.add(f));
378 } else if (staticFiles && staticFiles.$get) {
379 const s = staticFiles.$get();
380 for (let i = 0; i < s.length; i++) urls.add(s.addr ? s.addr(i).$get() : s[i]);
381 }
382 // App build outputs.
383 for (const f of app) urls.add('/' + f);
384 // SW build outputs.
385 for (const f of sw) urls.add('/$sw/' + f);
386 // Cache all, skipping failures. Use cache: 'reload' to bypass HTTP cache.
387 Promise.allSettled([...urls].map(u =>
388 fetch(u, noCache).then(r => r.ok ? cache.put(u, r) : null).catch(() => {})
389 )).then(() => { if (done) done(); });
390 });
391 }
392
393 export function CachePut(cacheId, url, respId, done) {
394 const cache = _caches.get(cacheId);
395 const resp = _responses.get(respId);
396 if (!cache || !resp) { if (done) done(); return; }
397 cache.put(new Request(url), resp).then(() => { if (done) done(); });
398 }
399
400 export function CacheMatch(url, fn) {
401 caches.match(new Request(url)).then((resp) => {
402 fn(_storeResponse(resp));
403 });
404 }
405
406 export function CacheDelete(name, done) {
407 caches.delete(name).then(() => { if (done) done(); });
408 }
409
410 // --- Fetch ---
411
412 export function Fetch(url, fn) {
413 fetch(url).then(
414 (resp) => fn(_storeResponse(resp), true),
415 () => fn(0, false)
416 );
417 }
418
419 export function FetchAll(urls, onEach, onDone) {
420 let remaining = urls.length;
421 if (remaining === 0) { if (onDone) onDone(); return; }
422 urls.forEach((url, i) => {
423 fetch(url).then(
424 (resp) => { onEach(i, _storeResponse(resp), true); },
425 () => { onEach(i, 0, false); }
426 ).finally(() => {
427 remaining--;
428 if (remaining === 0 && onDone) onDone();
429 });
430 });
431 }
432
433 export function ResponseOK(respId) {
434 const resp = _responses.get(respId);
435 return resp ? resp.ok : false;
436 }
437
438 // --- SSE ---
439
440 export function SSEConnect(url, onMessage) {
441 if (typeof EventSource === 'undefined') {
442 // EventSource is not available in Service Worker scope.
443 // Poll instead. Bypass HTTP cache so a stale 304 doesn't mask updates.
444 const id = _nextSseId++;
445 let last = '';
446 function poll() {
447 fetch(url, { cache: 'reload' }).then(r => r.ok ? r.text() : '').then(t => {
448 if (t && t !== last) { last = t; if (onMessage) onMessage(t.replace(/^data:\s*/, '')); }
449 }).catch(() => {});
450 setTimeout(poll, 3000);
451 }
452 setTimeout(poll, 500);
453 return id;
454 }
455 const id = _nextSseId++;
456 const es = new EventSource(url);
457 _sseConns.set(id, es);
458 es.onmessage = (event) => {
459 if (onMessage) onMessage(event.data);
460 };
461 return id;
462 }
463
464 export function SSEClose(sseId) {
465 const es = _sseConns.get(sseId);
466 if (es) {
467 es.close();
468 _sseConns.delete(sseId);
469 }
470 }
471
472 // --- Timers ---
473
474 export function SetTimeout(ms, fn) {
475 return setTimeout(fn, ms);
476 }
477
478 export function ClearTimeout(timerId) {
479 clearTimeout(timerId);
480 }
481
482 // --- Time ---
483
484 export function NowSeconds() {
485 return BigInt(Math.floor(Date.now() / 1000));
486 }
487
488 export function NowMillis() {
489 return BigInt(Date.now());
490 }
491
492 // --- SW globals ---
493
494 export function Origin() {
495 return self.location.origin;
496 }
497
498 // --- Logging ---
499
500 export function Log(msg) {
501 console.log('sw:', msg);
502 }
503
504