Files
MeBox/web/public/artwork-cache-sw.js
2026-09-20 15:16:40 +08:00

196 lines
6.5 KiB
JavaScript
Vendored

const ARTWORK_CACHE_PREFIX = 'mebox-artwork-'
const ARTWORK_CACHE = `${ARTWORK_CACHE_PREFIX}v3`
const MIN_CACHEABLE_ARTWORK_BYTES = 128
const STRIP_QUERY_KEYS = ['token', 'profile_id', 'profile_pin_token']
// 浏览器侧作品缓存的容量上限。
//
// 没有上限时 Cache Storage 会一直增长,浏览器在存储压力下会整体清空该
// origin 的缓存,失效时机完全不可控。条数上限每次写入都检查(只是读一遍
// 键,开销小);字节上限最多每两分钟统计一次,因为需要逐个读取已存响应的
// Content-Length。两者都淘汰到上限的 80%,避免刚清完又立刻触发。
const MAX_CACHE_ENTRIES = 500
const MAX_CACHE_BYTES = 64 * 1024 * 1024
const TRIM_TARGET_RATIO = 0.8
const BYTE_TRIM_INTERVAL_MS = 2 * 60 * 1000
let lastByteTrimAt = 0
let trimInFlight = null
function isArtworkRequest(url) {
if (url.origin !== self.location.origin) return false
if (url.pathname === '/api/img') return true
return url.pathname.startsWith('/api/cloud/play/')
}
function normalizedArtworkRequest(request) {
const url = new URL(request.url)
for (const key of STRIP_QUERY_KEYS) {
url.searchParams.delete(key)
}
return new Request(url.toString(), {
method: 'GET',
headers: {
Accept: request.headers.get('Accept') || 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8',
},
credentials: 'same-origin',
mode: 'same-origin',
redirect: 'follow',
})
}
self.addEventListener('fetch', (event) => {
const request = event.request
if (request.method !== 'GET') return
const url = new URL(request.url)
if (!isArtworkRequest(url)) return
event.respondWith(cacheArtwork(request))
})
self.addEventListener('install', (event) => {
event.waitUntil(self.skipWaiting())
})
self.addEventListener('activate', (event) => {
event.waitUntil(deleteOldArtworkCaches().then(() => self.clients.claim()))
})
async function cacheArtwork(request) {
const cache = await caches.open(ARTWORK_CACHE)
const cacheKey = normalizedArtworkRequest(request)
const cached = await cache.match(cacheKey)
if (cached) return cached
const response = await fetch(request)
const cacheResponse = await cloneCacheableArtworkResponse(response)
if (cacheResponse) {
await cache.put(cacheKey, cacheResponse)
await deleteOldArtworkVariants(cache, cacheKey)
await trimArtworkCache(cache)
}
return response
}
// trimArtworkCache 把缓存裁剪回容量上限之内,按写入顺序淘汰(最旧的先删)。
// 同一时刻只允许一次裁剪在跑。
async function trimArtworkCache(cache) {
if (trimInFlight) return trimInFlight
trimInFlight = runArtworkTrim(cache).catch(() => undefined)
try {
await trimInFlight
} finally {
trimInFlight = null
}
}
async function runArtworkTrim(cache) {
const keys = await cache.keys()
if (keys.length > MAX_CACHE_ENTRIES) {
const keep = Math.floor(MAX_CACHE_ENTRIES * TRIM_TARGET_RATIO)
await deleteOldestArtwork(cache, keys, keys.length - keep)
return
}
const now = Date.now()
if (now - lastByteTrimAt < BYTE_TRIM_INTERVAL_MS) return
lastByteTrimAt = now
const sizes = []
let total = 0
for (const key of keys) {
const response = await cache.match(key)
const size = cacheableResponseSize(response)
sizes.push(size)
total += size
}
if (total <= MAX_CACHE_BYTES) return
const target = Math.floor(MAX_CACHE_BYTES * TRIM_TARGET_RATIO)
let remaining = total
const evicted = []
for (let i = 0; i < keys.length && remaining > target; i += 1) {
remaining -= sizes[i]
evicted.push(keys[i])
}
await Promise.all(evicted.map((key) => cache.delete(key)))
}
// cacheableResponseSize 用 Content-Length 估算体积。MeBox 的图片响应由
// ServeContent 生成,始终带该头;缺失时按 0 计(只是少算,不会误删)。
function cacheableResponseSize(response) {
if (!response) return 0
const size = Number(response.headers.get('Content-Length') || '0')
return Number.isFinite(size) && size > 0 ? size : 0
}
// deleteOldestArtwork 按 cache.keys() 的顺序(写入顺序)删除最旧的若干条。
async function deleteOldestArtwork(cache, keys, count) {
const victims = keys.slice(0, Math.max(0, count))
await Promise.all(victims.map((key) => cache.delete(key)))
}
async function cloneCacheableArtworkResponse(response) {
if (!response.ok) return null
const contentType = response.headers.get('Content-Type') || ''
if (!contentType.toLowerCase().startsWith('image/')) return null
const cacheControl = response.headers.get('Cache-Control') || ''
if (/\bno-store\b/i.test(cacheControl)) return null
const contentLength = Number(response.headers.get('Content-Length') || '0')
if (Number.isFinite(contentLength) && contentLength > 0 && contentLength <= MIN_CACHEABLE_ARTWORK_BYTES) {
return null
}
const buffer = await response.clone().arrayBuffer()
if (buffer.byteLength <= MIN_CACHEABLE_ARTWORK_BYTES) return null
return new Response(buffer, {
status: response.status,
statusText: response.statusText,
headers: new Headers(response.headers),
})
}
async function deleteOldArtworkCaches() {
const names = await caches.keys()
await Promise.all(names.map((name) => {
if (!name.startsWith(ARTWORK_CACHE_PREFIX) || name === ARTWORK_CACHE) return undefined
return caches.delete(name)
}))
}
async function deleteOldArtworkVariants(cache, currentRequest) {
const currentURL = new URL(currentRequest.url)
const currentIdentity = artworkIdentity(currentURL)
if (!currentIdentity) return
const keys = await cache.keys()
await Promise.all(keys.map(async (key) => {
if (key.url === currentRequest.url) return
const keyURL = new URL(key.url)
if (artworkIdentity(keyURL) !== currentIdentity) return
await cache.delete(key)
}))
}
function artworkIdentity(url) {
// 尺寸/质量属于同一源图的不同派生资源,必须参与身份计算;
// 否则 400px 卡片图会删掉 1600px Hero 图的缓存。
const variant = ['maxWidth', 'maxHeight', 'quality']
.map((key) => {
const value = url.searchParams.get(key)
return value ? `${key}=${value}` : ''
})
.filter(Boolean)
.join('&')
const variantSuffix = variant ? `&${variant}` : ''
if (url.pathname === '/api/img') {
return `${url.origin}${url.pathname}?url=${url.searchParams.get('url') || ''}${variantSuffix}`
}
if (url.pathname.startsWith('/api/cloud/play/')) {
return `${url.origin}${url.pathname}?ref=${url.searchParams.get('ref') || ''}${variantSuffix}`
}
return ''
}