Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a5d0a7912 | ||
|
|
3e95514053 | ||
|
|
fe124f0d52 | ||
|
|
dd58865323 |
@@ -3,6 +3,8 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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>
|
<title>MealQuest</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Generated
+5227
File diff suppressed because it is too large
Load Diff
+3
-2
@@ -5,9 +5,9 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"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",
|
"preview": "vite preview",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.sw.json",
|
||||||
"test": "npm run lint:colors && vitest run",
|
"test": "npm run lint:colors && vitest run",
|
||||||
"lint:colors": "node scripts/lint-colors.mjs",
|
"lint:colors": "node scripts/lint-colors.mjs",
|
||||||
"check:dist-fonts": "node scripts/check-dist-fonts.mjs"
|
"check:dist-fonts": "node scripts/check-dist-fonts.mjs"
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
"jsdom": "^30.1.1",
|
"jsdom": "^30.1.1",
|
||||||
"typescript": "^7.0.2",
|
"typescript": "^7.0.2",
|
||||||
"vite": "^8.3.2",
|
"vite": "^8.3.2",
|
||||||
|
"vite-plugin-pwa": "^2.0.0",
|
||||||
"vitest": "^5.0.3"
|
"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 |
@@ -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()
|
||||||
@@ -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"')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)))),
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -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'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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
@@ -13,9 +13,11 @@
|
|||||||
"noUncheckedIndexedAccess": true,
|
"noUncheckedIndexedAccess": true,
|
||||||
"verbatimModuleSyntax": true,
|
"verbatimModuleSyntax": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"noEmit": true
|
"noEmit": true
|
||||||
},
|
},
|
||||||
"include": ["src", "vite.config.ts"]
|
"include": ["src", "vite.config.ts", "manifest.json"],
|
||||||
|
"exclude": ["src/sw.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["ES2022", "WebWorker"],
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": ["src/sw.ts"],
|
||||||
|
"exclude": []
|
||||||
|
}
|
||||||
+21
-1
@@ -1,10 +1,30 @@
|
|||||||
import { defineConfig } from 'vitest/config'
|
import { defineConfig } from 'vitest/config'
|
||||||
import react from '@vitejs/plugin-react'
|
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'
|
const backend = 'http://localhost:8080'
|
||||||
|
|
||||||
export default defineConfig({
|
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: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': backend,
|
'/api': backend,
|
||||||
|
|||||||
Reference in New Issue
Block a user