// NOTE: This service worker is for the web/PWA build. // For development we aggressively unregister SWs in `src/player.js`. // // This value is rewritten automatically before each build so deployed clients // refresh to the newest shell and cached assets. const CACHE_NAME = 'radioplayer-pwa-v5-1777404493334'; const CORE_ASSETS = [ './', 'index.html', 'privacy.html', 'data/radio-stations.json', 'stations.json', 'manifest.json', 'images/radio-placeholder.svg', 'assets/radioplayer-logo-192.png', 'assets/radioplayer-logo-512.png', ]; const CORE_PATHS = new Set(CORE_ASSETS.map((p) => new URL(p, self.registration.scope).pathname)); const DATA_PATHS = new Set([ new URL('data/radio-stations.json', self.registration.scope).pathname, new URL('stations.json', self.registration.scope).pathname, new URL('manifest.json', self.registration.scope).pathname, ]); const IMAGE_FALLBACK_PATH = new URL('images/radio-placeholder.svg', self.registration.scope).pathname; function isCacheableResponse(response) { return Boolean(response && response.ok && response.type === 'basic'); } async function putInCache(request, response) { if (!isCacheableResponse(response)) return; const cache = await caches.open(CACHE_NAME); await cache.put(request, response.clone()); } async function precacheBuiltAssets(cache) { try { const indexResp = await fetch(new Request('./', { cache: 'reload' })); if (isCacheableResponse(indexResp)) { await cache.put('./', indexResp.clone()); } const indexText = await indexResp.text(); const assetUrls = [...indexText.matchAll(/(?:src|href)="([^"]+)"/g)] .map((match) => match[1]) .filter((assetPath) => assetPath.startsWith('./assets/') || assetPath.startsWith('assets/')) .map((assetPath) => new URL(assetPath, self.registration.scope).href); await Promise.allSettled(assetUrls.map(async (assetUrl) => { const assetResp = await fetch(new Request(assetUrl, { cache: 'reload' })); if (!isCacheableResponse(assetResp)) return; await cache.put(assetUrl, assetResp); })); } catch (e) { // If HTML parsing fails, runtime caching below still catches assets. } } async function networkFirst(request, fallbackRequest) { try { const networkResp = await fetch(request); await putInCache(request, networkResp); return networkResp; } catch (error) { const cached = await caches.match(request); if (cached) return cached; if (fallbackRequest) { const fallback = await caches.match(fallbackRequest); if (fallback) return fallback; } throw error; } } async function staleWhileRevalidate(request, fallbackRequest) { const cached = await caches.match(request); const networkPromise = fetch(request) .then(async (networkResp) => { await putInCache(request, networkResp); return networkResp; }) .catch(async () => { if (fallbackRequest) { const fallback = await caches.match(fallbackRequest); if (fallback) return fallback; } return null; }); if (cached) { return cached; } const networkResp = await networkPromise; if (networkResp) return networkResp; return new Response('', { status: 503, statusText: 'Service Unavailable' }); } self.addEventListener('install', (event) => { // Activate updated SW as soon as it's installed. self.skipWaiting(); event.waitUntil( caches.open(CACHE_NAME).then(async (cache) => { const reqs = CORE_ASSETS.map((p) => new Request(p, { cache: 'reload' })); await Promise.allSettled(reqs.map(async (request) => { const response = await fetch(request); if (!isCacheableResponse(response)) return; await cache.put(request, response); })); // Vite fingerprints JS/CSS assets in production. Parse the built HTML so // the installed PWA can launch offline after its first install. await precacheBuiltAssets(cache); }) ); }); self.addEventListener('activate', (event) => { event.waitUntil( Promise.all([ self.clients.claim(), caches.keys().then((keys) => Promise.all( keys.map((k) => { if (k !== CACHE_NAME) return caches.delete(k); return null; }) )), ]) ); }); self.addEventListener('fetch', (event) => { // Only handle GET requests if (event.request.method !== 'GET') return; if (event.request.headers.has('range')) return; const url = new URL(event.request.url); // Don't cache cross-origin requests (station logos, APIs, etc.). if (url.origin !== self.location.origin) { return; } const isCore = CORE_PATHS.has(url.pathname); const isDataRequest = DATA_PATHS.has(url.pathname); const isHtmlNavigation = event.request.mode === 'navigate' || (event.request.headers.get('accept') || '').includes('text/html'); const isStaticAsset = ['script', 'style', 'font', 'worker'].includes(event.request.destination) || url.pathname.includes('/assets/'); const isImage = event.request.destination === 'image'; if (url.pathname.endsWith('/sw.js')) { return; } // Network-first for navigations and core assets to prevent "old UI" issues. if (isHtmlNavigation) { event.respondWith( networkFirst(event.request, './').catch( () => caches.match('./') || caches.match('index.html') || new Response('', { status: 503, statusText: 'Offline' }) ) ); return; } if (isCore || isDataRequest) { event.respondWith( networkFirst(event.request).catch( () => caches.match(event.request) || new Response('', { status: 503, statusText: 'Offline' }) ) ); return; } if (isStaticAsset) { event.respondWith(staleWhileRevalidate(event.request)); return; } if (isImage) { event.respondWith(staleWhileRevalidate(event.request, IMAGE_FALLBACK_PATH)); return; } event.respondWith( staleWhileRevalidate(event.request) ); });