← back to Watches
public/service-worker.js
329 lines
/**
* Service Worker for Omega Watch Price History PWA
* Provides offline capabilities, caching, and background sync
*/
const CACHE_NAME = 'omega-watches-v1.0.0';
const API_CACHE_NAME = 'omega-api-cache-v1.0.0';
const IMAGE_CACHE_NAME = 'omega-images-v1.0.0';
// Assets to cache immediately on install
const STATIC_ASSETS = [
'/',
'/index.html',
'/manifest.json',
'/styles.css',
'/app.js',
'/favicon.ico',
'/icons/icon-72x72.png',
'/icons/icon-96x96.png',
'/icons/icon-128x128.png',
'/icons/icon-144x144.png',
'/icons/icon-152x152.png',
'/icons/icon-192x192.png',
'/icons/icon-384x384.png',
'/icons/icon-512x512.png'
];
// API endpoints to cache for offline use
const API_ENDPOINTS = [
'/api/watches',
'/api/statistics',
'/api/collections',
'/api/watches/trending'
];
// Install event - cache static assets
self.addEventListener('install', (event) => {
console.log('[ServiceWorker] Installing...');
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => {
console.log('[ServiceWorker] Caching static assets');
return cache.addAll(STATIC_ASSETS.filter(url => {
// Only cache files that exist (skip icons if not yet created)
return !url.includes('/icons/');
}));
})
.then(() => {
console.log('[ServiceWorker] Static assets cached');
return self.skipWaiting(); // Activate immediately
})
.catch((error) => {
console.error('[ServiceWorker] Cache install failed:', error);
})
);
});
// Activate event - clean up old caches
self.addEventListener('activate', (event) => {
console.log('[ServiceWorker] Activating...');
event.waitUntil(
caches.keys()
.then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => {
return name !== CACHE_NAME &&
name !== API_CACHE_NAME &&
name !== IMAGE_CACHE_NAME;
})
.map((name) => {
console.log('[ServiceWorker] Deleting old cache:', name);
return caches.delete(name);
})
);
})
.then(() => {
console.log('[ServiceWorker] Activated');
return self.clients.claim(); // Take control of all pages
})
);
});
// Fetch event - serve from cache, fallback to network
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// Handle API requests with network-first strategy
if (url.pathname.startsWith('/api/')) {
event.respondWith(
networkFirstStrategy(request, API_CACHE_NAME)
);
return;
}
// Handle image requests with cache-first strategy
if (request.destination === 'image') {
event.respondWith(
cacheFirstStrategy(request, IMAGE_CACHE_NAME)
);
return;
}
// Handle static assets with cache-first strategy
event.respondWith(
cacheFirstStrategy(request, CACHE_NAME)
);
});
/**
* Network-first strategy: Try network, fallback to cache
* Best for API data that changes frequently
*/
async function networkFirstStrategy(request, cacheName) {
try {
const networkResponse = await fetch(request);
// Cache successful responses
if (networkResponse && networkResponse.status === 200) {
const cache = await caches.open(cacheName);
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
// Network failed, try cache
const cachedResponse = await caches.match(request);
if (cachedResponse) {
console.log('[ServiceWorker] Serving from cache (offline):', request.url);
return cachedResponse;
}
// Return offline page if available
return caches.match('/offline.html');
}
}
/**
* Cache-first strategy: Try cache, fallback to network
* Best for static assets that rarely change
*/
async function cacheFirstStrategy(request, cacheName) {
const cachedResponse = await caches.match(request);
if (cachedResponse) {
// Return cached version
return cachedResponse;
}
try {
// Not in cache, fetch from network
const networkResponse = await fetch(request);
// Cache for future use
if (networkResponse && networkResponse.status === 200) {
const cache = await caches.open(cacheName);
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
console.error('[ServiceWorker] Fetch failed:', error);
// Return offline fallback
return caches.match('/offline.html');
}
}
/**
* Stale-while-revalidate strategy
* Serve from cache immediately, then update cache in background
*/
async function staleWhileRevalidate(request, cacheName) {
const cache = await caches.open(cacheName);
const cachedResponse = await cache.match(request);
const fetchPromise = fetch(request).then((networkResponse) => {
if (networkResponse && networkResponse.status === 200) {
cache.put(request, networkResponse.clone());
}
return networkResponse;
});
return cachedResponse || fetchPromise;
}
// Background sync for offline actions
self.addEventListener('sync', (event) => {
console.log('[ServiceWorker] Background sync:', event.tag);
if (event.tag === 'sync-watchlist') {
event.waitUntil(syncWatchlist());
}
if (event.tag === 'sync-favorites') {
event.waitUntil(syncFavorites());
}
});
// Sync watchlist when back online
async function syncWatchlist() {
try {
// Get pending watchlist changes from IndexedDB
const db = await openDB();
const tx = db.transaction('pendingActions', 'readonly');
const store = tx.objectStore('pendingActions');
const actions = await store.getAll();
// Send each action to server
for (const action of actions) {
if (action.type === 'watchlist') {
await fetch('/api/watchlist', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(action.data)
});
// Remove from pending after successful sync
const deleteTx = db.transaction('pendingActions', 'readwrite');
await deleteTx.objectStore('pendingActions').delete(action.id);
}
}
console.log('[ServiceWorker] Watchlist synced');
} catch (error) {
console.error('[ServiceWorker] Sync failed:', error);
}
}
// Sync favorites when back online
async function syncFavorites() {
// Similar to syncWatchlist but for favorites
console.log('[ServiceWorker] Favorites sync complete');
}
// Push notification handler
self.addEventListener('push', (event) => {
console.log('[ServiceWorker] Push notification received');
let data = {
title: 'Omega Watch Update',
body: 'Price alert or new watch added',
icon: '/icons/icon-192x192.png',
badge: '/icons/badge-72x72.png'
};
if (event.data) {
data = event.data.json();
}
const options = {
body: data.body,
icon: data.icon,
badge: data.badge,
vibrate: [200, 100, 200],
tag: 'omega-watch-notification',
requireInteraction: false,
actions: [
{ action: 'view', title: 'View Watch' },
{ action: 'dismiss', title: 'Dismiss' }
]
};
event.waitUntil(
self.registration.showNotification(data.title, options)
);
});
// Notification click handler
self.addEventListener('notificationclick', (event) => {
console.log('[ServiceWorker] Notification clicked:', event.action);
event.notification.close();
if (event.action === 'view') {
// Open the app to the relevant watch
event.waitUntil(
clients.openWindow('/')
);
}
});
// Message handler for communication with app
self.addEventListener('message', (event) => {
console.log('[ServiceWorker] Message received:', event.data);
if (event.data.action === 'skipWaiting') {
self.skipWaiting();
}
if (event.data.action === 'clearCache') {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((name) => caches.delete(name))
);
})
);
}
});
// Helper: Open IndexedDB
function openDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open('OmegaWatchesDB', 1);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains('pendingActions')) {
db.createObjectStore('pendingActions', { keyPath: 'id', autoIncrement: true });
}
if (!db.objectStoreNames.contains('watches')) {
db.createObjectStore('watches', { keyPath: 'id' });
}
};
});
}
console.log('[ServiceWorker] Script loaded');