From 7bdbd10498425582730b6be63e2dc900cb825cf6 Mon Sep 17 00:00:00 2001 From: mrcfps Date: Tue, 28 Jul 2026 14:11:46 +0800 Subject: [PATCH 1/5] fix(web): harden service worker against SPA HTML cache poison Bump cache schema to app-cache-v2 and only CacheFirst hashed /static JS/CSS with MIME validation on install, runtime, and background precache so 200 text/html SPA fallbacks cannot be stored as scripts. --- apps/web/src/service-worker-sw.ts | 237 ++++++++++++++++++++++++------ 1 file changed, 190 insertions(+), 47 deletions(-) diff --git a/apps/web/src/service-worker-sw.ts b/apps/web/src/service-worker-sw.ts index 5bd8eebd4b..1e5b0af716 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 | undefined; + }) => 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,90 @@ 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); + // Allow empty content-type (some edges omit it) but never non-HTML types + if (!contentType) { + return true; + } + 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 +178,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 // ============================================================================ @@ -116,11 +226,31 @@ self.addEventListener('install', (event) => { ); try { - await cache.addAll(criticalUrls); - console.log('[SW] Critical resources precached'); + // Validated fetch+put (never cache.addAll — SPA HTML 200 would poison the cache) + const results = await Promise.allSettled( + 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)}`, + ); + } + }), + ); + const failed = results.filter((r) => r.status === 'rejected'); + if (failed.length > 0) { + console.error( + `[SW] Critical precache failed for ${failed.length}/${criticalUrls.length}`, + ); + } else { + console.log('[SW] Critical resources precached'); + } } catch (error) { console.error('[SW] Precache failed:', error); } finally { + // Always activate so MIME guards take effect even if some critical assets failed await self.skipWaiting(); } })(), @@ -132,15 +262,14 @@ self.addEventListener('activate', (event) => { event.waitUntil( (async () => { - // Clean up old caches - const cacheNames = await caches.keys(); + // Only drop known legacy Refly buckets — do not wipe unrelated origin caches await Promise.all( - cacheNames - .filter((name) => name !== CACHE_NAME) - .map((name) => { - console.log(`[SW] Deleting old cache: ${name}`); - return caches.delete(name); - }), + LEGACY_CACHE_NAMES.map(async (name) => { + const deleted = await caches.delete(name); + if (deleted) { + console.log(`[SW] Deleted legacy cache: ${name}`); + } + }), ); // Take control immediately @@ -168,7 +297,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 +318,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 +366,11 @@ registerRoute( clientId, ); - // 1. Clear all old HTML caches (except SSR pages) + // 1. Clear old HTML shell caches only (never touch /static/* assets) 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 path = new URL(req.url).pathname; + return isHtmlDocumentPath(path) && path !== '/' && !isSsrPath(path); }); console.log(`[SW] Clearing ${htmlCachesToDelete.length} old HTML caches`); @@ -271,7 +396,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 +431,44 @@ 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), + ); + const cache = await caches.open(CACHE_NAME); + await cache.delete(request); + return null; } + console.log('[SW] Cache HIT:', request.url); return cachedResponse; }, - }, + } satisfies CachedResponseWillBeUsedPlugin, ], matchOptions: { ignoreSearch: false, @@ -660,25 +803,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 +936,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)'); From c832bb6334ce7477638f7253cac6e8149a5c6be0 Mon Sep 17 00:00:00 2001 From: mrcfps Date: Tue, 28 Jul 2026 14:15:40 +0800 Subject: [PATCH 2/5] fix(web): only activate SW after critical assets validate Fail install (no skipWaiting) when critical precache fails so v2 does not claim clients or delete v1 with an incomplete cache. Also require HTML MIME and same-origin when clearing document shells. --- apps/web/src/service-worker-sw.ts | 61 ++++++++++++++----------------- 1 file changed, 28 insertions(+), 33 deletions(-) diff --git a/apps/web/src/service-worker-sw.ts b/apps/web/src/service-worker-sw.ts index 1e5b0af716..2113e6452a 100644 --- a/apps/web/src/service-worker-sw.ts +++ b/apps/web/src/service-worker-sw.ts @@ -123,10 +123,7 @@ const isCacheableHtmlResponse = (response: Response): boolean => { return false; } const contentType = getContentType(response); - // Allow empty content-type (some edges omit it) but never non-HTML types - if (!contentType) { - return true; - } + // Require explicit HTML MIME — never cache unknown/empty types as documents return isHtmlContentType(contentType); }; @@ -225,34 +222,28 @@ self.addEventListener('install', (event) => { `[SW] Precaching ${criticalUrls.length} critical resources (filtered from ${self.__WB_MANIFEST.length})`, ); - try { - // Validated fetch+put (never cache.addAll — SPA HTML 200 would poison the cache) - const results = await Promise.allSettled( - 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)}`, - ); - } - }), - ); - const failed = results.filter((r) => r.status === 'rejected'); - if (failed.length > 0) { - console.error( - `[SW] Critical precache failed for ${failed.length}/${criticalUrls.length}`, - ); - } else { - console.log('[SW] Critical resources precached'); - } - } catch (error) { - console.error('[SW] Precache failed:', error); - } finally { - // Always activate so MIME guards take effect even if some critical assets failed - 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(); })(), ); }); @@ -366,10 +357,14 @@ registerRoute( clientId, ); - // 1. Clear old HTML shell caches only (never touch /static/* assets) + // 1. Clear old same-origin HTML shell caches only (never touch /static/*) const allCachedRequests = await cache.keys(); const htmlCachesToDelete = allCachedRequests.filter((req) => { - const path = new URL(req.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); }); From 8904a8640ce4fc0fc31c3172f564b91a11fb6047 Mon Sep 17 00:00:00 2001 From: mrcfps Date: Tue, 28 Jul 2026 14:25:43 +0800 Subject: [PATCH 3/5] fix(web): migrate v1 static assets and fix SW plugin types - Fix TS2322: cachedResponse is optional in Workbox callback param - On activate, copy MIME-valid /static JS/CSS from app-cache-v1 into v2 before deleting v1 so open tabs keep lazy chunks --- apps/web/src/service-worker-sw.ts | 73 ++++++++++++++++++++++++++----- 1 file changed, 61 insertions(+), 12 deletions(-) diff --git a/apps/web/src/service-worker-sw.ts b/apps/web/src/service-worker-sw.ts index 2113e6452a..576e29ef09 100644 --- a/apps/web/src/service-worker-sw.ts +++ b/apps/web/src/service-worker-sw.ts @@ -41,7 +41,7 @@ type CacheWillUpdatePlugin = { type CachedResponseWillBeUsedPlugin = { cachedResponseWillBeUsed?: (args: { request: Request; - cachedResponse: Response | undefined; + cachedResponse?: Response; }) => Promise; }; @@ -248,20 +248,65 @@ self.addEventListener('install', (event) => { ); }); +/** Copy MIME-valid hashed static assets from a legacy bucket into v2 before delete. */ +const migrateValidStaticFromLegacy = async (legacyName: string, target: Cache): Promise => { + let legacy: Cache | undefined; + try { + legacy = await caches.open(legacyName); + } catch { + return 0; + } + + const keys = await legacy.keys(); + const results = await Promise.all( + keys.map(async (request): Promise => { + let url: URL; + try { + url = new URL(request.url); + } catch { + return false; + } + if (url.origin !== self.location.origin || !isStaticAssetPath(url.pathname)) { + return false; + } + + const response = await legacy.match(request); + if (!response || !isCacheableStaticResponse(request, response)) { + return false; + } + + const existing = await target.match(request); + if (existing) { + return false; + } + + await target.put(request, response.clone()); + return true; + }), + ); + + return results.filter(Boolean).length; +}; + self.addEventListener('activate', (event) => { console.log('[SW] Activate event'); event.waitUntil( (async () => { - // Only drop known legacy Refly buckets — do not wipe unrelated origin caches - await Promise.all( - LEGACY_CACHE_NAMES.map(async (name) => { - const deleted = await caches.delete(name); - if (deleted) { - console.log(`[SW] Deleted legacy cache: ${name}`); - } - }), - ); + const cache = await caches.open(CACHE_NAME); + + // Preserve open-tab lazy chunks: migrate valid static from v1 → v2, then drop v1. + // Do not migrate HTML shells (stale) or poisoned MIME entries. + for (const name of LEGACY_CACHE_NAMES) { + const migrated = await migrateValidStaticFromLegacy(name, cache); + if (migrated > 0) { + console.log(`[SW] Migrated ${migrated} static assets from ${name}`); + } + const deleted = await caches.delete(name); + if (deleted) { + console.log(`[SW] Deleted legacy cache: ${name}`); + } + } // Take control immediately await self.clients.claim(); @@ -456,8 +501,12 @@ registerRoute( request.url, getContentType(cachedResponse), ); - const cache = await caches.open(CACHE_NAME); - await cache.delete(request); + 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); From 51d6ca8555ed1dfd7db79afc2848bc7fb997b0aa Mon Sep 17 00:00:00 2001 From: mrcfps Date: Tue, 28 Jul 2026 14:44:05 +0800 Subject: [PATCH 4/5] fix(web): make SW legacy cache migration quota-safe Migrate v1 static assets sequentially, delete each source entry after copy, and never let QuotaExceededError block activate/claim. --- apps/web/src/service-worker-sw.ts | 84 +++++++++++++++++++++---------- 1 file changed, 57 insertions(+), 27 deletions(-) diff --git a/apps/web/src/service-worker-sw.ts b/apps/web/src/service-worker-sw.ts index 576e29ef09..e4d9e00569 100644 --- a/apps/web/src/service-worker-sw.ts +++ b/apps/web/src/service-worker-sw.ts @@ -248,44 +248,62 @@ self.addEventListener('install', (event) => { ); }); -/** Copy MIME-valid hashed static assets from a legacy bucket into v2 before delete. */ +/** + * Best-effort copy of MIME-valid hashed static assets from a legacy bucket into v2. + * Never throws: quota / put failures must not block activate (delete + claim). + * Deletes each source entry after a successful put to avoid doubling storage use. + */ const migrateValidStaticFromLegacy = async (legacyName: string, target: Cache): Promise => { - let legacy: Cache | undefined; + let legacy: Cache; try { legacy = await caches.open(legacyName); } catch { return 0; } - const keys = await legacy.keys(); - const results = await Promise.all( - keys.map(async (request): Promise => { + 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 { - return false; + continue; } if (url.origin !== self.location.origin || !isStaticAssetPath(url.pathname)) { - return false; + continue; } const response = await legacy.match(request); if (!response || !isCacheableStaticResponse(request, response)) { - return false; + continue; } const existing = await target.match(request); - if (existing) { - return false; + if (!existing) { + await target.put(request, response.clone()); + migrated += 1; } - await target.put(request, response.clone()); - return true; - }), - ); + // 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 static migrate stopped early:', legacyName, error); + break; + } + } - return results.filter(Boolean).length; + return migrated; }; self.addEventListener('activate', (event) => { @@ -293,22 +311,34 @@ self.addEventListener('activate', (event) => { event.waitUntil( (async () => { - const cache = await caches.open(CACHE_NAME); + try { + const cache = await caches.open(CACHE_NAME); - // Preserve open-tab lazy chunks: migrate valid static from v1 → v2, then drop v1. - // Do not migrate HTML shells (stale) or poisoned MIME entries. - for (const name of LEGACY_CACHE_NAMES) { - const migrated = await migrateValidStaticFromLegacy(name, cache); - if (migrated > 0) { - console.log(`[SW] Migrated ${migrated} static assets from ${name}`); - } - const deleted = await caches.delete(name); - if (deleted) { - console.log(`[SW] Deleted legacy cache: ${name}`); + // Preserve open-tab lazy chunks: migrate valid static 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 migrateValidStaticFromLegacy(name, cache); + if (migrated > 0) { + console.log(`[SW] Migrated ${migrated} static assets 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'); From a8b2acbd4b6dd339ca335bc188a426d9b09ef6c9 Mon Sep 17 00:00:00 2001 From: mrcfps Date: Tue, 28 Jul 2026 15:07:41 +0800 Subject: [PATCH 5/5] fix(web): migrate offline HTML shells from SW v1 cache Also carry MIME-valid non-home/non-SSR document entries into v2 so offline refresh keeps working after legacy bucket delete. Drop unnecessary response.clone() on migrate put. --- apps/web/src/service-worker-sw.ts | 41 ++++++++++++++++++++++--------- 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/apps/web/src/service-worker-sw.ts b/apps/web/src/service-worker-sw.ts index e4d9e00569..b66035f8ef 100644 --- a/apps/web/src/service-worker-sw.ts +++ b/apps/web/src/service-worker-sw.ts @@ -248,12 +248,31 @@ self.addEventListener('install', (event) => { ); }); +/** 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 MIME-valid hashed static assets from a legacy bucket into v2. + * 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 a successful put to avoid doubling storage use. + * Deletes each source entry after put to avoid doubling storage use. */ -const migrateValidStaticFromLegacy = async (legacyName: string, target: Cache): Promise => { +const migrateValidEntriesFromLegacy = async ( + legacyName: string, + target: Cache, +): Promise => { let legacy: Cache; try { legacy = await caches.open(legacyName); @@ -279,18 +298,16 @@ const migrateValidStaticFromLegacy = async (legacyName: string, target: Cache): } catch { continue; } - if (url.origin !== self.location.origin || !isStaticAssetPath(url.pathname)) { - continue; - } const response = await legacy.match(request); - if (!response || !isCacheableStaticResponse(request, response)) { + if (!response || !isMigratableLegacyEntry(request, response, url)) { continue; } const existing = await target.match(request); if (!existing) { - await target.put(request, response.clone()); + // response body is only used here — no clone needed + await target.put(request, response); migrated += 1; } @@ -298,7 +315,7 @@ const migrateValidStaticFromLegacy = async (legacyName: string, target: Cache): await legacy.delete(request); } catch (error) { // QuotaExceededError or transient cache errors — stop migrating, still activate - console.warn('[SW] Legacy static migrate stopped early:', legacyName, error); + console.warn('[SW] Legacy migrate stopped early:', legacyName, error); break; } } @@ -314,13 +331,13 @@ self.addEventListener('activate', (event) => { try { const cache = await caches.open(CACHE_NAME); - // Preserve open-tab lazy chunks: migrate valid static from v1 → v2, then drop v1. + // 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 migrateValidStaticFromLegacy(name, cache); + const migrated = await migrateValidEntriesFromLegacy(name, cache); if (migrated > 0) { - console.log(`[SW] Migrated ${migrated} static assets from ${name}`); + console.log(`[SW] Migrated ${migrated} cache entries from ${name}`); } } catch (error) { console.warn(`[SW] Legacy migrate failed for ${name}:`, error);