diff --git a/.gitignore b/.gitignore index 1555e91..c1955a9 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ # production /build +/public/sw.js # misc .DS_Store diff --git a/package.json b/package.json index 4863ce6..3f2d2fb 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "private": true, "scripts": { "dev": "next dev", + "prebuild": "node scripts/build-sw.mjs", "build": "next build", "start": "next start", "lint": "eslint", diff --git a/scripts/build-sw.mjs b/scripts/build-sw.mjs new file mode 100644 index 0000000..25f5a11 --- /dev/null +++ b/scripts/build-sw.mjs @@ -0,0 +1,49 @@ +import { randomUUID } from "node:crypto"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const TEMPLATE_PATH = resolve(ROOT, "scripts/sw.template.js"); +const DEFAULT_OUTPUT_PATH = resolve(ROOT, "public/sw.js"); +const BUILD_ID_TOKEN = "__STUDYMAP_BUILD_ID__"; + +function getOption(name) { + const prefix = `${name}=`; + const match = process.argv.slice(2).find((arg) => arg.startsWith(prefix)); + return match?.slice(prefix.length); +} + +const explicitBuildId = getOption("--build-id"); +if (explicitBuildId === "") { + throw new Error("--build-id must not be empty"); +} + +const sourceRevision = + process.env.VERCEL_GIT_COMMIT_SHA ?? process.env.GITHUB_SHA ?? "local"; +const buildId = + explicitBuildId ?? + `${sourceRevision}-${Date.now().toString(36)}-${randomUUID()}`; +const outputPath = resolve( + process.cwd(), + getOption("--output") ?? DEFAULT_OUTPUT_PATH, +); + +const template = await readFile(TEMPLATE_PATH, "utf8"); +const tokenCount = template.split(BUILD_ID_TOKEN).length - 1; +if (tokenCount !== 1) { + throw new Error( + `Expected exactly one ${BUILD_ID_TOKEN} token in ${relative(ROOT, TEMPLATE_PATH)}, found ${tokenCount}`, + ); +} + +// A replacer function keeps "$"-sequences in the build ID from being +// interpreted as special replacement patterns, and JSON encoding keeps the +// generated script valid even if a future build ID contains characters that +// would otherwise need JavaScript string escaping. +const rendered = template.replace(BUILD_ID_TOKEN, () => JSON.stringify(buildId)); + +await mkdir(dirname(outputPath), { recursive: true }); +await writeFile(outputPath, rendered, "utf8"); + +console.log(`[sw] generated ${relative(ROOT, outputPath)} for build ${buildId}`); diff --git a/public/sw.js b/scripts/sw.template.js similarity index 57% rename from public/sw.js rename to scripts/sw.template.js index 1c865f8..665d308 100644 --- a/public/sw.js +++ b/scripts/sw.template.js @@ -1,11 +1,21 @@ -// StudyMap service worker. Caches the app shell and visited map tiles so the -// map still opens on exam day with a weak or absent signal. +// StudyMap service worker template. scripts/build-sw.mjs replaces the build ID +// token before Next.js packages public/sw.js for deployment. -const VERSION = "studymap-v1"; -const APP_CACHE = `app-${VERSION}`; -const TILE_CACHE = `tiles-${VERSION}`; +const VERSION = __STUDYMAP_BUILD_ID__; +const APP_CACHE = `studymap-app-${VERSION}`; +const TILE_CACHE = `studymap-tiles-${VERSION}`; const TILE_LIMIT = 300; -const PRECACHE = ["/", "/offline", "/manifest.webmanifest"]; +const PRECACHE = ["/", "/map", "/offline", "/manifest.webmanifest"]; +const STUDYMAP_CACHE_PREFIXES = ["studymap-app-", "studymap-tiles-"]; +const LEGACY_CACHE_NAMES = new Set(["app-studymap-v1", "tiles-studymap-v1"]); +const ACTIVE_CACHES = new Set([APP_CACHE, TILE_CACHE]); + +function isStudyMapCache(name) { + return ( + LEGACY_CACHE_NAMES.has(name) || + STUDYMAP_CACHE_PREFIXES.some((prefix) => name.startsWith(prefix)) + ); +} self.addEventListener("install", (event) => { event.waitUntil( @@ -23,9 +33,10 @@ self.addEventListener("activate", (event) => { event.waitUntil( (async () => { const keys = await caches.keys(); - await Promise.all( - keys.filter((key) => !key.endsWith(VERSION)).map((key) => caches.delete(key)), + const staleStudyMapCaches = keys.filter( + (key) => isStudyMapCache(key) && !ACTIVE_CACHES.has(key), ); + await Promise.all(staleStudyMapCaches.map((key) => caches.delete(key))); await self.clients.claim(); })(), ); @@ -34,7 +45,26 @@ self.addEventListener("activate", (event) => { async function trimCache(name, max) { const cache = await caches.open(name); const keys = await cache.keys(); - if (keys.length > max) await cache.delete(keys[0]); + const excess = keys.length - max; + if (excess <= 0) return; + await Promise.all(keys.slice(0, excess).map((key) => cache.delete(key))); +} + +// Cache writes are serialized so concurrent tile responses cannot race the +// limit check and leave more than TILE_LIMIT entries behind. +let tileMutationQueue = Promise.resolve(); + +function cacheTile(request, response) { + const update = tileMutationQueue.then(async () => { + const cache = await caches.open(TILE_CACHE); + await cache.put(request, response); + await trimCache(TILE_CACHE, TILE_LIMIT); + }); + + // Keep the queue usable even if one cache write fails. The caller still gets + // the original rejection so it can report the failed cache update. + tileMutationQueue = update.catch(() => {}); + return update; } self.addEventListener("fetch", (event) => { @@ -53,8 +83,11 @@ self.addEventListener("fetch", (event) => { try { const res = await fetch(request); if (res.ok) { - cache.put(request, res.clone()); - trimCache(TILE_CACHE, TILE_LIMIT); + try { + await cacheTile(request, res.clone()); + } catch (err) { + console.warn("[SW] Tile cache update failed:", err); + } } return res; } catch (err) { diff --git a/src/components/pwa-register.tsx b/src/components/pwa-register.tsx index 803860a..5dee4c7 100644 --- a/src/components/pwa-register.tsx +++ b/src/components/pwa-register.tsx @@ -7,7 +7,10 @@ export function PwaRegister() { useEffect(() => { if (process.env.NODE_ENV !== "production") return; if (!("serviceWorker" in navigator)) return; - navigator.serviceWorker.register("/sw.js").catch(() => {}); + + navigator.serviceWorker.register("/sw.js").catch((error) => { + console.error("[PWA] Service worker registration failed:", error); + }); }, []); return null; diff --git a/src/lib/service-worker.test.ts b/src/lib/service-worker.test.ts new file mode 100644 index 0000000..32ae0e9 --- /dev/null +++ b/src/lib/service-worker.test.ts @@ -0,0 +1,170 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { createContext, runInContext } from "node:vm"; +import { describe, expect, it, vi } from "vitest"; + +const BUILD_SCRIPT = resolve(process.cwd(), "scripts/build-sw.mjs"); + +function renderServiceWorker(buildId: string) { + const tempDir = mkdtempSync(join(tmpdir(), "studymap-sw-")); + const outputPath = join(tempDir, "sw.js"); + + try { + execFileSync( + process.execPath, + [BUILD_SCRIPT, `--build-id=${buildId}`, `--output=${outputPath}`], + { cwd: process.cwd(), stdio: "pipe" }, + ); + return readFileSync(outputPath, "utf8"); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +} + +const SOURCE = renderServiceWorker("build-123"); + +type WorkerEventHandler = (event: { waitUntil(promise: Promise): void }) => void; + +function loadServiceWorker({ + source = SOURCE, + href = "https://studymap.test/sw.js", + cacheNames = [] as string[], + entries = [] as string[], +} = {}) { + const handlers = new Map(); + const cacheEntries = [...entries]; + + const cache = { + addAll: vi.fn(async () => {}), + keys: vi.fn(async () => cacheEntries.map((url) => ({ url }))), + delete: vi.fn(async (request: { url: string }) => { + const index = cacheEntries.indexOf(request.url); + if (index === -1) return false; + cacheEntries.splice(index, 1); + return true; + }), + put: vi.fn(async (request: { url: string }) => { + if (!cacheEntries.includes(request.url)) cacheEntries.push(request.url); + }), + match: vi.fn(async () => undefined), + }; + + const caches = { + open: vi.fn(async () => cache), + keys: vi.fn(async () => cacheNames), + delete: vi.fn(async () => true), + }; + + const self = { + location: { href, origin: "https://studymap.test" }, + clients: { claim: vi.fn(async () => {}) }, + skipWaiting: vi.fn(), + addEventListener: vi.fn((name: string, handler: WorkerEventHandler) => { + handlers.set(name, handler); + }), + }; + + const context = createContext({ + self, + caches, + URL, + Response, + console, + fetch: vi.fn(), + }); + runInContext(source, context); + + return { cache, cacheEntries, caches, context, handlers, self }; +} + +async function runWaitUntil(handler: WorkerEventHandler | undefined) { + expect(handler).toBeDefined(); + let pending: Promise | undefined; + handler!({ + waitUntil(promise) { + pending = Promise.resolve(promise); + }, + }); + expect(pending).toBeDefined(); + await pending; +} + +describe("service worker cache lifecycle", () => { + it("bakes the build ID into the generated service-worker bytes", () => { + const firstBuild = renderServiceWorker("deploy-a"); + const secondBuild = renderServiceWorker("deploy-b"); + + expect(firstBuild).not.toBe(secondBuild); + expect(firstBuild).toContain('const VERSION = "deploy-a";'); + expect(secondBuild).toContain('const VERSION = "deploy-b";'); + expect(firstBuild).not.toContain("__STUDYMAP_BUILD_ID__"); + expect(firstBuild).not.toContain("searchParams"); + }); + + it("precaches the map route in the deployment-scoped StudyMap app cache", async () => { + const runtime = loadServiceWorker(); + + await runWaitUntil(runtime.handlers.get("install")); + + expect(runtime.caches.open).toHaveBeenCalledWith("studymap-app-build-123"); + expect(runtime.cache.addAll).toHaveBeenCalledWith([ + "/", + "/map", + "/offline", + "/manifest.webmanifest", + ]); + }); + + it("removes stale and legacy StudyMap caches without touching generic caches", async () => { + const runtime = loadServiceWorker({ + cacheNames: [ + "app-studymap-v1", + "tiles-studymap-v1", + "studymap-app-old-build", + "studymap-tiles-old-build", + "studymap-app-build-123", + "studymap-tiles-build-123", + "app-another-product", + "tiles-another-product", + "another-app-cache", + ], + }); + + await runWaitUntil(runtime.handlers.get("activate")); + + expect(runtime.caches.delete).toHaveBeenCalledTimes(4); + expect(runtime.caches.delete).toHaveBeenCalledWith("app-studymap-v1"); + expect(runtime.caches.delete).toHaveBeenCalledWith("tiles-studymap-v1"); + expect(runtime.caches.delete).toHaveBeenCalledWith("studymap-app-old-build"); + expect(runtime.caches.delete).toHaveBeenCalledWith("studymap-tiles-old-build"); + expect(runtime.caches.delete).not.toHaveBeenCalledWith("studymap-app-build-123"); + expect(runtime.caches.delete).not.toHaveBeenCalledWith("studymap-tiles-build-123"); + expect(runtime.caches.delete).not.toHaveBeenCalledWith("app-another-product"); + expect(runtime.caches.delete).not.toHaveBeenCalledWith("tiles-another-product"); + expect(runtime.caches.delete).not.toHaveBeenCalledWith("another-app-cache"); + }); + + it("keeps the tile cache at its limit across queued writes", async () => { + const initialEntries = Array.from({ length: 300 }, (_, index) => `tile-${index}`); + const runtime = loadServiceWorker({ entries: initialEntries }); + + Object.assign(runtime.context, { + requestA: { url: "tile-new-a" }, + requestB: { url: "tile-new-b" }, + responseA: {}, + responseB: {}, + }); + + const first = runInContext("cacheTile(requestA, responseA)", runtime.context); + const second = runInContext("cacheTile(requestB, responseB)", runtime.context); + await Promise.all([first, second]); + + expect(runtime.cacheEntries).toHaveLength(300); + expect(runtime.cacheEntries).toContain("tile-new-a"); + expect(runtime.cacheEntries).toContain("tile-new-b"); + expect(runtime.cache.delete).toHaveBeenCalledTimes(2); + expect(runtime.caches.open).toHaveBeenCalledWith("studymap-tiles-build-123"); + }); +});