diff --git a/.changeset/fix-ratewise-pwa-etag-convergence.md b/.changeset/fix-ratewise-pwa-etag-convergence.md
new file mode 100644
index 000000000..976e4de76
--- /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 條件式請求,匯率與換錢所 CDN 皆以 cache no-cache 強制重新驗證
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/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/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/__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/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/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', () => {
diff --git a/apps/ratewise/src/services/__tests__/exchangeRateService.test.ts b/apps/ratewise/src/services/__tests__/exchangeRateService.test.ts
index 241ed2d5d..9655e1b09 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,14 @@ 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();
+ expect(capturedInit?.cache).toBe('no-cache');
+ 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 +670,14 @@ 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) {
+ expect(init.cache).toBe('no-cache');
+ 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/__tests__/moneyboxRateService.test.ts b/apps/ratewise/src/services/__tests__/moneyboxRateService.test.ts
index f07eea877..fc4dffa3d 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,26 @@ 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);
+ expect(capturedInit?.cache).toBe('no-cache');
+ 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/exchangeRateService.ts b/apps/ratewise/src/services/exchangeRateService.ts
index 247479902..9ab5933a8 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 永遠失敗並降級);fetch 使用 cache: 'no-cache'
+// 強制 CDN 重新驗證,並以 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 時重新啟用條件請求),但不再用於後續請求。
+ * TTL 到期後以 cache: 'no-cache' 強制 CDN 重新驗證,避免 HTTP cache 回傳過期 body。
*/
async function fetchFromCDN(signal?: AbortSignal): Promise {
const errors: Error[] = [];
@@ -162,32 +166,16 @@ 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)。
+ // TTL 到期後以 cache: 'no-cache' 強制 CDN 重新驗證。
const fetchInit: RequestInit = {
+ cache: 'no-cache',
...(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}`);
diff --git a/apps/ratewise/src/services/moneyboxRateService.ts b/apps/ratewise/src/services/moneyboxRateService.ts
index 4a4593ce9..38cdcbdf2 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, { cache: 'no-cache' });
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);
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;
diff --git a/docs/dev/002_development_reward_penalty_log.md b/docs/dev/002_development_reward_penalty_log.md
index 2b6144989..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)|累計總分:前次總分 +59
+> 本次分數變化:+3(reward 3、penalty 0)|累計總分:+62
## 新增模板(4 行)
@@ -13,6 +13,31 @@
## 條目(新→舊)
+- 日期: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 餵舊換錢所報價
+- 解法: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
+- 解法:fetchFromCDN 加 cache: 'no-cache' 強制 CDN 重新驗證,並補 exchangeRateService 測試斷言
+
+- 日期: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)無法安全合併
+- 解法:自 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 連帶失敗