service-work.js 10 KB

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