2ced93a425
The request was a mask's EXPOSURE losing hue, and the mask pass has not done that since6d60d45: measured today through a page the service worker controls, with an ellipse over the whole frame and +1 EV, the pixels inside move exactly as the frame's own knob moves them — identical to the byte (mask+1 vs frame+1 over four codes: 619 pixels of 1,709,450, all of them on the rim), and the hue each one leaves behind is the same distribution to a hundredth of a degree over the 370,606 pixels that carry a hue in both reads (mean 2.07°, p99 15.31° for the mask against 15.32° for the frame; on the vivid pixels, mean 0.83° at +1 EV). The one place that still says otherwise is a doc on the Android branch, whose §3.2 quotes the old line. But the symptom is real, and the build that produces it is the one from before that commit, where maskAdjust multiplied the three channels by the stop: c = c * half(pow(2.0, a.x)); Three channels clip by three different amounts, so the differences between them stop being scaled together and the hue goes with them. That bundle could still be what a visitor runs, because of two files the deploy never took away: - index.html was the only document the server handed over with no Cache-Control at all (the .mjs, /assets, /wasm and /models locations all name their policy, sw.js and the manifest both opted out). With no header the browser is free to guess a freshness window out of Last-Modified — a tenth of the file's age — and answer a navigation from its own cache for hours after a deploy. The page it answers with names the previous build's hashed bundle, so the previous shader is what runs, and a hard reload is the only way out. The SPA fallback lands on the same file (an internal redirect re-matches locations), so /app and /library were covered by the same guess. - sw.js is the second place the pin lived. Its navigations are network-first, but a plain fetch is not the network: it can be answered by the browser's cache, so the network never came first — and the old shell's hashed bundle, once fetched, is a STATIC path that the cache-first rule serves forever. So: nginx names the policy for the shell, with the isolation pair restated because an add_header in a location drops every inherited one and index.html is the document that needs them — the wasm renderer's SharedArrayBuffer is behind that pair. The worker reads its navigations past the browser's cache, precaches the shell the same way (a cache.add of '/' consults that cache like any other fetch, so a shell read inside a stale window would be stored as the offline shell of the build that replaced it), and VERSION goes to v2, whose activate drops the cache the old worker pinned — the old shell and the old bundle with it. The root fix is still6d60d45: this commit is what lets it reach the browser. Verified: nginx -t on the shipped config, and a container of this config against a copy of index.html hands / and /app `Cache-Control: no-cache` with all five original headers intact, while /assets/index-abc.js keeps `public, immutable` (30 days) — the exact location does not shadow the hashed bundle. node --check on sw.js. The measurements above come from the live 8090 build inside a persistent profile whose page is controlled by the worker (controlled true, crossOriginIsolated true, bundle index-CKOd57MG.js), the same session that pinned the build the fix is about. Co-authored-by: PenguinHarness <noreply@penguin.local>
91 lines
4.2 KiB
JavaScript
91 lines
4.2 KiB
JavaScript
// The app shell as a service worker, so an installed RecipesCam opens with no
|
|
// network at all. Not bundled on purpose: through vite/rollup it would stop being
|
|
// 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.
|
|
//
|
|
// 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.
|
|
const SHELL = ['/', '/app', '/library'];
|
|
|
|
// Cache-first paths: vite hashes every filename in its own bundle, and the wasm,
|
|
// 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.
|
|
const cachedFetch = (request) =>
|
|
fetch(request).then((response) => {
|
|
if (response.ok) {
|
|
const copy = response.clone();
|
|
caches.open(VERSION).then((cache) => cache.put(request, copy));
|
|
}
|
|
return response;
|
|
});
|
|
|
|
// Offline, the studio the app is for; the landing page for a build without /app.
|
|
const offlineShell = async () =>
|
|
(await caches.match('/app')) ?? (await caches.match('/')) ?? Response.error();
|
|
|
|
self.addEventListener('install', (event) => {
|
|
// Best effort, one route at a time: a deploy caught mid-flight must not leave the
|
|
// worker uninstalled, so each route fails on its own.
|
|
event.waitUntil(
|
|
caches.open(VERSION)
|
|
.then((cache) => Promise.all(SHELL.map((url) => cache.add(shellRequest(url)).catch(() => {}))))
|
|
.then(() => self.skipWaiting()),
|
|
);
|
|
});
|
|
|
|
self.addEventListener('activate', (event) => {
|
|
event.waitUntil(
|
|
caches.keys()
|
|
.then((names) => Promise.all(names.filter((n) => n !== VERSION).map((n) => caches.delete(n))))
|
|
.then(() => self.clients.claim()),
|
|
);
|
|
});
|
|
|
|
self.addEventListener('fetch', (event) => {
|
|
const { request } = event;
|
|
// A worker is a cache, not a proxy: a POST, the API, another origin's font — a
|
|
// request that is not a plain same-origin read — goes straight out.
|
|
if (request.method !== 'GET') return;
|
|
const url = new URL(request.url);
|
|
if (url.origin !== self.location.origin || url.pathname.startsWith('/api/')) return;
|
|
|
|
// 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, { cache: 'no-store' }).catch(offlineShell));
|
|
return;
|
|
}
|
|
|
|
// ponytail: a navigation is never written back, the precached entry is what
|
|
// answers offline. Add a put() here once a route must survive on its own.
|
|
if (STATIC.test(url.pathname)) {
|
|
event.respondWith(caches.match(request).then((hit) => hit ?? cachedFetch(request)));
|
|
return;
|
|
}
|
|
|
|
// Everything else — a font, the manifest, an uploaded photo — is network-first,
|
|
// and answered from the cache only when that fails.
|
|
event.respondWith(fetch(request).catch(async () => (await caches.match(request)) ?? Response.error()));
|
|
});
|