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