From 7a0a5cde30ac4385c48235037eed27927f14c8cb Mon Sep 17 00:00:00 2001 From: haotool Date: Sun, 28 Jun 2026 11:12:32 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(ratewise):=20=E8=A3=81=E5=89=AA=20PWA?= =?UTF-8?q?=20precache=20=E4=BF=AE=E5=BE=A9=E8=A1=8C=E5=8B=95=E5=86=B7?= =?UTF-8?q?=E5=95=9F=E5=8B=95=E8=AA=A4=E8=A7=B8=E9=9B=A2=E7=B7=9A=E9=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - precache 由 428 筆(約 34.5MB)降至 222 筆(約 11.8MB),排除幣別頁金額排列子頁 - 金額子頁改由 runtime html-cache 與 NavigationRoute 的 index.html SPA shell 還原 - 同步 offline.html 為 SSOT 模板產物(generate-offline-html.mjs 輸出) - 不改動 sw.ts 導覽邏輯 測試:typecheck 通過;pnpm test 全綠 2476+66;precache 222 筆 11.8MB 金額子頁洩漏 0 Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- .changeset/ratewise-precache-cold-start.md | 5 + apps/ratewise/public/offline.html | 232 +++------------------ apps/ratewise/vite.config.ts | 4 + 3 files changed, 34 insertions(+), 207 deletions(-) create mode 100644 .changeset/ratewise-precache-cold-start.md diff --git a/.changeset/ratewise-precache-cold-start.md b/.changeset/ratewise-precache-cold-start.md new file mode 100644 index 000000000..a8b41aa26 --- /dev/null +++ b/.changeset/ratewise-precache-cold-start.md @@ -0,0 +1,5 @@ +--- +'@app/ratewise': patch +--- + +修復 PWA 行動裝置冷啟動/弱網時誤觸離線頁面:將 precache 從 428 筆(約 34.5MB)裁減至 222 筆(約 11.8MB),排除幣別頁的金額排列子頁(改由 runtime 快取與 SPA shell 還原處理),讓 Service Worker 安裝能在弱網下順利完成,避免顯示「您目前處於離線狀態」。 diff --git a/apps/ratewise/public/offline.html b/apps/ratewise/public/offline.html index 5d4d808b9..1594b5972 100644 --- a/apps/ratewise/public/offline.html +++ b/apps/ratewise/public/offline.html @@ -2,184 +2,13 @@ - - - - - + + 離線模式 - HaoRate - diff --git a/apps/ratewise/vite.config.ts b/apps/ratewise/vite.config.ts index 017ba7422..8ed3eb0ac 100644 --- a/apps/ratewise/vite.config.ts +++ b/apps/ratewise/vite.config.ts @@ -322,6 +322,10 @@ export default defineConfig(({ mode }) => { '**/robots.txt', '**/llms.txt', '**/manifest.webmanifest', + // SSG 金額排列子頁(例 usd-twd/500/index.html)由 runtime html-cache 處理; + // precache 僅保留 app shell 與各幣別頂層 landing index.html。 + '**/[a-z][a-z][a-z]-twd/*/index.html', + '**/twd-[a-z][a-z][a-z]/*/index.html', ], additionalManifestEntries: [ { url: 'offline.html', revision: getFileRevision('public/offline.html') }, From 72ef68656b4473801b572632e12467d567a031f1 Mon Sep 17 00:00:00 2001 From: haotool Date: Sun, 28 Jun 2026 11:31:16 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(ratewise):=20=E5=BF=AB=E5=8F=96?= =?UTF-8?q?=E5=84=AA=E5=85=88=E7=B4=9A=E5=88=86=E5=B1=A4=E7=A2=BA=E4=BF=9D?= =?UTF-8?q?=E4=B8=BB=E5=8A=9F=E8=83=BD=E9=9B=A2=E7=B7=9A=E5=8F=AF=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tier 1 precache 僅留 app shell(index.html)+ JS/CSS + 路由 loader 清單 + 4 shell 圖示 + offline.html - 7.3MB 圖片改 runtime CacheFirst(image-cache 60 筆/30 天);非 shell HTML 由 NavigationRoute 回退 app shell - 匯率 JSON 改 runtime SWR(GitHub raw + 同域 api/latest、api/pairs,7 天離線備援) - precache 由 222 筆/11.8MB 降至 92 筆/2.2MB;offline.html 僅作最後手段 - 保留 static-loader-data-manifest precache,離線 SPA 路由導覽不回歸 測試:typecheck 通過;PWA 相關 80 測試全綠;gate 92 筆/2.2MB 無洩漏;loader manifest 已 precache Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- .changeset/ratewise-precache-cold-start.md | 6 +- apps/ratewise/src/__tests__/sw.test.ts | 29 +++++++- .../__tests__/verify-precache-assets.test.ts | 27 ++++++++ apps/ratewise/src/pwa-offline.test.ts | 21 ++++++ apps/ratewise/src/sw.ts | 40 +++++++---- apps/ratewise/vite.config.ts | 32 +++++---- scripts/verify-precache-assets.mjs | 67 ++++++++++++++++++- 7 files changed, 194 insertions(+), 28 deletions(-) diff --git a/.changeset/ratewise-precache-cold-start.md b/.changeset/ratewise-precache-cold-start.md index a8b41aa26..2d97d0e5f 100644 --- a/.changeset/ratewise-precache-cold-start.md +++ b/.changeset/ratewise-precache-cold-start.md @@ -2,4 +2,8 @@ '@app/ratewise': patch --- -修復 PWA 行動裝置冷啟動/弱網時誤觸離線頁面:將 precache 從 428 筆(約 34.5MB)裁減至 222 筆(約 11.8MB),排除幣別頁的金額排列子頁(改由 runtime 快取與 SPA shell 還原處理),讓 Service Worker 安裝能在弱網下順利完成,避免顯示「您目前處於離線狀態」。 +重整 PWA 快取為優先級分層,確保主功能(幣別換算)離線必可載入,並讓離線錯誤頁不再無謂出現: + +- Tier 1 預快取只保留 app shell(index.html)、JS/CSS、路由 loader 清單與少量 shell 圖示與離線頁,precache 由 428 筆(約 34.5MB)降至 92 筆(約 2.2MB),弱網下 Service Worker 能可靠安裝完成。 +- Tier 2 改為 runtime 快取:大型圖片(CacheFirst)、SEO 頁(導覽回退至 app shell)、匯率資料(StaleWhileRevalidate 7 天離線備援)。 +- 離線時一律以 app shell 還原主功能,offline.html 僅作最後手段,不再因預快取過大而誤觸。 diff --git a/apps/ratewise/src/__tests__/sw.test.ts b/apps/ratewise/src/__tests__/sw.test.ts index 616faad01..e62f7db12 100644 --- a/apps/ratewise/src/__tests__/sw.test.ts +++ b/apps/ratewise/src/__tests__/sw.test.ts @@ -191,7 +191,7 @@ describe('Service Worker Cache Strategies', () => { const expectedStrategies = { 'history-rates-cdn': { strategy: 'CacheFirst', maxAge: 365 * 24 * 60 * 60 }, 'latest-rate-cache': { strategy: 'StaleWhileRevalidate', maxAge: 7 * 24 * 60 * 60 }, - 'image-cache': { strategy: 'CacheFirst', maxAge: 90 * 24 * 60 * 60 }, + 'image-cache': { strategy: 'CacheFirst', maxAge: 30 * 24 * 60 * 60, maxEntries: 60 }, 'font-cache': { strategy: 'CacheFirst', maxAge: 365 * 24 * 60 * 60 }, 'static-resources': { strategy: 'CacheFirst', maxAge: 30 * 24 * 60 * 60 }, }; @@ -231,6 +231,33 @@ describe('Service Worker Cache Strategies', () => { expect(config.maxAge).toBe(7 * 24 * 60 * 60); // 7 days }); + it('should cache same-origin api latest and pairs JSON with StaleWhileRevalidate', async () => { + const fs = await import('node:fs/promises'); + const path = await import('node:path'); + + const swPath = path.resolve(__dirname, '../sw.ts'); + const sourceCode = await fs.readFile(swPath, 'utf-8'); + + expect(sourceCode).toContain("url.pathname.endsWith('/api/latest.json')"); + expect(sourceCode).toContain("url.pathname.includes('/api/pairs/')"); + }); + + it('should use CacheFirst with 30-day expiration for runtime images', async () => { + const fs = await import('node:fs/promises'); + const path = await import('node:path'); + + const swPath = path.resolve(__dirname, '../sw.ts'); + const sourceCode = await fs.readFile(swPath, 'utf-8'); + const config = expectedStrategies['image-cache']; + + expect(sourceCode).toContain('IMAGE_EXTENSION_PATTERN'); + expect(sourceCode).toContain("cacheName: 'image-cache'"); + expect(sourceCode).toContain('maxEntries: 60'); + expect(config.strategy).toBe('CacheFirst'); + expect(config.maxAge).toBe(30 * 24 * 60 * 60); + expect(config.maxEntries).toBe(60); + }); + it('should register a network-only route for connectivity probe', async () => { const fs = await import('node:fs/promises'); const path = await import('node:path'); diff --git a/apps/ratewise/src/config/__tests__/verify-precache-assets.test.ts b/apps/ratewise/src/config/__tests__/verify-precache-assets.test.ts index 051dfe8ee..58ade25ed 100644 --- a/apps/ratewise/src/config/__tests__/verify-precache-assets.test.ts +++ b/apps/ratewise/src/config/__tests__/verify-precache-assets.test.ts @@ -59,4 +59,31 @@ describe('verify-precache-assets script', () => { path.resolve('/repo/apps/ratewise/dist', 'assets/app.css'), ); }); + + it('should define tier-1 precache guardrails for shell assets and forbidden runtime-only resources', async () => { + const script = await loadVerifyPrecacheModule(); + + expect(script.MAX_PRECACHE_ENTRY_COUNT).toBe(100); + expect(script.MAX_PRECACHE_BYTES).toBe(3 * 1024 * 1024); + expect(script.REQUIRED_PRECACHE_URLS).toEqual( + expect.arrayContaining([ + 'index.html', + 'offline.html', + 'favicon.svg', + 'favicon.ico', + 'apple-touch-icon.png', + 'icons/ratewise-icon-192x192.png', + ]), + ); + expect( + script.FORBIDDEN_PRECACHE_PATTERNS.some((pattern: RegExp) => + pattern.test('screenshots/a.png'), + ), + ).toBe(true); + expect( + script.FORBIDDEN_PRECACHE_PATTERNS.some((pattern: RegExp) => + pattern.test('usd-twd/index.html'), + ), + ).toBe(true); + }); }); diff --git a/apps/ratewise/src/pwa-offline.test.ts b/apps/ratewise/src/pwa-offline.test.ts index de5b69d2d..1949c6cd2 100644 --- a/apps/ratewise/src/pwa-offline.test.ts +++ b/apps/ratewise/src/pwa-offline.test.ts @@ -292,6 +292,27 @@ describe('PWA 離線功能測試', () => { expect(viteConfig).not.toContain('**/*.{js,css,html'); }); + it('should keep images out of precache globPatterns and add shell icons via additionalManifestEntries', () => { + const viteConfig = readFileSync(resolve(ROOT_PATH, 'vite.config.ts'), 'utf-8'); + expect(viteConfig).not.toContain("'**/*.png'"); + expect(viteConfig).not.toContain("'**/*.ico'"); + expect(viteConfig).not.toContain("'**/*.svg'"); + expect(viteConfig).not.toContain("'**/*.webp'"); + expect(viteConfig).not.toContain("'**/*.avif'"); + expect(viteConfig).not.toContain("'**/*.json'"); + expect(viteConfig).toContain("'favicon.svg'"); + expect(viteConfig).toContain("'favicon.ico'"); + expect(viteConfig).toContain("'apple-touch-icon.png'"); + expect(viteConfig).toContain("'icons/ratewise-icon-192x192.png'"); + }); + + it('should exclude nested HTML and openapi from precache via globIgnores', () => { + const viteConfig = readFileSync(resolve(ROOT_PATH, 'vite.config.ts'), 'utf-8'); + expect(viteConfig).toContain("'**/*/index.html'"); + expect(viteConfig).toContain("'**/openapi.json'"); + expect(viteConfig).toContain("'**/screenshots/**'"); + }); + it('should verify index.html and shell assets exist in the generated precache manifest', () => { const verifyScript = readFileSync( resolve(ROOT_PATH, '../../scripts/verify-precache-assets.mjs'), diff --git a/apps/ratewise/src/sw.ts b/apps/ratewise/src/sw.ts index 4e1149f21..1c825242d 100644 --- a/apps/ratewise/src/sw.ts +++ b/apps/ratewise/src/sw.ts @@ -394,33 +394,49 @@ registerRoute( }), ); -// 最新匯率:StaleWhileRevalidate,離線備援 7 天。 +const LATEST_RATE_SWR_PLUGINS = [ + new CacheableResponsePlugin({ statuses: [0, 200] }), + new ExpirationPlugin({ + maxEntries: 20, + maxAgeSeconds: 60 * 60 * 24 * 7, // 7 天 + }), +]; + +// 最新匯率(GitHub raw):StaleWhileRevalidate,離線備援 7 天。 registerRoute( ({ url }: { url: URL }) => url.origin === 'https://raw.githubusercontent.com' && url.pathname.includes('/public/rates/latest.json'), new StaleWhileRevalidate({ cacheName: 'latest-rate-cache', - plugins: [ - new CacheableResponsePlugin({ statuses: [0, 200] }), - new ExpirationPlugin({ - maxEntries: 1, - maxAgeSeconds: 60 * 60 * 24 * 7, // 7 天 - }), - ], + plugins: LATEST_RATE_SWR_PLUGINS, }), ); -// 圖片:CacheFirst,90 天。 +// 同域匯率 API(latest / pairs):StaleWhileRevalidate,離線備援 7 天。 registerRoute( - ({ request }: { request: Request }) => request.destination === 'image', + ({ url }: { url: URL }) => + url.pathname.endsWith('/api/latest.json') || + (url.pathname.includes('/api/pairs/') && url.pathname.endsWith('.json')), + new StaleWhileRevalidate({ + cacheName: 'latest-rate-cache', + plugins: LATEST_RATE_SWR_PLUGINS, + }), +); + +const IMAGE_EXTENSION_PATTERN = /\.(?:png|jpe?g|webp|avif)$/i; + +// 圖片(Tier 2):CacheFirst,按需快取大圖示 / OG / screenshots 等。 +registerRoute( + ({ request, url }: { request: Request; url: URL }) => + request.destination === 'image' || IMAGE_EXTENSION_PATTERN.test(url.pathname), new CacheFirst({ cacheName: 'image-cache', plugins: [ new CacheableResponsePlugin({ statuses: [0, 200] }), new ExpirationPlugin({ - maxEntries: 150, - maxAgeSeconds: 60 * 60 * 24 * 90, // 90 天 + maxEntries: 60, + maxAgeSeconds: 60 * 60 * 24 * 30, // 30 天 }), ], }), diff --git a/apps/ratewise/vite.config.ts b/apps/ratewise/vite.config.ts index 8ed3eb0ac..ca8d8c2f9 100644 --- a/apps/ratewise/vite.config.ts +++ b/apps/ratewise/vite.config.ts @@ -298,37 +298,43 @@ export default defineConfig(({ mode }) => { injectRegister: 'inline', injectManifest: { - // 避免 brace expansion 相依異常導致 glob 全數失效,明確列出副檔名。 - // 含 json 以預快取 React Router data manifest(離線 SPA 導覽必要)。 + // Tier 1 precache:app shell(index.html)+ JS/CSS。 + // 圖片改 runtime CacheFirst、匯率 JSON 改 runtime SWR(見 sw.ts)。 + // static-loader-data-manifest 為 vite-react-ssg 路由 loader 資料清單, + // 屬 app shell 一環、離線 SPA 導覽必要,必須保留 precache(內容雜湊命名)。 globPatterns: [ '**/*.js', '**/*.css', '**/*.html', - '**/*.ico', - '**/*.png', - '**/*.svg', - '**/*.avif', - '**/*.webp', - '**/*.json', + '**/static-loader-data-manifest-*.json', ], globIgnores: [ '**/og-image-old.png', '**/node_modules/**', '**/lighthouse-reports/**', - '**/rates/**/*.json', '**/pwa-install/**', + '**/screenshots/**', '**/offline.html', '**/sitemap.xml', '**/robots.txt', '**/llms.txt', '**/manifest.webmanifest', - // SSG 金額排列子頁(例 usd-twd/500/index.html)由 runtime html-cache 處理; - // precache 僅保留 app shell 與各幣別頂層 landing index.html。 - '**/[a-z][a-z][a-z]-twd/*/index.html', - '**/twd-[a-z][a-z][a-z]/*/index.html', + '**/openapi.json', + // 非 shell HTML(幣別 landing、金額子頁、about/faq 等)由 NavigationRoute → index.html 處理。 + '**/*/index.html', ], additionalManifestEntries: [ { url: 'offline.html', revision: getFileRevision('public/offline.html') }, + { url: 'favicon.svg', revision: getFileRevision('public/favicon.svg') }, + { url: 'favicon.ico', revision: getFileRevision('public/favicon.ico') }, + { + url: 'apple-touch-icon.png', + revision: getFileRevision('public/apple-touch-icon.png'), + }, + { + url: 'icons/ratewise-icon-192x192.png', + revision: getFileRevision('public/icons/ratewise-icon-192x192.png'), + }, ], rollupFormat: 'iife', // SW 中 location 全域變數 polyfill(Workbox 相容性) diff --git a/scripts/verify-precache-assets.mjs b/scripts/verify-precache-assets.mjs index e346d781c..5aa55589e 100755 --- a/scripts/verify-precache-assets.mjs +++ b/scripts/verify-precache-assets.mjs @@ -2,7 +2,7 @@ /* eslint-env node */ /* eslint-disable no-undef */ -import { readFile, existsSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import { readFile as readFileAsync } from 'node:fs/promises'; import path from 'node:path'; @@ -13,6 +13,27 @@ const DIST_DIR = path.resolve(PROJECT_ROOT, 'apps/ratewise/dist'); const SW_PATH = path.resolve(PROJECT_ROOT, 'apps/ratewise/dist/sw.js'); const INDEX_HTML_PATH = path.resolve(DIST_DIR, 'index.html'); const MIN_PRECACHE_ENTRY_COUNT = 20; +const MAX_PRECACHE_ENTRY_COUNT = 100; +const MAX_PRECACHE_BYTES = 3 * 1024 * 1024; + +const REQUIRED_PRECACHE_URLS = [ + 'index.html', + 'offline.html', + 'favicon.svg', + 'favicon.ico', + 'apple-touch-icon.png', + 'icons/ratewise-icon-192x192.png', +]; + +const FORBIDDEN_PRECACHE_PATTERNS = [ + /screenshots\//, + /pwa-install\//, + /-1024x1024\.png/, + /pwa-512x512\.png/, + /openapi\.json$/, + /[a-z]{3}-[a-z]{3}\/index\.html$/, + /\/[a-z]{3}-[a-z]{3}\/\d+/, +]; function normalizeBase(url) { // Remove trailing slashes first, then add a single slash @@ -213,6 +234,46 @@ async function main() { ); } + if (entries.length > MAX_PRECACHE_ENTRY_COUNT) { + throw new Error( + `precache 條目過多:目前 ${entries.length} 筆,上限 ${MAX_PRECACHE_ENTRY_COUNT} 筆。請確認 globIgnores 已排除非 Tier 1 資源。`, + ); + } + + const missingRequired = REQUIRED_PRECACHE_URLS.filter((url) => !entryUrls.has(url)); + if (missingRequired.length > 0) { + throw new Error(`precache 缺少 Tier 1 shell 資產:${missingRequired.join(', ')}。`); + } + + const forbiddenMatches = entries + .map((entry) => entry.url) + .filter((url) => url && FORBIDDEN_PRECACHE_PATTERNS.some((pattern) => pattern.test(url))); + if (forbiddenMatches.length > 0) { + throw new Error( + `precache 洩漏非 Tier 1 資源:${forbiddenMatches.slice(0, 5).join(', ')}${ + forbiddenMatches.length > 5 ? '…' : '' + }`, + ); + } + + if (VERIFY_SOURCE === 'local') { + let totalBytes = 0; + for (const entry of entries) { + const localPath = resolveLocalPrecacheAssetPath(entry.url, DIST_DIR); + if (existsSync(localPath)) { + totalBytes += readFileSync(localPath).byteLength; + } + } + if (totalBytes > MAX_PRECACHE_BYTES) { + throw new Error( + `precache 總體積過大:${(totalBytes / 1024 / 1024).toFixed(2)}MB,上限 ${MAX_PRECACHE_BYTES / 1024 / 1024}MB。`, + ); + } + console.log( + `📦 precache 體積:${entries.length} 筆 / ${(totalBytes / 1024 / 1024).toFixed(2)}MB`, + ); + } + if (!entryUrls.has('index.html')) { throw new Error('precache 缺少 index.html,冷啟動離線導覽將直接失敗。'); } @@ -287,4 +348,8 @@ export { parseShellAssetUrls, shouldProbePrecacheAssetsOverHttp, resolveLocalPrecacheAssetPath, + REQUIRED_PRECACHE_URLS, + FORBIDDEN_PRECACHE_PATTERNS, + MAX_PRECACHE_ENTRY_COUNT, + MAX_PRECACHE_BYTES, }; From 5db26c9275c4ebbdbdb2fa011ecbb425801a3c03 Mon Sep 17 00:00:00 2001 From: haotool Date: Sun, 28 Jun 2026 11:50:53 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(ratewise):=20=E6=94=B6=E6=96=82?= =?UTF-8?q?=E5=A4=9A=20QA=20review=20=E5=BC=B7=E5=8C=96=E5=BF=AB=E5=8F=96?= =?UTF-8?q?=E5=88=86=E5=B1=A4=E8=AD=B7=E6=AC=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - verifyAndRepairPrecache 補修 static-loader-data-manifest(離線 SPA 導覽唯一無 runtime 後備的 shell 資產) - latest-rate-cache maxEntries 20→32,預留擴充幣對餘裕避免 LRU 驅逐離線匯率備援 - 同域 API SWR matcher 加同源判斷,避免 cross-origin pathname 碰撞污染快取 - precache gate 新增 loader-manifest 必含檢查,FORBIDDEN 補 api JSON、巢狀 index.html、點陣圖 - 補回歸測試鎖定上述護欄(共 83 PWA 測試) 測試:typecheck 通過;PWA/gate 83 測試全綠;build precache 92 筆/2.2MB;hardened gate 通過 Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- .changeset/ratewise-precache-guardrails.md | 5 ++++ apps/ratewise/src/__tests__/sw.test.ts | 15 ++++++++++ .../__tests__/verify-precache-assets.test.ts | 18 ++++++++++++ apps/ratewise/src/sw.ts | 17 ++++++++--- scripts/verify-precache-assets.mjs | 29 +++++++++++++++++-- 5 files changed, 77 insertions(+), 7 deletions(-) create mode 100644 .changeset/ratewise-precache-guardrails.md diff --git a/.changeset/ratewise-precache-guardrails.md b/.changeset/ratewise-precache-guardrails.md new file mode 100644 index 000000000..fc3d4eea7 --- /dev/null +++ b/.changeset/ratewise-precache-guardrails.md @@ -0,0 +1,5 @@ +--- +'@app/ratewise': patch +--- + +強化 PWA 快取分層的離線韌性與防回歸護欄:iOS 快取被驅逐後一併修復離線導覽所需的路由 loader 清單、擴充匯率離線備援的快取容量,並收緊預快取守門避免非必要資源回流,確保主功能離線載入更穩定。 diff --git a/apps/ratewise/src/__tests__/sw.test.ts b/apps/ratewise/src/__tests__/sw.test.ts index e62f7db12..6197780e2 100644 --- a/apps/ratewise/src/__tests__/sw.test.ts +++ b/apps/ratewise/src/__tests__/sw.test.ts @@ -240,6 +240,21 @@ describe('Service Worker Cache Strategies', () => { expect(sourceCode).toContain("url.pathname.endsWith('/api/latest.json')"); expect(sourceCode).toContain("url.pathname.includes('/api/pairs/')"); + // 防回歸:同域 API SWR 必須限制同源,避免 cross-origin pathname 碰撞污染 latest-rate-cache。 + expect(sourceCode).toContain('url.origin === self.location.origin'); + // 防回歸:共用 latest-rate-cache 需保留擴充幣對餘裕(GitHub raw + latest + 17 pairs)。 + expect(sourceCode).toContain('maxEntries: 32'); + }); + + it('should repair the loader-data manifest alongside JS/CSS after iOS eviction', async () => { + const fs = await import('node:fs/promises'); + const path = await import('node:path'); + + const swPath = path.resolve(__dirname, '../sw.ts'); + const sourceCode = await fs.readFile(swPath, 'utf-8'); + + // 防回歸:verifyAndRepairPrecache 必須涵蓋 static-loader-data-manifest(無 runtime route 後備)。 + expect(sourceCode).toContain("relUrl.includes('static-loader-data-manifest')"); }); it('should use CacheFirst with 30-day expiration for runtime images', async () => { diff --git a/apps/ratewise/src/config/__tests__/verify-precache-assets.test.ts b/apps/ratewise/src/config/__tests__/verify-precache-assets.test.ts index 58ade25ed..1f2d0d27e 100644 --- a/apps/ratewise/src/config/__tests__/verify-precache-assets.test.ts +++ b/apps/ratewise/src/config/__tests__/verify-precache-assets.test.ts @@ -86,4 +86,22 @@ describe('verify-precache-assets script', () => { ), ).toBe(true); }); + + it('requires the hash-named loader-data manifest in precache (offline SPA nav)', async () => { + const script = await loadVerifyPrecacheModule(); + expect(script.REQUIRED_PRECACHE_SUBSTRINGS).toContain('static-loader-data-manifest'); + }); + + it('forbids Tier 2 runtime assets (api JSON, nested index.html, raster images) in precache', async () => { + const script = await loadVerifyPrecacheModule(); + const isForbidden = (url: string): boolean => + script.FORBIDDEN_PRECACHE_PATTERNS.some((pattern: RegExp) => pattern.test(url)); + expect(isForbidden('api/latest.json')).toBe(true); + expect(isForbidden('api/pairs/usd-twd.json')).toBe(true); + expect(isForbidden('about/index.html')).toBe(true); + expect(isForbidden('faq/index.html')).toBe(true); + expect(isForbidden('og-image.jpg')).toBe(true); + // 根 index.html 與 shell 圖示不得被視為禁止項。 + expect(isForbidden('index.html')).toBe(false); + }); }); diff --git a/apps/ratewise/src/sw.ts b/apps/ratewise/src/sw.ts index 1c825242d..a8cb9be89 100644 --- a/apps/ratewise/src/sw.ts +++ b/apps/ratewise/src/sw.ts @@ -88,9 +88,15 @@ async function verifyAndRepairPrecache(): Promise { const scope = self.registration.scope; type ManifestEntry = string | { url: string; revision?: string | null }; + // 補回 iOS eviction 清除的 Tier 1 shell 資產:JS/CSS 與路由 loader 清單。 + // loader 清單為離線 SPA 子路由導覽必要,且無 runtime route 後備,故一併修復。 const missing = (WB_MANIFEST as ManifestEntry[]).filter((entry) => { const relUrl = typeof entry === 'string' ? entry : entry.url; - if (!relUrl.endsWith('.js') && !relUrl.endsWith('.css')) return false; + const isRepairable = + relUrl.endsWith('.js') || + relUrl.endsWith('.css') || + relUrl.includes('static-loader-data-manifest'); + if (!isRepairable) return false; const fullUrl = new URL(relUrl, scope).href; return !cachedUrls.has(fullUrl); }); @@ -397,7 +403,9 @@ registerRoute( const LATEST_RATE_SWR_PLUGINS = [ new CacheableResponsePlugin({ statuses: [0, 200] }), new ExpirationPlugin({ - maxEntries: 20, + // GitHub raw + 同域 api/latest + 17 個 api/pairs 共用此快取(約 19 筆), + // 預留擴充幣對的餘裕,避免 LRU 驅逐離線匯率備援。 + maxEntries: 32, maxAgeSeconds: 60 * 60 * 24 * 7, // 7 天 }), ]; @@ -416,8 +424,9 @@ registerRoute( // 同域匯率 API(latest / pairs):StaleWhileRevalidate,離線備援 7 天。 registerRoute( ({ url }: { url: URL }) => - url.pathname.endsWith('/api/latest.json') || - (url.pathname.includes('/api/pairs/') && url.pathname.endsWith('.json')), + url.origin === self.location.origin && + (url.pathname.endsWith('/api/latest.json') || + (url.pathname.includes('/api/pairs/') && url.pathname.endsWith('.json'))), new StaleWhileRevalidate({ cacheName: 'latest-rate-cache', plugins: LATEST_RATE_SWR_PLUGINS, diff --git a/scripts/verify-precache-assets.mjs b/scripts/verify-precache-assets.mjs index 5aa55589e..c88d2bfa5 100755 --- a/scripts/verify-precache-assets.mjs +++ b/scripts/verify-precache-assets.mjs @@ -25,14 +25,21 @@ const REQUIRED_PRECACHE_URLS = [ 'icons/ratewise-icon-192x192.png', ]; +// Tier 1 必含但檔名帶 hash 的資產,以子字串比對。 +const REQUIRED_PRECACHE_SUBSTRINGS = ['static-loader-data-manifest']; + const FORBIDDEN_PRECACHE_PATTERNS = [ /screenshots\//, /pwa-install\//, /-1024x1024\.png/, /pwa-512x512\.png/, /openapi\.json$/, - /[a-z]{3}-[a-z]{3}\/index\.html$/, - /\/[a-z]{3}-[a-z]{3}\/\d+/, + // 匯率 JSON 屬 Tier 2 runtime SWR,不得進 precache(loader manifest 例外,於 REQUIRED 檢查)。 + /(?:^|\/)api\/(?:latest\.json|pairs\/)/, + // 任何非根目錄 index.html(幣別 landing、about、faq 等 SSG 頁)由 NavigationRoute 回退 shell。 + /.+\/index\.html$/, + // 點陣圖一律 runtime CacheFirst(REQUIRED 的 shell 圖示於下方掃描時排除)。 + /\.(?:png|jpe?g|webp|avif)$/, ]; function normalizeBase(url) { @@ -245,9 +252,24 @@ async function main() { throw new Error(`precache 缺少 Tier 1 shell 資產:${missingRequired.join(', ')}。`); } + const missingRequiredSubstrings = REQUIRED_PRECACHE_SUBSTRINGS.filter( + (needle) => ![...entryUrls].some((url) => url.includes(needle)), + ); + if (missingRequiredSubstrings.length > 0) { + throw new Error( + `precache 缺少 Tier 1 雜湊命名資產:${missingRequiredSubstrings.join(', ')}(離線 SPA 導覽必要)。`, + ); + } + + const requiredUrlSet = new Set(REQUIRED_PRECACHE_URLS); const forbiddenMatches = entries .map((entry) => entry.url) - .filter((url) => url && FORBIDDEN_PRECACHE_PATTERNS.some((pattern) => pattern.test(url))); + .filter( + (url) => + url && + !requiredUrlSet.has(url) && + FORBIDDEN_PRECACHE_PATTERNS.some((pattern) => pattern.test(url)), + ); if (forbiddenMatches.length > 0) { throw new Error( `precache 洩漏非 Tier 1 資源:${forbiddenMatches.slice(0, 5).join(', ')}${ @@ -349,6 +371,7 @@ export { shouldProbePrecacheAssetsOverHttp, resolveLocalPrecacheAssetPath, REQUIRED_PRECACHE_URLS, + REQUIRED_PRECACHE_SUBSTRINGS, FORBIDDEN_PRECACHE_PATTERNS, MAX_PRECACHE_ENTRY_COUNT, MAX_PRECACHE_BYTES,