/** * WorkRoot IT Solutions - Service Worker * Implements: * - Cache-first strategy for static assets * - Stale-while-revalidate for HTML pages * - Offline fallback page * - Background sync for form submissions */ const CACHE_VERSION = 'v1'; const STATIC_CACHE = `workroot-static-${CACHE_VERSION}`; const PAGE_CACHE = `workroot-pages-${CACHE_VERSION}`; const SYNC_QUEUE = 'workroot-sync-queue'; // Static assets to pre-cache on install const PRECACHE_ASSETS = [ '/', '/offline', '/manifest.json', '/favicon.svg', '/apple-touch-icon.png', ]; // Page routes to cache with stale-while-revalidate const PAGE_ROUTES = [ '/', '/about', '/services', '/portfolio', '/contact', '/blog', '/privacy', '/terms', ]; // ─── Install Event ──────────────────────────────────────────────────────────── self.addEventListener('install', (event) => { event.waitUntil( caches.open(STATIC_CACHE).then((cache) => { return cache.addAll(PRECACHE_ASSETS); }).then(() => self.skipWaiting()) ); }); // ─── Activate Event ─────────────────────────────────────────────────────────── self.addEventListener('activate', (event) => { event.waitUntil( Promise.all([ // Delete old caches caches.keys().then((cacheNames) => { return Promise.all( cacheNames .filter((name) => name.startsWith('workroot-') && name !== STATIC_CACHE && name !== PAGE_CACHE) .map((name) => caches.delete(name)) ); }), // Take control immediately self.clients.claim(), ]) ); }); // ─── Fetch Event ────────────────────────────────────────────────────────────── self.addEventListener('fetch', (event) => { const { request } = event; const url = new URL(request.url); // Only handle same-origin requests if (url.origin !== self.location.origin) return; // Skip non-GET requests (POST etc. handled via background sync) if (request.method !== 'GET') return; // Skip API routes — always go to network if (url.pathname.startsWith('/api/')) return; // Static assets: cache-first if (isStaticAsset(url.pathname)) { event.respondWith(cacheFirst(request, STATIC_CACHE)); return; } // HTML pages: stale-while-revalidate with offline fallback if (request.headers.get('Accept')?.includes('text/html')) { event.respondWith(staleWhileRevalidate(request)); return; } }); // ─── Background Sync Event ─────────────────────────────────────────────────── self.addEventListener('sync', (event) => { if (event.tag === 'form-sync') { event.waitUntil(processSyncQueue()); } }); // ─── Push Event (for future notifications) ────────────────────────────────── self.addEventListener('push', (event) => { if (!event.data) return; const data = event.data.json(); event.waitUntil( self.registration.showNotification(data.title || 'WorkRoot', { body: data.body || '', icon: '/apple-touch-icon.png', badge: '/favicon.svg', data: data.url ? { url: data.url } : undefined, }) ); }); self.addEventListener('notificationclick', (event) => { event.notification.close(); if (event.notification.data?.url) { event.waitUntil(clients.openWindow(event.notification.data.url)); } }); // ─── Strategy Helpers ───────────────────────────────────────────────────────── function isStaticAsset(pathname) { return ( pathname.startsWith('/_assets/') || /\.(js|css|woff2?|ttf|otf|eot|png|jpg|jpeg|gif|webp|avif|svg|ico)$/i.test(pathname) ); } async function cacheFirst(request, cacheName) { 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) { cache.put(request, response.clone()); } return response; } catch { return new Response('Asset unavailable offline', { status: 503 }); } } async function staleWhileRevalidate(request) { const cache = await caches.open(PAGE_CACHE); const cached = await cache.match(request); const fetchPromise = fetch(request) .then((response) => { if (response.ok) { cache.put(request, response.clone()); } return response; }) .catch(() => null); if (cached) { // Serve cached immediately, update in background fetchPromise.catch(() => {}); return cached; } // No cache — await network const response = await fetchPromise; if (response) return response; // Network failed and no cache — serve offline fallback const offlineFallback = await cache.match('/offline') || await caches.match('/offline'); if (offlineFallback) return offlineFallback; return new Response( '
Please check your connection.
', { status: 503, headers: { 'Content-Type': 'text/html' } } ); } // ─── Background Sync Queue ──────────────────────────────────────────────────── async function processSyncQueue() { const db = await openDB(); const queue = await getAllFromDB(db, SYNC_QUEUE); for (const item of queue) { try { const response = await fetch(item.url, { method: item.method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(item.body), }); if (response.ok) { await deleteFromDB(db, SYNC_QUEUE, item.id); // Notify any open clients of success const clients = await self.clients.matchAll(); clients.forEach((client) => { client.postMessage({ type: 'SYNC_SUCCESS', id: item.id, formType: item.formType }); }); } } catch { // Leave in queue for next sync attempt } } } // ─── IndexedDB Helpers ──────────────────────────────────────────────────────── function openDB() { return new Promise((resolve, reject) => { const request = indexedDB.open('workroot-pwa', 1); request.onupgradeneeded = (event) => { const db = event.target.result; if (!db.objectStoreNames.contains(SYNC_QUEUE)) { const store = db.createObjectStore(SYNC_QUEUE, { keyPath: 'id', autoIncrement: true }); store.createIndex('timestamp', 'timestamp', { unique: false }); } }; request.onsuccess = (event) => resolve(event.target.result); request.onerror = () => reject(request.error); }); } function getAllFromDB(db, storeName) { return new Promise((resolve, reject) => { const tx = db.transaction(storeName, 'readonly'); const store = tx.objectStore(storeName); const request = store.getAll(); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); } function deleteFromDB(db, storeName, id) { return new Promise((resolve, reject) => { const tx = db.transaction(storeName, 'readwrite'); const store = tx.objectStore(storeName); const request = store.delete(id); request.onsuccess = () => resolve(); request.onerror = () => reject(request.error); }); }