mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
chore(pwa): cut the service worker's comments to what a reader needs
The worker is served as written, so every browser downloaded its release history: more than half of the file was comments, many of them dated notes about earlier states of the code. The comments now say what the code does and why, without release numbers. No code changes; the fallback version literal and its rewrite marker are untouched.
This commit is contained in:
1 parent
a824484d4a
commit
51827f43a6
1 file changed
+58
-183
+58
-183
@@ -1,39 +1,19 @@
|
||||
/**
|
||||
* Service Worker for HealthLog PWA.
|
||||
* Handles Web Push notifications and offline caching.
|
||||
* HealthLog service worker: Web Push, a page cache for public pages, and a
|
||||
* static cache for catalogs and icons. API answers and app chunks are never
|
||||
* stored here.
|
||||
*/
|
||||
|
||||
// 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.
|
||||
// Cache names carry the release, so `activate` drops every older cache. The
|
||||
// build writes the release to `/sw-version.js`; the literal below is the
|
||||
// fallback when that file is missing (dev without `prebuild`, a 404), and
|
||||
// `scripts/generate-sw-version.mjs` rewrites it at the marker on every
|
||||
// build. Do not edit it by hand.
|
||||
try {
|
||||
importScripts("/sw-version.js");
|
||||
} catch {
|
||||
// Fall through to the literal fallback below.
|
||||
// Fall through to the fallback literal.
|
||||
}
|
||||
// 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";
|
||||
@@ -45,31 +25,19 @@ const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
|
||||
// `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.
|
||||
// Set by `src/proxy.ts` on every HealthLog page; same name as 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.
|
||||
// While set, the fetch handler declines every request. Set when a page looks
|
||||
// like another app, cleared when `confirmForeignOrigin` finds HealthLog.
|
||||
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.
|
||||
// One confirmation at a time, shared by a burst of navigations.
|
||||
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.
|
||||
// Never `/`: installed while signed in, it would store the dashboard HTML.
|
||||
const PRECACHE_URLS = ["/logo-192.png", "/logo-512.png", "/favicon.svg"];
|
||||
|
||||
// ── Install: precache public immutable assets ────────────────────────────────
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
@@ -79,7 +47,6 @@ self.addEventListener("install", (event) => {
|
||||
);
|
||||
});
|
||||
|
||||
// ── Activate: clean up old caches ────────────────────────────────────────────
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
Promise.all([
|
||||
@@ -96,10 +63,8 @@ self.addEventListener("activate", (event) => {
|
||||
.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.
|
||||
// The browser starts the navigation while the worker boots;
|
||||
// `networkFirst` uses `event.preloadResponse`.
|
||||
self.registration.navigationPreload
|
||||
? self.registration.navigationPreload.enable()
|
||||
: Promise.resolve(),
|
||||
@@ -107,31 +72,17 @@ self.addEventListener("activate", (event) => {
|
||||
);
|
||||
});
|
||||
|
||||
// ── 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.
|
||||
// The OIDC login and callback are navigations the browser has already sent
|
||||
// as a preload. Declining here would make it send them a second time, and
|
||||
// the single-use code fails on the second. Answer with the preload (or one
|
||||
// fetch) and store nothing.
|
||||
if (url.pathname.startsWith("/api/auth/")) {
|
||||
event.respondWith(
|
||||
(async () => (await event.preloadResponse) || fetch(request))(),
|
||||
@@ -143,35 +94,24 @@ self.addEventListener("fetch", (event) => {
|
||||
// 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.
|
||||
// Locale catalog: the URL carries the catalogs' content hash, so a change
|
||||
// is a new URL. Public strings only.
|
||||
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;
|
||||
@@ -215,30 +155,11 @@ function isCacheableNavigation(request, response) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Does this page come from another app on the same origin (a developer's
|
||||
* `localhost:3000`, say)? Only a successful page without the proxy's marker
|
||||
* counts: a redirect is opaque, and a 401 or 502 from a gateway in front of a
|
||||
* stopped HealthLog proves nothing. Still only a suspicion, because a proxy
|
||||
* with a header allowlist strips the marker; `confirmForeignOrigin` decides.
|
||||
*/
|
||||
function isForeignNavigationResponse(response) {
|
||||
if (!response || !response.ok) return false;
|
||||
@@ -246,30 +167,15 @@ function isForeignNavigationResponse(response) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Is the origin positively not HealthLog? Asks the public `/api/version`,
|
||||
* whose body (`data.version` a string, `data.buildSha` present, possibly
|
||||
* null) survives any header rewriting. A 404, non-JSON or another shape is
|
||||
* foreign; a thrown fetch, a 5xx, a 401 or a redirect proves nothing.
|
||||
*
|
||||
* 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.
|
||||
* An auth gateway that answers an expired session with a 200 sign-in page
|
||||
* still reads as foreign, which is why `retireFromForeignOrigin` keeps a
|
||||
* worker that holds a push subscription. The gateway cookie is sent, so a
|
||||
* valid gateway session reaches HealthLog.
|
||||
*/
|
||||
async function confirmForeignOrigin() {
|
||||
let response;
|
||||
@@ -299,9 +205,7 @@ async function confirmForeignOrigin() {
|
||||
return !isHealthlog;
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm, then either retire or resume. Shared by concurrent navigations.
|
||||
*/
|
||||
/** Confirm, then retire or resume. Shared by concurrent navigations. */
|
||||
function checkForeignOrigin() {
|
||||
if (!foreignOriginCheck) {
|
||||
foreignOriginCheck = (async () => {
|
||||
@@ -315,8 +219,7 @@ function checkForeignOrigin() {
|
||||
await retireFromForeignOrigin();
|
||||
return;
|
||||
}
|
||||
// HealthLog after all, or no answer: serve normally again, and let a
|
||||
// later suspicious navigation ask again.
|
||||
// HealthLog after all, or no answer: resume, and ask again next time.
|
||||
foreignOrigin = false;
|
||||
foreignOriginCheck = null;
|
||||
})();
|
||||
@@ -325,11 +228,9 @@ function checkForeignOrigin() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Whether this registration holds a Web Push subscription, which
|
||||
* unregistering would destroy without notice. When unsure, yes: a
|
||||
* pass-through worker costs nothing, lost reminders do.
|
||||
*/
|
||||
async function hasPushSubscription() {
|
||||
try {
|
||||
@@ -344,18 +245,11 @@ async function hasPushSubscription() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Leave an origin that no longer serves HealthLog: delete HealthLog's caches
|
||||
* (never another app's) and unregister. With a push subscription it stays
|
||||
* registered in pass-through instead (`foreignOrigin` stays set), and the
|
||||
* next worker start probes again. Each step is independent: a failed delete
|
||||
* must not keep the worker registered.
|
||||
*/
|
||||
async function retireFromForeignOrigin() {
|
||||
try {
|
||||
@@ -366,7 +260,7 @@ async function retireFromForeignOrigin() {
|
||||
.map((k) => caches.delete(k)),
|
||||
);
|
||||
} catch {
|
||||
// CacheStorage unavailable; unregistering still matters more.
|
||||
// CacheStorage unavailable; unregistering matters more.
|
||||
}
|
||||
if (await hasPushSubscription()) return;
|
||||
try {
|
||||
@@ -390,8 +284,7 @@ async function networkFirst(event, cacheName) {
|
||||
(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.
|
||||
// Step aside at once; delete nothing until it is confirmed.
|
||||
foreignOrigin = true;
|
||||
event.waitUntil(checkForeignOrigin());
|
||||
return response;
|
||||
@@ -431,10 +324,8 @@ const OFFLINE_PAGE =
|
||||
'<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.
|
||||
* Store a response, then trim. Failures (quota, eviction) are swallowed: a
|
||||
* cache write must never replace a response that already succeeded.
|
||||
*/
|
||||
async function bestEffortCacheWrite(
|
||||
cacheName,
|
||||
@@ -447,15 +338,11 @@ async function bestEffortCacheWrite(
|
||||
await cache.put(request, response);
|
||||
await trimCache(cacheName, maxEntries);
|
||||
} catch {
|
||||
// CacheStorage is an optional optimization.
|
||||
// Optional.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
/** Drop the oldest entries past `maxEntries`; `keys()` is insertion order. */
|
||||
async function trimCache(cacheName, maxEntries) {
|
||||
const cache = await caches.open(cacheName);
|
||||
const keys = await cache.keys();
|
||||
@@ -465,11 +352,8 @@ async function trimCache(cacheName, maxEntries) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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.
|
||||
// The outstanding-dose count on the app icon, where the Badging API exists.
|
||||
// 0 clears it; anything that is not a number leaves it alone.
|
||||
function applyAppBadge(count) {
|
||||
if (typeof count !== "number" || !isFinite(count)) return;
|
||||
try {
|
||||
@@ -481,12 +365,10 @@ function applyAppBadge(count) {
|
||||
self.navigator.clearAppBadge();
|
||||
}
|
||||
} catch {
|
||||
// Badging API can reject (permission / unsupported) — never let it
|
||||
// break the push handler.
|
||||
// Never let the badge break the push handler.
|
||||
}
|
||||
}
|
||||
|
||||
// ── Push Notifications ───────────────────────────────────────────────────────
|
||||
self.addEventListener("push", (event) => {
|
||||
if (!event.data) return;
|
||||
|
||||
@@ -511,12 +393,9 @@ self.addEventListener("push", (event) => {
|
||||
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.
|
||||
// A re-sent reminder with the same tag alerts again.
|
||||
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.
|
||||
// Set by the sender for urgent events, where the browser honours it.
|
||||
requireInteraction: requireInteraction === true,
|
||||
icon: "/logo-192.png",
|
||||
badge: "/logo-192.png",
|
||||
@@ -530,12 +409,8 @@ self.addEventListener("push", (event) => {
|
||||
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 "/".
|
||||
// Only ever navigate within this origin: a server bug or a compromised
|
||||
// sender must not turn a tap into a redirect elsewhere.
|
||||
const rawUrl = event.notification.data?.url || "/";
|
||||
let safeUrl = "/";
|
||||
try {
|
||||
|
||||
Reference in new issue
Block a user