diff --git a/apps/web/src/service-worker-sw.ts b/apps/web/src/service-worker-sw.ts index 5bd8eebd4..b66035f8e 100644 --- a/apps/web/src/service-worker-sw.ts +++ b/apps/web/src/service-worker-sw.ts @@ -1,13 +1,13 @@ /// /** - * Custom Service Worker with Background Precaching - Simplified Single Cache + * Custom Service Worker with Background Precaching * - * Key improvements: - * 1. Single cache bucket (app-cache-v1) for all resources - * 2. No expiration/maxEntries (files are hashed, no need for cleanup) - * 3. Explicit cache checking before precaching (avoid duplicate downloads) - * 4. Singleton pattern for background precacher (avoid duplicate instances) + * Cache schema v2 (post HTML-under-JS poison incident): + * 1. CacheFirst only for hashed /static JS/CSS (not all same-origin) + * 2. MIME validation on every cache write/read path + * 3. Known legacy buckets deleted on activate (not every origin cache) + * 4. Background precache + install use the same validation */ import { registerRoute } from 'workbox-routing'; @@ -38,6 +38,13 @@ type CacheWillUpdatePlugin = { }) => Promise; }; +type CachedResponseWillBeUsedPlugin = { + cachedResponseWillBeUsed?: (args: { + request: Request; + cachedResponse?: Response; + }) => Promise; +}; + // TypeScript declarations for Service Worker context declare const self: ServiceWorkerGlobalScope; @@ -45,7 +52,11 @@ declare const self: ServiceWorkerGlobalScope; // Configuration // ============================================================================ -const CACHE_NAME = 'app-cache-v1'; +/** Cache schema version — bump only when cache semantics change (not every deploy). */ +const CACHE_NAME = 'app-cache-v2'; + +/** Previous Refly SW cache buckets to drop on activate. */ +const LEGACY_CACHE_NAMES = ['app-cache-v1'] as const; const getClientId = (event: ExtendableEvent): string | null => { if ('clientId' in event) { @@ -54,6 +65,87 @@ const getClientId = (event: ExtendableEvent): string | null => { return null; }; +const getContentType = (response: Response): string => { + return (response.headers.get('content-type') || '').toLowerCase().split(';')[0].trim(); +}; + +const isJavaScriptContentType = (contentType: string): boolean => { + return ( + contentType === 'application/javascript' || + contentType === 'text/javascript' || + contentType === 'application/x-javascript' + ); +}; + +const isCssContentType = (contentType: string): boolean => { + return contentType === 'text/css'; +}; + +const isHtmlContentType = (contentType: string): boolean => { + return contentType === 'text/html' || contentType === 'application/xhtml+xml'; +}; + +/** Hashed static JS/CSS under /static/ — safe for long-lived CacheFirst. */ +const isStaticAssetPath = (pathname: string): boolean => { + if (!pathname.startsWith('/static/')) { + return false; + } + return pathname.endsWith('.js') || pathname.endsWith('.css'); +}; + +const isCacheableStaticResponse = (request: Request, response: Response): boolean => { + if (!response || response.status !== 200) { + return false; + } + + const pathname = new URL(request.url).pathname; + if (!isStaticAssetPath(pathname)) { + return false; + } + + const contentType = getContentType(response); + // SPA fallbacks are 200 text/html — never cache those as assets + if (!contentType || isHtmlContentType(contentType)) { + return false; + } + + if (pathname.endsWith('.js')) { + return isJavaScriptContentType(contentType); + } + if (pathname.endsWith('.css')) { + return isCssContentType(contentType); + } + return false; +}; + +const isCacheableHtmlResponse = (response: Response): boolean => { + if (!response || response.status !== 200) { + return false; + } + const contentType = getContentType(response); + // Require explicit HTML MIME — never cache unknown/empty types as documents + return isHtmlContentType(contentType); +}; + +/** Put only if response MIME matches the static asset URL. */ +const putStaticAssetIfValid = async ( + cache: Cache, + request: Request, + response: Response, +): Promise => { + if (!isCacheableStaticResponse(request, response)) { + console.warn( + '[SW] Skip cache put (invalid static response):', + request.url, + response.status, + getContentType(response), + ); + return false; + } + await cache.put(request, response.clone()); + return true; +}; + const normalizeHtmlCacheKey = (request: Request): string => { const url = new URL(request.url); @@ -83,6 +175,21 @@ const isSsrPath = (path: string): boolean => { ); }; +const isHtmlDocumentPath = (pathname: string): boolean => { + if ( + pathname.startsWith('/static/') || + pathname.startsWith('/api/') || + pathname.startsWith('/v1/') + ) { + return false; + } + if (pathname.includes('.')) { + // Likely a file path (e.g. /logo.svg, /config.js) — not app HTML shell + return false; + } + return true; +}; + // ============================================================================ // Service Worker Lifecycle // ============================================================================ @@ -115,35 +222,140 @@ self.addEventListener('install', (event) => { `[SW] Precaching ${criticalUrls.length} critical resources (filtered from ${self.__WB_MANIFEST.length})`, ); - try { - await cache.addAll(criticalUrls); - console.log('[SW] Critical resources precached'); - } catch (error) { - console.error('[SW] Precache failed:', error); - } finally { - await self.skipWaiting(); + // Validated fetch+put (never cache.addAll — SPA HTML 200 would poison the cache). + // Fail the install if any critical asset is missing/invalid so we do not + // skipWaiting + delete v1 while leaving clients without a usable v2 cache. + if (criticalUrls.length === 0) { + throw new Error('[SW] No critical URLs found in __WB_MANIFEST'); } + + await Promise.all( + criticalUrls.map(async (url) => { + const request = new Request(url); + const response = await fetch(request, { cache: 'no-cache' }); + const ok = await putStaticAssetIfValid(cache, request, response); + if (!ok) { + throw new Error( + `Invalid critical asset: ${url} status=${response.status} ct=${getContentType(response)}`, + ); + } + }), + ); + + console.log('[SW] Critical resources precached'); + await self.skipWaiting(); })(), ); }); +/** Entries worth carrying from v1 → v2 for open tabs / offline shells. */ +const isMigratableLegacyEntry = (request: Request, response: Response, url: URL): boolean => { + if (url.origin !== self.location.origin) { + return false; + } + // Hashed static JS/CSS (open-tab lazy chunks) + if (isStaticAssetPath(url.pathname)) { + return isCacheableStaticResponse(request, response); + } + // App HTML shells for offline refresh (skip home/SSR — those are NetworkOnly) + if (isHtmlDocumentPath(url.pathname) && url.pathname !== '/' && !isSsrPath(url.pathname)) { + return isCacheableHtmlResponse(response); + } + return false; +}; + +/** + * Best-effort copy of migratable entries from a legacy bucket into v2. + * Never throws: quota / put failures must not block activate (delete + claim). + * Deletes each source entry after put to avoid doubling storage use. + */ +const migrateValidEntriesFromLegacy = async ( + legacyName: string, + target: Cache, +): Promise => { + let legacy: Cache; + try { + legacy = await caches.open(legacyName); + } catch { + return 0; + } + + let keys: readonly Request[]; + try { + keys = await legacy.keys(); + } catch { + return 0; + } + + let migrated = 0; + + // Sequential: release source entries as we go so quota stays roughly flat. + for (const request of keys) { + try { + let url: URL; + try { + url = new URL(request.url); + } catch { + continue; + } + + const response = await legacy.match(request); + if (!response || !isMigratableLegacyEntry(request, response, url)) { + continue; + } + + const existing = await target.match(request); + if (!existing) { + // response body is only used here — no clone needed + await target.put(request, response); + migrated += 1; + } + + // Free space whether we copied or v2 already had it + await legacy.delete(request); + } catch (error) { + // QuotaExceededError or transient cache errors — stop migrating, still activate + console.warn('[SW] Legacy migrate stopped early:', legacyName, error); + break; + } + } + + return migrated; +}; + self.addEventListener('activate', (event) => { console.log('[SW] Activate event'); event.waitUntil( (async () => { - // Clean up old caches - const cacheNames = await caches.keys(); - await Promise.all( - cacheNames - .filter((name) => name !== CACHE_NAME) - .map((name) => { - console.log(`[SW] Deleting old cache: ${name}`); - return caches.delete(name); - }), - ); + try { + const cache = await caches.open(CACHE_NAME); + + // Preserve open-tab lazy chunks + offline HTML shells from v1 → v2, then drop v1. + // Migration is best-effort; activate must still complete on quota pressure. + for (const name of LEGACY_CACHE_NAMES) { + try { + const migrated = await migrateValidEntriesFromLegacy(name, cache); + if (migrated > 0) { + console.log(`[SW] Migrated ${migrated} cache entries from ${name}`); + } + } catch (error) { + console.warn(`[SW] Legacy migrate failed for ${name}:`, error); + } + try { + const deleted = await caches.delete(name); + if (deleted) { + console.log(`[SW] Deleted legacy cache: ${name}`); + } + } catch (error) { + console.warn(`[SW] Legacy delete failed for ${name}:`, error); + } + } + } catch (error) { + console.error('[SW] Activate cache setup failed:', error); + } - // Take control immediately + // Always claim so MIME guards take effect even if migration was partial await self.clients.claim(); console.log('[SW] Activated, waiting for page load before starting precache'); @@ -168,7 +380,10 @@ registerRoute( ); // === Strategy 2: API requests - NetworkOnly (never cache API responses) === -registerRoute(({ url }) => url.pathname.startsWith('/api/'), new NetworkOnly()); +registerRoute( + ({ url }) => url.pathname.startsWith('/api/') || url.pathname.startsWith('/v1/'), + new NetworkOnly(), +); // === Strategy 3: HTML (non-home) - StaleWhileRevalidate with version check === registerRoute( @@ -186,8 +401,8 @@ registerRoute( } satisfies CacheKeyPlugin, { cacheWillUpdate: async ({ request, response, event }) => { - // Only cache successful responses - if (!response || response.status !== 200) { + // Only cache successful HTML document responses + if (!isCacheableHtmlResponse(response)) { return null; } @@ -234,18 +449,15 @@ registerRoute( clientId, ); - // 1. Clear all old HTML caches (except SSR pages) + // 1. Clear old same-origin HTML shell caches only (never touch /static/*) const allCachedRequests = await cache.keys(); const htmlCachesToDelete = allCachedRequests.filter((req) => { - const url = new URL(req.url); - // Match both: - // - req.destination === 'document' (from fetch events) - // - req.destination === '' (from cache.put with string keys) - return ( - (req.destination === 'document' || req.destination === '') && - url.pathname !== '/' && - !isSsrPath(url.pathname) - ); + const cachedUrl = new URL(req.url); + if (cachedUrl.origin !== self.location.origin) { + return false; + } + const path = cachedUrl.pathname; + return isHtmlDocumentPath(path) && path !== '/' && !isSsrPath(path); }); console.log(`[SW] Clearing ${htmlCachesToDelete.length} old HTML caches`); @@ -271,7 +483,7 @@ registerRoute( const routeResponse = await fetch(routeUrl, { cache: 'no-cache', }); - if (routeResponse.ok) { + if (isCacheableHtmlResponse(routeResponse)) { await cache.put(routeUrl, routeResponse); console.log('[SW] Precached new HTML:', route); } @@ -306,26 +518,48 @@ registerRoute( }), ); -// === Strategy 4: All other same-origin resources - CacheFirst === +// === Strategy 4: Hashed static JS/CSS only - CacheFirst + MIME guards === registerRoute( - ({ url }) => url.origin === self.location.origin, + ({ url }) => url.origin === self.location.origin && isStaticAssetPath(url.pathname), new CacheFirst({ cacheName: CACHE_NAME, plugins: [ new CacheableResponsePlugin({ statuses: [200], }), - // Add debug logging to see if cache matching works + { + cacheWillUpdate: async ({ request, response }) => { + if (!response || !isCacheableStaticResponse(request, response)) { + return null; + } + return response; + }, + } satisfies CacheWillUpdatePlugin, { cachedResponseWillBeUsed: async ({ request, cachedResponse }) => { - if (cachedResponse) { - console.log('[SW] Cache HIT:', request.url); - } else { + if (!cachedResponse) { console.log('[SW] Cache MISS:', request.url); + return undefined; } + // Heal poisoned entries (e.g. SPA HTML stored under a .js URL) + if (!isCacheableStaticResponse(request, cachedResponse)) { + console.warn( + '[SW] Dropping poisoned cache entry:', + request.url, + getContentType(cachedResponse), + ); + try { + const cache = await caches.open(CACHE_NAME); + await cache.delete(request); + } catch { + // ignore delete failures + } + return null; + } + console.log('[SW] Cache HIT:', request.url); return cachedResponse; }, - }, + } satisfies CachedResponseWillBeUsedPlugin, ], matchOptions: { ignoreSearch: false, @@ -660,25 +894,25 @@ class ServiceWorkerBackgroundPrecache { activePrecacheControllers.add(controller); try { - // Create Request object for consistent cache key + // Create Request object for consistent cache key const request = new Request(url); - // Check if already cached (real-time check) + // Skip only if a *valid* static asset is already cached const cached = await cache.match(request); - if (cached) { + if (cached && isCacheableStaticResponse(request, cached)) { return; } + if (cached) { + await cache.delete(request); + } - // Fetch and explicitly cache + // Fetch and cache only valid static JS/CSS (reject SPA HTML fallbacks) const response = await fetch(request, { cache: 'default', signal: controller.signal, }); - if (response.ok) { - // Use Request object as key for consistent matching with Workbox - await cache.put(request, response.clone()); - } + await putStaticAssetIfValid(cache, request, response); } catch (error) { if (error?.name !== 'AbortError') { console.warn('[SW] Failed to fetch:', url); @@ -793,4 +1027,4 @@ self.addEventListener('fetch', (event) => { } }); -console.log('[SW] Service Worker loaded with simplified single-cache architecture'); +console.log('[SW] Service Worker loaded (cache schema v2, static MIME guards)');