/* ============================================================================ * sw.js — YT Player Service Worker * * Strategy: * App shell (HTML/CSS/JS) → cache-first, versioned cache * /api/* requests → network-only (never cache yt-dlp results) * /api/download/* → network-only (streamed binary, never cache) * YouTube thumbnails (i.ytimg.com) → stale-while-revalidate * Everything else → network, fallback to cache * * Auto-update flow: * 1. New SW installs alongside the old one. * 2. activate: if an older *versioned shell cache* is found (i.e. this * activation is genuinely replacing a previous deploy, not just the * first-ever install of a freshly (re)registered worker), broadcast * SW_UPDATE_AVAILABLE to all clients. * 3. Client shows "Update ready" banner. * 4. User clicks "Refresh UI" → client (hardReloadUI in app.js) wipes every * cache, unregisters the SW, and hard-navigates to fetch everything * fresh from the network. * ========================================================================== */ // BUILD_TAG is injected by the server at request time (GET /sw.js). // It changes on every deploy/restart so the cache is busted automatically // without any manual version bump. const VERSION = typeof __BUILD_TAG__ !== 'undefined' ? __BUILD_TAG__ : 'v1.0.3'; const CACHE = 'ytplayer-' + VERSION; // Prefix shared by every versioned app-shell cache (ytplayer-). // Utility caches (ytplayer-thumbs, ytplayer-fonts) intentionally do NOT // match this — they aren't versioned and must survive every activate. const SHELL_CACHE_PREFIX = 'ytplayer-'; const UTILITY_CACHES = new Set(['ytplayer-thumbs', 'ytplayer-fonts']); function isVersionedShellCache(key) { return key.startsWith(SHELL_CACHE_PREFIX) && !UTILITY_CACHES.has(key); } // Files that form the installable app shell. const SHELL = [ '/', '/index.html', '/styles.css', '/async-guard.js', '/fingerprint.js', '/opfs.js', '/app.js', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/icon-512.png', ]; // ---- Install: pre-cache the app shell ---- self.addEventListener('install', (e) => { e.waitUntil( caches.open(CACHE).then((cache) => cache.addAll(SHELL)) // skipWaiting() is NOT called here — we wait for the client to confirm // before activating, so the update banner can appear first. ); }); // ---- Activate: evict old caches, claim clients, notify about update ---- self.addEventListener('activate', (e) => { e.waitUntil((async () => { const keys = await caches.keys(); // Was there a *previous deploy's* app-shell cache lying around? If so, // this activation is a genuine version bump — worth telling the client // about. If the only versioned shell cache present is our own CACHE (or // none at all), this is the first-ever activation of a freshly // (re)registered worker — e.g. right after hardReloadUI() unregisters // the old SW and hard-navigates — and there is nothing new to report. // Without this check, that harmless re-install would re-broadcast // SW_UPDATE_AVAILABLE and immediately reopen the "Update ready" banner // the user just dismissed by clicking "Reload now". const staleShellCaches = keys.filter((k) => isVersionedShellCache(k) && k !== CACHE); const isGenuineUpdate = staleShellCaches.length > 0; // Delete every stale *versioned shell* cache — never the utility caches // (thumbs/fonts), which aren't tied to a deploy version and should // survive every activate. await Promise.all(staleShellCaches.map((k) => caches.delete(k))); // Claim all open clients immediately (new installs) await self.clients.claim(); if (isGenuineUpdate) { // Broadcast to every open window so the app can show an update banner const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); all.forEach((c) => c.postMessage({ type: 'SW_UPDATE_AVAILABLE', version: VERSION })); } })()); }); // ---- Fetch: routing logic ---- self.addEventListener('fetch', (e) => { const { request } = e; const url = new URL(request.url); // Only intercept GET/HEAD — let POST (sync endpoint) go through unmodified if (request.method !== 'GET' && request.method !== 'HEAD') return; // API calls and binary downloads → network only, no caching if (url.pathname.startsWith('/api/')) { e.respondWith(fetch(request)); return; } // YouTube thumbnails → stale-while-revalidate (fast load, fresh in background) if (url.hostname === 'i.ytimg.com') { e.respondWith(staleWhileRevalidate(request, 'ytplayer-thumbs')); return; } // Google Fonts CSS — stale-while-revalidate so offline doesn't break type if (url.hostname === 'fonts.googleapis.com' || url.hostname === 'fonts.gstatic.com') { e.respondWith(staleWhileRevalidate(request, 'ytplayer-fonts')); return; } // App shell → cache-first, then network, then generic offline fallback e.respondWith(cacheFirst(request)); }); // ---- Message: handle SKIP_WAITING from the client ---- self.addEventListener('message', (e) => { if (e.data && e.data.type === 'SKIP_WAITING') { self.skipWaiting(); } }); // ============================================================================ // Fetch helpers // ============================================================================ // Cache-first: serve from cache; if missing, fetch, cache, return. async function cacheFirst(request) { const cache = await caches.open(CACHE); const cached = await cache.match(request); if (cached) return cached; try { const response = await fetch(request); // Only cache successful, non-opaque responses if (response && response.status === 200 && response.type !== 'opaque') { cache.put(request, response.clone()); } return response; } catch { // Network failed and nothing in cache — return a minimal offline page // for navigation requests; let sub-resources fail naturally. if (request.mode === 'navigate') { const nav = await cache.match('/index.html'); if (nav) return nav; } return new Response('Offline', { status: 503, statusText: 'Service Unavailable' }); } } // Stale-while-revalidate: return cached immediately, update in background. async function staleWhileRevalidate(request, cacheName) { const cache = await caches.open(cacheName); const cached = await cache.match(request); // Start a background revalidation — don't await it before responding const networkFetch = fetch(request).then((r) => { if (r && r.status === 200) cache.put(request, r.clone()); return r; }).catch(() => null); return cached || networkFetch; }