Service worker caching strategy
Precache the shell, JS, CSS and self-hosted fonts in mealquest-precache-v1. Route every other request through the pure src/sw/routes.ts decision: NetworkFirst (3 s timeout) for plans, shopping, dinners and home; CacheFirst with expiration for /media/* and font files; NetworkOnly for /auth/*, settings, jobs and every non-GET. App navigations fall back to the precached index.html offline. On activate, delete only stale caches named mealquest-*. Maestro task: 8fa0d0bd-4a52-4f09-ba68-41bdbdfda63b Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 () => {
|
it('links the manifest, theme colour and apple-touch-icon from index.html', async () => {
|
||||||
const html = await (await get('/')).text()
|
const html = await (await get('/')).text()
|
||||||
expect(html.match(/<link rel="manifest" href="\/manifest\.webmanifest">/g)).toHaveLength(1)
|
expect(html.match(/<link rel="manifest" href="\/manifest\.webmanifest">/g)).toHaveLength(1)
|
||||||
|
|||||||
+71
-8
@@ -1,17 +1,80 @@
|
|||||||
// MealQuest service worker. vite-plugin-pwa replaces self.__WB_MANIFEST with
|
// MealQuest service worker. vite-plugin-pwa replaces self.__WB_MANIFEST with
|
||||||
// the build's precache list, and installing caches every entry. Serving from
|
// the build's precache list (shell, JS, CSS and fonts, each with a revision).
|
||||||
// that cache, expiring old entries and the update flow belong to the runtime
|
// Every other request goes through the route src/sw/routes.ts decides.
|
||||||
// caching strategies, which are not built yet.
|
|
||||||
|
|
||||||
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 & {
|
declare const self: ServiceWorkerGlobalScope & {
|
||||||
__WB_MANIFEST: Array<{ url: string; revision: string | null }>
|
__WB_MANIFEST: Array<{ url: string; revision: string | null }>
|
||||||
}
|
}
|
||||||
|
|
||||||
const precacheName = 'mealquest-precache'
|
// The precache becomes mealquest-precache-v1.
|
||||||
const precacheURLs = self.__WB_MANIFEST.map((entry) => entry.url)
|
setCacheNameDetails({ prefix: 'mealquest', precache: 'precache', suffix: cacheVersion })
|
||||||
|
precacheAndRoute(self.__WB_MANIFEST)
|
||||||
|
|
||||||
self.addEventListener('install', (event) => {
|
const day = 24 * 60 * 60
|
||||||
event.waitUntil(caches.open(precacheName).then((cache) => cache.addAll(precacheURLs)))
|
|
||||||
|
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<Response> {
|
||||||
|
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)))),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@ export default defineConfig({
|
|||||||
srcDir: 'src',
|
srcDir: 'src',
|
||||||
filename: 'sw.ts',
|
filename: 'sw.ts',
|
||||||
injectRegister: 'script',
|
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
|
// The web manifest lives in manifest.json: its theme and background
|
||||||
// colours are --color-accent and --color-bg from src/styles/tokens.css,
|
// colours are --color-accent and --color-bg from src/styles/tokens.css,
|
||||||
// and hex colours may not be written in TypeScript.
|
// and hex colours may not be written in TypeScript.
|
||||||
|
|||||||
Reference in New Issue
Block a user