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 self.addEventListener('install', (event) => {
44 if (_goInstallHandler) {
45 const id = _storeEvent(event);
46 _goInstallHandler(id);
47 _events.delete(id);
48 } else {
49 _installQueue.push(event);
50 }
51 });
52
53 export function OnInstall(fn) {
54 _goInstallHandler = fn;
55 while (_installQueue.length > 0) {
56 const id = _storeEvent(_installQueue.shift());
57 fn(id);
58 _events.delete(id);
59 }
60 }
61
62 let _goActivateHandler = null;
63 const _activateQueue = [];
64 self.addEventListener('activate', (event) => {
65 if (_goActivateHandler) {
66 const id = _storeEvent(event);
67 _goActivateHandler(id);
68 _events.delete(id);
69 } else {
70 _activateQueue.push(event);
71 }
72 });
73
74 export function OnActivate(fn) {
75 _goActivateHandler = fn;
76 while (_activateQueue.length > 0) {
77 const id = _storeEvent(_activateQueue.shift());
78 fn(id);
79 _events.delete(id);
80 }
81 }
82
83 // Fetch events use a deferred promise pattern:
84 // Go calls RespondWithCache or RespondWithNetwork synchronously to pick strategy.
85 // The runtime creates the promise and calls event.respondWith() synchronously.
86 const _fetchResolvers = new Map();
87
88 let _goFetchHandler = null;
89 self.addEventListener('fetch', (event) => {
90 if (!_goFetchHandler) return; // no handler yet — let browser handle
91 const id = _storeEvent(event);
92 try {
93 _goFetchHandler(id);
94 } catch (e) {
95 _events.delete(id);
96 return;
97 }
98 const resolver = _fetchResolvers.get(id);
99 if (resolver) {
100 event.respondWith(resolver);
101 _fetchResolvers.delete(id);
102 }
103 _events.delete(id);
104 });
105
106 export function OnFetch(fn) {
107 _goFetchHandler = fn;
108 }
109
110 let _goMessageHandler = null;
111
112 // Early message queue — buffers messages arriving before main() registers OnMessage.
113 const _earlyQueue = [];
114 self.addEventListener('message', (event) => {
115 if (_goMessageHandler) {
116 const id = _storeEvent(event);
117 try {
118 _goMessageHandler(id);
119 } catch (e) { /* runtime handles logging */ }
120 _events.delete(id);
121 } else {
122 _earlyQueue.push(event);
123 }
124 });
125
126 export function OnMessage(fn) {
127 _goMessageHandler = fn;
128 while (_earlyQueue.length > 0) {
129 const id = _storeEvent(_earlyQueue.shift());
130 try {
131 fn(id);
132 } catch (e) { /* runtime handles logging */ }
133 _events.delete(id);
134 }
135 }
136
137 // --- Event methods ---
138
139 export function WaitUntil(eventId, fn) {
140 const ev = _events.get(eventId);
141 if (!ev) return;
142 ev.waitUntil(new Promise((resolve) => {
143 fn(resolve);
144 }));
145 }
146
147 export function RespondWith(eventId, respId) {
148 const resp = _responses.get(respId);
149 if (resp) {
150 _fetchResolvers.set(eventId, Promise.resolve(resp));
151 }
152 }
153
154 export function RespondWithNetwork(eventId) {
155 // Don't set a resolver — browser handles the fetch.
156 }
157
158 // RespondWithCacheFirst tries cache, falls back to network.
159 // This is the common pattern and must be called synchronously from onFetch.
160 export function RespondWithCacheFirst(eventId) {
161 const ev = _events.get(eventId);
162 if (!ev) return;
163 _fetchResolvers.set(eventId,
164 caches.match(ev.request).then(cached => cached || fetch(ev.request))
165 );
166 }
167
168 // RespondWithNetworkFirst tries network, falls back to cache.
169 // Updates cache on successful fetch so offline still works.
170 export function RespondWithNetworkFirst(eventId) {
171 const ev = _events.get(eventId);
172 if (!ev) return;
173 _fetchResolvers.set(eventId,
174 fetch(ev.request).then(resp => {
175 if (resp.ok) {
176 const clone = resp.clone();
177 caches.open('smesh').then(c => c.put(ev.request, clone));
178 }
179 return resp;
180 }).catch(() => caches.match(ev.request).then(c => c || new Response('offline', {status: 503})))
181 );
182 }
183
184 export function GetRequestURL(eventId) {
185 const ev = _events.get(eventId);
186 return ev ? ev.request.url : '';
187 }
188
189 export function GetRequestPath(eventId) {
190 const ev = _events.get(eventId);
191 if (!ev) return '';
192 return new URL(ev.request.url).pathname;
193 }
194
195 export function GetMessageData(eventId) {
196 const ev = _events.get(eventId);
197 if (!ev) return '';
198 const d = ev.data;
199 return typeof d === 'string' ? d : JSON.stringify(d);
200 }
201
202 export function GetMessageClientID(eventId) {
203 const ev = _events.get(eventId);
204 if (!ev || !ev.source) return '';
205 return ev.source.id || '';
206 }
207
208 // --- Resource cleanup ---
209
210 export function ReleaseResponse(respId) {
211 _responses.delete(respId);
212 }
213
214 export function ReleaseClient(clientId) {
215 _clients.delete(clientId);
216 }
217
218 // --- SW globals ---
219
220 export function SkipWaiting() {
221 self.skipWaiting();
222 }
223
224 export function ClaimClients(done) {
225 self.clients.claim().then(() => { if (done) done(); });
226 }
227
228 export function MatchClients(fn) {
229 self.clients.matchAll({ type: 'window' }).then((all) => {
230 for (const c of all) {
231 fn(_storeClient(c));
232 }
233 });
234 }
235
236 export function PostMessage(clientId, msg) {
237 const c = _clients.get(clientId);
238 if (c) c.postMessage({ type: '' + msg });
239 }
240
241 export function PostMessageJSON(clientId, json) {
242 const c = _clients.get(clientId);
243 if (c) c.postMessage('' + json);
244 }
245
246 export function GetClientByID(id, fn) {
247 self.clients.get(id).then((client) => {
248 if (client) {
249 fn(_storeClient(client), true);
250 } else {
251 fn(0, false);
252 }
253 });
254 }
255
256 export function Navigate(clientId, url) {
257 const c = _clients.get(clientId);
258 if (c) c.navigate(url || c.url);
259 }
260
261 // --- Cache ---
262
263 export function CacheOpen(name, fn) {
264 caches.open(name).then((cache) => {
265 const id = _nextCacheId++;
266 _caches.set(id, cache);
267 fn(id);
268 }).catch(() => { fn(0); });
269 }
270
271 export function CacheAddAll(cacheId, urls, done) {
272 const cache = _caches.get(cacheId);
273 if (!cache) { if (done) done(); return; }
274 // Add each URL individually, skipping failures.
275 Promise.allSettled(urls.map(u =>
276 fetch(u).then(r => r.ok ? cache.put(u, r) : null).catch(() => {})
277 )).then(() => { if (done) done(); });
278 }
279
280 export function CacheFromManifests(cacheId, staticFiles, done) {
281 const cache = _caches.get(cacheId);
282 if (!cache) { if (done) done(); return; }
283 // Bypass HTTP cache when fetching manifests and assets — otherwise the
284 // browser may serve stale files, defeating the whole point of refresh.
285 const noCache = { cache: 'reload' };
286 Promise.all([
287 fetch('/$manifest.json', noCache).then(r => r.ok ? r.json() : []).catch(() => []),
288 fetch('/$sw/$manifest.json', noCache).then(r => r.ok ? r.json() : []).catch(() => []),
289 ]).then(([app, sw]) => {
290 const urls = new Set();
291 // Static files from Moxie source.
292 if (staticFiles && staticFiles.forEach) {
293 staticFiles.forEach(f => urls.add(f));
294 } else if (staticFiles && staticFiles.$get) {
295 const s = staticFiles.$get();
296 for (let i = 0; i < s.length; i++) urls.add(s.addr ? s.addr(i).$get() : s[i]);
297 }
298 // App build outputs.
299 for (const f of app) urls.add('/' + f);
300 // SW build outputs.
301 for (const f of sw) urls.add('/$sw/' + f);
302 // Cache all, skipping failures. Use cache: 'reload' to bypass HTTP cache.
303 Promise.allSettled([...urls].map(u =>
304 fetch(u, noCache).then(r => r.ok ? cache.put(u, r) : null).catch(() => {})
305 )).then(() => { if (done) done(); });
306 });
307 }
308
309 export function CachePut(cacheId, url, respId, done) {
310 const cache = _caches.get(cacheId);
311 const resp = _responses.get(respId);
312 if (!cache || !resp) { if (done) done(); return; }
313 cache.put(new Request(url), resp).then(() => { if (done) done(); });
314 }
315
316 export function CacheMatch(url, fn) {
317 caches.match(new Request(url)).then((resp) => {
318 fn(_storeResponse(resp));
319 });
320 }
321
322 export function CacheDelete(name, done) {
323 caches.delete(name).then(() => { if (done) done(); });
324 }
325
326 // --- Fetch ---
327
328 export function Fetch(url, fn) {
329 fetch(url).then(
330 (resp) => fn(_storeResponse(resp), true),
331 () => fn(0, false)
332 );
333 }
334
335 export function FetchAll(urls, onEach, onDone) {
336 let remaining = urls.length;
337 if (remaining === 0) { if (onDone) onDone(); return; }
338 urls.forEach((url, i) => {
339 fetch(url).then(
340 (resp) => { onEach(i, _storeResponse(resp), true); },
341 () => { onEach(i, 0, false); }
342 ).finally(() => {
343 remaining--;
344 if (remaining === 0 && onDone) onDone();
345 });
346 });
347 }
348
349 export function ResponseOK(respId) {
350 const resp = _responses.get(respId);
351 return resp ? resp.ok : false;
352 }
353
354 // --- SSE ---
355
356 export function SSEConnect(url, onMessage) {
357 if (typeof EventSource === 'undefined') {
358 // EventSource is not available in Service Worker scope.
359 // Poll instead. Bypass HTTP cache so a stale 304 doesn't mask updates.
360 const id = _nextSseId++;
361 let last = '';
362 function poll() {
363 fetch(url, { cache: 'reload' }).then(r => r.ok ? r.text() : '').then(t => {
364 if (t && t !== last) { last = t; if (onMessage) onMessage(t.replace(/^data:\s*/, '')); }
365 }).catch(() => {});
366 setTimeout(poll, 3000);
367 }
368 setTimeout(poll, 500);
369 return id;
370 }
371 const id = _nextSseId++;
372 const es = new EventSource(url);
373 _sseConns.set(id, es);
374 es.onmessage = (event) => {
375 if (onMessage) onMessage(event.data);
376 };
377 return id;
378 }
379
380 export function SSEClose(sseId) {
381 const es = _sseConns.get(sseId);
382 if (es) {
383 es.close();
384 _sseConns.delete(sseId);
385 }
386 }
387
388 // --- Timers ---
389
390 export function SetTimeout(ms, fn) {
391 return setTimeout(fn, ms);
392 }
393
394 export function ClearTimeout(timerId) {
395 clearTimeout(timerId);
396 }
397
398 // --- Time ---
399
400 export function NowSeconds() {
401 return BigInt(Math.floor(Date.now() / 1000));
402 }
403
404 export function NowMillis() {
405 return BigInt(Date.now());
406 }
407
408 // --- SW globals ---
409
410 export function Origin() {
411 return self.location.origin;
412 }
413
414 // --- Logging ---
415
416 export function Log(msg) {
417 console.log('sw:', msg);
418 }
419
420