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

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();
}