Service worker caching strategy (#51)
CI / ci (push) Successful in 1m31s

Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
This commit was merged in pull request #51.
This commit is contained in:
2026-10-04 16:36:43 -04:00
committed by approver_agent
parent dd58865323
commit 21b2672d40
6 changed files with 394 additions and 8 deletions
+27
View File
@@ -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(/<link rel="manifest" href="\/manifest\.webmanifest">/g)).toHaveLength(1)
+88 -8
View File
@@ -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<Response> {
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)))),
)
})
+157
View File
@@ -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'])
})
})
+96
View File
@@ -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))
}
+24
View File
@@ -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)
})
})
+2
View File
@@ -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.