UNPKG

next-pwa-pack

Version:

PWA cache provider for Next.js/React apps (service worker, manifest, offline page, SPA cache, offline)

209 lines (187 loc) 6.3 kB
const CACHE_NAME = "html-cache-v2"; const STATIC_ASSETS_CACHE = "static-assets-v2"; const CACHE_WHITELIST = [CACHE_NAME, STATIC_ASSETS_CACHE]; const TTL = 10 * 60 * 1000; // 10 minutes const OFFLINE_URL = "/offline.html"; let cacheDisabled = false; // Array of routes/patterns that are not cached const CACHE_EXCLUDE = []; // ["/api/", "/admin", "/some-dynamic-route"] function shouldCache(request) { return !CACHE_EXCLUDE.some((path) => request.url.includes(path)); } const MESSAGE_EVENT_TYPES = { CACHE_CURRENT_HTML: "CACHE_CURRENT_HTML", REVALIDATE_URL: "REVALIDATE_URL", DISABLE_CACHE: "DISABLE_CACHE", ENABLE_CACHE: "ENABLE_CACHE", CLEAR_STATIC_CACHE: "CLEAR_STATIC_CACHE", }; // Check: is it HTML const isHTML = (request) => { return request.headers.get("accept")?.includes("text/html"); }; // Installation — cache offline.html self.addEventListener("install", (event) => { event.waitUntil( caches.open(STATIC_ASSETS_CACHE).then((cache) => { return cache.addAll([OFFLINE_URL]); }) ); self.skipWaiting(); }); // Activation — take control immediately self.addEventListener("activate", (event) => { event.waitUntil( caches .keys() .then((cacheNames) => { return Promise.all( cacheNames .filter((cacheName) => !CACHE_WHITELIST.includes(cacheName)) .map((cacheName) => caches.delete(cacheName)) ); }) .then(() => self.clients.claim()) ); }); // Processing fetch requests self.addEventListener("fetch", (event) => { const { request } = event; if (request.method !== "GET") return; if (cacheDisabled || !shouldCache(request)) { return; } // HTML pages if (isHTML(request)) { event.respondWith( caches.open(CACHE_NAME).then(async (cache) => { const cached = await cache.match(request); const cachedTime = await cache.match(request.url + ":ts"); let age = 0; if (cached && cachedTime) { age = Date.now() - (await cachedTime.json()).ts; if (age < TTL) { // Cache is fresh — return it return cached; } } // Cache is stale or missing — try fetch try { const response = await fetch(request); cache.put(request, response.clone()); cache.put( request.url + ":ts", new Response(JSON.stringify({ ts: Date.now() })) ); return response; } catch (err) { // If there is any cache — return it! if (cached) return cached; // If there is no cache — offline.html const fallback = await caches.match(OFFLINE_URL); return fallback || new Response("Offline", { status: 503 }); } }) ); } // Static assets else { event.respondWith( caches.open(STATIC_ASSETS_CACHE).then((cache) => cache.match(request).then((cached) => { return ( cached || fetch(request) .then((response) => { if (response.status === 200) { cache.put(request, response.clone()); //the body of the response can be read only once, so we clone it } return response; }) .catch(() => undefined) ); }) ) ); } }); // Processing messages from the client (SPA navigation and invalidation) self.addEventListener("message", (event) => { const { type, url, ts, html } = event.data || {}; // Global enable/disable caching if (type === MESSAGE_EVENT_TYPES.DISABLE_CACHE) { cacheDisabled = true; } if (type === MESSAGE_EVENT_TYPES.ENABLE_CACHE) { cacheDisabled = false; } // Fast activation of a new service worker if (type === "SKIP_WAITING") { self.skipWaiting(); } // Invalidation by URL to update the cache manually if (type === MESSAGE_EVENT_TYPES.REVALIDATE_URL && url) { caches.open(CACHE_NAME).then(async (cache) => { try { const response = await fetch(url, { headers: { Accept: "text/html" } }); await cache.put(url, response.clone()); await cache.put( url + ":ts", new Response(JSON.stringify({ ts: Date.now() })) ); console.log("[SW] Revalidated and updated cache for:", url); } catch (err) { console.error("[SW] Failed to revalidate cache for:", url, err); } }); } // Clear static assets cache (for API data revalidation) if (type === MESSAGE_EVENT_TYPES.CLEAR_STATIC_CACHE) { console.log("[SW] Received CLEAR_STATIC_CACHE message"); caches .open(STATIC_ASSETS_CACHE) .then(async (cache) => { const keys = await cache.keys(); console.log( "[SW] Found", keys.length, "entries in static assets cache" ); await Promise.all(keys.map((key) => cache.delete(key))); console.log("[SW] Cleared static assets cache"); }) .catch((err) => { console.error("[SW] Error clearing static assets cache:", err); }); } // SPA: manually cache HTML, but only if TTL has expired or there is no cache // In SPA navigation does not require a full page reload // The service worker cannot automatically intercept HTML during SPA navigation // The client code must explicitly tell the service worker about the new page if (type === MESSAGE_EVENT_TYPES.CACHE_CURRENT_HTML && html && url) { if (cacheDisabled) { console.log("[SW] Skipping cache (cacheDisabled):", url); return; } caches.open(CACHE_NAME).then(async (cache) => { const existing = await cache.match(url); const existingTs = await cache.match(url + ":ts"); if (existing && existingTs) { const age = Date.now() - (await existingTs.json()).ts; if (age < TTL) { console.log("[SW] Skip caching, still fresh:", url); return; } } const response = new Response(html, { headers: { "Content-Type": "text/html" }, }); await cache.put(url, response.clone()); await cache.put( url + ":ts", new Response(JSON.stringify({ ts: ts || Date.now() })) ); console.log("[SW] Cached:", url); }); } });