PageSourceSearch

https://www.onpointbusiness.co.uk/sw.js

js onpointbusiness.co.uk collected 2026-10-02 02:11:33 UTC 22,418 bytes, 625 lines download raw bytes

1/**
2 * OnPoint Service Worker
3 *
4 * Goals:
5 * - Prevent "blank screen after deploy" caused by stale SW caches from older builds
6 * - Cache Next.js hashed static assets safely (/_next/static/*)
7 * - Cache page shells and RSC payloads so navigation works offline and on poor connections
8 * - On activate we wipe only old caches and preserve core pages so cold start offline works
9 *
10 * Storage safety (Documents & Data bloat):
11 * - Cache keys are normalized to strip volatile query params (signed-URL tokens, _viewerRetry,
12 *   X-Amz-*) so the same photo doesn't get stored once per token refresh.
13 * - Responses larger than MAX_RESPONSE_BYTES are not cached.
14 * - When a cache exceeds its size cap, oldest entries are evicted (FIFO).
15 * - If device storage is tight, new entries are not cached.
16 * - Cache sizes are tracked in a small sidecar IndexedDB.
17 */
18
19// Version is auto-injected at build time (see scripts/bump-pwa-version.mjs).
20const VERSION = "onpoint-sw-d8fe58f";
21
22// SHELL holds page shells, hashed Next.js static, fonts, JSON, RSC payloads.
23// MEDIA holds images (same and cross origin) — bigger budget, evictable.
24// Keeping a separate cache for media means image churn can't evict the offline shells.
25const SHELL_CACHE = `${VERSION}-shell`;
26const MEDIA_CACHE = `${VERSION}-media`;
27// Backward-compat alias (older code paths referenced ASSET_CACHE).
28const ASSET_CACHE = SHELL_CACHE;
29
30const OFFLINE_URL = "/offline.html";
31const RESET_URL = "/pwa-reset.html";
32
33// Per-cache soft size caps. When exceeded, oldest entries are evicted until
34// we're below EVICT_TARGET_RATIO of the cap.
35const CACHE_LIMITS = {
36  [SHELL_CACHE]: 60 * 1024 * 1024,   //  60 MB — shells, hashed JS/CSS, fonts, RSC
37  [MEDIA_CACHE]: 150 * 1024 * 1024,  // 150 MB — images
38};
39const MAX_RESPONSE_BYTES = 10 * 1024 * 1024; // do not cache responses > 10 MB
40const MIN_FREE_BYTES = 200 * 1024 * 1024;    // do not cache when device free < 200 MB
41const EVICT_TARGET_RATIO = 0.85;
42
43// Timeout for navigation fetch so poor connections fall back to cache quickly (ms).
44// Shorter on purpose so iOS Capacitor cold start offline hits cache fast instead of hanging.
45const NAV_FETCH_TIMEOUT = 3000;
46// Sentinel returned by the navigation timeout so it can be told apart from a
47// real Response without ever being mistaken for one.
48const NAV_TIMEOUT_MARKER = Symbol("nav-timeout");
49// Core shells for cold start offline. Use full URLs so cache keys are consistent across install/activate.
50function getCorePageUrls(origin) {
51  return [
52    origin + "/",
53    origin + "/sign-in",
54    origin + "/dashboard",
55    origin + OFFLINE_URL,
56    origin + RESET_URL,
57  ];
58}
59// Timeout for RSC fetch so we fall back to cache when offline/slow (ms).
60const RSC_FETCH_TIMEOUT = 5000;
61
62// ----- URL normalization ---------------------------------------------------
63// Volatile query params that should NOT be part of the cache key. Without
64// stripping these, every signed-URL refresh (Supabase rotates every 60s) and
65// every viewer retry produces a brand new cache entry for the same photo —
66// which is the dominant source of multi-GB cache bloat on iOS.
67const STRIP_PARAMS = new Set([
68  "_viewerRetry",
69  "_rsc",
70  "token",
71  "expires",
72  "expires_in",
73  "signature",
74]);
75const STRIP_PARAM_PREFIXES = ["x-amz-", "x-goog-", "x-ms-"];
76
77function normalizeCacheUrl(rawUrl) {
78  try {
79    const u = new URL(rawUrl);
80    const toDelete = [];
81    u.searchParams.forEach((_value, key) => {
82      const lower = key.toLowerCase();
83      if (STRIP_PARAMS.has(key) || STRIP_PARAMS.has(lower)) {
84        toDelete.push(key);
85        return;
86      }
87      for (const prefix of STRIP_PARAM_PREFIXES) {
88        if (lower.startsWith(prefix)) {
89          toDelete.push(key);
90          return;
91        }
92      }
93    });
94    for (const k of toDelete) u.searchParams.delete(k);
95    return u.toString();
96  } catch {
97    return rawUrl;
98  }
99}
100
101function makeStableCacheKey(request) {
102  const normalized = normalizeCacheUrl(request.url);
103  if (normalized === request.url) return request;
104  return new Request(normalized, { method: "GET" });
105}
106
107// ----- Metadata IndexedDB (size + insertion order for eviction) ------------
108const METADATA_DB = "onpoint_sw_cache_metadata";
109let _metaDbPromise = null;
110
111function openMetadataDb() {
112  if (_metaDbPromise) return _metaDbPromise;
113  _metaDbPromise = new Promise((resolve, reject) => {
114    let req;
115    try {
116      req = indexedDB.open(METADATA_DB, 1);
117    } catch (err) {
118      reject(err);
119      return;
120    }
121    req.onupgradeneeded = () => {
122      const db = req.result;
123      if (!db.objectStoreNames.contains("entries")) {
124        const store = db.createObjectStore("entries", { keyPath: "key" });
125        store.createIndex("cache_inserted", ["cache", "inserted"]);
126      }
127    };
128    req.onsuccess = () => resolve(req.result);
129    req.onerror = () => reject(req.error);
130  }).catch((err) => {
131    // Reset so a later attempt can retry (e.g. after private-mode → normal).
132    _metaDbPromise = null;
133    throw err;
134  });
135  return _metaDbPromise;
136}
137
138async function recordCachedEntry(cacheName, normalizedUrl, size) {
139  try {
140    const db = await openMetadataDb();
141    await new Promise((resolve, reject) => {
142      const tx = db.transaction("entries", "readwrite");
143      tx.objectStore("entries").put({
144        key: `${cacheName}:${normalizedUrl}`,
145        cache: cacheName,
146        url: normalizedUrl,
147        size: size || 0,
148        inserted: Date.now(),
149      });
150      tx.oncomplete = resolve;
151      tx.onerror = () => reject(tx.error);
152      tx.onabort = () => reject(tx.error);
153    });
154  } catch {
155    // Metadata is non-authoritative — caching still works without it; the
156    // entry just won't be tracked for eviction. Acceptable failure mode.
157  }
158}
159
160async function forgetCachedEntry(cacheName, normalizedUrl) {
161  try {
162    const db = await openMetadataDb();
163    await new Promise((resolve) => {
164      const tx = db.transaction("entries", "readwrite");
165      tx.objectStore("entries").delete(`${cacheName}:${normalizedUrl}`);
166      tx.oncomplete = resolve;
167      tx.onerror = resolve;
168      tx.onabort = resolve;
169    });
170  } catch {}
171}
172
173async function listCacheEntriesOldestFirst(cacheName) {
174  try {
175    const db = await openMetadataDb();
176    return await new Promise((resolve) => {
177      const tx = db.transaction("entries", "readonly");
178      const idx = tx.objectStore("entries").index("cache_inserted");
179      const range = IDBKeyRange.bound(
180        [cacheName, 0],
181        [cacheName, Number.MAX_SAFE_INTEGER]
182      );
183      const items = [];
184      idx.openCursor(range).onsuccess = (e) => {
185        const cursor = e.target.result;
186        if (cursor) {
187          items.push(cursor.value);
188          cursor.continue();
189        } else {
190          resolve(items);
191        }
192      };
193      tx.onerror = () => resolve([]);
194      tx.onabort = () => resolve([]);
195    });
196  } catch {
197    return [];
198  }
199}
200
201async function dropMetadataForCaches(cacheNames) {
202  if (!cacheNames || cacheNames.length === 0) return;
203  try {
204    const db = await openMetadataDb();
205    for (const cacheName of cacheNames) {
206      await new Promise((resolve) => {
207        const tx = db.transaction("entries", "readwrite");
208        const idx = tx.objectStore("entries").index("cache_inserted");
209        const range = IDBKeyRange.bound(
210          [cacheName, 0],
211          [cacheName, Number.MAX_SAFE_INTEGER]
212        );
213        idx.openCursor(range).onsuccess = (e) => {
214          const cursor = e.target.result;
215          if (cursor) {
216            cursor.delete();
217            cursor.continue();
218          }
219        };
220        tx.oncomplete = resolve;
221        tx.onerror = resolve;
222        tx.onabort = resolve;
223      });
224    }
225  } catch {}
226}
227
228// ----- Quota check ---------------------------------------------------------
229async function isQuotaTight() {
230  try {
231    if (!self.navigator || !self.navigator.storage || !self.navigator.storage.estimate) {
232      return false;
233    }
234    const { quota, usage } = await self.navigator.storage.estimate();
235    if (!quota) return false;
236    return quota - (usage || 0) < MIN_FREE_BYTES;
237  } catch {
238    return false;
239  }
240}
241
242// ----- Eviction ------------------------------------------------------------
243let _evictionInFlight = new Set();
244
245async function evictIfOverLimit(cacheName) {
246  const limit = CACHE_LIMITS[cacheName];
247  if (!limit) return;
248  if (_evictionInFlight.has(cacheName)) return;
249  _evictionInFlight.add(cacheName);
250  try {
251    const entries = await listCacheEntriesOldestFirst(cacheName);
252    let total = entries.reduce((s, e) => s + (e.size || 0), 0);
253    if (total <= limit) return;
254
255    const target = Math.floor(limit * EVICT_TARGET_RATIO);
256    const cache = await caches.open(cacheName);
257    for (const entry of entries) {
258      if (total <= target) break;
259      try {
260        await cache.delete(entry.url);
261      } catch {}
262      await forgetCachedEntry(cacheName, entry.url);
263      total -= entry.size || 0;
264    }
265  } finally {
266    _evictionInFlight.delete(cacheName);
267  }
268}
269
270// ----- Cache put with size accounting --------------------------------------
271async function safeCachePut(cacheName, request, response) {
272  if (!response) return;
273  if (!response.ok && response.type !== "opaque") return;
274
275  const declaredLen = parseInt(response.headers.get("content-length") || "0", 10);
276  if (Number.isFinite(declaredLen) && declaredLen > MAX_RESPONSE_BYTES) {
277    return; // too big — re-fetch on demand
278  }
279
280  if (await isQuotaTight()) {
281    // Device is low on storage — don't make it worse. Try to evict instead.
282    evictIfOverLimit(cacheName).catch(() => {});
283    return;
284  }
285
286  try {
287    const cache = await caches.open(cacheName);
288    await cache.put(request, response.clone());
289
290    let size = Number.isFinite(declaredLen) && declaredLen > 0 ? declaredLen : 0;
291    if (!size) {
292      // Opaque cross-origin responses don't expose content-length. Read the
293      // body of a fresh match() — caches return cloned responses, so reading
294      // here doesn't consume the cached copy.
295      try {
296        const sized = await cache.match(request);
297        if (sized) {
298          const blob = await sized.blob();
299          size = blob.size;
300        }
301      } catch {}
302    }
303
304    // Retroactive cap (covers opaque responses where we couldn't tell up front).
305    if (size > MAX_RESPONSE_BYTES) {
306      try {
307        await cache.delete(request);
308      } catch {}
309      return;
310    }
311
312    await recordCachedEntry(cacheName, request.url, size);
313    evictIfOverLimit(cacheName).catch(() => {});
314  } catch (err) {
315    // QuotaExceededError or similar — kick off an emergency eviction so the
316    // next attempt has room.
317    evictIfOverLimit(cacheName).catch(() => {});
318  }
319}
320
321// ----- Strategies ----------------------------------------------------------
322async function cacheFirst(request, cacheName) {
323  const cache = await caches.open(cacheName);
324  const stableKey = makeStableCacheKey(request);
325  const cached = await cache.match(stableKey);
326  if (cached) return cached;
327
328  const response = await fetch(request);
329  // Fire and forget — don't block the response on cache I/O.
330  safeCachePut(cacheName, stableKey, response).catch(() => {});
331  return response;
332}
333
334function makeNormalizedRscCacheKey(request) {
335  const url = new URL(request.url);
336  const normalized = new URL(url.pathname, url.origin);
337
338  // Preserve query params except for the random _rsc value.
339  for (const [k, v] of url.searchParams.entries()) {
340    if (k === "_rsc") continue;
341    normalized.searchParams.append(k, v);
342  }
343
344  // Stable marker so all _rsc variants map to the same cache key.
345  normalized.searchParams.set("_rsc", "1");
346
347  return new Request(normalized.toString(), {
348    method: "GET",
349    headers: request.headers,
350  });
351}
352
353// Network-first with timeout then cache. Prevents blank screens after deploy when
354// an old SW would serve stale HTML/RSC; cache is only used when offline/slow.
355async function networkFirstRsc(request) {
356  const cache = await caches.open(SHELL_CACHE);
357  const cacheKey = makeNormalizedRscCacheKey(request);
358  try {
359    const controller = new AbortController();
360    const timeoutId = setTimeout(() => controller.abort(), RSC_FETCH_TIMEOUT);
361    const response = await fetch(request, { signal: controller.signal });
362    clearTimeout(timeoutId);
363    if (response && response.ok) {
364      safeCachePut(SHELL_CACHE, cacheKey, response).catch(() => {});
365    }
366    return response;
367  } catch {
368    const cached = await cache.match(cacheKey);
369    if (cached) return cached;
370    return new Response("Service Unavailable", { status: 503 });
371  }
372}
373
374// Try to get a cached navigation response; browsers and PWAs can use different URL forms
375// (e.g. with/without trailing slash) so try several variants so cold start offline works.
376// Only try root (base / base+"/") when the requested path is actually root – otherwise we
377// would serve the root page for e.g. /dashboard/certificates and "kick" the user to root.
378async function getCachedPage(cache, url) {
379  const u = new URL(url, self.location.origin);
380  const path = u.pathname || "/";
381  const base = u.origin;
382  const withSlash = path.endsWith("/") ? path : path + "/";
383  const noSlash = path.endsWith("/") && path.length > 1 ? path.slice(0, -1) : path;
384  const candidates = [
385    url,
386    base + noSlash,
387    base + noSlash + "/",
388    base + withSlash,
389    base + (path === "/" ? "" : path),
390  ];
391  if (path === "/" || path === "") {
392    candidates.push(base + "/", base);
393  }
394  for (const candidate of candidates) {
395    const c = await cache.match(candidate);
396    if (c) return c;
397  }
398  return undefined;
399}
400
401self.addEventListener("install", (event) => {
402  event.waitUntil(
403    (async () => {
404      const origin = self.location.origin;
405      const coreUrls = getCorePageUrls(origin);
406      const cache = await caches.open(SHELL_CACHE);
407      await cache.addAll(coreUrls);
408      // iOS/WebViews often request root as origin with no path (e.g. https://example.com).
409      // Store root under both origin+"/" and origin so cold start always finds it.
410      const rootResponse = await cache.match(origin + "/");
411      if (rootResponse) {
412        await cache.put(origin, rootResponse.clone());
413      }
414      await self.skipWaiting();
415    })(),
416  );
417});
418
419self.addEventListener("message", (event) => {
420  // Allow the page to request immediate activation.
421  if (event?.data?.type === "SKIP_WAITING") {
422    self.skipWaiting();
423  }
424});
425
426// Web Push: show notification and handle click (PWA / Android)
427self.addEventListener("push", (event) => {
428  if (!event.data) return;
429  let payload = { title: "OnPoint", body: "", url: "/" };
430  try {
431    const data = event.data.json();
432    payload = { ...payload, ...data };
433  } catch {
434    payload.body = event.data.text() || "";
435  }
436  event.waitUntil(
437    self.registration.showNotification(payload.title, {
438      body: payload.body,
439      icon: "/icon-192x192.png",
440      badge: "/icon-96.webp",
441      data: { url: payload.url || "/" },
442      tag: payload.tag || "onpoint-push",
443      renotify: true,
444    })
445  );
446});
447
448self.addEventListener("notificationclick", (event) => {
449  event.notification.close();
450  const path = event.notification.data?.url || "/";
451  const fullUrl = path.startsWith("http") ? path : new URL(path, self.location.origin).href;
452  event.waitUntil(
453    self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clientList) => {
454      for (const client of clientList) {
455        if (client.url.includes(self.location.origin) && "focus" in client) {
456          client.navigate(fullUrl);
457          return client.focus();
458        }
459      }
460      if (self.clients.openWindow) return self.clients.openWindow(fullUrl);
461    })
462  );
463});
464
465self.addEventListener("activate", (event) => {
466  event.waitUntil(
467    (async () => {
468      const origin = self.location.origin;
469      const coreUrls = getCorePageUrls(origin);
470      const keys = await caches.keys();
471      const kept = new Set([SHELL_CACHE, MEDIA_CACHE]);
472
473      // Open current shell cache and copy core pages from any *old* cache so
474      // cold start offline still works when addAll fails (e.g. after deploy when offline).
475      const cache = await caches.open(SHELL_CACHE);
476      for (const url of coreUrls) {
477        for (const key of keys) {
478          if (kept.has(key)) continue;
479          const oldCache = await caches.open(key);
480          const response = await oldCache.match(url);
481          if (response) {
482            await cache.put(url, response);
483            break;
484          }
485        }
486      }
487
488      // Delete every cache that isn't part of the current version. This is
489      // what frees the multi-GB of stale entries from previous SW versions on
490      // existing devices the first time the new SW activates.
491      const toDelete = keys.filter((k) => !kept.has(k));
492      await Promise.all(toDelete.map((k) => caches.delete(k)));
493      await dropMetadataForCaches(toDelete);
494
495      // Refresh core pages when online so deploy gets fresh HTML.
496      try {
497        await cache.addAll(coreUrls);
498      } catch {
499        // Offline: core pages already in cache from copy above.
500      }
501
502      // iOS/WebViews: ensure root is under both origin and origin+"/".
503      const rootResponse = await cache.match(origin + "/");
504      if (rootResponse) await cache.put(origin, rootResponse.clone());
505
506      await self.clients.claim();
507    })(),
508  );
509});
510
511self.addEventListener("fetch", (event) => {
512  const { request } = event;
513  if (request.method !== "GET") return;
514
515  const url = new URL(request.url);
516  // Cross-origin images: cache-first (opaque allowed) so logos/media can still
517  // render offline after being seen once. Goes to MEDIA_CACHE with eviction.
518  if (url.origin !== self.location.origin) {
519    if (request.destination === "image") {
520      event.respondWith(cacheFirst(request, MEDIA_CACHE));
521    }
522    return;
523  }
524
525  // Next.js App Router RSC payloads (same-origin XHR/fetch with ?_rsc=...).
526  // Network-first so we never serve stale RSC after deploy (avoids blank screen); cache fallback when offline.
527  if (url.searchParams.has("_rsc")) {
528    event.respondWith(networkFirstRsc(request));
529    return;
530  }
531
532  // Next.js Image Optimization endpoint (same-origin, query-based).
533  // This is how most images load in production, so cache it explicitly.
534  if (url.pathname === "/_next/image") {
535    event.respondWith(cacheFirst(request, MEDIA_CACHE));
536    return;
537  }
538
539  // Navigations: always network-first with timeout, then cache. Avoids serving stale
540  // HTML after deploy (blank screen on Vercel); cache only used when offline/slow.
541  if (request.mode === "navigate") {
542    event.respondWith(
543      (async () => {
544        const cache = await caches.open(SHELL_CACHE);
545        const pathname = url.pathname || "/";
546        const origin = url.origin;
547
548        // One real, *untimed* navigation request. The timeout below only decides
549        // whether we paint a cached page early on slow connections — it must
550        // never, on its own, surface the offline page while this request is
551        // still in flight. Otherwise a slow-but-online navigation to a fresh,
552        // uncached, server-rendered page (e.g. /create-account/verify-email right
553        // after the signup + verification-email API calls) wrongly shows the
554        // "You're offline" screen, and "Try again" only works because the retry
555        // is faster / now cached.
556        const networkFetch = fetch(request);
557        // Cache successful responses regardless of which path returns below.
558        networkFetch
559          .then((res) => {
560            if (res && res.ok) safeCachePut(SHELL_CACHE, request, res).catch(() => {});
561          })
562          .catch(() => {});
563
564        let timeoutId;
565        const timeout = new Promise((resolve) => {
566          timeoutId = setTimeout(() => resolve(NAV_TIMEOUT_MARKER), NAV_FETCH_TIMEOUT);
567        });
568        try {
569          const raced = await Promise.race([networkFetch, timeout]);
570          if (raced !== NAV_TIMEOUT_MARKER) {
571            clearTimeout(timeoutId);
572            return raced;
573          }
574          // Timed out, but the network may still be working. Prefer a cached
575          // page for a fast paint; otherwise wait for the real response rather
576          // than declaring the user offline.
577          const cachedPage = await getCachedPage(cache, request.url);
578          if (cachedPage) {
579            clearTimeout(timeoutId);
580            return cachedPage;
581          }
582          const response = await networkFetch; // rejects only if genuinely offline
583          clearTimeout(timeoutId);
584          return response;
585        } catch {
586          clearTimeout(timeoutId);
587          // Genuine network failure: use cache so cold start still works.
588          const cachedPage = await getCachedPage(cache, request.url);
589          if (cachedPage) return cachedPage;
590          if (pathname === "/pwa-reset" || pathname === "/pwa-reset.html") {
591            const reset = await cache.match(origin + RESET_URL) || await cache.match(RESET_URL);
592            if (reset) return reset;
593          }
594          // Always have a fallback so iOS Capacitor never shows a blank screen.
595          const offline = await cache.match(origin + OFFLINE_URL) || await cache.match(OFFLINE_URL);
596          const fallback =
597            offline ||
598            new Response(
599              "<!DOCTYPE html><html><head><meta charset=utf-8><meta name=viewport content='width=device-width,initial-scale=1'><title>Offline</title></head><body style='font-family:system-ui;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0'><p>You're offline. Open the app when online once to enable offline use.</p></body></html>",
600              { status: 503, headers: { "Content-Type": "text/html; charset=utf-8" } }
601            );
602          return fallback;
603        }
604      })(),
605    );
606    return;
607  }
608
609  // Next.js build assets are content-hashed and safe to cache.
610  if (url.pathname.startsWith("/_next/static/")) {
611    event.respondWith(cacheFirst(request, SHELL_CACHE));
612    return;
613  }
614
615  // Same-origin image extensions → media cache (evictable).
616  if (/\.(?:png|jpe?g|gif|webp|svg|ico)$/i.test(url.pathname)) {
617    event.respondWith(cacheFirst(request, MEDIA_CACHE));
618    return;
619  }
620
621  // Same-origin code/style/font/data → shell cache.
622  if (/\.(?:css|js|mjs|woff2?|json)$/i.test(url.pathname)) {
623    event.respondWith(cacheFirst(request, SHELL_CACHE));
624  }
625});

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.