Files
ApiCore/ApiCore-Frontend/public/sw-routes.js
T
Nick SzaunigandClaude Opus 5.5 9760ce154f
Backend Image / build-and-push (push) Successful in 4m27s
Frontend Image / build-and-push (push) Successful in 9m34s
Manifests / validate (push) Successful in 19s
feat: T4 wave 11 – bottling form with article values and confirmation, Telegram in the app
- Bottling form: jar size and best-before from the product with source,
  differs-hints before saving, prefill status, water-content confirmation
  naming the measured value, idempotency key renewed on body change;
  product form gets shelf life (months) and whole-gram jar weight.
- Telegram frontend: link card with step-up dialog, t.me-only deep link,
  status polling, unlink; system Telegram card with receiver/sender state
  and confirmed retry; StepUpDialog shared with the backup import.
- Reauth calls never refresh-and-retry on 401 (no double lockout count);
  German texts for expired session and 429; service worker never caches
  Telegram, notification settings and system responses.
- Telegram backend round 2: shared link removal on every disconnect path,
  lease released on shutdown, connection errors not counted as poison.
- ARCHITECTURE.md §2, §6 (Annahmen 56–58), §10, §11.12, §11.13; TASKS.md T4.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 01:21:08 +02:00

256 lines
8.5 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,
];
// 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);