Files
weinibuliu b5c29b5335 feedback7 G-TOOLS: asset downloads honour HTTP(S)_PROXY on Node 22.21+ and 24 (PR #289 by @weinibuliu)
Did not apply the patch. It set a global dispatcher on the npm undici package and rewrote the lockfile. Node's built-in fetch does not use that dispatcher, and node:undici is not importable. fetch() reads HTTP_PROXY, HTTPS_PROXY and NO_PROXY only at startup, and only when that startup had NODE_USE_ENV_PROXY=1 (or --use-env-proxy). That is Node >=22.21.0 and >=24.0.0.

tools/fetch-assets.mjs restarts itself once with NODE_USE_ENV_PROXY=1 before downloading. The default fetch in the downloader and the index cache rejects when a proxy is set but this process cannot use it, including Node 22.0–22.20 and 23, instead of fetching directly. No new dependency. Engines stay >=22.

Tests: node --test test/assets-env-proxy.test.js — 8 pass, 0 fail. node --test test/asset-network.test.js test/assets-diy.test.js test/fetch-assets-shrink.test.js test/assets.test.js — 84 pass, 0 fail. Before this commit: npm run -s lint — 0 errors (135 existing warnings); npm run -s typecheck — ok; npm run -s check:imports — exit 0 (3 pre-existing sim-node notes). docs, version and package tests: 46 pass and 1 fail while env-proxy.mjs was still untracked; after git add, node --test test/package.test.js — 10 pass, 0 fail.
2026-10-08 19:07:49 +08:00

115 lines
4.9 KiB
JavaScript

// Cached upstream JSON indexes needed by the asset pipeline:
// .cache/gamedata/excel/audio_data.json (Kengxxiao/ArknightsGameData, zh_CN)
// .cache/gamedata/excel/charword_table.json (same repo: the operators' voice slots and voice assets)
// .cache/ark-models/models_data.json (isHarryh/Ark-Models enemy Spine index)
// Downloaded once when missing (or with --refresh-index), then reused.
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { RAW } from './sources.mjs';
import { MirrorPolicy } from './network.mjs';
import { guardDefaultFetch } from './env-proxy.mjs';
/**
* Read a cached JSON file, downloading it first when missing or unparsable.
* @param {object} o
* @param {string} o.cacheFile absolute cache path
* @param {string} o.url raw GitHub URL
* @param {boolean} [o.refresh] force re-download
* @param {boolean} [o.offline] never download (throw when the cache is missing)
* @param {(m:string)=>void} [o.log]
* @param {'direct'|'mirror'} [o.source]
* @param {string} [o.proxyPrefix]
* @param {typeof fetch} [o.fetchImpl]
* @param {MirrorPolicy} [o.mirrorPolicy] shared invocation-wide mirror circuit breaker
* @param {number} [o.timeoutMs]
* @param {number} [o.backoffMs]
* @returns {Promise<any>} parsed JSON
*/
export async function cachedJson({ cacheFile, url, refresh = false, offline = false, log = console.log, source = 'direct', proxyPrefix, fetchImpl = globalThis.fetch, mirrorPolicy, timeoutMs = 180000, backoffMs = 500 }) {
// Same rule as the file downloader: a configured proxy must not be skipped.
const fetchFn = guardDefaultFetch(fetchImpl);
if (!refresh || offline) {
try { return JSON.parse(await readFile(cacheFile, 'utf8')); } catch (e) {
if (offline) throw new Error(`--offline: cached index ${cacheFile} is missing or corrupt (${e.message}); run once online`);
}
}
const network = mirrorPolicy ?? new MirrorPolicy({ source, proxyPrefix, log });
let lastErr = null;
for (const src of network.urls(url)) {
if (network.skip(src)) continue;
const attempts = network.isProxy(src) ? 1 : 3;
for (let attempt = 1; attempt <= attempts; attempt++) {
let text, json;
try {
log(`[cache] downloading ${src}`);
const res = await network.request(src, fetchFn, {}, timeoutMs);
if (!res.ok) {
await res.body?.cancel();
if (res.status === 404 || res.status === 410) {
network.succeeded(src); // missing index, not a proxy outage
lastErr = new Error(`HTTP ${res.status}`);
break;
}
throw new Error(`HTTP ${res.status}`);
}
text = (await network.readBody(src, res)).toString('utf8');
json = JSON.parse(text);
} catch (e) {
lastErr = e;
network.failed(src);
if (attempt < attempts && backoffMs) await new Promise((r) => setTimeout(r, backoffMs * attempt));
continue;
}
network.succeeded(src);
// A local disk failure is not evidence that the mirror is unhealthy.
await mkdir(dirname(cacheFile), { recursive: true });
await writeFile(cacheFile + '.tmp', text);
await rename(cacheFile + '.tmp', cacheFile);
return json;
}
}
if (lastErr) network.directFailureHint();
throw new Error(`cannot fetch ${url}: ${lastErr?.message}`);
}
/**
* Load audio_data.json (official), charword_table.json (official voice slots) and Ark-Models models_data.json.
* @param {string} root project root
* @param {{refresh?:boolean, offline?:boolean, log?:(m:string)=>void, source?:'direct'|'mirror', proxyPrefix?:string, fetchImpl?:typeof fetch, mirrorPolicy?:MirrorPolicy}} [opts]
* @returns {Promise<{ audioData: any, modelsData: any, charword: any }>}
*/
export async function loadIndexes(root, opts = {}) {
const mirrorPolicy = opts.mirrorPolicy ?? new MirrorPolicy(opts);
const audioData = await cachedJson({
...opts,
mirrorPolicy,
cacheFile: join(root, '.cache', 'gamedata', 'excel', 'audio_data.json'),
url: RAW.gamedata + 'excel/audio_data.json',
refresh: opts.refresh,
offline: opts.offline,
log: opts.log,
});
// 11 MB, and raw.githubusercontent stalls on it often: the jsDelivr mirror cachedJson() falls back to is the
// reliable path (the operator battle voice needs it, plan.mjs indexVoice).
const charword = await cachedJson({
...opts,
mirrorPolicy,
cacheFile: join(root, '.cache', 'gamedata', 'excel', 'charword_table.json'),
url: RAW.gamedata + 'excel/charword_table.json',
refresh: opts.refresh,
offline: opts.offline,
log: opts.log,
});
const modelsData = await cachedJson({
...opts,
mirrorPolicy,
cacheFile: join(root, '.cache', 'ark-models', 'models_data.json'),
url: RAW.arkModels + 'models_data.json',
refresh: opts.refresh,
offline: opts.offline,
log: opts.log,
});
return { audioData, modelsData, charword };
}