service-work.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341
  1. /* global globalThis */
  2. const cacheName = 'spa-cache-v1';
  3. // —— Cache pressure controls ——
  4. const MAX_NOHASH_ENTRIES = 500; // 每个无 hash 版本缓存的最大条目数
  5. const MAX_HASH_ENTRIES = 200; // 带 hash 静态资源缓存最大条目数
  6. const MAX_IMG_RESPONSE_SIZE = 1_500_000; // 超过此大小(字节)不入缓存
  7. // iOS/Safari 下调限额(小容量设备)
  8. const IS_SAFARI = /Safari\//.test(globalThis.navigator.userAgent) && !/Chrome\//.test(globalThis.navigator.userAgent);
  9. const IS_FB_BROWSER = /FBAN|FBAV|FB_IAB/i.test(globalThis.navigator.userAgent);
  10. const IOS_LIMIT_FACTOR = IS_SAFARI ? 0.5 : 1;
  11. const FB_LIMIT_FACTOR = IS_FB_BROWSER ? 0.3 : 1;
  12. const DEVICE_FACTOR = Math.min(IOS_LIMIT_FACTOR, FB_LIMIT_FACTOR);
  13. // —— Image cache configs ——
  14. const IMG_NOHASH_CACHE_PREFIX = 'img-webp-nohash-v'; // versioned
  15. const IMG_HASH_CACHE_NAME = 'img-webp-hash'; // stable (hashed filenames)
  16. let IMG_VERSION = '0';
  17. async function readAppVersion() {
  18. try {
  19. const res = await fetch('/app_version.json', { cache: 'no-store' });
  20. if (res && res.ok) {
  21. const data = await res.json();
  22. IMG_VERSION = String(data?.version || '0');
  23. }
  24. } catch (e) {
  25. // ignore
  26. }
  27. }
  28. // —— Cookie Store 安全封装(iOS Safari 可能不支持) ——
  29. function hasCookieStore() {
  30. return 'cookieStore' in globalThis && globalThis.cookieStore && typeof globalThis.cookieStore.get === 'function';
  31. }
  32. async function safeGetCookie(name) {
  33. if (!hasCookieStore()) return undefined;
  34. try {
  35. const res = await globalThis.cookieStore.get(name);
  36. return res?.value;
  37. } catch (e) {
  38. return undefined;
  39. }
  40. }
  41. async function safeSetCookie(name, value) {
  42. if (!hasCookieStore()) return;
  43. try {
  44. await globalThis.cookieStore.set(name, value);
  45. } catch (e) {
  46. // ignore
  47. }
  48. }
  49. async function safeDeleteCookie(name) {
  50. if (!hasCookieStore()) return;
  51. try {
  52. await globalThis.cookieStore.delete(name);
  53. } catch (e) {
  54. // ignore
  55. }
  56. }
  57. // 定义主域名和备用域名列表
  58. var BACKUP_DOMAINS = ['https://winus777.com'];
  59. function getAllowedBackupDomain(value) {
  60. if (typeof value !== 'string' || value.trim() === '') return undefined;
  61. try {
  62. const origin = new URL(value).origin;
  63. return BACKUP_DOMAINS.some(domain => new URL(domain).origin === origin) ? origin : undefined;
  64. } catch (error) {
  65. return undefined;
  66. }
  67. }
  68. // 安装 Service Worker 并缓存 index.html
  69. globalThis.addEventListener('install', function (e) {
  70. e.waitUntil(
  71. (async () => {
  72. await readAppVersion();
  73. const cache = await caches.open(cacheName);
  74. let ok;
  75. try {
  76. ok = await cache.addAll([
  77. // '/index.html'
  78. ]);
  79. } catch (err) {
  80. console.log('err: cache.addAll');
  81. }
  82. return ok;
  83. })()
  84. );
  85. console.log('[Service Worker] Install');
  86. globalThis.skipWaiting();
  87. });
  88. globalThis.addEventListener('activate', e => {
  89. // Remove unwanted caches
  90. e.waitUntil(
  91. (async () => {
  92. await readAppVersion();
  93. const names = await caches.keys();
  94. const validNoHashName = IMG_NOHASH_CACHE_PREFIX + IMG_VERSION;
  95. await Promise.all(
  96. names.map(name => {
  97. const isOldNoHash = name.startsWith(IMG_NOHASH_CACHE_PREFIX) && name !== validNoHashName;
  98. const isOldSpa = name !== cacheName && !name.startsWith(IMG_NOHASH_CACHE_PREFIX) && name !== IMG_HASH_CACHE_NAME;
  99. if (isOldNoHash || isOldSpa) {
  100. console.log('Service Worker: Clearing Old Cache', name);
  101. return caches.delete(name);
  102. }
  103. return undefined;
  104. })
  105. );
  106. await globalThis.clients.claim();
  107. })()
  108. );
  109. });
  110. globalThis.addEventListener('fetch', event => {
  111. const request = event.request;
  112. const url = new URL(request.url);
  113. // 仅处理同源请求
  114. const isSameOrigin = url.origin === globalThis.location.origin;
  115. // 1) 导航请求:沿用现有逻辑
  116. if (request.mode === 'navigate') {
  117. event.respondWith(handleRequest(request, url));
  118. return;
  119. }
  120. // 2) 仅处理 .webp 图片
  121. const isWebpImage = request.destination === 'image' && /\.webp(\?|$)/.test(url.pathname);
  122. if (!isWebpImage || !isSameOrigin) {
  123. return; // 放过其余请求
  124. }
  125. const isNoHashDir = url.pathname.startsWith('/game_data/') || url.pathname.startsWith('/img/');
  126. // 2.1 无 hash 目录:Stale-While-Revalidate,版本化缓存名
  127. if (isNoHashDir) {
  128. const noHashCacheName = IMG_NOHASH_CACHE_PREFIX + IMG_VERSION;
  129. event.respondWith(
  130. (async () => {
  131. const cache = await caches.open(noHashCacheName);
  132. const cached = await cache.match(request);
  133. const networkPromise = fetch(request)
  134. .then(async resp => {
  135. if (resp && resp.ok) {
  136. const len = Number(resp.headers.get('content-length') || '0');
  137. if (len === 0 || len <= MAX_IMG_RESPONSE_SIZE) {
  138. await cache.put(request, resp.clone());
  139. // 限制缓存条数,删除最早的
  140. await enforceCacheLimit(noHashCacheName, Math.floor(MAX_NOHASH_ENTRIES * DEVICE_FACTOR));
  141. }
  142. }
  143. return resp;
  144. })
  145. .catch(() => cached); // 网络失败时回退缓存
  146. if (cached) {
  147. event.waitUntil(networkPromise); // 背景更新
  148. return cached;
  149. }
  150. return networkPromise;
  151. })()
  152. );
  153. return;
  154. }
  155. // 2.2 其他(带 hash):Cache-First
  156. event.respondWith(
  157. (async () => {
  158. const cache = await caches.open(IMG_HASH_CACHE_NAME);
  159. const cached = await cache.match(request);
  160. if (cached) return cached;
  161. const resp = await fetch(request);
  162. if (resp && resp.ok) {
  163. const len = Number(resp.headers.get('content-length') || '0');
  164. if (len === 0 || len <= MAX_IMG_RESPONSE_SIZE) {
  165. await cache.put(request, resp.clone());
  166. await enforceCacheLimit(IMG_HASH_CACHE_NAME, Math.floor(MAX_HASH_ENTRIES * DEVICE_FACTOR));
  167. }
  168. }
  169. return resp;
  170. })()
  171. );
  172. });
  173. async function handleRequest(request, url) {
  174. const cachedResponse = await caches.match(request);
  175. const storedOriginalDomain = await safeGetCookie('ORIGINAL_DOMAIN');
  176. const originalDomain = getAllowedBackupDomain(storedOriginalDomain);
  177. if (storedOriginalDomain && !originalDomain) await safeDeleteCookie('ORIGINAL_DOMAIN');
  178. try {
  179. // 如果缓存中有匹配,则继续请求网络,并更新缓存
  180. return await fetchAndUpdateCache(request, originalDomain, url);
  181. } catch (err) {
  182. if (cachedResponse) {
  183. return cachedResponse;
  184. }
  185. try {
  186. return await fetch(request);
  187. } catch (e2) {
  188. return new Response('Service Worker Error', { status: 500 });
  189. }
  190. }
  191. }
  192. async function enforceCacheLimit(cacheName, maxEntries) {
  193. try {
  194. const cache = await caches.open(cacheName);
  195. const keys = await cache.keys();
  196. const over = keys.length - maxEntries;
  197. if (over > 0) {
  198. for (let i = 0; i < over; i++) {
  199. await cache.delete(keys[i]); // 近似 LRU:按插入顺序清理最早的
  200. }
  201. }
  202. } catch (e) {
  203. // ignore
  204. }
  205. }
  206. async function fetchAndUpdateCache(request, ORIGINAL_DOMAIN, url) {
  207. try {
  208. const networkResponse = await fetch(request);
  209. if (ORIGINAL_DOMAIN && ORIGINAL_DOMAIN !== url.origin && url.origin === globalThis.location.origin) {
  210. await safeDeleteCookie('ORIGINAL_DOMAIN');
  211. }
  212. return networkResponse;
  213. } catch (err) {
  214. return await handleBackupDomain(request, ORIGINAL_DOMAIN, url);
  215. }
  216. }
  217. async function handleBackupDomain(request, ORIGINAL_DOMAIN, url) {
  218. if (ORIGINAL_DOMAIN && ORIGINAL_DOMAIN !== url.origin && url.origin === globalThis.location.origin) {
  219. const backupResponse = await judgeActiveDomain(ORIGINAL_DOMAIN, request);
  220. if (backupResponse) return backupResponse;
  221. }
  222. return await fetchFromBackup(request);
  223. }
  224. async function fetchFromBackup(request) {
  225. for (let domain of BACKUP_DOMAINS) {
  226. const response = await judgeActiveDomain(domain, request);
  227. if (response) return response;
  228. }
  229. return new Response('Service Unavailable', { status: 503 });
  230. }
  231. async function judgeActiveDomain(url, originalRequest) {
  232. const backupDomain = getAllowedBackupDomain(url);
  233. if (!backupDomain) return undefined;
  234. try {
  235. const newUrl = originalRequest.url.replace(globalThis.location.origin, backupDomain);
  236. const newRequest = new Request(newUrl, originalRequest);
  237. const response = await fetch(newRequest);
  238. if (response.ok) {
  239. // 成功从备用域名获取资源,更新 activeDomain,将请求的html 存入缓存
  240. await safeSetCookie('ORIGINAL_DOMAIN', backupDomain);
  241. // 刷新界面
  242. return response.text().then(body => {
  243. // 定义路径前缀映射表
  244. const pathReplacements = {
  245. '/js': `${backupDomain}/js`,
  246. '/css': `${backupDomain}/css`,
  247. '/favicon': `${backupDomain}/favicon`,
  248. '/cdn': `${backupDomain}/cdn`,
  249. '/img': `${backupDomain}/img`,
  250. '/manifest': `${backupDomain}/product/manifest`
  251. };
  252. // 动态替换规则
  253. let newBody = body;
  254. for (const [prefix, replacement] of Object.entries(pathReplacements)) {
  255. const regex = new RegExp(`(src|href)="${prefix}`, 'g');
  256. newBody = newBody.replace(regex, `$1="${replacement}`);
  257. }
  258. newBody = newBody.replace(/src="\.\//g, `src="${backupDomain}/`);
  259. newBody = newBody.replace(/href="\.\//g, `href="${backupDomain}/`);
  260. const headers = new Headers(response.headers);
  261. headers.delete('content-length');
  262. headers.delete('content-encoding');
  263. return new Response(newBody, { status: response.status, statusText: response.statusText, headers });
  264. });
  265. } else {
  266. console.log(`Backup domain ${backupDomain} returned status: ${response.status}`);
  267. }
  268. } catch (error) {
  269. console.log(`Backup domain ${backupDomain} failed:`, error);
  270. }
  271. }
  272. globalThis.addEventListener('push', function (event) {
  273. if (event.data) {
  274. var notificationData = event.data.json();
  275. var title = notificationData.title;
  276. var imageSmall = notificationData.imageSmall;
  277. var imageLarge = notificationData.imageLarge;
  278. // 跳转地址
  279. var url = notificationData.url;
  280. var actions = notificationData.actions;
  281. var options = {
  282. body: notificationData.body || notificationData.message,
  283. icon: imageSmall,
  284. image: imageLarge,
  285. badge: imageSmall,
  286. actions: actions,
  287. data: {
  288. url: url
  289. }
  290. };
  291. event.waitUntil(globalThis.registration.showNotification(title, options));
  292. }
  293. });
  294. // 处理点击消息
  295. globalThis.addEventListener('notificationclick', function (event) {
  296. event.notification.close(); // 关闭推送通知
  297. // 获取推送通知的数据
  298. var notificationData = event.notification.data;
  299. // 获取要跳转的页面链接
  300. var targetPage = notificationData?.url; // 假设推送通知的数据中包含了要跳转的页面链接
  301. try {
  302. const targetUrl = new URL(targetPage || '/', globalThis.location.origin);
  303. if (targetUrl.protocol !== 'http:' && targetUrl.protocol !== 'https:') return;
  304. // 执行页面跳转操作
  305. event.waitUntil(globalThis.clients.openWindow(targetUrl.href));
  306. } catch (error) {
  307. console.error('[Service Worker] Invalid notification URL:', error);
  308. }
  309. });