- Readiness: all 18 checks (medicine lots, treatment log gaps, labels, harvest withdrawal), medicine subjects masked without veterinary-medicines:read, report PDF (counts only), summary cached 60 s, 409 codes READINESS_*. - Frontend: /compliance page with acknowledge/revoke, dashboard line and nav; veterinary medicine catalogue, lots, movement book with inventory and disposal; label designer check panel, print card with gap list and proof print, food business master data, product legal name. - Service worker: cacheability is path-based (the backend sends no-store on every answer); compliance, medicine, restriction, notice and label check/print paths and restricted document kinds are never cached. - ARCHITECTURE.md §2, §12.10–12.11, Annahmen 86–90. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
306 lines
11 KiB
JavaScript
306 lines
11 KiB
JavaScript
/*
|
|
* 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,
|
|
// Telegram link and settings (one-time link, chat state), and the System
|
|
// page (queue and delivery details): current state or nothing
|
|
/\/api\/v1\/profile\/telegram(\/|$)/i,
|
|
/\/api\/v1\/profile\/notification-settings(\/|$)/i,
|
|
/\/api\/v1\/system(\/|$)/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,
|
|
// Prüfbereitschaft and authority data: file numbers, addresses, notices,
|
|
// restricted zones around an apiary, medicine packs with receipts and
|
|
// suppliers, and the label check and print (they carry the food business address)
|
|
/\/api\/v1\/compliance(\/|$)/i,
|
|
/\/api\/v1\/authority-notices(\/|$)/i,
|
|
/\/api\/v1\/apiary-restrictions(\/|$)/i,
|
|
/\/api\/v1\/apiaries\/[^/]+\/restrictions\/?$/i,
|
|
/\/api\/v1\/veterinary-medicines(\/|$)/i,
|
|
/\/api\/v1\/veterinary-medicine-lots(\/|$)/i,
|
|
/\/api\/v1\/label-templates\/check(\/|$)/i,
|
|
/\/api\/v1\/label-templates\/[^/]+\/(print|check)(\/|$)/i,
|
|
// One single document (metadata): its kind is not in the path, and it may
|
|
// belong to a pack, a notice or a restriction. The lists (with the kind
|
|
// in the query, see below) and the photo downloads use other routes.
|
|
/\/api\/v1\/documents\/[^/]+\/?$/i,
|
|
];
|
|
|
|
// The document list of these records is never kept either (attachments of a
|
|
// pack, a notice or a restriction). The list is /documents?entityType=...,
|
|
// so the query decides; documents of inspections (photos) stay available
|
|
// offline. Downloads are blobs and never kept anyway.
|
|
var DOCUMENTS_PATH = /\/api\/v1\/documents\/?$/i;
|
|
var NEVER_CACHED_DOCUMENT_TYPES = ["MEDICINE_LOT", "AUTHORITY_NOTICE", "APIARY_RESTRICTION"];
|
|
|
|
// 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;
|
|
}
|
|
|
|
function isNeverCachedDocumentList(url) {
|
|
if (!DOCUMENTS_PATH.test(url.pathname)) {
|
|
return false;
|
|
}
|
|
var entityType = (url.searchParams.get("entityType") || "").toUpperCase();
|
|
return NEVER_CACHED_DOCUMENT_TYPES.indexOf(entityType) !== -1;
|
|
}
|
|
|
|
/** The path rules plus the document lists, which only the query tells apart. */
|
|
function isCacheableApiUrl(url) {
|
|
return isCacheableApiPath(url.pathname) && !isNeverCachedDocumentList(url);
|
|
}
|
|
|
|
/** Only answers of the backend named by the runtime config are kept. */
|
|
function shouldCacheApi(url, apiOrigin) {
|
|
return Boolean(apiOrigin) && url.origin === apiOrigin && isCacheableApiUrl(url);
|
|
}
|
|
|
|
/**
|
|
* Whether a backend answer may be stored: a successful JSON record. The
|
|
* backend marks EVERY answer `Cache-Control: no-store`, so that header says
|
|
* nothing about one record; what must not stay on the device is decided by
|
|
* the path rules above. Never downloads, photos, PDFs.
|
|
*/
|
|
function isKeepableApiAnswer(answer) {
|
|
return (
|
|
Boolean(answer.ok) &&
|
|
String(answer.contentType || "").toLowerCase().indexOf("application/json") !== -1
|
|
);
|
|
}
|
|
|
|
/** 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,
|
|
isCacheableApiUrl: isCacheableApiUrl,
|
|
shouldCacheApi: shouldCacheApi,
|
|
isKeepableApiAnswer: isKeepableApiAnswer,
|
|
apiOriginFrom: apiOriginFrom,
|
|
isRoutePayload: isRoutePayload,
|
|
buildAssetsToPrune: buildAssetsToPrune,
|
|
};
|
|
|
|
root.ApiCoreSwRoutes = routes;
|
|
if (typeof module !== "undefined" && module.exports) {
|
|
module.exports = routes;
|
|
}
|
|
})(typeof self !== "undefined" ? self : globalThis);
|