CI / ci (push) Successful in 2m33s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
119 lines
5.0 KiB
TypeScript
119 lines
5.0 KiB
TypeScript
// 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: the worker leaves the request to the browser.
|
|
| { strategy: 'network-only' }
|
|
// Network only, nothing cached; offline, the precached index.html.
|
|
| { strategy: 'navigation-fallback' }
|
|
|
|
export interface RouteRequest {
|
|
method: string
|
|
url: URL
|
|
mode: RequestMode
|
|
}
|
|
|
|
// Each cache carries its own version. Bumping one renames only that cache;
|
|
// activate then deletes its old name and keeps every other cache, so saved
|
|
// photos and fonts stay available offline.
|
|
export const cacheVersions = {
|
|
precache: 'v1',
|
|
// v2 retired the v1 API cache, whose dinner responses still carried the
|
|
// pasted recipe_url; v3 retires the v2 API cache, whose recipe-candidate
|
|
// responses used the full URL as candidate_id.
|
|
api: 'v3',
|
|
media: 'v1',
|
|
fonts: 'v1',
|
|
} as const
|
|
export const cachePrefix = 'mealquest-'
|
|
|
|
export const cacheNames = {
|
|
api: `${cachePrefix}api-${cacheVersions.api}`,
|
|
media: `${cachePrefix}media-${cacheVersions.media}`,
|
|
fonts: `${cachePrefix}fonts-${cacheVersions.fonts}`,
|
|
} as const
|
|
|
|
export const networkTimeoutSeconds = 3
|
|
|
|
// cacheFallbackHeader marks a network-first response answered from the cache
|
|
// because the server could not be reached in time, so the app can show the
|
|
// offline indicator even while the browser reports that it is online.
|
|
export const cacheFallbackHeader = 'X-MealQuest-Cache-Fallback'
|
|
|
|
// markCacheFallback copies a cached response with cacheFallbackHeader set.
|
|
export function markCacheFallback(cached: Response): Response {
|
|
const headers = new Headers(cached.headers)
|
|
headers.set(cacheFallbackHeader, '1')
|
|
return new Response(cached.body, { status: cached.status, statusText: cached.statusText, headers })
|
|
}
|
|
|
|
const neverCached = ['/auth', '/api/v1/settings', '/api/v1/jobs']
|
|
const networkFirst = ['/api/v1/plans', '/api/v1/shopping', '/api/v1/dinners', '/api/v1/recipes', '/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))
|
|
}
|