/* Service Worker for Seismo Fleet Manager PWA */ /* Network-first strategy with cache fallback for real-time data */ const CACHE_VERSION = 'v1'; const STATIC_CACHE = `sfm-static-${CACHE_VERSION}`; const DYNAMIC_CACHE = `sfm-dynamic-${CACHE_VERSION}`; const DATA_CACHE = `sfm-data-${CACHE_VERSION}`; // Files to precache (critical app shell) const STATIC_FILES = [ '/', '/static/style.css', '/static/mobile.css', '/static/mobile.js', '/static/offline-db.js', '/static/manifest.json', 'https://cdn.tailwindcss.com', 'https://unpkg.com/htmx.org@1.9.10', 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css', 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js' ]; // Install event - cache static files self.addEventListener('install', (event) => { console.log('[SW] Installing service worker...'); event.waitUntil( caches.open(STATIC_CACHE) .then((cache) => { console.log('[SW] Precaching static files'); return cache.addAll(STATIC_FILES); }) .then(() => { console.log('[SW] Static files cached successfully'); return self.skipWaiting(); // Activate immediately }) .catch((error) => { console.error('[SW] Precaching failed:', error); }) ); }); // 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.map((cacheName) => { // Delete old caches that don't match current version if (cacheName !== STATIC_CACHE && cacheName !== DYNAMIC_CACHE && cacheName !== DATA_CACHE) { console.log('[SW] Deleting old cache:', cacheName); return caches.delete(cacheName); } }) ); }) .then(() => { console.log('[SW] Service worker activated'); return self.clients.claim(); // Take control of all pages }) ); }); // Fetch event - network-first strategy self.addEventListener('fetch', (event) => { const { request } = event; const url = new URL(request.url); // Skip non-GET requests if (request.method !== 'GET') { return; } // Skip chrome-extension and other non-http(s) requests if (!url.protocol.startsWith('http')) { return; } // API requests - network first, cache fallback if (url.pathname.startsWith('/api/')) { event.respondWith(networkFirstStrategy(request, DATA_CACHE)); return; } // Static assets - cache first if (isStaticAsset(url.pathname)) { event.respondWith(cacheFirstStrategy(request, STATIC_CACHE)); return; } // HTML pages - network first with cache fallback if (request.headers.get('accept')?.includes('text/html')) { event.respondWith(networkFirstStrategy(request, DYNAMIC_CACHE)); return; } // Everything else - network first event.respondWith(networkFirstStrategy(request, DYNAMIC_CACHE)); }); // Network-first strategy async function networkFirstStrategy(request, cacheName) { try { // Try network first 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 console.log('[SW] Network failed, trying cache:', request.url); const cachedResponse = await caches.match(request); if (cachedResponse) { console.log('[SW] Serving from cache:', request.url); return cachedResponse; } // No cache available, return offline page or error console.error('[SW] No cache available for:', request.url); // For HTML requests, return a basic offline page if (request.headers.get('accept')?.includes('text/html')) { return new Response( ` Offline - SFM

📡 You're Offline

SFM requires an internet connection for this page.

Please check your connection and try again.

`, { headers: { 'Content-Type': 'text/html' } } ); } // For other requests, return error return new Response('Network error', { status: 503, statusText: 'Service Unavailable' }); } } // Cache-first strategy async function cacheFirstStrategy(request, cacheName) { const cachedResponse = await caches.match(request); if (cachedResponse) { return cachedResponse; } // Not in cache, fetch from network 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) { console.error('[SW] Fetch failed:', request.url, error); return new Response('Network error', { status: 503, statusText: 'Service Unavailable' }); } } // Check if URL is a static asset function isStaticAsset(pathname) { const staticExtensions = ['.css', '.js', '.png', '.jpg', '.jpeg', '.svg', '.ico', '.woff', '.woff2']; return staticExtensions.some(ext => pathname.endsWith(ext)); } // Background Sync - for offline edits self.addEventListener('sync', (event) => { console.log('[SW] Background sync event:', event.tag); if (event.tag === 'sync-edits') { event.waitUntil(syncPendingEdits()); } }); // Sync pending edits to server async function syncPendingEdits() { console.log('[SW] Syncing pending edits...'); try { // Get pending edits from IndexedDB const db = await openDatabase(); const edits = await getPendingEdits(db); if (edits.length === 0) { console.log('[SW] No pending edits to sync'); return; } console.log(`[SW] Syncing ${edits.length} pending edits`); // Send edits to server const response = await fetch('/api/sync-edits', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ edits }) }); if (response.ok) { const result = await response.json(); console.log('[SW] Sync successful:', result); // Clear synced edits from IndexedDB await clearSyncedEdits(db, result.synced_ids || []); // Notify all clients about successful sync const clients = await self.clients.matchAll(); clients.forEach(client => { client.postMessage({ type: 'SYNC_COMPLETE', synced: result.synced }); }); } else { console.error('[SW] Sync failed:', response.status); } } catch (error) { console.error('[SW] Sync error:', error); throw error; // Will retry sync later } } // IndexedDB helpers (simplified versions - full implementations in offline-db.js) function openDatabase() { return new Promise((resolve, reject) => { const request = indexedDB.open('sfm-offline-db', 1); request.onerror = () => reject(request.error); request.onsuccess = () => resolve(request.result); request.onupgradeneeded = (event) => { const db = event.target.result; if (!db.objectStoreNames.contains('pending-edits')) { db.createObjectStore('pending-edits', { keyPath: 'id', autoIncrement: true }); } }; }); } function getPendingEdits(db) { return new Promise((resolve, reject) => { const transaction = db.transaction(['pending-edits'], 'readonly'); const store = transaction.objectStore('pending-edits'); const request = store.getAll(); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); } function clearSyncedEdits(db, editIds) { return new Promise((resolve, reject) => { const transaction = db.transaction(['pending-edits'], 'readwrite'); const store = transaction.objectStore('pending-edits'); editIds.forEach(id => { store.delete(id); }); transaction.oncomplete = () => resolve(); transaction.onerror = () => reject(transaction.error); }); } // Message event - handle messages from clients self.addEventListener('message', (event) => { console.log('[SW] Message received:', event.data); if (event.data.type === 'SKIP_WAITING') { self.skipWaiting(); } if (event.data.type === 'CLEAR_CACHE') { event.waitUntil( caches.keys().then((cacheNames) => { return Promise.all( cacheNames.map((cacheName) => caches.delete(cacheName)) ); }) ); } }); console.log('[SW] Service Worker loaded');