Files
HealthLog/public/sw.js
T
Marc-André Bombeck 3f47224825 fix(i18n): key the catalog's cache by its content, not the release
The service worker serves the locale catalog's boot script cache-first,
and its URL carried the package version. Every build of one release
therefore shared one URL: an instance updated within a release (a beta,
a redeploy) kept the catalog of its first build, and every message added
since read as its raw key ("timeline.zoom.range", "timeline.selection.week")
or fell back to English.

next.config.ts now hashes the message catalogs at build time and the
layout keys the script by that hash, so a changed catalog is a new URL.
2026-10-09 13:13:26 +02:00

754 lines
31 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.
}
// The `|| "..."` fallback literal is the value used only when the
// `importScripts('/sw-version.js')` above is absent (dev with no `prebuild`
// step, or a 404 on the generated file). `scripts/generate-sw-version.mjs`
// rewrites it to the current `package.json` version on every prebuild — the
// marker below is the rewrite anchor — so in any shipped image the literal
// matches the active release and can never drift stale the way it did across
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.42.0";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
// reads. Network-first: online callers always get the fresh server response
// (and the cache is refreshed behind it), so a read-after-write never serves a
// stale pre-mutation list. Offline (the network fetch throws) the LAST cached
// dashboard/series is served instead of empty skeletons forever.
//
// v1.18.6.x — reverted from stale-while-revalidate, which served the cached
// pre-mutation copy first and only revalidated in the background, so a list
// read straight after a create/update showed the old (or empty) data until the
// next paint. Network-first restores read-after-write freshness while keeping
// the offline fallback.
const DATA_CACHE = `healthlog-data-${CACHE_VERSION}`;
// Keep this ownership contract aligned with
// `clearOfflineCachesForSessionEnd()` in `query-persister.ts`. Cache cleanup
// must cover every current/legacy HealthLog cache without deleting unrelated
// CacheStorage namespaces owned by another app on the same origin.
const HEALTHLOG_CACHE_NAME_RE = /^healthlog-(?:static|pages|data)-/;
const CURRENT_HEALTHLOG_CACHES = new Set([
STATIC_CACHE,
PAGE_CACHE,
DATA_CACHE,
]);
// Presence marker `src/proxy.ts` sets on every HealthLog page response. See
// `isForeignNavigationResponse` for why the worker needs it and the proxy for
// why it carries no version. Keep the name in lockstep with
// `HEALTHLOG_PAGE_MARKER_HEADER` there.
const HEALTHLOG_PAGE_MARKER_HEADER = "X-HealthLog-Page";
// Set as soon as a navigation looks like another app (see
// `isForeignNavigationResponse`). While it is set the fetch handler declines
// every request, so the page and its assets come from the network. It is
// cleared again when `confirmForeignOrigin` finds HealthLog after all, and
// stays set for the rest of the worker's life when the origin is confirmed
// foreign and the worker retires.
let foreignOrigin = false;
// One confirmation at a time: a burst of navigations must not fire a burst of
// probes, or race one probe's "HealthLog after all" against another's retire.
let foreignOriginCheck = null;
const MAX_STATIC_ENTRIES = 150;
const MAX_PAGE_ENTRIES = 30;
const MAX_DATA_ENTRIES = 60;
// Safe-GET read allowlist. ONLY idempotent reads that render the core views
// and carry NO secret-shaped body. Auth, mutations and token endpoints are
// never listed — and `isCacheableApiResponse` is a second, body-level guard
// (mirrors the idempotency cache's `hlk_`/`hlr_`/`sk-` refusal) so a future
// endpoint that slipped onto the list can still never persist a secret.
// Matched by exact path or `<path>/` / `<path>?` prefix.
// v1.18.6 — AI/clinical narrative surfaces (`/api/insights`, `/api/analytics`)
// are deliberately NOT cached: `/api/insights` prefix-matched the Coach chat
// endpoint into the disk cache, and neither belongs on a self-hosted health
// PWA's durable storage. Only the dashboard snapshot, the widget layout, the
// measurement reads (incl. the batched daily series), the medication reads,
// and the version probe are eligible.
const API_READ_ALLOWLIST = [
"/api/dashboard/snapshot",
// v1.37.20 — daily digest: the Today rail's offline read. Keep in
// lockstep with src/lib/pwa/sw-cache-policy.ts.
"/api/daily/digest",
"/api/dashboard/widgets",
"/api/measurements",
"/api/medications",
"/api/version",
];
// Defence in depth: even an allowlisted path is refused if its pathname looks
// like an auth/token/secret surface.
const API_DENY_RE = /\/(auth|tokens?|sessions?|login|password|webauthn)(\/|$|\?)/i;
function isAllowlistedApiRead(pathname) {
if (API_DENY_RE.test(pathname)) return false;
return API_READ_ALLOWLIST.some(
(p) =>
pathname === p ||
pathname.startsWith(p + "/") ||
pathname.startsWith(p + "?"),
);
}
// Body-level secret refusal: never persist a body carrying a token/secret-
// shaped pattern even if the path was allowlisted by mistake.
const SECRET_BODY_RE = /(hlk_|hlr_|sk-)[A-Za-z0-9_-]/;
function isCacheableApiResponse(response, bodyText) {
if (!response || !response.ok) return false;
const cacheControl = response.headers.get("Cache-Control") || "";
if (/no-store/i.test(cacheControl)) return false;
if (typeof bodyText === "string" && SECRET_BODY_RE.test(bodyText)) {
return false;
}
return true;
}
// Public immutable assets to precache on install. Never include `/`: installing
// while signed in would fetch the authenticated Dashboard document with the
// browser's session cookies and persist that health-bearing HTML in
// CacheStorage for replay after logout.
const PRECACHE_URLS = ["/logo-192.png", "/logo-512.png", "/favicon.svg"];
// ── Install: precache public immutable assets ────────────────────────────────
self.addEventListener("install", (event) => {
event.waitUntil(
caches
.open(STATIC_CACHE)
.then(async (cache) => {
// An updated worker can reuse the same release-scoped cache when its
// source changes without a version bump. Remove a root document left
// by the previous worker before adding the safe public asset set.
await cache.delete("/");
await 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) =>
HEALTHLOG_CACHE_NAME_RE.test(k) &&
!CURRENT_HEALTHLOG_CACHES.has(k),
)
.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) => {
// The origin serves another app now (see `retireFromForeignOrigin`): no
// `respondWith`, no cache, the browser's own fetch for everything.
if (foreignOrigin) return;
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;
// One-shot auth navigations must reach the server EXACTLY ONCE. The OIDC
// login + callback endpoints are top-level browser navigations (not
// `fetch`/XHR), so with navigation preload enabled (see `activate`) the
// browser has ALREADY dispatched the request by the time this handler runs.
// Returning early WITHOUT `event.respondWith()` — the general `/api/` path
// below does exactly that — makes the browser ALSO run its own default
// navigation fetch, so the request is sent twice. The OIDC authorization
// code is single-use: the first callback redeems it and sets the session,
// the second fails at the IdP and redirects to
// `/auth/login?error=oidc_failed` on an otherwise-successful sign-in.
// Consuming the preload response here (or fetching once when there is none)
// settles the event with a single network request. These responses are
// never cached.
if (url.pathname.startsWith("/api/auth/")) {
event.respondWith(
(async () => (await event.preloadResponse) || fetch(request))(),
);
return;
}
// API calls. Allowlisted safe GET reads use network-first so the installed
// PWA always renders fresh server data online (and falls back to the last
// cached copy only when the network fails); everything else (auth,
// mutations, anything not on the list) stays network-only.
if (url.pathname.startsWith("/api/")) {
if (isAllowlistedApiRead(url.pathname)) {
event.respondWith(networkFirstApi(request));
}
return;
}
// Next.js static assets — cache-first (immutable hashed filenames)
if (url.pathname.startsWith("/_next/static/")) {
event.respondWith(cacheFirst(request, STATIC_CACHE));
return;
}
// Locale-catalog boot script — cache-first. The URL is versioned by the
// catalogs' content hash (`/i18n/<locale>?v=<hash>`, `next.config.ts`),
// so a changed catalog is a new URL and cache-first is
// correct and keeps the catalog available on an offline relaunch (the
// cached shell HTML references the same versioned URL it was rendered
// with). Carries no user data — same public strings as the repository's
// messages/*.json.
if (url.pathname.startsWith("/i18n/")) {
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) {
await bestEffortCacheWrite(
cacheName,
request,
response.clone(),
MAX_STATIC_ENTRIES,
);
}
return response;
}
/**
* Network-first for allowlisted safe GET `/api/*` reads.
*
* Always tries the network first so an online caller gets the fresh server
* response — critical for read-after-write: a list fetched straight after a
* create/update must reflect the mutation, not a stale pre-mutation copy. On a
* cacheable response the data cache is refreshed behind it. Only when the
* network fails (offline) is the last cached copy served; with neither, a JSON
* 503 lets the client's TanStack Query cache / IndexedDB persister supply data.
*
* Caching is gated twice: `isAllowlistedApiRead` already filtered the path,
* and `isCacheableApiResponse` inspects the actual body so a `no-store` or
* secret-shaped (`hlk_`/`hlr_`/`sk-`) response is never persisted — the same
* refusal discipline the idempotency cache uses.
*/
async function networkFirstApi(request) {
try {
const response = await fetch(request);
// Read the body once to body-screen it, then reconstruct a fresh Response
// so both the cache.put and the return value have an unconsumed body.
const bodyText = await response
.clone()
.text()
.catch(() => undefined);
if (isCacheableApiResponse(response, bodyText)) {
await bestEffortCacheWrite(
DATA_CACHE,
request,
new Response(bodyText, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
}),
MAX_DATA_ENTRIES,
);
}
return response;
} catch {
// Network failed (offline) — serve the last cached copy if we have one.
const cache = await caches.open(DATA_CACHE);
const cached = await cache.match(request);
if (cached) return cached;
return new Response(
JSON.stringify({ data: null, error: "offline", meta: { offline: true } }),
{
status: 503,
headers: { "Content-Type": "application/json; charset=utf-8" },
},
);
}
}
/**
* Privacy gate for the navigation page cache. Most app routes are
* client-fetch-only shells with no server-rendered PII (health JSON loads
* over `/api/*`, which is network-only here), so caching their HTML is
* safe. Two carve-outs:
*
* 1. A `Cache-Control: no-store` response opts itself out — the
* principled, future-proof rule for any current/future server RSC
* that renders user data and sets the header.
* 2. `/c/*` (the clinician-share view) renders health values + wellness
* scores straight into the HTML and emits `no-store`; the explicit
* path skip is belt-and-braces so a revoked share can never linger in
* CacheStorage and render back offline.
*
* In practice rule 1 covers every signed-in page: `src/proxy.ts` pins
* `private, no-store` on all of them, because several server-prefetch one
* account's record into the HTML. Only the public pages (sign-in, legal)
* land in this cache, so a navigation to an app page without a connection
* gets the offline page below, never somebody's cached record. A page that
* is already open keeps working offline from the cached chunks and the
* allowlisted data reads.
*/
function isCacheableNavigation(request, response) {
const cacheControl = response.headers.get("Cache-Control") || "";
if (/no-store/i.test(cacheControl)) return false;
try {
const { pathname } = new URL(request.url);
if (pathname === "/c" || pathname.startsWith("/c/")) return false;
} catch {
// Unparseable URL — fall through; the no-store check already ran.
}
return true;
}
/**
* Does this navigation response come from something other than HealthLog?
*
* The worker is registered with scope `/` because HealthLog serves everything
* from `/`. When HealthLog is stopped and a different app starts on the same
* origin (the usual case is `localhost:3000` on a developer machine), this
* worker kept answering: `/_next/static/*` is cache-first, so the other app
* ran on HealthLog's cached chunks, and the only way out was unregistering by
* hand in the browser's developer tools.
*
* Only a response that positively looks like another app counts:
*
* - a redirect is opaque to the worker (`opaqueredirect`, status 0, no
* readable headers), and HealthLog redirects every signed-out page, so it
* proves nothing; status 0 fails the `ok` check below;
* - a non-2xx answer proves nothing either: a reverse proxy or auth gateway
* in front of a stopped HealthLog answers 502 or 401 without the marker,
* and unregistering there would also drop the Web Push subscription;
* - a network failure never reaches this check (it throws into the offline
* fallback in `networkFirst`).
*
* What is left is a successful page without the marker HealthLog's proxy puts
* on every page response. That is a suspicion, not a verdict: a reverse proxy
* with a response-header allowlist strips the marker from every genuine page.
* `confirmForeignOrigin` settles it before anything is deleted.
*/
function isForeignNavigationResponse(response) {
if (!response || !response.ok) return false;
return !response.headers.has(HEALTHLOG_PAGE_MARKER_HEADER);
}
/**
* Is the origin positively not HealthLog? Asked of `/api/version`, which is
* public (on the proxy's `PUBLIC_PATHS` list, no session needed) and answers
* `{ data: { version, buildSha, ... } }`. A body check survives any proxy that
* rewrites headers.
*
* Retire only on a positive answer from something else: a 404, a 2xx that is
* not JSON (another app's HTML fallback), or JSON without the HealthLog shape.
* `buildSha` is `null` on an image not built by the release workflow, so the
* shape is "`version` is a string and `buildSha` is present", not "both are
* strings". Anything that is not an answer (a thrown fetch, a 5xx, a 401 from
* an auth gateway, a redirect) proves nothing, and the worker resumes normal
* service.
*
* One gateway shape still reads as "another app": a same-origin auth gateway
* that answers an expired session with its sign-in page and status 200 (nginx
* `auth_request` with `error_page 401 = /oauth2/sign_in` does this). Both the
* navigation and this probe then come back as HTML. The probe sends the
* gateway cookie (`credentials: "same-origin"`), so a valid gateway session
* reaches HealthLog; with an expired one the verdict is wrong, which is why
* `retireFromForeignOrigin` never unregisters while a push subscription exists.
*
* The probe is the worker's own `fetch`, which never passes through this
* worker's fetch handler; `no-store` keeps the HTTP cache out of it and
* `redirect: "manual"` keeps a login redirect from reading as an answer.
*/
async function confirmForeignOrigin() {
let response;
try {
response = await fetch("/api/version", {
cache: "no-store",
redirect: "manual",
credentials: "same-origin",
});
} catch {
return false;
}
if (response.status === 404) return true;
if (!response.ok) return false;
let body;
try {
body = JSON.parse(await response.text());
} catch {
return true;
}
const data = body && typeof body === "object" ? body.data : null;
const isHealthlog =
!!data &&
typeof data === "object" &&
typeof data.version === "string" &&
"buildSha" in data;
return !isHealthlog;
}
/**
* Confirm, then either retire or resume. Shared by concurrent navigations.
*/
function checkForeignOrigin() {
if (!foreignOriginCheck) {
foreignOriginCheck = (async () => {
let foreign = false;
try {
foreign = await confirmForeignOrigin();
} catch {
foreign = false;
}
if (foreign) {
await retireFromForeignOrigin();
return;
}
// HealthLog after all, or no answer: serve normally again, and let a
// later suspicious navigation ask again.
foreignOrigin = false;
foreignOriginCheck = null;
})();
}
return foreignOriginCheck;
}
/**
* Whether this registration holds a Web Push subscription. Unregistering
* destroys the subscription and nothing in the app re-creates it (the push
* card only reads it), so reminders would stop without notice. A missing
* `pushManager` or a throwing lookup counts as "has one": keeping a worker
* that only passes requests through costs nothing, losing reminders does.
*/
async function hasPushSubscription() {
try {
const pushManager = self.registration.pushManager;
if (!pushManager || typeof pushManager.getSubscription !== "function") {
return true;
}
return (await pushManager.getSubscription()) !== null;
} catch {
return true;
}
}
/**
* Leave an origin that no longer serves HealthLog: delete every HealthLog
* cache (only ours, matched by name, never another app's CacheStorage) and
* unregister, so the next load of that origin runs without this worker. The
* `foreignOrigin` flag set by the caller stays set, so the requests the
* current page still makes go to the network. Each step is best-effort and
* independent: a failed cache delete must not keep the worker registered.
*
* With a push subscription the worker does not unregister: the verdict may
* be a gateway's 200 sign-in page (see `confirmForeignOrigin`), and
* unregistering would destroy the subscription. It deletes the caches and
* stays in pass-through for the rest of its life instead, so another app on
* the origin still gets network content. The next worker start probes again.
*/
async function retireFromForeignOrigin() {
try {
const keys = await caches.keys();
await Promise.all(
keys
.filter((k) => HEALTHLOG_CACHE_NAME_RE.test(k))
.map((k) => caches.delete(k)),
);
} catch {
// CacheStorage unavailable; unregistering still matters more.
}
if (await hasPushSubscription()) return;
try {
await self.registration.unregister();
} catch {
// Nothing further to do from inside the worker.
}
}
/**
* 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 (isForeignNavigationResponse(response)) {
// Step aside at once (declining a request only sends it to the
// network), but delete nothing until the origin is confirmed foreign.
foreignOrigin = true;
event.waitUntil(checkForeignOrigin());
return response;
}
if (response.ok && isCacheableNavigation(request, response)) {
event.waitUntil(
bestEffortCacheWrite(
cacheName,
request,
response.clone(),
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.
//
// v1.42 — the arrow is a real control now (a link to the same URL,
// 64 px), the page reloads by itself as soon as the connection is
// back, it follows the device's light or dark scheme, and it keeps
// clear of the notch and home indicator of an installed app.
return new Response(OFFLINE_PAGE, {
status: 503,
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
}
const OFFLINE_PAGE =
'<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="color-scheme" content="dark light"><title>HealthLog — Offline</title><style>' +
":root{--bg:#282a36;--fg:#f8f8f2;--accent:#bd93f9;--muted:#a4a9c9;color-scheme:dark}" +
"@media (prefers-color-scheme:light){:root{--bg:#f3f2f5;--fg:#1f2030;--accent:#6b46c1;--muted:#5b5e78;color-scheme:light}}" +
"body{font-family:system-ui,sans-serif;background:var(--bg);color:var(--fg);display:flex;align-items:center;justify-content:center;min-height:100vh;min-height:100dvh;margin:0;padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);box-sizing:border-box}" +
"main{text-align:center;padding:2rem}h1{color:var(--accent);margin:0 0 .5rem;font-size:2rem;letter-spacing:.05em}p{color:var(--muted);margin:.25rem 0 1.5rem;font-size:1rem}" +
"a{display:inline-flex;align-items:center;justify-content:center;width:64px;height:64px;border-radius:9999px;color:var(--accent);border:2px solid currentColor}a:focus-visible{outline:3px solid var(--accent);outline-offset:3px}svg{width:28px;height:28px}" +
'</style></head><body><main><h1>HealthLog</h1><p>Offline</p><a href="" aria-label="Reload" title="Reload"><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></a></main>' +
'<script>addEventListener("online",function(){location.reload()})</script></body></html>';
/**
* Cache a successful network response without making CacheStorage
* availability part of the fetch contract. Quota, eviction, or trim failures
* are intentionally swallowed: caching may improve a later request, but it
* must never replace this request's already-successful network response.
*/
async function bestEffortCacheWrite(
cacheName,
request,
response,
maxEntries,
) {
try {
const cache = await caches.open(cacheName);
await cache.put(request, response);
await trimCache(cacheName, maxEntries);
} catch {
// CacheStorage is an optional optimization.
}
}
/**
* 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]);
}
}
// ── App badge helper (PWA Badging API) ───────────────────────────────────────
// v1.18.4 — reflect the server-authoritative outstanding-dose count on the
// installed PWA icon. Feature-detected + best-effort: unsupported engines
// (most desktop Firefox, older Safari) silently no-op. A count of 0 clears the
// badge; a positive count sets it. `undefined`/non-number leaves it untouched.
function applyAppBadge(count) {
if (typeof count !== "number" || !isFinite(count)) return;
try {
if (count > 0) {
if (typeof self.navigator?.setAppBadge === "function") {
self.navigator.setAppBadge(count);
}
} else if (typeof self.navigator?.clearAppBadge === "function") {
self.navigator.clearAppBadge();
}
} catch {
// Badging API can reject (permission / unsupported) — never let it
// break the push handler.
}
}
// ── 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() };
}
// v1.18.4 — `type:"clear"` is the PWA equivalent of ending an iOS Live
// Activity: the server sends it when a dose is logged so the still-pending
// dose-due reminder for that slot is closed here (matched on its stable
// `tag`), and the app badge re-reflects the outstanding-dose count. No new
// notification is shown.
if (payload && payload.type === "clear") {
event.waitUntil(
(async () => {
const tagToClear = payload.tag;
if (tagToClear) {
const matches = await self.registration.getNotifications({
tag: tagToClear,
});
for (const n of matches) n.close();
}
applyAppBadge(payload.badge);
})(),
);
return;
}
const {
title = "HealthLog",
body = "",
tag = "default",
url = "/",
badge,
requireInteraction = false,
} = payload;
event.waitUntil(
Promise.all([
self.registration.showNotification(title, {
body,
tag,
// `renotify` lets a re-fired reminder for the same `tag` re-alert the
// user instead of silently replacing the existing notification.
renotify: true,
// v1.18.4 — urgent events (the web-push sender sets this) keep the
// notification on screen until the user acts, where the browser
// honours it.
requireInteraction: requireInteraction === true,
icon: "/logo-192.png",
badge: "/logo-192.png",
data: { url },
}),
Promise.resolve(applyAppBadge(badge)),
]),
);
});
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);
}),
);
});