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      fn(_storeEvent(event));
  41    });
  42  }
  43  
  44  export function OnActivate(fn) {
  45    self.addEventListener('activate', (event) => {
  46      fn(_storeEvent(event));
  47    });
  48  }
  49  
  50  // Fetch events use a deferred promise pattern:
  51  // Go calls RespondWithCache or RespondWithNetwork synchronously to pick strategy.
  52  // The runtime creates the promise and calls event.respondWith() synchronously.
  53  const _fetchResolvers = new Map();
  54  
  55  export function OnFetch(fn) {
  56    self.addEventListener('fetch', (event) => {
  57      const id = _storeEvent(event);
  58      fn(id);
  59      // Check if Go code set up a response strategy.
  60      const resolver = _fetchResolvers.get(id);
  61      if (resolver) {
  62        event.respondWith(resolver);
  63        _fetchResolvers.delete(id);
  64      }
  65      // If no resolver was set, browser handles the fetch normally.
  66    });
  67  }
  68  
  69  export function OnMessage(fn) {
  70    self.addEventListener('message', (event) => {
  71      fn(_storeEvent(event));
  72    });
  73  }
  74  
  75  // --- Event methods ---
  76  
  77  export function WaitUntil(eventId, fn) {
  78    const ev = _events.get(eventId);
  79    if (!ev) return;
  80    ev.waitUntil(new Promise((resolve) => {
  81      fn(resolve);
  82    }));
  83  }
  84  
  85  export function RespondWith(eventId, respId) {
  86    const resp = _responses.get(respId);
  87    if (resp) {
  88      _fetchResolvers.set(eventId, Promise.resolve(resp));
  89    }
  90  }
  91  
  92  export function RespondWithNetwork(eventId) {
  93    // Don't set a resolver — browser handles the fetch.
  94  }
  95  
  96  // RespondWithCacheFirst tries cache, falls back to network.
  97  // This is the common pattern and must be called synchronously from onFetch.
  98  export function RespondWithCacheFirst(eventId) {
  99    const ev = _events.get(eventId);
 100    if (!ev) return;
 101    _fetchResolvers.set(eventId,
 102      caches.match(ev.request).then(cached => cached || fetch(ev.request))
 103    );
 104  }
 105  
 106  export function GetRequestURL(eventId) {
 107    const ev = _events.get(eventId);
 108    return ev ? ev.request.url : '';
 109  }
 110  
 111  export function GetRequestPath(eventId) {
 112    const ev = _events.get(eventId);
 113    if (!ev) return '';
 114    return new URL(ev.request.url).pathname;
 115  }
 116  
 117  export function GetMessageData(eventId) {
 118    const ev = _events.get(eventId);
 119    if (!ev) return '';
 120    const d = ev.data;
 121    return typeof d === 'string' ? d : JSON.stringify(d);
 122  }
 123  
 124  export function GetMessageClientID(eventId) {
 125    const ev = _events.get(eventId);
 126    if (!ev) return '';
 127    return ev.source ? ev.source.id || '' : '';
 128  }
 129  
 130  // --- SW globals ---
 131  
 132  export function Origin() {
 133    return self.location.origin;
 134  }
 135  
 136  export function SkipWaiting() {
 137    self.skipWaiting();
 138  }
 139  
 140  export function ClaimClients(done) {
 141    self.clients.claim().then(() => { if (done) done(); }).catch(() => { if (done) done(); });
 142  }
 143  
 144  export function MatchClients(fn) {
 145    self.clients.matchAll({ type: 'window' }).then((all) => {
 146      for (const c of all) {
 147        fn(_storeClient(c));
 148      }
 149    });
 150  }
 151  
 152  export function PostMessage(clientId, msg) {
 153    const c = _clients.get(clientId);
 154    if (c) c.postMessage(msg);
 155  }
 156  
 157  export function PostMessageJSON(clientId, json) {
 158    const c = _clients.get(clientId);
 159    if (c) {
 160      try { c.postMessage(JSON.parse(json)); }
 161      catch { c.postMessage(json); }
 162    }
 163  }
 164  
 165  export function GetClientByID(id, fn) {
 166    self.clients.get(id).then((client) => {
 167      if (client) {
 168        fn(_storeClient(client), true);
 169      } else {
 170        fn(0, false);
 171      }
 172    }).catch(() => fn(0, false));
 173  }
 174  
 175  export function Navigate(clientId, url) {
 176    const c = _clients.get(clientId);
 177    if (c) c.navigate(url || c.url);
 178  }
 179  
 180  // --- Cache ---
 181  
 182  export function CacheOpen(name, fn) {
 183    caches.open(name).then((cache) => {
 184      const id = _nextCacheId++;
 185      _caches.set(id, cache);
 186      fn(id);
 187    }).catch(() => fn(0));
 188  }
 189  
 190  export function CacheAddAll(cacheId, urls, done) {
 191    const cache = _caches.get(cacheId);
 192    if (!cache) { if (done) done(); return; }
 193    cache.addAll(urls).then(() => { if (done) done(); }).catch(() => { if (done) done(); });
 194  }
 195  
 196  export function CachePut(cacheId, url, respId, done) {
 197    const cache = _caches.get(cacheId);
 198    const resp = _responses.get(respId);
 199    if (!cache || !resp) { if (done) done(); return; }
 200    cache.put(new Request(url), resp).then(() => { if (done) done(); }).catch(() => { if (done) done(); });
 201  }
 202  
 203  export function CacheMatch(url, fn) {
 204    caches.match(new Request(url)).then((resp) => {
 205      fn(_storeResponse(resp));
 206    }).catch(() => fn(0));
 207  }
 208  
 209  export function CacheDelete(name, done) {
 210    caches.delete(name).then(() => { if (done) done(); }).catch(() => { if (done) done(); });
 211  }
 212  
 213  // --- Fetch ---
 214  
 215  export function Fetch(url, fn) {
 216    fetch(url).then(
 217      (resp) => fn(_storeResponse(resp), true),
 218      () => fn(0, false)
 219    );
 220  }
 221  
 222  export function FetchAll(urls, onEach, onDone) {
 223    const arr = urls.$array
 224      ? urls.$array.slice(urls.$offset, urls.$offset + urls.$length)
 225      : urls;
 226    if (arr.length === 0) { if (onDone) onDone(); return; }
 227    let remaining = arr.length;
 228    for (let i = 0; i < arr.length; i++) {
 229      ((idx) => {
 230        fetch(arr[idx]).then(
 231          (resp) => { onEach(idx, _storeResponse(resp), true); if (--remaining === 0 && onDone) onDone(); },
 232          ()     => { onEach(idx, 0, false);                   if (--remaining === 0 && onDone) onDone(); }
 233        );
 234      })(i);
 235    }
 236  }
 237  
 238  export function ResponseOK(respId) {
 239    const resp = _responses.get(respId);
 240    return resp ? resp.ok : false;
 241  }
 242  
 243  // --- SSE ---
 244  
 245  export function SSEConnect(url, onMessage) {
 246    const id = _nextSseId++;
 247    if (typeof EventSource !== 'undefined') {
 248      const es = new EventSource(url);
 249      _sseConns.set(id, es);
 250      es.onmessage = (event) => {
 251        if (onMessage) onMessage(event.data);
 252      };
 253    } else {
 254      // SW scope: EventSource not available. Poll with fetch.
 255      let active = true;
 256      _sseConns.set(id, { close() { active = false; } });
 257      (async function poll() {
 258        let backoff = 3000;
 259        while (active) {
 260          try {
 261            const resp = await fetch(url, { headers: { 'Accept': 'text/event-stream' } });
 262            backoff = 3000;
 263            const reader = resp.body.getReader();
 264            const decoder = new TextDecoder();
 265            let buf = '';
 266            while (active) {
 267              const { value, done } = await reader.read();
 268              if (done) break;
 269              buf += decoder.decode(value, { stream: true });
 270              let idx;
 271              while ((idx = buf.indexOf('\n\n')) !== -1) {
 272                const msg = buf.substring(0, idx);
 273                buf = buf.substring(idx + 2);
 274                for (const line of msg.split('\n')) {
 275                  if (line.startsWith('data: ') && onMessage) {
 276                    onMessage(line.substring(6));
 277                  }
 278                }
 279              }
 280            }
 281          } catch (e) {
 282            if (active) {
 283              await new Promise(r => setTimeout(r, backoff));
 284              backoff = Math.min(backoff * 2, 60000);
 285            }
 286          }
 287        }
 288      })();
 289    }
 290    return id;
 291  }
 292  
 293  export function SSEClose(sseId) {
 294    const es = _sseConns.get(sseId);
 295    if (es) {
 296      es.close();
 297      _sseConns.delete(sseId);
 298    }
 299  }
 300  
 301  // --- Timers ---
 302  
 303  export function SetTimeout(ms, fn) {
 304    return setTimeout(fn, ms);
 305  }
 306  
 307  export function ClearTimeout(t) {
 308    clearTimeout(t);
 309  }
 310  
 311  export function NowSeconds() {
 312    return Math.floor(Date.now() / 1000);
 313  }
 314  
 315  export function NowMillis() {
 316    return Date.now();
 317  }
 318  
 319  // --- Logging ---
 320  
 321  export function Log(msg) {
 322    console.log('sw:', msg);
 323  }
 324