From b5c29b53354122026689f1ef366ee765e72e93be Mon Sep 17 00:00:00 2001 From: weinibuliu Date: Thu, 8 Oct 2026 19:07:49 +0800 Subject: [PATCH] feedback7 G-TOOLS: asset downloads honour HTTP(S)_PROXY on Node 22.21+ and 24 (PR #289 by @weinibuliu) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- test/assets-env-proxy.test.js | 305 ++++++++++++++++++++++++++++++++++ tools/assets/cache.mjs | 5 +- tools/assets/downloader.mjs | 6 +- tools/assets/env-proxy.mjs | 145 ++++++++++++++++ tools/fetch-assets.mjs | 5 +- 5 files changed, 463 insertions(+), 3 deletions(-) create mode 100644 test/assets-env-proxy.test.js create mode 100644 tools/assets/env-proxy.mjs diff --git a/test/assets-env-proxy.test.js b/test/assets-env-proxy.test.js new file mode 100644 index 00000000..93a3c38d --- /dev/null +++ b/test/assets-env-proxy.test.js @@ -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']); +}); diff --git a/tools/assets/cache.mjs b/tools/assets/cache.mjs index db8fc325..3ad6f5f0 100644 --- a/tools/assets/cache.mjs +++ b/tools/assets/cache.mjs @@ -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} 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) { diff --git a/tools/assets/downloader.mjs b/tools/assets/downloader.mjs index 510821b4..375511e1 100644 --- a/tools/assets/downloader.mjs +++ b/tools/assets/downloader.mjs @@ -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} 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: {} }; diff --git a/tools/assets/env-proxy.mjs b/tools/assets/env-proxy.mjs new file mode 100644 index 00000000..c46b162b --- /dev/null +++ b/tools/assets/env-proxy.mjs @@ -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} 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} 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} [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; +} diff --git a/tools/fetch-assets.mjs b/tools/fetch-assets.mjs index 4624cba9..3c07d85c 100644 --- a/tools/fetch-assets.mjs +++ b/tools/fetch-assets.mjs @@ -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;