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) + }) +})