/* * ApiCore service worker. A beekeeper must be able to open the app at the * apiary without any reception (cold start), open a QR/NFC deep link there, * and still see data loaded earlier. So it keeps: * - the app shell: the HTML of SHELL_ROUTES and their /_next/static assets, * warmed on install and on the WARM_SHELL message ("Für offline laden"), * - API answers of the configured backend (network first, the cache only * when the network fails) - except what the offline packs answer. * The routing rules live in sw-routes.js (unit-tested). * * What is never kept: auth answers (tokens, session state), apiaries * themselves (coordinates, address), Prüfbereitschaft and authority data, * medicine packs, single document records, Next.js route * payloads, and request * headers - entries are stored under their bare URL, so the Authorization * header with the access token never lands in Cache Storage. The cached * HTML holds no user data: the (app) layout renders nothing until the client * knows the user. */ // Content version of sw-routes.js (sha256 of its LF text, first 12 hex // characters; sw-routes.spec.ts checks it). A changed value changes this // file, so every browser - Safari too - installs the worker with new rules. const SW_ROUTES_VERSION = "17503174a2a9"; importScripts(`/sw-routes.js?v=${SW_ROUTES_VERSION}`); const { SHELL_ROUTES, offlineFallbackFor, offlineHomeFor, navigationPlanFor, extractAssetUrls, isCacheableApiUrl, isKeepableApiAnswer: isKeepableApiHeaders, apiOriginFrom, isRoutePayload, buildAssetsToPrune, } = self.ApiCoreSwRoutes; const SHELL_CACHE = "apicore-shell-v4"; // v2: v1 could hold answers this version never keeps (exports, downloads). const DATA_CACHE = "apicore-data-v2"; const KNOWN_CACHES = [SHELL_CACHE, DATA_CACHE]; // Same budget as every request of the app (lib/http/fetch-with-timeout.ts). const ASSET_TIMEOUT_MS = 4000; const RUNTIME_CONFIG_PATH = "/api/runtime-config"; const STATIC_PREFIX = "/_next/static/"; // Stored under the bare URL (no headers), so Vary cannot hide them later. const BY_URL = { ignoreVary: true }; const BY_PATH = { ignoreVary: true, ignoreSearch: true }; const TIMED_OUT = new Error("timeout"); // Last resort when not even the offline page is cached (the app was never // opened with network since this worker was installed). Never a browser // error page: that looks like the app is broken. const OFFLINE_NOTICE = `
ApiCore ist auf diesem Gerät noch nicht für offline bereit.
Öffne die App einmal mit Netz. Danach startet sie auch ohne Netz.
`; // The backend whose answers may be kept, from the runtime config. Read from // the cache at every worker start and after WIPE_CACHES, updated whenever // the config passes by. let apiOrigin = null; let apiOriginLoaded = readCachedApiOrigin(); // Raised by every WIPE_CACHES. A request that began under an older value // belongs to the user who has just signed out: its answer is never stored. let wipeGeneration = 0; self.addEventListener("install", (event) => { self.skipWaiting(); // A failed warm-up must not stop the worker: navigations refill the shell. event.waitUntil(warmShell().catch(() => undefined)); }); self.addEventListener("activate", (event) => { event.waitUntil( removeUnknownCaches() .then(pruneForbiddenEntries) .catch(() => undefined) .then(() => self.clients.claim()), ); }); self.addEventListener("message", (event) => { const data = event.data; const type = typeof data === "string" ? data : data && data.type; if (type === "WARM_SHELL") { event.waitUntil( warmShell() .catch(() => undefined) .then(() => reply(event, "WARM_SHELL_DONE")), ); } else if (type === "WIPE_CACHES") { event.waitUntil(wipeCaches().then(() => reply(event, "WIPE_CACHES_DONE"))); } }); self.addEventListener("fetch", (event) => { const { request } = event; if (request.method !== "GET") { return; } const url = new URL(request.url); if (url.protocol !== "https:" && url.protocol !== "http:") { return; } const isSameOrigin = url.origin === self.location.origin; if (request.mode === "navigate") { const plan = navigationPlanFor(url); if (isSameOrigin && plan.kind !== "browser") { event.respondWith(respondToNavigation(event, url, plan)); } return; } // Which backend to talk to: needed for a cold start, so kept, but always // asked live first - a stale value could point at a backend that is gone. if (isSameOrigin && url.pathname === RUNTIME_CONFIG_PATH) { event.respondWith(respondWithRuntimeConfig(event)); return; } if (isCacheableApiUrl(url)) { respondToApiCall(event, url); return; } // Auth and other uncached backend calls (also on a same-origin backend), // other hosts and route payloads: untouched. if (!isSameOrigin || url.pathname.startsWith("/api/")) { return; } if (isRoutePayload(url) || request.headers.get("RSC") === "1") { return; } // Build assets, icons, the manifest. On a weak signal the cached copy // answers after the timeout, so the app still starts. event.respondWith(networkFirst(event, SHELL_CACHE, ASSET_TIMEOUT_MS)); }); function respondToApiCall(event, url) { // Taken when the request arrives, not when its fetch starts: waiting for the // backend origin below may outlast a WIPE_CACHES. const generation = wipeGeneration; const keepApiAnswer = () => networkFirst(event, DATA_CACHE, null, isKeepableApiAnswer, generation); if (apiOrigin !== null) { if (url.origin === apiOrigin) { event.respondWith(keepApiAnswer()); } return; } // Worker just started (or caches wiped): the backend is known once the // cached config is read - without one, nothing is kept. event.respondWith( apiOriginLoaded.then((origin) => origin === url.origin && generation === wipeGeneration ? keepApiAnswer() : fetch(event.request), ), ); } function reply(event, type) { const port = event.ports && event.ports[0]; if (port) { port.postMessage({ type }); } } /** * Deletes every cache and forgets the backend until a config tells it again. * The first lines run before anything is awaited: from this moment on no * request, running or new, may write the signed-out user's data back. */ async function wipeCaches() { wipeGeneration += 1; apiOrigin = null; apiOriginLoaded = Promise.resolve(null); const keys = await caches.keys(); await Promise.all(keys.map((key) => caches.delete(key))); apiOriginLoaded = readCachedApiOrigin(); await apiOriginLoaded; } function removeUnknownCaches() { return caches .keys() .then((keys) => Promise.all(keys.filter((key) => !KNOWN_CACHES.includes(key)).map((key) => caches.delete(key))), ); } /** * Entries an earlier worker version kept that this one never would: apiary * rows (coordinates), pack-covered answers, route payloads. */ async function pruneForbiddenEntries() { const data = await caches.open(DATA_CACHE); for (const request of await data.keys()) { if (!isCacheableApiUrl(new URL(request.url))) { await data.delete(request); } } const shell = await caches.open(SHELL_CACHE); for (const request of await shell.keys()) { if (isRoutePayload(new URL(request.url))) { await shell.delete(request); } } } async function readCachedApiOrigin() { try { const cache = await caches.open(SHELL_CACHE); const response = await cache.match(RUNTIME_CONFIG_PATH, BY_URL); const origin = response ? apiOriginFrom(await response.json()) : null; if (apiOrigin === null) { apiOrigin = origin; } return apiOrigin; } catch { return null; } } function learnApiOrigin(response) { response .clone() .json() .then((config) => { const origin = apiOriginFrom(config); if (origin) { apiOrigin = origin; } }) .catch(() => undefined); } function respondWithRuntimeConfig(event) { return networkFirst(event, SHELL_CACHE, null).then((response) => { if (response.ok) { learnApiOrigin(response); } return response; }); } /** Rejects with TIMED_OUT after `ms`; the promise itself keeps running. `null` = no limit. */ function within(promise, ms) { if (ms === null) { return promise; } return new Promise((resolve, reject) => { const timer = setTimeout(() => reject(TIMED_OUT), ms); promise.then( (value) => { clearTimeout(timer); resolve(value); }, (error) => { clearTimeout(timer); reject(error); }, ); }); } /** * Stores a copy of a good answer under `key` once it arrives. Registered on * the network promise before anyone else reads the answer, so the copy is * taken before the page consumes the body; waitUntil keeps the worker alive * until it is written, even when the cache answered first. */ function storeWhenAnswered(event, network, cacheName, key, isWorthKeeping, generation) { const stored = network.then((response) => { if (!isWorthKeeping(response) || generation !== wipeGeneration) { return undefined; } const copy = response.clone(); return caches .open(cacheName) .then((cache) => (generation === wipeGeneration ? cache.put(key, copy) : undefined)); }); event.waitUntil(stored.catch(() => undefined)); } function isGoodAnswer(response) { return response.ok; } /** * Only JSON records are kept from the backend - never downloads, photos, * PDFs or CSVs (documents, avatars, exports). Content-Type is CORS-safelisted, * so it is readable on the backend's cross-origin answers. */ function isKeepableApiAnswer(response) { return isKeepableApiHeaders({ ok: response.ok, contentType: response.headers.get("content-type"), }); } function isGoodPage(response) { return response.ok && !response.redirected; } async function cachedByUrl(cacheName, key) { const cache = await caches.open(cacheName); return cache.match(key, BY_URL); } /** * The network's answer (and a copy for later); without network the cached * copy. After `timeoutMs` a cached copy answers too - without one, the * network still has its chance. */ function networkFirst( event, cacheName, timeoutMs, isWorthKeeping = isGoodAnswer, generation = wipeGeneration, ) { const key = event.request.url; const network = fetch(event.request); storeWhenAnswered(event, network, cacheName, key, isWorthKeeping, generation); return within(network, timeoutMs).catch(async () => { const cached = await cachedByUrl(cacheName, key); return cached || network; }); } /** The same URL from the shell cache, else the shell page of its path. */ async function cachedPage(url, fallback) { const cache = await caches.open(SHELL_CACHE); const samePage = await cache.match(url.href, BY_URL); if (samePage || fallback.kind !== "cached-path") { return samePage; } return cache.match(fallback.path, BY_PATH); } /** * Page navigations, planned per route (sw-routes.js): the offline pages * from the cache at once; shell pages 4 s, deep links 10 s, other pages 15 s * network first. Without network: the cached page, else the offline view of * a deep-linked record or the offline page - both with the way back. */ async function respondToNavigation(event, url, plan) { const fallback = offlineFallbackFor(url); if (plan.kind === "cache-first") { const cached = await cachedPage(url, fallback); if (cached) { refreshPageInBackground(event, fallback); return cached; } } const network = fetch(event.request); if (fallback.kind === "cached-path") { storePageWhenAnswered(event, network, fallback.path); } const timeoutMs = plan.kind === "network-first" ? plan.timeoutMs : null; try { return await within(network, timeoutMs); } catch (error) { const cached = await cachedPage(url, fallback); if (cached) { return cached; } if (error === TIMED_OUT && plan.kind === "network-first" && !plan.redirectOnTimeout) { return network.catch(() => offlineRedirect(url, fallback)); } return offlineRedirect(url, fallback); } } /** * Keeps a cache-first page current for the next visit - through the warm-up * path, so a new build's page is only kept together with its assets (an * offline view must never be a page whose scripts are missing). */ function refreshPageInBackground(event, fallback) { if (fallback.kind !== "cached-path") { return; } event.waitUntil( caches .open(SHELL_CACHE) .then((cache) => keepPage(cache, fallback.path, new Map())) .catch(() => undefined), ); } /** * A shell page from a live navigation is kept the same way: its assets * first, the HTML only once they are all cached. */ function storePageWhenAnswered(event, network, path) { // The copy is taken before the page consumes the body. const copied = network.then((response) => (isGoodPage(response) ? response.clone() : null)); event.waitUntil( copied .then(async (copy) => { if (copy) { const cache = await caches.open(SHELL_CACHE); await keepPageAnswer(cache, path, copy, new Map()); } }) .catch(() => undefined), ); } /** Redirects only to a page the cache can serve, so there is no loop. */ async function offlineRedirect(url, fallback) { const cache = await caches.open(SHELL_CACHE); const candidates = fallback.kind === "redirect" ? [fallback.to] : []; candidates.push(offlineHomeFor(url)); for (const candidate of candidates) { const target = new URL(candidate, self.location.origin); if (target.pathname !== url.pathname && (await cache.match(target.pathname, BY_PATH))) { return Response.redirect(target.href, 302); } } return new Response(OFFLINE_NOTICE, { status: 503, headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" }, }); } /** Fetches and keeps the shell pages, their build assets and the runtime config. */ async function warmShell() { const cache = await caches.open(SHELL_CACHE); // One download per asset, however many pages share it. const assetJobs = new Map(); const pages = await Promise.allSettled( SHELL_ROUTES.map((route) => keepPage(cache, route, assetJobs)), ); const assets = new Set(); let allPagesKept = true; for (const page of pages) { if (page.status === "fulfilled" && page.value !== null) { page.value.forEach((asset) => assets.add(asset)); } else { allPagesKept = false; } } await keepRuntimeConfig(cache).catch(() => undefined); // Only with the full picture of the current build may old assets go. if (allPagesKept) { await pruneOldBuildAssets(cache, [...assets]); } } /** Fetches and keeps a shell page with its assets; its asset list, or null when not kept. */ async function keepPage(cache, route, assetJobs) { const response = await fetch(route, { cache: "no-cache" }); return isGoodPage(response) ? keepPageAnswer(cache, route, response, assetJobs) : null; } /** * Keeps the page's build assets first and the HTML only once all of them * are cached - otherwise the old page (with its own assets) stays. Rejects * when an asset cannot be fetched. */ async function keepPageAnswer(cache, route, response, assetJobs) { const html = await response.clone().text(); const assets = extractAssetUrls(html); await Promise.all(assets.map((asset) => keepAssetOnce(cache, asset, assetJobs))); await cache.put(route, response); return assets; } function keepAssetOnce(cache, asset, assetJobs) { if (!assetJobs.has(asset)) { assetJobs.set(asset, keepAsset(cache, asset)); } return assetJobs.get(asset); } /** Build assets are content-hashed: one already kept never changes. */ async function keepAsset(cache, asset) { if (await cache.match(asset, BY_URL)) { return; } const response = await fetch(asset, { cache: "no-cache" }); if (!isGoodAnswer(response)) { throw new Error("Asset not available: " + asset); } await cache.put(asset, response); } async function keepRuntimeConfig(cache) { const response = await fetch(RUNTIME_CONFIG_PATH, { cache: "no-cache" }); if (isGoodAnswer(response)) { learnApiOrigin(response); await cache.put(RUNTIME_CONFIG_PATH, response); } } async function pruneOldBuildAssets(cache, currentAssets) { const entries = []; for (const request of await cache.keys()) { if (!new URL(request.url).pathname.startsWith(STATIC_PREFIX)) { continue; } const response = await cache.match(request, BY_URL); const date = response ? Date.parse(response.headers.get("date") || "") : Number.NaN; entries.push({ url: request.url, date: Number.isNaN(date) ? null : date }); } const prune = buildAssetsToPrune(entries, currentAssets, Date.now()); await Promise.all(prune.map((url) => cache.delete(url, BY_URL))); }