- api-client accepts 2xx answers without body (revoke showed an error) - authority notices: sentOn in the future is rejected (Berlin day) on create, mark-sent and PATCH; dialog defaults to the Berlin day - service worker never caches /api/v1/site-settings - treatments/new no longer overflows at 390 px (min-w-0 on form wrappers); dose with more than 3 decimals is an error, never rounded - medicines: expired lots flagged, disposal over the rest warns - label designer: side panel at lg, sample values instead of placeholders, compliance panel reopens on lg, labelled zoom buttons - skip link to #main-content, headings on no-access pages Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
521 lines
17 KiB
JavaScript
521 lines
17 KiB
JavaScript
/*
|
||
* 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 = `<!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 (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)));
|
||
}
|