Service worker caching strategy #51

3 changed files with 60 additions and 11 deletions
Showing only changes of commit 3e95514053 - Show all commits
+14 -6
View File
@@ -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)
}
})
+22 -5
View File
@@ -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<Response> {
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<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) {
+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)
})
})