From fe124f0d5239b9e4042f0a834c370a0b53ba5dfd Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sun, 4 Oct 2026 16:20:19 -0400 Subject: [PATCH 1/3] 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 --- web/src/pwa.test.ts | 19 ++++++ web/src/sw.ts | 79 ++++++++++++++++++++--- web/src/sw/routes.test.ts | 129 ++++++++++++++++++++++++++++++++++++++ web/src/sw/routes.ts | 67 ++++++++++++++++++++ web/vite.config.ts | 2 + 5 files changed, 288 insertions(+), 8 deletions(-) create mode 100644 web/src/sw/routes.test.ts create mode 100644 web/src/sw/routes.ts 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. -- 2.55.0 From 3e955140535fa26796b5ccbe90c4418a5bdf4ded Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sun, 4 Oct 2026 16:25:08 -0400 Subject: [PATCH 2/3] Route font fetches through the expiring font cache Register the font route ahead of precacheAndRoute so a font fetch uses CacheFirst with its ExpirationPlugin instead of being answered by the precache route. Fonts stay in the precache manifest; when the network fails and the runtime cache has no copy, the font comes from the precache. Source tests pin the expiration plugin, the precache fallback and the route order. Read the injected precache list in the production-build test as JSON and assert the index.html entry's revision there, so it no longer depends on matching the minified sw.js with a regex. Maestro task: 8fa0d0bd-4a52-4f09-ba68-41bdbdfda63b Co-Authored-By: Claude Opus 5.5 --- web/src/pwa.test.ts | 20 ++++++++++++++------ web/src/sw.ts | 27 ++++++++++++++++++++++----- web/src/sw/worker.test.ts | 24 ++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 11 deletions(-) create mode 100644 web/src/sw/worker.test.ts diff --git a/web/src/pwa.test.ts b/web/src/pwa.test.ts index 85e708d..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) @@ -96,13 +104,13 @@ 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]) + 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(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) + 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', @@ -110,7 +118,7 @@ describe('production build', () => { 'Figtree-SemiBold', 'Figtree-Bold', ]) { - expect(urls.some((url) => url?.startsWith(`assets/${font}`) && url.endsWith('.woff2'))).toBe(true) + expect(urls.some((url) => url.startsWith(`assets/${font}`) && url.endsWith('.woff2'))).toBe(true) } }) diff --git a/web/src/sw.ts b/web/src/sw.ts index be53e17..b5f92dd 100644 --- a/web/src/sw.ts +++ b/web/src/sw.ts @@ -1,11 +1,14 @@ // MealQuest service worker. vite-plugin-pwa replaces self.__WB_MANIFEST with // 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. 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 { PrecacheFallbackPlugin, matchPrecache, precacheAndRoute } from 'workbox-precaching' import { registerRoute } from 'workbox-routing' import { CacheFirst, NetworkFirst, NetworkOnly, type Strategy } from 'workbox-strategies' import { @@ -23,7 +26,6 @@ declare const self: ServiceWorkerGlobalScope & { // The precache becomes mealquest-precache-v1. setCacheNameDetails({ prefix: 'mealquest', precache: 'precache', suffix: cacheVersion }) -precacheAndRoute(self.__WB_MANIFEST) const day = 24 * 60 * 60 @@ -39,6 +41,8 @@ const strategies = { 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) }, ], }), } @@ -59,11 +63,24 @@ function strategyFor(route: Route): Strategy { } } -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 }) +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) { 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) + }) +}) -- 2.55.0 From 9a5d0a79120aa63bcb7c8235cafef20ccabecc7c Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sun, 4 Oct 2026 16:30:27 -0400 Subject: [PATCH 3/3] Match service worker routes on the decoded path Offline navigations to /%61uth/login or /%61pi/v1/settings/household missed the reserved prefixes and were answered with the precached index.html. routeFor now percent-decodes the pathname and checks reserved prefixes before and after dot segments are resolved, as the server does; a malformed encoding is network-only. Co-Authored-By: Claude Opus 5.5 --- web/src/sw/routes.test.ts | 28 ++++++++++++++++++++++++++++ web/src/sw/routes.ts | 37 +++++++++++++++++++++++++++++++++---- 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/web/src/sw/routes.test.ts b/web/src/sw/routes.test.ts index f9298ec..784fa68 100644 --- a/web/src/sw/routes.test.ts +++ b/web/src/sw/routes.test.ts @@ -56,6 +56,34 @@ describe('routeFor', () => { }, ) + 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) => { diff --git a/web/src/sw/routes.ts b/web/src/sw/routes.ts index 2d097bb..e9ff348 100644 --- a/web/src/sw/routes.ts +++ b/web/src/sw/routes.ts @@ -3,7 +3,10 @@ // 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. +// 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. @@ -42,20 +45,46 @@ 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 = url.pathname - if (neverCached.some((prefix) => under(path, prefix))) 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 (under(path, '/api')) return networkOnly + if (reserved('/api')) return networkOnly if (mode === 'navigate') return { strategy: 'navigation-fallback' } return networkOnly } -- 2.55.0