Konubinix' opinionated web of thoughts

PWA Score Apps — Shared Blocks

Shared noweb blocks for tally_score_tracker.org and score_counter.org. Consumer files use cross-file references like <<pwa_score_apps_shared_blocks.org:head-meta-ex()>>.

Head and PWA configuration

Meta tags

Standard PWA meta tags. The purge script at the top provides a ?purge query parameter escape hatch: it nukes the service worker cache and registrations, then reloads — useful when a bad SW gets stuck in production.

<meta charset="UTF-8">
<script>
if(new URLSearchParams(location.search).has('purge')&&'serviceWorker'in navigator){
  caches.keys().then(function(k){k.forEach(function(n){caches.delete(n)})});
  navigator.serviceWorker.getRegistrations().then(function(r){r.forEach(function(s){s.unregister()})});
  location.replace(location.pathname);
}
</script>
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#1a1a2e">
<link rel="manifest" href="manifest.json">
<title>Tally</title>

Import map

All dependencies loaded via ESM from esm.sh. The deps=yjs@13.6.29 pins ensure y-indexeddb, y-websocket and y-protocols all share the exact same Yjs instance — without this, each would bundle its own copy, breaking cross-module instanceof checks.

<script type="importmap">
{"imports":{
  "yjs":"https://esm.sh/yjs@13.6.29",
  "y-indexeddb":"https://esm.sh/y-indexeddb@9?deps=yjs@13.6.29",
  "y-websocket":"https://esm.sh/y-websocket@2?bundle-deps&deps=yjs@13.6.29",
  "lib0/broadcastchannel":"https://esm.sh/lib0@0.2.99/broadcastchannel",
  "lib0/time":"https://esm.sh/lib0@0.2.99/time",
  "lib0/encoding":"https://esm.sh/lib0@0.2.99/encoding",
  "lib0/decoding":"https://esm.sh/lib0@0.2.99/decoding",
  "lib0/observable":"https://esm.sh/lib0@0.2.99/observable",
  "lib0/math":"https://esm.sh/lib0@0.2.99/math",
  "lib0/url":"https://esm.sh/lib0@0.2.99/url",
  "lib0/environment":"https://esm.sh/lib0@0.2.99/environment",
  "y-protocols/sync":"https://esm.sh/y-protocols@1.0.6/sync?deps=yjs@13.6.29",
  "y-protocols/auth":"https://esm.sh/y-protocols@1.0.6/auth?deps=yjs@13.6.29",
  "y-protocols/awareness":"https://esm.sh/y-protocols@1.0.6/awareness?deps=yjs@13.6.29",
  "alpinejs":"https://esm.sh/alpinejs@3"
}}
</script>

Service worker

Network-first strategy for navigation (always fetch the latest HTML), cache-first for static assets (CDN libraries that are versioned in the import map). On install, pre-caches the shell and Alpine. On activate, purges stale caches.

const CACHE = 'tally-v6';
const ASSETS = [
  './',
  './index.html',
  'https://esm.sh/alpinejs@3',
  'https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.min.js'
];

self.addEventListener('install', e => {
  e.waitUntil(caches.open(CACHE).then(c => c.addAll(ASSETS)));
  self.skipWaiting();
});

self.addEventListener('activate', e => {
  e.waitUntil(
    caches.keys().then(keys =>
      Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))
    )
  );
  self.clients.claim();
});

self.addEventListener('fetch', e => {
  if (e.request.mode === 'navigate') {
    // Network-first for pages — always get the latest HTML
    e.respondWith(
      fetch(e.request)
        .then(r => caches.open(CACHE).then(c => { c.put(e.request, r.clone()); return r; }))
        .catch(() => caches.match(e.request))
    );
  } else {
    // Cache-first for static assets (CDN libs)
    e.respondWith(
      caches.match(e.request).then(r => r || fetch(e.request))
    );
  }
});

Manifest

{
  "name": "Tally",
  "short_name": "Tally",
  "description": "Score tracker for board games",
  "start_url": ".",
  "display": "standalone",
  "background_color": "#1a1a2e",
  "theme_color": "#1a1a2e",
  "icons": [
    {
      "src": "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'><rect width='512' height='512' rx='96' fill='%231a1a2e'/><text x='256' y='340' font-size='280' text-anchor='middle' fill='%23e94560'>T</text></svg>",
      "sizes": "512x512",
      "type": "image/svg+xml",
      "purpose": "any maskable"
    }
  ]
}

Sync infrastructure

Shared WebSocket connection, remote-change rendering, and init logic. Each app calls setupSync(persistence, ydoc, onReady) where onReady is the app-specific first-render function (e.g. render() or Alpine.store('app').syncFromYjs()).

The scheduleRender function is set by the caller so that remote Yjs updates and IndexedDB sync both trigger the app’s render pipeline.

// ── Shared sync infrastructure ──
var wsProvider;
var _rendering = false;
var _scheduleRender;

function setupSync(persistence, ydoc, onReady, scheduleRenderFn) {
  _scheduleRender = scheduleRenderFn || function() {
    if (_rendering) return;
    _rendering = true;
    requestAnimationFrame(function() { _rendering = false; onReady(); });
  };

  // Re-render on any non-local Yjs update (including IndexedDB sync)
  ydoc.on('update', function(update, origin) {
    if (origin === null) return;
    _scheduleRender();
  });

  // WebSocket — connect only after IndexedDB synced, retry on denial
  var _wsRetryTimer = null;
  function connectWs() {
    try {
      if (wsProvider) return;
      var wsHost = location.host.endsWith('.konubinix.eu') ? 'konubinix.eu' : location.host;
      var wsUrl = (location.protocol === 'https:' ? 'wss://' : 'ws://') + wsHost + '/ywebsocket';
      var wsEverConnected = false;
      wsProvider = new WebsocketProvider(wsUrl, _roomName, ydoc);
      var syncBtn = document.getElementById('syncDot');
      wsProvider.on('status', function(e) {
        if (e.status === 'connected') {
          wsEverConnected = true;
          if (_wsRetryTimer) { clearInterval(_wsRetryTimer); _wsRetryTimer = null; }
        }
        var state = e.status === 'connected' ? 'connected' : 'offline';
        document.body.setAttribute('data-ws', state);
        if (syncBtn) syncBtn.title = state === 'connected' ? 'Synced' : 'Offline';
      });
      wsProvider.on('connection-close', function(e) {
        if (!wsEverConnected) {
          document.body.setAttribute('data-ws', 'denied');
          if (syncBtn) syncBtn.title = 'Not authorized';
          wsProvider.destroy();
          wsProvider = null;
          if (!_wsRetryTimer) {
            _wsRetryTimer = setInterval(connectWs, 10000);
          }
        }
      });
    } catch(e) { console.warn('WebSocket setup failed:', e); }
  }

  function init() {
    onReady();
    document.body.setAttribute('data-ready', 'true');
  }

  persistence.once('synced', connectWs);
  persistence.once('synced', function() { setTimeout(init, 0); });
  if (persistence.synced) { connectWs(); setTimeout(init, 0); }

  if ('serviceWorker' in navigator) navigator.serviceWorker.register('sw.js', { updateViaCache: 'none' });
}

Notes linking here