feat: platform backbone and field capture complete (checkpoint)

Platform backbone (plan 2026-10-13-plattform-rueckgrat), all tasks reviewed:
- webhooks with delivery table, retries and stable delivery id
- calendar handlers; a finished task removes its Outlook event
- tasks, automations, inspections, varroa, treatments, feedings,
  harvests, colonies, wintering, stock and bottling record domain events
  in the same transaction; legacy post-commit calls removed
- row locks through one helper (lockRowForUpdate, lockRowsForShare,
  lockRowsForUpdate)
- job framework with JobRun, cli exit codes, cleanup, task templates,
  daily evaluation, hourly app-backup and the rekey job
- system API and page "Verwaltung > System" with retry
- @nestjs/schedule removed, guard tests against in-process schedulers

Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), all tasks reviewed:
- inspection photo offline without metadata
- bulk capture for treatments, feedings and varroa (backend + frontend)
- offline stand mode with service worker, offline pages and packs
- offline session, cold start and logout on shared devices

Docs: ARCHITECTURE.md assumptions and sections updated, TASKS.md.
Gates: backend lint, typecheck, 1770 unit, 420 e2e, build, no schema
drift; frontend lint, typecheck, 913 tests, build. Not rolled out.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 11:36:38 +02:00
1 parent c01a38835a
commit cdd30cf3ee
288 files changed
+39165 -2853

No files matched your search

+250
View File
@@ -0,0 +1,250 @@
/*
* Routing rules of the service worker (public/sw.js), kept free of the Cache
* and Fetch APIs so they can be unit-tested (src/lib/offline/sw-routes.spec.ts).
* A plain script on purpose: the worker loads it with importScripts and gets
* `self.ApiCoreSwRoutes`, Vitest loads it with require. No syntax newer than
* the oldest phone browser that runs the worker (no lookbehind, no `?.`).
*
* Changing this file changes its content version: update SW_ROUTES_VERSION
* in sw.js (a test says which value), or phones keep the old rules.
*/
(function (root) {
"use strict";
/** Pages whose HTML is kept for a cold start without network. */
var SHELL_ROUTES = [
"/",
"/login",
"/dashboard",
"/offline",
"/offline/apiary",
"/offline/colony",
"/inspections/new",
"/varroa/new",
"/treatments/new",
"/feedings/new",
"/swarm-control/new",
];
var OFFLINE_PAGE = "/offline";
/** Read only this device's storage: answered from the cache at once. */
var OFFLINE_PAGES = ["/offline", "/offline/apiary", "/offline/colony"];
var SHELL_TIMEOUT_MS = 4000;
var DEEP_LINK_TIMEOUT_MS = 10000;
var OTHER_PAGE_TIMEOUT_MS = 15000;
/** Build assets of old builds stay this long after they were last fetched. */
var BUILD_ASSET_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
// Deep links (QR codes, NFC tags) open the offline view of the same record.
// Static pages under the same path are not ids.
var DETAIL_ROUTES = [
{ pattern: /^\/colonies\/([^/]+)$/, offlinePage: "/offline/colony", notIds: ["new"] },
{ pattern: /^\/apiaries\/([^/]+)$/, offlinePage: "/offline/apiary", notIds: ["new", "map"] },
];
// Every backend call goes through /api/v1/. The backend matches paths
// case-insensitively, so these rules do too.
var API_PATH = /\/api\/v1\//;
// Never kept, each for its own reason:
var NEVER_CACHED_API_PATHS = [
// tokens and session state
/\/api\/v1\/auth(\/|$)/i,
// apiaries themselves: exact coordinates and the address (theft risk)
/\/api\/v1\/apiaries\/?$/i,
/\/api\/v1\/apiaries\/[^/]+\/?$/i,
// exports and backups: full records incl. coordinates and addresses,
// names (Bestandsbuch), megabytes
/\/api\/v1\/export\//i,
/\/api\/v1\/backup(\/|$)/i,
// what the offline packs answer: without network they must fail fast,
// so the pack - with its age shown - answers instead of an old copy
/\/api\/v1\/colonies\/?$/i,
/\/api\/v1\/colonies\/[^/]+\/inspections\/?$/i,
/\/api\/v1\/apiaries\/[^/]+\/(colony-overview|offline-pack)\/?$/i,
];
// A root-relative build asset, in an attribute or in the inline RSC data
// (where quotes are escaped). The leading character keeps absolute URLs of
// other hosts out.
var NEXT_STATIC_ASSET = /(^|["'(\s,=])(\/_next\/static\/[^"'\s<>()\\]+)/g;
var NEXT_STATIC_PREFIX = "/_next/static/";
function normalizePath(pathname) {
return pathname.length > 1 && pathname.charAt(pathname.length - 1) === "/"
? pathname.slice(0, -1)
: pathname;
}
function isShellRoute(pathname) {
return SHELL_ROUTES.indexOf(normalizePath(pathname)) !== -1;
}
function decodeSegment(segment) {
try {
return decodeURIComponent(segment);
} catch {
return null;
}
}
/** "from" parameter: where the user wanted to go, for the way back. */
function fromParam(url) {
return "from=" + encodeURIComponent(url.pathname + url.search);
}
function offlineHomeFor(url) {
return OFFLINE_PAGE + "?" + fromParam(url);
}
function detailRouteOf(pathname) {
for (var index = 0; index < DETAIL_ROUTES.length; index += 1) {
var route = DETAIL_ROUTES[index];
var match = route.pattern.exec(pathname);
if (!match) {
continue;
}
var id = decodeSegment(match[1]);
if (id === null || route.notIds.indexOf(id) !== -1) {
return null;
}
return { offlinePage: route.offlinePage, id: id };
}
return null;
}
/**
* What a page navigation gets when neither the network nor the cache has
* that exact URL: a shell page from the cache (its query stays in the
* address bar, the page reads it), the offline view of a record, or the
* offline start page. Redirects carry `from` for the way back.
*/
function offlineFallbackFor(url) {
var pathname = normalizePath(url.pathname);
if (isShellRoute(pathname)) {
return { kind: "cached-path", path: pathname };
}
var detail = detailRouteOf(pathname);
if (detail) {
return {
kind: "redirect",
to: detail.offlinePage + "?id=" + encodeURIComponent(detail.id) + "&" + fromParam(url),
};
}
return { kind: "redirect", to: offlineHomeFor(url) };
}
/**
* How long a page navigation waits for the network. On a timeout the
* cached copy answers; without one, `redirectOnTimeout` decides between the
* offline fallback and waiting on (a shell page has no better fallback).
*/
function navigationPlanFor(url) {
var pathname = normalizePath(url.pathname);
if (pathname === "/api" || pathname.indexOf("/api/") === 0) {
return { kind: "browser" };
}
if (OFFLINE_PAGES.indexOf(pathname) !== -1) {
return { kind: "cache-first" };
}
if (isShellRoute(pathname)) {
return { kind: "network-first", timeoutMs: SHELL_TIMEOUT_MS, redirectOnTimeout: false };
}
if (detailRouteOf(pathname)) {
return { kind: "network-first", timeoutMs: DEEP_LINK_TIMEOUT_MS, redirectOnTimeout: true };
}
return { kind: "network-first", timeoutMs: OTHER_PAGE_TIMEOUT_MS, redirectOnTimeout: true };
}
function isCacheableApiPath(pathname) {
if (!API_PATH.test(pathname)) {
return false;
}
for (var index = 0; index < NEVER_CACHED_API_PATHS.length; index += 1) {
if (NEVER_CACHED_API_PATHS[index].test(pathname)) {
return false;
}
}
return true;
}
/** Only answers of the backend named by the runtime config are kept. */
function shouldCacheApi(url, apiOrigin) {
return Boolean(apiOrigin) && url.origin === apiOrigin && isCacheableApiPath(url.pathname);
}
/** The backend's origin from /api/runtime-config ({ apiUrl }), or null. */
function apiOriginFrom(config) {
if (!config || typeof config.apiUrl !== "string") {
return null;
}
try {
var origin = new URL(config.apiUrl).origin;
return origin === "null" ? null : origin;
} catch {
return null;
}
}
/** Next.js route data (soft navigations): never kept, the page HTML is. */
function isRoutePayload(url) {
return url.searchParams.has("_rsc");
}
/** The build assets (scripts, styles, fonts) a page's HTML refers to. */
function extractAssetUrls(html) {
var found = [];
var match;
NEXT_STATIC_ASSET.lastIndex = 0;
while ((match = NEXT_STATIC_ASSET.exec(html)) !== null) {
var asset = match[2].replace(/&amp;/g, "&");
if (found.indexOf(asset) === -1) {
found.push(asset);
}
}
return found;
}
/**
* Cached build assets no shell page refers to any more and not fetched
* within a week (`date`: the answer's Date header, null when missing) -
* leftovers of old builds. Lazily loaded chunks of the current build are
* kept while they are in use.
*/
function buildAssetsToPrune(cached, current, now) {
return cached
.filter(function (entry) {
var url = new URL(entry.url);
if (url.pathname.indexOf(NEXT_STATIC_PREFIX) !== 0) {
return false;
}
if (current.indexOf(url.pathname + url.search) !== -1) {
return false;
}
return entry.date === null || now - entry.date > BUILD_ASSET_MAX_AGE_MS;
})
.map(function (entry) {
return entry.url;
});
}
var routes = {
SHELL_ROUTES: SHELL_ROUTES,
offlineFallbackFor: offlineFallbackFor,
offlineHomeFor: offlineHomeFor,
navigationPlanFor: navigationPlanFor,
isShellRoute: isShellRoute,
extractAssetUrls: extractAssetUrls,
isCacheableApiPath: isCacheableApiPath,
shouldCacheApi: shouldCacheApi,
apiOriginFrom: apiOriginFrom,
isRoutePayload: isRoutePayload,
buildAssetsToPrune: buildAssetsToPrune,
};
root.ApiCoreSwRoutes = routes;
if (typeof module !== "undefined" && module.exports) {
module.exports = routes;
}
})(typeof self !== "undefined" ? self : globalThis);
+492 -51
View File
@@ -1,74 +1,515 @@
const CACHE_NAME = "apicore-shell-v3";
/*
* 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), 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.
*/
self.addEventListener("install", () => {
// 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 = "3b534be6a6bf";
importScripts(`/sw-routes.js?v=${SW_ROUTES_VERSION}`);
const {
SHELL_ROUTES,
offlineFallbackFor,
offlineHomeFor,
navigationPlanFor,
extractAssetUrls,
isCacheableApiPath,
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 = `<!doctype html>
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>ApiCore – keine Verbindung</title></head>
<body style="font-family:system-ui,sans-serif;margin:0;padding:24px;line-height:1.5;max-width:40rem">
<h1 style="font-size:1.5rem">Keine Verbindung</h1>
<p>ApiCore ist auf diesem Gerät noch nicht für offline bereit.</p>
<p>Öffne die App einmal mit Netz. Danach startet sie auch ohne Netz.</p>
<p><a href="/" style="display:inline-block;min-height:44px;line-height:44px;padding:0 16px;border:1px solid currentColor;border-radius:12px">Erneut versuchen</a></p>
</body></html>`;
// 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(
caches
.keys()
.then((keys) =>
Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))),
)
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);
// /api/runtime-config tells the app which backend origin to use - it
// must always be fetched live. A stale cached value would point the app
// at the wrong (or no longer running) backend, which is worse than no
// cache at all.
if (url.pathname === "/api/runtime-config") {
if (url.protocol !== "https:" && url.protocol !== "http:") {
return;
}
const isSameOrigin = url.origin === self.location.origin;
// Same-origin static assets/pages, or the real backend API. The backend
// runs on its own origin (API_URL, injected at runtime via
// /api/runtime-config) rather than this app's own origin, so it can't be
// matched by origin the way static assets are - matched by path instead,
// since every backend call goes through /api/v1/. Without this, colonies,
// apiaries, tasks etc. were never cached at all: a beekeeper opening the
// app with no signal at an apiary saw nothing, not even data they had
// already loaded minutes earlier on the same device.
const isCacheable =
url.origin === self.location.origin || url.pathname.includes("/api/v1/");
if (!isCacheable) {
return;
}
// Page navigations must never be answered with a synthetic Response.error() -
// Chrome/Edge render that as a native "This page couldn't load" interstitial
// instead of the app. Let the browser handle navigations directly; only
// static assets/API data go through the offline-cache fallback below.
if (request.mode === "navigate") {
const plan = navigationPlanFor(url);
if (isSameOrigin && plan.kind !== "browser") {
event.respondWith(respondToNavigation(event, url, plan));
}
return;
}
event.respondWith(
fetch(request)
.then((response) => {
// Never cache an error response (e.g. a 401 hit right as an access
// token expired, before refresh could kick in) - it would otherwise
// get served back later as if it were still the current state.
if (!response.ok) {
return response;
}
const responseClone = response.clone();
return caches
.open(CACHE_NAME)
.then((cache) => cache.put(request, responseClone))
.then(() => response);
})
.catch(() => caches.match(request).then((cached) => cached || Response.error())),
);
// 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 (isCacheableApiPath(url.pathname)) {
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 (!isCacheableApiPath(new URL(request.url).pathname)) {
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) {
const contentType = response.headers.get("content-type") || "";
return response.ok && contentType.toLowerCase().includes("application/json");
}
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)));
}