Files
ApiCore/ApiCore-Frontend/public/sw.js
T
Nick SzaunigandClaude Opus 5.5 cdd30cf3ee 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]>
2026-10-14 11:36:38 +02:00

516 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 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.
*/
// 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(
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 (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)));
}