diff --git a/docs/architecture.md b/docs/architecture.md index db3dcdb..1575539 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -55,7 +55,8 @@ Manifest znajduje się w `public/manifest.json`, a service worker w `public/sw.j Service worker: - cache'uje powłokę aplikacji, -- obsługuje fallback `/offline` dla nawigacji, +- obsługuje fallback `/offline` dla nawigacji i zapisuje udane nawigacje do cache, +- cache'uje statyczne assety Next.js, style, skrypty, fonty, obrazy, manifest i ikony, - cache'uje wybrane odpowiedzi API: `/api/imgw/*`, `/api/imgw-current`, `/api/current-weather`, `/api/forecast`, - obsługuje zdarzenia `push` i kliknięcia w powiadomienia. diff --git a/public/sw.js b/public/sw.js index e37c73b..11be1d1 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,4 +1,10 @@ -const CACHE_NAME = "wtr-shell-v5"; +const CACHE_VERSION = "v6"; +const CACHE_PREFIX = "wtr-"; +const SHELL_CACHE = `${CACHE_PREFIX}shell-${CACHE_VERSION}`; +const RUNTIME_CACHE = `${CACHE_PREFIX}runtime-${CACHE_VERSION}`; +const API_CACHE = `${CACHE_PREFIX}api-${CACHE_VERSION}`; +const ACTIVE_CACHES = new Set([SHELL_CACHE, RUNTIME_CACHE, API_CACHE]); + const SHELL = [ "/", "/settings", @@ -11,8 +17,75 @@ const SHELL = [ "/icons/maskable-512.png", ]; +function isCacheableResponse(response) { + return response && response.ok && response.type !== "opaque"; +} + +async function putInCache(cacheName, request, response) { + if (!isCacheableResponse(response)) return; + const cache = await caches.open(cacheName); + await cache.put(request, response.clone()); +} + +function isWeatherApiRequest(url) { + return ( + url.pathname.startsWith("/api/imgw/") || + url.pathname === "/api/imgw-current" || + url.pathname === "/api/current-weather" || + url.pathname === "/api/forecast" + ); +} + +function isStaticAssetRequest(request, url) { + return ( + url.origin === self.location.origin && + (url.pathname.startsWith("/_next/static/") || + url.pathname.startsWith("/icons/") || + url.pathname === "/manifest.json" || + ["font", "image", "script", "style", "worker"].includes(request.destination)) + ); +} + +async function cacheFirst(request) { + const cached = await caches.match(request); + if (cached) return cached; + const response = await fetch(request); + await putInCache(RUNTIME_CACHE, request, response); + return response; +} + +async function networkFirst(request, cacheName) { + try { + const response = await fetch(request); + await putInCache(cacheName, request, response); + return response; + } catch { + return ( + (await caches.match(request)) || + new Response(JSON.stringify({ error: "Offline." }), { + status: 503, + headers: { "Content-Type": "application/json" }, + }) + ); + } +} + +async function handleNavigation(request) { + try { + const response = await fetch(request); + await putInCache(SHELL_CACHE, request, response); + return response; + } catch { + return (await caches.match(request)) || (await caches.match("/")) || (await caches.match("/offline")); + } +} + self.addEventListener("install", (event) => { - event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL))); + event.waitUntil( + caches + .open(SHELL_CACHE) + .then((cache) => Promise.allSettled(SHELL.map((url) => cache.add(new Request(url, { cache: "reload" }))))), + ); self.skipWaiting(); }); @@ -20,44 +93,42 @@ self.addEventListener("activate", (event) => { event.waitUntil( caches .keys() - .then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)))), + .then((keys) => + Promise.all( + keys + .filter((key) => key.startsWith(CACHE_PREFIX) && !ACTIVE_CACHES.has(key)) + .map((key) => caches.delete(key)), + ), + ), ); self.clients.claim(); }); self.addEventListener("fetch", (event) => { if (event.request.method !== "GET") return; + const url = new URL(event.request.url); - if ( - url.pathname.startsWith("/api/imgw/") || - url.pathname === "/api/imgw-current" || - url.pathname === "/api/current-weather" || - url.pathname === "/api/forecast" - ) { - event.respondWith( - fetch(event.request) - .then((response) => { - const copy = response.clone(); - caches.open(CACHE_NAME).then((cache) => cache.put(event.request, copy)); - return response; - }) - .catch(() => caches.match(event.request)), - ); + if (url.origin !== self.location.origin) return; + + if (isWeatherApiRequest(url)) { + event.respondWith(networkFirst(event.request, API_CACHE)); return; } + if (event.request.mode === "navigate") { - event.respondWith( - fetch(event.request).catch(() => - caches.match(event.request).then((response) => response || caches.match("/offline")), - ), - ); + event.respondWith(handleNavigation(event.request)); + return; + } + + if (isStaticAssetRequest(event.request, url)) { + event.respondWith(cacheFirst(event.request)); } }); self.addEventListener("push", (event) => { const fallbackPayload = { title: "IMGW", - body: "Nowe ostrzeżenie meteorologiczne.", + body: "Nowe ostrze\u017cenie meteorologiczne.", url: "/warnings", }; let payload = fallbackPayload;