-
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathexchangeRateService.ts
More file actions
393 lines (347 loc) · 13.4 KB
/
Copy pathexchangeRateService.ts
File metadata and controls
393 lines (347 loc) · 13.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
/**
* 匯率資料服務
* 從台灣銀行 API 獲取即時匯率
* [context7:googlechrome/lighthouse-ci:2025-10-20T04:10:04+08:00]
* [2025-12-10] 整合 Request ID 追蹤
* [2026-01-11] Safari PWA 離線優化:雙重儲存策略 (localStorage + IndexedDB)
*/
import { logger } from '../utils/logger';
import { fetchWithRequestId } from '../utils/requestId';
import { STORAGE_KEYS } from '../features/ratewise/storage-keys';
import {
saveExchangeRatesToIDB,
getExchangeRatesFromIDBWithStaleness,
type ExchangeRateData,
} from '../utils/offlineStorage';
import buildTimeRates from '../config/generated/build-time-rates.json';
// ExchangeRateData 類型從 offlineStorage.ts 導入,確保類型一致性
// CDN URLs
// 策略:jsDelivr CDN 為主要端點,GitHub Raw 為備援。
// jsDelivr CDN edge 快取 12 小時(s-maxage=43200),但 update-latest-rates.yml 在每次
// 推送 data 分支後自動呼叫 jsDelivr Purge API,使快取立即失效 → 實際新鮮度約 5 分鐘。
// 優勢:全球 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 後立即最新,全球加速
'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',
];
// 單次 CDN fetch 逾時上限。行動網路平均 RTT 約 200-500ms,8 秒足以涵蓋 3G 網路,同時防止無限等待。
export const FETCH_TIMEOUT_MS = 8_000;
const CACHE_KEY = STORAGE_KEYS.EXCHANGE_RATES;
const CACHE_DURATION = 5 * 60 * 1000; // 5 分鐘
const IS_LHCI_OFFLINE = import.meta.env['VITE_LHCI_OFFLINE'] === 'true';
interface CachedData {
data: ExchangeRateData;
timestamp: number;
etag?: string; // 保留回應 ETag 供未來 proxy/worker 路徑重新啟用條件式請求
}
interface FetchResult {
data: ExchangeRateData;
etag?: string;
}
export function getBuildTimeExchangeRates(): ExchangeRateData {
return buildTimeRates;
}
function buildFallbackExchangeRates(updateTime: string): ExchangeRateData {
const fallbackRates = getBuildTimeExchangeRates();
return {
...fallbackRates,
updateTime,
source: 'fallback',
rates: {
TWD: 1,
...fallbackRates.rates,
},
};
}
/**
* 讀取完整快取條目(含 ETag);不捕獲例外,由呼叫端決定錯誤處理方式。
*/
function getCachedEntry(): CachedData | null {
const raw = localStorage.getItem(CACHE_KEY);
if (!raw) return null;
return JSON.parse(raw) as CachedData; // 可能拋出 SyntaxError
}
/**
* 從快取讀取匯率資料(檢查有效性)
*
* 離線快取保護策略:
* - 不再刪除過期快取,保留給離線使用
* - 過期只表示需要更新,不代表數據無效
* - 只有成功獲取新數據時才覆蓋舊快取
*/
function getFromCache(): ExchangeRateData | null {
try {
const entry = getCachedEntry();
if (!entry) {
logger.debug('No cache found');
return null;
}
const ageMs = Date.now() - entry.timestamp;
const ageMinutes = Math.floor(ageMs / (60 * 1000));
if (ageMs > CACHE_DURATION) {
logger.debug(
`Cache expired: ${ageMinutes} minutes old (limit: ${CACHE_DURATION / 60000} minutes)`,
);
return null;
}
logger.debug(`Cache valid: ${ageMinutes} minutes old, updateTime: ${entry.data.updateTime}`);
return entry.data;
} catch (error) {
logger.warn('Failed to read from cache', { error });
return null;
}
}
/**
* 儲存匯率資料到快取
*
* 雙重儲存策略:
* - localStorage: 5 分鐘有效期(控制數據新鮮度);可選保存 ETag 供下次條件式請求
* - IndexedDB: 7 天有效期(Safari PWA 冷啟動離線備援)
*/
function saveToCache(data: ExchangeRateData, etag?: string): void {
// 1. 儲存到 localStorage(5 分鐘有效期)
try {
const cached: CachedData = {
data,
timestamp: Date.now(),
...(etag ? { etag } : {}),
};
localStorage.setItem(CACHE_KEY, JSON.stringify(cached));
} catch (error) {
logger.warn('Failed to save to localStorage cache', { error });
}
// 2. 同時儲存到 IndexedDB(7 天有效期,作為離線備援)
// 使用 fire-and-forget 模式,不阻塞主流程
void saveExchangeRatesToIDB(data).catch((error) => {
logger.warn('Failed to save to IndexedDB cache', { error });
});
}
/**
* 從 CDN 獲取匯率資料(帶 fallback)
*
* [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<FetchResult> {
const errors: Error[] = [];
const startTime = Date.now();
for (let i = 0; i < CDN_URLS.length; i++) {
const url = CDN_URLS[i];
if (!url) continue;
try {
logger.debug(`Trying CDN #${i + 1}/${CDN_URLS.length}`, { url: url.substring(0, 80) });
const fetchInit: RequestInit = {
cache: 'no-cache',
...(signal ? { signal } : {}),
};
// [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}`);
}
const data = (await response.json()) as ExchangeRateData;
// 驗證資料格式
if (!data.rates || typeof data.rates !== 'object') {
throw new Error('Invalid data format');
}
// 讀取 ETag(jsDelivr 透過 Access-Control-Expose-Headers: * 暴露;GitHub Raw 回傳 null)。
const newETag = response.headers?.get('etag') ?? undefined;
const elapsed = Date.now() - startTime;
logger.info(`Fetched rates from CDN #${i + 1}`, {
elapsedMs: elapsed,
updateTime: data.updateTime,
currencyCount: Object.keys(data.rates).length,
hasETag: !!newETag,
});
return { data, etag: newETag };
} catch (error) {
const elapsed = Date.now() - startTime;
errors.push(error instanceof Error ? error : new Error(String(error)));
logger.warn(`CDN #${i + 1} failed`, { elapsedMs: elapsed, error });
continue;
}
}
throw new Error(
`Failed to fetch from all ${CDN_URLS.length} sources:\n${errors.map((e, i) => ` ${i + 1}. ${e.message}`).join('\n')}`,
);
}
/**
* 帶逾時保護的 CDN fetch。
* 使用 AbortController 防止行動網路卡頓時無限等待。
*/
async function fetchWithTimeout(): Promise<FetchResult> {
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort();
logger.warn(`CDN fetch timed out after ${FETCH_TIMEOUT_MS}ms`);
}, FETCH_TIMEOUT_MS);
try {
return await fetchFromCDN(controller.signal);
} finally {
clearTimeout(timeoutId);
}
}
/**
* 檢測網路狀態
* @see https://developer.mozilla.org/en-US/docs/Web/API/Navigator/onLine
*/
function isOnline(): boolean {
return typeof navigator !== 'undefined' && navigator.onLine;
}
/**
* 嘗試讀取任何可用的快取(包括過期的)
*
* 用於離線模式或網路請求失敗時的備援;靜默吞噬例外(損壞快取不影響備援流程)。
*/
function getAnyCachedData(): ExchangeRateData | null {
try {
const entry = getCachedEntry();
if (entry) {
const ageMinutes = Math.floor((Date.now() - entry.timestamp) / (60 * 1000));
logger.debug(`Found cached data (${ageMinutes} minutes old)`, {
updateTime: entry.data.updateTime,
});
return entry.data;
}
} catch {
// 損壞快取:靜默忽略,避免阻斷備援流程
}
return null;
}
/**
* 獲取匯率資料(帶快取和 fallback)
*
* 離線優先策略:
* 1. 離線時直接使用快取,不嘗試網路請求(節省資源)
* 2. 在線時優先使用有效快取,過期才請求網路
* 3. 網路失敗時使用任何可用快取(即使過期)
*
* Safari PWA 冷啟動離線優化:
* 4. 增加 IndexedDB 作為第二層備援(localStorage → IndexedDB → build-time snapshot)
* 5. IndexedDB 有效期 7 天,比 localStorage (5 分鐘) 更持久
*/
export async function getExchangeRates(): Promise<ExchangeRateData> {
if (IS_LHCI_OFFLINE) {
logger.info('LHCI offline mode: using build-time exchange rates');
return getBuildTimeExchangeRates();
}
const online = isOnline();
logger.debug('Getting exchange rates', { online });
// Offline: use cache directly without network request; fallback to static data
if (!online) {
// 第一層:嘗試 localStorage
const cachedData = getAnyCachedData();
if (cachedData) {
logger.info('Offline mode: using localStorage cache', {
updateTime: cachedData.updateTime,
});
return cachedData;
}
// Second layer: try IndexedDB (critical fallback for Safari PWA cold start)
try {
const { data: idbData, staleness } = await getExchangeRatesFromIDBWithStaleness();
if (idbData) {
// 如果資料已過期(> 7 天),記錄警告但仍使用 fallback 更安全
if (staleness.isExpired) {
logger.warn('Offline mode: IndexedDB data expired, using fallback rates', {
updateTime: idbData.updateTime,
staleness: staleness.level,
ageDays: staleness.ageDays,
message: staleness.message,
});
// 超過 7 天,回落到 build-time snapshot 避免誤導用戶
} else {
logger.info('Offline mode: using IndexedDB cache (Safari PWA fallback)', {
updateTime: idbData.updateTime,
staleness: staleness.level,
shouldWarn: staleness.shouldWarn,
});
return idbData;
}
}
} catch (idbError) {
logger.warn('Offline mode: IndexedDB read failed', { error: idbError });
}
// 第三層:使用 build-time fallback snapshot
logger.warn('Offline mode: no cache available, using fallback rates');
return buildFallbackExchangeRates('離線模式 - 使用預設匯率');
}
// 1. 嘗試從快取讀取(getFromCache 只返回 5 分鐘內的新鮮資料)
const cached = getFromCache();
if (cached) {
return cached;
}
// 2. Stale-while-revalidate:有過期快取時立即返回,背景更新。
// 根本解決骨架屏卡住問題:不讓 UI 等待 CDN 回應(行動網路可能很慢)。
const staleData = getAnyCachedData();
if (staleData) {
logger.info('Stale-while-revalidate: serving stale cache, refreshing in background', {
updateTime: staleData.updateTime,
});
void fetchWithTimeout()
.then(({ data, etag }) => {
saveToCache(data, etag);
logger.debug('Background cache refresh completed', { updateTime: data.updateTime });
})
.catch((err: unknown) => {
logger.warn('Background cache refresh failed', { error: err });
});
return staleData;
}
// 3. 完全無快取(首次啟動):帶逾時保護的 CDN fetch
logger.debug('No cache available, fetching from CDN with timeout');
try {
const { data, etag } = await fetchWithTimeout();
saveToCache(data, etag);
logger.debug('Fresh data saved to cache');
return data;
} catch (error) {
logger.error('Failed to fetch exchange rates', error instanceof Error ? error : undefined);
// 4. 嘗試 IndexedDB 作為最後防線(Safari PWA 冷啟動)
try {
const { data: idbData, staleness } = await getExchangeRatesFromIDBWithStaleness();
if (idbData && !staleness.isExpired) {
logger.warn('Using IndexedDB cache as fallback due to fetch error', {
updateTime: idbData.updateTime,
staleness: staleness.level,
shouldWarn: staleness.shouldWarn,
message: staleness.message,
});
return idbData;
}
} catch (idbError) {
logger.warn('IndexedDB fallback read failed', { error: idbError });
}
logger.warn('Remote rates unavailable and no cache available, using fallback rates');
return buildFallbackExchangeRates('遠端匯率暫不可用 - 使用預設匯率');
}
}
/**
* 清除快取(用於測試或強制重新載入)
*/
export function clearExchangeRateCache(): void {
localStorage.removeItem(CACHE_KEY);
logger.debug('Exchange rate cache cleared');
}
/**
* 轉換匯率資料為應用程式使用的格式
* 從台灣銀行的即期買入價轉換為應用需要的匯率
*/
export function transformRates(data: ExchangeRateData): Record<string, number> {
const transformed: Record<string, number> = {};
// 台灣銀行的匯率是 1 外幣 = X 台幣
// 但我們的應用需要 1 台幣 = X 外幣
Object.entries(data.rates).forEach(([code, rate]) => {
transformed[code] = 1 / rate; // 轉換為 TWD 基準
});
return transformed;
}