Mayx's Home Page
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

140 lines
4.8 KiB

​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
​
  1. /*
  2. * Mayx的博客 · 离线模式 Service Worker
  3. *
  4. * 两种模式(由 Cache Storage 中是否存在就绪标记决定):
  5. * - 在线模式(默认,无标记):所有请求原样转发网络,SW 形同虚设。
  6. * - 离线模式(/offline.html 启用后写入标记):同源 GET 请求缓存优先,
  7. * 缓存未命中且清单里没有的路径才回源网络,实现断网阅读。
  8. *
  9. * 版本控制:压缩包 /MayxBlog.7z 的 content-length 即版本号(每次重新部署
  10. * 都会变化)。SW 激活时、以及导航请求按 RECHECK_INTERVAL 节流复核;
  11. * 一旦发现线上压缩包与已缓存版本不一致,立即清空缓存 → 自动回退在线模式。
  12. * 网络不可用时复核失败,保留缓存(离线阅读本来就是离线模式的目的)。
  13. */
  14. const CACHE_NAME = 'mayxblog-offline-v1';
  15. const READY_KEY = '/__offline_manifest__';
  16. const ARCHIVE_URL = '/MayxBlog.7z';
  17. const RECHECK_INTERVAL = 5 * 60 * 1000;
  18. let lastRecheck = 0;
  19. self.addEventListener('install', () => {
  20. self.skipWaiting();
  21. });
  22. self.addEventListener('activate', (event) => {
  23. event.waitUntil(
  24. caches.keys()
  25. .then((keys) =>
  26. Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
  27. )
  28. .then(verifyPayloadVersion)
  29. .then(() => self.clients.claim())
  30. );
  31. });
  32. /*
  33. * HEAD 请求不被下面的 fetch 处理器拦截,这里读到的一定是线上真实压缩包,
  34. * 而不是缓存里的 quine 副本。网络失败返回 null,调用方选择信任现有缓存。
  35. */
  36. async function payloadSize() {
  37. try {
  38. const response = await fetch(ARCHIVE_URL, { method: 'HEAD', cache: 'no-store' });
  39. if (!response.ok) return null;
  40. const size = Number(response.headers.get('content-length'));
  41. return Number.isFinite(size) && size > 0 ? size : null;
  42. } catch {
  43. return null;
  44. }
  45. }
  46. /*
  47. * 就绪标记同时是清单:记录了解包写入的所有路径。没有它就无法区分
  48. * “站点本来就没有这个页面”和“缓存条目被浏览器回收了”。
  49. */
  50. async function readManifest(cache) {
  51. const hit = await cache.match(READY_KEY);
  52. if (!hit) return null;
  53. const meta = await hit.json().catch(() => null);
  54. if (!meta || !Array.isArray(meta.keys) || !meta.keys.length) return null;
  55. return meta;
  56. }
  57. /* 压缩包更新 → 清空缓存(即回退在线模式);返回是否发生了失效。 */
  58. async function verifyPayloadVersion() {
  59. const cache = await caches.open(CACHE_NAME);
  60. const meta = await readManifest(cache);
  61. if (!meta) return false;
  62. const size = await payloadSize();
  63. if (size && meta.payloadSize !== size) {
  64. await caches.delete(CACHE_NAME);
  65. return true;
  66. }
  67. return false;
  68. }
  69. /* 服务器对目录索引的处理与 Jekyll 产物一致,这里补齐候选路径。 */
  70. function candidatesFor(pathname) {
  71. const keys = [pathname];
  72. if (pathname.endsWith('/')) {
  73. keys.push(pathname + 'index.html');
  74. } else {
  75. keys.push(pathname + '/index.html');
  76. keys.push(pathname + '.html');
  77. }
  78. return keys;
  79. }
  80. self.addEventListener('fetch', (event) => {
  81. const request = event.request;
  82. if (request.method !== 'GET') return; // HEAD 等直通网络(版本探测依赖这一点)
  83. const url = new URL(request.url);
  84. if (url.origin !== self.location.origin) return; // 跨域请求不干预
  85. // 显式 no-store 的请求(重新下载压缩包等)绕过缓存,避免吃到旧的 quine 副本
  86. if (request.cache === 'no-store') return;
  87. event.respondWith(respond(request, url, event));
  88. });
  89. async function respond(request, url, event) {
  90. const cache = await caches.open(CACHE_NAME);
  91. // 导航请求顺带做节流版本复核;失效发生在后台,本次响应不受影响,
  92. // 下一次请求自然落入在线模式。
  93. if (request.mode === 'navigate' && Date.now() - lastRecheck > RECHECK_INTERVAL) {
  94. lastRecheck = Date.now();
  95. event.waitUntil(verifyPayloadVersion());
  96. }
  97. const candidates = candidatesFor(url.pathname);
  98. for (const key of candidates) {
  99. const hit = await cache.match(key);
  100. if (hit) return hit;
  101. }
  102. const meta = await readManifest(cache);
  103. if (!meta) return fetch(request); // 在线模式:纯转发
  104. // 清单承诺过这个路径但缓存里没有 → 条目被浏览器回收,缓存已不可信,
  105. // 整体作废并回退在线模式。
  106. if (candidates.some((key) => meta.keys.includes(key))) {
  107. await caches.delete(CACHE_NAME);
  108. return fetch(request);
  109. }
  110. // 清单之外的路径回源网络;断网时的导航请求用站点 404 页兜底。
  111. try {
  112. return await fetch(request);
  113. } catch (error) {
  114. if (request.mode === 'navigate') {
  115. const notFound = await cache.match('/404.html');
  116. if (notFound) {
  117. return new Response(notFound.body, { status: 404, headers: notFound.headers });
  118. }
  119. }
  120. throw error;
  121. }
  122. }