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