/* eslint-disable no-restricted-globals */ // Service Worker for PWA support and offline functionality const CACHE_VERSION = 'v1.0.3'; const CACHE_NAME = `fotbal-club-cache-${CACHE_VERSION}`; // Rate limiting for background updates const BACKGROUND_UPDATE_INTERVAL = 5000; // 5 seconds minimum between updates const lastBackgroundUpdates = new Map(); // Assets to cache on install const STATIC_ASSETS = [ '/', '/index.html', '/manifest.json', '/favicon.ico', '/logo192.png?v=2', '/logo512.png?v=2', '/robots.txt', ]; // API endpoints to cache const API_CACHE_ENDPOINTS = [ '/api/v1/settings/public', '/api/v1/seo', ]; // Install event - cache static assets self.addEventListener('install', (event) => { console.log('[SW] Installing service worker...'); event.waitUntil( caches.open(CACHE_NAME).then((cache) => { console.log('[SW] Caching static assets'); return cache.addAll(STATIC_ASSETS.map(url => new Request(url, { cache: 'reload' }))); }).catch((error) => { console.error('[SW] Failed to cache static assets:', error); }) ); // Activate immediately self.skipWaiting(); }); // 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 .filter((name) => name !== CACHE_NAME) .map((name) => { console.log('[SW] Deleting old cache:', name); return caches.delete(name); }) ); }) ); // Take control immediately return self.clients.claim(); }); // Fetch event - serve from cache, fall back to network self.addEventListener('fetch', (event) => { const { request } = event; const url = new URL(request.url); // Skip non-GET requests if (request.method !== 'GET') { return; } // Skip Chrome extensions and non-http(s) requests if (!url.protocol.startsWith('http')) { return; } // Only handle same-origin requests if (url.origin !== self.location.origin) { return; } // Skip admin routes if (url.pathname.startsWith('/admin')) { return; } // Skip background update requests to prevent infinite loops if (request.headers.get('X-SW-Background-Update') === 'true') { return; } // Handle SPA navigations with app shell fallback if (request.mode === 'navigate') { event.respondWith(handleNavigationRequest(request)); return; } // Handle API requests if (url.pathname.startsWith('/api/')) { event.respondWith(handleAPIRequest(request)); return; } // Handle static assets and pages event.respondWith(handleStaticRequest(request)); }); // Handle static requests - Cache First strategy async function handleStaticRequest(request) { try { // Try cache first const cachedResponse = await caches.match(request); if (cachedResponse) { // For static assets with long cache headers, don't update in background const url = new URL(request.url); const isStaticAsset = url.pathname.match(/\.(png|jpg|jpeg|gif|svg|webp|ico|woff|woff2|ttf|eot)$/); const hasLongCache = cachedResponse.headers.get('cache-control')?.includes('max-age=31536000'); // Only update in background if it's not a static asset with long cache if (!isStaticAsset || !hasLongCache) { fetchAndUpdateCache(request); } return cachedResponse; } // Not in cache - fetch from network const networkResponse = await fetch(request); // Cache successful responses if (networkResponse.ok) { const cache = await caches.open(CACHE_NAME); cache.put(request, networkResponse.clone()); } return networkResponse; } catch (error) { console.error('[SW] Fetch failed:', error); // Return app shell (index.html) if available const cachedOffline = await caches.match('/index.html'); if (cachedOffline) { return cachedOffline; } // Return basic offline response return new Response('Offline - Please check your connection', { status: 503, statusText: 'Service Unavailable', headers: { 'Content-Type': 'text/plain' }, }); } } // Handle SPA navigation requests - Network First with index.html fallback async function handleNavigationRequest(request) { try { const networkResponse = await fetch(request); if (networkResponse && networkResponse.ok) { return networkResponse; } } catch (error) { console.error('[SW] Navigation fetch failed:', error); } // Fallback to cached index.html (app shell) const cachedIndex = await caches.match('/index.html'); if (cachedIndex) { return cachedIndex; } return new Response('Offline - Please check your connection', { status: 503, statusText: 'Service Unavailable', headers: { 'Content-Type': 'text/plain' }, }); } // Handle API requests - Network First strategy with cache fallback async function handleAPIRequest(request) { try { // Try network first for fresh data const networkResponse = await fetch(request); // Cache successful responses if (networkResponse.ok) { const cache = await caches.open(CACHE_NAME); cache.put(request, networkResponse.clone()); } return networkResponse; } catch (error) { console.log('[SW] Network failed, trying cache:', request.url); // Fall back to cache const cachedResponse = await caches.match(request); if (cachedResponse) { return cachedResponse; } // Return error response return new Response( JSON.stringify({ error: 'Offline - cached data not available' }), { status: 503, statusText: 'Service Unavailable', headers: { 'Content-Type': 'application/json' }, } ); } } // Update cache in background async function fetchAndUpdateCache(request) { try { // Skip if this is already a background update request to prevent infinite loops if (request.headers.get('X-SW-Background-Update') === 'true') { return; } // Rate limit background updates to prevent excessive requests const now = Date.now(); const lastUpdate = lastBackgroundUpdates.get(request.url); if (lastUpdate && (now - lastUpdate) < BACKGROUND_UPDATE_INTERVAL) { return; } lastBackgroundUpdates.set(request.url, now); // Use fetch with cache: 'no-store' to bypass service worker and avoid infinite loops const response = await fetch(request.url, { cache: 'no-store', headers: { 'X-SW-Background-Update': 'true' } }); if (response.ok) { const cache = await caches.open(CACHE_NAME); // Use the original request as key, but the new response cache.put(request, response); } } catch (error) { // Silent fail - we already returned cached version } } // Handle background sync for offline actions self.addEventListener('sync', (event) => { console.log('[SW] Background sync:', event.tag); if (event.tag === 'sync-data') { event.waitUntil(syncOfflineData()); } }); async function syncOfflineData() { // Implement offline data sync logic here // For example, sync form submissions, votes, etc. console.log('[SW] Syncing offline data...'); } // Handle push notifications self.addEventListener('push', (event) => { const data = event.data ? event.data.json() : {}; const title = data.title || 'Fotbal Club'; const options = { body: data.body || 'Nová notifikace', icon: '/logo192.png?v=2', badge: '/logo192.png?v=2', data: data.url || '/', }; event.waitUntil( self.registration.showNotification(title, options) ); }); // Handle notification clicks self.addEventListener('notificationclick', (event) => { event.notification.close(); const urlToOpen = event.notification.data || '/'; event.waitUntil( clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => { // Check if there's already a window open for (const client of clientList) { if (client.url === urlToOpen && 'focus' in client) { return client.focus(); } } // Open new window if (clients.openWindow) { return clients.openWindow(urlToOpen); } }) ); }); // Message handler for manual cache updates self.addEventListener('message', (event) => { if (event.data && event.data.type === 'SKIP_WAITING') { self.skipWaiting(); } if (event.data && event.data.type === 'CLEAR_CACHE') { event.waitUntil( caches.delete(CACHE_NAME).then(() => { return caches.open(CACHE_NAME); }) ); } }); console.log('[SW] Service Worker loaded successfully');