diff --git a/src/utils/proxy.noProxy.test.ts b/src/utils/proxy.noProxy.test.ts index abca8853a..375869dfc 100644 --- a/src/utils/proxy.noProxy.test.ts +++ b/src/utils/proxy.noProxy.test.ts @@ -14,6 +14,7 @@ const run = promisify(execFile) const { key, cert } = createTestTlsFixture() let directory: string let modulePath: string +let proxyModulePath: string beforeAll(async () => { directory = await mkdtemp(join(tmpdir(), 'proxy-no-proxy-')) @@ -23,15 +24,16 @@ beforeAll(async () => { writeFile(join(directory, 'client.key'), key), ]) modulePath = join(directory, 'network.mjs') + proxyModulePath = join(directory, 'proxy.mjs') const entrypoint = join(directory, 'entry.ts') await writeFile(entrypoint, ` - export { getProxyAgent, getNoProxy, shouldBypassProxy, configureGlobalAgents, clearProxyCache } from ${JSON.stringify(join(import.meta.dir, 'proxy.ts'))}; + export { getProxyAgent, getProxyFetchOptions, getNoProxy, shouldBypassProxy, configureGlobalAgents, clearProxyCache } from ${JSON.stringify(join(import.meta.dir, 'proxy.ts'))}; export { StructuredIO } from ${JSON.stringify(join(import.meta.dir, '../cli/structuredIO.ts'))}; - export { getCACertificates } from ${JSON.stringify(join(import.meta.dir, 'caCerts.ts'))}; - export { getMTLSConfig, getMTLSAgent } from ${JSON.stringify(join(import.meta.dir, 'mtls.ts'))}; + export { clearCACertsCache, getCACertificates } from ${JSON.stringify(join(import.meta.dir, 'caCerts.ts'))}; + export { clearMTLSCache, getMTLSConfig, getMTLSAgent } from ${JSON.stringify(join(import.meta.dir, 'mtls.ts'))}; `) const result = await Bun.build({ - entrypoints: [entrypoint], + entrypoints: [entrypoint, join(import.meta.dir, 'proxy.ts')], target: 'node', plugins: [{ name: 'node-proxy-environment-fixture', @@ -71,6 +73,7 @@ beforeAll(async () => { }) if (!result.success) throw new AggregateError(result.logs, 'Could not build proxy environment fixture') await writeFile(modulePath, await result.outputs[0].text()) + await writeFile(proxyModulePath, await result.outputs[1].text()) }) afterAll(async () => { @@ -80,6 +83,8 @@ afterAll(async () => { test.each([ 'combined certificate and no_proxy', 'certificate-only', + 'proxy-clear-ca', + 'proxy-clear-mtls', ] as const)('structured I/O refreshes certificate caches with a %s update', async mode => { const caPath = join(directory, 'ca.pem') const certPath = join(directory, 'client.pem') @@ -89,7 +94,7 @@ test.each([ let requests = 0 let authorizedRequests = 0 const target = createHttpsServer({ - key, cert, ca: cert, requestCert: true, rejectUnauthorized: true, + key, cert, ca: cert, requestCert: mode !== 'proxy-clear-ca', rejectUnauthorized: true, }, (request, response) => { requests++ if ('authorized' in request.socket && request.socket.authorized) authorizedRequests++ @@ -150,6 +155,7 @@ test.each([ const previousGlobalDispatcher = getGlobalDispatcher(); network.configureGlobalAgents(); const agents = new Set([cached]); + const tlsAgents = new Set(); const update = async variables => { const io = new network.StructuredIO((async function* () { yield JSON.stringify({ type: 'update_environment_variables', variables }) + '\\n'; @@ -161,38 +167,53 @@ test.each([ try { await update({ NODE_EXTRA_CA_CERTS: config.caPath, - CLAUDE_CODE_CLIENT_CERT: config.certPath, - CLAUDE_CODE_CLIENT_KEY: config.keyPath, + ...(config.mode !== 'proxy-clear-ca' && { + CLAUDE_CODE_CLIENT_CERT: config.certPath, + CLAUDE_CODE_CLIENT_KEY: config.keyPath, + }), ...(config.mode !== 'certificate-only' && { no_proxy: 'unused.invalid' }), }); agents.add(getGlobalDispatcher()); - const expectedCert = readFileSync(config.certPath, 'utf8'); - const expectedKey = readFileSync(config.keyPath, 'utf8'); - assert.deepEqual(network.getMTLSConfig(), { cert: expectedCert, key: expectedKey }); - assert.ok(network.getCACertificates().includes(readFileSync(config.caPath, 'utf8'))); - assert.equal(network.getMTLSAgent().options.cert, expectedCert); - assert.equal(network.getMTLSAgent().options.key, expectedKey); - for (const noProxy of ['unused.invalid', '*.127.0.0.1']) { - if (network.getNoProxy() !== noProxy) await update({ no_proxy: noProxy }); - const dispatcher = network.getProxyAgent(config.proxy); - agents.add(dispatcher); - assert.notEqual(dispatcher, cached); - for (const options of [{ dispatcher }, {}]) { - const response = await fetch(config.url, options); - assert.equal(response.status, 200); - assert.equal(await response.text(), 'authenticated response'); + tlsAgents.add(network.getMTLSAgent()); + if (config.mode.startsWith('proxy-clear')) { + const proxied = await fetch(config.url); + assert.equal(await proxied.text(), 'authenticated response'); + await update({ HTTPS_PROXY: '' }); + agents.add(getGlobalDispatcher()); + tlsAgents.add(network.getMTLSAgent()); + const direct = await fetch(config.url); + assert.equal(direct.status, 200); + assert.equal(await direct.text(), 'authenticated response'); + } else { + const expectedCert = readFileSync(config.certPath, 'utf8'); + const expectedKey = readFileSync(config.keyPath, 'utf8'); + assert.deepEqual(network.getMTLSConfig(), { cert: expectedCert, key: expectedKey }); + assert.ok(network.getCACertificates().includes(readFileSync(config.caPath, 'utf8'))); + assert.equal(network.getMTLSAgent().options.cert, expectedCert); + assert.equal(network.getMTLSAgent().options.key, expectedKey); + for (const noProxy of ['unused.invalid', '*.127.0.0.1']) { + if (network.getNoProxy() !== noProxy) await update({ no_proxy: noProxy }); + const dispatcher = network.getProxyAgent(config.proxy); + agents.add(dispatcher); + assert.notEqual(dispatcher, cached); + for (const options of [{ dispatcher }, {}]) { + const response = await fetch(config.url, options); + assert.equal(response.status, 200); + assert.equal(await response.text(), 'authenticated response'); + } } } console.log('verified'); } finally { setGlobalDispatcher(previousGlobalDispatcher); - await Promise.all([...agents].map(dispatcher => dispatcher.close())); + await Promise.all([...agents].map(dispatcher => dispatcher.destroy())); + for (const agent of tlsAgents) agent.destroy(); } `, modulePath, JSON.stringify(config)], { env, timeout: 6000 }) expect(stdout.trim()).toBe('verified') - expect(connects).toBe(2) - expect(requests).toBe(4) - expect(authorizedRequests).toBe(4) + expect(connects).toBe(mode.startsWith('proxy-clear') ? 1 : 2) + expect(requests).toBe(mode.startsWith('proxy-clear') ? 2 : 4) + expect(authorizedRequests).toBe(mode === 'proxy-clear-ca' ? 0 : mode === 'proxy-clear-mtls' ? 2 : 4) } finally { for (const socket of sockets) socket.destroy() await Promise.all([target, proxy].map(server => new Promise(resolve => { @@ -262,7 +283,7 @@ test.each(['TLS/proxy settings', 'unrelated session token'] as const)( console.log('verified'); } finally { setGlobalDispatcher(previousGlobalDispatcher); - await Promise.all([...dispatchers].map(dispatcher => dispatcher.close())); + await Promise.all([...dispatchers].map(dispatcher => dispatcher.destroy())); for (const agent of tlsAgents) agent.destroy(); } `, modulePath, JSON.stringify({ mode, directory })], { env, timeout: 6000 }) @@ -274,6 +295,7 @@ for (const mode of [ 'lowercase-bypass', 'lowercase-proxy', 'empty-lowercase', 'refresh-uppercase', 'refresh-lowercase', 'cache-wildcard', 'update-uppercase', 'update-lowercase', 'clear-cache', + 'clear-proxy', 'clear-unset', 'retained-proxy', ] as const) { // The Node child has a 6s deadline; allow extra time for cleanup. test(`Node NO_PROXY routing: ${mode}`, async () => { @@ -285,14 +307,17 @@ for (const mode of [ response.end('direct') }) const proxy = createServer() - proxy.on('connect', (_request, client) => { + const replacementProxy = mode === 'retained-proxy' ? createServer() : undefined + const proxies = [proxy, ...(replacementProxy ? [replacementProxy] : [])] + for (const server of proxies) server.on('connect', (_request, client) => { connects++ client.write('HTTP/1.1 200 Connection Established\r\n\r\n') client.once('data', () => { client.end('HTTP/1.1 200 OK\r\nContent-Length: 5\r\nConnection: close\r\n\r\nproxy') }) }) - for (const server of [target, proxy]) { + const servers = [target, ...proxies] + for (const server of servers) { server.on('connection', socket => { sockets.add(socket) socket.on('close', () => sockets.delete(socket)) @@ -304,6 +329,10 @@ for (const mode of [ await once(target, 'listening') proxy.listen(0, '127.0.0.1') await once(proxy, 'listening') + if (replacementProxy) { + replacementProxy.listen(0, '127.0.0.1') + await once(replacementProxy, 'listening') + } const env = { ...process.env } for (const key of [ 'HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy', 'NO_PROXY', 'no_proxy', @@ -313,14 +342,19 @@ for (const mode of [ const config = { mode, proxy: `http://127.0.0.1:${(proxy.address() as AddressInfo).port}`, + replacementProxy: replacementProxy + ? `http://127.0.0.1:${(replacementProxy.address() as AddressInfo).port}` + : undefined, url: `http://127.0.0.1:${(target.address() as AddressInfo).port}/`, } const { stdout } = await run('node', ['--input-type=module', '-e', ` import assert from 'node:assert/strict'; import { pathToFileURL } from 'node:url'; const network = await import(pathToFileURL(process.argv[1])); - const { mode, proxy, url } = JSON.parse(process.argv[2]); - process.env.HTTPS_PROXY = proxy; + const { getGlobalDispatcher, setGlobalDispatcher } = await import(pathToFileURL(${JSON.stringify(Bun.resolveSync('undici/index.js', import.meta.dir))})); + const { mode, proxy, replacementProxy, url } = JSON.parse(process.argv[2]); + const previousGlobalDispatcher = getGlobalDispatcher(); + if (mode !== 'clear-unset') process.env.HTTPS_PROXY = proxy; const agents = new Set(); const agent = () => { const result = network.getProxyAgent(proxy); @@ -341,7 +375,50 @@ for (const mode of [ } }; try { - if (mode.startsWith('lowercase') || mode === 'empty-lowercase') { + if (mode === 'retained-proxy') { + const retained = network.getProxyFetchOptions().dispatcher; + agents.add(retained); + network.configureGlobalAgents(); + assert.equal(getGlobalDispatcher(), retained); + await request('proxy'); + await update({ HTTPS_PROXY: replacementProxy }); + agents.add(getGlobalDispatcher()); + assert.notEqual(getGlobalDispatcher(), retained); + await request('proxy'); + await request('proxy', retained); + await update({ HTTPS_PROXY: '' }); + agents.add(getGlobalDispatcher()); + await request('direct'); + await request('proxy', retained); + } else if (mode === 'clear-proxy' || mode === 'clear-unset') { + if (mode === 'clear-proxy') { + network.configureGlobalAgents(); + const installed = agent(); + let closed = 0; + const close = installed.close.bind(installed); + installed.close = () => { closed++; return close(); }; + network.configureGlobalAgents(); + assert.equal(closed, 0, 'the active cached dispatcher must stay open'); + await request('proxy'); + await update({ HTTPS_PROXY: '' }); + agents.add(getGlobalDispatcher()); + await request('direct'); + assert.equal(closed, 0, 'retained proxy dispatchers must stay open'); + // Reusing the same proxy must use the refreshed cache. + process.env.HTTPS_PROXY = proxy; + network.configureGlobalAgents(); + agents.add(getGlobalDispatcher()); + await request('proxy'); + await update({ HTTPS_PROXY: '' }); + } else { + await update({ HTTPS_PROXY: '' }); + } + agents.add(getGlobalDispatcher()); + await request('direct'); + await update({ HTTPS_PROXY: '' }); + agents.add(getGlobalDispatcher()); + await request('direct'); + } else if (mode.startsWith('lowercase') || mode === 'empty-lowercase') { process.env.NO_PROXY = mode === 'lowercase-bypass' ? 'unused.invalid' : '127.0.0.1'; process.env.no_proxy = mode === 'lowercase-bypass' ? '127.0.0.1' : mode === 'empty-lowercase' ? '' : 'unused.invalid'; @@ -391,22 +468,165 @@ for (const mode of [ } console.log('verified'); } finally { - for (const dispatcher of agents) await dispatcher.close(); + setGlobalDispatcher(previousGlobalDispatcher); + for (const dispatcher of agents) await dispatcher.destroy(); } `, modulePath, JSON.stringify(config)], { env, timeout: 6000 }) expect(stdout.trim()).toBe('verified') - const expectedConnects = mode === 'lowercase-bypass' ? 0 : + const expectedConnects = mode === 'retained-proxy' ? 4 : mode === 'clear-proxy' ? 2 : mode === 'clear-unset' ? 0 : mode === 'lowercase-bypass' ? 0 : mode.startsWith('refresh') ? 3 : mode.startsWith('update') || mode === 'clear-cache' ? 2 : 1 - const expectedDirect = mode === 'lowercase-bypass' ? 1 : + const expectedDirect = mode === 'retained-proxy' ? 1 : mode === 'clear-proxy' ? 3 : mode === 'clear-unset' ? 2 : mode === 'lowercase-bypass' ? 1 : mode.startsWith('lowercase') || mode === 'empty-lowercase' ? 0 : mode.startsWith('update') || mode === 'clear-cache' ? 4 : mode === 'cache-wildcard' ? 3 : 2 expect(connects).toBe(expectedConnects) expect(requests).toBe(expectedDirect) } finally { for (const socket of sockets) socket.destroy() - await Promise.all([target, proxy].map(server => new Promise(resolve => { + await Promise.all(servers.map(server => new Promise(resolve => { server.close(() => resolve()) }))) } }, 10000) } + +test('empty configuration does not load or install a global dispatcher', async () => { + const env = { ...process.env } + for (const name of [ + 'HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy', 'NO_PROXY', 'no_proxy', + 'NODE_OPTIONS', 'NODE_EXTRA_CA_CERTS', 'CLAUDE_CODE_CLIENT_CERT', + 'CLAUDE_CODE_CLIENT_KEY', 'CLAUDE_CODE_CLIENT_KEY_PASSPHRASE', + ]) delete env[name] + const { stdout } = await run('node', ['--input-type=module', '-e', ` + import assert from 'node:assert/strict'; + import Module from 'node:module'; + import { pathToFileURL } from 'node:url'; + const load = Module._load; + Module._load = function(specifier, ...args) { + assert.ok(!specifier.includes('undici'), 'empty configuration must stay lazy'); + return load.call(this, specifier, ...args); + }; + const symbols = ['undici.globalDispatcher.1', 'undici.globalDispatcher.2'].map(Symbol.for); + try { + const network = await import(pathToFileURL(process.argv[1])); + // Importing HTTP clients may initialize Node's built-in fetch dispatcher. + const before = symbols.map(symbol => globalThis[symbol]); + network.configureGlobalAgents(); + network.configureGlobalAgents(); + assert.deepEqual(symbols.map(symbol => globalThis[symbol]), before); + // The full fixture imports unrelated services which can eagerly load + // dispatchers. Only the environment-update operation must stay lazy. + Module._load = load; + const structured = await import(pathToFileURL(process.argv[2])); + const structuredBefore = symbols.map(symbol => globalThis[symbol]); + Module._load = function(specifier, ...args) { + assert.ok(!specifier.includes('undici'), 'empty update must stay lazy'); + return load.call(this, specifier, ...args); + }; + const io = new structured.StructuredIO((async function* () { + yield JSON.stringify({ type: 'update_environment_variables', variables: { HTTPS_PROXY: '' } }) + '\\n'; + })()); + for await (const message of io.structuredInput) assert.fail(message.type); + assert.deepEqual(symbols.map(symbol => globalThis[symbol]), structuredBefore); + console.log('verified'); + } finally { Module._load = load; } + `, proxyModulePath, modulePath], { env, timeout: 6000 }) + expect(stdout.trim()).toBe('verified') +}, 10000) + +test.each(['shared TLS', 'TLS clear', 'TLS refresh', 'proxy replacement', 'external replacement'] as const)( + 'global dispatcher ownership: %s', async mode => { + const env = { ...process.env } + for (const name of [ + 'HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy', 'NO_PROXY', 'no_proxy', + 'NODE_OPTIONS', 'NODE_EXTRA_CA_CERTS', 'CLAUDE_CODE_CLIENT_CERT', + 'CLAUDE_CODE_CLIENT_KEY', 'CLAUDE_CODE_CLIENT_KEY_PASSPHRASE', + ]) delete env[name] + const { stdout } = await run('node', ['--input-type=module', '-e', ` + import assert from 'node:assert/strict'; + import { pathToFileURL } from 'node:url'; + const undici = (await import(pathToFileURL(${JSON.stringify(Bun.resolveSync('undici/index.js', import.meta.dir))}))).default; + const network = await import(pathToFileURL(process.argv[1])); + const { mode, directory } = JSON.parse(process.argv[2]); + const original = undici.getGlobalDispatcher(); + const setGlobalDispatcher = undici.setGlobalDispatcher; + const dispatchers = new Set(); + undici.setGlobalDispatcher = dispatcher => { + dispatchers.add(dispatcher); + setGlobalDispatcher(dispatcher); + }; + const countCloses = dispatcher => { + const close = dispatcher.close; + let count = 0; + dispatcher.close = function(...args) { count++; return close.apply(this, args); }; + return () => count; + }; + const defaultCloses = countCloses(original); + try { + network.configureGlobalAgents(); + assert.equal(undici.getGlobalDispatcher(), original); + if (mode === 'shared TLS' || mode.startsWith('TLS')) { + process.env.NODE_EXTRA_CA_CERTS = directory + '/ca.pem'; + network.clearCACertsCache(); + network.clearMTLSCache(); + network.configureGlobalAgents(); + const shared = undici.getGlobalDispatcher(); + assert.notEqual(shared, original, 'CA configuration must install a TLS dispatcher'); + const sharedCloses = countCloses(shared); + network.configureGlobalAgents(); + assert.equal(undici.getGlobalDispatcher(), shared, 'cached TLS dispatcher must be reused'); + assert.equal(sharedCloses(), 0, 'cached TLS dispatcher must stay open'); + if (mode.startsWith('TLS')) { + if (mode === 'TLS clear') delete process.env.NODE_EXTRA_CA_CERTS; + network.clearCACertsCache(); + network.clearMTLSCache(); + network.configureGlobalAgents(); + const replacement = undici.getGlobalDispatcher(); + assert.notEqual(replacement, shared, 'invalidated TLS configuration must be replaced'); + if (mode === 'TLS clear') assert.ok(replacement instanceof undici.Agent); + assert.equal(sharedCloses(), 1, 'retired TLS dispatcher must be closed after cache invalidation'); + network.configureGlobalAgents(); + if (mode === 'TLS clear') assert.equal(undici.getGlobalDispatcher(), replacement); + } else { + process.env.HTTPS_PROXY = 'http://127.0.0.1:1'; + network.configureGlobalAgents(); + delete process.env.HTTPS_PROXY; + network.clearProxyCache(); + network.configureGlobalAgents(); + assert.equal(sharedCloses(), 0, 'retained cached TLS dispatcher must stay open'); + } + } else { + process.env.HTTPS_PROXY = 'http://127.0.0.1:1'; + network.configureGlobalAgents(); + const first = undici.getGlobalDispatcher(); + const firstCloses = countCloses(first); + if (mode === 'external replacement') { + const external = new undici.Agent(); + undici.setGlobalDispatcher(external); + delete process.env.HTTPS_PROXY; + network.configureGlobalAgents(); + assert.equal(undici.getGlobalDispatcher(), external); + assert.equal(firstCloses(), 0, 'a cached proxy must stay open'); + } else { + process.env.HTTPS_PROXY = 'http://127.0.0.1:2'; + network.configureGlobalAgents(); + const second = undici.getGlobalDispatcher(); + assert.equal(firstCloses(), 0, 'replaced shared proxy must stay open'); + assert.notEqual(second, first); + assert.equal(network.getProxyAgent(process.env.HTTPS_PROXY), second); + const recreated = network.getProxyAgent('http://127.0.0.1:1'); + dispatchers.add(recreated); + assert.notEqual(recreated, first, 'retired proxy must leave the cache'); + } + } + assert.equal(defaultCloses(), 0, 'the default dispatcher must stay open'); + console.log('verified'); + } finally { + undici.setGlobalDispatcher = setGlobalDispatcher; + setGlobalDispatcher(original); + await Promise.all([...dispatchers].map(dispatcher => dispatcher.destroy())); + network.getMTLSAgent()?.destroy(); + } + `, modulePath, JSON.stringify({ mode, directory })], { env, timeout: 6000 }) + expect(stdout.trim()).toBe('verified') + }, 10000, +) diff --git a/src/utils/proxy.test.ts b/src/utils/proxy.test.ts index 9f90bcff5..0f0a2fa4f 100644 --- a/src/utils/proxy.test.ts +++ b/src/utils/proxy.test.ts @@ -132,9 +132,11 @@ describe('runtime CA certificates through CONNECT', () => { clearMTLSCache() clearProxyCache() configureGlobalAgents() // Eject the test's global axios interceptor. + const directDispatcher = getGlobalDispatcher() Object.assign(axios.defaults, defaults) setGlobalDispatcher(dispatcher) - if (testDispatcher !== dispatcher) await testDispatcher.close?.() + if (testDispatcher !== dispatcher) await testDispatcher.destroy?.() + if (directDispatcher !== dispatcher) await directDispatcher.destroy?.() restoreEnv() clearCACertsCache() clearMTLSCache() diff --git a/src/utils/proxy.ts b/src/utils/proxy.ts index 4f7e43243..494493571 100644 --- a/src/utils/proxy.ts +++ b/src/utils/proxy.ts @@ -258,6 +258,10 @@ export function createAxiosInstance( return instance } +function getProxyAgentCacheKey(uri: string): string { + return JSON.stringify([uri, getNoProxy()]) +} + /** * Get or create a proxy agent cached by URI and effective NO_PROXY value. * Include the bypass list because wildcard normalization pins it on the agent. @@ -306,7 +310,7 @@ export const getProxyAgent = memoize((uri: string): undici.Dispatcher => { return typeof Bun === 'undefined' ? new undiciMod.Dispatcher1Wrapper(agent) : agent -}, (uri: string) => JSON.stringify([uri, getNoProxy()])) +}, getProxyAgentCacheKey) /** * Get an HTTP agent configured for WebSocket proxy support @@ -395,6 +399,11 @@ export function getProxyFetchOptions(opts?: { forAnthropicAPI?: boolean }): { * This ensures all HTTP requests use the proxy and/or mTLS if configured */ let proxyInterceptorId: number | undefined +let installedDispatcher: undici.Dispatcher | undefined +let installedProxyCacheKey: string | undefined +let installedTLSDispatcher = false +let installedTLSAgent: ReturnType +let ownsInstalledDispatcher = false export function configureGlobalAgents(): void { const proxyUrl = getProxyUrl() @@ -438,25 +447,48 @@ export function configureGlobalAgents(): void { } return config }) - - // Set global dispatcher that now respects NO_PROXY via EnvHttpProxyAgent - // eslint-disable-next-line @typescript-eslint/no-require-imports - ;(require('undici') as typeof undici).setGlobalDispatcher( - getProxyAgent(proxyUrl), - ) - } else if (mtlsAgent) { - // No proxy but mTLS is configured + } else { axios.defaults.httpsAgent = mtlsAgent + } - // Set undici global dispatcher with mTLS - const mtlsOptions = getTLSFetchOptions() - if (mtlsOptions.dispatcher) { - // eslint-disable-next-line @typescript-eslint/no-require-imports - ;(require('undici') as typeof undici).setGlobalDispatcher( - mtlsOptions.dispatcher, - ) + // Keep the ordinary direct path lazy. Only clear our proxy/TLS installation + // while it is still global; another caller may have installed a replacement. + if (!proxyUrl && !mtlsAgent && installedProxyCacheKey === undefined && !installedTLSDispatcher) return + // eslint-disable-next-line @typescript-eslint/no-require-imports + const undiciMod = require('undici') as typeof undici + if (!proxyUrl && !mtlsAgent && undiciMod.getGlobalDispatcher() !== installedDispatcher) return + + // Reuse our TLS installation while its source configuration remains cached. + const cachedTLSDispatcher = !proxyUrl && mtlsAgent && installedTLSDispatcher && + installedTLSAgent === mtlsAgent ? installedDispatcher : undefined + const tlsOptions = proxyUrl || cachedTLSDispatcher ? undefined : getTLSFetchOptions() + const dispatcher = proxyUrl ? getProxyAgent(proxyUrl) : + cachedTLSDispatcher ?? tlsOptions?.dispatcher ?? new undiciMod.Agent({ connect: tlsOptions?.tls }) + undiciMod.setGlobalDispatcher(dispatcher) + + if (installedDispatcher && installedDispatcher !== dispatcher) { + // Evict only the retired proxy, leaving the active dispatcher cached. + if (installedProxyCacheKey !== undefined && + getProxyAgent.cache.get(installedProxyCacheKey) === installedDispatcher) { + getProxyAgent.cache.delete(installedProxyCacheKey) + } + // Proxy dispatchers are shared with callers that can retain fetch options. + // Eviction does not prove those references are gone, so leave them open. + if (ownsInstalledDispatcher || (installedTLSDispatcher && installedTLSAgent !== mtlsAgent)) { + try { + // Graceful close lets in-flight requests finish without blocking updates. + void installedDispatcher.close().catch(() => {}) + } catch { + // Dispatcher cleanup is best effort. + } } } + installedDispatcher = dispatcher + installedProxyCacheKey = proxyUrl ? getProxyAgentCacheKey(proxyUrl) : undefined + installedTLSDispatcher = !proxyUrl && !!mtlsAgent + installedTLSAgent = mtlsAgent + // Only the plain direct dispatcher is private; proxy options are handed out. + ownsInstalledDispatcher = !proxyUrl && !mtlsAgent } /**