Files
agento3/static/sw.js

236 lines
9.1 KiB
JavaScript
Raw Normal View History

/* 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。若使用 WiFi请检查路由器是否正常。</p></li>' +
'<li><p class="step-title"><span class="step-num">2</span><span>尝试切换到移动流量</span></p>' +
'<p class="step-body">部分宽带或校园网可能对镜像域名有限制。请关闭 WiFi使用手机 <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
});