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:
1 parent
c01a38835a
commit
cdd30cf3ee
288 files changed
+39165
-2853
No files matched your search
@@ -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(/&/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
@@ -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)));
|
||||
}
|
||||
Reference in new issue
Block a user