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:
weinibuliu authored and sganggs committed 2026-10-08 19:07:49 +08:00
1 parent 51c53de189
commit b5c29b5335
5 files changed
+463 -3

No files matched your search

+305
View File
@@ -0,0 +1,305 @@
// HTTP(S)_PROXY for the asset downloader. Node's built-in fetch honours it only when
// the process was started with NODE_USE_ENV_PROXY=1 (Node >=22.21 and >=24). The
// npm undici dispatcher does not reach that fetch, so fetch-assets restarts once
// and every other default fetch fails closed.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawn, spawnSync } from 'node:child_process';
import { createServer } from 'node:http';
import { connect as netConnect } from 'node:net';
import { Downloader } from '../tools/assets/downloader.mjs';
import { cachedJson } from '../tools/assets/cache.mjs';
import {
PROXY_ENV_KEYS, REEXEC_MARKER, envProxyPlan, guardDefaultFetch, nodeHonoursEnvProxy,
proxyConfigured, restartForEnvProxy, startedWithEnvProxy,
} from '../tools/assets/env-proxy.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const SCRIPT = join(ROOT, 'tools', 'fetch-assets.mjs');
const PROXY = 'http://127.0.0.1:9';
const STRIP = [...PROXY_ENV_KEYS, 'NO_PROXY', 'no_proxy', 'ALL_PROXY', 'all_proxy', 'NODE_USE_ENV_PROXY', REEXEC_MARKER];
function cleanEnv(extra = {}) {
const env = { ...process.env };
for (const key of STRIP) delete env[key];
return Object.assign(env, extra);
}
test('version gate: fetch() env proxy is Node >=22.21 and >=24, not 23 or early 22', () => {
assert.equal(nodeHonoursEnvProxy('22.20.0'), false);
assert.equal(nodeHonoursEnvProxy('22.21.0'), true);
assert.equal(nodeHonoursEnvProxy('v22.22.0'), true);
assert.equal(nodeHonoursEnvProxy('23.11.0'), false);
assert.equal(nodeHonoursEnvProxy('24.0.0'), true);
assert.equal(nodeHonoursEnvProxy('24.5.0'), true);
assert.equal(nodeHonoursEnvProxy('25.1.0'), true);
assert.equal(nodeHonoursEnvProxy(''), false);
assert.equal(nodeHonoursEnvProxy('nope'), false);
});
test('startup switch: the variable, the flag, and NODE_OPTIONS; a late or lookalike value does not count', () => {
assert.equal(startedWithEnvProxy({ NODE_USE_ENV_PROXY: '1' }, []), true);
assert.equal(startedWithEnvProxy({ NODE_USE_ENV_PROXY: 'true' }, []), false);
assert.equal(startedWithEnvProxy({ NODE_USE_ENV_PROXY: '' }, []), false);
assert.equal(startedWithEnvProxy({}, ['--use-env-proxy']), true);
assert.equal(startedWithEnvProxy({}, ['--use_env_proxy']), true);
assert.equal(startedWithEnvProxy({}, ['--no-warnings']), false);
assert.equal(startedWithEnvProxy({ NODE_OPTIONS: '--no-warnings --use-env-proxy' }, []), true);
assert.equal(startedWithEnvProxy({ NODE_OPTIONS: '--use-env-proxy-extra' }, []), false);
assert.equal(startedWithEnvProxy({}, []), false);
});
test('plan: unset proxy is off; a proxy restarts, is already on, or is refused', () => {
assert.equal(proxyConfigured({}), false);
assert.equal(proxyConfigured({ HTTP_PROXY: ' ', NO_PROXY: 'localhost' }), false);
assert.equal(envProxyPlan({}).action, 'off');
assert.equal(envProxyPlan({ NO_PROXY: '127.0.0.1' }).action, 'off');
assert.equal(envProxyPlan({ HTTP_PROXY: 'http://proxy.example:8080' }, '22.20.0', false).action, 'unsupported');
assert.match(envProxyPlan({ HTTP_PROXY: 'http://proxy.example:8080' }, '23.11.0', true).reason, /Node 23\.11\.0/);
assert.equal(envProxyPlan({ http_proxy: 'http://proxy.example:8080' }, '22.21.0', false).action, 'reexec');
assert.match(envProxyPlan({ HTTPS_PROXY: 'http://proxy.example:8080' }, '24.0.0', false).reason, /Refusing to fetch directly/);
// The flag on the object is not the startup snapshot: the third argument is.
assert.equal(envProxyPlan({ HTTP_PROXY: 'http://proxy.example:8080', NODE_USE_ENV_PROXY: '1' }, '22.22.0', false).action, 'reexec');
assert.equal(envProxyPlan({ HTTP_PROXY: 'http://proxy.example:8080', NODE_USE_ENV_PROXY: '1' }, '24.5.0', true).action, 'already');
assert.equal(envProxyPlan({ HTTPS_PROXY: 'http://proxy.example:8080' }, '25.0.0', true).action, 'already');
});
test('an explicit fetch is returned unchanged', () => {
const fetchImpl = async () => new Response('ok');
assert.equal(guardDefaultFetch(fetchImpl), fetchImpl);
});
test('downloader and index cache fail closed under HTTP_PROXY, and an injected fetch still runs', async (t) => {
// The only test that mutates process.env. Node runs tests in a file concurrently;
// the plan tests above pass their own env object and do not read process.env.
const dir = await mkdtemp(join(tmpdir(), 'sp-env-proxy-'));
t.after(() => rm(dir, { recursive: true, force: true }));
const saved = STRIP.map((key) => [key, process.env[key]]);
for (const key of STRIP) delete process.env[key];
process.env.HTTP_PROXY = 'http://127.0.0.1:9';
process.env.HTTPS_PROXY = process.env.HTTP_PROXY;
try {
const plan = envProxyPlan();
const bare = new Downloader({
root: dir, ledgerPath: join(dir, 'ledger.json'), retries: 1, backoffMs: 0, log() {},
});
if (plan.action !== 'already') {
const refused = await bare.fetchWithRetries('https://example.invalid/a.json', 'json');
assert.match(refused.error, /Refusing to fetch directly/);
await assert.rejects(guardDefaultFetch()('https://example.invalid/a.json'), /Refusing to fetch directly/);
// Set after startup. Node has already decided not to proxy, so this must still refuse.
process.env.NODE_USE_ENV_PROXY = '1';
const late = await bare.fetchWithRetries('https://example.invalid/b.json', 'json');
assert.match(late.error, /not started with NODE_USE_ENV_PROXY=1/);
delete process.env.NODE_USE_ENV_PROXY;
await assert.rejects(
cachedJson({
cacheFile: join(dir, 'missing.json'), url: 'https://example.invalid/index.json',
log() {}, backoffMs: 0, timeoutMs: 1000,
}),
/Refusing to fetch directly/,
);
}
let called = 0;
const injected = new Downloader({
root: dir, ledgerPath: join(dir, 'ledger.json'), retries: 1, backoffMs: 0, log() {},
fetchImpl: async () => { called += 1; return Response.json({ ok: true }); },
});
const got = await injected.fetchWithRetries('https://example.invalid/a.json', 'json');
assert.equal(called, 1);
assert.equal(got.buf.toString(), '{"ok":true}');
assert.deepEqual(await cachedJson({
cacheFile: join(dir, 'ok.json'), url: 'https://example.invalid/index.json', log() {}, backoffMs: 0,
fetchImpl: async () => Response.json({ ok: true }),
}), { ok: true });
} finally {
for (const [key, value] of saved) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
});
test('restart decision: nothing to do, one spawn, or a hard stop — and the marker cannot loop', () => {
const prev = process.exitCode;
const spawned = [];
const logs = [];
const child = new EventEmitter();
const spawnImpl = (...args) => { spawned.push(args); return child; };
try {
assert.equal(restartForEnvProxy({
env: cleanEnv(), spawnImpl, error: (line) => logs.push(line),
}), false);
assert.equal(spawned.length, 0);
const proxyEnv = cleanEnv({ HTTP_PROXY: 'http://proxy.example:8080', KEEP: 'yes' });
const stopped = restartForEnvProxy({
env: proxyEnv,
execPath: process.execPath,
execArgv: ['--no-warnings'],
argv: ['node', SCRIPT, '--help'],
spawnImpl,
error: (line) => logs.push(line),
});
assert.equal(stopped, true);
if (!nodeHonoursEnvProxy(process.versions.node)) {
assert.equal(spawned.length, 0);
assert.match(logs.join('\n'), /does not route fetch/);
return;
}
assert.equal(spawned.length, 1);
const [bin, args, opts] = spawned[0];
assert.equal(bin, process.execPath);
assert.deepEqual(args, ['--no-warnings', SCRIPT, '--help']);
assert.equal(opts.stdio, 'inherit');
assert.equal(opts.env.NODE_USE_ENV_PROXY, '1');
assert.equal(opts.env[REEXEC_MARKER], '1');
assert.equal(opts.env.KEEP, 'yes');
assert.equal(opts.env.HTTP_PROXY, 'http://proxy.example:8080');
assert.match(logs.join('\n'), /restarting once with NODE_USE_ENV_PROXY=1/);
child.emit('close', 0);
spawned.length = 0;
logs.length = 0;
assert.equal(restartForEnvProxy({
env: cleanEnv({ HTTP_PROXY: 'http://proxy.example:8080', [REEXEC_MARKER]: '1' }),
spawnImpl,
error: (line) => logs.push(line),
}), true);
assert.equal(spawned.length, 0);
assert.match(logs.join('\n'), /refused to restart again/);
} finally {
process.exitCode = prev;
}
});
function runHelp(extra) {
return spawnSync(process.execPath, [SCRIPT, '--help'], {
encoding: 'utf8', env: cleanEnv(extra), timeout: 20000,
});
}
test('fetch-assets --help restarts once when HTTP(S)_PROXY is set, and a second restart is refused', () => {
const supported = nodeHonoursEnvProxy(process.versions.node);
const first = runHelp({ HTTP_PROXY: PROXY, HTTPS_PROXY: PROXY });
if (!supported) {
assert.equal(first.status, 1, first.stderr);
assert.match(first.stderr, /does not route fetch/);
assert.equal(first.stdout, '');
return;
}
assert.equal(first.status, 0, first.stderr);
assert.match(first.stdout, /--allow-shrink/);
assert.equal((first.stderr.match(/restarting once with NODE_USE_ENV_PROXY=1/g) || []).length, 1, first.stderr);
const already = runHelp({ HTTP_PROXY: PROXY, NODE_USE_ENV_PROXY: '1' });
assert.equal(already.status, 0, already.stderr);
assert.match(already.stdout, /--allow-shrink/);
assert.doesNotMatch(already.stderr, /restarting once/);
const loop = runHelp({ HTTP_PROXY: PROXY, [REEXEC_MARKER]: '1' });
assert.equal(loop.status, 1, loop.stderr);
assert.match(loop.stderr, /refused to restart again/);
assert.equal(loop.stdout, '');
});
function listen(server) {
return new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', () => resolve(server.address().port));
});
}
test('built-in fetch tunnels through HTTP_PROXY only when NODE_USE_ENV_PROXY was set at startup', { timeout: 30000 }, async (t) => {
if (!nodeHonoursEnvProxy(process.versions.node)) return;
const hits = [];
const sockets = new Set();
const proxy = createServer((req, res) => {
hits.push(`REQ ${req.method}`);
res.writeHead(500);
res.end();
});
proxy.on('connect', (req, client, head) => {
hits.push(`CONNECT ${req.url}`);
const cut = req.url.lastIndexOf(':');
const upstream = netConnect(Number(req.url.slice(cut + 1)), req.url.slice(0, cut), () => {
client.write('HTTP/1.1 200 Connection Established\r\n\r\n');
if (head.length) upstream.write(head);
upstream.pipe(client);
client.pipe(upstream);
});
sockets.add(upstream);
upstream.on('error', () => client.destroy());
client.on('error', () => upstream.destroy());
});
proxy.on('connection', (socket) => {
sockets.add(socket);
socket.on('close', () => sockets.delete(socket));
});
const target = createServer((req, res) => {
hits.push(`TARGET ${req.url}`);
res.end('from-target');
});
target.on('connection', (socket) => {
sockets.add(socket);
socket.on('close', () => sockets.delete(socket));
});
const proxyPort = await listen(proxy);
const targetPort = await listen(target);
t.after(() => {
for (const socket of sockets) socket.destroy();
proxy.close();
target.close();
});
const targetUrl = `http://127.0.0.1:${targetPort}/x`;
const proxyUrl = `http://127.0.0.1:${proxyPort}`;
const snippet = `
const res = await fetch(process.env.TARGET, { signal: AbortSignal.timeout(2500) });
console.log(JSON.stringify({ status: res.status, body: await res.text() }));
`;
// spawn, not spawnSync: a synchronous child would freeze this server, and fetch would time out.
const run = (extra) => new Promise((resolve, reject) => {
hits.length = 0;
const child = spawn(process.execPath, ['--input-type=module', '-e', snippet], {
env: cleanEnv({ TARGET: targetUrl, ...extra }), stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
const timer = setTimeout(() => { child.kill(); reject(new Error(`fetch child hung\n${stderr}`)); }, 8000);
child.stdout.on('data', (chunk) => { stdout += chunk; });
child.stderr.on('data', (chunk) => { stderr += chunk; });
child.on('error', (e) => { clearTimeout(timer); reject(e); });
child.on('close', (code) => {
clearTimeout(timer);
if (code !== 0) { reject(new Error(stderr || `exit ${code}`)); return; }
const body = JSON.parse(stdout.trim().split('\n').pop());
resolve({ body, hits: [...hits] });
});
});
const direct = await run({});
assert.deepEqual(direct.body, { status: 200, body: 'from-target' });
assert.deepEqual(direct.hits, ['TARGET /x']);
const ignored = await run({ HTTP_PROXY: proxyUrl, HTTPS_PROXY: proxyUrl });
assert.deepEqual(ignored.body, { status: 200, body: 'from-target' });
assert.deepEqual(ignored.hits, ['TARGET /x'], 'a proxy set without the startup switch is ignored by fetch()');
const tunneled = await run({ http_proxy: proxyUrl, NODE_USE_ENV_PROXY: '1' });
assert.deepEqual(tunneled.body, { status: 200, body: 'from-target' });
assert.deepEqual(tunneled.hits, [`CONNECT 127.0.0.1:${targetPort}`, 'TARGET /x']);
const both = await run({ HTTP_PROXY: proxyUrl, HTTPS_PROXY: proxyUrl, NODE_USE_ENV_PROXY: '1' });
assert.equal(both.body.body, 'from-target');
assert.deepEqual(both.hits, [`CONNECT 127.0.0.1:${targetPort}`, 'TARGET /x']);
const bypass = await run({ HTTP_PROXY: proxyUrl, HTTPS_PROXY: proxyUrl, NO_PROXY: '127.0.0.1', NODE_USE_ENV_PROXY: '1' });
assert.equal(bypass.body.body, 'from-target');
assert.deepEqual(bypass.hits, ['TARGET /x']);
});
+4 -1
View File
@@ -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) {
+5 -1
View File
@@ -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: {} };
+145
View File
@@ -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;
}
+4 -1
View File
@@ -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;