Add service worker implementation and tests for PWA caching strategy

- Implemented a service worker (sw.js) that handles installation, activation, and fetch events.
- Added caching logic for public assets and a fallback for offline navigation.
- Created unit tests (sw.test.ts) to validate service worker behavior, including asset caching, navigation handling, and activation logic.
- Ensured that private API routes and third-party requests are excluded from caching.
This commit is contained in:
syntaxbullet
2026-09-08 17:19:12 +02:00
parent e081e9acbf
commit ccd998ab04
29 changed files with 795 additions and 13 deletions

26
src/pwa/assets.ts Normal file
View File

@@ -0,0 +1,26 @@
import path from "node:path";
import { manifest } from "./manifest";
/** Serve only public PWA files. Never expose the server bundle or source maps. */
export function pwaAsset(request: Request): Response {
const pathname = new URL(request.url).pathname;
const headers = { "Cache-Control": "no-cache", "X-Content-Type-Options": "nosniff" };
if (!["GET", "HEAD"].includes(request.method)) return new Response(null, { status: 405, headers: { ...headers, Allow: "GET, HEAD" } });
if (pathname === "/manifest.webmanifest") {
return new Response(request.method === "HEAD" ? null : JSON.stringify(manifest), { headers: { ...headers, "Content-Type": "application/manifest+json" } });
}
const production = process.env.NODE_ENV === "production";
const filename = pathname.slice("/pwa/".length);
const icon = /^icon-(192|512|maskable-512)\.png$/.test(filename);
const shell = production && /^shell-[a-f0-9]{16}\.html$/.test(filename);
if (pathname !== "/sw.js" && !icon && !shell) return new Response(null, { status: 404, headers });
if (pathname === "/sw.js" && !production) return new Response(null, { status: 404, headers });
const file = Bun.file(path.resolve(production ? "pwa" : "src/pwa/public", pathname === "/sw.js" ? "sw.js" : filename));
if (!file.size) return new Response(null, { status: 404, headers });
return new Response(request.method === "HEAD" ? null : file, { headers: {
...headers,
"Content-Type": icon ? "image/png" : shell ? "text/html; charset=utf-8" : "text/javascript; charset=utf-8",
...(pathname === "/sw.js" ? { "Service-Worker-Allowed": "/" } : {}),
...(shell ? { "Cache-Control": "public, max-age=31536000, immutable" } : {}),
} });
}

90
src/pwa/client.ts Normal file
View File

@@ -0,0 +1,90 @@
import { useSyncExternalStore } from "react";
type InstallPrompt = Event & {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
};
type PwaState = { enabled: boolean; installed: boolean; installPrompt: InstallPrompt | null; updateReady: boolean; error: string };
let state: PwaState = { enabled: false, installed: false, installPrompt: null, updateReady: false, error: "" };
const listeners = new Set<() => void>();
let registration: ServiceWorkerRegistration | undefined;
let reloading = false;
let started = false;
const publish = (change: Partial<PwaState>) => {
state = { ...state, ...change };
listeners.forEach(listener => listener());
};
const subscribe = (listener: () => void) => { listeners.add(listener); return () => { listeners.delete(listener); }; };
export const usePwa = () => useSyncExternalStore(subscribe, () => state, () => state);
const subscribeOnline = (listener: () => void) => {
window.addEventListener("online", listener);
window.addEventListener("offline", listener);
return () => { window.removeEventListener("online", listener); window.removeEventListener("offline", listener); };
};
export const useOnline = () => useSyncExternalStore(subscribeOnline, () => navigator.onLine !== false, () => true);
/** Called once before React renders so early install events cannot be missed. */
export function startPwa() {
if (started || !window.isSecureContext || !("serviceWorker" in navigator)) return;
started = true;
const standalone = window.matchMedia("(display-mode: standalone)");
const installed = () => standalone.matches || (navigator as Navigator & { standalone?: boolean }).standalone === true;
publish({ enabled: true, installed: installed() });
standalone.addEventListener("change", () => publish({ installed: installed() }));
window.addEventListener("beforeinstallprompt", event => {
event.preventDefault();
publish({ installPrompt: event as InstallPrompt });
});
window.addEventListener("appinstalled", () => publish({ installed: true, installPrompt: null }));
let controller = navigator.serviceWorker.controller;
navigator.serviceWorker.addEventListener("controllerchange", () => {
if (reloading) window.location.reload();
else if (controller) publish({ updateReady: true });
controller = navigator.serviceWorker.controller;
});
const register = async () => {
try {
registration = await navigator.serviceWorker.register("/sw.js", { scope: "/", updateViaCache: "none" });
publish({ updateReady: Boolean(registration.waiting) });
const observeInstall = () => {
const worker = registration?.installing;
worker?.addEventListener("statechange", () => {
if (worker.state === "installed") publish({ updateReady: Boolean(navigator.serviceWorker.controller), error: "" });
if (worker.state === "redundant" && !navigator.serviceWorker.controller)
publish({ error: "Offline setup didn’t finish. Reconnect and reload to try again." });
});
};
registration.addEventListener("updatefound", observeInstall);
observeInstall();
const check = () => {
if (navigator.onLine && document.visibilityState === "visible") void registration?.update().catch(() => {});
};
document.addEventListener("visibilitychange", check);
window.addEventListener("online", check);
window.setInterval(check, 60 * 60 * 1000);
} catch {
publish({ error: "Offline setup is unavailable. Reconnect and reload to try again." });
}
};
if (document.readyState === "complete") void register();
else window.addEventListener("load", () => { void register(); }, { once: true });
}
export async function installApp(): Promise<boolean> {
const prompt = state.installPrompt;
if (!prompt) return false;
publish({ installPrompt: null });
try {
await prompt.prompt();
await prompt.userChoice;
} catch { publish({ error: "Could not open installation. Try your browser’s install menu." }); }
return true;
}
export function applyUpdate() {
if (!state.updateReady) return;
if (registration?.waiting) {
reloading = true;
registration.waiting.postMessage({ type: "ACTIVATE_UPDATE" });
} else window.location.reload();
}

9
src/pwa/manifest.ts Normal file
View File

@@ -0,0 +1,9 @@
import source from "./manifest.webmanifest" with { type: "text" };
export const manifest = JSON.parse(source) as {
id: string; name: string; short_name: string; description: string;
lang: string; dir: string; start_url: string; scope: string; display: string;
background_color: string; theme_color: string; categories: string[];
prefer_related_applications: boolean;
icons: { src: string; sizes: string; type: string; purpose: string }[];
};

View File

@@ -0,0 +1,38 @@
{
"id": "/",
"name": "Mina Habits",
"short_name": "Mina Habits",
"description": "A quiet place to track your habits and see your progress.",
"lang": "en",
"dir": "ltr",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#ffffff",
"categories": [
"productivity",
"lifestyle"
],
"prefer_related_applications": false,
"icons": [
{
"src": "/pwa/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/pwa/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/pwa/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}

BIN
src/pwa/public/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
src/pwa/public/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

66
src/pwa/sw.js Normal file
View File

@@ -0,0 +1,66 @@
/* Build injects a content-derived version and an explicit public-asset allowlist. */
const CONFIG = JSON.parse("__PWA_CONFIG__");
const PREFIX = "mina-habits-static-";
const CACHE = PREFIX + CONFIG.version;
const ASSETS = new Set(CONFIG.assets);
self.addEventListener("install", event => {
event.waitUntil((async () => {
const cache = await caches.open(CACHE);
// addAll is atomic: a failed download cannot activate a partial release.
await cache.addAll(CONFIG.assets.map(url => new Request(url, { cache: "reload", credentials: "omit" })));
})());
});
self.addEventListener("activate", event => {
event.waitUntil((async () => {
// A user can accept an update while other windows still hold older code.
// Preserve their lazy chunks until a later activation with no open windows.
const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
if (windows.length === 0) {
await Promise.all((await caches.keys()).filter(key => key.startsWith(PREFIX) && key !== CACHE).map(key => caches.delete(key)));
}
await self.clients.claim();
})());
});
self.addEventListener("message", event => {
if (event.data?.type === "ACTIVATE_UPDATE") event.waitUntil(self.skipWaiting());
});
async function cachedAsset(request) {
const current = await caches.open(CACHE);
const cached = await current.match(request);
if (cached) return cached;
// Old windows can still request fingerprinted chunks after an accepted update.
for (const key of await caches.keys()) {
if (key.startsWith(PREFIX) && key !== CACHE) {
const response = await (await caches.open(key)).match(request);
if (response) return response;
}
}
return fetch(request);
}
async function navigate(request) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 4000);
try {
const response = await fetch(request, { signal: controller.signal });
if (response.ok) return response;
} catch { /* Unreachable server or offline: use the public shell. */ }
finally { clearTimeout(timeout); }
return (await (await caches.open(CACHE)).match(CONFIG.shell)) || Response.error();
}
self.addEventListener("fetch", event => {
const request = event.request;
const url = new URL(request.url);
// In particular: no account data, OAuth callbacks, exports, writes or third-party responses.
if (request.method !== "GET" || url.origin !== self.location.origin || /^\/api(?:\/|$)/.test(url.pathname)) return;
if (request.mode === "navigate") {
event.respondWith(navigate(request));
} else if (ASSETS.has(url.pathname) || /^\/chunk-[\w-]+\.js$/.test(url.pathname)) {
event.respondWith(cachedAsset(request));
}
});

99
src/pwa/sw.test.ts Normal file
View File

@@ -0,0 +1,99 @@
import { expect, test } from "bun:test";
import { runInNewContext } from "node:vm";
const template = await Bun.file(new URL("./sw.js", import.meta.url)).text();
const origin = "https://mina.example";
const cacheName = "mina-habits-static-test";
function worker({ offline = false, windows = 0, failInstall = false } = {}) {
const events = new Map<string, (event: any) => void>();
const stores = new Map<string, Map<string, Response>>([
[cacheName, new Map([[`${origin}/pwa/shell-test.html`, new Response("offline shell")]])],
["mina-habits-static-old", new Map([[`${origin}/chunk-old.js`, new Response("old chunk")]])],
["unrelated-cache", new Map()],
]);
const deleted: string[] = [];
const fetched: string[] = [];
let skipped = false;
let claimed = false;
let installed: Request[] = [];
const requestOptions: RequestInit[] = [];
const url = (input: Request | string) => new URL(typeof input === "string" ? input : input.url, origin).href;
const config = { version: "test", shell: "/pwa/shell-test.html", assets: ["/chunk-new.js", "/pwa/shell-test.html"] };
runInNewContext(template.replace('"__PWA_CONFIG__"', JSON.stringify(JSON.stringify(config))), {
self: {
location: { origin }, addEventListener: (type: string, handler: (event: any) => void) => events.set(type, handler),
skipWaiting: async () => { skipped = true; },
clients: { matchAll: async () => Array.from({ length: windows }, () => ({})), claim: async () => { claimed = true; } },
},
caches: {
open: async (name: string) => ({
match: async (request: Request | string) => stores.get(name)?.get(url(request))?.clone(),
addAll: async (requests: Request[]) => { if (failInstall) throw new Error("download failed"); installed = requests; },
}),
keys: async () => [...stores.keys()],
delete: async (name: string) => { deleted.push(name); return stores.delete(name); },
},
fetch: async (request: Request) => { fetched.push(request.url); if (offline) throw new TypeError("offline"); return new Response("network"); },
Request: class extends Request { constructor(input: string, init?: RequestInit) { super(url(input), init); requestOptions.push(init ?? {}); } },
Response, Set, URL, AbortController, setTimeout, clearTimeout,
});
async function lifecycle(type: string, data?: unknown) {
let promise: Promise<unknown> | undefined;
events.get(type)!({ data, waitUntil: (value: Promise<unknown>) => { promise = value; } });
await promise;
}
function request(pathname: string, method = "GET", mode = "cors") {
let response: Promise<Response> | undefined;
events.get("fetch")!({ request: { url: new URL(pathname, origin).href, method, mode }, respondWith: (value: Promise<Response>) => { response = value; } });
return response;
}
return { request, lifecycle, deleted, fetched, requestOptions, get skipped() { return skipped; }, get claimed() { return claimed; }, get installed() { return installed; } };
}
test("service worker excludes private API and OAuth navigations, writes and other origins", () => {
const sw = worker();
for (const route of ["/api", "/api/me", "/api/account/export", "/api/auth/discord/callback?code=private"]) {
expect(sw.request(route, "GET", "navigate")).toBeUndefined();
}
expect(sw.request("/chunk-new.js", "POST")).toBeUndefined();
expect(sw.request("https://discord.com/api/me")).toBeUndefined();
expect(sw.request("/private.json")).toBeUndefined();
expect(sw.request("/index.js")).toBeUndefined();
expect(sw.request("/chunk-new.js.map")).toBeUndefined();
});
test("network navigation is fresh while offline navigation uses only the public shell", async () => {
expect(await (await worker().request("/", "GET", "navigate"))!.text()).toBe("network");
const sw = worker({ offline: true });
expect(await (await sw.request("/a/deep/link", "GET", "navigate"))!.text()).toBe("offline shell");
});
test("older windows can load their cached lazy chunks after an update", async () => {
const sw = worker({ offline: true });
expect(await (await sw.request("/chunk-old.js"))!.text()).toBe("old chunk");
expect(sw.fetched).toEqual([]);
});
test("install excludes credentials and waits for every public asset without forcing activation", async () => {
const sw = worker();
await sw.lifecycle("install");
expect(sw.installed).toHaveLength(2);
expect(sw.requestOptions.every(options => options.credentials === "omit" && options.cache === "reload")).toBe(true);
expect(sw.skipped).toBe(false);
await expect(worker({ failInstall: true }).lifecycle("install")).rejects.toThrow("download failed");
});
test("activation preserves old windows and cleans only its own obsolete caches when windows close", async () => {
const occupied = worker({ windows: 2 });
await occupied.lifecycle("activate");
expect(occupied.deleted).toEqual([]);
expect(occupied.claimed).toBe(true);
const empty = worker();
await empty.lifecycle("activate");
expect(empty.deleted).toEqual(["mina-habits-static-old"]);
await empty.lifecycle("message", { type: "unrelated" });
expect(empty.skipped).toBe(false);
await empty.lifecycle("message", { type: "ACTIVATE_UPDATE" });
expect(empty.skipped).toBe(true);
});