From 21b2672d40b0869b5daa0d06aea09de2383d8944 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sun, 4 Oct 2026 16:36:43 -0400 Subject: [PATCH] Service worker caching strategy (#51) Co-authored-by: dev_agent Co-committed-by: dev_agent --- web/src/pwa.test.ts | 27 +++++++ web/src/sw.ts | 96 +++++++++++++++++++++-- web/src/sw/routes.test.ts | 157 ++++++++++++++++++++++++++++++++++++++ web/src/sw/routes.ts | 96 +++++++++++++++++++++++ web/src/sw/worker.test.ts | 24 ++++++ web/vite.config.ts | 2 + 6 files changed, 394 insertions(+), 8 deletions(-) create mode 100644 web/src/sw/routes.test.ts create mode 100644 web/src/sw/routes.ts create mode 100644 web/src/sw/worker.test.ts diff --git a/web/src/pwa.test.ts b/web/src/pwa.test.ts index 671e89e..dda3d6a 100644 --- a/web/src/pwa.test.ts +++ b/web/src/pwa.test.ts @@ -23,6 +23,14 @@ function token(name: string): string { // Width and height from a PNG's IHDR chunk, which follows the 8-byte // signature, the chunk length and the chunk type. +// The precache list vite-plugin-pwa injects into sw.js: a JSON array of +// { revision, url } entries. +function precacheEntries(sw: string): Array<{ revision: string | null; url: string }> { + const start = sw.indexOf('[{"revision":') + expect(start, 'injected precache list').toBeGreaterThanOrEqual(0) + return JSON.parse(sw.slice(start, sw.indexOf('}]', start) + 2)) +} + function pngSize(bytes: Uint8Array): { width: number; height: number } { const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] expect(Array.from(bytes.subarray(0, 8))).toEqual(signature) @@ -95,6 +103,25 @@ describe('production build', () => { } }) + it('precaches the shell, JS, CSS and every self-hosted font', async () => { + const entries = precacheEntries(await (await get('/sw.js')).text()) + const urls = entries.map((entry) => entry.url) + // index.html keeps its URL across builds, so it carries a content + // revision; the other assets have content hashes in their names. + expect(entries.find((entry) => entry.url === 'index.html')?.revision).toMatch(/^[0-9a-f]{32}$/) + expect(urls.some((url) => url.endsWith('.js'))).toBe(true) + expect(urls.some((url) => url.endsWith('.css'))).toBe(true) + for (const font of [ + 'BagelFatOne-LatinExt', + 'BagelFatOne-Latin', + 'Figtree-Regular', + 'Figtree-SemiBold', + 'Figtree-Bold', + ]) { + expect(urls.some((url) => url.startsWith(`assets/${font}`) && url.endsWith('.woff2'))).toBe(true) + } + }) + it('links the manifest, theme colour and apple-touch-icon from index.html', async () => { const html = await (await get('/')).text() expect(html.match(//g)).toHaveLength(1) diff --git a/web/src/sw.ts b/web/src/sw.ts index 98e9b6c..b5f92dd 100644 --- a/web/src/sw.ts +++ b/web/src/sw.ts @@ -1,17 +1,97 @@ // MealQuest service worker. vite-plugin-pwa replaces self.__WB_MANIFEST with -// the build's precache list, and installing caches every entry. Serving from -// that cache, expiring old entries and the update flow belong to the runtime -// caching strategies, which are not built yet. +// the build's precache list (shell, JS, CSS and fonts, each with a revision). +// Every other request goes through the route src/sw/routes.ts decides. +// Fonts are precached too, but their route is registered ahead of the +// precache route, so a font fetch goes through the expiring CacheFirst +// strategy and falls back to the precached copy when the network fails. -export {} +import { cacheNames as workboxCacheNames, setCacheNameDetails } from 'workbox-core' +import type { RouteHandlerCallbackOptions } from 'workbox-core/types' +import { ExpirationPlugin } from 'workbox-expiration' +import { PrecacheFallbackPlugin, matchPrecache, precacheAndRoute } from 'workbox-precaching' +import { registerRoute } from 'workbox-routing' +import { CacheFirst, NetworkFirst, NetworkOnly, type Strategy } from 'workbox-strategies' +import { + cacheNames, + cacheVersion, + networkTimeoutSeconds, + routeFor, + staleCaches, + type Route, +} from './sw/routes' declare const self: ServiceWorkerGlobalScope & { __WB_MANIFEST: Array<{ url: string; revision: string | null }> } -const precacheName = 'mealquest-precache' -const precacheURLs = self.__WB_MANIFEST.map((entry) => entry.url) +// The precache becomes mealquest-precache-v1. +setCacheNameDetails({ prefix: 'mealquest', precache: 'precache', suffix: cacheVersion }) -self.addEventListener('install', (event) => { - event.waitUntil(caches.open(precacheName).then((cache) => cache.addAll(precacheURLs))) +const day = 24 * 60 * 60 + +const strategies = { + [cacheNames.api]: new NetworkFirst({ cacheName: cacheNames.api, networkTimeoutSeconds }), + [cacheNames.media]: new CacheFirst({ + cacheName: cacheNames.media, + plugins: [ + new ExpirationPlugin({ maxEntries: 200, maxAgeSeconds: 30 * day, purgeOnQuotaError: true }), + ], + }), + [cacheNames.fonts]: new CacheFirst({ + cacheName: cacheNames.fonts, + plugins: [ + new ExpirationPlugin({ maxEntries: 20, maxAgeSeconds: 365 * day, purgeOnQuotaError: true }), + // Offline with no runtime copy yet: the font from the precache. + { handlerDidError: async ({ request }) => matchPrecache(request.url) }, + ], + }), +} +const networkOnly = new NetworkOnly() +const navigation = new NetworkOnly({ + plugins: [new PrecacheFallbackPlugin({ fallbackURL: 'index.html' })], +}) + +function strategyFor(route: Route): Strategy { + switch (route.strategy) { + case 'network-first': + case 'cache-first': + return strategies[route.cacheName] + case 'network-only': + return networkOnly + case 'navigation-fallback': + return navigation + } +} + +function routeOf({ request, url }: { request: Request; url: URL }): Route { + return routeFor({ method: request.method, url, mode: request.mode }, self.location.origin) +} + +function handle({ request, url, event }: RouteHandlerCallbackOptions): Promise { + return strategyFor(routeOf({ request, url })).handle({ request, event }) +} + +function isFont(options: { request: Request; url: URL }): boolean { + const route = routeOf(options) + return route.strategy === 'cache-first' && route.cacheName === cacheNames.fonts +} + +// Workbox uses the first route that matches, so the font route comes before +// the precache route, which would otherwise answer every precached font. +registerRoute(isFont, handle, 'GET') +precacheAndRoute(self.__WB_MANIFEST) + +// Workbox routes are per method; every method goes through routeFor, which +// sends every non-GET to the network only. +for (const method of ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'] as const) { + registerRoute(() => true, handle, method) +} + +self.addEventListener('activate', (event) => { + const current = [workboxCacheNames.precache, ...Object.values(cacheNames)] + event.waitUntil( + caches + .keys() + .then((names) => Promise.all(staleCaches(names, current).map((name) => caches.delete(name)))), + ) }) diff --git a/web/src/sw/routes.test.ts b/web/src/sw/routes.test.ts new file mode 100644 index 0000000..784fa68 --- /dev/null +++ b/web/src/sw/routes.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from 'vitest' +import { cacheNames, routeFor, staleCaches, type Route } from './routes' + +const origin = 'https://mealquest.test' + +function route(path: string, method = 'GET', mode: RequestMode = 'cors'): Route { + return routeFor({ method, url: new URL(path, origin), mode }, origin) +} + +const apiCache: Route = { strategy: 'network-first', cacheName: cacheNames.api } +const networkOnly: Route = { strategy: 'network-only' } + +describe('routeFor', () => { + it.each([ + '/api/v1/plans', + '/api/v1/plans/2026-W40', + '/api/v1/shopping/current', + '/api/v1/dinners', + '/api/v1/dinners/01J9ZK?include=recipe', + '/api/v1/home', + ])('GET %s is network-first in the API cache', (path) => { + expect(route(path)).toEqual(apiCache) + }) + + it.each(['/media/photos/abc.jpg', '/media/thumbs/abc.webp'])( + 'GET %s is cache-first in the media cache', + (path) => { + expect(route(path)).toEqual({ strategy: 'cache-first', cacheName: cacheNames.media }) + }, + ) + + it('GET of a font file is cache-first in the fonts cache', () => { + expect(route('/assets/Figtree-Bold-a1b2c3.woff2')).toEqual({ + strategy: 'cache-first', + cacheName: cacheNames.fonts, + }) + }) + + it.each([ + '/auth/login', + '/auth/callback?code=x&state=y', + '/auth/logout', + '/auth/me', + '/api/v1/settings', + '/api/v1/settings/ai', + '/api/v1/jobs', + '/api/v1/jobs/01J9ZK', + ])('GET %s is network-only', (path) => { + expect(route(path)).toEqual(networkOnly) + }) + + it.each(['/auth/login', '/auth/callback?code=x&state=y', '/api/v1/settings/ai'])( + 'a navigation to %s is network-only with no offline fallback', + (path) => { + expect(route(path, 'GET', 'navigate')).toEqual(networkOnly) + }, + ) + + it.each([ + '/%61uth/login', + '/auth%2Fcallback?code=x&state=y', + '/%61pi/v1/settings/household', + '/api/v1/%73ettings/ai', + '/api/v1/settings%2Fai', + '/%61pi/v1/jobs/01J9ZK', + '/api/v1/j%6Fbs', + '/api/v1/plans%2F..%2Fsettings', + '/plan%2F..%2Fauth%2Flogin', + ])('an encoded reserved path %s is network-only, navigated or not', (path) => { + expect(route(path)).toEqual(networkOnly) + expect(route(path, 'GET', 'navigate')).toEqual(networkOnly) + }) + + it.each(['/%E0%A4%A', '/auth%', '/plan/%zz', '/api/v1/plans/%C3'])( + 'a malformed encoding in %s is network-only, navigated or not', + (path) => { + expect(route(path)).toEqual(networkOnly) + expect(route(path, 'GET', 'navigate')).toEqual(networkOnly) + }, + ) + + it('classifies other API paths by their decoded form', () => { + expect(route('/%61pi/v1/plans/2026-W40')).toEqual(apiCache) + expect(route('/%61pi/v1/recipes', 'GET', 'navigate')).toEqual(networkOnly) + }) + + it.each(['POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'])( + '%s is network-only on every URL class', + (method) => { + for (const path of [ + '/api/v1/plans/2026-W40', + '/api/v1/shopping/current', + '/api/v1/dinners/01J9ZK', + '/api/v1/home', + '/media/photos/abc.jpg', + '/assets/Figtree-Bold-a1b2c3.woff2', + '/auth/logout', + '/plan', + ]) { + expect(route(path, method), `${method} ${path}`).toEqual(networkOnly) + expect(route(path, method, 'navigate'), `${method} ${path}`).toEqual(networkOnly) + } + }, + ) + + it('matches whole path segments only', () => { + expect(route('/api/v1/plansx')).toEqual(networkOnly) + expect(route('/api/v1/homepage')).toEqual(networkOnly) + expect(route('/mediax/a.jpg')).toEqual(networkOnly) + expect(route('/authors')).toEqual(networkOnly) + expect(route('/api/v1/settingsx')).toEqual(networkOnly) + }) + + it('leaves other API calls network-only', () => { + expect(route('/api/v1/recipes')).toEqual(networkOnly) + expect(route('/api/v1/ideas', 'GET', 'navigate')).toEqual(networkOnly) + }) + + it('leaves cross-origin requests network-only', () => { + for (const path of ['/api/v1/plans', '/media/a.jpg', '/assets/a.woff2']) { + const url = new URL(path, 'https://cdn.example') + expect(routeFor({ method: 'GET', url, mode: 'no-cors' }, origin)).toEqual(networkOnly) + } + }) + + it.each(['/', '/plan', '/supply-run', '/dinners/01J9ZK'])( + 'a navigation to the app at %s falls back to the precached index.html', + (path) => { + expect(route(path, 'GET', 'navigate')).toEqual({ strategy: 'navigation-fallback' }) + }, + ) + + it('leaves other same-origin GETs network-only', () => { + expect(route('/health')).toEqual(networkOnly) + }) +}) + +describe('cache names', () => { + it('all start with mealquest-', () => { + for (const name of Object.values(cacheNames)) expect(name).toMatch(/^mealquest-/) + }) +}) + +describe('staleCaches', () => { + it('deletes only MealQuest caches that are not current', () => { + const current = ['mealquest-precache-v1', ...Object.values(cacheNames)] + const names = [ + ...current, + 'mealquest-precache', + 'mealquest-api-v0', + 'workbox-precache-v2-https://mealquest.test/', + 'other-app-cache', + 'my-mealquest-cache', + ] + expect(staleCaches(names, current)).toEqual(['mealquest-precache', 'mealquest-api-v0']) + }) +}) diff --git a/web/src/sw/routes.ts b/web/src/sw/routes.ts new file mode 100644 index 0000000..e9ff348 --- /dev/null +++ b/web/src/sw/routes.ts @@ -0,0 +1,96 @@ +// Decides how the service worker handles each request. This module is pure — +// no Workbox, no service worker globals — so the tests can assert every URL +// class without a worker; src/sw.ts maps each route to a Workbox strategy. +// +// Security rule: /auth/*, /api/v1/settings/*, /api/v1/jobs/* and every +// non-GET request are network-only and never reach a cache. Paths are matched +// as the server sees them — percent-decoded, before and after dot segments are +// resolved — so /%61uth/login is /auth/login; a malformed encoding is +// network-only. + +export type Route = + // Network with a 3 s timeout, then the last cached response. + | { strategy: 'network-first'; cacheName: typeof cacheNames.api } + // Cached response when there is one, else the network; entries expire. + | { strategy: 'cache-first'; cacheName: typeof cacheNames.media | typeof cacheNames.fonts } + // Network only, nothing cached. + | { strategy: 'network-only' } + // Network only, nothing cached; offline, the precached index.html. + | { strategy: 'navigation-fallback' } + +export interface RouteRequest { + method: string + url: URL + mode: RequestMode +} + +// Bumping the version renames every cache; activate then deletes the old ones. +export const cacheVersion = 'v1' +export const cachePrefix = 'mealquest-' + +export const cacheNames = { + api: `${cachePrefix}api-${cacheVersion}`, + media: `${cachePrefix}media-${cacheVersion}`, + fonts: `${cachePrefix}fonts-${cacheVersion}`, +} as const + +export const networkTimeoutSeconds = 3 + +const neverCached = ['/auth', '/api/v1/settings', '/api/v1/jobs'] +const networkFirst = ['/api/v1/plans', '/api/v1/shopping', '/api/v1/dinners', '/api/v1/home'] + +// True when path is prefix itself or a path below it: /api/v1/plans matches +// /api/v1/plans and /api/v1/plans/2026-W40, never /api/v1/plansx. +function under(path: string, prefix: string): boolean { + return path === prefix || path.startsWith(`${prefix}/`) +} + +// The pathname percent-decoded, as the server's router sees it, or undefined +// when the encoding is malformed. +function decodePath(pathname: string): string | undefined { + try { + return decodeURIComponent(pathname) + } catch { + return undefined + } +} + +// Resolves "." and ".." segments the way the server's path.Clean does, so a +// decoded %2F cannot carry a request out of a reserved prefix: +// /api/v1/plans/../settings cleans to /api/v1/settings. +function cleanPath(path: string): string { + const segments: string[] = [] + for (const segment of path.split('/')) { + if (segment === '' || segment === '.') continue + if (segment === '..') segments.pop() + else segments.push(segment) + } + return `/${segments.join('/')}` +} + +const networkOnly: Route = { strategy: 'network-only' } + +export function routeFor(request: RouteRequest, origin: string): Route { + const { method, url, mode } = request + if (method !== 'GET') return networkOnly + if (url.origin !== origin) return networkOnly + const path = decodePath(url.pathname) + if (path === undefined) return networkOnly + const cleaned = cleanPath(path) + const reserved = (prefix: string) => under(path, prefix) || under(cleaned, prefix) + if (neverCached.some(reserved)) return networkOnly + if (networkFirst.some((prefix) => under(path, prefix))) { + return { strategy: 'network-first', cacheName: cacheNames.api } + } + if (under(path, '/media')) return { strategy: 'cache-first', cacheName: cacheNames.media } + if (path.endsWith('.woff2')) return { strategy: 'cache-first', cacheName: cacheNames.fonts } + if (reserved('/api')) return networkOnly + if (mode === 'navigate') return { strategy: 'navigation-fallback' } + return networkOnly +} + +// The caches activate deletes: MealQuest caches that are no longer current. +// Caches without the mealquest- prefix belong to someone else and are kept. +export function staleCaches(names: readonly string[], current: readonly string[]): string[] { + return names.filter((name) => name.startsWith(cachePrefix) && !current.includes(name)) +} diff --git a/web/src/sw/worker.test.ts b/web/src/sw/worker.test.ts new file mode 100644 index 0000000..07382fb --- /dev/null +++ b/web/src/sw/worker.test.ts @@ -0,0 +1,24 @@ +// The service worker itself needs worker globals, so these tests read its +// source: they pin the parts routes.ts cannot express — the font strategy's +// expiration and the font route's place ahead of the precache route. +import { describe, expect, it } from 'vitest' +import source from '../sw.ts?raw' + +describe('service worker source', () => { + it('gives the fonts cache a CacheFirst strategy with an expiration plugin', () => { + expect(source).toMatch( + /\[cacheNames\.fonts\]: new CacheFirst\(\{\s*cacheName: cacheNames\.fonts,\s*plugins: \[\s*new ExpirationPlugin\(\{ maxEntries: \d+, maxAgeSeconds: [^}]+\}\)/, + ) + }) + + it('falls back to the precached font when the network fails', () => { + expect(source).toContain('handlerDidError: async ({ request }) => matchPrecache(request.url)') + }) + + it('registers the font route before the precache route', () => { + const fontRoute = source.indexOf("registerRoute(isFont, handle, 'GET')") + const precache = source.indexOf('precacheAndRoute(self.__WB_MANIFEST)') + expect(fontRoute).toBeGreaterThanOrEqual(0) + expect(precache).toBeGreaterThan(fontRoute) + }) +}) diff --git a/web/vite.config.ts b/web/vite.config.ts index ae9bdf5..48a76c4 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -17,6 +17,8 @@ export default defineConfig({ srcDir: 'src', filename: 'sw.ts', injectRegister: 'script', + // Precache the shell, JS, CSS and the self-hosted fonts. + injectManifest: { globPatterns: ['**/*.{html,js,css,woff2}'] }, // The web manifest lives in manifest.json: its theme and background // colours are --color-accent and --color-bg from src/styles/tokens.css, // and hex colours may not be written in TypeScript.