// UK Data Services - Service Worker for PWA Functionality // Version 1.0 - Advanced caching and offline support const CACHE_NAME = 'ukds-pwa-v1.0.0'; const STATIC_CACHE = 'ukds-static-v1.0.0'; const DYNAMIC_CACHE = 'ukds-dynamic-v1.0.0'; const IMAGE_CACHE = 'ukds-images-v1.0.0'; // Files to cache immediately (critical resources) const STATIC_ASSETS = [ '/', '/index.php', '/assets/css/main.min.css', '/assets/js/main.min.js', '/assets/images/ukds-main-logo.webp', '/assets/images/ukds-main-logo.png', '/assets/images/logo-white.svg', '/assets/images/favicon.svg', '/manifest.json', '/offline.html' ]; // Network-first resources (always try network first) const NETWORK_FIRST = [ '/quote.php', '/contact-handler.php', '/quote-handler.php' ]; // Cache-first resources (images, fonts, static assets) const CACHE_FIRST = [ '/assets/images/', '/assets/fonts/', 'https://fonts.googleapis.com/', 'https://fonts.gstatic.com/' ]; // Install event - cache critical resources self.addEventListener('install', (event) => { console.log('[SW] Installing service worker...'); event.waitUntil( Promise.all([ // Cache static assets caches.open(STATIC_CACHE).then((cache) => { console.log('[SW] Caching static assets'); return cache.addAll(STATIC_ASSETS); }), // Skip waiting to activate immediately self.skipWaiting() ]) ); }); // Activate event - clean up old caches self.addEventListener('activate', (event) => { console.log('[SW] Activating service worker...'); event.waitUntil( Promise.all([ // Clean up old caches caches.keys().then((cacheNames) => { return Promise.all( cacheNames.map((cacheName) => { if (!isCurrentCache(cacheName)) { console.log('[SW] Deleting old cache:', cacheName); return caches.delete(cacheName); } }) ); }), // Take control of all clients self.clients.claim() ]) ); }); // Fetch event - handle all network requests self.addEventListener('fetch', (event) => { const { request } = event; const url = new URL(request.url); // Skip non-GET requests if (request.method !== 'GET') { return; } // Skip external analytics and third-party requests if (url.origin !== location.origin && !isCacheableExternal(url.href)) { return; } event.respondWith(handleRequest(request)); }); // Handle different types of requests with appropriate strategies async function handleRequest(request) { const url = new URL(request.url); try { // Network-first strategy for forms and dynamic content if (isNetworkFirst(request.url)) { return await networkFirst(request); } // Cache-first strategy for images and static assets if (isCacheFirst(request.url)) { return await cacheFirst(request); } // Stale-while-revalidate for HTML pages if (isHTML(request)) { return await staleWhileRevalidate(request); } // Default to network-first return await networkFirst(request); } catch (error) { console.log('[SW] Request failed:', error); return await handleOffline(request); } } // Network-first strategy async function networkFirst(request) { try { const networkResponse = await fetch(request); // Cache successful responses if (networkResponse.ok) { const cache = await caches.open(DYNAMIC_CACHE); cache.put(request, networkResponse.clone()); } return networkResponse; } catch (error) { // Fallback to cache const cachedResponse = await caches.match(request); if (cachedResponse) { return cachedResponse; } throw error; } } // Cache-first strategy async function cacheFirst(request) { const cachedResponse = await caches.match(request); if (cachedResponse) { return cachedResponse; } try { const networkResponse = await fetch(request); if (networkResponse.ok) { const cache = await caches.open(getAppropriateCache(request.url)); cache.put(request, networkResponse.clone()); } return networkResponse; } catch (error) { throw error; } } // Stale-while-revalidate strategy async function staleWhileRevalidate(request) { const cachedResponse = await caches.match(request); // Always try to update in background const networkPromise = fetch(request).then(async (networkResponse) => { if (networkResponse.ok) { const cache = await caches.open(DYNAMIC_CACHE); cache.put(request, networkResponse.clone()); } return networkResponse; }).catch(() => { // Ignore network errors for background updates }); // Return cached version immediately if available if (cachedResponse) { return cachedResponse; } // Wait for network if no cache return await networkPromise; } // Handle offline scenarios async function handleOffline(request) { const url = new URL(request.url); // Try to find cached version const cachedResponse = await caches.match(request); if (cachedResponse) { return cachedResponse; } // Return offline page for HTML requests if (isHTML(request)) { const offlinePage = await caches.match('/offline.html'); if (offlinePage) { return offlinePage; } } // Return generic offline response return new Response( JSON.stringify({ error: 'Offline', message: 'This content is not available offline' }), { status: 503, statusText: 'Service Unavailable', headers: { 'Content-Type': 'application/json' } } ); } // Utility functions function isCurrentCache(cacheName) { return [CACHE_NAME, STATIC_CACHE, DYNAMIC_CACHE, IMAGE_CACHE].includes(cacheName); } function isNetworkFirst(url) { return NETWORK_FIRST.some(pattern => url.includes(pattern)); } function isCacheFirst(url) { return CACHE_FIRST.some(pattern => url.includes(pattern)); } function isHTML(request) { return request.headers.get('accept')?.includes('text/html'); } function isCacheableExternal(url) { return url.includes('fonts.googleapis.com') || url.includes('fonts.gstatic.com') || url.includes('cdnjs.cloudflare.com'); } function getAppropriateCache(url) { if (url.includes('/assets/images/')) { return IMAGE_CACHE; } if (url.includes('/assets/')) { return STATIC_CACHE; } return DYNAMIC_CACHE; } // Background sync for form submissions self.addEventListener('sync', (event) => { if (event.tag === 'quote-submission') { event.waitUntil(syncQuoteSubmissions()); } if (event.tag === 'contact-submission') { event.waitUntil(syncContactSubmissions()); } }); // Handle quote submissions when back online async function syncQuoteSubmissions() { try { const submissions = await getStoredSubmissions('quote'); for (const submission of submissions) { try { const response = await fetch('/quote-handler.php', { method: 'POST', body: submission.data }); if (response.ok) { await removeStoredSubmission('quote', submission.id); console.log('[SW] Quote submission synced:', submission.id); } } catch (error) { console.log('[SW] Failed to sync quote submission:', error); } } } catch (error) { console.log('[SW] Sync failed:', error); } } // Handle contact submissions when back online async function syncContactSubmissions() { try { const submissions = await getStoredSubmissions('contact'); for (const submission of submissions) { try { const response = await fetch('/contact-handler.php', { method: 'POST', body: submission.data }); if (response.ok) { await removeStoredSubmission('contact', submission.id); console.log('[SW] Contact submission synced:', submission.id); } } catch (error) { console.log('[SW] Failed to sync contact submission:', error); } } } catch (error) { console.log('[SW] Sync failed:', error); } } // IndexedDB helpers for storing offline form submissions async function getStoredSubmissions(type) { // Simplified version - in production use IndexedDB return []; } async function removeStoredSubmission(type, id) { // Simplified version - in production use IndexedDB return true; } // Push notification handling self.addEventListener('push', (event) => { console.log('[SW] Push received'); const options = { body: event.data ? event.data.text() : 'New notification from UK Data Services', icon: '/assets/images/favicon-192x192.png', badge: '/assets/images/badge-72x72.png', vibrate: [200, 100, 200], tag: 'ukds-notification', actions: [ { action: 'view', title: 'View', icon: '/assets/images/icon-view.png' }, { action: 'dismiss', title: 'Dismiss', icon: '/assets/images/icon-dismiss.png' } ] }; event.waitUntil( self.registration.showNotification('UK Data Services', options) ); }); // Handle notification clicks self.addEventListener('notificationclick', (event) => { console.log('[SW] Notification clicked'); event.notification.close(); if (event.action === 'view') { event.waitUntil( clients.openWindow('/') ); } }); // Periodic background sync (if supported) self.addEventListener('periodicsync', (event) => { if (event.tag === 'update-cache') { event.waitUntil(updateCriticalResources()); } }); // Update critical resources in background async function updateCriticalResources() { try { const cache = await caches.open(STATIC_CACHE); const updatePromises = [ '/', '/assets/css/main.min.css', '/assets/js/main.min.js' ].map(url => fetch(url).then(response => { if (response.ok) { return cache.put(url, response); } }).catch(() => { // Ignore failed updates }) ); await Promise.all(updatePromises); console.log('[SW] Critical resources updated'); } catch (error) { console.log('[SW] Failed to update critical resources:', error); } } // Message handling from main thread self.addEventListener('message', (event) => { if (event.data && event.data.type === 'SKIP_WAITING') { self.skipWaiting(); } if (event.data && event.data.type === 'CACHE_URLS') { event.waitUntil( cacheUrls(event.data.urls) ); } }); // Cache specific URLs on demand async function cacheUrls(urls) { const cache = await caches.open(DYNAMIC_CACHE); const cachePromises = urls.map(url => fetch(url).then(response => { if (response.ok) { return cache.put(url, response); } }).catch(() => { // Ignore failed caching }) ); await Promise.all(cachePromises); } // Analytics for service worker performance function trackSWEvent(eventType, details = {}) { // Send analytics data when online if (navigator.onLine) { fetch('/analytics/sw-events', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: eventType, timestamp: Date.now(), ...details }) }).catch(() => { // Ignore analytics failures }); } }