From 8dd155cdf53dd8b148f2fda88a61af8ec6500166 Mon Sep 17 00:00:00 2001 From: Samuel Attard Date: Sun, 19 Apr 2026 03:10:55 -0700 Subject: [PATCH 1/4] feat: replace make-fetch-happen with built-in fetch Use Node's built-in fetch for downloading headers/tarballs, with undici's EnvHttpProxyAgent providing --proxy, --noproxy and --cafile support (plus http_proxy/https_proxy/no_proxy env var handling). Drops 36 transitive dependencies. --- lib/download.js | 56 ++++++++++++++++++++++++++++++++++++------- package.json | 2 +- test/test-download.js | 28 +++++++++++++++------- 3 files changed, 69 insertions(+), 17 deletions(-) diff --git a/lib/download.js b/lib/download.js index ed0aa37f44..e9cc2d7d75 100644 --- a/lib/download.js +++ b/lib/download.js @@ -1,4 +1,5 @@ -const fetch = require('make-fetch-happen') +const { Readable } = require('stream') +const { EnvHttpProxyAgent } = require('undici') const { promises: fs } = require('graceful-fs') const log = require('./log') @@ -10,19 +11,58 @@ async function download (gyp, url) { 'User-Agent': `node-gyp v${gyp.version} (node ${process.version})`, Connection: 'keep-alive' }, - proxy: gyp.opts.proxy, - noProxy: gyp.opts.noproxy + dispatcher: await createDispatcher(gyp) } - const cafile = gyp.opts.cafile - if (cafile) { - requestOpts.ca = await readCAFile(cafile) + let res + try { + res = await fetch(url, requestOpts) + } catch (err) { + // Built-in fetch wraps low-level errors in "TypeError: fetch failed" with + // the underlying error on .cause. Callers inspect .code (e.g. ENOTFOUND). + if (err.cause) { + throw err.cause + } + throw err } - const res = await fetch(url, requestOpts) log.http(res.status, res.url) - return res + const body = Readable.fromWeb(res.body) + return { + status: res.status, + url: res.url, + body, + text: async () => { + let data = '' + body.setEncoding('utf8') + for await (const chunk of body) { + data += chunk + } + return data + } + } +} + +async function createDispatcher (gyp) { + const env = process.env + const hasProxyEnv = env.http_proxy || env.HTTP_PROXY || env.https_proxy || env.HTTPS_PROXY + if (!gyp.opts.proxy && !gyp.opts.cafile && !hasProxyEnv) { + return undefined + } + + const opts = {} + if (gyp.opts.cafile) { + opts.connect = { ca: await readCAFile(gyp.opts.cafile) } + } + if (gyp.opts.proxy) { + opts.httpProxy = gyp.opts.proxy + opts.httpsProxy = gyp.opts.proxy + } + if (gyp.opts.noproxy) { + opts.noProxy = gyp.opts.noproxy + } + return new EnvHttpProxyAgent(opts) } async function readCAFile (filename) { diff --git a/package.json b/package.json index 71e0790043..92807b96aa 100644 --- a/package.json +++ b/package.json @@ -25,12 +25,12 @@ "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", - "make-fetch-happen": "^15.0.0", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", + "undici": "^6.25.0", "which": "^6.0.0" }, "engines": { diff --git a/test/test-download.js b/test/test-download.js index a746c98cc6..f068c46b00 100644 --- a/test/test-download.js +++ b/test/test-download.js @@ -6,6 +6,7 @@ const fs = require('fs/promises') const path = require('path') const http = require('http') const https = require('https') +const net = require('net') const install = require('../lib/install') const { download, readCAFile } = require('../lib/download') const { FULL_TEST, devDir, platformTimeout } = require('./common') @@ -69,13 +70,22 @@ describe('download', function () { }) it('download over http with proxy', async function () { - const server = http.createServer((_, res) => { + const server = http.createServer((req, res) => { + assert.strictEqual(req.headers['user-agent'], `node-gyp v42 (node ${process.version})`) res.end('ok') }) - const pserver = http.createServer((req, res) => { - assert.strictEqual(req.headers['user-agent'], `node-gyp v42 (node ${process.version})`) - res.end('proxy ok') + let proxyUsed = false + const pserver = http.createServer() + pserver.on('connect', (req, clientSocket, head) => { + proxyUsed = true + const [targetHost, targetPort] = req.url.split(':') + const serverSocket = net.connect(targetPort, targetHost, () => { + clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n') + serverSocket.write(head) + serverSocket.pipe(clientSocket) + clientSocket.pipe(serverSocket) + }) }) after(() => Promise.all([ @@ -96,7 +106,8 @@ describe('download', function () { } const url = `http://${host}:${port}` const res = await download(gyp, url) - assert.strictEqual(await res.text(), 'proxy ok') + assert.strictEqual(await res.text(), 'ok') + assert.strictEqual(proxyUsed, true) }) it('download over http with noproxy', async function () { @@ -105,9 +116,9 @@ describe('download', function () { res.end('ok') }) - const pserver = http.createServer((_, res) => { - res.end('proxy ok') - }) + let proxyUsed = false + const pserver = http.createServer() + pserver.on('connect', () => { proxyUsed = true }) after(() => Promise.all([ new Promise((resolve) => server.close(resolve)), @@ -128,6 +139,7 @@ describe('download', function () { const url = `http://${host}:${port}` const res = await download(gyp, url) assert.strictEqual(await res.text(), 'ok') + assert.strictEqual(proxyUsed, false) }) it('download with missing cafile', async function () { From f86045eeec121f08bd15de1783d88e0d31e573fc Mon Sep 17 00:00:00 2001 From: Samuel Attard Date: Sun, 19 Apr 2026 12:43:46 -0700 Subject: [PATCH 2/4] fixup: address review feedback - Guard Readable.fromWeb against null body (204/304 responses) - Add socket error handlers to CONNECT tunnel in proxy test - Destroy socket in noproxy test's CONNECT handler so a regression fails fast instead of hanging --- lib/download.js | 2 +- test/test-download.js | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/lib/download.js b/lib/download.js index e9cc2d7d75..2e53d42628 100644 --- a/lib/download.js +++ b/lib/download.js @@ -28,7 +28,7 @@ async function download (gyp, url) { log.http(res.status, res.url) - const body = Readable.fromWeb(res.body) + const body = res.body ? Readable.fromWeb(res.body) : Readable.from([]) return { status: res.status, url: res.url, diff --git a/test/test-download.js b/test/test-download.js index f068c46b00..65be6f1930 100644 --- a/test/test-download.js +++ b/test/test-download.js @@ -86,6 +86,8 @@ describe('download', function () { serverSocket.pipe(clientSocket) clientSocket.pipe(serverSocket) }) + clientSocket.on('error', () => serverSocket.destroy()) + serverSocket.on('error', () => clientSocket.destroy()) }) after(() => Promise.all([ @@ -118,7 +120,10 @@ describe('download', function () { let proxyUsed = false const pserver = http.createServer() - pserver.on('connect', () => { proxyUsed = true }) + pserver.on('connect', (_, socket) => { + proxyUsed = true + socket.destroy() + }) after(() => Promise.all([ new Promise((resolve) => server.close(resolve)), From ae72094c8c0c9a5b5d2ecbec7ff34c1f2cbecfba Mon Sep 17 00:00:00 2001 From: Samuel Attard Date: Sun, 19 Apr 2026 13:17:42 -0700 Subject: [PATCH 3/4] ci: fix Python lint and npm install in tests workflow Apply f-string fix from gyp-next#337 to resolve ruff F507 in simple_copy.py, and add npm@~11.10.0 pre-install step from #3300 to work around npm/cli#9151. --- .github/workflows/tests.yml | 4 +++- gyp/pylib/gyp/simple_copy.py | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d530aed20d..8d679a3157 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -67,7 +67,9 @@ jobs: with: node-version: 22.x - name: Update npm - run: npm install npm@latest -g + run: | + npm install npm@~11.10.0 -g # Workaround for https://github.com/npm/cli/issues/9151 + npm install npm@latest -g - name: Install Dependencies run: npm install - name: Pack diff --git a/gyp/pylib/gyp/simple_copy.py b/gyp/pylib/gyp/simple_copy.py index 8b026642fc..2b9100f3e1 100644 --- a/gyp/pylib/gyp/simple_copy.py +++ b/gyp/pylib/gyp/simple_copy.py @@ -24,8 +24,8 @@ def deepcopy(x): return _deepcopy_dispatch[type(x)](x) except KeyError: raise Error( - "Unsupported type %s for deepcopy. Use copy.deepcopy " - + "or expand simple_copy support." % type(x) + f"Unsupported type {type(x)} for deepcopy. Use copy.deepcopy " + + "or expand simple_copy support." ) From 8d78f6d2501144f27e3f34deb556e26c63ad9369 Mon Sep 17 00:00:00 2001 From: Samuel Attard Date: Mon, 20 Apr 2026 16:59:05 -0700 Subject: [PATCH 4/4] fix: apply cafile to proxied TLS connections EnvHttpProxyAgent forwards opts to an internal ProxyAgent for proxied requests, which reads origin TLS config from requestTls rather than connect. Set connect/requestTls/proxyTls so the custom CA is honored on both direct and proxied paths. Adds a test covering https origin behind an HTTP CONNECT proxy with a custom CA. --- lib/download.js | 9 +++++++- test/test-download.js | 48 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/lib/download.js b/lib/download.js index 2e53d42628..a9866d8a63 100644 --- a/lib/download.js +++ b/lib/download.js @@ -53,7 +53,14 @@ async function createDispatcher (gyp) { const opts = {} if (gyp.opts.cafile) { - opts.connect = { ca: await readCAFile(gyp.opts.cafile) } + const ca = await readCAFile(gyp.opts.cafile) + // EnvHttpProxyAgent forwards opts to both its internal Agent (direct) and + // ProxyAgent (proxied). Agent reads TLS config from `connect`; ProxyAgent + // reads it from `requestTls` (origin) / `proxyTls` (proxy). Set all three + // so the custom CA is applied regardless of which path a request takes. + opts.connect = { ca } + opts.requestTls = { ca } + opts.proxyTls = { ca } } if (gyp.opts.proxy) { opts.httpProxy = gyp.opts.proxy diff --git a/test/test-download.js b/test/test-download.js index 65be6f1930..4078efe5cd 100644 --- a/test/test-download.js +++ b/test/test-download.js @@ -112,6 +112,54 @@ describe('download', function () { assert.strictEqual(proxyUsed, true) }) + it('download over https with proxy and custom ca', async function () { + const cafile = path.join(__dirname, 'fixtures/ca-proxy.crt') + await fs.writeFile(cafile, certs['ca.crt'], 'utf8') + + const server = https.createServer({ + ca: await readCAFile(cafile), + cert: certs['server.crt'], + key: certs['server.key'] + }, (_, res) => res.end('ok')) + + let proxyUsed = false + const pserver = http.createServer() + pserver.on('connect', (req, clientSocket, head) => { + proxyUsed = true + const [targetHost, targetPort] = req.url.split(':') + const serverSocket = net.connect(targetPort, targetHost, () => { + clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n') + serverSocket.write(head) + serverSocket.pipe(clientSocket) + clientSocket.pipe(serverSocket) + }) + clientSocket.on('error', () => serverSocket.destroy()) + serverSocket.on('error', () => clientSocket.destroy()) + }) + + after(async () => { + await new Promise((resolve) => server.close(resolve)) + await new Promise((resolve) => pserver.close(resolve)) + await fs.unlink(cafile) + }) + + const host = 'localhost' + await new Promise((resolve) => server.listen(0, host, resolve)) + const { port } = server.address() + await new Promise((resolve) => pserver.listen(port + 1, host, resolve)) + const gyp = { + opts: { + cafile, + proxy: `http://${host}:${port + 1}`, + noproxy: 'bad' + }, + version: '42' + } + const res = await download(gyp, `https://${host}:${port}`) + assert.strictEqual(await res.text(), 'ok') + assert.strictEqual(proxyUsed, true) + }) + it('download over http with noproxy', async function () { const server = http.createServer((req, res) => { assert.strictEqual(req.headers['user-agent'], `node-gyp v42 (node ${process.version})`)