From 1b10a994ddc990d19be9f1f27f925f405c65b4f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 02:11:38 +0000 Subject: [PATCH] Version-stamped static URLs cached forever, thumbnail cache 25000 with throttled trimming --- frontend/sw.js | 13 +++++++++---- server/server.js | 11 +++++++++-- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/frontend/sw.js b/frontend/sw.js index 9ef0e38..1c8dd95 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -46,7 +46,7 @@ const THUMB_HOSTS = new Set([ ]); // Thumbnails accumulate with every card ever scrolled past — keep it bounded. -const THUMB_CACHE_MAX = 2500; +const THUMB_CACHE_MAX = 25000; function isVersionedShellCache(key) { return key.startsWith(SHELL_CACHE_PREFIX) && !UTILITY_CACHES.has(key); } @@ -222,7 +222,10 @@ self.addEventListener('message', (e) => { // Cache-first: serve from cache; if missing, fetch, cache, return. async function cacheFirst(request) { const cache = await caches.open(CACHE); - const cached = await cache.match(request); + // Shell files are precached under their plain URL; index.html asks for them as + // `file?v=` (so the HTTP cache can keep them forever) — match ignoring it. + const versioned = /[?&]v=/.test(request.url); + const cached = await cache.match(request, versioned ? { ignoreSearch: true } : undefined); if (cached) return cached; try { const response = await fetch(request); @@ -262,7 +265,7 @@ async function thumbnail(request) { const cors = await fetch(request.url, { mode: 'cors', credentials: 'omit' }); if (cors && cors.ok) { await cache.put(request, cors.clone()).catch(() => {}); - trimThumbCache(); + if (++_thumbPuts % 100 === 0) trimThumbCache(); return cors; } } catch { /* CORS refused or offline — fall through to the raw request */ } @@ -271,7 +274,8 @@ async function thumbnail(request) { const raw = await fetch(request); if (raw && (raw.status === 200 || raw.status === 0)) { await cache.put(request, raw.clone()).catch(() => {}); - trimThumbCache(); + // keys() on a 25k-entry cache is not free — trim every 100th put, not each one. + if (++_thumbPuts % 100 === 0) trimThumbCache(); } return raw; } catch { @@ -282,6 +286,7 @@ async function thumbnail(request) { // Cache.keys() is insertion-ordered, so the front of the list is the oldest. let _trimmingThumbs = false; +let _thumbPuts = 0; async function trimThumbCache() { if (_trimmingThumbs) return; _trimmingThumbs = true; diff --git a/server/server.js b/server/server.js index a4dc4d2..5f3d40d 100644 --- a/server/server.js +++ b/server/server.js @@ -2187,7 +2187,10 @@ function indexHtml(c) { try { _indexSource = readFileSync('./public/index.html', 'utf8'); } catch { return c.text('index.html not found', 404); } } - const html = _indexSource.replace('__BUILD_TAG__', BUILD_TAG); + // Local css/js get `?v=`: that URL's bytes never change, so the static + // handler below lets browsers keep it forever; a new build is a new URL. + const html = _indexSource.replace('__BUILD_TAG__', BUILD_TAG) + .replace(/((?:href|src)=")((?![a-z]+:|\/)[^"?]+\.(?:css|js))(")/g, `$1$2?v=${BUILD_TAG}$3`); return sendCompressed(c, compressedEntry('index.html', Buffer.from(html), MIME.html), 'no-cache'); } app.get('/', indexHtml); @@ -2203,7 +2206,11 @@ app.get('/*', async (c, next) => { if (!(await file.exists())) return next(); const raw = Buffer.from(await file.arrayBuffer()); const ext = p.slice(p.lastIndexOf('.') + 1).toLowerCase(); - return sendCompressed(c, compressedEntry(p, raw, MIME[ext] || 'application/octet-stream'), 'no-cache'); + // `?v=` equal to the running build → forever (a new build changes the URL). + // Any other / missing v keeps revalidating. + const forever = c.req.query('v') === BUILD_TAG; + return sendCompressed(c, compressedEntry(p, raw, MIME[ext] || 'application/octet-stream'), + forever ? 'public, max-age=31536000, immutable' : 'no-cache'); }); // Self-hosted fonts and icons rarely change: let the browser keep them for 30 days // (the service worker precaches the shell anyway); everything else revalidates.