Files
RecipesCam/docker/frontend/public/sw.js
T
3dtours 45fc852d04 fix(library): land a reading's rows as they are read, and keep the copy the reader reads offline
- scan: rows go down on a clock as well as on a full batch. The reader's folder
  of 26 RAWs is under one batch of 50, so nothing of it reached the screen until
  the reading was over — a wall that stood empty for the whole scan and then
  filled in one go. Frames now reach the screen every 400ms while the roll is
  still being read, so the count climbs and the first tiles are drawn as the
  frames land.
- tiles: a HEIC or a RAW has its tile written the first time it is drawn, so
  every later visit reads the tile back instead of the file.
- resume: a reading a reload cut off is offered by the next visit with a chip
  instead of being run behind the reader's back.
- offline listing: the desk's listing is kept whole in IndexedDB under
  `recipescam-offline`/`reads`, past the 200 000 characters localStorage held,
  so an offline visit opens on the listing it kept instead of on nothing.
- offline writes: a vote or a view cast with no server is held and goes out when
  one answers again, with a bar that says how many are waiting.
- sw: the shell precaches its own bundle, stylesheet and icon, read out of the
  document — a worker that precached only the document answered an offline
  launch with a page whose JavaScript the machine did not have.
- checks in docker/frontend/scripts: tree-scan-check (counts while reading,
  resume after a reload, tiles of a long roll), offline-read-check,
  offline-queue-check.
2026-10-09 12:02:05 +07:00

131 lines
6.3 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-v12';
// nginx answers all three routes with the same index.html (SPA fallback), so they
// are one document under three keys: an offline navigation finds it whichever key
// it asks. The manifest is not that document and is precached with them for the
// same reason — it is what makes the installed app an app, and offline it is
// asked for on every launch.
const SHELL = ['/', '/app', '/library', '/manifest.json'];
// 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 route that was asked for if this browser has ever been on it, then
// the studio the app is for, then the landing page for a build without /app. The
// route first because that is the copy a visit keeps up to date: the other two may
// be shells of a build the deploy has replaced.
const offlineShell = async (request) =>
(await caches.match(new Request(new URL(request.url).pathname))) ??
(await caches.match('/app')) ??
(await caches.match('/')) ??
Response.error();
// The files the shell is built out of, read out of the shell itself: the hashed
// bundle, the stylesheet, the icon. A worker that precached only the document
// would answer an offline launch with a page whose JavaScript this machine does
// not have — and it never would, because the visit that first loads the app is
// the visit this worker is not yet controlling, so its own STATIC rule below
// never sees those requests. The shell is the one place their names are written.
const shellFiles = (html) => [...html.matchAll(/(?:src|href)="(\/assets\/[^"]+)"/g)].map((m) => m[1]);
self.addEventListener('install', (event) => {
// Best effort, one file at a time: a deploy caught mid-flight must not leave the
// worker uninstalled, so each one fails on its own.
event.waitUntil(
caches.open(VERSION)
.then(async (cache) => {
await Promise.all(SHELL.map((url) => cache.add(shellRequest(url)).catch(() => {})));
const shell = await cache.match('/app') ?? await cache.match('/');
if (!shell) return;
for (const url of shellFiles(await shell.text())) {
await 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' })
.then((response) => {
// The shell kept for offline is the one the server handed over last, not
// the one this worker installed with: a navigation that fails — a deploy
// recreating the container is the ordinary way one does — otherwise
// answers with the build of the first visit, `activate` never runs to
// replace it while this worker's bytes stay the same, and the browser
// runs an old build until a reload happens to land after the server is
// back. Stored under the route the navigation asked for, which is one of
// the three SHELL keys — a navigation request is not a key a cache takes.
if (response.ok) {
const copy = response.clone();
const key = new Request(new URL(request.url).pathname);
caches.open(VERSION).then((cache) => cache.put(key, copy)).catch(() => {});
}
return response;
})
.catch(() => offlineShell(request))
);
return;
}
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()));
});