mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
754 lines
31 KiB
JavaScript
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 — 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.1";
|
|
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);
|
|
}),
|
|
);
|
|
});
|