// ============================================================================ // Service Worker — Homelab Automation Dashboard PWA // ============================================================================ const CACHE_VERSION = 'v1'; const STATIC_CACHE = `homelab-static-${CACHE_VERSION}`; const DYNAMIC_CACHE = `homelab-dynamic-${CACHE_VERSION}`; // App shell — resources to pre-cache on install const APP_SHELL = [ '/', '/static/main.js', '/static/containers_page.js', '/static/dashboard_core.js', '/static/docker_section.js', '/static/favorites_manager.js', '/static/icon_picker.js', '/static/playbook_editor.js', '/static/container_customizations_manager.js', '/manifest.json', '/static/icons/icon-192x192.png', '/static/icons/icon-512x512.png', ]; // External CDN resources to cache on first fetch const CDN_PATTERNS = [ 'cdn.tailwindcss.com', 'cdnjs.cloudflare.com', 'fonts.googleapis.com', 'fonts.gstatic.com', 'cdn.jsdelivr.net', ]; // ---- INSTALL: pre-cache app shell ---- self.addEventListener('install', (event) => { console.log('[SW] Installing...'); event.waitUntil( caches.open(STATIC_CACHE) .then((cache) => cache.addAll(APP_SHELL)) .then(() => self.skipWaiting()) .catch((err) => console.warn('[SW] Pre-cache failed for some resources:', err)) ); }); // ---- ACTIVATE: clean up old caches ---- self.addEventListener('activate', (event) => { console.log('[SW] Activating...'); event.waitUntil( caches.keys() .then((keys) => Promise.all( keys .filter((key) => key !== STATIC_CACHE && key !== DYNAMIC_CACHE) .map((key) => { console.log('[SW] Removing old cache:', key); return caches.delete(key); }) ) ) .then(() => self.clients.claim()) ); }); // ---- FETCH: routing strategies ---- self.addEventListener('fetch', (event) => { const { request } = event; const url = new URL(request.url); // Skip non-GET requests if (request.method !== 'GET') return; // Skip WebSocket upgrades if (url.protocol === 'ws:' || url.protocol === 'wss:') return; // Skip /docs and /redoc (Swagger UI) — always network if (url.pathname.startsWith('/docs') || url.pathname.startsWith('/redoc')) return; // --- API calls: Network First --- if (url.pathname.startsWith('/api/')) { event.respondWith(networkFirst(request, DYNAMIC_CACHE)); return; } // --- CDN resources: Cache First --- if (CDN_PATTERNS.some((pattern) => url.hostname.includes(pattern))) { event.respondWith(cacheFirst(request, STATIC_CACHE)); return; } // --- Static assets & app shell: Cache First --- if ( url.pathname.startsWith('/static/') || url.pathname === '/manifest.json' || url.pathname === '/favicon.ico' || url.pathname === '/sw.js' ) { event.respondWith(cacheFirst(request, STATIC_CACHE)); return; } // --- HTML pages (/, etc.): Network First --- event.respondWith(networkFirst(request, DYNAMIC_CACHE)); }); // ============================================================================ // Caching strategies // ============================================================================ /** * Cache First — try cache, fall back to network (and cache the response). */ 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 { // If both cache and network fail, return a basic offline page return new Response('Offline', { status: 503, statusText: 'Service Unavailable', headers: { 'Content-Type': 'text/plain' }, }); } } /** * Network First — try network, fall back to cache. */ 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 { const cached = await caches.match(request); if (cached) return cached; // For HTML navigations, serve the cached root page if (request.headers.get('Accept')?.includes('text/html')) { const fallback = await caches.match('/'); if (fallback) return fallback; } return new Response('Offline', { status: 503, statusText: 'Service Unavailable', headers: { 'Content-Type': 'text/plain' }, }); } }