Files
SnowCatQQ/release-tools/webui-static/sw_template.js
T
2026-11-12 06:29:38 +08:00

338 lines
9.3 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.
/* eslint-disable no-undef */
/**
* NapCat WebUI Service Worker
*
* 路由缓存策略设计:
*
* 【永不缓存 - Network Only】
* - /api/* WebUI API
* - /plugin/:id/api/* 插件 API
* - /files/theme.css 动态主题 CSS
* - /webui/fonts/CustomFont.woff 用户自定义字体
* - WebSocket / SSE 连接
*
* 【强缓存 - Cache First】
* - /webui/assets/* 前端静态资源(带 hash)
* - /webui/fonts/* 内置字体(排除 CustomFont)
* - q1.qlogo.cn QQ 头像
*
* 【网络优先 - Network First】
* - /webui/* (HTML 导航) SPA 页面
* - /plugin/:id/page/* 插件页面
* - /plugin/:id/files/* 插件文件系统静态资源
*
* 【后台更新 - Stale-While-Revalidate】
* - /plugin/:id/mem/* 插件内存静态资源
*/
const CACHE_NAME = 'snowcat-webui-v{{VERSION}}';
// 缓存配置
const CACHE_CONFIG = {
// 静态资源缓存最大条目数
MAX_STATIC_ENTRIES: 200,
// QQ 头像缓存最大条目数
MAX_AVATAR_ENTRIES: 100,
// 插件资源缓存最大条目数
MAX_PLUGIN_ENTRIES: 50,
};
// ============ 路由匹配辅助函数 ============
/**
* 检查是否为永不缓存的请求
*/
function isNeverCache (url, request) {
// WebUI API
if (url.pathname.startsWith('/api/')) return true;
// 插件 API: /plugin/:id/api/*
if (/^\/plugin\/[^/]+\/api(\/|$)/.test(url.pathname)) return true;
// 动态主题 CSS
if (url.pathname === '/files/theme.css' || url.pathname.endsWith('/files/theme.css')) return true;
// 用户自定义字体
if (url.pathname.includes('/webui/fonts/CustomFont.woff')) return true;
// WebSocket 升级请求
if (request.headers.get('Upgrade') === 'websocket') return true;
// SSE 请求
if (request.headers.get('Accept') === 'text/event-stream') return true;
// Socket 相关
if (url.pathname.includes('/socket')) return true;
return false;
}
/**
* 检查是否为 WebUI 静态资源(强缓存)
*/
function isWebUIStaticAsset (url) {
// /webui/assets/* - 前端构建产物(带 hash)
if (url.pathname.startsWith('/webui/assets/')) return true;
// /webui/fonts/* - 内置字体(排除 CustomFont)
if (url.pathname.startsWith('/webui/fonts/') &&
!url.pathname.includes('CustomFont.woff')) return true;
return false;
}
/**
* 检查是否为外部头像(强缓存)
*/
function isQLogoAvatar (url) {
return url.hostname === 'q1.qlogo.cn' || url.hostname === 'q2.qlogo.cn';
}
/**
* 检查是否为插件文件系统静态资源(网络优先)
*/
function isPluginStaticFiles (url) {
// /plugin/:id/files/*
return /^\/plugin\/[^/]+\/files(\/|$)/.test(url.pathname);
}
/**
* 检查是否为插件内存静态资源(Stale-While-Revalidate)
*/
function isPluginMemoryAsset (url) {
// /plugin/:id/mem/*
return /^\/plugin\/[^/]+\/mem(\/|$)/.test(url.pathname);
}
/**
* 检查是否为插件页面(Network First)
*/
function isPluginPage (url) {
// /plugin/:id/page/*
return /^\/plugin\/[^/]+\/page(\/|$)/.test(url.pathname);
}
// ============ 缓存管理函数 ============
/**
* 限制缓存条目数量
*/
async function trimCache (cacheName, maxEntries) {
const cache = await caches.open(cacheName);
const keys = await cache.keys();
if (keys.length > maxEntries) {
// 删除最早的条目
const deleteCount = keys.length - maxEntries;
for (let i = 0; i < deleteCount; i++) {
await cache.delete(keys[i]);
}
console.log(`[SW] Trimmed ${deleteCount} entries from cache`);
}
}
/**
* 按类型获取缓存限制
*/
function getCacheLimitForRequest (url) {
if (isQLogoAvatar(url)) return CACHE_CONFIG.MAX_AVATAR_ENTRIES;
if (isPluginStaticFiles(url) || isPluginMemoryAsset(url)) return CACHE_CONFIG.MAX_PLUGIN_ENTRIES;
return CACHE_CONFIG.MAX_STATIC_ENTRIES;
}
// ============ Service Worker 生命周期 ============
self.addEventListener('install', (event) => {
console.log('[SW] Installing new version:', CACHE_NAME);
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
console.log('[SW] Activating new version:', CACHE_NAME);
event.waitUntil(
(async () => {
// 清理所有旧版本缓存
const cacheNames = await caches.keys();
await Promise.all(
cacheNames.map((cacheName) => {
if (cacheName.startsWith('snowcat-webui-') && cacheName !== CACHE_NAME) {
console.log('[SW] Deleting old cache:', cacheName);
return caches.delete(cacheName);
}
return undefined;
})
);
// 立即接管所有客户端
await self.clients.claim();
})()
);
});
// ============ 请求拦截 ============
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
const request = event.request;
// 1. 永不缓存的请求 - Network Only
if (isNeverCache(url, request)) {
// 不调用 respondWith,让请求直接穿透到网络
return;
}
// 2. WebUI 静态资源 - Cache First
if (isWebUIStaticAsset(url)) {
event.respondWith(cacheFirst(request, url));
return;
}
// 3. QQ 头像 - Cache First(支持 opaque response)
if (isQLogoAvatar(url)) {
event.respondWith(cacheFirstWithOpaque(request, url));
return;
}
// 4. 插件文件系统静态资源 - Network First
if (isPluginStaticFiles(url)) {
event.respondWith(networkFirst(request));
return;
}
// 5. 插件内存静态资源 - Stale-While-Revalidate
if (isPluginMemoryAsset(url)) {
event.respondWith(staleWhileRevalidate(request, url));
return;
}
// 6. 插件页面 - Network First
if (isPluginPage(url)) {
event.respondWith(networkFirst(request));
return;
}
// 7. HTML 导航请求 - Network First
if (request.mode === 'navigate') {
event.respondWith(networkFirst(request));
return;
}
// 8. 其他同源请求 - Network Only(避免意外缓存)
if (url.origin === self.location.origin) {
// 不缓存,直接穿透
}
// 9. 其他外部请求 - Network Only
});
// ============ 缓存策略实现 ============
/**
* Cache First 策略
* 优先从缓存返回,缓存未命中则从网络获取并缓存
*/
async function cacheFirst (request, url) {
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
try {
const networkResponse = await fetch(request);
if (networkResponse && networkResponse.status === 200) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, networkResponse.clone());
// 异步清理缓存
trimCache(CACHE_NAME, getCacheLimitForRequest(url));
}
return networkResponse;
} catch (error) {
console.error('[SW] Cache First fetch failed:', error);
return new Response('Network error', { status: 503 });
}
}
/**
* Cache First 策略(支持 opaque response,用于跨域头像)
*/
async function cacheFirstWithOpaque (request, url) {
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
try {
const networkResponse = await fetch(request);
// opaque response 的 status 是 0,但仍可缓存
const isValidResponse = networkResponse && (
networkResponse.status === 200 ||
networkResponse.type === 'opaque'
);
if (isValidResponse) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, networkResponse.clone());
trimCache(CACHE_NAME, getCacheLimitForRequest(url));
}
return networkResponse;
} catch (error) {
console.error('[SW] Cache First (opaque) fetch failed:', error);
return new Response('Network error', { status: 503 });
}
}
/**
* Network First 策略
* 优先从网络获取,网络失败则返回缓存
*/
async function networkFirst (request) {
try {
const networkResponse = await fetch(request);
if (networkResponse && networkResponse.status === 200) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
console.log('[SW] Network First: network failed, trying cache');
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
return new Response('Offline', { status: 503 });
}
}
/**
* Stale-While-Revalidate 策略
* 立即返回缓存(如果有),同时后台更新缓存
*/
async function staleWhileRevalidate (request, url) {
const cache = await caches.open(CACHE_NAME);
const cachedResponse = await cache.match(request);
// 后台刷新缓存
const fetchPromise = fetch(request).then((networkResponse) => {
if (networkResponse && networkResponse.status === 200) {
cache.put(request, networkResponse.clone());
trimCache(CACHE_NAME, getCacheLimitForRequest(url));
}
return networkResponse;
}).catch((error) => {
console.log('[SW] SWR background fetch failed:', error);
return null;
});
// 如果有缓存,立即返回缓存
if (cachedResponse) {
return cachedResponse;
}
// 没有缓存,等待网络
const networkResponse = await fetchPromise;
if (networkResponse) {
return networkResponse;
}
return new Response('Network error', { status: 503 });
}