Files
blog/public/sw.js
T
wait4xandGLM-5.3 a347ce8baf feat(pwa): 全站 PWA 化——manifest+Service Worker+图标三件
- manifest.webmanifest:standalone 双语描述、192/512/maskable 图标
  (favicon.svg 经 sharp 直出)
- sw.js 保守策略:HTML network-first(不比 ESA 边缘更激进,防双重
  陈旧)+ install 预缓存 16 核心路由(SSG 全站 <1MB,全站离线可读)
  + /_astro|fonts|photos 不可变 cache-first + 评论 API/CMS 绕行
- BaseLayout:manifest+双 scheme theme-color+apple-touch-icon+一次性
  SW 注册(controller 守卫×VT 不重跑双保险)
- 踩坑入档:python 单线程 http.server 拖慢 16 并发预缓存,验收须等
  缓存键数稳定(曾误判 install 未跑);allSettled 静默是调试黑洞,
  install 失败必须探针可见

验证: 离线导航 6 页全 200(预缓存 16 条 500ms 就绪);node
      tests/e2e.mjs 31/31 PASS;manifest/sw/图标三路由 200

Spec: none (PWA 基建:manifest+SW+图标,离线矩阵入档)

Co-authored-by: GLM-5.3
2026-10-25 20:49:16 +08:00

91 lines
3.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* wait4x.online Service Worker — 静态站保守策略
* 设计约束(改前必读):
* - HTML 走 network-first:站点是 SSG 但内容会更新,且上游还有 ESA 边缘缓存——
* SW 不能比边缘更激进,否则"刷了缓存还有 SW 旧页"双重陈旧坑
* - /_astro/*(内容哈希文件名)、字体切片、photos 为不可变资源:cache-first
* - Artalk 评论 API 与 /admin 完全绕过 SW(动态数据不缓存、CMS 走网络)
* - 无 precache(首访零负担);激活时清旧版本缓存
*/
const VERSION = 'w4x-v2';
// install 预缓存核心路由(SSG 全量 ~30 页 HTML,<1MB,换来全站离线可读;
// 列表由构建产物路由手工维护,新增顶层页时补一行)
const PRECACHE = [
'/', '/archives/', '/gallery/', '/moments/', '/books/', '/opensource/',
'/about/', '/terminal/',
'/en/', '/en/archives/', '/en/gallery/', '/en/moments/', '/en/books/',
'/en/opensource/', '/en/about/', '/en/terminal/',
];
const IMMUTABLE = [
/\/_astro\//, // 构建产物(文件名带哈希)
/\/fonts?\//, // 字体切片(fontsource,内容不变)
/\/photos\//, // 图库压缩版(git 仓管理,变更=换文件)
/\/icons\//, // PWA 图标
/\.(?:woff2|png|jpg|webp|svg|ico|css|js|webmanifest|xml)$/i, // 兜底静态后缀
];
const NEVER_CACHE = [/\/api\//, /\/artalk/, /\/admin/, /\/wp-/];
self.addEventListener('install', (e) => {
e.waitUntil(
(async () => {
const cache = await caches.open(VERSION);
const results = await Promise.allSettled(PRECACHE.map((u) => cache.add(new Request(u, { cache: 'reload' }))));
// 探针:install 失败原因必须可见(allSettled 静默是调试黑洞)
results.forEach((r, i) => {
if (r.status === 'rejected') console.error(`[sw-install] ${PRECACHE[i]} →`, r.reason);
});
console.log(`[sw-install] done ok=${results.filter(r => r.status === 'fulfilled').length}/${PRECACHE.length} keys=${(await cache.keys()).length}`);
self.skipWaiting();
})(),
);
});
self.addEventListener('activate', (e) => {
e.waitUntil(
(async () => {
const keys = await caches.keys();
await Promise.all(keys.filter((k) => k !== VERSION).map((k) => caches.delete(k)));
await self.clients.claim();
})(),
);
});
self.addEventListener('fetch', (e) => {
const req = e.request;
if (req.method !== 'GET') return;
const url = new URL(req.url);
if (url.origin !== self.location.origin) return; // 评论等三方资源不掺和
if (NEVER_CACHE.some((re) => re.test(url.pathname))) return;
if (req.mode === 'navigate') {
// HTML network-first:网络失败时回缓存(离线阅读兜底),成功时后台更新缓存
e.respondWith(
(async () => {
const cache = await caches.open(VERSION);
try {
const fresh = await fetch(req);
if (fresh.ok) cache.put(req, fresh.clone());
return fresh;
} catch {
const hit = await cache.match(req);
return hit || Response.error();
}
})(),
);
return;
}
if (IMMUTABLE.some((re) => re.test(url.pathname))) {
e.respondWith(
(async () => {
const cache = await caches.open(VERSION);
const hit = await cache.match(req);
if (hit) return hit;
const res = await fetch(req);
if (res.ok && res.status === 200) cache.put(req, res.clone());
return res;
})(),
);
}
});