Compare commits

...
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 9a5d0a7912 Match service worker routes on the decoded path
CI / ci (push) Successful in 1m34s
CI / ci (pull_request) Successful in 1m33s
Independent review Codex gpt-6-sol high: APPROVED (0 findings)
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 <noreply@anthropic.com>
2026-10-04 16:30:27 -04:00
dev_agentandClaude Opus 5.5 3e95514053 Route font fetches through the expiring font cache
CI / ci (push) Successful in 1m34s
CI / ci (pull_request) Successful in 1m34s
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 <noreply@anthropic.com>
2026-10-04 16:25:08 -04:00
dev_agentandClaude Opus 5.5 fe124f0d52 Service worker caching strategy
CI / ci (pull_request) Successful in 1m34s
CI / ci (push) Successful in 1m33s
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>
2026-10-04 16:20:19 -04:00
dev_agent dd58865323 Web app manifest and icons (#50)
CI / ci (push) Successful in 1m35s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-04 16:16:41 -04:00
17 changed files with 5874 additions and 4 deletions
+2
View File
@@ -3,6 +3,8 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#5a358c" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<title>MealQuest</title>
</head>
<body>
+19
View File
@@ -0,0 +1,19 @@
{
"name": "MealQuest",
"short_name": "MealQuest",
"start_url": "/",
"scope": "/",
"display": "standalone",
"theme_color": "#5a358c",
"background_color": "#f9f6f1",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{
"src": "/icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
+5227
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -5,9 +5,9 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "npm run lint:colors && tsc --noEmit && vite build && npm run check:dist-fonts",
"build": "npm run lint:colors && npm run typecheck && vite build && npm run check:dist-fonts",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.sw.json",
"test": "npm run lint:colors && vitest run",
"lint:colors": "node scripts/lint-colors.mjs",
"check:dist-fonts": "node scripts/check-dist-fonts.mjs"
@@ -29,6 +29,7 @@
"jsdom": "^30.1.1",
"typescript": "^7.0.2",
"vite": "^8.3.2",
"vite-plugin-pwa": "^2.0.0",
"vitest": "^5.0.3"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 505 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

+59
View File
@@ -0,0 +1,59 @@
"""Generates the PWA icons in web/public from the MealQuest logo.
Run from the repository root with Pillow installed:
python3 web/scripts/generate-icons.py
The logo fills the regular icons edge to edge. The maskable icon shrinks it
onto the colour of its own parchment, so the banner stays inside
the circular safe zone (radius 40% of the icon) that launchers may crop to.
"""
from pathlib import Path
from PIL import Image, ImageDraw, ImageFilter, ImageStat
ROOT = Path(__file__).resolve().parents[2]
SOURCE = ROOT / "design" / "logos" / "logo_v1_2Oct2026.jpg"
PUBLIC = ROOT / "web" / "public"
MASKABLE_SCALE = 0.66
def resized(logo: Image.Image, size: int) -> Image.Image:
return logo.resize((size, size), Image.Resampling.LANCZOS)
def maskable(logo: Image.Image, size: int) -> Image.Image:
# The padding is the median colour of the logo's top edge, which is bare
# parchment; a feathered edge hides the seam.
top_edge = ImageStat.Stat(logo.crop((0, 0, logo.width, logo.height // 40)))
canvas = Image.new("RGB", (size, size), tuple(round(c) for c in top_edge.median))
inner = round(size * MASKABLE_SCALE)
offset = (size - inner) // 2
feather = Image.new("L", (inner, inner), 0)
margin = inner // 24
ImageDraw.Draw(feather).rectangle((margin, margin, inner - margin, inner - margin), fill=255)
feather = feather.filter(ImageFilter.GaussianBlur(margin / 2))
canvas.paste(resized(logo, inner), (offset, offset), feather)
return canvas
def main() -> None:
logo = Image.open(SOURCE).convert("RGB")
if logo.width != logo.height:
raise SystemExit(f"{SOURCE} is {logo.width}x{logo.height}; the logo must be square")
outputs = {
PUBLIC / "icons" / "icon-192.png": resized(logo, 192),
PUBLIC / "icons" / "icon-512.png": resized(logo, 512),
PUBLIC / "icons" / "icon-maskable-512.png": maskable(logo, 512),
PUBLIC / "apple-touch-icon.png": resized(logo, 180),
}
for path, image in outputs.items():
path.parent.mkdir(parents=True, exist_ok=True)
image.save(path, optimize=True)
print(f"wrote {path.relative_to(ROOT)} ({image.width}x{image.height})")
if __name__ == "__main__":
main()
+157
View File
@@ -0,0 +1,157 @@
// @vitest-environment node
// Builds the app with the production config and serves the result, so the
// assertions cover what ships in dist: the web manifest, the service worker,
// every icon, and the service worker registration that only builds include.
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { build, createServer, preview, type PreviewServer, type ViteDevServer } from 'vite'
import tokens from './styles/tokens.css?raw'
const outDir = 'node_modules/.tmp/pwa-test-dist'
const icons = [
{ path: '/icons/icon-192.png', size: 192 },
{ path: '/icons/icon-512.png', size: 512 },
{ path: '/icons/icon-maskable-512.png', size: 512 },
{ path: '/apple-touch-icon.png', size: 180 },
]
function token(name: string): string {
const value = tokens.match(new RegExp(`^\\s*${name}:\\s*([^;\\s]+);`, 'm'))?.[1]
if (!value) throw new Error(`${name} is not defined in tokens.css`)
return value
}
// 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)
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
return { width: view.getUint32(16), height: view.getUint32(20) }
}
describe('production build', () => {
let server: PreviewServer
let origin: string
async function get(path: string): Promise<Response> {
const response = await fetch(new URL(path, origin))
expect(response.status, path).toBe(200)
return response
}
beforeAll(async () => {
await build({ logLevel: 'error', build: { outDir, emptyOutDir: true } })
server = await preview({
logLevel: 'error',
build: { outDir },
preview: { port: 0, strictPort: false, host: '127.0.0.1' },
})
const url = server.resolvedUrls?.local[0]
if (!url) throw new Error('preview server did not report a URL')
origin = url
}, 60_000)
afterAll(async () => {
await server?.close()
})
it('emits manifest.webmanifest with the MealQuest identity', async () => {
const manifest = await (await get('/manifest.webmanifest')).json()
expect(manifest).toMatchObject({
name: 'MealQuest',
short_name: 'MealQuest',
start_url: '/',
scope: '/',
display: 'standalone',
// --color-accent and --color-bg hold the manifest colours; hex colours
// may not be written outside tokens.css.
theme_color: token('--color-accent'),
background_color: token('--color-bg'),
})
expect(manifest.icons).toEqual([
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
{
src: '/icons/icon-maskable-512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable',
},
])
})
it.each(icons)('ships $path as a $size px square PNG', async ({ path, size }) => {
const bytes = new Uint8Array(await (await get(path)).arrayBuffer())
expect(pngSize(bytes)).toEqual({ width: size, height: size })
})
it('emits sw.js with the precache list injected', async () => {
const sw = await (await get('/sw.js')).text()
expect(sw).not.toContain('self.__WB_MANIFEST')
expect(sw).toContain('"url":"index.html"')
for (const { path } of icons.slice(0, 3)) {
expect(sw).toContain(`"url":"${path.slice(1)}"`)
}
})
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)
expect(html).toContain(`<meta name="theme-color" content="${token('--color-accent')}" />`)
expect(html).toContain('<link rel="apple-touch-icon" href="/apple-touch-icon.png" />')
})
it('registers /sw.js at scope / from index.html', async () => {
const html = await (await get('/')).text()
expect(html).toContain('<script id="vite-plugin-pwa:register-sw" src="/registerSW.js"></script>')
const register = await (await get('/registerSW.js')).text()
expect(register).toContain("navigator.serviceWorker.register('/sw.js', { scope: '/' })")
})
})
describe('dev server', () => {
let server: ViteDevServer
beforeAll(async () => {
server = await createServer({ logLevel: 'error', server: { middlewareMode: true } })
})
afterAll(async () => {
await server?.close()
})
it('does not register a service worker', async () => {
const html = await server.transformIndexHtml('/', '<html><head></head><body></body></html>')
expect(html).not.toContain('registerSW')
expect(html).not.toContain('serviceWorker')
expect(html).not.toContain('rel="manifest"')
})
})
+97
View File
@@ -0,0 +1,97 @@
// 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, 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 }>
}
// The precache becomes mealquest-precache-v1.
setCacheNameDetails({ prefix: 'mealquest', precache: 'precache', suffix: cacheVersion })
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)
})
})
+3 -1
View File
@@ -13,9 +13,11 @@
"noUncheckedIndexedAccess": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"resolveJsonModule": true,
"allowImportingTsExtensions": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src", "vite.config.ts"]
"include": ["src", "vite.config.ts", "manifest.json"],
"exclude": ["src/sw.ts"]
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"lib": ["ES2022", "WebWorker"],
"types": []
},
"include": ["src/sw.ts"],
"exclude": []
}
+21 -1
View File
@@ -1,10 +1,30 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import { VitePWA, type ManifestOptions } from 'vite-plugin-pwa'
import manifest from './manifest.json' with { type: 'json' }
const backend = 'http://localhost:8080'
export default defineConfig({
plugins: [react()],
plugins: [
react(),
// The plugin writes manifest.webmanifest and sw.js, links the manifest
// from index.html and adds the registerSW.js script. It does both only
// in production builds: dev options stay off, so neither `vite` nor
// Vitest registers a service worker.
VitePWA({
strategies: 'injectManifest',
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.
manifest: manifest as Partial<ManifestOptions>,
}),
],
server: {
proxy: {
'/api': backend,