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.
306 lines
14 KiB
JavaScript
306 lines
14 KiB
JavaScript
// 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']);
|
|
});
|