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