Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@

# production
/build
/public/sw.js

# misc
.DS_Store
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"private": true,
"scripts": {
"dev": "next dev",
"prebuild": "node scripts/build-sw.mjs",
"build": "next build",
"start": "next start",
"lint": "eslint",
Expand Down
49 changes: 49 additions & 0 deletions scripts/build-sw.mjs
Original file line number Diff line number Diff line change
@@ -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}`);
55 changes: 44 additions & 11 deletions public/sw.js → scripts/sw.template.js
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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();
})(),
);
Expand All @@ -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) => {
Expand All @@ -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) {
Expand Down
5 changes: 4 additions & 1 deletion src/components/pwa-register.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
170 changes: 170 additions & 0 deletions src/lib/service-worker.test.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>): void }) => void;

function loadServiceWorker({
source = SOURCE,
href = "https://studymap.test/sw.js",
cacheNames = [] as string[],
entries = [] as string[],
} = {}) {
const handlers = new Map<string, WorkerEventHandler>();
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<unknown> | 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");
});
});
Loading