Files
HealthLog/public/sw.js
T
Marc-André Bombeck 09f3280c1c fix(pwa): keep signed-in onboarding pages out of the page cache
The proxy treats /onboarding/* as public, so those pages are not sent
no-store, and the worker stored the documents a signed-in account saw
during setup, against its own rule that only public pages land in the
page cache. The worker now skips them by path, like the share view.
2026-11-11 20:40:32 +01:00

598 lines
25 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 — 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.47.2";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// Every HealthLog cache, current and legacy, and nothing another app on the
// origin owns. `data` stays in the pattern so caches from releases that still
// kept API reads are deleted on activate. Same pattern as
// `HEALTHLOG_CACHE_NAME_RE` in `src/lib/pwa/sw-cache-policy.ts`.
const HEALTHLOG_CACHE_NAME_RE = /^healthlog-(?:static|pages|data)-/;
const CURRENT_HEALTHLOG_CACHES = new Set([STATIC_CACHE, PAGE_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;
// 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;
}
// Every other API request goes to the network untouched: no API answer is
// kept on this device.
if (url.pathname.startsWith("/api/")) 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;
}
/**
* Which navigation responses may land in the page cache. Signed-in pages
* never do: the proxy sends them `private, no-store` because some carry one
* account's record in the HTML. Two paths are skipped by name as well: the
* clinician share view `/c/*` (no-store too; a revoked share must never
* render back offline) and `/onboarding/*`. What is left are the public
* pages (sign-in, legal), so a navigation to an app page without a
* connection gets the offline page, never somebody's cached record.
*/
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;
// Public to the proxy, so not no-store, but rendered for a signed-in
// account during its setup.
if (pathname === "/onboarding" || pathname.startsWith("/onboarding/")) {
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 from HealthLog's caches (chunks, at the time), 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() };
}
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 = "/";
}
const target = new URL(safeUrl, self.location.origin).href;
event.waitUntil(
self.clients
.matchAll({ type: "window", includeUncontrolled: true })
.then((clientList) => {
const windows = clientList.filter(
(c) => c.url.startsWith(self.location.origin + "/") && "focus" in c,
);
// A window already on the target only needs focus: navigating it
// would reload the app and drop whatever is open there.
const there = windows.find((c) => c.url === target);
if (there) return there.focus();
const client = windows[0];
if (!client) return self.clients.openWindow(safeUrl);
// Focus first, while the tap still counts as user activation.
return client
.focus()
.then(() => client.navigate(safeUrl))
.catch(() => self.clients.openWindow(safeUrl));
}),
);
});