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