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