← back to Watches
public/sw-advanced.js
541 lines
/**
* Advanced Service Worker for Omega Watch Price History PWA
* Features: Offline mode, push notifications, background sync, camera caching
* Version: 2.0.0
*/
const CACHE_VERSION = 'omega-watches-v2.0.0';
const STATIC_CACHE = `${CACHE_VERSION}-static`;
const API_CACHE = `${CACHE_VERSION}-api`;
const IMAGE_CACHE = `${CACHE_VERSION}-images`;
const CAMERA_CACHE = `${CACHE_VERSION}-camera`;
const AR_CACHE = `${CACHE_VERSION}-ar`;
// Critical assets for offline functionality
const CRITICAL_ASSETS = [
'/',
'/index.html',
'/offline.html',
'/manifest.json',
'/pwa-app.js',
'/pwa-styles.css',
'/camera-scanner.js',
'/ar-viewer.js',
'/biometric-auth.js'
];
// API endpoints to cache for offline use
const API_ENDPOINTS_TO_CACHE = [
'/api/watches',
'/api/statistics',
'/api/collections',
'/api/watches/trending'
];
// Install event - cache critical assets
self.addEventListener('install', (event) => {
console.log('[SW] Installing service worker v2.0.0...');
event.waitUntil(
Promise.all([
caches.open(STATIC_CACHE).then(cache => {
console.log('[SW] Caching critical assets');
return cache.addAll(CRITICAL_ASSETS.map(url => new Request(url, { cache: 'reload' })))
.catch(err => {
console.warn('[SW] Some assets failed to cache:', err);
// Continue anyway - we'll cache them on demand
return Promise.resolve();
});
}),
caches.open(API_CACHE).then(cache => {
console.log('[SW] Pre-caching API endpoints');
return Promise.all(
API_ENDPOINTS_TO_CACHE.map(url =>
fetch(url)
.then(response => cache.put(url, response))
.catch(() => console.log('[SW] Skipping API cache:', url))
)
);
})
]).then(() => {
console.log('[SW] Installation complete');
return self.skipWaiting(); // Activate immediately
})
);
});
// Activate event - clean up old caches
self.addEventListener('activate', (event) => {
console.log('[SW] Activating service worker...');
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames
.filter(name => !name.startsWith(CACHE_VERSION))
.map(name => {
console.log('[SW] Deleting old cache:', name);
return caches.delete(name);
})
);
}).then(() => {
console.log('[SW] Activated and ready');
return self.clients.claim(); // Take control immediately
})
);
});
// Fetch event - serve from cache with network fallback
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// Skip non-GET requests
if (request.method !== 'GET') {
return;
}
// Handle API requests
if (url.pathname.startsWith('/api/')) {
event.respondWith(handleAPIRequest(request));
return;
}
// Handle image requests
if (request.destination === 'image' || /\.(jpg|jpeg|png|gif|svg|webp|ico)$/i.test(url.pathname)) {
event.respondWith(handleImageRequest(request));
return;
}
// Handle camera/video streams (don't cache)
if (request.destination === 'video' || url.pathname.includes('/camera')) {
event.respondWith(fetch(request));
return;
}
// Handle AR model files
if (url.pathname.includes('/models/') || url.pathname.endsWith('.glb') || url.pathname.endsWith('.gltf')) {
event.respondWith(handleARModelRequest(request));
return;
}
// Handle static assets
event.respondWith(handleStaticRequest(request));
});
/**
* Handle API requests with network-first strategy
*/
async function handleAPIRequest(request) {
try {
const networkResponse = await fetch(request);
// Cache successful responses
if (networkResponse && networkResponse.ok) {
const cache = await caches.open(API_CACHE);
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
// Network failed, try cache
const cachedResponse = await caches.match(request);
if (cachedResponse) {
console.log('[SW] Serving API from cache (offline):', request.url);
// Add offline indicator header
const headers = new Headers(cachedResponse.headers);
headers.set('X-Served-From-Cache', 'true');
return new Response(cachedResponse.body, {
status: cachedResponse.status,
statusText: cachedResponse.statusText,
headers: headers
});
}
// Return offline data if available
return new Response(
JSON.stringify({
error: 'Offline',
message: 'This data is not available offline'
}),
{
status: 503,
headers: { 'Content-Type': 'application/json' }
}
);
}
}
/**
* Handle image requests with cache-first strategy
*/
async function handleImageRequest(request) {
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
try {
const networkResponse = await fetch(request);
if (networkResponse && networkResponse.ok) {
const cache = await caches.open(IMAGE_CACHE);
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
// Return placeholder image for offline
return new Response(
'<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400"><rect fill="#ddd" width="400" height="400"/><text x="50%" y="50%" text-anchor="middle" fill="#999">Offline</text></svg>',
{ headers: { 'Content-Type': 'image/svg+xml' } }
);
}
}
/**
* Handle AR model requests with cache-first strategy
*/
async function handleARModelRequest(request) {
const cachedResponse = await caches.match(request);
if (cachedResponse) {
console.log('[SW] Serving AR model from cache:', request.url);
return cachedResponse;
}
try {
const networkResponse = await fetch(request);
if (networkResponse && networkResponse.ok) {
const cache = await caches.open(AR_CACHE);
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
console.error('[SW] AR model not available offline:', request.url);
return new Response('AR model not available offline', { status: 503 });
}
}
/**
* Handle static asset requests with stale-while-revalidate
*/
async function handleStaticRequest(request) {
const cache = await caches.open(STATIC_CACHE);
const cachedResponse = await cache.match(request);
const fetchPromise = fetch(request).then(networkResponse => {
if (networkResponse && networkResponse.ok) {
cache.put(request, networkResponse.clone());
}
return networkResponse;
}).catch(() => cachedResponse);
return cachedResponse || fetchPromise || caches.match('/offline.html');
}
/**
* Background Sync for offline actions
*/
self.addEventListener('sync', (event) => {
console.log('[SW] Background sync:', event.tag);
if (event.tag === 'sync-watchlist') {
event.waitUntil(syncWatchlist());
}
if (event.tag === 'sync-favorites') {
event.waitUntil(syncFavorites());
}
if (event.tag === 'sync-scanned-watches') {
event.waitUntil(syncScannedWatches());
}
});
async function syncWatchlist() {
try {
const db = await openDB();
const tx = db.transaction('pendingActions', 'readonly');
const store = tx.objectStore('pendingActions');
const actions = await getAllFromStore(store);
for (const action of actions) {
if (action.type === 'watchlist') {
const response = await fetch('/api/watchlist', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(action.data)
});
if (response.ok) {
const deleteTx = db.transaction('pendingActions', 'readwrite');
await deleteTx.objectStore('pendingActions').delete(action.id);
}
}
}
console.log('[SW] Watchlist synced successfully');
} catch (error) {
console.error('[SW] Sync failed:', error);
throw error; // Will retry sync
}
}
async function syncFavorites() {
// Similar implementation for favorites
console.log('[SW] Favorites sync initiated');
}
async function syncScannedWatches() {
try {
const db = await openDB();
const tx = db.transaction('scannedWatches', 'readonly');
const store = tx.objectStore('scannedWatches');
const scans = await getAllFromStore(store);
for (const scan of scans) {
if (!scan.synced) {
const response = await fetch('/api/watches/scan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(scan.data)
});
if (response.ok) {
scan.synced = true;
const updateTx = db.transaction('scannedWatches', 'readwrite');
await updateTx.objectStore('scannedWatches').put(scan);
}
}
}
console.log('[SW] Scanned watches synced');
} catch (error) {
console.error('[SW] Scan sync failed:', error);
}
}
/**
* Push Notification handling
*/
self.addEventListener('push', (event) => {
console.log('[SW] Push notification received');
let data = {
title: 'Omega Watch Update',
body: 'New price alert available',
icon: '/icons/icon-192x192.svg',
badge: '/icons/icon-72x72.svg',
tag: 'omega-notification',
data: {}
};
if (event.data) {
try {
data = { ...data, ...event.data.json() };
} catch (e) {
data.body = event.data.text();
}
}
const options = {
body: data.body,
icon: data.icon,
badge: data.badge,
vibrate: [200, 100, 200],
tag: data.tag,
requireInteraction: data.requireInteraction || false,
data: data.data,
actions: [
{ action: 'view', title: 'View Details', icon: '/icons/icon-96x96.svg' },
{ action: 'dismiss', title: 'Dismiss' }
],
image: data.image
};
event.waitUntil(
self.registration.showNotification(data.title, options)
);
});
/**
* Notification click handler
*/
self.addEventListener('notificationclick', (event) => {
console.log('[SW] Notification clicked:', event.action);
event.notification.close();
if (event.action === 'view') {
const urlToOpen = event.notification.data.url || '/';
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true })
.then(windowClients => {
// Check if there's already a window open
for (let client of windowClients) {
if (client.url === urlToOpen && 'focus' in client) {
return client.focus();
}
}
// Open new window if none exists
if (clients.openWindow) {
return clients.openWindow(urlToOpen);
}
})
);
}
});
/**
* Periodic Background Sync for price updates
*/
self.addEventListener('periodicsync', (event) => {
console.log('[SW] Periodic sync:', event.tag);
if (event.tag === 'update-prices') {
event.waitUntil(updatePriceData());
}
});
async function updatePriceData() {
try {
const response = await fetch('/api/watches/updates');
const data = await response.json();
// Update cache with new data
const cache = await caches.open(API_CACHE);
await cache.put('/api/watches', new Response(JSON.stringify(data)));
// Notify user of significant price changes
if (data.significantChanges && data.significantChanges.length > 0) {
const change = data.significantChanges[0];
await self.registration.showNotification('Price Alert!', {
body: `${change.model}: ${change.change > 0 ? '+' : ''}${change.change}%`,
icon: '/icons/icon-192x192.svg',
badge: '/icons/icon-72x72.svg',
data: { url: `/?watch=${change.id}` }
});
}
console.log('[SW] Price data updated');
} catch (error) {
console.error('[SW] Price update failed:', error);
}
}
/**
* Message handler for app communication
*/
self.addEventListener('message', (event) => {
console.log('[SW] 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)));
}).then(() => {
event.ports[0].postMessage({ success: true });
})
);
}
if (event.data.action === 'cacheCamera') {
event.waitUntil(
caches.open(CAMERA_CACHE).then(cache => {
return cache.put('/camera-frame', new Response(event.data.imageData));
})
);
}
if (event.data.action === 'getCacheStats') {
event.waitUntil(
getCacheStats().then(stats => {
event.ports[0].postMessage(stats);
})
);
}
});
/**
* Get cache statistics
*/
async function getCacheStats() {
const cacheNames = await caches.keys();
const stats = {};
for (const name of cacheNames) {
const cache = await caches.open(name);
const keys = await cache.keys();
stats[name] = keys.length;
}
return stats;
}
/**
* IndexedDB helper functions
*/
function openDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open('OmegaWatchesPWA', 2);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = event.target.result;
// Pending actions store
if (!db.objectStoreNames.contains('pendingActions')) {
db.createObjectStore('pendingActions', { keyPath: 'id', autoIncrement: true });
}
// Watches store
if (!db.objectStoreNames.contains('watches')) {
const watchStore = db.createObjectStore('watches', { keyPath: 'id' });
watchStore.createIndex('series', 'series', { unique: false });
watchStore.createIndex('model', 'model', { unique: false });
}
// Scanned watches store
if (!db.objectStoreNames.contains('scannedWatches')) {
const scanStore = db.createObjectStore('scannedWatches', { keyPath: 'id', autoIncrement: true });
scanStore.createIndex('timestamp', 'timestamp', { unique: false });
scanStore.createIndex('synced', 'synced', { unique: false });
}
// User preferences store
if (!db.objectStoreNames.contains('preferences')) {
db.createObjectStore('preferences', { keyPath: 'key' });
}
// Biometric data store (encrypted)
if (!db.objectStoreNames.contains('biometric')) {
db.createObjectStore('biometric', { keyPath: 'userId' });
}
};
});
}
function getAllFromStore(store) {
return new Promise((resolve, reject) => {
const request = store.getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
console.log('[SW] Service Worker v2.0.0 loaded and ready');