236 lines
9.1 KiB
JavaScript
236 lines
9.1 KiB
JavaScript
|
|
/* AO3 Mirror Service Worker v5 — Client-side cache + offline fallback + navigation intercept
|
|||
|
|
*
|
|||
|
|
* Deployed at /sw-YYYYMMDD.js (versioned), /sw.js redirects to latest.
|
|||
|
|
* Server injects <script>navigator.serviceWorker.register('/sw.js')</script> into HTML pages.
|
|||
|
|
*/
|
|||
|
|
'use strict';
|
|||
|
|
|
|||
|
|
const MIRROR_DOMAINS = ['agento3.miscs.dev'];
|
|||
|
|
const PRIMARY_DOMAIN = 'agento3.miscs.dev';
|
|||
|
|
|
|||
|
|
// Static asset types to cache aggressively
|
|||
|
|
const STATIC_EXTENSIONS = [
|
|||
|
|
'.css', '.js', '.jpg', '.jpeg', '.png', '.gif', '.ico',
|
|||
|
|
'.woff', '.woff2', '.ttf', '.svg', '.webp', '.json',
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
// Cache names
|
|||
|
|
const STATIC_CACHE = 'ao3-static-v1';
|
|||
|
|
const HTML_CACHE = 'ao3-html-v1';
|
|||
|
|
|
|||
|
|
// Navigation timeout (25s, like go3)
|
|||
|
|
const NAV_FETCH_TIMEOUT_MS = 25000;
|
|||
|
|
|
|||
|
|
// ─── Helpers ──────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
function primaryMirrorHost() {
|
|||
|
|
try {
|
|||
|
|
var scopeHost = new URL(self.registration.scope).hostname;
|
|||
|
|
if (scopeHost && scopeHost.indexOf('.') !== -1) return scopeHost;
|
|||
|
|
} catch (e) {}
|
|||
|
|
for (var i = 0; i < MIRROR_DOMAINS.length; i++) {
|
|||
|
|
var h = MIRROR_DOMAINS[i];
|
|||
|
|
if (h && h.indexOf('.') !== -1) return h;
|
|||
|
|
}
|
|||
|
|
return PRIMARY_DOMAIN;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function isStaticAsset(url) {
|
|||
|
|
var path = url.pathname.toLowerCase();
|
|||
|
|
for (var i = 0; i < STATIC_EXTENSIONS.length; i++) {
|
|||
|
|
if (path.endsWith(STATIC_EXTENSIONS[i])) return true;
|
|||
|
|
}
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function hostFromRequest(request) {
|
|||
|
|
try {
|
|||
|
|
return new URL(request.url).hostname || '';
|
|||
|
|
} catch (e) {
|
|||
|
|
return '';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function repairNavigationURL(url) {
|
|||
|
|
var host = url.hostname;
|
|||
|
|
if (!host || host.indexOf('.') !== -1 || host === 'localhost') return url;
|
|||
|
|
var mirror = primaryMirrorHost();
|
|||
|
|
if (!mirror) return url;
|
|||
|
|
var fixed = new URL(url.toString());
|
|||
|
|
fixed.hostname = mirror;
|
|||
|
|
fixed.pathname = '/' + host + (fixed.pathname || '/');
|
|||
|
|
return fixed;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─── Offline / mirror picker page ─────────────────────────────────────
|
|||
|
|
|
|||
|
|
function buildMirrorPickerPage(currentHost, mirrors) {
|
|||
|
|
var mirrorItems = mirrors
|
|||
|
|
.filter(function (h) { return h; })
|
|||
|
|
.map(function (h) {
|
|||
|
|
return '<li><a href="https://' + h + '/' + '">' + h + '</a></li>';
|
|||
|
|
})
|
|||
|
|
.join('');
|
|||
|
|
return (
|
|||
|
|
'<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>无法访问镜像站点</title>' +
|
|||
|
|
'<style>' +
|
|||
|
|
'body{margin:0;background:#f6f6f6;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#222}' +
|
|||
|
|
'.wrap{max-width:680px;margin:40px auto;padding:0 16px}' +
|
|||
|
|
'.card{background:#fff;border:1px solid #e5e5e5;border-radius:12px;padding:24px 28px;box-shadow:0 6px 20px rgba(0,0,0,.05)}' +
|
|||
|
|
'h1{margin:0 0 8px;color:#860000;font-size:22px}' +
|
|||
|
|
'.lead{color:#555;line-height:1.6;margin:0 0 20px}' +
|
|||
|
|
'.steps{margin:0;padding:0;list-style:none}' +
|
|||
|
|
'.steps>li{margin:0 0 20px;padding:0 0 20px;border-bottom:1px solid #eee}' +
|
|||
|
|
'.steps>li:last-child{margin-bottom:0;padding-bottom:0;border-bottom:none}' +
|
|||
|
|
'.step-title{display:flex;align-items:flex-start;gap:10px;font-weight:600;color:#333;margin:0 0 8px;line-height:1.5}' +
|
|||
|
|
'.step-num{flex-shrink:0;width:26px;height:26px;border-radius:50%;background:#860000;color:#fff;font-size:14px;line-height:26px;text-align:center}' +
|
|||
|
|
'.step-body{color:#555;line-height:1.65;margin:0;font-size:15px}' +
|
|||
|
|
'.mirrors{margin:8px 0 0;padding-left:20px}' +
|
|||
|
|
'.mirrors li{margin:8px 0}' +
|
|||
|
|
'a{color:#900;text-decoration:none;font-weight:500}a:hover{text-decoration:underline}' +
|
|||
|
|
'code{background:#f3f3f3;padding:2px 6px;border-radius:4px;font-size:.92em}' +
|
|||
|
|
'.muted{color:#888;font-size:13px;line-height:1.6;margin-top:20px;padding-top:16px;border-top:1px solid #eee}' +
|
|||
|
|
'</style></head><body><div class="wrap"><div class="card">' +
|
|||
|
|
'<h1>无法连接到镜像站点</h1>' +
|
|||
|
|
'<p class="lead">浏览器未能与 <code>' + currentHost + '</code> 建立网络连接(例如断网、DNS 失败或被防火墙拦截)。请按下面顺序逐步排查。</p>' +
|
|||
|
|
'<ol class="steps">' +
|
|||
|
|
'<li><p class="step-title"><span class="step-num">1</span><span>先检查网络连接</span></p>' +
|
|||
|
|
'<p class="step-body">确认设备已联网:可尝试打开其他网站或 App。若使用 Wi‑Fi,请检查路由器是否正常。</p></li>' +
|
|||
|
|
'<li><p class="step-title"><span class="step-num">2</span><span>尝试切换到移动流量</span></p>' +
|
|||
|
|
'<p class="step-body">部分宽带或校园网可能对镜像域名有限制。请关闭 Wi‑Fi,使用手机 <strong>4G / 5G 流量</strong> 重新访问。</p></li>' +
|
|||
|
|
'<li><p class="step-title"><span class="step-num">3</span><span>尝试备用域名</span></p>' +
|
|||
|
|
'<p class="step-body">点击下方备用镜像站点:</p>' +
|
|||
|
|
'<ul class="mirrors">' + mirrorItems + '</ul></li>' +
|
|||
|
|
'</ol>' +
|
|||
|
|
'<p class="muted">页面由 AO3 Mirror Service Worker 提供。若以上步骤后仍无法打开,请稍后再试。</p>' +
|
|||
|
|
'</div></div></body></html>'
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function offlineGuideResponse(currentHost) {
|
|||
|
|
return new Response(buildMirrorPickerPage(currentHost, MIRROR_DOMAINS), {
|
|||
|
|
status: 200,
|
|||
|
|
headers: {
|
|||
|
|
'Content-Type': 'text/html; charset=utf-8',
|
|||
|
|
'Cache-Control': 'no-store',
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─── Network strategies ───────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
function handleNavigation(request) {
|
|||
|
|
var currentHost = hostFromRequest(request);
|
|||
|
|
var url = new URL(request.url);
|
|||
|
|
|
|||
|
|
// Repair malformed navigation URLs
|
|||
|
|
var navUrl = repairNavigationURL(url);
|
|||
|
|
|
|||
|
|
var fetchInit = {};
|
|||
|
|
if (navUrl.href !== request.url) {
|
|||
|
|
fetchInit = {
|
|||
|
|
method: request.method,
|
|||
|
|
headers: request.headers,
|
|||
|
|
credentials: request.credentials,
|
|||
|
|
redirect: 'follow',
|
|||
|
|
referrer: request.referrer,
|
|||
|
|
referrerPolicy: request.referrerPolicy,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var ctrl = new AbortController();
|
|||
|
|
var timer = setTimeout(function () { ctrl.abort(); }, NAV_FETCH_TIMEOUT_MS);
|
|||
|
|
|
|||
|
|
var fetchTarget = navUrl.href !== request.url ? new Request(navUrl.href, fetchInit) : request;
|
|||
|
|
|
|||
|
|
return fetch(fetchTarget, { signal: ctrl.signal })
|
|||
|
|
.finally(function () { clearTimeout(timer); })
|
|||
|
|
.then(function (response) {
|
|||
|
|
if (response.redirected && response.url) {
|
|||
|
|
return Response.redirect(response.url, 302);
|
|||
|
|
}
|
|||
|
|
return response;
|
|||
|
|
})
|
|||
|
|
.catch(function () {
|
|||
|
|
return offlineGuideResponse(currentHost);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function handleStatic(request) {
|
|||
|
|
// Cache-first for static assets
|
|||
|
|
return caches.open(STATIC_CACHE).then(function (cache) {
|
|||
|
|
return cache.match(request).then(function (cached) {
|
|||
|
|
if (cached) {
|
|||
|
|
// Background revalidation
|
|||
|
|
fetch(request).then(function (response) {
|
|||
|
|
if (response && response.ok) {
|
|||
|
|
cache.put(request, response);
|
|||
|
|
}
|
|||
|
|
}).catch(function () {});
|
|||
|
|
return cached;
|
|||
|
|
}
|
|||
|
|
// Network with cache fallback
|
|||
|
|
return fetch(request).then(function (response) {
|
|||
|
|
if (response && response.ok) {
|
|||
|
|
var cloned = response.clone();
|
|||
|
|
cache.put(request, cloned);
|
|||
|
|
}
|
|||
|
|
return response;
|
|||
|
|
}).catch(function () {
|
|||
|
|
// Offline — return whatever we have
|
|||
|
|
return cache.match(request);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─── Install / Activate ───────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
self.addEventListener('install', function (event) {
|
|||
|
|
self.skipWaiting();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
self.addEventListener('activate', function (event) {
|
|||
|
|
event.waitUntil(self.clients.claim());
|
|||
|
|
// Clean old caches
|
|||
|
|
event.waitUntil(
|
|||
|
|
caches.keys().then(function (keys) {
|
|||
|
|
return Promise.all(
|
|||
|
|
keys.map(function (key) {
|
|||
|
|
if (key !== STATIC_CACHE && key !== HTML_CACHE) {
|
|||
|
|
return caches.delete(key);
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
);
|
|||
|
|
})
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// ─── Fetch handler ────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
self.addEventListener('fetch', function (event) {
|
|||
|
|
if (event.request.method !== 'GET') return;
|
|||
|
|
var url = new URL(event.request.url);
|
|||
|
|
|
|||
|
|
// Don't intercept SW or monitor paths
|
|||
|
|
if (url.pathname.startsWith('/sw') || url.pathname === '/mirror-domains.json') return;
|
|||
|
|
if (url.pathname === '/_monitor' || url.pathname.startsWith('/_monitor/')) return;
|
|||
|
|
if (url.pathname === '/stats' || url.pathname === '/metrics' || url.pathname === '/health') return;
|
|||
|
|
|
|||
|
|
// Static assets: cache-first
|
|||
|
|
if (isStaticAsset(url)) {
|
|||
|
|
event.respondWith(handleStatic(event.request));
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Navigation (HTML pages): network-first with offline fallback
|
|||
|
|
if (event.request.mode === 'navigate' || event.request.destination === 'document') {
|
|||
|
|
event.respondWith(
|
|||
|
|
handleNavigation(event.request).catch(function () {
|
|||
|
|
return offlineGuideResponse(hostFromRequest(event.request));
|
|||
|
|
})
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
// Other requests pass through to server normally
|
|||
|
|
});
|