diff --git a/public/sw.js b/public/sw.js index f64c27e1..3049ee3f 100644 --- a/public/sw.js +++ b/public/sw.js @@ -6,15 +6,34 @@ * - During `npm run build`, `npm run build:sw` injects the commit build id into `out/sw.js`. */ const APP_VERSION = '__BUILD_ID__'; -const CACHE_NAME = `byteflow-v${APP_VERSION}`; +const CACHE_PREFIX = 'byteflow-'; +const APP_SHELL_CACHE_NAME = `byteflow-app-shell-v${APP_VERSION}`; +const STATIC_ASSET_CACHE_NAME = `byteflow-static-assets-v${APP_VERSION}`; +const TOOL_CHUNK_CACHE_NAME = `byteflow-tool-chunks-v${APP_VERSION}`; +const MANIFEST_ICON_CACHE_NAME = `byteflow-manifest-icons-v${APP_VERSION}`; +const RUNTIME_PAGE_CACHE_NAME = `byteflow-runtime-pages-v${APP_VERSION}`; const CACHE_META_NAME = `byteflow-meta-v${APP_VERSION}`; +const ACTIVE_CACHE_NAMES = [ + APP_SHELL_CACHE_NAME, + STATIC_ASSET_CACHE_NAME, + TOOL_CHUNK_CACHE_NAME, + MANIFEST_ICON_CACHE_NAME, + RUNTIME_PAGE_CACHE_NAME, + CACHE_META_NAME, +]; const OFFLINE_FALLBACK_URL = '/offline.html'; const OFFLINE_FALLBACK_CANDIDATES = [OFFLINE_FALLBACK_URL, '/offline']; const MAX_RUNTIME_CACHE_ENTRIES = 120; const MAX_RUNTIME_CACHE_AGE_MS = 7 * 24 * 60 * 60 * 1000; +const CACHE_WRITE_PAUSE_AFTER_CLEAR_MS = 5000; const CACHE_META_URL_PREFIX = '/__byteflow-cache-meta__?url='; +let cacheWritesPausedUntil = 0; + +const APP_SHELL_ASSETS = [ + ...OFFLINE_FALLBACK_CANDIDATES, +]; -const STATIC_ASSETS = [ +const MANIFEST_ICON_ASSETS = [ '/manifest.json', '/manifest.zh-CN.json', '/manifest.zh-TW.json', @@ -29,9 +48,10 @@ const STATIC_ASSETS = [ '/icon-maskable-512.png', '/icon.png', '/apple-icon.png', - ...OFFLINE_FALLBACK_CANDIDATES, ]; +const CACHEABLE_STATIC_ASSET_PATTERN = /\.(?:css|js|woff2?|ttf|png|jpg|jpeg|svg|ico|webp)$/i; + const SENSITIVE_QUERY_PARAMS = [ 'access_token', 'apikey', @@ -75,17 +95,17 @@ function matchOfflineFallback() { )); } -function cacheMetaRequest(request) { - return new Request(`${CACHE_META_URL_PREFIX}${encodeURIComponent(request.url)}`); +function cacheMetaRequest(cacheName, request) { + return new Request(`${CACHE_META_URL_PREFIX}${encodeURIComponent(`${cacheName}:${request.url}`)}`); } -function rememberCachedRequest(request) { +function rememberCachedRequest(cacheName, request) { return caches.open(CACHE_META_NAME) - .then((metaCache) => metaCache.put(cacheMetaRequest(request), new Response(String(Date.now())))); + .then((metaCache) => metaCache.put(cacheMetaRequest(cacheName, request), new Response(String(Date.now())))); } -function readCachedAt(metaCache, request) { - return metaCache.match(cacheMetaRequest(request)) +function readCachedAt(cacheName, metaCache, request) { + return metaCache.match(cacheMetaRequest(cacheName, request)) .then((response) => response ? response.text() : '0') .then((value) => { const timestamp = Number(value); @@ -93,12 +113,12 @@ function readCachedAt(metaCache, request) { }); } -function pruneRuntimeCache() { +function pruneRuntimeCache(cacheName) { const now = Date.now(); - return Promise.all([caches.open(CACHE_NAME), caches.open(CACHE_META_NAME)]) + return Promise.all([caches.open(cacheName), caches.open(CACHE_META_NAME)]) .then(([runtimeCache, metaCache]) => runtimeCache.keys() .then((requests) => Promise.all(requests.map((request) => - readCachedAt(metaCache, request).then((cachedAt) => ({ request, cachedAt })) + readCachedAt(cacheName, metaCache, request).then((cachedAt) => ({ request, cachedAt })) ))) .then((entries) => { const entriesWithExpiry = entries.map((entry) => ({ @@ -113,24 +133,88 @@ function pruneRuntimeCache() { return Promise.all([...expired, ...overflow].map((entry) => Promise.all([ runtimeCache.delete(entry.request), - metaCache.delete(cacheMetaRequest(entry.request)), + metaCache.delete(cacheMetaRequest(cacheName, entry.request)), ]) )); })); } -function putRuntimeCache(request, response) { +function isCacheWritePaused() { + return Date.now() < cacheWritesPausedUntil; +} + +function discardRuntimeCacheWrite(cacheName) { + return Promise.all([ + caches.delete(cacheName), + caches.delete(CACHE_META_NAME), + ]).then(() => undefined); +} + +function putRuntimeCache(request, response, cacheName) { + if (isCacheWritePaused()) return Promise.resolve(); const clone = response.clone(); - return caches.open(CACHE_NAME) - .then((cache) => cache.put(request, clone)) - .then(() => rememberCachedRequest(request)) - .then(() => pruneRuntimeCache()); + return caches.open(cacheName) + .then((cache) => { + if (isCacheWritePaused()) return discardRuntimeCacheWrite(cacheName); + return cache.put(request, clone).then(() => { + if (isCacheWritePaused()) return discardRuntimeCacheWrite(cacheName); + return rememberCachedRequest(cacheName, request); + }); + }) + .then(() => { + if (isCacheWritePaused()) return discardRuntimeCacheWrite(cacheName); + return pruneRuntimeCache(cacheName); + }); +} + +function isNetworkOnlyRequest(request, url) { + return ( + url.origin !== self.location.origin || + hasSensitiveQuery(url) || + request.headers.get('x-byteflow-cache-mode') === 'network-only' || + request.headers.get('x-byteflow-external-request') === '1' || + url.pathname.startsWith('/api/') + ); +} + +function isHtmlRequest(request, url) { + return url.pathname.endsWith('.html') || request.headers.get('accept')?.includes('text/html'); +} + +function isToolChunkRequest(url) { + return url.pathname.startsWith('/_next/static/chunks/'); +} + +function isNextStaticAssetRequest(url) { + return url.pathname.startsWith('/_next/static/'); +} + +function isManifestOrIconRequest(url) { + return MANIFEST_ICON_ASSETS.includes(url.pathname); +} + +function selectCacheName(request, url) { + if (isHtmlRequest(request, url)) return RUNTIME_PAGE_CACHE_NAME; + if (isToolChunkRequest(url)) return TOOL_CHUNK_CACHE_NAME; + if (isManifestOrIconRequest(url)) return MANIFEST_ICON_CACHE_NAME; + if (isNextStaticAssetRequest(url) || CACHEABLE_STATIC_ASSET_PATTERN.test(url.pathname)) return STATIC_ASSET_CACHE_NAME; + return STATIC_ASSET_CACHE_NAME; +} + +function deleteByteflowCaches() { + cacheWritesPausedUntil = Date.now() + CACHE_WRITE_PAUSE_AFTER_CLEAR_MS; + return caches.keys().then((names) => + Promise.all(names.filter((name) => name.startsWith(CACHE_PREFIX)).map((name) => caches.delete(name))) + ); } // Install: cache critical static assets; waiting/activation is user-triggered from the app shell. self.addEventListener('install', (event) => { event.waitUntil( - caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS)) + Promise.all([ + caches.open(APP_SHELL_CACHE_NAME).then((cache) => cache.addAll(APP_SHELL_ASSETS)), + caches.open(MANIFEST_ICON_CACHE_NAME).then((cache) => cache.addAll(MANIFEST_ICON_ASSETS)), + ]) ); }); @@ -139,50 +223,56 @@ self.addEventListener('message', (event) => { if (event.data?.type === 'SKIP_WAITING') { self.skipWaiting(); } + if (event.data?.type === 'CLEAR_BYTEFLOW_CACHES') { + event.waitUntil( + deleteByteflowCaches().then(() => { + event.source?.postMessage({ type: 'BYTEFLOW_CACHES_CLEARED', version: APP_VERSION }); + }) + ); + } }); - -// Activate: purge old caches and notify clients that an update is ready -self.addEventListener('activate', (event) => { - event.waitUntil( + +// Activate: purge old Byteflow caches and notify clients that an update is ready. +self.addEventListener('activate', (event) => { + event.waitUntil( caches.keys().then((names) => Promise.all( - names.filter((name) => ![CACHE_NAME, CACHE_META_NAME].includes(name)).map((name) => caches.delete(name)) + names + .filter((name) => name.startsWith(CACHE_PREFIX) && !ACTIVE_CACHE_NAMES.includes(name)) + .map((name) => caches.delete(name)) ) - ).then(() => { - // Notify all open tabs that a new version is active - self.clients.matchAll({ type: 'window' }).then((clients) => { - clients.forEach((client) => { - client.postMessage({ type: 'SW_UPDATED', version: APP_VERSION }); - }); - }); - }) - ); - self.clients.claim(); -}); - -// Fetch: network-first for _next chunks (always get latest code), -// cache-first for truly static assets (fonts, images) + ).then(() => { + self.clients.matchAll({ type: 'window' }).then((clients) => { + clients.forEach((client) => { + client.postMessage({ type: 'SW_UPDATED', version: APP_VERSION }); + }); + }); + }) + ); + self.clients.claim(); +}); + +// Fetch: network-first for pages and Next.js chunks; cache-first for static assets. self.addEventListener('fetch', (event) => { const url = new URL(event.request.url); // Skip non-GET requests if (event.request.method !== 'GET') return; - // Do not cache or intercept third-party requests. - if (url.origin !== self.location.origin) return; - // Keep tool payloads and secret-like query strings out of CacheStorage. - if (hasSensitiveQuery(url)) return; - - // Network-first for Next.js chunks and pages (ensures fresh code) - if ( - url.pathname.startsWith('/_next/') || - url.pathname.endsWith('.html') || - event.request.headers.get('accept')?.includes('text/html') - ) { + // Network-only for third-party requests, API/external probes, and secret-like query strings. + if (isNetworkOnlyRequest(event.request, url)) return; + + const cacheName = selectCacheName(event.request, url); + + // Network-first for Next.js chunks and pages (ensures fresh code) + if ( + url.pathname.startsWith('/_next/') || + isHtmlRequest(event.request, url) + ) { event.respondWith( fetch(event.request) .then((response) => { if (response.ok) { - event.waitUntil(putRuntimeCache(event.request, response).catch(() => undefined)); + event.waitUntil(putRuntimeCache(event.request, response, cacheName).catch(() => undefined)); } return response; }) @@ -192,29 +282,29 @@ self.addEventListener('fetch', (event) => { ); return; } - - // Cache-first for truly static assets (fonts, images, icons) - if (url.pathname.match(/\.(woff2?|ttf|png|jpg|jpeg|svg|ico|webp)$/)) { - event.respondWith( - caches.match(event.request).then((cached) => { - if (cached) return cached; + + // Cache-first for manifest, icons, fonts, images, and other static assets. + if (isManifestOrIconRequest(url) || CACHEABLE_STATIC_ASSET_PATTERN.test(url.pathname)) { + event.respondWith( + caches.match(event.request).then((cached) => { + if (cached) return cached; return fetch(event.request).then((response) => { if (response.ok) { - event.waitUntil(putRuntimeCache(event.request, response).catch(() => undefined)); + event.waitUntil(putRuntimeCache(event.request, response, cacheName).catch(() => undefined)); } return response; }); - }) - ); - return; - } - - // Default: network-first - event.respondWith( - fetch(event.request) + }) + ); + return; + } + + // Default: network-first + event.respondWith( + fetch(event.request) .then((response) => { if (response.ok) { - event.waitUntil(putRuntimeCache(event.request, response).catch(() => undefined)); + event.waitUntil(putRuntimeCache(event.request, response, cacheName).catch(() => undefined)); } return response; }) diff --git a/scripts/e2e/run-playwright-smoke.js b/scripts/e2e/run-playwright-smoke.js index 5396e619..c21618ea 100644 --- a/scripts/e2e/run-playwright-smoke.js +++ b/scripts/e2e/run-playwright-smoke.js @@ -887,7 +887,7 @@ async function assertLocaleSwitchJourney(context, baseUrl) { await page.close(); } -async function assertPwaShellJourney(browser, baseUrl, goOffline) { +async function assertPwaShellJourney(browser, baseUrl) { const context = await browser.newContext({ serviceWorkers: "allow" }); const page = await context.newPage(); const runtimeErrors = []; @@ -951,12 +951,78 @@ async function assertPwaShellJourney(browser, baseUrl, goOffline) { throw new Error("Service worker stopped controlling the PWA smoke page before offline navigation."); } - if (goOffline) { - await goOffline(); - } else { - await context.setOffline(true); - contextOffline = true; + const cacheBucketsBeforeOffline = await page.evaluate(async () => + (await caches.keys()).filter((key) => key.startsWith("byteflow-")).sort(), + ); + for (const requiredBucket of [ + "byteflow-app-shell-v", + "byteflow-manifest-icons-v", + "byteflow-runtime-pages-v", + "byteflow-tool-chunks-v", + ]) { + if (!cacheBucketsBeforeOffline.some((key) => key.startsWith(requiredBucket))) { + throw new Error(`PWA smoke did not find cache bucket ${requiredBucket}. Found: ${cacheBucketsBeforeOffline.join(", ")}`); + } } + + await context.setOffline(true); + contextOffline = true; + + const offlineToolResult = await page.evaluate(async (targetUrl) => { + try { + const response = await fetch(targetUrl, { + headers: { accept: "text/html" }, + }); + const bodyText = await response.text(); + return { + ok: response.ok, + status: response.status, + bodyText, + error: "", + }; + } catch (error) { + return { + ok: false, + status: 0, + bodyText: "", + error: error instanceof Error ? error.message : String(error), + }; + } + }, `${baseUrl}/en/json-formatter`); + if (!offlineToolResult.ok || !/JSON Formatter/i.test(offlineToolResult.bodyText)) { + throw new Error(`Offline local tool shell did not render from cache. Status: ${offlineToolResult.status}; error: ${offlineToolResult.error || "none"}`); + } + + const externalRequestProbe = await page.evaluate(async () => { + try { + await fetch("https://example.com/byteflow-pwa-external-probe", { + cache: "no-store", + mode: "no-cors", + }); + return { reachedNetwork: true, error: "" }; + } catch (error) { + return { + reachedNetwork: false, + error: error instanceof Error ? error.message : String(error), + }; + } + }); + if (externalRequestProbe.reachedNetwork) { + throw new Error("External request probe unexpectedly succeeded while the PWA smoke context was offline."); + } + const externalProbeCached = await page.evaluate(async () => { + const keys = await caches.keys(); + for (const key of keys) { + const cache = await caches.open(key); + const match = await cache.match("https://example.com/byteflow-pwa-external-probe"); + if (match) return key; + } + return ""; + }); + if (externalProbeCached) { + throw new Error(`External request probe was cached in ${externalProbeCached}.`); + } + const offlineResult = await page.evaluate(async (targetUrl) => { try { const response = await fetch(targetUrl, { @@ -990,6 +1056,21 @@ async function assertPwaShellJourney(browser, baseUrl, goOffline) { throw new Error("Offline navigation did not render the cached offline fallback."); } + if (contextOffline) { + await context.setOffline(false); + contextOffline = false; + } + + await page.goto(`${baseUrl}/en/install-app`, { waitUntil: "networkidle" }); + await page.getByRole("button", { name: /Clear cached app files/i }).click(); + await page.getByText("Cached app files cleared.").waitFor({ state: "visible", timeout: 15_000 }); + const cacheBucketsAfterClear = await page.evaluate(async () => + (await caches.keys()).filter((key) => key.startsWith("byteflow-")), + ); + if (cacheBucketsAfterClear.length > 0) { + throw new Error(`Manual PWA cache clear left Byteflow caches behind: ${cacheBucketsAfterClear.join(", ")}`); + } + if (runtimeErrors.length > 0) { throw new Error(`PWA smoke triggered runtime errors:\n- ${runtimeErrors.join("\n- ")}`); } @@ -1052,11 +1133,11 @@ async function runSmoke(baseUrl) { } } -async function runPwaSmoke(baseUrl, goOffline) { +async function runPwaSmoke(baseUrl) { const browser = await chromium.launch({ headless: true }); try { - await assertPwaShellJourney(browser, baseUrl, goOffline); - console.log("[playwright-smoke] PASS pwa: service worker, manifest, and offline fallback"); + await assertPwaShellJourney(browser, baseUrl); + console.log("[playwright-smoke] PASS pwa: service worker, cache buckets, offline fallback, and manual cache clear"); } finally { await browser.close(); } @@ -1075,15 +1156,7 @@ async function main() { await runSmoke(baseUrl); if (includePwa) { - await runPwaSmoke( - baseUrl, - serverHandle - ? async () => { - await stopServer(serverHandle.server); - serverHandle = null; - } - : null, - ); + await runPwaSmoke(baseUrl); } console.log("[playwright-smoke] PASS: critical routes render and navigate correctly"); } catch (error) { diff --git a/src/core/i18n/translations/de.json b/src/core/i18n/translations/de.json index 573bd168..f666914e 100644 --- a/src/core/i18n/translations/de.json +++ b/src/core/i18n/translations/de.json @@ -2815,7 +2815,7 @@ "trust_center_analytics_title": "Analytics-Strategie", "trust_center_analytics_desc": "Analytics sind auf aggregierte Produktsignale wie Seitenaufrufe und sichere Ereignisnamen beschränkt. Tool-Eingaben, Ausgaben, JWTs, Geheimnisse, Logtexte, Dateiinhalte, Bildinhalte, Suchtexte und vollständige URLs sind keine Analytics-Felder.", "trust_center_pwa_title": "PWA-Cache-Strategie", - "trust_center_pwa_desc": "Die PWA kann App-Shell, statische Assets, Icons und Tool-Chunks für Offline-Nutzung zwischenspeichern. Tool-Eingaben, Ausgaben, hochgeladene Dateiinhalte und Antworten externer Anfragen dürfen nicht gecacht werden.", + "trust_center_pwa_desc": "Die PWA kann versionierte App-Shell-Dateien, statische Assets, Icons und Tool-Chunks für Offline-Nutzung zwischenspeichern. Antworten externer Anfragen bleiben network-only, und gecachte App-Dateien lassen sich auf der Installationsseite löschen. Tool-Eingaben, Ausgaben, hochgeladene Dateiinhalte und Antworten externer Anfragen dürfen nicht gecacht werden.", "trust_center_security_headers_title": "Sicherheitsheader und CSP", "trust_center_security_headers_desc": "Prüfungen für Sicherheitsheader decken Transport Security, Referrer Policy, Content-Type Sniffing, Permissions Policy, Frame-Ancestors, Object-Quellen und eine CSP ohne beliebige Script-Quellen ab.", "trust_center_xss_title": "Darstellung von Nutzerinhalten", diff --git a/src/core/i18n/translations/en.json b/src/core/i18n/translations/en.json index b8aaa257..2efb16ea 100644 --- a/src/core/i18n/translations/en.json +++ b/src/core/i18n/translations/en.json @@ -2815,7 +2815,7 @@ "trust_center_analytics_title": "Analytics strategy", "trust_center_analytics_desc": "Analytics are limited to aggregate product signals such as page views and safe event names. Tool input, output, JWTs, secrets, log bodies, file contents, image contents, search query text, and full URLs are not analytics fields.", "trust_center_pwa_title": "PWA cache strategy", - "trust_center_pwa_desc": "The PWA may cache the app shell, static assets, icons, and tool chunks for offline use. It must not cache tool input, tool output, uploaded file content, or external-request responses.", + "trust_center_pwa_desc": "The PWA may cache versioned app shell files, static assets, icons, and tool chunks for offline use. External-request responses are network-only, and you can clear cached app files from the install page. Tool input, output, uploaded file content, and external-request responses must not be cached.", "trust_center_security_headers_title": "Security headers and CSP", "trust_center_security_headers_desc": "Security header checks guard transport security, referrer policy, content type sniffing, permissions policy, frame ancestors, object sources, and a CSP that avoids arbitrary script sources.", "trust_center_xss_title": "User content rendering", diff --git a/src/core/i18n/translations/fr.json b/src/core/i18n/translations/fr.json index 46766204..24eaf94a 100644 --- a/src/core/i18n/translations/fr.json +++ b/src/core/i18n/translations/fr.json @@ -2815,7 +2815,7 @@ "trust_center_analytics_title": "Stratégie analytics", "trust_center_analytics_desc": "L’analytics est limitée aux signaux produit agrégés comme les vues de page et des noms d’événements sûrs. Entrées, sorties, JWT, secrets, corps de journaux, contenus de fichiers, contenus d’images, texte de recherche et URL complètes ne sont pas des champs analytics.", "trust_center_pwa_title": "Stratégie de cache PWA", - "trust_center_pwa_desc": "La PWA peut mettre en cache le shell applicatif, les assets statiques, les icônes et les modules d’outils pour l’usage hors ligne. Elle ne doit pas cacher les entrées, sorties, contenus de fichiers téléversés ni réponses de requêtes externes.", + "trust_center_pwa_desc": "La PWA peut mettre en cache des fichiers de shell versionnés, des assets statiques, des icônes et des modules d’outils pour l’usage hors ligne. Les réponses de requêtes externes restent network-only, et les fichiers d’app en cache peuvent être effacés depuis la page d’installation. Elle ne doit pas cacher les entrées, sorties, contenus de fichiers téléversés ni réponses de requêtes externes.", "trust_center_security_headers_title": "En-têtes de sécurité et CSP", "trust_center_security_headers_desc": "Les contrôles d’en-têtes couvrent la sécurité du transport, la referrer policy, le sniffing de type de contenu, la permissions policy, les frame ancestors, les sources object et une CSP sans source de script arbitraire.", "trust_center_xss_title": "Rendu du contenu utilisateur", diff --git a/src/core/i18n/translations/ja.json b/src/core/i18n/translations/ja.json index f1aa4996..40b55d6e 100644 --- a/src/core/i18n/translations/ja.json +++ b/src/core/i18n/translations/ja.json @@ -2815,7 +2815,7 @@ "trust_center_analytics_title": "分析方針", "trust_center_analytics_desc": "分析はページビューや安全なイベント名など集計された製品シグナルに限定します。ツール入力、出力、JWT、秘密情報、ログ本文、ファイル内容、画像内容、検索語、完全な URL は分析項目にしません。", "trust_center_pwa_title": "PWA キャッシュ方針", - "trust_center_pwa_desc": "PWA はオフライン利用のためにアプリシェル、静的アセット、アイコン、ツールチャンクをキャッシュできます。ツール入力、出力、アップロードファイル内容、外部リクエスト応答はキャッシュしてはいけません。", + "trust_center_pwa_desc": "PWA はオフライン利用のために、バージョン付きのアプリシェル、静的アセット、アイコン、ツールチャンクをキャッシュできます。外部リクエスト応答は network-only で、インストールページからキャッシュ済みアプリファイルを消去できます。ツール入力、出力、アップロードファイル内容、外部リクエスト応答はキャッシュしてはいけません。", "trust_center_security_headers_title": "セキュリティヘッダーと CSP", "trust_center_security_headers_desc": "セキュリティヘッダー検査は通信保護、リファラー方針、コンテンツ種別スニッフィング、権限方針、frame 制限、object 送信元、任意 script 送信元を避ける CSP を確認します。", "trust_center_xss_title": "ユーザー内容のレンダリング", diff --git a/src/core/i18n/translations/ko.json b/src/core/i18n/translations/ko.json index 53ae410f..0ea35c99 100644 --- a/src/core/i18n/translations/ko.json +++ b/src/core/i18n/translations/ko.json @@ -2815,7 +2815,7 @@ "trust_center_analytics_title": "분석 전략", "trust_center_analytics_desc": "분석은 페이지 조회와 안전한 이벤트 이름 같은 집계 제품 신호로 제한됩니다. 도구 입력, 출력, JWT, 비밀값, 로그 본문, 파일 내용, 이미지 내용, 검색 원문, 전체 URL은 분석 필드가 아닙니다.", "trust_center_pwa_title": "PWA 캐시 전략", - "trust_center_pwa_desc": "PWA는 오프라인 사용을 위해 앱 셸, 정적 자산, 아이콘, 도구 청크를 캐시할 수 있습니다. 도구 입력, 도구 출력, 업로드 파일 내용, 외부 요청 응답은 캐시하면 안 됩니다.", + "trust_center_pwa_desc": "PWA는 오프라인 사용을 위해 버전이 지정된 앱 셸, 정적 자산, 아이콘, 도구 청크를 캐시할 수 있습니다. 외부 요청 응답은 network-only이며, 설치 페이지에서 캐시된 앱 파일을 지울 수 있습니다. 도구 입력, 도구 출력, 업로드 파일 내용, 외부 요청 응답은 캐시하면 안 됩니다.", "trust_center_security_headers_title": "보안 헤더와 CSP", "trust_center_security_headers_desc": "보안 헤더 검사는 전송 보안, 리퍼러 정책, 콘텐츠 유형 스니핑, 권한 정책, frame 제한, object 출처, 임의 script 출처를 피하는 CSP를 확인합니다.", "trust_center_xss_title": "사용자 콘텐츠 렌더링", diff --git a/src/core/i18n/translations/zh-CN.json b/src/core/i18n/translations/zh-CN.json index 80043d17..cf464142 100644 --- a/src/core/i18n/translations/zh-CN.json +++ b/src/core/i18n/translations/zh-CN.json @@ -2815,7 +2815,7 @@ "trust_center_analytics_title": "分析策略", "trust_center_analytics_desc": "分析仅限页面访问量和安全事件名等聚合产品信号。工具输入、输出、JWT、密钥、日志正文、文件内容、图片内容、搜索原文和完整 URL 都不是分析字段。", "trust_center_pwa_title": "PWA 缓存策略", - "trust_center_pwa_desc": "PWA 可缓存应用外壳、静态资源、图标和工具代码块以支持离线使用。它不得缓存工具输入、工具输出、上传文件内容或外部请求响应。", + "trust_center_pwa_desc": "PWA 可缓存带版本的应用外壳、静态资源、图标和工具代码块以支持离线使用。外部请求响应始终走 network-only,你也可以在安装页清除已缓存应用文件。工具输入、工具输出、上传文件内容和外部请求响应不得被缓存。", "trust_center_security_headers_title": "安全响应头与 CSP", "trust_center_security_headers_desc": "安全头检查覆盖传输安全、引用来源策略、内容类型嗅探、权限策略、frame 限制、object 来源,以及避免任意脚本来源的 CSP。", "trust_center_xss_title": "用户内容渲染", diff --git a/src/core/i18n/translations/zh-TW.json b/src/core/i18n/translations/zh-TW.json index b6ee1a50..1005aa87 100644 --- a/src/core/i18n/translations/zh-TW.json +++ b/src/core/i18n/translations/zh-TW.json @@ -2815,7 +2815,7 @@ "trust_center_analytics_title": "分析策略", "trust_center_analytics_desc": "分析僅限頁面瀏覽量和安全事件名稱等彙總產品訊號。工具輸入、輸出、JWT、金鑰、日誌本文、檔案內容、圖片內容、搜尋原文和完整 URL 都不是分析欄位。", "trust_center_pwa_title": "PWA 快取策略", - "trust_center_pwa_desc": "PWA 可快取應用外殼、靜態資源、圖示和工具程式碼區塊以支援離線使用。它不得快取工具輸入、工具輸出、上傳檔案內容或外部請求回應。", + "trust_center_pwa_desc": "PWA 可快取帶版本的應用外殼、靜態資源、圖示和工具程式碼區塊以支援離線使用。外部請求回應一律採 network-only,你也可以在安裝頁清除已快取應用檔案。工具輸入、工具輸出、上傳檔案內容和外部請求回應不得被快取。", "trust_center_security_headers_title": "安全回應標頭與 CSP", "trust_center_security_headers_desc": "安全標頭檢查涵蓋傳輸安全、來源參照策略、內容類型嗅探、權限策略、frame 限制、object 來源,以及避免任意腳本來源的 CSP。", "trust_center_xss_title": "使用者內容渲染", diff --git a/src/core/utils/install-app-copy.ts b/src/core/utils/install-app-copy.ts index bfb4888a..c9c3ac19 100644 --- a/src/core/utils/install-app-copy.ts +++ b/src/core/utils/install-app-copy.ts @@ -37,6 +37,11 @@ export type InstallPageCopy = { bottomTrust: string manualHint: string guidePreviewLabel: string + cacheControlsTitle: string + cacheControlsDescription: string + clearCachedAppButton: string + clearCachedAppSuccess: string + clearCachedAppUnavailable: string benefits: InstallBenefit[] guides: Record faq: InstallFaq[] @@ -66,6 +71,11 @@ export const INSTALL_PAGE_COPY: Record = { bottomTrust: "Privacy-first by design. Fast by default. Browser-local tools work without network.", manualHint: "Install prompt is unavailable in this browser. Follow the platform steps below.", guidePreviewLabel: "Guide preview", + cacheControlsTitle: "Cached app files", + cacheControlsDescription: "Clear the PWA app shell, static assets, icons, and tool chunks stored by your browser. This does not clear tool input or output.", + clearCachedAppButton: "Clear cached app files", + clearCachedAppSuccess: "Cached app files cleared.", + clearCachedAppUnavailable: "Cache clearing is not available in this browser.", benefits: [ { key: "instant_launch", @@ -173,6 +183,11 @@ export const INSTALL_PAGE_COPY: Record = { bottomTrust: "隐私优先,速度优先。浏览器本地工具无网可用。", manualHint: "当前浏览器不支持安装弹窗,请按下方步骤手动安装。", guidePreviewLabel: "教程预览", + cacheControlsTitle: "已缓存的应用文件", + cacheControlsDescription: "清除浏览器保存的 PWA 应用外壳、静态资源、图标和工具代码块。这不会清除工具输入或输出。", + clearCachedAppButton: "清除已缓存应用文件", + clearCachedAppSuccess: "已清除缓存的应用文件。", + clearCachedAppUnavailable: "当前浏览器不支持清除缓存。", benefits: [ { key: "instant_launch", @@ -280,6 +295,11 @@ export const INSTALL_PAGE_COPY: Record = { bottomTrust: "隱私優先、速度優先。瀏覽器本地工具無網可用。", manualHint: "目前瀏覽器不支援安裝彈窗,請依下方步驟手動安裝。", guidePreviewLabel: "教學預覽", + cacheControlsTitle: "已快取的應用檔案", + cacheControlsDescription: "清除瀏覽器保存的 PWA 應用外殼、靜態資源、圖示和工具程式碼區塊。這不會清除工具輸入或輸出。", + clearCachedAppButton: "清除已快取應用檔案", + clearCachedAppSuccess: "已清除快取的應用檔案。", + clearCachedAppUnavailable: "此瀏覽器不支援清除快取。", benefits: [ { key: "instant_launch", @@ -387,6 +407,11 @@ export const INSTALL_PAGE_COPY: Record = { bottomTrust: "プライバシー重視。高速。ブラウザローカルツールはオフライン対応。", manualHint: "このブラウザではインストールプロンプトが使えません。下の手順を確認してください。", guidePreviewLabel: "手順プレビュー", + cacheControlsTitle: "キャッシュ済みアプリファイル", + cacheControlsDescription: "ブラウザに保存された PWA のアプリシェル、静的アセット、アイコン、ツールチャンクを消去します。ツール入力や出力は消去しません。", + clearCachedAppButton: "キャッシュ済みアプリファイルを消去", + clearCachedAppSuccess: "キャッシュ済みアプリファイルを消去しました。", + clearCachedAppUnavailable: "このブラウザではキャッシュ消去を利用できません。", benefits: [ { key: "instant_launch", @@ -494,6 +519,11 @@ export const INSTALL_PAGE_COPY: Record = { bottomTrust: "개인정보 보호 우선. 빠른 속도. 브라우저 로컬 도구는 오프라인 지원.", manualHint: "이 브라우저에서는 설치 프롬프트를 지원하지 않습니다. 아래 수동 가이드를 확인하세요.", guidePreviewLabel: "가이드 미리보기", + cacheControlsTitle: "캐시된 앱 파일", + cacheControlsDescription: "브라우저에 저장된 PWA 앱 셸, 정적 자산, 아이콘, 도구 청크를 지웁니다. 도구 입력이나 출력은 지우지 않습니다.", + clearCachedAppButton: "캐시된 앱 파일 지우기", + clearCachedAppSuccess: "캐시된 앱 파일을 지웠습니다.", + clearCachedAppUnavailable: "이 브라우저에서는 캐시 지우기를 사용할 수 없습니다.", benefits: [ { key: "instant_launch", @@ -601,6 +631,11 @@ export const INSTALL_PAGE_COPY: Record = { bottomTrust: "Datenschutz zuerst. Schnell. Browser-lokale Tools funktionieren ohne Netzwerk.", manualHint: "In diesem Browser ist kein Installationsprompt verfügbar. Folgen Sie der Anleitung unten.", guidePreviewLabel: "Vorschau", + cacheControlsTitle: "Gecachte App-Dateien", + cacheControlsDescription: "Löscht App-Shell, statische Assets, Icons und Tool-Chunks, die der Browser für die PWA gespeichert hat. Tool-Eingaben oder Ausgaben werden nicht gelöscht.", + clearCachedAppButton: "Gecachte App-Dateien löschen", + clearCachedAppSuccess: "Gecachte App-Dateien wurden gelöscht.", + clearCachedAppUnavailable: "Cache-Löschen ist in diesem Browser nicht verfügbar.", benefits: [ { key: "instant_launch", @@ -708,6 +743,11 @@ export const INSTALL_PAGE_COPY: Record = { bottomTrust: "Confidentialité d'abord. Rapide. Les outils locaux au navigateur fonctionnent hors ligne.", manualHint: "Le prompt d'installation n'est pas disponible dans ce navigateur. Suivez les étapes ci-dessous.", guidePreviewLabel: "Aperçu du guide", + cacheControlsTitle: "Fichiers d'app en cache", + cacheControlsDescription: "Efface le shell PWA, les assets statiques, les icônes et les modules d'outils stockés par le navigateur. Cela n'efface pas les entrées ni les sorties des outils.", + clearCachedAppButton: "Effacer les fichiers d'app en cache", + clearCachedAppSuccess: "Fichiers d'app en cache effacés.", + clearCachedAppUnavailable: "L'effacement du cache n'est pas disponible dans ce navigateur.", benefits: [ { key: "instant_launch", diff --git a/src/features/install-app/components/install-app-client.tsx b/src/features/install-app/components/install-app-client.tsx index baa8191f..36d4bfeb 100644 --- a/src/features/install-app/components/install-app-client.tsx +++ b/src/features/install-app/components/install-app-client.tsx @@ -3,7 +3,7 @@ import * as React from "react" import Link from "next/link" import Image from "next/image" -import { CheckCircle2, Download, Globe, Shield, Smartphone, WifiOff, Zap } from "lucide-react" +import { CheckCircle2, Download, Globe, Shield, Smartphone, Trash2, WifiOff, Zap } from "lucide-react" import { trackEvent } from "@/core/analytics/analytics" import { Button } from "@/components/ui/button" import { getAllToolsHref } from "@/core/routing/all-tools-route" @@ -28,6 +28,45 @@ const BENEFIT_ICON_BY_KEY = { works_offline: WifiOff, local_first: Shield, } as const +const BYTEFLOW_CACHE_PREFIX = "byteflow-" +const SERVICE_WORKER_CACHE_CLEAR_TIMEOUT_MS = 1500 + +async function deleteByteflowCacheBuckets() { + if (typeof window === "undefined" || !("caches" in window)) { + throw new Error("CacheStorage unavailable") + } + const cacheKeys = await window.caches.keys() + await Promise.all( + cacheKeys + .filter((key) => key.startsWith(BYTEFLOW_CACHE_PREFIX)) + .map((key) => window.caches.delete(key)), + ) +} + +async function requestServiceWorkerCacheClear() { + if (typeof navigator === "undefined" || !("serviceWorker" in navigator)) return + const controller = navigator.serviceWorker.controller + if (!controller) return + + await new Promise((resolve) => { + const cleanup = () => { + window.clearTimeout(timeoutId) + navigator.serviceWorker.removeEventListener("message", handleMessage) + } + const handleMessage = (event: MessageEvent) => { + if (event.data?.type !== "BYTEFLOW_CACHES_CLEARED") return + cleanup() + resolve() + } + const timeoutId = window.setTimeout(() => { + cleanup() + resolve() + }, SERVICE_WORKER_CACHE_CLEAR_TIMEOUT_MS) + + navigator.serviceWorker.addEventListener("message", handleMessage) + controller.postMessage({ type: "CLEAR_BYTEFLOW_CACHES" }) + }) +} type InstallAppClientProps = { locale: Locale @@ -41,6 +80,8 @@ export function InstallAppClient({ locale, copy, allToolsLabel, trustCenterLabel const [deferredPrompt, setDeferredPrompt] = React.useState(null) const [installed, setInstalled] = React.useState(false) const [manualHintVisible, setManualHintVisible] = React.useState(false) + const [cacheClearStatus, setCacheClearStatus] = React.useState<"idle" | "success" | "unavailable">("idle") + const [cacheClearPending, setCacheClearPending] = React.useState(false) const guideRef = React.useRef(null) const installSuccessTrackedRef = React.useRef(false) @@ -124,7 +165,26 @@ export function InstallAppClient({ locale, copy, allToolsLabel, trustCenterLabel } } + const handleClearCachedAppFiles = async () => { + setCacheClearPending(true) + try { + await deleteByteflowCacheBuckets() + await requestServiceWorkerCacheClear() + await deleteByteflowCacheBuckets() + setCacheClearStatus("success") + } catch { + setCacheClearStatus("unavailable") + } finally { + setCacheClearPending(false) + } + } + const primaryLabel = installed ? copy.alreadyInstalled : deferredPrompt ? copy.installNow : copy.seeGuide + const cacheClearMessage = cacheClearStatus === "success" + ? copy.clearCachedAppSuccess + : cacheClearStatus === "unavailable" + ? copy.clearCachedAppUnavailable + : "" return (
@@ -171,6 +231,30 @@ export function InstallAppClient({ locale, copy, allToolsLabel, trustCenterLabel
+
+
+
+

{copy.cacheControlsTitle}

+

+ {copy.cacheControlsDescription} +

+
+ +
+

+ {cacheClearMessage} +

+
+

{copy.sectionGuide}

diff --git a/tests/component/install-app-page.test.tsx b/tests/component/install-app-page.test.tsx index 85e5c3be..fe0d58a6 100644 --- a/tests/component/install-app-page.test.tsx +++ b/tests/component/install-app-page.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react" +import { fireEvent, render, screen, waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" import { InstallAppClient } from "@/features/install-app/components/install-app-client" import { getAllToolsHref } from "@/core/routing/all-tools-route" @@ -55,6 +55,7 @@ describe("install app page", () => { expect(browseLink).toHaveAttribute("href", getAllToolsHref("en")) expect(browseLink).not.toHaveAttribute("href", "/en/format-validate") expect(screen.getByRole("link", { name: "Trust Center" })).toHaveAttribute("href", "/en/trust-center") + expect(screen.getByRole("button", { name: "Clear cached app files" })).toBeInTheDocument() }) it("uses localized all-tools label and localized image alt in zh-CN locale", () => { @@ -76,4 +77,37 @@ describe("install app page", () => { const previewImage = screen.getByTestId("mock-next-image") expect(previewImage).toHaveAttribute("aria-label", `${copy.guides.chrome_desktop.label} ${copy.guidePreviewLabel}`) }) + + it("clears only byteflow PWA cache buckets from the install page", async () => { + const deleteCache = vi.fn().mockResolvedValue(true) + Object.defineProperty(window, "caches", { + configurable: true, + value: { + keys: vi.fn().mockResolvedValue([ + "byteflow-app-shell-vtest", + "byteflow-tool-chunks-vtest", + "third-party-cache", + ]), + delete: deleteCache, + }, + }) + + render( + , + ) + + fireEvent.click(screen.getByRole("button", { name: "Clear cached app files" })) + + await waitFor(() => { + expect(deleteCache).toHaveBeenCalledWith("byteflow-app-shell-vtest") + expect(deleteCache).toHaveBeenCalledWith("byteflow-tool-chunks-vtest") + }) + expect(deleteCache).not.toHaveBeenCalledWith("third-party-cache") + expect(await screen.findByText("Cached app files cleared.")).toBeInTheDocument() + }) }) diff --git a/tests/component/trust-center-page.test.tsx b/tests/component/trust-center-page.test.tsx index 98e45323..fa5bbdec 100644 --- a/tests/component/trust-center-page.test.tsx +++ b/tests/component/trust-center-page.test.tsx @@ -20,6 +20,8 @@ describe("TrustCenterPage", () => { expect(screen.getByRole("heading", { name: "Privacy and Trust Center", level: 1 })).toBeInTheDocument() expect(screen.getByRole("heading", { name: "Verify local processing in DevTools" })).toBeInTheDocument() expect(screen.getByText("Open the tool page, then open DevTools and select the Network panel.")).toBeInTheDocument() + expect(screen.getByText(/External-request responses are network-only/i)).toBeInTheDocument() + expect(screen.getByText(/clear cached app files from the install page/i)).toBeInTheDocument() const externalTools = TOOL_REGISTRY.filter((tool) => tool.privacy.externalRequest.required) const toolCopy = getTranslation("en").tools as Record diff --git a/tests/guards/offline-fallback-page.test.ts b/tests/guards/offline-fallback-page.test.ts index 8b423b78..657dcb03 100644 --- a/tests/guards/offline-fallback-page.test.ts +++ b/tests/guards/offline-fallback-page.test.ts @@ -36,7 +36,46 @@ describe("offline fallback page", () => { expect(source).toContain("'signature'") expect(source).toContain("param.toLowerCase()") expect(source).toContain("function hasSensitiveQuery(url)") - expect(source).toContain("if (hasSensitiveQuery(url)) return;") + expect(source).toContain("hasSensitiveQuery(url) ||") + expect(source).toContain("if (isNetworkOnlyRequest(event.request, url)) return;") + }) + + it("keeps PWA caches versioned and separated by resource class", () => { + const source = fs.readFileSync(path.join(process.cwd(), "public", "sw.js"), "utf8") + + expect(source).toContain("const CACHE_PREFIX = 'byteflow-';") + expect(source).toContain("const APP_SHELL_CACHE_NAME = `byteflow-app-shell-v${APP_VERSION}`") + expect(source).toContain("const STATIC_ASSET_CACHE_NAME = `byteflow-static-assets-v${APP_VERSION}`") + expect(source).toContain("const TOOL_CHUNK_CACHE_NAME = `byteflow-tool-chunks-v${APP_VERSION}`") + expect(source).toContain("const MANIFEST_ICON_CACHE_NAME = `byteflow-manifest-icons-v${APP_VERSION}`") + expect(source).toContain("const RUNTIME_PAGE_CACHE_NAME = `byteflow-runtime-pages-v${APP_VERSION}`") + expect(source).toContain("const ACTIVE_CACHE_NAMES = [") + expect(source).toContain("const APP_SHELL_ASSETS = [") + expect(source).toContain("const MANIFEST_ICON_ASSETS = [") + expect(source).toContain("function selectCacheName(request, url)") + expect(source).toContain("if (isToolChunkRequest(url)) return TOOL_CHUNK_CACHE_NAME;") + expect(source).toContain("if (isManifestOrIconRequest(url)) return MANIFEST_ICON_CACHE_NAME;") + expect(source).toContain("name.startsWith(CACHE_PREFIX) && !ACTIVE_CACHE_NAMES.includes(name)") + }) + + it("keeps external-request and explicit network-only fetches out of service-worker caches", () => { + const source = fs.readFileSync(path.join(process.cwd(), "public", "sw.js"), "utf8") + + expect(source).toContain("function isNetworkOnlyRequest(request, url)") + expect(source).toContain("url.origin !== self.location.origin") + expect(source).toContain("request.headers.get('x-byteflow-cache-mode') === 'network-only'") + expect(source).toContain("request.headers.get('x-byteflow-external-request') === '1'") + expect(source).toContain("url.pathname.startsWith('/api/')") + expect(source).toContain("if (isNetworkOnlyRequest(event.request, url)) return;") + }) + + it("lets users clear byteflow PWA caches without clearing tool payload storage", () => { + const source = fs.readFileSync(path.join(process.cwd(), "public", "sw.js"), "utf8") + + expect(source).toContain("function deleteByteflowCaches()") + expect(source).toContain("name.startsWith(CACHE_PREFIX)") + expect(source).toContain("event.data?.type === 'CLEAR_BYTEFLOW_CACHES'") + expect(source).toContain("BYTEFLOW_CACHES_CLEARED") }) it("keeps runtime cache bounded by size and age", () => { @@ -45,12 +84,19 @@ describe("offline fallback page", () => { expect(source).toContain("const CACHE_META_NAME") expect(source).toContain("const MAX_RUNTIME_CACHE_ENTRIES") expect(source).toContain("const MAX_RUNTIME_CACHE_AGE_MS") - expect(source).toContain("function pruneRuntimeCache()") + expect(source).toContain("const CACHE_WRITE_PAUSE_AFTER_CLEAR_MS") + expect(source).toContain("let cacheWritesPausedUntil = 0;") + expect(source).toContain("function pruneRuntimeCache(cacheName)") expect(source).toContain("retained.slice(MAX_RUNTIME_CACHE_ENTRIES)") expect(source).toContain("now - entry.cachedAt > MAX_RUNTIME_CACHE_AGE_MS") - expect(source).toContain("function putRuntimeCache(request, response)") - const cacheWrites = source.match(/putRuntimeCache\(event\.request, response\)/g) ?? [] - const guardedCacheWrites = source.match(/event\.waitUntil\(putRuntimeCache\(event\.request, response\)\.catch\(\(\) => undefined\)\)/g) ?? [] + expect(source).toContain("function isCacheWritePaused()") + expect(source).toContain("function discardRuntimeCacheWrite(cacheName)") + expect(source).toContain("caches.delete(cacheName)") + expect(source).toContain("function putRuntimeCache(request, response, cacheName)") + expect(source).toContain("if (isCacheWritePaused()) return Promise.resolve();") + expect(source).toContain("if (isCacheWritePaused()) return discardRuntimeCacheWrite(cacheName);") + const cacheWrites = source.match(/putRuntimeCache\(event\.request, response, cacheName\)/g) ?? [] + const guardedCacheWrites = source.match(/event\.waitUntil\(putRuntimeCache\(event\.request, response, cacheName\)\.catch\(\(\) => undefined\)\)/g) ?? [] expect(guardedCacheWrites).toHaveLength(cacheWrites.length) }) }) diff --git a/tests/guards/playwright-smoke-matrix-guard.test.ts b/tests/guards/playwright-smoke-matrix-guard.test.ts index c5d7e5ad..7dc1805b 100644 --- a/tests/guards/playwright-smoke-matrix-guard.test.ts +++ b/tests/guards/playwright-smoke-matrix-guard.test.ts @@ -34,9 +34,12 @@ describe("playwright smoke matrix guard", () => { expect(SMOKE_SOURCE).toContain('if (arg === "--pwa")') expect(SMOKE_SOURCE).toContain("assertPwaShellJourney") expect(SMOKE_SOURCE).toContain("serviceWorkers: \"allow\"") - expect(SMOKE_SOURCE).toContain("goOffline") - expect(SMOKE_SOURCE).toContain("await stopServer(serverHandle.server)") expect(SMOKE_SOURCE).toContain("await context.setOffline(true)") + expect(SMOKE_SOURCE).toContain("byteflow-tool-chunks-v") + expect(SMOKE_SOURCE).toContain("byteflow-pwa-external-probe") + expect(SMOKE_SOURCE).toContain("External request probe was cached") + expect(SMOKE_SOURCE).toContain("/en/install-app") + expect(SMOKE_SOURCE).toContain("Clear cached app files") expect(SMOKE_SOURCE).toContain('headers: { accept: "text/html" }') }) }) diff --git a/tests/guards/sensitive-storage-audit.test.ts b/tests/guards/sensitive-storage-audit.test.ts index 71dfb4fe..f0c81181 100644 --- a/tests/guards/sensitive-storage-audit.test.ts +++ b/tests/guards/sensitive-storage-audit.test.ts @@ -103,8 +103,12 @@ describe("sensitive storage audit", () => { const serviceWorker = read("public/sw.js") expect(serviceWorker).toContain("if (event.request.method !== 'GET') return;") - expect(serviceWorker).toContain("if (url.origin !== self.location.origin) return;") - expect(serviceWorker).toContain("if (hasSensitiveQuery(url)) return;") + expect(serviceWorker).toContain("function isNetworkOnlyRequest(request, url)") + expect(serviceWorker).toContain("url.origin !== self.location.origin") + expect(serviceWorker).toContain("hasSensitiveQuery(url) ||") + expect(serviceWorker).toContain("request.headers.get('x-byteflow-cache-mode') === 'network-only'") + expect(serviceWorker).toContain("request.headers.get('x-byteflow-external-request') === '1'") + expect(serviceWorker).toContain("if (isNetworkOnlyRequest(event.request, url)) return;") expect(serviceWorker).toContain("'handoff'") expect(serviceWorker).toContain("'handoff_ref'") expect(serviceWorker).toContain("'payload'")