Files
2026-10-06 14:18:30 +08:00

116 lines
4.6 KiB
JavaScript

// Explicit download-source selection and one shared policy per asset invocation.
// Selecting a source never looks up the user's public IP or makes a request.
import { downloadUrls, normalizeProxyPrefix } from './sources.mjs';
export function validateSource(mode) {
if (!['direct', 'mirror'].includes(mode)) throw new Error(`unknown asset source: ${mode} (direct|mirror)`);
return mode;
}
export async function selectDownloadSource({ mode = 'direct', offline = false, proxyPrefix, log = console.log } = {}) {
validateSource(mode);
if (offline) return 'direct';
const normalizedProxy = mode === 'mirror' ? normalizeProxyPrefix(proxyPrefix) : '';
log(mode === 'mirror'
? `[network] 手动开启 GitHub 镜像:${normalizedProxy || '代理已禁用'};文件仅校验格式和大小。`
: '[network] 原始源(未开启 GitHub 镜像)');
return mode;
}
/** Shared by indexes, files, Spine follow-ups and fonts; a tripped mirror stays off. */
export class MirrorPolicy {
constructor({ source = 'direct', proxyPrefix, failureLimit = 3, timeoutMs = 8000, log = console.log } = {}) {
this.source = validateSource(source);
this.proxyPrefix = this.source === 'mirror' ? normalizeProxyPrefix(proxyPrefix) : '';
this.failureLimit = Math.max(1, Number(failureLimit) || 3);
this.timeoutMs = Math.max(1, Number(timeoutMs) || 8000);
this.log = log;
this.failures = 0;
this.disabled = false;
this.hintShown = false;
this.abortMirror = new AbortController();
}
urls(url) {
return downloadUrls(url, { source: this.disabled ? 'direct' : this.source, proxyPrefix: this.proxyPrefix });
}
isProxy(url) {
return this.source === 'mirror' && !!this.proxyPrefix && url.startsWith(this.proxyPrefix);
}
skip(url) { return this.isProxy(url) && this.disabled; }
async request(url, fetchImpl, options = {}, timeoutMs = 120000) {
if (!this.isProxy(url)) {
return fetchImpl(url, { ...options, signal: options.signal ?? AbortSignal.timeout(timeoutMs) });
}
const headerDeadline = new AbortController();
const signal = AbortSignal.any([headerDeadline.signal, this.abortMirror.signal]);
const timer = setTimeout(() => headerDeadline.abort(new Error('GitHub mirror response headers timed out')), Math.min(timeoutMs, this.timeoutMs));
try {
return await fetchImpl(url, { ...options, signal });
} finally {
clearTimeout(timer);
}
}
async readBody(url, response) {
if (!this.isProxy(url)) return Buffer.from(await response.arrayBuffer());
if (!response.body) return Buffer.alloc(0);
const reader = response.body.getReader();
const chunks = [];
let size = 0;
try {
while (true) {
const { value, done } = await readChunk(reader, this.timeoutMs, this.abortMirror.signal);
if (done) break;
const chunk = Buffer.from(value);
chunks.push(chunk);
size += chunk.length;
}
} catch (error) {
try { await reader.cancel(error); } catch { /* ignore */ }
throw error;
} finally {
reader.releaseLock();
}
return Buffer.concat(chunks, size);
}
succeeded(url) {
// A success from a request already in flight must not reopen a tripped circuit.
if (this.isProxy(url) && !this.disabled) this.failures = 0;
}
failed(url) {
if (this.isProxy(url)) {
if (this.disabled || ++this.failures < this.failureLimit) return;
this.disabled = true;
this.log(`[network] GitHub 镜像连续失败 ${this.failures} 次:本次运行停用镜像,继续使用原始源 / jsDelivr。`);
this.abortMirror.abort(new Error('GitHub mirror disabled for this run'));
}
}
directFailureHint() {
if (this.source !== 'direct' || this.hintShown) return;
this.hintShown = true;
this.log('[network] GitHub 下载失败;如需使用第三方镜像,可重新运行并加 --asset-source=mirror(或 SP_ASSET_SOURCE=mirror)。');
}
}
function readChunk(reader, timeoutMs, signal) {
return new Promise((resolve, reject) => {
let timer;
const cleanup = () => {
clearTimeout(timer);
signal?.removeEventListener('abort', onAbort);
};
const onAbort = () => { cleanup(); reject(signal.reason ?? new Error('GitHub mirror request aborted')); };
if (signal?.aborted) return onAbort();
timer = setTimeout(() => { cleanup(); reject(new Error(`GitHub mirror response body idle for ${timeoutMs} ms`)); }, timeoutMs);
signal?.addEventListener('abort', onAbort, { once: true });
reader.read().then((value) => { cleanup(); resolve(value); }, (error) => { cleanup(); reject(error); });
});
}