diff --git a/web/src/pwa.test.ts b/web/src/pwa.test.ts index 671e89e..85e708d 100644 --- a/web/src/pwa.test.ts +++ b/web/src/pwa.test.ts @@ -95,6 +95,25 @@ describe('production build', () => { } }) + it('precaches the shell, JS, CSS and every self-hosted font', async () => { + const sw = await (await get('/sw.js')).text() + const urls = [...sw.matchAll(/"url":"([^"]+)"/g)].map((match) => match[1]) + // index.html keeps its URL across builds, so it carries a content + // revision; the other assets have content hashes in their names. + expect(sw).toMatch(/\{"revision":"[0-9a-f]{32}","url":"index\.html"\}/) + 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..be53e17 100644 --- a/web/src/sw.ts +++ b/web/src/sw.ts @@ -1,17 +1,80 @@ // 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. -export {} +import { cacheNames as workboxCacheNames, setCacheNameDetails } from 'workbox-core' +import type { RouteHandlerCallbackOptions } from 'workbox-core/types' +import { ExpirationPlugin } from 'workbox-expiration' +import { PrecacheFallbackPlugin, 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 }) +precacheAndRoute(self.__WB_MANIFEST) -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 }), + ], + }), +} +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 handle({ request, url, event }: RouteHandlerCallbackOptions): Promise { + const route = routeFor({ method: request.method, url, mode: request.mode }, self.location.origin) + return strategyFor(route).handle({ request, event }) +} + +// 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..f9298ec --- /dev/null +++ b/web/src/sw/routes.test.ts @@ -0,0 +1,129 @@ +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(['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..2d097bb --- /dev/null +++ b/web/src/sw/routes.ts @@ -0,0 +1,67 @@ +// 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. + +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}/`) +} + +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 = url.pathname + if (neverCached.some((prefix) => under(path, prefix))) 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 (under(path, '/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/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.