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  const _lifecycleResolvers = new Map();
  44  self.addEventListener('install', (event) => {
  45    if (_goInstallHandler) {
  46      const id = _storeEvent(event);
  47      _goInstallHandler(id);
  48      _events.delete(id);
  49    } else {
  50      let resolve;
  51      event.waitUntil(new Promise((r) => { resolve = r; }));
  52      _installQueue.push({ event, resolve });
  53    }
  54  });
  55  
  56  export function OnInstall(fn) {
  57    _goInstallHandler = fn;
  58    while (_installQueue.length > 0) {
  59      const { event, resolve } = _installQueue.shift();
  60      const id = _storeEvent(event);
  61      _lifecycleResolvers.set(id, resolve);
  62      fn(id);
  63      _events.delete(id);
  64    }
  65  }
  66  
  67  let _goActivateHandler = null;
  68  const _activateQueue = [];
  69  self.addEventListener('activate', (event) => {
  70    if (_goActivateHandler) {
  71      const id = _storeEvent(event);
  72      _goActivateHandler(id);
  73      _events.delete(id);
  74    } else {
  75      let resolve;
  76      event.waitUntil(new Promise((r) => { resolve = r; }));
  77      _activateQueue.push({ event, resolve });
  78    }
  79  });
  80  
  81  export function OnActivate(fn) {
  82    _goActivateHandler = fn;
  83    while (_activateQueue.length > 0) {
  84      const { event, resolve } = _activateQueue.shift();
  85      const id = _storeEvent(event);
  86      _lifecycleResolvers.set(id, resolve);
  87      fn(id);
  88      _events.delete(id);
  89    }
  90  }
  91  
  92  // Fetch events use a deferred promise pattern:
  93  // Go calls RespondWithCache or RespondWithNetwork synchronously to pick strategy.
  94  // The runtime creates the promise and calls event.respondWith() synchronously.
  95  const _fetchResolvers = new Map();
  96  
  97  let _goFetchHandler = null;
  98  self.addEventListener('fetch', (event) => {
  99    if (!_goFetchHandler) return; // no handler yet — let browser handle
 100    const id = _storeEvent(event);
 101    try {
 102      _goFetchHandler(id);
 103    } catch (e) {
 104      _events.delete(id);
 105      return;
 106    }
 107    const resolver = _fetchResolvers.get(id);
 108    if (resolver) {
 109      event.respondWith(resolver);
 110      _fetchResolvers.delete(id);
 111    }
 112    _events.delete(id);
 113  });
 114  
 115  export function OnFetch(fn) {
 116    _goFetchHandler = fn;
 117  }
 118  
 119  let _goMessageHandler = null;
 120  
 121  // Early message queue — buffers messages arriving before main() registers OnMessage.
 122  const _earlyQueue = [];
 123  self.addEventListener('message', (event) => {
 124    if (_goMessageHandler) {
 125      const id = _storeEvent(event);
 126      try {
 127        _goMessageHandler(id);
 128      } catch (e) { /* runtime handles logging */ }
 129      _events.delete(id);
 130    } else {
 131      _earlyQueue.push(event);
 132    }
 133  });
 134  
 135  export function OnMessage(fn) {
 136    _goMessageHandler = fn;
 137    while (_earlyQueue.length > 0) {
 138      const id = _storeEvent(_earlyQueue.shift());
 139      try {
 140        fn(id);
 141      } catch (e) { /* runtime handles logging */ }
 142      _events.delete(id);
 143    }
 144  }
 145  
 146  // --- Event methods ---
 147  
 148  export function WaitUntil(eventId, fn) {
 149    const resolver = _lifecycleResolvers.get(eventId);
 150    if (resolver) {
 151      _lifecycleResolvers.delete(eventId);
 152      fn(resolver);
 153      return;
 154    }
 155    const ev = _events.get(eventId);
 156    if (!ev) return;
 157    ev.waitUntil(new Promise((resolve) => {
 158      fn(resolve);
 159    }));
 160  }
 161  
 162  export function RespondWith(eventId, respId) {
 163    const resp = _responses.get(respId);
 164    if (resp) {
 165      _fetchResolvers.set(eventId, Promise.resolve(resp));
 166    }
 167  }
 168  
 169  export function RespondWithNetwork(eventId) {
 170    // Don't set a resolver — browser handles the fetch.
 171  }
 172  
 173  // RespondWithCacheFirst tries cache, falls back to network.
 174  // This is the common pattern and must be called synchronously from onFetch.
 175  export function RespondWithCacheFirst(eventId) {
 176    const ev = _events.get(eventId);
 177    if (!ev) return;
 178    _fetchResolvers.set(eventId,
 179      caches.match(ev.request).then(cached => cached || fetch(ev.request))
 180    );
 181  }
 182  
 183  // RespondWithProxyCache handles /proxy/<base64url> requests with a
 184  // normalized cache key (the decoded upstream URL) so two different
 185  // base64url encodings of the same upstream share one cache entry. Stamps
 186  // each cached Response with X-Smesh-Cached-At for age-based prune. Cache
 187  // hits younger than 30 days are returned; older or missing entries fall
 188  // through to network and are re-cached on success.
 189  const PROXY_CACHE_NAME = 'musiquay-proxy';
 190  const PROXY_CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
 191  function _decodeProxyB64Url(s) {
 192    s = s.replace(/-/g, '+').replace(/_/g, '/');
 193    while (s.length % 4) s += '=';
 194    try { return atob(s); } catch (e) { return ''; }
 195  }
 196  function _proxyCacheKey(requestUrl) {
 197    const idx = requestUrl.indexOf('/proxy/');
 198    if (idx < 0) return requestUrl;
 199    const enc = requestUrl.substr(idx + '/proxy/'.length);
 200    const decoded = _decodeProxyB64Url(enc);
 201    if (!decoded) return requestUrl;
 202    return new URL('/__proxy/' + encodeURIComponent(decoded), requestUrl).toString();
 203  }
 204  async function _wrapWithCachedAt(resp) {
 205    const blob = await resp.clone().blob();
 206    const headers = new Headers(resp.headers);
 207    headers.set('X-Smesh-Cached-At', String(Date.now()));
 208    return new Response(blob, { status: resp.status, statusText: resp.statusText, headers });
 209  }
 210  export function RespondWithProxyCache(eventId) {
 211    const ev = _events.get(eventId);
 212    if (!ev) return;
 213    const key = _proxyCacheKey(ev.request.url);
 214    _fetchResolvers.set(eventId, (async () => {
 215      const cache = await caches.open(PROXY_CACHE_NAME);
 216      const cached = await cache.match(key);
 217      if (cached) {
 218        const stamp = Number(cached.headers.get('X-Smesh-Cached-At') || '0');
 219        if (stamp && Date.now() - stamp < PROXY_CACHE_TTL_MS) {
 220          return cached;
 221        }
 222      }
 223      const resp = await fetch(ev.request);
 224      if (resp.ok) {
 225        const wrapped = await _wrapWithCachedAt(resp.clone());
 226        cache.put(key, wrapped).catch(() => {});
 227      }
 228      return resp;
 229    })());
 230  }
 231  export function PruneProxyCache(maxAgeMs, fn) {
 232    (async () => {
 233      try {
 234        const cache = await caches.open(PROXY_CACHE_NAME);
 235        const keys = await cache.keys();
 236        const now = Date.now();
 237        for (const req of keys) {
 238          const resp = await cache.match(req);
 239          if (!resp) continue;
 240          const stamp = Number(resp.headers.get('X-Smesh-Cached-At') || '0');
 241          if (!stamp || now - stamp > maxAgeMs) {
 242            await cache.delete(req);
 243          }
 244        }
 245      } catch (e) {
 246        // best-effort
 247      }
 248      if (fn) fn();
 249    })();
 250  }
 251  
 252  // RespondWithNetworkFirst tries network, falls back to cache.
 253  // Updates cache on successful fetch so offline still works.
 254  export function RespondWithNetworkFirst(eventId) {
 255    const ev = _events.get(eventId);
 256    if (!ev) return;
 257    _fetchResolvers.set(eventId,
 258      fetch(ev.request).then(resp => {
 259        if (resp.ok) {
 260          const clone = resp.clone();
 261          caches.open('musiquay').then(c => c.put(ev.request, clone));
 262        }
 263        return resp;
 264      }).catch(() => caches.match(ev.request).then(c => c || new Response('offline', {status: 503})))
 265    );
 266  }
 267  
 268  export function GetRequestURL(eventId) {
 269    const ev = _events.get(eventId);
 270    return ev ? ev.request.url : '';
 271  }
 272  
 273  export function GetRequestPath(eventId) {
 274    const ev = _events.get(eventId);
 275    if (!ev) return '';
 276    return new URL(ev.request.url).pathname;
 277  }
 278  
 279  export function GetMessageData(eventId) {
 280    const ev = _events.get(eventId);
 281    if (!ev) return '';
 282    const d = ev.data;
 283    return typeof d === 'string' ? d : JSON.stringify(d);
 284  }
 285  
 286  export function GetMessageClientID(eventId) {
 287    const ev = _events.get(eventId);
 288    if (!ev || !ev.source) return '';
 289    return ev.source.id || '';
 290  }
 291  
 292  // --- Resource cleanup ---
 293  
 294  export function ReleaseResponse(respId) {
 295    _responses.delete(respId);
 296  }
 297  
 298  export function ReleaseClient(clientId) {
 299    _clients.delete(clientId);
 300  }
 301  
 302  // --- SW globals ---
 303  
 304  export function SkipWaiting() {
 305    self.skipWaiting();
 306  }
 307  
 308  export function ClaimClients(done) {
 309    self.clients.claim().then(() => { if (done) done(); });
 310  }
 311  
 312  export function MatchClients(fn) {
 313    self.clients.matchAll({ type: 'window' }).then((all) => {
 314      for (const c of all) {
 315        fn(_storeClient(c));
 316      }
 317    });
 318  }
 319  
 320  export function PostMessage(clientId, msg) {
 321    const c = _clients.get(clientId);
 322    if (c) c.postMessage({ type: '' + msg });
 323  }
 324  
 325  export function PostMessageJSON(clientId, json) {
 326    const c = _clients.get(clientId);
 327    if (c) c.postMessage('' + json);
 328  }
 329  
 330  export function GetClientByID(id, fn) {
 331    self.clients.get(id).then((client) => {
 332      if (client) {
 333        fn(_storeClient(client), true);
 334      } else {
 335        fn(0, false);
 336      }
 337    });
 338  }
 339  
 340  export function Navigate(clientId, url) {
 341    const c = _clients.get(clientId);
 342    if (c) c.navigate(url || c.url);
 343  }
 344  
 345  // --- Cache ---
 346  
 347  export function CacheOpen(name, fn) {
 348    caches.open(name).then((cache) => {
 349      const id = _nextCacheId++;
 350      _caches.set(id, cache);
 351      fn(id);
 352    }).catch(() => { fn(0); });
 353  }
 354  
 355  export function CacheAddAll(cacheId, urls, done) {
 356    const cache = _caches.get(cacheId);
 357    if (!cache) { if (done) done(); return; }
 358    // Add each URL individually, skipping failures.
 359    Promise.allSettled(urls.map(u =>
 360      fetch(u).then(r => r.ok ? cache.put(u, r) : null).catch(() => {})
 361    )).then(() => { if (done) done(); });
 362  }
 363  
 364  export function CacheFromManifests(cacheId, staticFiles, done) {
 365    const cache = _caches.get(cacheId);
 366    if (!cache) { if (done) done(); return; }
 367    // Bypass HTTP cache when fetching manifests and assets — otherwise the
 368    // browser may serve stale files, defeating the whole point of refresh.
 369    const noCache = { cache: 'reload' };
 370    Promise.all([
 371      fetch('/$manifest.json', noCache).then(r => r.ok ? r.json() : []).catch(() => []),
 372      fetch('/$sw/$manifest.json', noCache).then(r => r.ok ? r.json() : []).catch(() => []),
 373    ]).then(([app, sw]) => {
 374      const urls = new Set();
 375      // Static files from Moxie source.
 376      if (staticFiles && staticFiles.forEach) {
 377        staticFiles.forEach(f => urls.add(f));
 378      } else if (staticFiles && staticFiles.$get) {
 379        const s = staticFiles.$get();
 380        for (let i = 0; i < s.length; i++) urls.add(s.addr ? s.addr(i).$get() : s[i]);
 381      }
 382      // App build outputs.
 383      for (const f of app) urls.add('/' + f);
 384      // SW build outputs.
 385      for (const f of sw) urls.add('/$sw/' + f);
 386      // Cache all, skipping failures. Use cache: 'reload' to bypass HTTP cache.
 387      Promise.allSettled([...urls].map(u =>
 388        fetch(u, noCache).then(r => r.ok ? cache.put(u, r) : null).catch(() => {})
 389      )).then(() => { if (done) done(); });
 390    });
 391  }
 392  
 393  export function CachePut(cacheId, url, respId, done) {
 394    const cache = _caches.get(cacheId);
 395    const resp = _responses.get(respId);
 396    if (!cache || !resp) { if (done) done(); return; }
 397    cache.put(new Request(url), resp).then(() => { if (done) done(); });
 398  }
 399  
 400  export function CacheMatch(url, fn) {
 401    caches.match(new Request(url)).then((resp) => {
 402      fn(_storeResponse(resp));
 403    });
 404  }
 405  
 406  export function CacheDelete(name, done) {
 407    caches.delete(name).then(() => { if (done) done(); });
 408  }
 409  
 410  // --- Fetch ---
 411  
 412  export function Fetch(url, fn) {
 413    fetch(url).then(
 414      (resp) => fn(_storeResponse(resp), true),
 415      () => fn(0, false)
 416    );
 417  }
 418  
 419  export function FetchAll(urls, onEach, onDone) {
 420    let remaining = urls.length;
 421    if (remaining === 0) { if (onDone) onDone(); return; }
 422    urls.forEach((url, i) => {
 423      fetch(url).then(
 424        (resp) => { onEach(i, _storeResponse(resp), true); },
 425        () => { onEach(i, 0, false); }
 426      ).finally(() => {
 427        remaining--;
 428        if (remaining === 0 && onDone) onDone();
 429      });
 430    });
 431  }
 432  
 433  export function ResponseOK(respId) {
 434    const resp = _responses.get(respId);
 435    return resp ? resp.ok : false;
 436  }
 437  
 438  // --- SSE ---
 439  
 440  export function SSEConnect(url, onMessage) {
 441    if (typeof EventSource === 'undefined') {
 442      // EventSource is not available in Service Worker scope.
 443      // Poll instead. Bypass HTTP cache so a stale 304 doesn't mask updates.
 444      const id = _nextSseId++;
 445      let last = '';
 446      function poll() {
 447        fetch(url, { cache: 'reload' }).then(r => r.ok ? r.text() : '').then(t => {
 448          if (t && t !== last) { last = t; if (onMessage) onMessage(t.replace(/^data:\s*/, '')); }
 449        }).catch(() => {});
 450        setTimeout(poll, 3000);
 451      }
 452      setTimeout(poll, 500);
 453      return id;
 454    }
 455    const id = _nextSseId++;
 456    const es = new EventSource(url);
 457    _sseConns.set(id, es);
 458    es.onmessage = (event) => {
 459      if (onMessage) onMessage(event.data);
 460    };
 461    return id;
 462  }
 463  
 464  export function SSEClose(sseId) {
 465    const es = _sseConns.get(sseId);
 466    if (es) {
 467      es.close();
 468      _sseConns.delete(sseId);
 469    }
 470  }
 471  
 472  // --- Timers ---
 473  
 474  export function SetTimeout(ms, fn) {
 475    return setTimeout(fn, ms);
 476  }
 477  
 478  export function ClearTimeout(timerId) {
 479    clearTimeout(timerId);
 480  }
 481  
 482  // --- Time ---
 483  
 484  export function NowSeconds() {
 485    return BigInt(Math.floor(Date.now() / 1000));
 486  }
 487  
 488  export function NowMillis() {
 489    return BigInt(Date.now());
 490  }
 491  
 492  // --- SW globals ---
 493  
 494  export function Origin() {
 495    return self.location.origin;
 496  }
 497  
 498  // --- Logging ---
 499  
 500  export function Log(msg) {
 501    console.log('sw:', msg);
 502  }
 503  
 504