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.