vite-plugin-sw-offline 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/runtime/sw.js ADDED
@@ -0,0 +1,729 @@
1
+ // ============================================
2
+ // Service Worker - 智能缓存策略
3
+ // ============================================
4
+ //
5
+ // 缓存策略概览:
6
+ // 1. 带 hash 静态资源(JS/CSS/字体)→ Cache First(永久缓存,hash 变则 URL 变)
7
+ // 2. 图片资源 → Stale-While-Revalidate(先返回缓存,后台更新,最多500项)
8
+ // 3. 可缓存 API 接口 → Stale-While-Revalidate(先返回缓存,后台更新)
9
+ // 4. 无 hash 同源静态资源 → Network First(网络优先,缓存兜底)
10
+ // 5. 导航请求 → Network First + 离线页面兜底
11
+ //
12
+ // ============================================
13
+
14
+ // ============================================
15
+ // 一、配置区(所有可调参数集中在此)
16
+ // ============================================
17
+
18
+ /** SW 构建版本号(构建时由 vite-plugin-sw-offline 注入,用于 vConsole 可见的状态上报) */
19
+ const SW_BUILD_VERSION = '__SW_VERSION__';
20
+
21
+ /** 缓存桶名称(固定,不需要手动版本号) */
22
+ const CACHE_NAMES = {
23
+ STATIC: 'static-cache-v1',
24
+ IMAGE: 'image-cache-v1',
25
+ API: 'api-cache-v2'
26
+ };
27
+
28
+ /**
29
+ * 缓存结构版本号 - 仅在大版本更新、需要清除所有旧缓存时手动递增
30
+ * 普通构建无需修改(靠 content hash 自然淘汰旧资源)
31
+ * 递增此值后,新 SW 激活时会自动清除所有旧缓存桶
32
+ */
33
+ const CACHE_SCHEMA_VERSION = 1;
34
+ const CACHE_SCHEMA_KEY = '__sw_cache_schema__';
35
+
36
+ /** 离线页面路径 */
37
+ const OFFLINE_PAGE = '/offline.html';
38
+
39
+ /**
40
+ * 离线页背景图(与离线页模板中 background-image 路径一致)
41
+ * jpg 走 handleImageRequest,须预缓存进 IMAGE 桶,否则断网后 CSS 拉背景会 503。
42
+ */
43
+ const OFFLINE_BACKGROUND = '/static/offline-bg.jpg';
44
+
45
+ /** 离线页 logo 路径(构建时由 vite-plugin-sw-offline 注入,与离线模板中 img 一致;空字符串表示不预缓存) */
46
+ const OFFLINE_LOGO_PATH = '__OFFLINE_LOGO_PATH__';
47
+
48
+ function hasOfflineLogoPrecache() {
49
+ return (
50
+ typeof OFFLINE_LOGO_PATH === 'string' &&
51
+ OFFLINE_LOGO_PATH.length > 0 &&
52
+ !OFFLINE_LOGO_PATH.startsWith('__')
53
+ );
54
+ }
55
+
56
+ /** 与 img 请求 URL 一致的 logo Request,保证 cache.match/put 键一致 */
57
+ function offlineLogoRequest() {
58
+ if (!hasOfflineLogoPrecache()) return null;
59
+ const abs = new URL(OFFLINE_LOGO_PATH, self.location.origin).href;
60
+ return new Request(abs, { cache: 'no-store', method: 'GET' });
61
+ }
62
+
63
+ /** 与文档请求 URL 一致的背景图 Request,保证 cache.match/put 键一致 */
64
+ function offlineBackgroundRequest() {
65
+ return new Request(new URL(OFFLINE_BACKGROUND, self.location.origin).href, {
66
+ cache: 'no-store',
67
+ method: 'GET'
68
+ });
69
+ }
70
+
71
+ /** 离线占位图(1x1 透明 PNG,用于图片加载失败时的兜底) */
72
+ const OFFLINE_IMAGE_PLACEHOLDER = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==';
73
+
74
+ /** 可缓存 API 路径白名单(构建时由 vite-plugin-sw-offline 注入;Stale-While-Revalidate) */
75
+ const CACHEABLE_API_PATHS = __CACHEABLE_API_PATHS__;
76
+
77
+ /** API 请求超时时间(毫秒,构建时由 vite-plugin-sw-offline 注入) */
78
+ const API_TIMEOUT = __SW_RT_API_TIMEOUT__;
79
+
80
+ /** 图片缓存过期时间(毫秒,构建时注入;当前 sw 逻辑未读取,预留) */
81
+ const IMAGE_CACHE_MAX_AGE = __SW_RT_IMAGE_CACHE_MAX_AGE__;
82
+
83
+ /** 图片缓存最大条目数(构建时注入) */
84
+ const IMAGE_CACHE_MAX_ITEMS = __SW_RT_IMAGE_CACHE_MAX_ITEMS__;
85
+
86
+ /** 静态资源缓存最大条目数(构建时注入) */
87
+ const STATIC_CACHE_MAX_ITEMS = __SW_RT_STATIC_CACHE_MAX_ITEMS__;
88
+
89
+ /**
90
+ * 网络探测 URL(构建时由 vite-plugin-sw-offline 注入完整地址,例如 maintain/checkMaintain?productCode=…)
91
+ * 用于 handleNavigationRequest 判断外网是否可达。
92
+ * 未注入或仍为占位符时,降级为探测同源 sw.js(避免开发环境未配置时误用错误地址)
93
+ */
94
+ const NETWORK_PROBE_URL = '__NETWORK_PROBE_URL__';
95
+
96
+ /** 网络探测超时时间(毫秒,构建时注入) */
97
+ const NETWORK_PROBE_TIMEOUT = __SW_RT_NETWORK_PROBE_TIMEOUT__;
98
+
99
+ // ============================================
100
+ // 二、工具函数
101
+ // ============================================
102
+
103
+ /**
104
+ * 带超时控制的 fetch
105
+ * @param {Request} request - 请求对象
106
+ * @param {number} timeout - 超时时间(毫秒)
107
+ * @returns {Promise<Response>}
108
+ */
109
+ function fetchWithTimeout(request, timeout) {
110
+ return new Promise((resolve, reject) => {
111
+ const controller = new AbortController();
112
+ const timeoutId = setTimeout(() => {
113
+ controller.abort();
114
+ reject(new Error('Request timeout'));
115
+ }, timeout);
116
+
117
+ fetch(request, { signal: controller.signal })
118
+ .then((response) => {
119
+ clearTimeout(timeoutId);
120
+ resolve(response);
121
+ })
122
+ .catch((error) => {
123
+ clearTimeout(timeoutId);
124
+ reject(error);
125
+ });
126
+ });
127
+ }
128
+
129
+ /**
130
+ * 判断 URL 是否带 content hash(Vite 构建产物)
131
+ * 匹配类似: index-JKaBVbgN.js, vendor-vue.BBcDyYlz.js, uni.9345ee40.css
132
+ * hash 使用 Base62 字符集 [a-zA-Z0-9],长度通常为 8 位
133
+ */
134
+ function isHashedAsset(url) {
135
+ return /[-\.][a-zA-Z0-9]{8}\.(js|css|woff2?|ttf|eot)(\?.*)?$/.test(url.pathname);
136
+ }
137
+
138
+ /** 检查 URL 是否指向图片资源 */
139
+ function isImageRequest(url) {
140
+ return /\.(png|jpe?g|gif|svg|webp|ico)$/i.test(url.pathname);
141
+ }
142
+
143
+ /** 检查 URL 是否命中可缓存的 API 路径 */
144
+ function isCacheableApi(url) {
145
+ return CACHEABLE_API_PATHS.some(path => url.pathname.includes(path));
146
+ }
147
+
148
+ /**
149
+ * 生成 API 缓存的 key
150
+ * - GET 请求:pathname + search(含分页等查询参数)
151
+ * - POST 请求:pathname + 请求体的简单 hash
152
+ */
153
+ async function getApiCacheKey(request) {
154
+ const url = new URL(request.url);
155
+ let key = url.pathname;
156
+
157
+ if (request.method === 'GET') {
158
+ key += url.search;
159
+ } else if (request.method === 'POST') {
160
+ try {
161
+ const body = await request.clone().text();
162
+ let hash = 0;
163
+ for (let i = 0; i < body.length; i++) {
164
+ const char = body.charCodeAt(i);
165
+ hash = ((hash << 5) - hash) + char;
166
+ hash = hash & hash;
167
+ }
168
+ key += `_POST_${hash}`;
169
+ } catch (e) {
170
+ console.warn('[SW] Failed to read POST body for cache key:', url.pathname, e.message);
171
+ key += '_POST_nobody';
172
+ }
173
+ }
174
+
175
+ return key;
176
+ }
177
+
178
+ /**
179
+ * 限制缓存数量,淘汰最旧的条目(类 LRU)
180
+ * 注意:此函数在各策略中以 fire-and-forget 方式调用,不影响请求响应速度
181
+ */
182
+ async function trimCache(cacheName, maxItems) {
183
+ const cache = await caches.open(cacheName);
184
+ const keys = await cache.keys();
185
+ if (keys.length > maxItems) {
186
+ const deleteCount = keys.length - maxItems;
187
+ for (let i = 0; i < deleteCount; i++) {
188
+ await cache.delete(keys[i]);
189
+ }
190
+ console.log('[SW] Trimmed cache', cacheName, '- removed', deleteCount, 'items');
191
+ }
192
+ }
193
+
194
+ /**
195
+ * 清理 API 缓存桶中不再属于 CACHEABLE_API_PATHS 白名单的旧条目
196
+ * 场景:从白名单移除某接口后,下次 SW 激活时自动淘汰其残留缓存
197
+ */
198
+ async function purgeStaleApiEntries() {
199
+ try {
200
+ const cache = await caches.open(CACHE_NAMES.API);
201
+ const keys = await cache.keys();
202
+ let purged = 0;
203
+ for (const req of keys) {
204
+ const url = new URL(req.url);
205
+ const stillValid = CACHEABLE_API_PATHS.some(path => url.pathname.includes(path));
206
+ if (!stillValid) {
207
+ await cache.delete(req);
208
+ purged++;
209
+ }
210
+ }
211
+ if (purged > 0) {
212
+ console.log('[SW] Purged', purged, 'stale API cache entries');
213
+ }
214
+ } catch (e) {
215
+ console.warn('[SW] Failed to purge stale API entries:', e.message);
216
+ }
217
+ }
218
+
219
+ /**
220
+ * 返回离线页面
221
+ * 优先从缓存读取完整的 offline.html,
222
+ * 若缓存中也没有(极端情况),则返回一个内联兜底页面(文案与 offline.html 保持一致)
223
+ */
224
+ async function getOfflineResponse() {
225
+ const cache = await caches.open(CACHE_NAMES.STATIC);
226
+ const offlineResponse = await cache.match(OFFLINE_PAGE);
227
+ if (offlineResponse) {
228
+ return offlineResponse;
229
+ }
230
+ // 内联兜底:仅在 offline.html 完全无法从缓存获取时才使用
231
+ // 注意:文案和按钮需与 offline.html 保持一致,避免用户体验割裂
232
+ console.warn('[SW] offline.html not in cache, using inline fallback');
233
+ return new Response(
234
+ '<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no">' +
235
+ '<title>网络连接失败</title>' +
236
+ '<style>*{margin:0;padding:0;box-sizing:border-box}' +
237
+ 'body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;background:#131529;min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:0 20px;color:#fff}' +
238
+ '.c{text-align:center;max-width:400px;width:100%}' +
239
+ 'h1{font-size:22px;font-weight:600;margin-bottom:16px}' +
240
+ 'p{font-size:14px;color:rgba(255,255,255,0.6);margin-bottom:40px;line-height:1.8;padding:0 10px}' +
241
+ '.btns{display:flex;justify-content:center;gap:16px}' +
242
+ '.btn{flex:1;max-width:170px;display:inline-flex;align-items:center;justify-content:center;padding:14px 20px;font-size:15px;font-weight:500;border:none;border-radius:28px;cursor:pointer;text-decoration:none;-webkit-tap-highlight-color:transparent}' +
243
+ '.btn:active{opacity:0.8}' +
244
+ '.s{background:rgba(255,255,255,0.1);color:rgba(255,255,255,0.85);border:1px solid rgba(255,255,255,0.12)}' +
245
+ '.p{background:linear-gradient(180deg,#6591FD 0%,#3D75FF 100%);color:#fff;box-shadow:0 4px 16px rgba(74,124,255,0.3);border-radius:100px}' +
246
+ '</style></head>' +
247
+ '<body><div class="c">' +
248
+ '<h1>网络连接失败</h1>' +
249
+ '<p>请检查网络设置后重试,或联系客服获取帮助,若无法重启,请您耐心等待并稍后再进行尝试。</p>' +
250
+ '<div class="btns">' +
251
+ '<a class="btn s" href="javascript:void(0)" onclick="var u=localStorage.getItem(\'customerServiceUrl\');u?window.open(u,\'_blank\'):alert(\'请在网络恢复后联系客服\')">联系客服</a>' +
252
+ '<button class="btn p" onclick="location.reload()">重新加载</button>' +
253
+ '</div></div>' +
254
+ '<script>window.addEventListener("online",function(){location.reload()});</script>' +
255
+ '</body></html>',
256
+ { status: 503, headers: { 'Content-Type': 'text/html; charset=utf-8' } }
257
+ );
258
+ }
259
+
260
+ /**
261
+ * 预缓存离线页 HTML(STATIC)+ 背景图(IMAGE),与 fetch 分发策略一致
262
+ */
263
+ function precacheOfflineBundle() {
264
+ const bgReq = offlineBackgroundRequest();
265
+ const htmlP = caches.open(CACHE_NAMES.STATIC).then((cache) =>
266
+ fetch(OFFLINE_PAGE, { cache: 'no-store' }).then((response) => {
267
+ if (response.status === 200) {
268
+ return cache.put(OFFLINE_PAGE, response.clone());
269
+ }
270
+ })
271
+ );
272
+ const bgP = caches.open(CACHE_NAMES.IMAGE).then((cache) =>
273
+ fetch(bgReq).then((response) => {
274
+ if (response.status === 200) {
275
+ return cache.put(bgReq, response.clone());
276
+ }
277
+ })
278
+ );
279
+ const logoReq = offlineLogoRequest();
280
+ const logoP = logoReq
281
+ ? caches.open(CACHE_NAMES.IMAGE).then((cache) =>
282
+ fetch(logoReq).then((response) => {
283
+ if (response.status === 200) {
284
+ return cache.put(logoReq, response.clone());
285
+ }
286
+ })
287
+ )
288
+ : Promise.resolve();
289
+ return Promise.all([htmlP, bgP, logoP]);
290
+ }
291
+
292
+ /**
293
+ * 自愈机制:确保 offline.html + 离线背景图已缓存
294
+ * 以 fire-and-forget 方式调用,不阻塞任何请求
295
+ */
296
+ function ensureOfflineBundleCached() {
297
+ const bgReq = offlineBackgroundRequest();
298
+ const logoReq = offlineLogoRequest();
299
+ const checks = [
300
+ caches.open(CACHE_NAMES.STATIC).then((c) => c.match(OFFLINE_PAGE)),
301
+ caches.open(CACHE_NAMES.IMAGE).then((c) => c.match(bgReq))
302
+ ];
303
+ if (logoReq) {
304
+ checks.push(caches.open(CACHE_NAMES.IMAGE).then((c) => c.match(logoReq)));
305
+ }
306
+ Promise.all(checks)
307
+ .then((results) => {
308
+ const htmlCached = results[0];
309
+ const bgCached = results[1];
310
+ const logoCached = logoReq ? results[2] : true;
311
+ if (htmlCached && bgCached && logoCached) {
312
+ return;
313
+ }
314
+ if (!htmlCached) {
315
+ console.log('[SW] offline.html missing from cache, attempting to cache now');
316
+ }
317
+ if (!bgCached) {
318
+ console.log('[SW] offline background missing from cache, attempting to cache now');
319
+ }
320
+ if (logoReq && !logoCached) {
321
+ console.log('[SW] offline logo missing from cache, attempting to cache now');
322
+ }
323
+ return precacheOfflineBundle().then(() => {
324
+ console.log('[SW] offline bundle cached successfully (self-healing)');
325
+ });
326
+ })
327
+ .catch(() => {
328
+ // 静默失败,下次导航还会再尝试
329
+ });
330
+ }
331
+
332
+ // ============================================
333
+ // 三、生命周期事件
334
+ // ============================================
335
+
336
+ // --- 安装事件:预缓存离线页面 ---
337
+ self.addEventListener('install', (event) => {
338
+ console.log('[SW] Installing | version:', SW_BUILD_VERSION, '| API paths:', CACHEABLE_API_PATHS.length);
339
+ // self.clients.matchAll().then((clients) => {
340
+ // clients.forEach((client) => {
341
+ // client.postMessage({
342
+ // type: 'sw-status',
343
+ // event: 'install',
344
+ // version: SW_BUILD_VERSION,
345
+ // apiPathsCount: CACHEABLE_API_PATHS.length,
346
+ // cacheNames: CACHE_NAMES
347
+ // });
348
+ // });
349
+ // });
350
+ event.waitUntil(
351
+ precacheOfflineBundle()
352
+ .then(() => {
353
+ console.log('[SW] Pre-cached offline bundle (html + background + logo if configured)');
354
+ })
355
+ .catch((err) => {
356
+ console.warn('[SW] Failed to pre-cache offline bundle:', err && err.message);
357
+ })
358
+ .then(() => self.skipWaiting())
359
+ );
360
+ });
361
+
362
+ // --- 激活事件:清理旧版本缓存 ---
363
+ self.addEventListener('activate', (event) => {
364
+ console.log('[SW] Activating | version:', SW_BUILD_VERSION, '| schema:', CACHE_SCHEMA_VERSION);
365
+
366
+ event.waitUntil(
367
+ caches.open(CACHE_NAMES.STATIC)
368
+ .then((cache) => cache.match(CACHE_SCHEMA_KEY))
369
+ .then((resp) => resp ? resp.text() : '0')
370
+ .then((text) => parseInt(text, 10) || 0)
371
+ .then((oldSchema) => {
372
+ if (oldSchema < CACHE_SCHEMA_VERSION) {
373
+ // schema 版本变化 → 大更新,清除所有缓存桶后重建
374
+ console.log('[SW] Cache schema upgraded:', oldSchema, '->', CACHE_SCHEMA_VERSION, '| purging all caches');
375
+ return caches.keys()
376
+ .then((names) => Promise.all(names.map((n) => caches.delete(n))))
377
+ .then(() => caches.open(CACHE_NAMES.STATIC))
378
+ .then((cache) => {
379
+ // 记录新 schema 版本号
380
+ cache.put(CACHE_SCHEMA_KEY, new Response(String(CACHE_SCHEMA_VERSION)));
381
+ return precacheOfflineBundle()
382
+ .then(() => {
383
+ console.log('[SW] Re-cached offline bundle after schema upgrade');
384
+ })
385
+ .catch(() => {
386
+ console.warn('[SW] Failed to re-cache offline bundle after schema upgrade');
387
+ });
388
+ });
389
+ }
390
+
391
+ // schema 未变 → 常规更新,只清理不在白名单中的缓存桶
392
+ const validCaches = Object.values(CACHE_NAMES);
393
+ return caches.keys()
394
+ .then((names) => {
395
+ return Promise.all(
396
+ names.map((name) => {
397
+ if (!validCaches.includes(name)) {
398
+ console.log('[SW] Deleting old cache:', name);
399
+ return caches.delete(name);
400
+ }
401
+ })
402
+ );
403
+ })
404
+ .then(() => {
405
+ // 确保 schema 版本号已记录(首次安装时 oldSchema 为 0 但 CACHE_SCHEMA_VERSION 为 1 会走上面的分支)
406
+ // 这里处理 oldSchema === CACHE_SCHEMA_VERSION 的情况,补写 schema key(防止丢失)
407
+ return caches.open(CACHE_NAMES.STATIC).then((cache) => {
408
+ cache.put(CACHE_SCHEMA_KEY, new Response(String(CACHE_SCHEMA_VERSION)));
409
+ });
410
+ })
411
+ .then(() => {
412
+ return precacheOfflineBundle()
413
+ .then(() => {
414
+ console.log('[SW] Refreshed offline bundle cache on activate');
415
+ })
416
+ .catch(() => {
417
+ console.warn('[SW] Failed to refresh offline bundle on activate');
418
+ });
419
+ });
420
+ })
421
+ .then(() => {
422
+ // 主动 trim STATIC 缓存,清理累积的旧 hashed 资源
423
+ return trimCache(CACHE_NAMES.STATIC, STATIC_CACHE_MAX_ITEMS);
424
+ })
425
+ .then(() => {
426
+ return purgeStaleApiEntries();
427
+ })
428
+ .then(() => {
429
+ console.log('[SW] Claiming all clients');
430
+ return self.clients.claim();
431
+ })
432
+ );
433
+ });
434
+
435
+ // ============================================
436
+ // 四、请求拦截 - 路由分发
437
+ // ============================================
438
+ self.addEventListener('fetch', (event) => {
439
+ const request = event.request;
440
+ const url = new URL(request.url);
441
+
442
+ // 只处理 GET 和 POST 请求
443
+ if (request.method !== 'GET' && request.method !== 'POST') {
444
+ return;
445
+ }
446
+
447
+ // 跳过 chrome-extension 等非 http(s) 请求
448
+ if (!url.protocol.startsWith('http')) {
449
+ return;
450
+ }
451
+
452
+ // 0. 导航请求(用户直接访问页面)- 网络优先,失败直接返回离线页面
453
+ if (request.mode === 'navigate') {
454
+ event.respondWith(handleNavigationRequest(request));
455
+ return;
456
+ }
457
+
458
+ // 1. 带 hash 的静态资源(JS/CSS/字体)- 缓存优先
459
+ if (isHashedAsset(url)) {
460
+ event.respondWith(handleHashedAssetRequest(request));
461
+ return;
462
+ }
463
+
464
+ // 2. 图片请求 - Stale-While-Revalidate(先返回缓存,后台更新)
465
+ if (isImageRequest(url)) {
466
+ event.respondWith(handleImageRequest(request));
467
+ return;
468
+ }
469
+
470
+ // 3. 可缓存的 API 请求 - Stale-While-Revalidate
471
+ if (isCacheableApi(url)) {
472
+ event.respondWith(handleApiRequest(request));
473
+ return;
474
+ }
475
+
476
+ // 4. 同源无 hash 静态资源 - 网络优先,缓存兜底
477
+ if (url.origin === self.location.origin && request.method === 'GET') {
478
+ event.respondWith(handleUnhashedStaticRequest(request));
479
+ return;
480
+ }
481
+
482
+ // 5. 其他请求 - 直接网络
483
+ });
484
+
485
+ // ============================================
486
+ // 五、缓存策略实现
487
+ // ============================================
488
+
489
+ /**
490
+ * 导航请求处理 - 网络优先 + 离线兜底
491
+ * 关键:离线时不返回缓存的 index.html(因为里面的 JS/API 都会失败),
492
+ * 而是直接返回自包含的 offline.html
493
+ *
494
+ * 探测策略:
495
+ * - 若构建注入了 NETWORK_PROBE_URL(完整 URL),则用它探测
496
+ * - 否则降级为探测同源 sw.js(开发或未配置时)
497
+ * - 带 AbortController 超时保护,防止请求无限挂起
498
+ */
499
+ async function handleNavigationRequest(request) {
500
+ const isProbeConfigured = NETWORK_PROBE_URL && !NETWORK_PROBE_URL.startsWith('__');
501
+
502
+ const probeUrl = isProbeConfigured
503
+ ? NETWORK_PROBE_URL
504
+ : self.registration.scope + 'sw.js?ping=' + Date.now();
505
+
506
+ // 第二步:带超时的网络探测
507
+ try {
508
+ const controller = new AbortController();
509
+ const timeoutId = setTimeout(() => controller.abort(), NETWORK_PROBE_TIMEOUT);
510
+
511
+ await fetch(probeUrl + (probeUrl.includes('?') ? '&' : '?') + '_t=' + Date.now(), {
512
+ method: 'HEAD',
513
+ cache: 'no-store',
514
+ mode: 'no-cors',
515
+ signal: controller.signal
516
+ });
517
+
518
+ clearTimeout(timeoutId);
519
+ } catch (error) {
520
+ // 网络探测失败(网络不通 / 超时 / 服务器不可达)→ 用户离线 → 返回离线页面
521
+ console.log('[SW] Network probe failed, returning offline page:', request.url);
522
+ return getOfflineResponse();
523
+ }
524
+
525
+ // 第三步:网络是通的,正常请求页面
526
+ try {
527
+ const response = await fetch(request);
528
+ // 导航成功 → 趁网络可用,确保 offline.html 已缓存(自愈机制,fire-and-forget)
529
+ ensureOfflineBundleCached();
530
+ return response;
531
+ } catch (error) {
532
+ // 页面请求失败(理论上不应该到这里,因为网络探测已经通过了)
533
+ console.log('[SW] Navigation fetch failed after probe success:', request.url);
534
+ return getOfflineResponse();
535
+ }
536
+ }
537
+
538
+ /** 带 hash 静态资源处理 - 缓存优先(Cache First) */
539
+ async function handleHashedAssetRequest(request) {
540
+ const cache = await caches.open(CACHE_NAMES.STATIC);
541
+
542
+ // 先查缓存
543
+ const cachedResponse = await cache.match(request);
544
+ if (cachedResponse) {
545
+ return cachedResponse;
546
+ }
547
+
548
+ // 缓存未命中,请求网络
549
+ try {
550
+ const response = await fetch(request);
551
+ if (response.status === 200) {
552
+ cache.put(request, response.clone());
553
+ trimCache(CACHE_NAMES.STATIC, STATIC_CACHE_MAX_ITEMS);
554
+ }
555
+ return response;
556
+ } catch (error) {
557
+ return new Response('', { status: 503, statusText: 'Service Unavailable' });
558
+ }
559
+ }
560
+
561
+ /**
562
+ * 图片请求处理 - Stale-While-Revalidate
563
+ *
564
+ * 先返回缓存(秒开),同时后台请求网络更新缓存。
565
+ * 这样即使同一 URL 的内容发生变化(如加密→非加密),最多差一次访问即可拿到最新版本。
566
+ * SW 只负责缓存原始响应并原样透传,不得修改 body / 构造新 Response,
567
+ * 否则跨域响应在部分 WebView 中会丢失 body 导致 net::ERR_FAILED。
568
+ */
569
+ async function handleImageRequest(request) {
570
+ const cache = await caches.open(CACHE_NAMES.IMAGE);
571
+ const cachedResponse = await cache.match(request);
572
+
573
+ const fetchPromise = fetch(request)
574
+ .then((response) => {
575
+ if (response.ok || response.type === 'opaque') {
576
+ cache.put(request, response.clone());
577
+ trimCache(CACHE_NAMES.IMAGE, IMAGE_CACHE_MAX_ITEMS);
578
+ }
579
+ return response;
580
+ })
581
+ .catch((error) => {
582
+ console.log('[SW] Image revalidate failed:', request.url, error.message);
583
+ return null;
584
+ });
585
+
586
+ if (cachedResponse) {
587
+ return cachedResponse;
588
+ }
589
+
590
+ const networkResponse = await fetchPromise;
591
+ if (networkResponse) {
592
+ return networkResponse;
593
+ }
594
+
595
+ return new Response('', { status: 503, statusText: 'Service Unavailable' });
596
+ }
597
+
598
+ /** API 请求处理 - Stale-While-Revalidate(先返回缓存,后台静默更新;网络异常时缓存兜底) */
599
+ async function handleApiRequest(request) {
600
+ const cache = await caches.open(CACHE_NAMES.API);
601
+ const cacheKey = await getApiCacheKey(request);
602
+ const cachedResponse = await cache.match(cacheKey);
603
+
604
+ const fetchPromise = fetchWithTimeout(request.clone(), API_TIMEOUT)
605
+ .then((response) => {
606
+ if (response.status === 200) {
607
+ cache.put(cacheKey, response.clone());
608
+ }
609
+ return response;
610
+ })
611
+ .catch((error) => {
612
+ console.log('[SW] API fetch failed:', request.url, error.message);
613
+ return null;
614
+ });
615
+
616
+ if (cachedResponse) {
617
+ return cachedResponse;
618
+ }
619
+
620
+ const networkResponse = await fetchPromise;
621
+
622
+ if (networkResponse && networkResponse.status === 200) {
623
+ return networkResponse;
624
+ }
625
+
626
+ // 网络返回非200或完全失败 → 再次尝试缓存(可能被并发请求写入)
627
+ const retryCached = await cache.match(cacheKey);
628
+ if (retryCached) {
629
+ return retryCached;
630
+ }
631
+
632
+ if (networkResponse) {
633
+ return networkResponse;
634
+ }
635
+
636
+ // 完全无网络也无缓存 → 透传原始请求让前端拦截器正常处理
637
+ try {
638
+ return await fetch(request);
639
+ } catch (e) {
640
+ return new Response(JSON.stringify({ code: 200, msg: '', data: null }), {
641
+ status: 200,
642
+ headers: { 'Content-Type': 'application/json' }
643
+ });
644
+ }
645
+ }
646
+
647
+ /** 无 hash 同源静态资源处理 - 网络优先(Network First),缓存兜底 */
648
+ async function handleUnhashedStaticRequest(request) {
649
+ const cache = await caches.open(CACHE_NAMES.STATIC);
650
+
651
+ try {
652
+ const response = await fetch(request);
653
+ if (response.status === 200) {
654
+ cache.put(request, response.clone());
655
+ }
656
+ return response;
657
+ } catch (error) {
658
+ // 网络失败,尝试缓存
659
+ const cachedResponse = await cache.match(request);
660
+ if (cachedResponse) {
661
+ console.log('[SW] Unhashed static from cache (offline):', request.url);
662
+ return cachedResponse;
663
+ }
664
+ // 没有缓存 - 根据请求的 Accept 头返回合适的格式
665
+ const accept = request.headers.get('Accept') || '';
666
+ if (accept.includes('application/json')) {
667
+ return new Response(JSON.stringify({ error: 'Network error', offline: true }), {
668
+ status: 503,
669
+ headers: { 'Content-Type': 'application/json' }
670
+ });
671
+ }
672
+ return new Response('Network error', {
673
+ status: 503,
674
+ headers: { 'Content-Type': 'text/plain' }
675
+ });
676
+ }
677
+ }
678
+
679
+ // ============================================
680
+ // 六、接收主线程消息(handler map 模式,便于扩展)
681
+ // ============================================
682
+
683
+ /** 消息处理器映射表:字符串消息 → 处理函数 */
684
+ const messageHandlers = {
685
+ skipWaiting() {
686
+ self.skipWaiting();
687
+ },
688
+ // getStatus() {
689
+ // self.clients.matchAll().then((clients) => {
690
+ // clients.forEach((client) => {
691
+ // client.postMessage({
692
+ // type: 'sw-status',
693
+ // event: 'active',
694
+ // version: SW_BUILD_VERSION,
695
+ // apiPathsCount: CACHEABLE_API_PATHS.length,
696
+ // apiPaths: CACHEABLE_API_PATHS,
697
+ // cacheNames: CACHE_NAMES
698
+ // });
699
+ // });
700
+ // });
701
+ // },
702
+ clearApiCache() {
703
+ caches.delete(CACHE_NAMES.API).then(() => {
704
+ console.log('[SW] API cache cleared');
705
+ });
706
+ },
707
+ clearImageCache() {
708
+ caches.delete(CACHE_NAMES.IMAGE).then(() => {
709
+ console.log('[SW] Image cache cleared');
710
+ });
711
+ }
712
+ };
713
+
714
+ self.addEventListener('message', (event) => {
715
+ const data = event.data;
716
+
717
+ // 字符串消息:查找 handler map
718
+ if (typeof data === 'string' && messageHandlers[data]) {
719
+ messageHandlers[data]();
720
+ return;
721
+ }
722
+
723
+ // 对象消息:按 type 分发
724
+ if (data && data.type === 'clearCache' && data.cacheName) {
725
+ caches.delete(data.cacheName).then(() => {
726
+ console.log('[SW] Cache cleared:', data.cacheName);
727
+ });
728
+ }
729
+ });