From 0331b7bcc6cdb302ba9b040f1933784ab33fa8ea Mon Sep 17 00:00:00 2001 From: haotool Date: Fri, 19 Jun 2026 12:34:51 +0800 Subject: [PATCH 1/9] =?UTF-8?q?fix(ratewise):=20=E4=BB=A5=20precache-first?= =?UTF-8?q?=20=E5=8F=96=E4=BB=A3=203s=20timeout=20=E4=BF=AE=E5=BE=A9=20iOS?= =?UTF-8?q?=20PWA=20eviction=20=E5=81=87=E9=9B=A2=E7=B7=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 NAVIGATION_NETWORK_TIMEOUT_MS + Promise.race:iOS eviction 後 3s timeout 會將 offline.html 回傳給在線用戶(假離線症狀,看起來像未更新至最新版本) - Cold start 改用 matchPrecache('index.html') 立即回傳,等同 createHandlerBoundToURL 行為且無需等待;背景同步更新 html-cache 供暖啟動使用 - ensureOfflineHtmlCached:同時備份 index.html 至 html-cache,使 index 與 offline.html 具有同等 iOS eviction 存活率 - resolveOfflineDocumentFallback 新增 matchIndexHtmlInAnyCache 第二層 fallback 測試:pwaOfflineFallback.test.ts 補齊 matchIndexHtmlInAnyCache 路徑;154 ratewise tests pass Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- .changeset/fix-ratewise-pwa-ios-eviction.md | 5 + apps/ratewise/public/offline.html | 232 ++++++++++++++++-- apps/ratewise/src/sw.ts | 95 ++++--- .../__tests__/pwaOfflineFallback.test.ts | 17 ++ apps/ratewise/src/utils/pwaOfflineFallback.ts | 12 + 5 files changed, 301 insertions(+), 60 deletions(-) create mode 100644 .changeset/fix-ratewise-pwa-ios-eviction.md diff --git a/.changeset/fix-ratewise-pwa-ios-eviction.md b/.changeset/fix-ratewise-pwa-ios-eviction.md new file mode 100644 index 000000000..4322d4802 --- /dev/null +++ b/.changeset/fix-ratewise-pwa-ios-eviction.md @@ -0,0 +1,5 @@ +--- +'@app/ratewise': patch +--- + +修復 iOS PWA precache 驅逐後 3s timeout 導致在線用戶看到 offline.html 的假離線問題,改用 precache-first 冷啟動策略 diff --git a/apps/ratewise/public/offline.html b/apps/ratewise/public/offline.html index 1594b5972..5d4d808b9 100644 --- a/apps/ratewise/public/offline.html +++ b/apps/ratewise/public/offline.html @@ -2,13 +2,184 @@ - - + + + + + 離線模式 - HaoRate + diff --git a/apps/ratewise/src/sw.ts b/apps/ratewise/src/sw.ts index daa95f259..13fd81312 100644 --- a/apps/ratewise/src/sw.ts +++ b/apps/ratewise/src/sw.ts @@ -28,7 +28,6 @@ self.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => { // 保存 manifest 供 VERIFY_AND_REPAIR_PRECACHE 使用。 const WB_MANIFEST = self.__WB_MANIFEST; const HTML_CACHE_NAME = 'html-cache'; -const NAVIGATION_NETWORK_TIMEOUT_MS = 3000; // 預快取 Vite 產出的靜態資源。 precacheAndRoute(WB_MANIFEST); @@ -44,24 +43,42 @@ precacheAndRoute(WB_MANIFEST); * 此機制在 SW activate 時直接用 bare URL(無 revision)快取 offline.html, * 確保 setCatchHandler 的 matchPrecache('offline.html') 或 caches.match() 一定能命中。 */ +/** + * 確保關鍵文件存入 html-cache,對抗 iOS Safari precache 驅逐。 + * + * 問題:iOS 在記憶體壓力下驅逐 Workbox precache(整個 cache 被砍), + * 造成 matchPrecache('index.html') 失敗,而 offline.html 若只在 html-cache + * 中備份則反而成為唯一可用文件,導致在線用戶看到離線頁面。 + * + * 修法:offline.html 與 index.html 都備份到 html-cache,確保兩者具有 + * 同等存活率。index.html 從 precache 複製(避免額外網路請求)。 + */ async function ensureOfflineHtmlCached(): Promise { try { - // 先檢查是否已在任何快取中(precache 或 critical-launch-cache) - const existingResponse = await caches.match('offline.html'); - if (existingResponse) { - return; + const scope = self.registration.scope; + const cache = await caches.open(HTML_CACHE_NAME); + + // 備份 offline.html(從網路取得,確保最新版) + const existingOffline = await caches.match('offline.html'); + if (!existingOffline) { + const offlineUrl = new URL('offline.html', scope).href; + const response = await fetch(offlineUrl, { cache: 'no-cache' }); + if (response.ok) { + await cache.put(offlineUrl, response.clone()); + await cache.put('offline.html', response); + } } - // 嘗試從網路取得並快取到 html-cache(setCatchHandler 可 match) - const scope = self.registration.scope; - const offlineUrl = new URL('offline.html', scope).href; - - const response = await fetch(offlineUrl, { cache: 'no-cache' }); - if (response.ok) { - const cache = await caches.open(HTML_CACHE_NAME); - await cache.put(offlineUrl, response.clone()); - // 同時用相對路徑快取,讓 matchPrecache('offline.html') 也能命中 - await cache.put('offline.html', response); + // 備份 index.html(從 precache 複製,避免額外網路請求) + // 使 index.html 與 offline.html 具有同等 iOS eviction 存活率。 + const indexUrl = new URL('index.html', scope).href; + const existingIndex = await cache.match(indexUrl); + if (!existingIndex) { + const precachedIndex = await matchPrecache('index.html'); + if (precachedIndex) { + await cache.put(indexUrl, precachedIndex.clone()); + await cache.put('index.html', precachedIndex); + } } } catch { // 離線時無法 fetch 為正常現象,忽略錯誤。 @@ -252,23 +269,22 @@ setCatchHandler(async ({ event, request }): Promise => { return resolveOfflineDocumentFallback({ emergencyReason: 'emergency-document-fallback', matchPrecache, + matchIndexHtmlInAnyCache: () => caches.match('index.html'), matchOfflineHtmlInAnyCache: () => caches.match('offline.html'), }); }); /** - * SPA 導覽策略:bounded SWR-style navigation(installed PWA 與瀏覽器共用) + * SPA 導覽策略:hybrid SWR + precache-first navigation * - * 業界最佳實踐(web.dev / Workbox docs): - * - 已 install 過的 PWA / 已 visited 的瀏覽器:cache hit 立即返回(零白屏冷啟動) - * - 背景 revalidate 抓取最新 HTML 寫回 cache,下一次 navigation 自動拿到新版 - * - 新版本切換由既有 SW controllerchange + reload 機制處理(main.tsx) - * - cache miss 導覽:網路最多等待 3s,再回 precache 三層 fallback + * - 暖快取(html-cache hit):立即回傳已快取 HTML + 背景 revalidate(零白屏) + * - 冷快取(html-cache miss):直接從 Workbox precache 取 index.html 回傳, + * 同時背景發網路請求更新 html-cache,下次導覽自動用最新版本 + * - precache 也 miss(iOS eviction):等網路回應,失敗才 fallback 到 offline.html * - * 取代 NetworkFirst + 3s timeout 的理由: - * - NetworkFirst 在慢網路下要等到 3s timeout 才 fallback → 感知白屏 - * - SWR-style cache hit 立即回應,把已暖機場景的感知白屏降為 0 - * - 對版本撕裂的防護:activate 時清掉舊 HTML runtime cache,避免新 SW 先回舊 HTML + * 為什麼不用 3s timeout: + * - timeout 命中時 precache 可能已被 iOS 驅逐,導致 offline.html 被服務給在線用戶 + * - cold cache 用 precache 直接回傳,等同舊的 createHandlerBoundToURL 行為,無需等待 * * @see https://developer.chrome.com/docs/workbox/modules/workbox-strategies#stale-while-revalidate */ @@ -276,6 +292,7 @@ function resolveNavigationFallback(): Promise { return resolveOfflineDocumentFallback({ emergencyReason: 'emergency-navigation-fallback', matchPrecache, + matchIndexHtmlInAnyCache: () => caches.match('index.html'), matchOfflineHtmlInAnyCache: () => caches.match('offline.html'), }); } @@ -302,6 +319,7 @@ async function handleNavigationRequest({ const cache = await caches.open(HTML_CACHE_NAME); const cached = await cache.match(request); if (cached) { + // 暖快取:SWR — 立即回傳 + 背景 revalidate event.waitUntil( fetchAndCacheNavigation(request, cache) .then(() => undefined) @@ -310,17 +328,24 @@ async function handleNavigationRequest({ return cached; } - const networkResponse = fetchAndCacheNavigation(request, cache).catch(() => - resolveNavigationFallback(), - ); - event.waitUntil(networkResponse.then(() => undefined).catch(() => undefined)); - const timeoutFallback = new Promise((resolve) => { - setTimeout(() => { - resolve(resolveNavigationFallback()); - }, NAVIGATION_NETWORK_TIMEOUT_MS); - }); + // 冷快取:先嘗試 precache index.html(零延遲),再背景抓最新版本寫入 html-cache。 + // 這避免了 3s timeout 在 iOS precache 被驅逐時錯誤回傳 offline.html 給在線用戶。 + const precachedShell = await matchPrecache('index.html'); + if (precachedShell) { + event.waitUntil( + fetchAndCacheNavigation(request, cache) + .then(() => undefined) + .catch(() => undefined), + ); + return precachedShell; + } - return Promise.race([networkResponse, timeoutFallback]); + // precache 也 miss(例如 iOS eviction):等網路,真正失敗才用 offline fallback + try { + return await fetchAndCacheNavigation(request, cache); + } catch { + return resolveNavigationFallback(); + } } registerRoute(new NavigationRoute(handleNavigationRequest)); diff --git a/apps/ratewise/src/utils/__tests__/pwaOfflineFallback.test.ts b/apps/ratewise/src/utils/__tests__/pwaOfflineFallback.test.ts index 55618984b..a50832d65 100644 --- a/apps/ratewise/src/utils/__tests__/pwaOfflineFallback.test.ts +++ b/apps/ratewise/src/utils/__tests__/pwaOfflineFallback.test.ts @@ -13,6 +13,7 @@ describe('pwaOfflineFallback', () => { const response = await resolveOfflineDocumentFallback({ emergencyReason: 'emergency-navigation-fallback', matchPrecache, + matchIndexHtmlInAnyCache: () => null, matchOfflineHtmlInAnyCache, }); @@ -22,12 +23,26 @@ describe('pwaOfflineFallback', () => { expect(matchOfflineHtmlInAnyCache).not.toHaveBeenCalled(); }); + it('should fall back to index.html from any cache when precache is evicted', async () => { + const anyCacheIndexResponse = new Response('index from html-cache'); + + const response = await resolveOfflineDocumentFallback({ + emergencyReason: 'emergency-navigation-fallback', + matchPrecache: () => null, + matchIndexHtmlInAnyCache: () => anyCacheIndexResponse, + matchOfflineHtmlInAnyCache: () => null, + }); + + expect(response).toBe(anyCacheIndexResponse); + }); + it('should use precached offline.html when index.html is unavailable', async () => { const offlineResponse = new Response('offline'); const response = await resolveOfflineDocumentFallback({ emergencyReason: 'emergency-navigation-fallback', matchPrecache: (url) => (url === 'offline.html' ? offlineResponse : null), + matchIndexHtmlInAnyCache: () => null, matchOfflineHtmlInAnyCache: () => null, }); @@ -40,6 +55,7 @@ describe('pwaOfflineFallback', () => { const response = await resolveOfflineDocumentFallback({ emergencyReason: 'emergency-navigation-fallback', matchPrecache: () => null, + matchIndexHtmlInAnyCache: () => null, matchOfflineHtmlInAnyCache: () => anyCacheOfflineResponse, }); @@ -50,6 +66,7 @@ describe('pwaOfflineFallback', () => { const response = await resolveOfflineDocumentFallback({ emergencyReason: 'emergency-navigation-fallback', matchPrecache: () => null, + matchIndexHtmlInAnyCache: () => null, matchOfflineHtmlInAnyCache: () => null, }); diff --git a/apps/ratewise/src/utils/pwaOfflineFallback.ts b/apps/ratewise/src/utils/pwaOfflineFallback.ts index f7288b395..edd6342bb 100644 --- a/apps/ratewise/src/utils/pwaOfflineFallback.ts +++ b/apps/ratewise/src/utils/pwaOfflineFallback.ts @@ -73,6 +73,11 @@ interface ResolveOfflineDocumentFallbackOptions { matchPrecache: ( url: 'index.html' | 'offline.html', ) => Response | undefined | null | Promise; + matchIndexHtmlInAnyCache: () => + | Response + | undefined + | null + | Promise; matchOfflineHtmlInAnyCache: () => | Response | undefined @@ -83,11 +88,18 @@ interface ResolveOfflineDocumentFallbackOptions { export async function resolveOfflineDocumentFallback({ emergencyReason, matchPrecache, + matchIndexHtmlInAnyCache, matchOfflineHtmlInAnyCache, }: ResolveOfflineDocumentFallbackOptions): Promise { + // 1. Workbox precache(正常情況) const precachedIndex = await matchPrecache('index.html'); if (precachedIndex) return precachedIndex; + // 2. 任何快取中的 index.html(iOS eviction 後 html-cache 仍可能有備份) + const anyIndex = await matchIndexHtmlInAnyCache(); + if (anyIndex) return anyIndex; + + // 3. offline.html(precache 或 html-cache) const precachedOffline = await matchPrecache('offline.html'); if (precachedOffline) return precachedOffline; From ffe3de7942e9b14b26caab5cf79f785b7821890b Mon Sep 17 00:00:00 2001 From: haotool Date: Sat, 13 Jun 2026 11:09:00 +0800 Subject: [PATCH 2/9] =?UTF-8?q?fix(ratewise):=20=E7=A7=BB=E9=99=A4=20CDN?= =?UTF-8?q?=20If-None-Match=20=E9=81=BF=E5=85=8D=20jsDelivr=20preflight=20?= =?UTF-8?q?=E5=A4=B1=E6=95=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 停止對 jsDelivr/GitHub Raw 發送 If-None-Match 條件式請求 - 移除 304 分支,保留 ETag 寫入快取供未來 Worker proxy 路徑 - 同步更新 exchangeRateService 單元測試 測試:pnpm --filter @app/ratewise test -- exchangeRateService(32 passed) Co-authored-by: Cursor --- .changeset/fix-cdn-preflight-if-none-match.md | 5 ++ .../__tests__/exchangeRateService.test.ts | 80 ++++--------------- .../src/services/exchangeRateService.ts | 47 ++++------- 3 files changed, 36 insertions(+), 96 deletions(-) create mode 100644 .changeset/fix-cdn-preflight-if-none-match.md diff --git a/.changeset/fix-cdn-preflight-if-none-match.md b/.changeset/fix-cdn-preflight-if-none-match.md new file mode 100644 index 000000000..07228c86b --- /dev/null +++ b/.changeset/fix-cdn-preflight-if-none-match.md @@ -0,0 +1,5 @@ +--- +'@app/ratewise': patch +--- + +修正匯率 CDN 請求因 preflight 失敗而過度降級至 GitHub Raw 的問題,提升主 CDN 命中率與載入穩定性 diff --git a/apps/ratewise/src/services/__tests__/exchangeRateService.test.ts b/apps/ratewise/src/services/__tests__/exchangeRateService.test.ts index 241ed2d5d..864124e1a 100644 --- a/apps/ratewise/src/services/__tests__/exchangeRateService.test.ts +++ b/apps/ratewise/src/services/__tests__/exchangeRateService.test.ts @@ -612,8 +612,8 @@ describe('exchangeRateService', () => { expect(saved.etag).toBe('"v1-abc"'); }); - it('快取含 ETag 時,對 CDN_URLS[0](jsDelivr)發送 If-None-Match 標頭', async () => { - // 過期快取含 ETag(SWR 路徑:立即返回 stale,背景 fetch 帶 If-None-Match) + it('即使快取存有 ETag,也不對 CDN 發送 If-None-Match(jsDelivr preflight 不允許)', async () => { + // 過期快取含 ETag(SWR 路徑:立即返回 stale,背景 fetch 觸發 CDN 請求) const cachedData = { data: mockRateData, timestamp: Date.now() - 10 * 60 * 1000, @@ -636,37 +636,13 @@ describe('exchangeRateService', () => { // fetch 在 SWR 背景立即被呼叫(synchronous before return) expect(global.fetch).toHaveBeenCalled(); - const sentHeaders = capturedInit?.headers as Record | undefined; - expect(sentHeaders?.['If-None-Match']).toBe('"stored-etag-v1"'); - }); - - it('快取無 ETag 時,不發送 If-None-Match 標頭', async () => { - // 過期快取,但沒有 ETag 欄位 - const cachedData = { - data: mockRateData, - timestamp: Date.now() - 10 * 60 * 1000, - // 刻意省略 etag - }; - mockLocalStorage.setItem('exchangeRates', JSON.stringify(cachedData)); - - let capturedInit: RequestInit | undefined; - (global.fetch as any).mockImplementation((_: unknown, init?: RequestInit) => { - capturedInit = init; - return Promise.resolve({ - status: 200, - ok: true, - json: async () => mockRateData, - headers: { get: () => null }, - }); - }); - - await getExchangeRates(); - - const sentHeaders = capturedInit?.headers as Record | undefined; - expect(sentHeaders?.['If-None-Match']).toBeUndefined(); + const headerKeys = Object.keys((capturedInit?.headers ?? {}) as Record).map( + (k) => k.toLowerCase(), + ); + expect(headerKeys).not.toContain('if-none-match'); }); - it('CDN_URLS[1](GitHub Raw 備援)不發送 If-None-Match 標頭', async () => { + it('CDN_URLS[1](GitHub Raw 備援)即使快取有 ETag 也不發送 If-None-Match', async () => { // 過期快取含 ETag,CDN_URLS[0] 失敗 → 落到 CDN_URLS[1] const cachedData = { data: mockRateData, @@ -693,41 +669,13 @@ describe('exchangeRateService', () => { await getExchangeRates(); - // CDN_URLS[0](jsDelivr):應帶 If-None-Match - const firstHeaders = capturedInits[0]?.headers as Record | undefined; - expect(firstHeaders?.['If-None-Match']).toBe('"v1"'); - - // CDN_URLS[1](GitHub Raw):不帶 If-None-Match - const secondHeaders = capturedInits[1]?.headers as Record | undefined; - expect(secondHeaders?.['If-None-Match']).toBeUndefined(); - }); - - it('CDN 返回 304 Not Modified 時,背景更新記錄 ETag 命中日誌', async () => { - // 過期快取含 ETag - const cachedData = { - data: { ...mockRateData, updateTime: '304-test-timestamp' }, - timestamp: Date.now() - 10 * 60 * 1000, - etag: '"v1"', - }; - mockLocalStorage.setItem('exchangeRates', JSON.stringify(cachedData)); - - (global.fetch as any).mockResolvedValueOnce({ - status: 304, - ok: false, // 304 在 response.ok 為 false,但先被 status === 304 分支攔截 - headers: { get: () => null }, - }); - - // SWR:立即返回 stale 資料 - const result = await getExchangeRates(); - expect(result.updateTime).toBe('304-test-timestamp'); - - // 排空 microtask queue,讓背景 fetch 鏈完成(fetchWithTimeout → fetchFromCDN → .then) - for (let i = 0; i < 10; i++) await Promise.resolve(); - - expect(logger.logger.info).toHaveBeenCalledWith( - expect.stringContaining('ETag hit'), - expect.any(Object), - ); + // CDN_URLS[0](jsDelivr)與 CDN_URLS[1](GitHub Raw)皆不帶 If-None-Match + for (const init of capturedInits) { + const headerKeys = Object.keys((init.headers ?? {}) as Record).map((k) => + k.toLowerCase(), + ); + expect(headerKeys).not.toContain('if-none-match'); + } }); }); diff --git a/apps/ratewise/src/services/exchangeRateService.ts b/apps/ratewise/src/services/exchangeRateService.ts index 247479902..944d255d0 100644 --- a/apps/ratewise/src/services/exchangeRateService.ts +++ b/apps/ratewise/src/services/exchangeRateService.ts @@ -22,10 +22,13 @@ import buildTimeRates from '../config/generated/build-time-rates.json'; // 策略:jsDelivr CDN 為主要端點,GitHub Raw 為備援。 // jsDelivr CDN edge 快取 12 小時(s-maxage=43200),但 update-latest-rates.yml 在每次 // 推送 data 分支後自動呼叫 jsDelivr Purge API,使快取立即失效 → 實際新鮮度約 5 分鐘。 -// 優勢:全球 PoP 加速、無速率限制、支援 ETag 條件式請求(省頻寬)。 -// GitHub Raw 作為備援:無快取但每 IP 每小時限 60 次請求,無 CORS ETag 暴露。 +// 優勢:全球 PoP 加速、CDN 快取。 +// [2026-06-12] 不使用 ETag 條件式請求(If-None-Match 非 CORS safelisted, +// jsDelivr preflight 會拒絕,導致主 CDN 永遠失敗並降級);頻寬由瀏覽器 HTTP cache +// 與 5 分鐘 localStorage TTL 控制。 +// GitHub Raw 作為備援:無快取但每 IP 每小時限 60 次請求。 const CDN_URLS = [ - // jsDelivr CDN(主要)- Purge 後立即最新,支援 ETag,全球加速 + // jsDelivr CDN(主要)- Purge 後立即最新,全球加速 'https://cdn.jsdelivr.net/gh/haotool/app@data/public/rates/latest.json', // GitHub Raw(備援)- 無快取,速率限制 60 req/hr/IP 'https://raw.githubusercontent.com/haotool/app/data/public/rates/latest.json', @@ -48,7 +51,7 @@ const IS_LHCI_OFFLINE = import.meta.env['VITE_LHCI_OFFLINE'] === 'true'; interface CachedData { data: ExchangeRateData; timestamp: number; - etag?: string; // ETag 條件式請求(jsDelivr 支援 Access-Control-Expose-Headers: *) + etag?: string; // 保留回應 ETag 供未來 proxy/worker 路徑重新啟用條件式請求 } interface FetchResult { @@ -146,10 +149,11 @@ function saveToCache(data: ExchangeRateData, etag?: string): void { /** * 從 CDN 獲取匯率資料(帶 fallback) * - * ETag 條件式請求策略(僅適用 CDN_URLS[0] jsDelivr): - * - jsDelivr 回應包含 Access-Control-Expose-Headers: *,瀏覽器可讀取 ETag。 - * - 若快取中有 ETag,發送 If-None-Match header;304 時重置快取時間戳,省去 ~5 KB 下載。 - * - GitHub Raw(index > 0)不暴露 ETag,條件式請求不適用。 + * [2026-06-12] 不發送 If-None-Match:該 header 非 CORS safelisted,jsDelivr 的 + * Access-Control-Allow-Headers 不允許它,導致 preflight 被拒、主 CDN 永遠失敗並 + * 降級到 GitHub Raw(每 IP 每小時 60 次限制)。回應 ETag 仍會讀取並存入快取 + * (供未來改走自家 Worker proxy 時重新啟用條件請求),但不再用於後續請求。 + * 頻寬由瀏覽器 HTTP cache 與 5 分鐘 localStorage TTL 控制。 */ async function fetchFromCDN(signal?: AbortSignal): Promise { const errors: Error[] = []; @@ -162,32 +166,15 @@ async function fetchFromCDN(signal?: AbortSignal): Promise { try { logger.debug(`Trying CDN #${i + 1}/${CDN_URLS.length}`, { url: url.substring(0, 80) }); - // ETag 條件式請求(僅 CDN_URLS[0] jsDelivr 支援 CORS 暴露 ETag)。 - const cachedEntry = i === 0 ? getCachedEntry() : null; - const storedETag = cachedEntry?.etag; - const headers: Record = {}; - if (storedETag) { - headers['If-None-Match'] = storedETag; - } - - // [2025-12-10] 使用 fetchWithRequestId 自動注入 X-Correlation-ID header + // [2026-06-12] 不發送 If-None-Match:該 header 非 CORS safelisted, + // jsDelivr preflight 不允許,會使主 CDN 永遠失敗並降級到 GitHub Raw(60 req/hr)。 + // 頻寬由瀏覽器 HTTP cache 與 5 分鐘 localStorage TTL 控制。 const fetchInit: RequestInit = { ...(signal ? { signal } : {}), - ...(Object.keys(headers).length > 0 ? { headers } : {}), }; - const response = await fetchWithRequestId(url, fetchInit); - // 304 Not Modified:資料未變更,重置快取時間戳以延長 5 分鐘有效期。 - if (response.status === 304) { - if (cachedEntry) { - logger.info('ETag hit: 304 Not Modified, refreshing cache timestamp', { - etag: storedETag, - }); - return { data: cachedEntry.data, etag: cachedEntry.etag }; - } - // 304 但無快取(不應發生,安全起見繼續嘗試下一個來源) - throw new Error('304 Not Modified but no cached data available'); - } + // [2025-12-10] 使用 fetchWithRequestId 自動注入 X-Correlation-ID header + const response = await fetchWithRequestId(url, fetchInit); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); From d4edb0e5ba179442ffcffeb484a8c9a7580d6c7d Mon Sep 17 00:00:00 2001 From: haotool Date: Fri, 19 Jun 2026 12:41:17 +0800 Subject: [PATCH 3/9] =?UTF-8?q?fix(ratewise):=20=E7=A7=BB=E9=99=A4=20money?= =?UTF-8?q?box=20CDN=20ETag=20=E8=AB=8B=E6=B1=82=EF=BC=8C=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=20OpenData=20=E8=88=87=20SeoTech=20=E8=AA=AA=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fetchFromCDN 不再傳入 cachedEtag,不發送 If-None-Match 標頭 - 移除 304 Not Modified 分支,改由 HTTP cache 管理重複請求 - 更新測試:驗證即使快取有 ETag 也不附帶 If-None-Match - 更新 OpenData.tsx、SeoTech.tsx、seo-metadata.ts FAQ 說明文字 - 同步更新 open-data.md Markdown mirror - AGENTS.md 補充 UI/UX 治理知識點 測試:pnpm --filter @app/ratewise test -- moneyboxRateService(passed) Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy --- .changeset/fix-moneybox-cdn-etag.md | 5 ++++ apps/ratewise/public/open-data.md | 2 +- apps/ratewise/src/config/seo-metadata.ts | 2 +- apps/ratewise/src/pages/OpenData.tsx | 10 +++++--- apps/ratewise/src/pages/SeoTech.tsx | 6 ++--- .../__tests__/moneyboxRateService.test.ts | 23 +++++++++++------ .../src/services/moneyboxRateService.ts | 25 +++---------------- 7 files changed, 35 insertions(+), 38 deletions(-) create mode 100644 .changeset/fix-moneybox-cdn-etag.md diff --git a/.changeset/fix-moneybox-cdn-etag.md b/.changeset/fix-moneybox-cdn-etag.md new file mode 100644 index 000000000..99d7c7e4e --- /dev/null +++ b/.changeset/fix-moneybox-cdn-etag.md @@ -0,0 +1,5 @@ +--- +'@app/ratewise': patch +--- + +移除 moneybox CDN fetchFromCDN 的 ETag/304 條件式請求,統一以 HTTP cache 與 client 端 TTL 管理重複請求;更新 OpenData 與 SeoTech 頁面說明文字以反映新行為 diff --git a/apps/ratewise/public/open-data.md b/apps/ratewise/public/open-data.md index eb3f3055e..bff152f12 100644 --- a/apps/ratewise/public/open-data.md +++ b/apps/ratewise/public/open-data.md @@ -87,7 +87,7 @@ print(data['details']['JPY']['cash']['buy']) ### 2. jsDelivr CDN 和 GitHub Raw 端點有何差異? -jsDelivr CDN(建議):全球 PoP 節點加速,無明確請求上限,支援 ETag 條件式請求(瀏覽器可讀取 ETag,實作 If-None-Match 省流量)。GitHub Actions 每次推送 data 分支後自動呼叫 jsDelivr Purge API,CDN 快取立即失效,實際新鮮度約 5 分鐘。GitHub Raw(備援):無快取,每次請求直接取得最新版本,但每小時限 60 次請求,CORS 不暴露 ETag,瀏覽器端無法使用條件式請求。 +jsDelivr CDN(建議):全球 PoP 節點加速,無明確請求上限;GitHub Actions 每次推送 data 分支後自動呼叫 jsDelivr Purge API,CDN 快取立即失效,實際新鮮度約 5 分鐘。GitHub Raw(備援):無快取,每次請求直接取得最新版本,但每小時限 60 次請求。瀏覽器端建議以 HTTP cache 搭配 client 端 5 分鐘快取控制重複請求。 ### 3. 有備援端點嗎? diff --git a/apps/ratewise/src/config/seo-metadata.ts b/apps/ratewise/src/config/seo-metadata.ts index a50b9e5eb..06444e200 100644 --- a/apps/ratewise/src/config/seo-metadata.ts +++ b/apps/ratewise/src/config/seo-metadata.ts @@ -1221,7 +1221,7 @@ export const OPEN_DATA_PAGE_FAQ = [ }, { question: 'jsDelivr CDN 和 GitHub Raw 端點有何差異?', - answer: `jsDelivr CDN(建議):全球 PoP 節點加速,無明確請求上限,支援 ETag 條件式請求(瀏覽器可讀取 ETag,實作 If-None-Match 省流量)。GitHub Actions 每次推送 data 分支後自動呼叫 jsDelivr Purge API,CDN 快取立即失效,實際新鮮度約 5 分鐘。GitHub Raw(備援):無快取,每次請求直接取得最新版本,但每小時限 60 次請求,CORS 不暴露 ETag,瀏覽器端無法使用條件式請求。`, + answer: `jsDelivr CDN(建議):全球 PoP 節點加速,無明確請求上限;GitHub Actions 每次推送 data 分支後自動呼叫 jsDelivr Purge API,CDN 快取立即失效,實際新鮮度約 5 分鐘。GitHub Raw(備援):無快取,每次請求直接取得最新版本,但每小時限 60 次請求。瀏覽器端建議以 HTTP cache 搭配 client 端 5 分鐘快取控制重複請求。`, }, { question: '有備援端點嗎?', diff --git a/apps/ratewise/src/pages/OpenData.tsx b/apps/ratewise/src/pages/OpenData.tsx index 704ef9c2d..e3cbe3167 100644 --- a/apps/ratewise/src/pages/OpenData.tsx +++ b/apps/ratewise/src/pages/OpenData.tsx @@ -600,10 +600,12 @@ const OpenData = () => {
快取建議 - :CDN 端點支援{' '} - If-None-Match ETag - 條件式請求, 資料未變時回傳 304(零 body),可節省約 5 KB/次。建議 client 端快取 5 - 分鐘,避免無意義重複請求。 + :瀏覽器端建議直接依賴 HTTP cache 與 client 端 5 分鐘快取,避免無意義重複請求;跨域 + CDN 請求不應主動附加 + + If-None-Match + + 之類非 safelisted 標頭,以免觸發預檢失敗。
{/* ── 資料新鮮度與時間戳記說明 ── */} diff --git a/apps/ratewise/src/pages/SeoTech.tsx b/apps/ratewise/src/pages/SeoTech.tsx index dcd8b09c3..3185eb94b 100644 --- a/apps/ratewise/src/pages/SeoTech.tsx +++ b/apps/ratewise/src/pages/SeoTech.tsx @@ -183,9 +183,9 @@ const TECH_FEATURES = [ }, { icon: Search, - title: 'ETag 條件式請求', - desc: '匯率 API 支援 If-None-Match 標頭,相同資料回傳 304 Not Modified,省流量。', - tech: 'jsDelivr CDN', + title: '瀏覽器快取策略', + desc: '匯率資料以 HTTP cache 搭配 5 分鐘 client 快取控制重複請求,避免跨域預檢失敗。', + tech: 'jsDelivr CDN + localStorage TTL', }, { icon: Globe, diff --git a/apps/ratewise/src/services/__tests__/moneyboxRateService.test.ts b/apps/ratewise/src/services/__tests__/moneyboxRateService.test.ts index f07eea877..1e2268ea3 100644 --- a/apps/ratewise/src/services/__tests__/moneyboxRateService.test.ts +++ b/apps/ratewise/src/services/__tests__/moneyboxRateService.test.ts @@ -132,7 +132,7 @@ describe('fetchExchangeShopRate', () => { expect(result).toBeNull(); }); - it('returns cached rate when server responds with 304 Not Modified', async () => { + it('即使快取存有 ETag,也不對 CDN 發送 If-None-Match', async () => { // First call: warm the cache vi.mocked(fetch).mockResolvedValueOnce({ ok: true, @@ -147,18 +147,25 @@ describe('fetchExchangeShopRate', () => { const cached = JSON.parse(localStorage.getItem(key)!); localStorage.setItem(key, JSON.stringify({ ...cached, timestamp: 0 })); - // Second call: CDN returns 304 - vi.mocked(fetch).mockResolvedValueOnce({ - ok: true, - status: 304, - json: () => Promise.resolve(null), - headers: { get: () => null }, - } as unknown as Response); + let capturedInit: RequestInit | undefined; + vi.mocked(fetch).mockImplementationOnce((_, init?: RequestInit) => { + capturedInit = init; + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve(MOCK_MONEYBOX_JSON), + headers: { get: () => null }, + } as unknown as Response); + }); const result = await fetchExchangeShopRate('KRW'); expect(result).not.toBeNull(); expect(result!.sell).toBe(44.85); expect(result!.isFallback).toBe(false); + const headerKeys = Object.keys((capturedInit?.headers ?? {}) as Record).map( + (key) => key.toLowerCase(), + ); + expect(headerKeys).not.toContain('if-none-match'); }); it('falls back to secondary CDN URL when primary fails', async () => { diff --git a/apps/ratewise/src/services/moneyboxRateService.ts b/apps/ratewise/src/services/moneyboxRateService.ts index 4a4593ce9..94b16a6ef 100644 --- a/apps/ratewise/src/services/moneyboxRateService.ts +++ b/apps/ratewise/src/services/moneyboxRateService.ts @@ -166,24 +166,12 @@ async function fetchWithTimeout(url: string, init?: RequestInit): Promise { +async function fetchFromCDN(config: ExchangeShopConfig): Promise<{ raw: unknown; etag?: string }> { const urls = [config.cdnUrl, config.cdnUrlFallback]; for (const url of urls) { try { - const headers: Record = {}; - if (url === config.cdnUrl && cachedEtag) { - headers['If-None-Match'] = cachedEtag; - } - - const res = await fetchWithTimeout(url, { headers }); - - if (res.status === 304) { - return { raw: null, notModified: true }; - } + const res = await fetchWithTimeout(url); if (!res.ok) { logger.warn(`Exchange shop CDN returned ${res.status}`, { url }); @@ -192,7 +180,7 @@ async function fetchFromCDN( const raw: unknown = await res.json(); const etag = res.headers.get('etag') ?? undefined; - return { raw, etag, notModified: false }; + return { raw, etag }; } catch (e) { logger.warn(`Exchange shop CDN fetch failed`, { url, error: e }); } @@ -216,12 +204,7 @@ export async function fetchExchangeShopRate( } try { - const { raw, etag, notModified } = await fetchFromCDN(config, cached?.etag); - - if (notModified && cached) { - writeCache(currency, { ...cached, timestamp: Date.now() }); - return cached.rate; - } + const { raw, etag } = await fetchFromCDN(config); const rate = parseExchangeShopRate(currency, config, raw); From c14bb5fcb5cb1c46cebef1455b8ecae4d9de8006 Mon Sep 17 00:00:00 2001 From: haotool Date: Fri, 26 Jun 2026 02:47:59 +0800 Subject: [PATCH 4/9] =?UTF-8?q?chore(ratewise):=20=E6=95=B4=E5=90=88=20P0?= =?UTF-8?q?=20PWA=20=E8=88=87=20ETag=20changeset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 合併三個 cherry-pick changeset 為單一 patch 條目 - 更新 002 紀錄 P0 收斂策略與累計分數 測試:focused sw/exchangeRate/moneybox 53 passed;pnpm build:ratewise Co-authored-by: Cursor --- .changeset/fix-cdn-preflight-if-none-match.md | 5 ----- .changeset/fix-moneybox-cdn-etag.md | 5 ----- .changeset/fix-ratewise-pwa-etag-convergence.md | 5 +++++ .changeset/fix-ratewise-pwa-ios-eviction.md | 5 ----- docs/dev/002_development_reward_penalty_log.md | 7 ++++++- 5 files changed, 11 insertions(+), 16 deletions(-) delete mode 100644 .changeset/fix-cdn-preflight-if-none-match.md delete mode 100644 .changeset/fix-moneybox-cdn-etag.md create mode 100644 .changeset/fix-ratewise-pwa-etag-convergence.md delete mode 100644 .changeset/fix-ratewise-pwa-ios-eviction.md diff --git a/.changeset/fix-cdn-preflight-if-none-match.md b/.changeset/fix-cdn-preflight-if-none-match.md deleted file mode 100644 index 07228c86b..000000000 --- a/.changeset/fix-cdn-preflight-if-none-match.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@app/ratewise': patch ---- - -修正匯率 CDN 請求因 preflight 失敗而過度降級至 GitHub Raw 的問題,提升主 CDN 命中率與載入穩定性 diff --git a/.changeset/fix-moneybox-cdn-etag.md b/.changeset/fix-moneybox-cdn-etag.md deleted file mode 100644 index 99d7c7e4e..000000000 --- a/.changeset/fix-moneybox-cdn-etag.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@app/ratewise': patch ---- - -移除 moneybox CDN fetchFromCDN 的 ETag/304 條件式請求,統一以 HTTP cache 與 client 端 TTL 管理重複請求;更新 OpenData 與 SeoTech 頁面說明文字以反映新行為 diff --git a/.changeset/fix-ratewise-pwa-etag-convergence.md b/.changeset/fix-ratewise-pwa-etag-convergence.md new file mode 100644 index 000000000..2840ccc8f --- /dev/null +++ b/.changeset/fix-ratewise-pwa-etag-convergence.md @@ -0,0 +1,5 @@ +--- +'@app/ratewise': patch +--- + +離線導覽更快、匯率不再因 ETag 304 卡住:iOS PWA 改用 precache-first 冷啟動,並移除 jsDelivr 跨域 If-None-Match 條件式請求 diff --git a/.changeset/fix-ratewise-pwa-ios-eviction.md b/.changeset/fix-ratewise-pwa-ios-eviction.md deleted file mode 100644 index 4322d4802..000000000 --- a/.changeset/fix-ratewise-pwa-ios-eviction.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@app/ratewise': patch ---- - -修復 iOS PWA precache 驅逐後 3s timeout 導致在線用戶看到 offline.html 的假離線問題,改用 precache-first 冷啟動策略 diff --git a/docs/dev/002_development_reward_penalty_log.md b/docs/dev/002_development_reward_penalty_log.md index 2b6144989..3c0149962 100644 --- a/docs/dev/002_development_reward_penalty_log.md +++ b/docs/dev/002_development_reward_penalty_log.md @@ -2,7 +2,7 @@ > 版本:outline-v2-ultra > 原則:每筆只保留日期、ID、原因、解法。 -> 本次分數變化:+1(reward 1、penalty 0)|累計總分:前次總分 +59 +> 本次分數變化:+1(reward 1、penalty 0)|累計總分:前次總分 +60 ## 新增模板(4 行) @@ -13,6 +13,11 @@ ## 條目(新→舊) +- 日期:2026-06-26 +- ID:reward-ratewise-pwa-etag-p0-convergence +- 原因:PR411 混雜 split-meow 與 AppLayout 變更,P0 修復(precache-first、If-None-Match preflight、moneybox ETag)無法安全合併 +- 解法:自 origin/main 開 fix/ratewise-pwa-etag 分支 cherry-pick 三 commit,整合 patch changeset 與 focused 測試後獨立 PR + - 日期:2026-06-26 - ID:reward-ci-e2e-full-shard-timeout-45 - 原因:main push E2E Full 2-way shard 仍設 20 分鐘逾時,完整 desktop+mobile 套件在冷快取下被取消,merge-reports 連帶失敗 From f9921d724a37a68d4740b0446a22ae5de291d0b5 Mon Sep 17 00:00:00 2001 From: haotool Date: Fri, 26 Jun 2026 02:50:25 +0800 Subject: [PATCH 5/9] =?UTF-8?q?test(ratewise):=20=E5=B0=8D=E9=BD=8A=20prec?= =?UTF-8?q?ache-first=20=E5=B0=8E=E8=A6=BD=E7=AD=96=E7=95=A5=E5=AE=88?= =?UTF-8?q?=E9=96=80=E6=B8=AC=E8=A9=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 3s timeout 舊斷言,改驗證 matchPrecache index.html - 防回歸 NAVIGATION_NETWORK_TIMEOUT_MS 重新引入 測試:sw.test.ts + pwa-offline.test.ts 65 passed Co-authored-by: Cursor --- apps/ratewise/src/__tests__/sw.test.ts | 14 ++++++-------- apps/ratewise/src/pwa-offline.test.ts | 9 ++++----- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/apps/ratewise/src/__tests__/sw.test.ts b/apps/ratewise/src/__tests__/sw.test.ts index 4b80b5591..50c167314 100644 --- a/apps/ratewise/src/__tests__/sw.test.ts +++ b/apps/ratewise/src/__tests__/sw.test.ts @@ -158,17 +158,15 @@ describe('Service Worker Cache Strategies', () => { const swPath = path.resolve(__dirname, '../sw.ts'); const sourceCode = await fs.readFile(swPath, 'utf-8'); - // 已 install 過的 PWA 與已 visited 的瀏覽器:cache hit 立即返回,背景 revalidate。 - // cache miss 則保留 3 秒 bounded fallback,避免慢網路下白屏。 + // 已 install 過的 PWA:暖快取 SWR;冷快取 precache-first,避免 3s timeout 假離線。 expect(sourceCode).toContain('handleNavigationRequest'); expect(sourceCode).toContain('new NavigationRoute(handleNavigationRequest)'); expect(sourceCode).toContain('event.waitUntil('); expect(sourceCode).toContain('fetchAndCacheNavigation(request, cache)'); - expect(sourceCode).toContain( - 'event.waitUntil(networkResponse.then(() => undefined).catch(() => undefined))', - ); + expect(sourceCode).toContain("matchPrecache('index.html')"); // 防回歸:禁止重新引入 NetworkFirst navigation(cold-start 白屏根因之一)。 expect(sourceCode).not.toContain('new NetworkFirst('); + expect(sourceCode).not.toContain('NAVIGATION_NETWORK_TIMEOUT_MS'); }); it('should have correct historical rates cache configuration', () => { @@ -237,14 +235,14 @@ describe('Service Worker Cache Strategies', () => { const swPath = path.resolve(__dirname, '../sw.ts'); const sourceCode = await fs.readFile(swPath, 'utf-8'); - // bounded SWR-style navigation → resolveOfflineDocumentFallback helper(含三層 fallback + emergency HTML)。 + // precache-first navigation → resolveOfflineDocumentFallback helper(含三層 fallback + emergency HTML)。 expect(sourceCode).toContain('new NavigationRoute('); expect(sourceCode).toContain('resolveOfflineDocumentFallback'); expect(sourceCode).toContain("emergencyReason: 'emergency-navigation-fallback'"); - expect(sourceCode).toContain('const NAVIGATION_NETWORK_TIMEOUT_MS = 3000'); - expect(sourceCode).toContain('Promise.race([networkResponse, timeoutFallback])'); + expect(sourceCode).toContain("matchPrecache('index.html')"); // 防回歸:navigation 不可重新引入 NetworkFirst(cold-start 白屏根因之一)。 expect(sourceCode).not.toContain('new NetworkFirst('); + expect(sourceCode).not.toContain('NAVIGATION_NETWORK_TIMEOUT_MS'); }); it('should clear stale navigation HTML runtime cache when a new worker activates', async () => { diff --git a/apps/ratewise/src/pwa-offline.test.ts b/apps/ratewise/src/pwa-offline.test.ts index d55ff6e58..de5b69d2d 100644 --- a/apps/ratewise/src/pwa-offline.test.ts +++ b/apps/ratewise/src/pwa-offline.test.ts @@ -117,20 +117,19 @@ describe('PWA 離線功能測試', () => { expect(swContent).toContain("const HTML_CACHE_NAME = 'html-cache'"); expect(swContent).toContain('event.waitUntil('); expect(swContent).toContain('fetchAndCacheNavigation(request, cache)'); - expect(swContent).toContain( - 'event.waitUntil(networkResponse.then(() => undefined).catch(() => undefined))', - ); + expect(swContent).toContain("matchPrecache('index.html')"); // 防回歸:禁止把 NetworkFirst 重新引入 navigation 路徑(cold-start 白屏根因之一)。 expect(swContent).not.toContain('new NetworkFirst('); + // 防回歸:禁止 3s timeout 在 iOS eviction 後誤服 offline.html 給在線用戶。 + expect(swContent).not.toContain('NAVIGATION_NETWORK_TIMEOUT_MS'); }); it('should clear old navigation HTML cache on activate and keep a bounded cache-miss fallback', () => { const swContent = readFileSync(resolve(ROOT_PATH, 'src/sw.ts'), 'utf-8'); expect(swContent).toContain('clearNavigationHtmlCacheOnActivate'); expect(swContent).toContain('caches.delete(HTML_CACHE_NAME)'); - expect(swContent).toContain('const NAVIGATION_NETWORK_TIMEOUT_MS = 3000'); - expect(swContent).toContain('Promise.race([networkResponse, timeoutFallback])'); expect(swContent).toContain('resolveNavigationFallback'); + expect(swContent).not.toContain('NAVIGATION_NETWORK_TIMEOUT_MS'); }); it('should have offline-first strategy in setCatchHandler', () => { From 31d5fc741ddba3f0030c6dc407fdcd22745af96e Mon Sep 17 00:00:00 2001 From: haotool Date: Fri, 26 Jun 2026 08:19:03 +0800 Subject: [PATCH 6/9] =?UTF-8?q?docs(agents):=20=E5=90=8C=E6=AD=A5=20contin?= =?UTF-8?q?ual-learning=20=E8=A8=98=E6=86=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 寫入 Learned Preferences/Facts(PR 合併批准、銀行賣出價定位、SSOT 路徑、Zen 主色) - 002 新增 neutral-agents-continual-learning-sync 條目 測試:未執行(僅文件變更) Co-authored-by: Cursor --- AGENTS.md | 6 ++++++ docs/dev/002_development_reward_penalty_log.md | 7 ++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 35fac977e..e2a692f1b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -806,6 +806,8 @@ Agent 在結案或提交時,應能提供下列證據(依任務適用性) - UI/UX 或基礎設施深度審查時,偏好並行多 agent 分面向分析並產出可執行 SPEC。 - 調整 Cloudflare 邊緣設定(Worker、Cache Rules、Transform Rules)前,必須先做本地安全備份。 - 生產環境有真實使用者;release 與 edge 變更需依 phased 順序(app → Worker → purge → live 驗證)且足夠嚴謹。 +- 合併 PR 時,較大型或功能型 PR 必須等待使用者明確批准,不可自動合併;可先合併無衝突的小型 PR。 +- RateWise 產品與 SEO 文案定位強調銀行賣出價(實際換匯/刷卡),而非中間價;目標成為台灣最精準匯率工具。 ## Learned Workspace Facts @@ -814,6 +816,10 @@ Agent 在結案或提交時,應能提供下列證據(依任務適用性) - `wrangler.jsonc` 的 observability/logs 取樣不應長期維持 100%;044 目標 ≤0.1。 - 本地 `wrangler deploy` 後若 `curl` 的 `x-security-policy-version` 與預期不符,可能是 edge 仍回舊版,需 purge 或 cache-busting 後再驗證。 - RateWise 2026 UX 產品規格 SSOT:`docs/superpowers/specs/2026-06-12-ratewise-2026-product-ux-spec.md`。 +- RateWise UI/UX 審查框架 SSOT:`docs/prompt/UIUX.md`。 +- RateWise 行動 PWA QA 稽核 SPEC:`docs/superpowers/specs/2026-06-26-ratewise-mobile-pwa-qa-audit-spec.md`。 +- SEO 治理 Master SSOT:`docs/SEO_MASTER_SSOT.md`。 +- Zen 主題主色已收斂為 violet-600(`apps/ratewise/src/index.css` 的 `--color-primary`)。 --- diff --git a/docs/dev/002_development_reward_penalty_log.md b/docs/dev/002_development_reward_penalty_log.md index 3c0149962..3f3c4c54c 100644 --- a/docs/dev/002_development_reward_penalty_log.md +++ b/docs/dev/002_development_reward_penalty_log.md @@ -2,7 +2,7 @@ > 版本:outline-v2-ultra > 原則:每筆只保留日期、ID、原因、解法。 -> 本次分數變化:+1(reward 1、penalty 0)|累計總分:前次總分 +60 +> 本次分數變化:0(neutral)|累計總分:+60 ## 新增模板(4 行) @@ -13,6 +13,11 @@ ## 條目(新→舊) +- 日期:2026-06-26 +- ID:neutral-agents-continual-learning-sync +- 原因:continual-learning 4c1b7c25 更新 AGENTS.md Learned Preferences/Facts 未入版控 +- 解法:新增 neutral 002 條目並以 docs(agents) commit 推送 fix/ratewise-pwa-etag + - 日期:2026-06-26 - ID:reward-ratewise-pwa-etag-p0-convergence - 原因:PR411 混雜 split-meow 與 AppLayout 變更,P0 修復(precache-first、If-None-Match preflight、moneybox ETag)無法安全合併 From 4822b84401e86db4484a89c518433b97886405cd Mon Sep 17 00:00:00 2001 From: haotool Date: Fri, 26 Jun 2026 08:22:54 +0800 Subject: [PATCH 7/9] =?UTF-8?q?fix(ratewise):=20CDN=20fetch=20=E5=8A=A0=20?= =?UTF-8?q?cache=20no-cache=20=E5=BC=B7=E5=88=B6=E9=87=8D=E6=96=B0?= =?UTF-8?q?=E9=A9=97=E8=AD=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Codex P1:localStorage TTL 到期後避免 HTTP cache 回傳過期 body - 補 exchangeRateService 測試斷言 cache: 'no-cache' - 更新 changeset 描述納入 CDN 重新驗證 測試:pnpm --filter @app/ratewise test -- exchangeRateService(32 passed) Co-authored-by: Cursor --- .changeset/fix-ratewise-pwa-etag-convergence.md | 2 +- .../src/services/__tests__/exchangeRateService.test.ts | 4 +++- apps/ratewise/src/services/exchangeRateService.ts | 9 +++++---- docs/dev/002_development_reward_penalty_log.md | 7 ++++++- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/.changeset/fix-ratewise-pwa-etag-convergence.md b/.changeset/fix-ratewise-pwa-etag-convergence.md index 2840ccc8f..cddbea067 100644 --- a/.changeset/fix-ratewise-pwa-etag-convergence.md +++ b/.changeset/fix-ratewise-pwa-etag-convergence.md @@ -2,4 +2,4 @@ '@app/ratewise': patch --- -離線導覽更快、匯率不再因 ETag 304 卡住:iOS PWA 改用 precache-first 冷啟動,並移除 jsDelivr 跨域 If-None-Match 條件式請求 +離線導覽更快、匯率不再因 ETag 304 卡住:iOS PWA 改用 precache-first 冷啟動,移除 jsDelivr 跨域 If-None-Match 條件式請求,並以 cache no-cache 強制 CDN 重新驗證 diff --git a/apps/ratewise/src/services/__tests__/exchangeRateService.test.ts b/apps/ratewise/src/services/__tests__/exchangeRateService.test.ts index 864124e1a..9655e1b09 100644 --- a/apps/ratewise/src/services/__tests__/exchangeRateService.test.ts +++ b/apps/ratewise/src/services/__tests__/exchangeRateService.test.ts @@ -636,6 +636,7 @@ describe('exchangeRateService', () => { // fetch 在 SWR 背景立即被呼叫(synchronous before return) expect(global.fetch).toHaveBeenCalled(); + expect(capturedInit?.cache).toBe('no-cache'); const headerKeys = Object.keys((capturedInit?.headers ?? {}) as Record).map( (k) => k.toLowerCase(), ); @@ -669,8 +670,9 @@ describe('exchangeRateService', () => { await getExchangeRates(); - // CDN_URLS[0](jsDelivr)與 CDN_URLS[1](GitHub Raw)皆不帶 If-None-Match + // CDN_URLS[0](jsDelivr)與 CDN_URLS[1](GitHub Raw)皆不帶 If-None-Match,且強制重新驗證 for (const init of capturedInits) { + expect(init.cache).toBe('no-cache'); const headerKeys = Object.keys((init.headers ?? {}) as Record).map((k) => k.toLowerCase(), ); diff --git a/apps/ratewise/src/services/exchangeRateService.ts b/apps/ratewise/src/services/exchangeRateService.ts index 944d255d0..9ab5933a8 100644 --- a/apps/ratewise/src/services/exchangeRateService.ts +++ b/apps/ratewise/src/services/exchangeRateService.ts @@ -24,8 +24,8 @@ import buildTimeRates from '../config/generated/build-time-rates.json'; // 推送 data 分支後自動呼叫 jsDelivr Purge API,使快取立即失效 → 實際新鮮度約 5 分鐘。 // 優勢:全球 PoP 加速、CDN 快取。 // [2026-06-12] 不使用 ETag 條件式請求(If-None-Match 非 CORS safelisted, -// jsDelivr preflight 會拒絕,導致主 CDN 永遠失敗並降級);頻寬由瀏覽器 HTTP cache -// 與 5 分鐘 localStorage TTL 控制。 +// jsDelivr preflight 會拒絕,導致主 CDN 永遠失敗並降級);fetch 使用 cache: 'no-cache' +// 強制 CDN 重新驗證,並以 5 分鐘 localStorage TTL 控制應用層新鮮度。 // GitHub Raw 作為備援:無快取但每 IP 每小時限 60 次請求。 const CDN_URLS = [ // jsDelivr CDN(主要)- Purge 後立即最新,全球加速 @@ -153,7 +153,7 @@ function saveToCache(data: ExchangeRateData, etag?: string): void { * Access-Control-Allow-Headers 不允許它,導致 preflight 被拒、主 CDN 永遠失敗並 * 降級到 GitHub Raw(每 IP 每小時 60 次限制)。回應 ETag 仍會讀取並存入快取 * (供未來改走自家 Worker proxy 時重新啟用條件請求),但不再用於後續請求。 - * 頻寬由瀏覽器 HTTP cache 與 5 分鐘 localStorage TTL 控制。 + * TTL 到期後以 cache: 'no-cache' 強制 CDN 重新驗證,避免 HTTP cache 回傳過期 body。 */ async function fetchFromCDN(signal?: AbortSignal): Promise { const errors: Error[] = []; @@ -168,8 +168,9 @@ async function fetchFromCDN(signal?: AbortSignal): Promise { // [2026-06-12] 不發送 If-None-Match:該 header 非 CORS safelisted, // jsDelivr preflight 不允許,會使主 CDN 永遠失敗並降級到 GitHub Raw(60 req/hr)。 - // 頻寬由瀏覽器 HTTP cache 與 5 分鐘 localStorage TTL 控制。 + // TTL 到期後以 cache: 'no-cache' 強制 CDN 重新驗證。 const fetchInit: RequestInit = { + cache: 'no-cache', ...(signal ? { signal } : {}), }; diff --git a/docs/dev/002_development_reward_penalty_log.md b/docs/dev/002_development_reward_penalty_log.md index 3f3c4c54c..d3785d386 100644 --- a/docs/dev/002_development_reward_penalty_log.md +++ b/docs/dev/002_development_reward_penalty_log.md @@ -2,7 +2,7 @@ > 版本:outline-v2-ultra > 原則:每筆只保留日期、ID、原因、解法。 -> 本次分數變化:0(neutral)|累計總分:+60 +> 本次分數變化:+1(reward 1、penalty 0)|累計總分:+61 ## 新增模板(4 行) @@ -13,6 +13,11 @@ ## 條目(新→舊) +- 日期:2026-06-26 +- ID:reward-ratewise-cdn-no-cache-revalidation +- 原因:Codex P1 指出移除 If-None-Match 後 localStorage TTL 到期仍可能被瀏覽器 HTTP cache 餵舊匯率 body +- 解法:fetchFromCDN 加 cache: 'no-cache' 強制 CDN 重新驗證,並補 exchangeRateService 測試斷言 + - 日期:2026-06-26 - ID:neutral-agents-continual-learning-sync - 原因:continual-learning 4c1b7c25 更新 AGENTS.md Learned Preferences/Facts 未入版控 From d4a3f877814445986ef69246c33c0db573833437 Mon Sep 17 00:00:00 2001 From: haotool Date: Fri, 26 Jun 2026 08:31:33 +0800 Subject: [PATCH 8/9] =?UTF-8?q?fix(ratewise):=20MoneyBox=20CDN=20fetch=20?= =?UTF-8?q?=E5=8A=A0=20cache=20no-cache?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Codex P2:換錢所 TTL 到期後避免 HTTP cache 回傳過期報價 - fetchFromCDN 加 cache: 'no-cache' 並補 moneyboxRateService 測試斷言 - 更新 changeset 與 002 獎懲記錄 測試:pnpm --filter @app/ratewise test -- moneyboxRateService/exchangeRateService(48 passed) Co-authored-by: Cursor --- .changeset/fix-ratewise-pwa-etag-convergence.md | 2 +- .../src/services/__tests__/moneyboxRateService.test.ts | 1 + apps/ratewise/src/services/moneyboxRateService.ts | 2 +- docs/dev/002_development_reward_penalty_log.md | 7 ++++++- 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.changeset/fix-ratewise-pwa-etag-convergence.md b/.changeset/fix-ratewise-pwa-etag-convergence.md index cddbea067..976e4de76 100644 --- a/.changeset/fix-ratewise-pwa-etag-convergence.md +++ b/.changeset/fix-ratewise-pwa-etag-convergence.md @@ -2,4 +2,4 @@ '@app/ratewise': patch --- -離線導覽更快、匯率不再因 ETag 304 卡住:iOS PWA 改用 precache-first 冷啟動,移除 jsDelivr 跨域 If-None-Match 條件式請求,並以 cache no-cache 強制 CDN 重新驗證 +離線導覽更快、匯率不再因 ETag 304 卡住:iOS PWA 改用 precache-first 冷啟動,移除 jsDelivr 跨域 If-None-Match 條件式請求,匯率與換錢所 CDN 皆以 cache no-cache 強制重新驗證 diff --git a/apps/ratewise/src/services/__tests__/moneyboxRateService.test.ts b/apps/ratewise/src/services/__tests__/moneyboxRateService.test.ts index 1e2268ea3..fc4dffa3d 100644 --- a/apps/ratewise/src/services/__tests__/moneyboxRateService.test.ts +++ b/apps/ratewise/src/services/__tests__/moneyboxRateService.test.ts @@ -162,6 +162,7 @@ describe('fetchExchangeShopRate', () => { expect(result).not.toBeNull(); expect(result!.sell).toBe(44.85); expect(result!.isFallback).toBe(false); + expect(capturedInit?.cache).toBe('no-cache'); const headerKeys = Object.keys((capturedInit?.headers ?? {}) as Record).map( (key) => key.toLowerCase(), ); diff --git a/apps/ratewise/src/services/moneyboxRateService.ts b/apps/ratewise/src/services/moneyboxRateService.ts index 94b16a6ef..38cdcbdf2 100644 --- a/apps/ratewise/src/services/moneyboxRateService.ts +++ b/apps/ratewise/src/services/moneyboxRateService.ts @@ -171,7 +171,7 @@ async function fetchFromCDN(config: ExchangeShopConfig): Promise<{ raw: unknown; for (const url of urls) { try { - const res = await fetchWithTimeout(url); + const res = await fetchWithTimeout(url, { cache: 'no-cache' }); if (!res.ok) { logger.warn(`Exchange shop CDN returned ${res.status}`, { url }); diff --git a/docs/dev/002_development_reward_penalty_log.md b/docs/dev/002_development_reward_penalty_log.md index d3785d386..16ccf980a 100644 --- a/docs/dev/002_development_reward_penalty_log.md +++ b/docs/dev/002_development_reward_penalty_log.md @@ -2,7 +2,7 @@ > 版本:outline-v2-ultra > 原則:每筆只保留日期、ID、原因、解法。 -> 本次分數變化:+1(reward 1、penalty 0)|累計總分:+61 +> 本次分數變化:+1(reward 1、penalty 0)|累計總分:+62 ## 新增模板(4 行) @@ -13,6 +13,11 @@ ## 條目(新→舊) +- 日期:2026-06-26 +- ID:reward-ratewise-moneybox-no-cache-revalidation +- 原因:Codex P2 指出 MoneyBox fetchFromCDN 移除 If-None-Match 後仍用預設 cache mode,TTL 到期可能被 HTTP cache 餵舊換錢所報價 +- 解法:fetchWithTimeout 加 cache: 'no-cache' 並補 moneyboxRateService 測試斷言 + - 日期:2026-06-26 - ID:reward-ratewise-cdn-no-cache-revalidation - 原因:Codex P1 指出移除 If-None-Match 後 localStorage TTL 到期仍可能被瀏覽器 HTTP cache 餵舊匯率 body From f20d84fd6c4122dabbdf343b5d1f4e33091b9d92 Mon Sep 17 00:00:00 2001 From: haotool Date: Fri, 26 Jun 2026 08:52:09 +0800 Subject: [PATCH 9/9] =?UTF-8?q?fix(docs):=20=E6=9B=B4=E6=AD=A3=20PR425=200?= =?UTF-8?q?02=20=E5=88=86=E6=95=B8=20header=20=E7=82=BA=20+3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - header 由 +1 更正為 +3(reward 3、penalty 0),累計 +62 不變 - 新增 neutral-002-score-header-correction-pr425 條目 測試:未執行(僅 002 稽核欄位修正) Co-authored-by: Cursor --- docs/dev/002_development_reward_penalty_log.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/dev/002_development_reward_penalty_log.md b/docs/dev/002_development_reward_penalty_log.md index 16ccf980a..80001cd9b 100644 --- a/docs/dev/002_development_reward_penalty_log.md +++ b/docs/dev/002_development_reward_penalty_log.md @@ -2,7 +2,7 @@ > 版本:outline-v2-ultra > 原則:每筆只保留日期、ID、原因、解法。 -> 本次分數變化:+1(reward 1、penalty 0)|累計總分:+62 +> 本次分數變化:+3(reward 3、penalty 0)|累計總分:+62 ## 新增模板(4 行) @@ -13,6 +13,11 @@ ## 條目(新→舊) +- 日期:2026-06-26 +- ID:neutral-002-score-header-correction-pr425 +- 原因:Codex P2 指出本批 3 reward + 1 neutral 的 header 誤寫 +1 +- 解法:header 更正為 +3(reward 3、penalty 0),累計 +62 維持不變 + - 日期:2026-06-26 - ID:reward-ratewise-moneybox-no-cache-revalidation - 原因:Codex P2 指出 MoneyBox fetchFromCDN 移除 If-None-Match 後仍用預設 cache mode,TTL 到期可能被 HTTP cache 餵舊換錢所報價