diff --git a/docs/push.md b/docs/push.md index b42cc51..84378ea 100644 --- a/docs/push.md +++ b/docs/push.md @@ -28,7 +28,12 @@ would 404 on its entry chunks. - **`/assets/*` — cache-first**: Vite content-hashes filenames, so cached entries are immutable. -- **`/images/*` — cache-first**, capped at 200 entries. +- **`/images/*` — cache-first**, capped at 200 entries. The app's own artwork + (`/images/icon-*`, `/images/logo-icon*`, `/images/apple-splash/*`) is the + exception: those URLs are stable while their contents change with the logo, + and `IMAGES_CACHE` survives every build, so they are served network-first + with a cached fallback. Otherwise a browser that had once loaded an icon + would keep showing the old one indefinitely. - **Pass-through** (never cached): `/api/`, `/ws/`, `/auth/`, `/push/`, `/content/`, `/content-viewers/`, `/debug`, `/admin`, non-GET, cross-origin. diff --git a/webclient/dist/sw.js b/webclient/dist/sw.js index 18f28b9..7fe0cca 100644 --- a/webclient/dist/sw.js +++ b/webclient/dist/sw.js @@ -5,7 +5,7 @@ * then background refresh — after a deploy old hashed assets are deleted * from dist, so a stale cached shell would 404 on its entry chunks; * - /assets/*: cache-first (Vite content-hashes filenames — immutable); - * - /images/*: cache-first, capped; + * - /images/*: cache-first, capped — except the app's own artwork (below); * - everything dynamic (/api, /ws, /auth, /push, /content, ...): pass-through. * * ae826552847354e7 is stamped at build time (vite.config.js closeBundle) @@ -27,6 +27,13 @@ '/debug', '/admin', ]; +// The app's own artwork — manifest icons, the favicon, the launch splashes — is +// not content-addressed: it changes whenever the logo does, and IMAGES_CACHE is +// deliberately kept across builds, so cache-first would pin a stale icon in a +// browser forever. Everything else under /images/ is content whose URL is +// unique, and stays cache-first. +const APP_ARTWORK = /^\/images\/(?:icon-|logo-icon|apple-splash\/)/; + function isPassThrough(url) { return url.pathname.startsWith('/') && PASS_THROUGH.some( (p) => url.pathname === p || url.pathname === p.slice(0, -1) || url.pathname.startsWith(p), @@ -120,6 +127,19 @@ return resp; } +async function networkFirst(request, cacheName) { + const cache = await caches.open(cacheName); + try { + const resp = await fetch(request); + if (resp && resp.ok && resp.type === 'basic') await cache.put(request, resp.clone()); + return resp; + } catch (err) { + const cached = await cache.match(request); + if (cached) return cached; + throw err; + } +} + self.addEventListener('fetch', (event) => { const { request } = event; if (request.method !== 'GET') return; @@ -136,7 +156,11 @@ return; } if (url.pathname.startsWith('/images/')) { - event.respondWith(cacheFirst(request, IMAGES_CACHE, IMAGES_CACHE_LIMIT)); + event.respondWith( + APP_ARTWORK.test(url.pathname) + ? networkFirst(request, IMAGES_CACHE) + : cacheFirst(request, IMAGES_CACHE, IMAGES_CACHE_LIMIT), + ); } }); diff --git a/webclient/public/sw.js b/webclient/public/sw.js index dbb7635..973c871 100644 --- a/webclient/public/sw.js +++ b/webclient/public/sw.js @@ -5,7 +5,7 @@ * then background refresh — after a deploy old hashed assets are deleted * from dist, so a stale cached shell would 404 on its entry chunks; * - /assets/*: cache-first (Vite content-hashes filenames — immutable); - * - /images/*: cache-first, capped; + * - /images/*: cache-first, capped — except the app's own artwork (below); * - everything dynamic (/api, /ws, /auth, /push, /content, ...): pass-through. * * __NAVI_BUILD_VERSION__ is stamped at build time (vite.config.js closeBundle) @@ -27,6 +27,13 @@ '/debug', '/admin', ]; +// The app's own artwork — manifest icons, the favicon, the launch splashes — is +// not content-addressed: it changes whenever the logo does, and IMAGES_CACHE is +// deliberately kept across builds, so cache-first would pin a stale icon in a +// browser forever. Everything else under /images/ is content whose URL is +// unique, and stays cache-first. +const APP_ARTWORK = /^\/images\/(?:icon-|logo-icon|apple-splash\/)/; + function isPassThrough(url) { return url.pathname.startsWith('/') && PASS_THROUGH.some( (p) => url.pathname === p || url.pathname === p.slice(0, -1) || url.pathname.startsWith(p), @@ -120,6 +127,19 @@ return resp; } +async function networkFirst(request, cacheName) { + const cache = await caches.open(cacheName); + try { + const resp = await fetch(request); + if (resp && resp.ok && resp.type === 'basic') await cache.put(request, resp.clone()); + return resp; + } catch (err) { + const cached = await cache.match(request); + if (cached) return cached; + throw err; + } +} + self.addEventListener('fetch', (event) => { const { request } = event; if (request.method !== 'GET') return; @@ -136,7 +156,11 @@ return; } if (url.pathname.startsWith('/images/')) { - event.respondWith(cacheFirst(request, IMAGES_CACHE, IMAGES_CACHE_LIMIT)); + event.respondWith( + APP_ARTWORK.test(url.pathname) + ? networkFirst(request, IMAGES_CACHE) + : cacheFirst(request, IMAGES_CACHE, IMAGES_CACHE_LIMIT), + ); } });