/** * Service Worker * * Handles caching of: * - App shell (HTML, CSS, JS) * - Map tiles (runtime caching) * - API responses (network-first) * * Note: Database operations are handled by the SharedWorker (shared-db-worker.js), * NOT by this service worker. They serve different purposes: * - Service Worker: Caching, offline asset serving, push notifications * - SharedWorker: Shared database connection across tabs */ const CACHE_VERSION = 'v1'; const SHELL_CACHE = `shell-${CACHE_VERSION}`; const TILES_CACHE = `tiles-${CACHE_VERSION}`; const MODULES_CACHE = `modules-${CACHE_VERSION}`; const API_CACHE = `api-${CACHE_VERSION}`; // Maximum number of tiles to cache const MAX_TILES = 500; // App shell assets - precached on install // Vite will generate hashed filenames, so we cache the entry points // and let the browser handle the hashed assets const SHELL_ASSETS = [ '/', '/index.html', '/offline.html', '/manifest.json' ]; // ============================================================================ // INSTALL EVENT // ============================================================================ self.addEventListener('install', (event) => { console.log('[SW] Installing...'); event.waitUntil( caches.open(SHELL_CACHE) .then((cache) => { console.log('[SW] Precaching app shell'); return cache.addAll(SHELL_ASSETS); }) .then(() => self.skipWaiting()) ); }); // ============================================================================ // ACTIVATE EVENT // ============================================================================ self.addEventListener('activate', (event) => { console.log('[SW] Activating...'); event.waitUntil( caches.keys() .then((cacheNames) => { return Promise.all( cacheNames .filter((name) => { // Delete old version caches return (name.startsWith('shell-') && name !== SHELL_CACHE) || (name.startsWith('tiles-') && name !== TILES_CACHE) || (name.startsWith('modules-') && name !== MODULES_CACHE) || (name.startsWith('api-') && name !== API_CACHE); }) .map((name) => { console.log('[SW] Deleting old cache:', name); return caches.delete(name); }) ); }) .then(() => self.clients.claim()) ); }); // ============================================================================ // FETCH EVENT // ============================================================================ self.addEventListener('fetch', (event) => { const request = event.request; const url = new URL(request.url); // Only handle GET requests if (request.method !== 'GET') return; // Skip chrome-extension and other non-http(s) requests if (!url.protocol.startsWith('http')) return; // Route to appropriate caching strategy if (isMapTile(url)) { event.respondWith(cacheThenNetwork(request, TILES_CACHE, MAX_TILES)); } else if (isApiRequest(url)) { event.respondWith(networkFirst(request, API_CACHE)); } else if (isModuleAsset(url)) { event.respondWith(staleWhileRevalidate(request, MODULES_CACHE)); } else if (isAppAsset(url)) { event.respondWith(cacheFirst(request, SHELL_CACHE)); } // Let other requests pass through to network }); // ============================================================================ // URL CLASSIFICATION // ============================================================================ function isMapTile(url) { // Common tile server patterns for all our base maps return url.hostname.includes('tile.openstreetmap.org') || url.hostname.includes('opentopomap.org') || url.hostname.includes('arcgisonline.com') || url.hostname.includes('basemaps.cartocdn.com') || url.hostname.includes('tiles.') || url.pathname.match(/\/\d+\/\d+\/\d+\.(png|jpg|pbf)$/) || url.pathname.match(/\/tile\/\d+\/\d+\/\d+/); } function isApiRequest(url) { return url.pathname.startsWith('/api/') || url.pathname.endsWith('.php'); } function isModuleAsset(url) { return url.pathname.startsWith('/modules/'); } function isAppAsset(url) { // Same origin, common asset extensions return url.origin === self.location.origin && (url.pathname.endsWith('.html') || url.pathname.endsWith('.css') || url.pathname.endsWith('.js') || url.pathname.endsWith('.wasm') || url.pathname.endsWith('.json') || url.pathname.match(/\.(png|jpg|jpeg|gif|svg|ico|webp)$/)); } // ============================================================================ // CACHING STRATEGIES // ============================================================================ /** * Cache First - Use cache, fallback to network * Best for: App shell, static assets */ async function cacheFirst(request, cacheName) { const cached = await caches.match(request); if (cached) return cached; try { const response = await fetch(request); if (response.ok) { const cache = await caches.open(cacheName); cache.put(request, response.clone()); } return response; } catch (error) { // Return offline page for navigation requests if (request.mode === 'navigate') { return caches.match('/offline.html'); } throw error; } } /** * Network First - Try network, fallback to cache * Best for: API requests, dynamic content */ async function networkFirst(request, cacheName) { try { const response = await fetch(request); if (response.ok) { const cache = await caches.open(cacheName); cache.put(request, response.clone()); } return response; } catch (error) { const cached = await caches.match(request); if (cached) return cached; throw error; } } /** * Stale While Revalidate - Return cache immediately, update in background * Best for: Module assets, frequently updated content */ async function staleWhileRevalidate(request, cacheName) { const cache = await caches.open(cacheName); const cached = await cache.match(request); const fetchPromise = fetch(request).then((response) => { if (response.ok) { cache.put(request, response.clone()); } return response; }).catch(() => cached); return cached || fetchPromise; } /** * Cache Then Network with limit - Cache tiles with size limit * Best for: Map tiles */ async function cacheThenNetwork(request, cacheName, maxItems) { const cache = await caches.open(cacheName); const cached = await cache.match(request); if (cached) return cached; try { const response = await fetch(request); if (response.ok) { // Check cache size and trim if needed const keys = await cache.keys(); if (keys.length >= maxItems) { // Remove oldest entries (first 10%) const toDelete = keys.slice(0, Math.ceil(maxItems * 0.1)); await Promise.all(toDelete.map(key => cache.delete(key))); } cache.put(request, response.clone()); } return response; } catch (error) { // For tiles, just fail silently - map will show blank tile return new Response('', { status: 408, statusText: 'Offline' }); } } // ============================================================================ // MESSAGE HANDLING // ============================================================================ self.addEventListener('message', (event) => { const { type, payload } = event.data || {}; switch (type) { case 'SKIP_WAITING': self.skipWaiting(); break; case 'CACHE_MODULES': cacheModules(payload.modules); break; case 'CLEAR_USER_CACHE': clearUserCaches(); break; case 'GET_CACHE_STATUS': getCacheStatus().then(status => { event.source.postMessage({ type: 'CACHE_STATUS', status }); }); break; } }); /** * Cache specific modules on demand */ async function cacheModules(moduleNames) { const cache = await caches.open(MODULES_CACHE); for (const moduleName of moduleNames) { try { const moduleAssets = [ `/modules/${moduleName}/index.js`, `/modules/${moduleName}/index.css`, `/modules/${moduleName}/index.html` ]; await cache.addAll(moduleAssets.filter(async (url) => { // Only cache assets that exist try { const response = await fetch(url, { method: 'HEAD' }); return response.ok; } catch { return false; } })); console.log('[SW] Cached module:', moduleName); } catch (error) { console.warn('[SW] Failed to cache module:', moduleName, error); } } } /** * Clear user-specific caches (call on logout) */ async function clearUserCaches() { await caches.delete(API_CACHE); await caches.delete(MODULES_CACHE); console.log('[SW] Cleared user caches'); } /** * Get cache status information */ async function getCacheStatus() { const cacheNames = await caches.keys(); const status = {}; for (const name of cacheNames) { const cache = await caches.open(name); const keys = await cache.keys(); status[name] = keys.length; } return status; }