diff --git a/docker/frontend/nginx.conf b/docker/frontend/nginx.conf index 6cb2903..4d50b89 100644 --- a/docker/frontend/nginx.conf +++ b/docker/frontend/nginx.conf @@ -69,6 +69,26 @@ server { try_files $uri =404; } + # The shell is the third name that never changes and whose contents do: with no + # header of its own a browser is free to guess a freshness window out of + # Last-Modified — a tenth of the file's age — and hand a visitor the PREVIOUS + # build's index.html for hours after a deploy. That page names that build's + # hashed bundle, so a fixed shader keeps running as the broken one and a hard + # reload is the only way out. The SPA fallback below lands here too (the + # internal redirect re-matches locations), so /app and /library are covered by + # the same line. + # (Restated: an add_header in a location drops every add_header inherited, and + # this document needs the isolation pair for the wasm renderer.) + location = /index.html { + add_header Cache-Control "no-cache"; + add_header Cross-Origin-Opener-Policy "same-origin" always; + add_header Cross-Origin-Embedder-Policy "require-corp" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + try_files $uri =404; + } + # The one route that carries a whole data dir back in (see /api/admin/restore # — who may call it is the API's own admin check, done before it reads a byte). # The upload cap that holds everywhere else would reject it, and unpacking the diff --git a/docker/frontend/public/sw.js b/docker/frontend/public/sw.js index 47bf0af..66b3885 100644 --- a/docker/frontend/public/sw.js +++ b/docker/frontend/public/sw.js @@ -3,7 +3,14 @@ // readable in DevTools, the one place a worker gets debugged. VERSION is the cache // name and the whole update story — bump it when the shell changes, and `activate` // drops every older cache. -const VERSION = 'recipescam-v1'; +// +// v2 is the bump that flushed a shell this worker had already pinned: before the +// header went on in nginx, a browser was free to guess a freshness window for +// index.html and answer a navigation with the previous build's shell — and its +// hashed bundle with it, which the STATIC rule below then served cache-first +// forever. Every navigation here now goes past the browser's own cache, so the +// build a visitor gets is the one the server has. +const VERSION = 'recipescam-v2'; // nginx answers all three with the same index.html (SPA fallback), so they are one // document under three keys: an offline navigation finds it whichever key it asks. @@ -13,6 +20,11 @@ const SHELL = ['/', '/app', '/library']; // the models and the icons never change under a given build. const STATIC = /^\/(assets|wasm|models|icons)\//; +// The shell, read past the browser's cache on purpose: a `cache.add` of '/' +// consults that cache like any other fetch, so a shell stored during a stale +// window would be precached as the offline shell of the build that replaced it. +const shellRequest = (url) => new Request(url, { cache: 'reload' }); + // Only a complete same-origin 200 is worth keeping — an opaque or error body // stored here comes back as a failure on the next visit, and nothing in the worker // can tell the two apart by then. @@ -34,7 +46,7 @@ self.addEventListener('install', (event) => { // worker uninstalled, so each route fails on its own. event.waitUntil( caches.open(VERSION) - .then((cache) => Promise.all(SHELL.map((url) => cache.add(url).catch(() => {})))) + .then((cache) => Promise.all(SHELL.map((url) => cache.add(shellRequest(url)).catch(() => {})))) .then(() => self.skipWaiting()), ); }); @@ -58,8 +70,10 @@ self.addEventListener('fetch', (event) => { // A navigation is network-first even when it is cached: a shell answered from the // cache while the network holds a newer build pins the visitor to the old one. It // is also the path an installed app opens through with no network. + // `no-store` because a plain fetch is not the network: it may be answered by the + // browser's own cache, which is the other place a stale shell hides. if (request.mode === 'navigate') { - event.respondWith(fetch(request).catch(offlineShell)); + event.respondWith(fetch(request, { cache: 'no-store' }).catch(offlineShell)); return; }