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.
This commit is contained in:
1 parent
51c53de189
commit
b5c29b5335
5 files changed
+463
-3
No files matched your search
@@ -8,6 +8,7 @@ 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.
|
||||
@@ -26,6 +27,8 @@ import { MirrorPolicy } from './network.mjs';
|
||||
* @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`);
|
||||
@@ -40,7 +43,7 @@ export async function cachedJson({ cacheFile, url, refresh = false, offline = fa
|
||||
let text, json;
|
||||
try {
|
||||
log(`[cache] downloading ${src}`);
|
||||
const res = await network.request(src, fetchImpl, {}, timeoutMs);
|
||||
const res = await network.request(src, fetchFn, {}, timeoutMs);
|
||||
if (!res.ok) {
|
||||
await res.body?.cancel();
|
||||
if (res.status === 404 || res.status === 410) {
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
// a 404/410 moves on immediately. Each candidate uses its opt-in prefix proxy
|
||||
// first (when enabled), then the original URL and jsDelivr fallback. The proxy gets one short attempt
|
||||
// per file and shares a run-wide circuit breaker with the index/font fetchers.
|
||||
// - HTTP(S)_PROXY: the default fetch uses it only when this process was started
|
||||
// with NODE_USE_ENV_PROXY=1 (Node >=22.21 or >=24). Otherwise it fails closed
|
||||
// (tools/assets/env-proxy.mjs). Pass fetchImpl to bypass that, as tests do.
|
||||
// - Idempotent: an existing file is kept when its size matches the ledger entry
|
||||
// of a previous download or the expected byte count from research, or (when
|
||||
// neither is known) when it passes format validation. The ledger lives in
|
||||
@@ -18,6 +21,7 @@ import { mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promis
|
||||
import { dirname, join } from 'node:path';
|
||||
import { MirrorPolicy } from './network.mjs';
|
||||
import { validate } from './formats.mjs';
|
||||
import { guardDefaultFetch } from './env-proxy.mjs';
|
||||
|
||||
/**
|
||||
* @typedef {{ rel: string, urls: string[], kind: string, bytes?: number, mutable?: boolean }} Job
|
||||
@@ -60,7 +64,7 @@ export class Downloader {
|
||||
/** @type {Set<string>} the files (paths under root) this run wrote */
|
||||
this.written = new Set();
|
||||
this.log = log;
|
||||
this.fetch = fetchImpl;
|
||||
this.fetch = guardDefaultFetch(fetchImpl);
|
||||
this.network = mirrorPolicy ?? new MirrorPolicy({ source, proxyPrefix, log });
|
||||
this.backoffMs = Math.max(0, Number(backoffMs) || 0);
|
||||
this.ledger = { files: {} };
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
// HTTP(S)_PROXY for the asset downloader, without a new dependency.
|
||||
//
|
||||
// Node's built-in fetch reads HTTP_PROXY / HTTPS_PROXY / NO_PROXY only at process
|
||||
// startup, and only when that startup had NODE_USE_ENV_PROXY=1 or --use-env-proxy.
|
||||
// fetch() honours this on Node >=22.21.0 and >=24.0.0. The CLI flag on the 24
|
||||
// line is >=24.5.0, so the restart sets the variable, which 24.0.0 already honours.
|
||||
// Assigning the variable later does nothing. Node 22.0–22.20 and 23 have no such
|
||||
// switch; engines stay ">=22", and a proxy there is an error rather than a direct
|
||||
// download.
|
||||
//
|
||||
// Nothing else reaches the built-in fetch: `node:undici` is not a public builtin,
|
||||
// and setGlobalDispatcher on the npm `undici` package does not affect it. The
|
||||
// fetch-assets script therefore restarts itself once. Any other caller of the
|
||||
// default fetch is rejected here while a proxy is configured.
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
/** Names Node's env proxy actually reads (both cases). NO_PROXY alone is not a proxy. */
|
||||
export const PROXY_ENV_KEYS = ['HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy'];
|
||||
|
||||
/** Set on the one restart, so a failed hand-off cannot spawn forever. */
|
||||
export const REEXEC_MARKER = 'SP_ASSETS_ENV_PROXY_REEXEC';
|
||||
|
||||
/**
|
||||
* @param {NodeJS.ProcessEnv | Record<string, string | undefined>} env
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function proxyConfigured(env) {
|
||||
return PROXY_ENV_KEYS.some((key) => typeof env[key] === 'string' && env[key].trim() !== '');
|
||||
}
|
||||
|
||||
/**
|
||||
* fetch() routes through HTTP(S)_PROXY when NODE_USE_ENV_PROXY is on at startup.
|
||||
* @param {string} version process.versions.node
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function nodeHonoursEnvProxy(version) {
|
||||
const match = /^v?(\d+)\.(\d+)/.exec(String(version ?? ''));
|
||||
if (!match) return false;
|
||||
const major = Number(match[1]);
|
||||
const minor = Number(match[2]);
|
||||
if (major === 22) return minor >= 21;
|
||||
return major >= 24;
|
||||
}
|
||||
|
||||
/**
|
||||
* True only for a process that was started with the switch. A value written onto
|
||||
* `process.env` afterwards does not count: Node has already decided.
|
||||
* @param {NodeJS.ProcessEnv | Record<string, string | undefined>} env
|
||||
* @param {string[]} [execArgv]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function startedWithEnvProxy(env, execArgv = []) {
|
||||
if (env.NODE_USE_ENV_PROXY === '1') return true;
|
||||
if (execArgv.some((arg) => arg === '--use-env-proxy' || arg === '--use_env_proxy')) return true;
|
||||
const options = env.NODE_OPTIONS;
|
||||
return typeof options === 'string' && /(?:^|\s)--use[-_]env[-_]proxy(?=\s|$)/.test(options);
|
||||
}
|
||||
|
||||
// Captured at load. Reading process.env.NODE_USE_ENV_PROXY at fetch time would
|
||||
// treat a late assignment as if Node had seen it.
|
||||
const STARTED_WITH_ENV_PROXY = startedWithEnvProxy(process.env, process.execArgv);
|
||||
|
||||
/**
|
||||
* @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [env]
|
||||
* @param {string} [version]
|
||||
* @param {boolean} [startedWithFlag] defaults to this process's startup snapshot, not to `env`
|
||||
* @returns {{ action: 'off' | 'already' | 'reexec' | 'unsupported', reason: string }}
|
||||
*/
|
||||
export function envProxyPlan(env = process.env, version = process.versions.node, startedWithFlag = STARTED_WITH_ENV_PROXY) {
|
||||
if (!proxyConfigured(env)) return { action: 'off', reason: '' };
|
||||
if (!nodeHonoursEnvProxy(version)) {
|
||||
return {
|
||||
action: 'unsupported',
|
||||
reason: `HTTP(S)_PROXY is set, but Node ${version} does not route fetch() through it. `
|
||||
+ 'Need Node >=22.21.0 or >=24.0.0, started with NODE_USE_ENV_PROXY=1. Refusing to fetch directly.',
|
||||
};
|
||||
}
|
||||
if (startedWithFlag) return { action: 'already', reason: '' };
|
||||
return {
|
||||
action: 'reexec',
|
||||
reason: 'HTTP(S)_PROXY is set, but this process was not started with NODE_USE_ENV_PROXY=1. '
|
||||
+ 'Node reads that proxy only at startup (Node >=22.21.0 or >=24.0.0). Refusing to fetch directly.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The built-in fetch, or the caller's own implementation unchanged.
|
||||
* When a proxy is configured and this process cannot use it, the default fetch
|
||||
* rejects instead of connecting to the origin.
|
||||
* @param {typeof fetch} [fetchImpl]
|
||||
* @returns {typeof fetch}
|
||||
*/
|
||||
export function guardDefaultFetch(fetchImpl = globalThis.fetch) {
|
||||
if (fetchImpl !== globalThis.fetch) return fetchImpl;
|
||||
return (input, init) => {
|
||||
const plan = envProxyPlan();
|
||||
if (plan.action === 'reexec' || plan.action === 'unsupported') return Promise.reject(new Error(plan.reason));
|
||||
return fetchImpl(input, init);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart the current script once so Node enables env proxy before any fetch.
|
||||
* @param {object} [io]
|
||||
* @param {typeof import('node:child_process').spawn} [io.spawnImpl]
|
||||
* @param {string} [io.execPath]
|
||||
* @param {string[]} [io.execArgv]
|
||||
* @param {string[]} [io.argv]
|
||||
* @param {NodeJS.ProcessEnv} [io.env]
|
||||
* @param {(line: string) => void} [io.error]
|
||||
* @returns {boolean} true when the caller must not continue (restarted, or refused)
|
||||
*/
|
||||
export function restartForEnvProxy({
|
||||
spawnImpl = spawn,
|
||||
execPath = process.execPath,
|
||||
execArgv = process.execArgv,
|
||||
argv = process.argv,
|
||||
env = process.env,
|
||||
error = (line) => console.error(line),
|
||||
} = {}) {
|
||||
const plan = envProxyPlan(env);
|
||||
if (plan.action === 'off' || plan.action === 'already') return false;
|
||||
if (plan.action === 'unsupported') {
|
||||
error(`[assets] ${plan.reason}`);
|
||||
process.exitCode = 1;
|
||||
return true;
|
||||
}
|
||||
if (env[REEXEC_MARKER] === '1') {
|
||||
error('[assets] refused to restart again for HTTP(S)_PROXY');
|
||||
process.exitCode = 1;
|
||||
return true;
|
||||
}
|
||||
error('[assets] HTTP(S)_PROXY is set; restarting once with NODE_USE_ENV_PROXY=1 so fetch() uses it.');
|
||||
const child = spawnImpl(execPath, [...execArgv, ...argv.slice(1)], {
|
||||
env: { ...env, NODE_USE_ENV_PROXY: '1', [REEXEC_MARKER]: '1' },
|
||||
stdio: 'inherit',
|
||||
});
|
||||
child.on('error', (e) => {
|
||||
error(`[assets] could not restart with NODE_USE_ENV_PROXY: ${e.message}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
child.on('close', (code) => { process.exitCode = code ?? 1; });
|
||||
return true;
|
||||
}
|
||||
@@ -18,6 +18,8 @@
|
||||
// Idempotent: existing files with the right size are skipped, so re-running is
|
||||
// cheap. Downloads use ~16 parallel connections, 3 retries per direct source,
|
||||
// a jsDelivr fallback and an opt-in GitHub proxy (one short attempt per URL).
|
||||
// HTTP(S)_PROXY is picked up by restarting once with NODE_USE_ENV_PROXY=1
|
||||
// (Node >=22.21 or >=24). An older Node fails instead of fetching directly.
|
||||
// Spine atlases get `size:` (and `pma: true` for enemies); every skeleton is
|
||||
// parsed to resolve animation roles.
|
||||
//
|
||||
@@ -49,6 +51,7 @@ import { existsSync, realpathSync } from 'node:fs';
|
||||
import { dirname, join, relative, sep } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { Downloader } from './assets/downloader.mjs';
|
||||
import { restartForEnvProxy } from './assets/env-proxy.mjs';
|
||||
import { MirrorPolicy, selectDownloadSource, validateSource } from './assets/network.mjs';
|
||||
import { normalizeProxyPrefix } from './assets/sources.mjs';
|
||||
import { loadIndexes } from './assets/cache.mjs';
|
||||
@@ -463,7 +466,7 @@ async function main() {
|
||||
|
||||
// run only as a script (tests import parseArgs / shrinkGuard)
|
||||
const invoked = (() => { try { return pathToFileURL(realpathSync(process.argv[1] || '')).href; } catch { return null; } })();
|
||||
if (invoked === import.meta.url) {
|
||||
if (invoked === import.meta.url && !restartForEnvProxy()) {
|
||||
main().then((code) => { process.exitCode = code; }, (e) => {
|
||||
console.error(`[assets] FAILED: ${process.env.DEBUG ? e?.stack || e : e?.message || e}`);
|
||||
process.exitCode = 1;
|
||||
|
||||
Reference in new issue
Block a user