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:
26
src/pwa/assets.ts
Normal file
26
src/pwa/assets.ts
Normal 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
90
src/pwa/client.ts
Normal 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
9
src/pwa/manifest.ts
Normal 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 }[];
|
||||
};
|
||||
38
src/pwa/manifest.webmanifest
Normal file
38
src/pwa/manifest.webmanifest
Normal 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
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
BIN
src/pwa/public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
BIN
src/pwa/public/icon-maskable-512.png
Normal file
BIN
src/pwa/public/icon-maskable-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
66
src/pwa/sw.js
Normal file
66
src/pwa/sw.js
Normal 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
99
src/pwa/sw.test.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user