/* Service worker for the Work Package Suite PWA. Goal: let the app (and especially the field view) load and run offline. Data durability is already handled by the sync outbox in project-data.js — this worker only caches the static app shell so the pages open without a network. Strategy: • /api/* and non-GET → never touched (pass straight to the network; offline reads fall back to the app's localStorage cache, writes queue in the outbox). • HTML / CSS / JS → network-first, cache as fallback. These reference each other, so a page must never run against a stale sibling. • images / icons / manifest → stale-while-revalidate (instant from cache). */ 'use strict'; // Bumped when the shell file list changes, so clients fetch the new assets // instead of serving a half-old shell from the previous cache. const CACHE = 'wp-suite-shell-v6'; const SHELL = [ '/', '/index.html', '/work-package-suite.html', '/wp-creation-index.html', '/field.html', '/login.html', '/admin.html', '/users.html', '/theme-light.css', '/work-package-suite-styles.css', '/wp-creation-styles.css', '/wp-chrome.css', '/console.css', '/wp-sidenav.css', '/auth-guard.js', '/project-data.js', '/feedback-config.js', '/help.js', '/work-package-suite-app.js', '/wp-creation-app.js', '/field.js', '/wp-chrome.js', '/wp-sidenav.js', '/wp-format.js', '/login.js', '/console-util.js', '/admin.js', '/users.js', '/prime-controls-logo.jpg', '/favicon.ico', '/manifest.webmanifest', '/icon-192.png', '/icon-512.png', ]; self.addEventListener('install', (e) => { // Cache each shell asset individually so one missing file doesn't abort install. e.waitUntil( caches.open(CACHE) // cache:'reload' bypasses the browser HTTP cache. Without it the precache // can be filled from stale HTTP entries, freezing a mismatched shell. .then((c) => Promise.all(SHELL.map( (u) => c.add(new Request(u, { cache: 'reload' })).catch(() => {})))) .then(() => self.skipWaiting()) ); }); self.addEventListener('activate', (e) => { e.waitUntil( caches.keys() .then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))) .then(() => self.clients.claim()) ); }); // Code (HTML / CSS / JS) is fetched NETWORK-FIRST, falling back to the cache when // offline. Everything else (images, icons, the manifest) stays cache-first, which is // where offline speed actually comes from. // // Why not cache-first for code: these files reference each other, and the cache // stores them as independent entries. Cache-first served whichever copy of each file // happened to be stored, so a browser could run new HTML against old CSS — which is // exactly how the creator once collapsed to a 300x150 box when it was an iframe // and its stylesheet was a version behind its markup. A page must // only ever run against the stylesheet and scripts it shipped with. const CODE_RE = /\.(html|css|js)$|\/$/i; self.addEventListener('fetch', (e) => { const req = e.request; if (req.method !== 'GET') return; // outbox owns writes const url = new URL(req.url); if (url.origin !== self.location.origin) return; // third-party: default if (url.pathname.startsWith('/api/')) return; // never cache the API const isCode = CODE_RE.test(url.pathname); // Cache key WITHOUT the query string. Links inside the app carry ?project=…&tab=…, // and the creator used to carry a cache-busting timestamp in its frame src, so keying on the // full URL both missed every offline navigation and grew the cache without bound. const key = new Request(url.origin + url.pathname, { credentials: 'same-origin' }); const fromCache = () => caches.match(key).then((c) => c || caches.match(req)); const store = (res) => { if (res && res.ok && res.type !== 'opaque') { const copy = res.clone(); caches.open(CACHE).then((c) => c.put(key, copy)).catch(() => {}); } return res; }; if (isCode) { e.respondWith( // cache:'no-cache' forces revalidation with the server. Plain fetch() inherits // the request's default cache mode, which consults the browser HTTP cache — so // "network-first" alone still let a page run against a stale sibling file. fetch(req, { cache: 'no-cache' }) .then((res) => { // A 502/404 must not replace a page the cache could still serve. if (!res || !res.ok) return fromCache().then((c) => c || res); return store(res); }) .catch(() => fromCache()) // offline → last good copy .then((res) => res || Response.error()) // never resolve to undefined ); return; } e.respondWith( caches.match(key).then((cached) => { const network = fetch(req).then(store).catch(() => cached); return cached || network.then((res) => res || Response.error()); }) ); });