1/** 2 * Storefront service worker. 3 * 4 * Only public shell/static assets may be cached. Authenticated, account, 5 * commerce, and API traffic always stays on the browser/network path. 6 */ 7 8importScripts("/sw-version.js"); 9 10const BUILD_VERSION = self.__INFINITY_BUILD_VERSION__ || "v1-dev"; 11 12// Generate cache names with build version 13function getCacheNames() { 14 return { 15 RUNTIME_CACHE: `runtime-cache-v${BUILD_VERSION}`, 16 }; 17} 18 19// URLs to cache on install 20const URLS_TO_CACHE = ["/", "/offline.html", "/manifest.json"]; 21 22const SENSITIVE_PATH_PREFIXES = [ 23 "/api", 24 "/auth", 25 "/account", 26 "/addresses", 27 "/cart", 28 "/checkout", 29 "/favorites", 30 "/orders", 31 "/password", 32 "/payment", 33 "/privileges", 34 "/super-admin", 35 "/wallet", 36]; 37 38function isSensitivePath(pathname) { 39 return SENSITIVE_PATH_PREFIXES.some( 40 (prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`), 41 ); 42} 43 44/** 45 * Install event: cache essential files 46 */ 47self.addEventListener("install", (event) => { 48 console.log("[SW] Installing service worker..."); 49 const { RUNTIME_CACHE } = getCacheNames(); 50 51 event.waitUntil( 52 Promise.all([ 53 // Load PWA clients from IndexedDB on install 54 loadPWAClientsFromIDB(), 55 // Cache essential files 56 caches 57 .open(RUNTIME_CACHE) 58 .then((cache) => { 59 return cache.addAll(URLS_TO_CACHE).catch((err) => { 60 console.log("[SW] Some files failed to cache:", err); 61 // Don't fail the install if some files fail 62 return Promise.resolve(); 63 }); 64 }), 65 ]).then(() => self.skipWaiting()), 66 ); 67}); 68 69/** 70 * Delete old caches that don't match current version 71 */ 72async function deleteOldCaches() { 73 try { 74 const { RUNTIME_CACHE } = getCacheNames(); 75 const currentCaches = [RUNTIME_CACHE]; 76 77 const cacheNames = await caches.keys(); 78 const deletePromises = cacheNames 79 .filter((cacheName) => !currentCaches.includes(cacheName)) 80 .map((cacheName) => { 81 console.log("[SW] Deleting old cache:", cacheName); 82 return caches.delete(cacheName); 83 }); 84 85 await Promise.all(deletePromises); 86 } catch (error) { 87 console.error("[SW] Error deleting old caches:", error); 88 } 89} 90 91/** 92 * Activate event: clean up old caches 93 */ 94self.addEventListener("activate", (event) => { 95 console.log("[SW] Activating service worker..."); 96 97 // Compute the full Promise chain first 98 const activationPromise = deleteOldCaches().then(() => self.clients.claim()); 99 100 // Then pass the complete promise to waitUntil 101 event.waitUntil(activationPromise); 102}); 103 104/** 105 * Fetch event: handle requests with caching strategies 106 */ 107self.addEventListener("fetch", (event) => { 108 const { request } = event; 109 const url = new URL(request.url); 110 111 // Skip non-GET requests and non-http(s) schemes 112 if (request.method !== "GET" || !url.protocol.startsWith("http")) { 113 return; 114 } 115 116 // Never intercept cross-origin requests (e.g. api.infinitycolor.co). 117 // Let the browser perform them so CORS is handled by the real API response. 118 // Otherwise the SW can return 503 on failure without CORS headers â CORS error in console. 119 try { 120 if (url.origin !== self.origin) { 121 return; 122 } 123 } catch (_) { 124 return; 125 } 126 127 // The release probe and the worker's own version assignment must always hit 128 // the network. Cache-first `.js` handling would otherwise pin `sw-version.js`, 129 // and a generic network-first intercept of `build-version.json` can still 130 // replay a cached copy when the origin is briefly unreachable during a roll. 131 if ( 132 url.pathname === "/build-version.json" || 133 url.pathname === "/sw-version.js" || 134 url.pathname === "/sw.js" 135 ) { 136 return; 137 } 138 139 // Sensitive data and authorization-aware routes must never be intercepted, 140 // cached, replayed, or replaced with synthetic service-worker responses. 141 if (request.headers.has("Authorization") || isSensitivePath(url.pathname)) { 142 return; 143 } 144 145 // Next.js owns cache busting and image optimization for these assets. 146 if ( 147 url.pathname.startsWith("/_next/static/") || 148 url.pathname.startsWith("/_next/chunks/") || 149 url.pathname.startsWith("/_next/image") 150 ) { 151 // Let these requests pass through without service worker interception 152 return; 153 } 154 155 // Handle static assets with cache-first strategy 156 if (isStaticAsset(url.pathname)) { 157 event.respondWith(handleStaticAsset(request)); 158 return; 159 } 160 161 // Navigations are intercepted for exactly one reason: serving `offline.html` 162 // to an INSTALLED PWA that is genuinely offline. For an ordinary browser tab 163 // `pwaModeClients` is empty, so there is nothing this worker can add to a 164 // navigation -- and everything to lose. Mediating it meant a transport-level 165 // failure (a blocking extension, a reset connection, flaky DNS) was answered 166 // with a synthetic 503 reading "Page not available", which the browser treats 167 // as a *successful* response: no native error page naming the real cause, no 168 // reload affordance, no automatic recovery. Handing navigations back to the 169 // browser is what makes the real error visible to the visitor and to a bug 170 // report. 171 if (isNavigationRequest(request) && pwaModeClients.size === 0) { 172 return; 173 } 174 175 // Handle other requests with network-first strategy + offline fallback 176 event.respondWith(handleGenericRequest(request)); 177}); 178 179/** 180 * Handle static assets (cache-first with network fallback) 181 * Images, CSS, JS, etc. 182 * For images, we use network-first to ensure they load properly 183 */ 184async function handleStaticAsset(request) { 185 const { RUNTIME_CACHE } = getCacheNames(); 186 187 // For images, try network first to ensure they load properly 188 // This prevents stale cache from blocking image loading 189 if (request.destination === "image" || isImagePath(request.url)) { 190 try { 191 const response = await fetch(request); 192 193 // Cache successful responses 194 if (response.ok) { 195 const cache = await caches.open(RUNTIME_CACHE); 196 cache.put(request, response.clone()); 197 } 198 199 return response; 200 } catch (error) {
201 // If network fails, try cache 202 const cached = await caches.match(request); 203 if (cached) { 204 return cached; 205 } 206 207 // Only return placeholder if truly offline and no cache 208 console.log("[SW] Failed to fetch image:", request.url); 209 // Return a transparent 1x1 PNG placeholder to avoid unhandled rejection 210 // This prevents breaking offline UX while still allowing Next.js Image to handle gracefully 211 return new Response( 212 new Uint8Array([ 213 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 214 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 215 0x89, 0x00, 0x00, 0x00, 0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00, 216 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 217 0x42, 0x60, 0x82 218 ]), 219 { 220 status: 200, 221 statusText: "OK", 222 headers: { "Content-Type": "image/png" }, 223 } 224 ); 225 } 226 } 227 228 // For other static assets, use cache-first 229 const cached = await caches.match(request); 230 if (cached) { 231 return cached; 232 } 233 234 try { 235 const response = await fetch(request); 236 237 // Cache successful responses 238 if (response.ok) { 239 const cache = await caches.open(RUNTIME_CACHE); 240 cache.put(request, response.clone()); 241 } 242 243 return response; 244 } catch (error) { 245 console.log("[SW] Failed to fetch static asset:", request.url); 246 // For other assets, return error 247 return new Response("Asset not available offline", { 248 status: 503, 249 statusText: "Service Unavailable", 250 }); 251 } 252} 253 254/** 255 * Check if URL is an image path 256 */ 257function isImagePath(url) { 258 const imageExtensions = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".avif"]; 259 try { 260 const urlObj = new URL(url); 261 return imageExtensions.some((ext) => urlObj.pathname.toLowerCase().endsWith(ext)); 262 } catch { 263 return false; 264 } 265} 266 267// Store PWA mode status per client (in-memory cache) 268const pwaModeClients = new Set(); 269 270// IndexedDB database name and store name for PWA mode persistence 271const IDB_DB_NAME = "pwa-mode-db"; 272const IDB_STORE_NAME = "pwa-clients"; 273 274/** 275 * Initialize IndexedDB for PWA mode persistence 276 */ 277async function initPWAIndexedDB() { 278 return new Promise((resolve, reject) => { 279 try { 280 const request = indexedDB.open(IDB_DB_NAME, 1); 281 282 request.onerror = () => { 283 console.warn("[SW] Failed to open IndexedDB for PWA mode, using in-memory only"); 284 resolve(null); 285 }; 286 287 request.onsuccess = () => { 288 resolve(request.result); 289 }; 290 291 request.onupgradeneeded = (event) => { 292 const db = event.target.result; 293 if (!db.objectStoreNames.contains(IDB_STORE_NAME)) { 294 db.createObjectStore(IDB_STORE_NAME, { keyPath: "clientId" }); 295 } 296 }; 297 } catch (error) { 298 console.warn("[SW] IndexedDB not available, using in-memory only:", error); 299 resolve(null); 300 } 301 }); 302} 303 304/** 305 * Load PWA mode clients from IndexedDB 306 */ 307async function loadPWAClientsFromIDB() { 308 try { 309 const db = await initPWAIndexedDB(); 310 if (!db) { 311 return; // Fall back to in-memory only 312 } 313 314 const transaction = db.transaction([IDB_STORE_NAME], "readonly"); 315 const store = transaction.objectStore(IDB_STORE_NAME); 316 const request = store.getAll(); 317 318 request.onsuccess = () => { 319 const clients = request.result || []; 320 clients.forEach((item) => { 321 if (item.clientId) { 322 pwaModeClients.add(item.clientId); 323 } 324 }); 325 console.log("[SW] Loaded PWA clients from IndexedDB:", clients.length); 326 }; 327 328 request.onerror = () => { 329 console.warn("[SW] Failed to load PWA clients from IndexedDB"); 330 }; 331 } catch (error) { 332 console.warn("[SW] Error loading PWA clients from IndexedDB:", error); 333 } 334} 335 336/** 337 * Store PWA client in IndexedDB 338 */ 339async function storePWAClientInIDB(clientId, isPWA) { 340 try { 341 const db = await initPWAIndexedDB(); 342 if (!db) { 343 return; // Fall back to in-memory only 344 } 345 346 const transaction = db.transaction([IDB_STORE_NAME], "readwrite"); 347 const store = transaction.objectStore(IDB_STORE_NAME); 348 349 if (isPWA) { 350 store.put({ clientId, isPWA, timestamp: Date.now() }); 351 } else { 352 store.delete(clientId); 353 } 354 } catch (error) { 355 console.warn("[SW] Error storing PWA client in IndexedDB:", error); 356 } 357} 358 359/** 360 * Check if a client is in PWA mode (standalone display mode) 361 * Clients should send a message with type "PWA_MODE" and isPWA boolean 362 */ 363async function isPWAMode(clientId) { 364 // If we have stored info about this client, use it
365 if (clientId && pwaModeClients.has(clientId)) { 366 return true; 367 } 368 369 // Default: don't assume PWA mode (conservative approach) 370 // offline.html should only show for confirmed PWA users 371 return false; 372} 373 374// Load PWA clients from IndexedDB on service worker startup 375loadPWAClientsFromIDB(); 376 377/** 378 * Check if request is a navigation request (HTML page request) 379 */ 380function isNavigationRequest(request) { 381 // Check if request mode is navigate (browser navigation) 382 if (request.mode === "navigate") { 383 return true; 384 } 385 386 // Check Accept header for HTML content 387 const acceptHeader = request.headers.get("Accept"); 388 if (acceptHeader && acceptHeader.includes("text/html")) { 389 return true; 390 } 391 392 // Check if request destination is document 393 if (request.destination === "document") { 394 return true; 395 } 396 397 return false; 398} 399 400/** 401 * Check if error indicates offline state (network error) 402 */ 403function isNetworkError(error) { 404 // Network errors typically have no response or are TypeError 405 // Server errors (4xx, 5xx) are not network errors 406 return ( 407 error instanceof TypeError || 408 error?.name === "NetworkError" || 409 error.message?.includes("Failed to fetch") || 410 error.message?.includes("NetworkError") 411 ); 412} 413 414/** 415 * Handle generic requests (network-first) 416 * Try network first, fall back to cache 417 * Only show offline.html for PWA users when actually offline and for navigation requests 418 */ 419async function handleGenericRequest(request) { 420 const { RUNTIME_CACHE } = getCacheNames(); 421 422 try { 423 const response = await fetch(request); 424 425 return response; 426 } catch (error) { 427 // Check if this is a network error (offline) vs server error 428 const isOffline = isNetworkError(error); 429 430 // Try cache first 431 const cached = await caches.match(request); 432 if (cached) { 433 return cached; 434 } 435 436 // Only show offline.html if: 437 // 1. It's a navigation request (HTML page) 438 // 2. User is actually offline (network error, not server error) 439 // 3. User is in PWA mode (confirmed via client message) 440 const isNav = isNavigationRequest(request); 441 442 if (isNav && isOffline) { 443 // Check if user is in PWA mode by checking stored client info 444 try { 445 const clients = await self.clients.matchAll({ type: "window", includeUncontrolled: true }); 446 let isPWA = false; 447 448 // Check if any of the clients are in PWA mode 449 for (const client of clients) { 450 const clientId = client.id || client.url || "default"; 451 if (pwaModeClients.has(clientId)) { 452 isPWA = true; 453 break; 454 } 455 } 456 457 // Only return offline.html for PWA users 458 if (isPWA) { 459 const offlinePage = await caches.match("/offline.html"); 460 if (offlinePage) { 461 console.log("[SW] Returning offline.html for PWA user"); 462 return offlinePage; 463 } 464 } else { 465 console.log("[SW] Not showing offline.html - user is not in PWA mode"); 466 } 467 } catch (err) { 468 console.log("[SW] Error checking PWA mode:", err); 469 } 470 } 471 472 // Only a PWA navigation still reaches here (see the guard in the fetch 473 // listener), and only when `offline.html` was not available to serve. 474 // Re-throw rather than synthesising a 503: a rejected `respondWith` gives 475 // the browser its own error page, which carries a Reload affordance and 476 // retries normally, instead of a dead plain-text body the browser believes 477 // succeeded. The page itself is generic (`ERR_FAILED`) -- the specific 478 // cause survives only in the console line below, which is why it logs the 479 // error name and message verbatim. 480 // 481 // A 4xx/5xx from the origin never reaches this branch -- `fetch` rejects 482 // only below HTTP -- so this cannot mask a server-rendered error page. 483 if (isNavigationRequest(request)) { 484 console.error( 485 "[SW] Navigation fetch failed; deferring to the browser error page:", 486 request.url, 487 error && (error.name || error.message) ? `${error.name}: ${error.message}` : error, 488 ); 489 throw error; 490 } 491 492 // For API or other requests, return JSON error 493 return new Response(JSON.stringify({ error: "Request failed", message: error.message }), { 494 status: 503, 495 statusText: "Service Unavailable", 496 headers: { "Content-Type": "application/json" }, 497 }); 498 } 499} 500 501/** 502 * Check if a path is a static asset 503 */ 504function isStaticAsset(pathname) { 505 const staticExtensions = [ 506 ".js", 507 ".css", 508 ".png", 509 ".jpg", 510 ".jpeg", 511 ".gif", 512 ".svg", 513 ".woff", 514 ".woff2", 515 ".ttf", 516 ".eot", 517 ]; 518 return staticExtensions.some((ext) => pathname.endsWith(ext)); 519} 520 521/** 522 * Handle messages from clients 523 */ 524self.addEventListener("message", (event) => {
525 console.log("[SW] Message received:", event.data); 526 527 if (event.data && event.data.type === "SKIP_WAITING") { 528 self.skipWaiting(); 529 } 530 531 if (event.data && event.data.type === "CLEAR_CACHE") { 532 clearAllCaches(); 533 } 534 535 // Handle PWA mode status from client 536 if (event.data && event.data.type === "PWA_MODE") { 537 // Get the client that sent the message 538 const client = event.source; 539 if (client) { 540 const clientId = client.id || client.url || "default"; 541 const isPWA = event.data.isPWA; 542 543 if (isPWA) { 544 // Store client ID in memory and IndexedDB 545 pwaModeClients.add(clientId); 546 storePWAClientInIDB(clientId, true); 547 console.log("[SW] Client is in PWA mode:", clientId); 548 } else { 549 // Remove client ID from memory and IndexedDB 550 pwaModeClients.delete(clientId); 551 storePWAClientInIDB(clientId, false); 552 console.log("[SW] Client is not in PWA mode:", clientId); 553 } 554 } 555 } 556}); 557 558/** 559 * Clear all caches 560 */ 561async function clearAllCaches() { 562 const cacheNames = await caches.keys(); 563 return Promise.all(cacheNames.map((name) => caches.delete(name))); 564} 565 566console.log("[SW] Service worker loaded");
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.