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:
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));
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user