/** * Service Worker for justin.deal * Provides caching and offline support with advanced strategies */ // Cache versioning for easier updates const CACHE_VERSION = '2'; const STATIC_CACHE_NAME = `justin-deal-static-v${CACHE_VERSION}`; const DYNAMIC_CACHE_NAME = `justin-deal-dynamic-v${CACHE_VERSION}`; const API_CACHE_NAME = `justin-deal-api-v${CACHE_VERSION}`; const IMAGE_CACHE_NAME = `justin-deal-images-v${CACHE_VERSION}`; // Cache expiration times (in milliseconds) const CACHE_EXPIRATION = { API: 5 * 60 * 1000, // 5 minutes DYNAMIC: 24 * 60 * 60 * 1000, // 1 day IMAGES: 7 * 24 * 60 * 60 * 1000 // 7 days }; // Cache size limits const CACHE_SIZE_LIMITS = { DYNAMIC: 50, // items IMAGES: 100 // items }; // Assets to cache immediately on service worker install const PRECACHE_ASSETS = [ '/', '/index.html', '/favicon.svg', '/site.webmanifest', '/offline.html', '/favicons/favicon.png', '/favicons/apple-touch-icon.png', '/favicons/favicon-16x16.png', '/favicons/favicon-32x32.png' ]; // Cache strategies const CACHE_STRATEGIES = { // Cache first, falling back to network CACHE_FIRST: 'cache-first', // Network first, falling back to cache NETWORK_FIRST: 'network-first', // Cache only (no network request) CACHE_ONLY: 'cache-only', // Network only (no cache lookup) NETWORK_ONLY: 'network-only', // Stale while revalidate (serve from cache, update cache in background) STALE_WHILE_REVALIDATE: 'stale-while-revalidate' }; // Route patterns and their corresponding cache strategies const ROUTE_STRATEGIES = [ // HTML pages - network first { pattern: /\.html$|\/$/, strategy: CACHE_STRATEGIES.NETWORK_FIRST, cacheName: STATIC_CACHE_NAME }, // CSS and JS - stale while revalidate { pattern: /\.(css|js)$/, strategy: CACHE_STRATEGIES.STALE_WHILE_REVALIDATE, cacheName: STATIC_CACHE_NAME }, // Images - cache first { pattern: /\.(jpe?g|png|gif|svg|webp|avif)$/, strategy: CACHE_STRATEGIES.CACHE_FIRST, cacheName: IMAGE_CACHE_NAME }, // Fonts - cache first { pattern: /\.(woff2?|ttf|otf|eot)$/, strategy: CACHE_STRATEGIES.CACHE_FIRST, cacheName: STATIC_CACHE_NAME }, // API requests - stale while revalidate { pattern: /\/api\//, strategy: CACHE_STRATEGIES.STALE_WHILE_REVALIDATE, cacheName: API_CACHE_NAME } ]; // Determine cache strategy and cache name for a given URL function getStrategyForUrl(url) { const matchedRoute = ROUTE_STRATEGIES.find(route => route.pattern.test(url)); return { strategy: matchedRoute ? matchedRoute.strategy : CACHE_STRATEGIES.NETWORK_FIRST, cacheName: matchedRoute ? matchedRoute.cacheName : DYNAMIC_CACHE_NAME }; } // Helper function to store cache metadata function storeCacheMetadata(cacheName, url, metadata) { return caches.open(`${cacheName}-metadata`) .then(metaCache => { return metaCache.put( new Request(`metadata:${url}`), new Response(JSON.stringify(metadata)) ); }); } // Helper function to get cache metadata async function getCacheMetadata(cacheName, url) { const metaCache = await caches.open(`${cacheName}-metadata`); const metadataResponse = await metaCache.match(new Request(`metadata:${url}`)); if (metadataResponse) { return JSON.parse(await metadataResponse.text()); } return null; } // Helper function to check if a cached response is expired async function isCacheExpired(cacheName, url) { const metadata = await getCacheMetadata(cacheName, url); if (!metadata || !metadata.timestamp) { return true; } const age = Date.now() - metadata.timestamp; switch (cacheName) { case API_CACHE_NAME: return age > CACHE_EXPIRATION.API; case DYNAMIC_CACHE_NAME: return age > CACHE_EXPIRATION.DYNAMIC; case IMAGE_CACHE_NAME: return age > CACHE_EXPIRATION.IMAGES; default: return false; // Static cache doesn't expire } } // Helper function to limit cache size async function trimCache(cacheName, maxItems) { const cache = await caches.open(cacheName); const keys = await cache.keys(); if (keys.length > maxItems) { // Delete oldest items (first in the list) for (let i = 0; i < keys.length - maxItems; i++) { await cache.delete(keys[i]); // Also delete metadata const metaCache = await caches.open(`${cacheName}-metadata`); await metaCache.delete(new Request(`metadata:${keys[i].url}`)); } } } // Install event - precache critical assets self.addEventListener('install', event => { event.waitUntil( caches.open(STATIC_CACHE_NAME) .then(cache => cache.addAll(PRECACHE_ASSETS)) .then(() => self.skipWaiting()) ); }); // Activate event - clean up old caches self.addEventListener('activate', event => { event.waitUntil( caches.keys().then(cacheNames => { return Promise.all( cacheNames .filter(cacheName => { // Keep current version caches return !cacheName.includes(`-v${CACHE_VERSION}`) && !cacheName.includes(`-metadata`); }) .map(cacheName => caches.delete(cacheName)) ); }).then(() => self.clients.claim()) ); }); // Helper function to handle network-first strategy async function networkFirstStrategy(request, cacheName) { try { // Try network first const networkResponse = await fetch(request); // If successful, clone and cache the response if (networkResponse.ok) { const cache = await caches.open(cacheName); cache.put(request, networkResponse.clone()); // Store metadata with timestamp storeCacheMetadata(cacheName, request.url, { timestamp: Date.now(), url: request.url }); } return networkResponse; } catch (error) { // If network fails, try cache const cachedResponse = await caches.match(request); if (cachedResponse) { return cachedResponse; } // If no cache, throw error throw error; } } // Helper function to handle cache-first strategy async function cacheFirstStrategy(request, cacheName) { // Try cache first const cachedResponse = await caches.match(request); // Check if we have a cached response and if it's expired const isExpired = cachedResponse ? await isCacheExpired(cacheName, request.url) : true; // If we have a valid cached response, use it if (cachedResponse && !isExpired) { return cachedResponse; } // If not in cache or expired, get from network try { const networkResponse = await fetch(request); // Cache the response for future if (networkResponse.ok) { const cache = await caches.open(cacheName); cache.put(request, networkResponse.clone()); // Store metadata with timestamp storeCacheMetadata(cacheName, request.url, { timestamp: Date.now(), url: request.url }); // Trim cache if needed if (cacheName === IMAGE_CACHE_NAME) { trimCache(IMAGE_CACHE_NAME, CACHE_SIZE_LIMITS.IMAGES); } } return networkResponse; } catch (error) { // If network fails and we have an expired cached response, use it as fallback if (cachedResponse) { console.log('Using expired cached response as fallback'); return cachedResponse; } // If no cache, throw error throw error; } } // Enhanced stale-while-revalidate strategy with cache expiration and metadata async function staleWhileRevalidateStrategy(request, cacheName) { const url = request.url; const cache = await caches.open(cacheName); // Try to get from cache const cachedResponse = await cache.match(request); // Check if we have a cached response and if it's expired const isExpired = cachedResponse ? await isCacheExpired(cacheName, url) : true; // If we have a cached response, use it immediately (even if expired) // This is the "stale" part // Fetch from network to update cache (don't await) const fetchPromise = fetch(request) .then(networkResponse => { if (networkResponse.ok) { // Clone the response before using it const responseToCache = networkResponse.clone(); // Store in cache cache.put(request, responseToCache); // Store metadata with timestamp storeCacheMetadata(cacheName, url, { timestamp: Date.now(), url: url }); // Trim cache if needed if (cacheName === DYNAMIC_CACHE_NAME) { trimCache(DYNAMIC_CACHE_NAME, CACHE_SIZE_LIMITS.DYNAMIC); } else if (cacheName === IMAGE_CACHE_NAME) { trimCache(IMAGE_CACHE_NAME, CACHE_SIZE_LIMITS.IMAGES); } } return networkResponse; }) .catch(error => { console.error('Failed to fetch and update cache:', error); // If we have a cached response but it's expired, still return it if (cachedResponse) { console.log('Returning expired cached response as fallback'); return cachedResponse; } throw error; }); // Return cached response immediately if available and not expired // Otherwise wait for the network response return cachedResponse && !isExpired ? cachedResponse : fetchPromise; } // Fetch event - handle all fetch requests self.addEventListener('fetch', event => { // Skip non-GET requests and browser extensions if (event.request.method !== 'GET' || event.request.url.startsWith('chrome-extension://') || event.request.url.includes('extension://')) { return; } // Get the appropriate strategy and cache name for this URL const { strategy, cacheName } = getStrategyForUrl(event.request.url); // Apply the selected strategy switch (strategy) { case CACHE_STRATEGIES.NETWORK_FIRST: event.respondWith(networkFirstStrategy(event.request, cacheName)); break; case CACHE_STRATEGIES.CACHE_FIRST: event.respondWith(cacheFirstStrategy(event.request, cacheName)); break; case CACHE_STRATEGIES.STALE_WHILE_REVALIDATE: event.respondWith(staleWhileRevalidateStrategy(event.request, cacheName)); break; case CACHE_STRATEGIES.CACHE_ONLY: event.respondWith(caches.match(event.request)); break; case CACHE_STRATEGIES.NETWORK_ONLY: event.respondWith(fetch(event.request)); break; default: // Default to network first event.respondWith(networkFirstStrategy(event.request, DYNAMIC_CACHE_NAME)); } }); // Handle offline fallback self.addEventListener('fetch', event => { // Only handle HTML navigation requests that fail if (event.request.mode === 'navigate') { event.respondWith( fetch(event.request) .catch(() => { // If fetch fails, return the offline page return caches.match('/offline.html') .then(response => { return response || new Response( '

Offline

You are currently offline.

', { headers: { 'Content-Type': 'text/html' } } ); }); }) ); } }); // Listen for messages from the client self.addEventListener('message', event => { if (event.data && event.data.type === 'SKIP_WAITING') { self.skipWaiting(); } // Handle cache cleanup request if (event.data && event.data.type === 'CLEAR_CACHES') { event.waitUntil( caches.keys().then(cacheNames => { return Promise.all( cacheNames.map(cacheName => caches.delete(cacheName)) ); }).then(() => { // Notify client that caches were cleared event.ports[0].postMessage({ status: 'success', message: 'All caches cleared successfully' }); }) ); } });