Files
HealthLog/public/sw.js
T
Marc-André Bombeck 298e4c34b4 perf: collapse the medication compliance hot path and the dashboard fan-out
The medication list fired one compliance request per card every 30
seconds, and each request scanned the medication's full intake history
and expanded the recurrence calendar five to six times. The endpoint now
mints bands once per request, serves the sub-windows from that single
ledger, bounds the event read to a year of selected columns and sits
behind a write-invalidated 15-minute server cache; the cards poll at
five minutes. The dashboard drops two redundant checklist queries by
reading counts from the snapshot, the BMI chart derives from the weight
series instead of refetching it, the detail page no longer serialises
behind the auth probe and seeds its query from the list row. Expensive
analytics reads gain a shared per-user rate limit, and the service
worker stops serving cross-version shells, enables navigation preload
and trims caches in one pass.
2026-06-10 12:55:56 +02:00

248 lines
9.9 KiB
JavaScript

/**
* Service Worker for HealthLog PWA.
* Handles Web Push notifications and offline caching.
*/
// v1.4.38.4 — `CACHE_VERSION` now tracks the app release tag.
// Bumped on every release so the `activate` step evicts every
// pre-release cache entry. Without this the precached root HTML and
// the cached `/_next/static/*` chunks survived across deploys,
// stale-shell-served the old chunk graph, and the running app then
// hit `ChunkLoadError` on the first lazy navigation. Pair with the
// `<VersionPoller>` client component (mounted in `<Providers>`) that
// polls `/api/version`, compares against `NEXT_PUBLIC_APP_VERSION`,
// and triggers an SW-unregister + cache-wipe + hard reload when the
// server moves ahead of the running shell.
//
// v1.4.43 QoL (L3) — read the active version from `self.__APP_VERSION__`,
// which the build step writes to `/public/sw-version.js` (loaded
// synchronously below via `importScripts`). Pre-fix the literal was
// hand-bumped per release and quietly drifted four releases stale, so
// every deploy between v1.4.38.4 → v1.4.42 served the previous shell's
// cache instead of evicting it on activate. The fallback literal stays
// in case the import is missing (legacy SW versions, dev mode without
// the build step), but the source of truth is now the generated file.
try {
importScripts("/sw-version.js");
} catch {
// Fall through to the literal fallback below.
}
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) || "v1.4.43";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
const MAX_STATIC_ENTRIES = 150;
const MAX_PAGE_ENTRIES = 30;
// App shell files to precache on install
const PRECACHE_URLS = ["/", "/logo-192.png", "/logo-512.png", "/favicon.svg"];
// ── Install: precache app shell ──────────────────────────────────────────────
self.addEventListener("install", (event) => {
event.waitUntil(
caches
.open(STATIC_CACHE)
.then((cache) => cache.addAll(PRECACHE_URLS))
.then(() => self.skipWaiting()),
);
});
// ── Activate: clean up old caches ────────────────────────────────────────────
self.addEventListener("activate", (event) => {
event.waitUntil(
Promise.all([
caches
.keys()
.then((keys) =>
Promise.all(
keys
.filter((k) => k !== STATIC_CACHE && k !== PAGE_CACHE)
.map((k) => caches.delete(k)),
),
),
// Navigation preload lets the browser start the navigation request
// in parallel with service-worker startup; `networkFirst` consumes
// it via `event.preloadResponse`, shaving SW boot latency off every
// page navigation. Guarded — older engines lack the API.
self.registration.navigationPreload
? self.registration.navigationPreload.enable()
: Promise.resolve(),
]).then(() => self.clients.claim()),
);
});
// ── Fetch: strategy per resource type ────────────────────────────────────────
self.addEventListener("fetch", (event) => {
const { request } = event;
const url = new URL(request.url);
// Only handle same-origin GET requests
if (request.method !== "GET" || url.origin !== self.location.origin) return;
// API calls — network only (never serve stale data)
if (url.pathname.startsWith("/api/")) return;
// Next.js static assets — cache-first (immutable hashed filenames)
if (url.pathname.startsWith("/_next/static/")) {
event.respondWith(cacheFirst(request, STATIC_CACHE));
return;
}
// Static files (fonts, icons, images) — cache-first with long TTL
if (/\.(png|svg|jpg|jpeg|gif|webp|ico|woff2?|ttf|eot)$/i.test(url.pathname)) {
event.respondWith(cacheFirst(request, STATIC_CACHE));
return;
}
// HTML pages — network-first with cache fallback. The whole event is
// passed so `networkFirst` can consume the navigation-preload response.
if (request.headers.get("accept")?.includes("text/html")) {
event.respondWith(networkFirst(event, PAGE_CACHE));
return;
}
});
/**
* Cache-first: serve from cache, fall back to network and cache the response.
*/
async function cacheFirst(request, cacheName) {
const cached = await caches.match(request);
if (cached) return cached;
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(cacheName);
cache.put(request, response.clone());
trimCache(cacheName, MAX_STATIC_ENTRIES);
}
return response;
}
/**
* Network-first: try network (preferring the navigation-preload response
* when the browser already started it), fall back to cache.
*
* The offline fallback only serves a shell cached under the CURRENT
* `CACHE_VERSION` cache names. The previous global `caches.match()` could
* resolve from a stale pre-update cache in the activation gap and serve a
* shell whose `/_next/static/*` chunk graph no longer exists
* (`ChunkLoadError` on the first lazy navigation).
*/
async function networkFirst(event, cacheName) {
const { request } = event;
try {
const response =
(event.preloadResponse ? await event.preloadResponse : null) ||
(await fetch(request));
if (response.ok) {
const cache = await caches.open(cacheName);
cache.put(request, response.clone());
trimCache(cacheName, MAX_PAGE_ENTRIES);
}
return response;
} catch {
// Version-scoped fallback: the page cache first, then the precached
// app shell — both names embed CACHE_VERSION, so a stale shell from a
// previous release can never be served here.
const pageCache = await caches.open(cacheName);
const cachedPage = await pageCache.match(request);
if (cachedPage) return cachedPage;
const staticCache = await caches.open(STATIC_CACHE);
const precached = await staticCache.match(request);
if (precached) return precached;
// Offline fallback. F-7 (mobile security audit, 2026-05-16): the
// previous fallback rendered hard-coded German body copy, which is
// wrong for any en/* user reaching it offline. The replacement is
// language-neutral — wordmark + the universally understood
// "Offline" token + a generic retry hint expressed as an icon
// (the round-trip arrow), so the page does the right thing in any
// locale without shipping a translation bundle into the worker.
return new Response(
'<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>HealthLog — Offline</title><style>body{font-family:system-ui,sans-serif;background:#282a36;color:#f8f8f2;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}div{text-align:center;padding:2rem}h1{color:#bd93f9;margin:0 0 .5rem;font-size:2rem;letter-spacing:.05em}p{color:#6272a4;margin:.25rem 0;font-size:.9rem}svg{width:48px;height:48px;color:#6272a4;margin-bottom:1rem}</style></head><body><div><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M23 4v6h-6"/><path d="M1 20v-6h6"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10"/><path d="M20.49 15a9 9 0 0 1-14.85 3.36L1 14"/></svg><h1>HealthLog</h1><p>Offline</p></div></body></html>',
{
status: 503,
headers: { "Content-Type": "text/html; charset=utf-8" },
},
);
}
}
/**
* Trim cache to maxEntries by removing oldest entries. `keys()` returns
* insertion order, so one read + a single pass over the excess prefix
* replaces the previous re-fetch-keys-per-deletion loop (O(n²) reads).
*/
async function trimCache(cacheName, maxEntries) {
const cache = await caches.open(cacheName);
const keys = await cache.keys();
const excess = keys.length - maxEntries;
for (let i = 0; i < excess; i++) {
await cache.delete(keys[i]);
}
}
// ── Push Notifications ───────────────────────────────────────────────────────
self.addEventListener("push", (event) => {
if (!event.data) return;
let payload;
try {
payload = event.data.json();
} catch {
payload = { title: "HealthLog", body: event.data.text() };
}
const {
title = "HealthLog",
body = "",
tag = "default",
url = "/",
} = payload;
event.waitUntil(
self.registration.showNotification(title, {
body,
tag,
icon: "/logo-192.png",
badge: "/logo-192.png",
data: { url },
}),
);
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
// F-7 (mobile security audit, 2026-05-16): validate the destination
// resolves to the same origin before navigating any client. Push
// payloads are VAPID-signed by our own server, but a server-side bug
// (or compromised admin issuing pushes) could ship an off-origin URL
// into `data.url` and use the focused PWA as a redirect-driven phish.
// Reject anything that doesn't match our origin and fall back to "/".
const rawUrl = event.notification.data?.url || "/";
let safeUrl = "/";
try {
const resolved = new URL(rawUrl, self.location.origin);
if (resolved.origin === self.location.origin) {
safeUrl = resolved.pathname + resolved.search + resolved.hash;
}
} catch {
safeUrl = "/";
}
event.waitUntil(
self.clients
.matchAll({ type: "window", includeUncontrolled: true })
.then((clientList) => {
for (const client of clientList) {
if (client.url.includes(self.location.origin) && "focus" in client) {
client.navigate(safeUrl);
return client.focus();
}
}
return self.clients.openWindow(safeUrl);
}),
);
});