diff --git a/.changeset/ratewise-precache-cold-start.md b/.changeset/ratewise-precache-cold-start.md new file mode 100644 index 000000000..2d97d0e5f --- /dev/null +++ b/.changeset/ratewise-precache-cold-start.md @@ -0,0 +1,9 @@ +--- +'@app/ratewise': patch +--- + +重整 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/.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/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/src/__tests__/sw.test.ts b/apps/ratewise/src/__tests__/sw.test.ts index 616faad01..6197780e2 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,48 @@ 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/')"); + // 防回歸:同域 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 () => { + 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..1f2d0d27e 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,49 @@ 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); + }); + + 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/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..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); }); @@ -394,33 +400,52 @@ registerRoute( }), ); -// 最新匯率:StaleWhileRevalidate,離線備援 7 天。 +const LATEST_RATE_SWR_PLUGINS = [ + new CacheableResponsePlugin({ statuses: [0, 200] }), + new ExpirationPlugin({ + // GitHub raw + 同域 api/latest + 17 個 api/pairs 共用此快取(約 19 筆), + // 預留擴充幣對的餘裕,避免 LRU 驅逐離線匯率備援。 + maxEntries: 32, + 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.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, + }), +); + +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 017ba7422..ca8d8c2f9 100644 --- a/apps/ratewise/vite.config.ts +++ b/apps/ratewise/vite.config.ts @@ -298,33 +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', + '**/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..c88d2bfa5 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,34 @@ 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', +]; + +// 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$/, + // 匯率 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) { // Remove trailing slashes first, then add a single slash @@ -213,6 +241,61 @@ 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 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 && + !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(', ')}${ + 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 +370,9 @@ export { parseShellAssetUrls, shouldProbePrecacheAssetsOverHttp, resolveLocalPrecacheAssetPath, + REQUIRED_PRECACHE_URLS, + REQUIRED_PRECACHE_SUBSTRINGS, + FORBIDDEN_PRECACHE_PATTERNS, + MAX_PRECACHE_ENTRY_COUNT, + MAX_PRECACHE_BYTES, };