407 lines
12 KiB
JavaScript
407 lines
12 KiB
JavaScript
/**
|
|
* 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(
|
|
'<html><body><h1>Offline</h1><p>You are currently offline.</p></body></html>',
|
|
{
|
|
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, but only if port is available
|
|
if (event.ports && event.ports.length > 0) {
|
|
try {
|
|
event.ports[0].postMessage({
|
|
status: 'success',
|
|
message: 'All caches cleared successfully'
|
|
});
|
|
} catch (err) {
|
|
console.log('Could not post message to client: port may be closed');
|
|
}
|
|
}
|
|
})
|
|
);
|
|
}
|
|
});
|