feat(宏观): ifinance 版本展示、手动/每日更新与按需文件缓存
Docker Build and Push / build-image (push) Successful in 3m58s
Docker Build and Push / build-image (push) Successful in 3m58s
- 「我」页展示 ifinance 版本/构建时间/构建号/上次检查, 点击手动更新 info.json - 启动及每 30 分钟检查, 超过 24h 自动刷新索引 - 新增 Service Worker: 按 files[].hash 内容寻址, 只缓存打开过的页面 - 未命中时按需下载单个文件并校验 sha256, 索引升级后清理旧缓存 - 缓存页面通过 CSP sandbox 隔离, 支持看板行内脚本且无法访问宿主数据
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
// ifinance 文件缓存 Service Worker
|
||||
// ------------------------------------------------------------
|
||||
// 宿主把 iframe 指向 /__ifinance__/<path>?h=<sha256>&t=<mime>, 由本 SW:
|
||||
// 1. 按 hash 从 Cache Storage 取本地缓存 (内容寻址, 索引更新后自然失效)
|
||||
// 2. 未命中时只拉当前这一个文件 (按需加载, 不预下载整个模块包)
|
||||
// 3. 用 sha256 校验后再写入缓存; 校验失败仍然返回内容, 但不落盘
|
||||
//
|
||||
// 为什么不用 blob: URL 直接渲染: blob: 文档会继承宿主页面的 CSP
|
||||
// (script-src 'self'), 内嵌看板的 <script> 会被拦截, 图表无法渲染。
|
||||
// SW 返回的是普通 http(s) 子文档, 不受宿主 CSP 的脚本限制。
|
||||
//
|
||||
// 文件缓存里以 /__ifinance_file__/<hash> 为 key, 供宿主侧清理旧版本。
|
||||
const PAGE_PREFIX = '/__ifinance__/'
|
||||
const FILE_CACHE_NAME = 'ifinance-files-v1'
|
||||
const FILE_KEY_PREFIX = '/__ifinance_file__/'
|
||||
const REMOTE_BASE = 'https://ifinance.honor3.com'
|
||||
const NETWORK_TIMEOUT = 20000
|
||||
|
||||
self.addEventListener('install', () => {
|
||||
// 新版本 SW 立刻接管, 避免用户必须再打开一次页面
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(self.clients.claim())
|
||||
})
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const url = new URL(event.request.url)
|
||||
if (url.origin !== self.location.origin) return
|
||||
if (!url.pathname.startsWith(PAGE_PREFIX)) return
|
||||
if (event.request.method !== 'GET') return
|
||||
event.respondWith(serve(url))
|
||||
})
|
||||
|
||||
async function serve(url) {
|
||||
const rel = decodeURIComponent(url.pathname.slice(PAGE_PREFIX.length))
|
||||
const hash = (url.searchParams.get('h') || '').toLowerCase()
|
||||
const type = url.searchParams.get('t') || guessType(rel)
|
||||
const keyUrl = self.location.origin + FILE_KEY_PREFIX + (hash || encodeURIComponent(rel))
|
||||
|
||||
// 缓存不可用时 (隐私模式/配额/被策略禁用) 仍然正常联网加载
|
||||
let cache = null
|
||||
if (typeof caches !== 'undefined') {
|
||||
try { cache = await caches.open(FILE_CACHE_NAME) } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
if (cache && hash) {
|
||||
try {
|
||||
const hit = await cache.match(keyUrl)
|
||||
if (hit) return respond(await hit.arrayBuffer(), type, 'hit')
|
||||
} catch { /* 读取失败按未命中处理 */ }
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await fetchRemote(rel)
|
||||
if (cache && hash) {
|
||||
try {
|
||||
const digest = await sha256Hex(payload)
|
||||
if (digest === hash) {
|
||||
await cache.put(keyUrl, new Response(payload, {
|
||||
headers: { 'Content-Type': withCharset(type), 'Cache-Control': 'no-store' },
|
||||
}))
|
||||
} else {
|
||||
console.warn('[ifinance-sw] hash 不匹配, 不写入缓存:', rel, digest, '!=', hash)
|
||||
}
|
||||
} catch (e) {
|
||||
// crypto.subtle 不可用等: 只跳过落盘, 不影响本次展示
|
||||
console.warn('[ifinance-sw] 校验失败, 跳过缓存:', rel, e)
|
||||
}
|
||||
}
|
||||
return respond(payload, type, 'network')
|
||||
} catch (e) {
|
||||
console.warn('[ifinance-sw] 加载失败:', rel, e)
|
||||
return respondText(errorPage(rel, e), 'text/html; charset=utf-8', 502)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRemote(rel) {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), NETWORK_TIMEOUT)
|
||||
try {
|
||||
const res = await fetch(REMOTE_BASE + '/' + rel, {
|
||||
cache: 'no-cache',
|
||||
mode: 'cors',
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status)
|
||||
return await res.arrayBuffer()
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
function respond(buf, type, source) {
|
||||
return new Response(buf, {
|
||||
headers: {
|
||||
'Content-Type': withCharset(type),
|
||||
'Cache-Control': 'no-store',
|
||||
'X-Ifinance-Cache': source,
|
||||
// 同源内容用 CSP sandbox 隔离成不透明源: 行内脚本照常运行 (看板需要),
|
||||
// 但读不到宿主 localStorage/cookie, 也不能访问 parent。
|
||||
'Content-Security-Policy': 'sandbox allow-scripts',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function respondText(text, type, status = 200) {
|
||||
return new Response(text, {
|
||||
status,
|
||||
headers: {
|
||||
'Content-Type': withCharset(type),
|
||||
'Cache-Control': 'no-store',
|
||||
'Content-Security-Policy': 'sandbox allow-scripts',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function withCharset(type) {
|
||||
const t = String(type || 'text/html')
|
||||
return /charset=/i.test(t) ? t : t + '; charset=utf-8'
|
||||
}
|
||||
|
||||
function guessType(rel) {
|
||||
return /\.json$/i.test(rel) ? 'application/json' : 'text/html'
|
||||
}
|
||||
|
||||
async function sha256Hex(buf) {
|
||||
const digest = await crypto.subtle.digest('SHA-256', buf)
|
||||
return Array.from(new Uint8Array(digest))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
}
|
||||
|
||||
function errorPage(rel, err) {
|
||||
const msg = String(err?.message || err || '加载失败')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
return '<!doctype html><html><head><meta charset="utf-8">' +
|
||||
'<meta name="viewport" content="width=device-width,initial-scale=1">' +
|
||||
'<style>body{margin:0;padding:24px;font:14px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;' +
|
||||
'color:#666;background:#f5f5f5}h3{margin:0 0 8px;color:#333;font-size:15px}code{font-size:12px;color:#999;word-break:break-all}</style>' +
|
||||
'</head><body><h3>内容加载失败</h3><div>请检查网络后重试。</div><p><code>' +
|
||||
msg + '<br>' + rel.replace(/&/g, '&').replace(/</g, '<') +
|
||||
'</code></p></body></html>'
|
||||
}
|
||||
Reference in New Issue
Block a user