diff --git a/docs/docs/api/EnvHttpProxyAgent.md b/docs/docs/api/EnvHttpProxyAgent.md new file mode 100644 index 00000000000..b1ee39b995d --- /dev/null +++ b/docs/docs/api/EnvHttpProxyAgent.md @@ -0,0 +1,162 @@ +# Class: EnvHttpProxyAgent + +Stability: Experimental. + +Extends: `undici.Dispatcher` + +EnvHttpProxyAgent automatically reads the proxy configuration from the environment variables `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` and sets up the proxy agents accordingly. When `HTTP_PROXY` and `HTTPS_PROXY` are set, `HTTP_PROXY` is used for HTTP requests and `HTTPS_PROXY` is used for HTTPS requests. If only `HTTP_PROXY` is set, `HTTP_PROXY` is used for both HTTP and HTTPS requests. If only `HTTPS_PROXY` is set, it is only used for HTTPS requests. + +`NO_PROXY` is a comma or space-separated list of hostnames that should not be proxied. The list may contain leading wildcard characters (`*`). If `NO_PROXY` is set, the EnvHttpProxyAgent will bypass the proxy for requests to hosts that match the list. If `NO_PROXY` is set to `"*"`, the EnvHttpProxyAgent will bypass the proxy for all requests. + +Lower case environment variables are also supported: `http_proxy`, `https_proxy`, and `no_proxy`. However, if both the lower case and upper case environment variables are set, the lower case environment variables will be ignored. + +## `new EnvHttpProxyAgent([options])` + +Arguments: + +* **options** `EnvHttpProxyAgentOptions` (optional) - extends the `Agent` options. + +Returns: `EnvHttpProxyAgent` + +### Parameter: `EnvHttpProxyAgentOptions` + +Extends: [`AgentOptions`](Agent.md#parameter-agentoptions) + +* **httpProxy** `string` (optional) - When set, it will override the `HTTP_PROXY` environment variable. +* **httpsProxy** `string` (optional) - When set, it will override the `HTTPS_PROXY` environment variable. +* **noProxy** `string` (optional) - When set, it will override the `NO_PROXY` environment variable. + +Examples: + +```js +import { EnvHttpProxyAgent } from 'undici' + +const envHttpProxyAgent = new EnvHttpProxyAgent() +// or +const envHttpProxyAgent = new EnvHttpProxyAgent({ httpProxy: 'my.proxy.server:8080', httpsProxy: 'my.proxy.server:8443', noProxy: 'localhost' }) +``` + +#### Example - EnvHttpProxyAgent instantiation + +This will instantiate the EnvHttpProxyAgent. It will not do anything until registered as the agent to use with requests. + +```js +import { EnvHttpProxyAgent } from 'undici' + +const envHttpProxyAgent = new EnvHttpProxyAgent() +``` + +#### Example - Basic Proxy Fetch with global agent dispatcher + +```js +import { setGlobalDispatcher, fetch, EnvHttpProxyAgent } from 'undici' + +const envHttpProxyAgent = new EnvHttpProxyAgent() +setGlobalDispatcher(envHttpProxyAgent) + +const { status, json } = await fetch('http://localhost:3000/foo') + +console.log('response received', status) // response received 200 + +const data = await json() // data { foo: "bar" } +``` + +#### Example - Basic Proxy Request with global agent dispatcher + +```js +import { setGlobalDispatcher, request, EnvHttpProxyAgent } from 'undici' + +const envHttpProxyAgent = new EnvHttpProxyAgent() +setGlobalDispatcher(envHttpProxyAgent) + +const { statusCode, body } = await request('http://localhost:3000/foo') + +console.log('response received', statusCode) // response received 200 + +for await (const data of body) { + console.log('data', data.toString('utf8')) // data foo +} +``` + +#### Example - Basic Proxy Request with local agent dispatcher + +```js +import { EnvHttpProxyAgent, request } from 'undici' + +const envHttpProxyAgent = new EnvHttpProxyAgent() + +const { + statusCode, + body +} = await request('http://localhost:3000/foo', { dispatcher: envHttpProxyAgent }) + +console.log('response received', statusCode) // response received 200 + +for await (const data of body) { + console.log('data', data.toString('utf8')) // data foo +} +``` + +#### Example - Basic Proxy Fetch with local agent dispatcher + +```js +import { EnvHttpProxyAgent, fetch } from 'undici' + +const envHttpProxyAgent = new EnvHttpProxyAgent() + +const { + status, + json +} = await fetch('http://localhost:3000/foo', { dispatcher: envHttpProxyAgent }) + +console.log('response received', status) // response received 200 + +const data = await json() // data { foo: "bar" } +``` + +## Instance Methods + +### `EnvHttpProxyAgent.close([callback])` + +Implements [`Dispatcher.close([callback])`](Dispatcher.md#dispatcherclosecallback-promise). + +### `EnvHttpProxyAgent.destroy([error, callback])` + +Implements [`Dispatcher.destroy([error, callback])`](Dispatcher.md#dispatcherdestroyerror-callback-promise). + +### `EnvHttpProxyAgent.dispatch(options, handler: AgentDispatchOptions)` + +Implements [`Dispatcher.dispatch(options, handler)`](Dispatcher.md#dispatcherdispatchoptions-handler). + +#### Parameter: `AgentDispatchOptions` + +Extends: [`DispatchOptions`](Dispatcher.md#parameter-dispatchoptions) + +* **origin** `string | URL` +* **maxRedirections** `Integer`. + +Implements [`Dispatcher.destroy([error, callback])`](Dispatcher.md#dispatcherdestroyerror-callback-promise). + +### `EnvHttpProxyAgent.connect(options[, callback])` + +See [`Dispatcher.connect(options[, callback])`](Dispatcher.md#dispatcherconnectoptions-callback). + +### `EnvHttpProxyAgent.dispatch(options, handler)` + +Implements [`Dispatcher.dispatch(options, handler)`](Dispatcher.md#dispatcherdispatchoptions-handler). + +### `EnvHttpProxyAgent.pipeline(options, handler)` + +See [`Dispatcher.pipeline(options, handler)`](Dispatcher.md#dispatcherpipelineoptions-handler). + +### `EnvHttpProxyAgent.request(options[, callback])` + +See [`Dispatcher.request(options [, callback])`](Dispatcher.md#dispatcherrequestoptions-callback). + +### `EnvHttpProxyAgent.stream(options, factory[, callback])` + +See [`Dispatcher.stream(options, factory[, callback])`](Dispatcher.md#dispatcherstreamoptions-factory-callback). + +### `EnvHttpProxyAgent.upgrade(options[, callback])` + +See [`Dispatcher.upgrade(options[, callback])`](Dispatcher.md#dispatcherupgradeoptions-callback). diff --git a/index.js b/index.js index dcf0be90798..e9fe28e5b43 100644 --- a/index.js +++ b/index.js @@ -6,6 +6,7 @@ const Pool = require('./lib/dispatcher/pool') const BalancedPool = require('./lib/dispatcher/balanced-pool') const Agent = require('./lib/dispatcher/agent') const ProxyAgent = require('./lib/dispatcher/proxy-agent') +const EnvHttpProxyAgent = require('./lib/dispatcher/env-http-proxy-agent') const RetryAgent = require('./lib/dispatcher/retry-agent') const errors = require('./lib/core/errors') const util = require('./lib/core/util') @@ -30,6 +31,7 @@ module.exports.Pool = Pool module.exports.BalancedPool = BalancedPool module.exports.Agent = Agent module.exports.ProxyAgent = ProxyAgent +module.exports.EnvHttpProxyAgent = EnvHttpProxyAgent module.exports.RetryAgent = RetryAgent module.exports.RetryHandler = RetryHandler diff --git a/lib/core/symbols.js b/lib/core/symbols.js index 02fda9e251d..879ba833de6 100644 --- a/lib/core/symbols.js +++ b/lib/core/symbols.js @@ -60,5 +60,8 @@ module.exports = { kConstruct: Symbol('constructable'), kListeners: Symbol('listeners'), kHTTPContext: Symbol('http context'), - kMaxConcurrentStreams: Symbol('max concurrent streams') + kMaxConcurrentStreams: Symbol('max concurrent streams'), + kNoProxyAgent: Symbol('no proxy agent'), + kHttpProxyAgent: Symbol('http proxy agent'), + kHttpsProxyAgent: Symbol('https proxy agent') } diff --git a/lib/dispatcher/dispatcher-base.js b/lib/dispatcher/dispatcher-base.js index 88e1ca48db7..bd860acdcf4 100644 --- a/lib/dispatcher/dispatcher-base.js +++ b/lib/dispatcher/dispatcher-base.js @@ -6,10 +6,8 @@ const { ClientClosedError, InvalidArgumentError } = require('../core/errors') -const { kDestroy, kClose, kDispatch, kInterceptors } = require('../core/symbols') +const { kDestroy, kClose, kClosed, kDestroyed, kDispatch, kInterceptors } = require('../core/symbols') -const kDestroyed = Symbol('destroyed') -const kClosed = Symbol('closed') const kOnDestroyed = Symbol('onDestroyed') const kOnClosed = Symbol('onClosed') const kInterceptedDispatch = Symbol('Intercepted Dispatch') diff --git a/lib/dispatcher/env-http-proxy-agent.js b/lib/dispatcher/env-http-proxy-agent.js new file mode 100644 index 00000000000..37c526df280 --- /dev/null +++ b/lib/dispatcher/env-http-proxy-agent.js @@ -0,0 +1,160 @@ +'use strict' + +const DispatcherBase = require('./dispatcher-base') +const { kClose, kDestroy, kClosed, kDestroyed, kDispatch, kNoProxyAgent, kHttpProxyAgent, kHttpsProxyAgent } = require('../core/symbols') +const ProxyAgent = require('./proxy-agent') +const Agent = require('./agent') + +const DEFAULT_PORTS = { + 'http:': 80, + 'https:': 443 +} + +let experimentalWarned = false + +class EnvHttpProxyAgent extends DispatcherBase { + #noProxyValue = null + #noProxyEntries = null + #opts = null + + constructor (opts = {}) { + super() + this.#opts = opts + + if (!experimentalWarned) { + experimentalWarned = true + process.emitWarning('EnvHttpProxyAgent is experimental, expect them to change at any time.', { + code: 'UNDICI-EHPA' + }) + } + + const { httpProxy, httpsProxy, noProxy, ...agentOpts } = opts + + this[kNoProxyAgent] = new Agent(agentOpts) + + const HTTP_PROXY = httpProxy ?? process.env.HTTP_PROXY ?? process.env.http_proxy + if (HTTP_PROXY) { + this[kHttpProxyAgent] = new ProxyAgent({ ...agentOpts, uri: HTTP_PROXY }) + } else { + this[kHttpProxyAgent] = this[kNoProxyAgent] + } + + const HTTPS_PROXY = httpsProxy ?? process.env.HTTPS_PROXY ?? process.env.https_proxy + if (HTTPS_PROXY) { + this[kHttpsProxyAgent] = new ProxyAgent({ ...agentOpts, uri: HTTPS_PROXY }) + } else { + this[kHttpsProxyAgent] = this[kHttpProxyAgent] + } + + this.#parseNoProxy() + } + + [kDispatch] (opts, handler) { + const url = new URL(opts.origin) + const agent = this.#getProxyAgentForUrl(url) + return agent.dispatch(opts, handler) + } + + async [kClose] () { + await this[kNoProxyAgent].close() + if (!this[kHttpProxyAgent][kClosed]) { + await this[kHttpProxyAgent].close() + } + if (!this[kHttpsProxyAgent][kClosed]) { + await this[kHttpsProxyAgent].close() + } + } + + async [kDestroy] (err) { + await this[kNoProxyAgent].destroy(err) + if (!this[kHttpProxyAgent][kDestroyed]) { + await this[kHttpProxyAgent].destroy(err) + } + if (!this[kHttpsProxyAgent][kDestroyed]) { + await this[kHttpsProxyAgent].destroy(err) + } + } + + #getProxyAgentForUrl (url) { + let { protocol, host: hostname, port } = url + + // Stripping ports in this way instead of using parsedUrl.hostname to make + // sure that the brackets around IPv6 addresses are kept. + hostname = hostname.replace(/:\d*$/, '').toLowerCase() + port = Number.parseInt(port, 10) || DEFAULT_PORTS[protocol] || 0 + if (!this.#shouldProxy(hostname, port)) { + return this[kNoProxyAgent] + } + if (protocol === 'https:') { + return this[kHttpsProxyAgent] + } + return this[kHttpProxyAgent] + } + + #shouldProxy (hostname, port) { + if (this.#noProxyChanged) { + this.#parseNoProxy() + } + + if (this.#noProxyEntries.length === 0) { + return true // Always proxy if NO_PROXY is not set or empty. + } + if (this.#noProxyValue === '*') { + return false // Never proxy if wildcard is set. + } + + for (let i = 0; i < this.#noProxyEntries.length; i++) { + const entry = this.#noProxyEntries[i] + if (entry.port && entry.port !== port) { + continue // Skip if ports don't match. + } + if (!/^[.*]/.test(entry.hostname)) { + // No wildcards, so don't proxy only if there is not an exact match. + if (hostname === entry.hostname) { + return false + } + } else { + // Don't proxy if the hostname ends with the no_proxy host. + if (hostname.endsWith(entry.hostname.replace(/^\*/, ''))) { + return false + } + } + } + + return true + } + + #parseNoProxy () { + const noProxyValue = this.#opts.noProxy ?? this.#noProxyEnv + const noProxySplit = noProxyValue.split(/[,\s]/) + const noProxyEntries = [] + + for (let i = 0; i < noProxySplit.length; i++) { + const entry = noProxySplit[i] + if (!entry) { + continue + } + const parsed = entry.match(/^(.+):(\d+)$/) + noProxyEntries.push({ + hostname: (parsed ? parsed[1] : entry).toLowerCase(), + port: parsed ? Number.parseInt(parsed[2], 10) : 0 + }) + } + + this.#noProxyValue = noProxyValue + this.#noProxyEntries = noProxyEntries + } + + get #noProxyChanged () { + if (this.#opts.noProxy !== undefined) { + return false + } + return this.#noProxyValue !== this.#noProxyEnv + } + + get #noProxyEnv () { + return process.env.NO_PROXY ?? process.env.no_proxy ?? '' + } +} + +module.exports = EnvHttpProxyAgent diff --git a/package.json b/package.json index a3dc801c4dc..6044c737de7 100644 --- a/package.json +++ b/package.json @@ -110,6 +110,7 @@ "node-forge": "^1.3.1", "pre-commit": "^1.2.2", "proxy": "^2.1.1", + "sinon": "^17.0.1", "snazzy": "^9.0.0", "standard": "^17.0.0", "tsd": "^0.31.0", diff --git a/test/env-http-proxy-agent.js b/test/env-http-proxy-agent.js new file mode 100644 index 00000000000..3483fa5de7c --- /dev/null +++ b/test/env-http-proxy-agent.js @@ -0,0 +1,477 @@ +'use strict' + +const { tspl } = require('@matteo.collina/tspl') +const { test, describe, after, beforeEach } = require('node:test') +const sinon = require('sinon') +const { EnvHttpProxyAgent, ProxyAgent, Agent, fetch, MockAgent } = require('..') +const { kNoProxyAgent, kHttpProxyAgent, kHttpsProxyAgent, kClosed, kDestroyed, kProxy } = require('../lib/core/symbols') + +const env = { ...process.env } + +beforeEach(() => { + ['HTTP_PROXY', 'http_proxy', 'HTTPS_PROXY', 'https_proxy', 'NO_PROXY', 'no_proxy'].forEach((varname) => { + delete process.env[varname] + }) +}) + +after(() => { + process.env = { ...env } +}) + +test('does not create any proxy agents if HTTP_PROXY and HTTPS_PROXY are not set', async (t) => { + t = tspl(t, { plan: 4 }) + const dispatcher = new EnvHttpProxyAgent() + t.ok(dispatcher[kNoProxyAgent] instanceof Agent) + t.ok(!(dispatcher[kNoProxyAgent] instanceof ProxyAgent)) + t.deepStrictEqual(dispatcher[kHttpProxyAgent], dispatcher[kNoProxyAgent]) + t.deepStrictEqual(dispatcher[kHttpsProxyAgent], dispatcher[kNoProxyAgent]) + return dispatcher.close() +}) + +test('creates one proxy agent for both http and https when only HTTP_PROXY is defined', async (t) => { + t = tspl(t, { plan: 5 }) + process.env.HTTP_PROXY = 'http://example.com:8080' + const dispatcher = new EnvHttpProxyAgent() + t.ok(dispatcher[kNoProxyAgent] instanceof Agent) + t.ok(!(dispatcher[kNoProxyAgent] instanceof ProxyAgent)) + t.ok(dispatcher[kHttpProxyAgent] instanceof ProxyAgent) + t.equal(dispatcher[kHttpProxyAgent][kProxy].uri, 'http://example.com:8080/') + t.deepStrictEqual(dispatcher[kHttpsProxyAgent], dispatcher[kHttpProxyAgent]) + return dispatcher.close() +}) + +test('creates separate proxy agent for http and https when HTTP_PROXY and HTTPS_PROXY are set', async (t) => { + t = tspl(t, { plan: 6 }) + process.env.HTTP_PROXY = 'http://example.com:8080' + process.env.HTTPS_PROXY = 'http://example.com:8443' + const dispatcher = new EnvHttpProxyAgent() + t.ok(dispatcher[kNoProxyAgent] instanceof Agent) + t.ok(!(dispatcher[kNoProxyAgent] instanceof ProxyAgent)) + t.ok(dispatcher[kHttpProxyAgent] instanceof ProxyAgent) + t.equal(dispatcher[kHttpProxyAgent][kProxy].uri, 'http://example.com:8080/') + t.ok(dispatcher[kHttpsProxyAgent] instanceof ProxyAgent) + t.equal(dispatcher[kHttpsProxyAgent][kProxy].uri, 'http://example.com:8443/') + return dispatcher.close() +}) + +test('handles lowercase http_proxy and https_proxy', async (t) => { + t = tspl(t, { plan: 6 }) + process.env.http_proxy = 'http://example.com:8080' + process.env.https_proxy = 'http://example.com:8443' + const dispatcher = new EnvHttpProxyAgent() + t.ok(dispatcher[kNoProxyAgent] instanceof Agent) + t.ok(!(dispatcher[kNoProxyAgent] instanceof ProxyAgent)) + t.ok(dispatcher[kHttpProxyAgent] instanceof ProxyAgent) + t.equal(dispatcher[kHttpProxyAgent][kProxy].uri, 'http://example.com:8080/') + t.ok(dispatcher[kHttpsProxyAgent] instanceof ProxyAgent) + t.equal(dispatcher[kHttpsProxyAgent][kProxy].uri, 'http://example.com:8443/') + return dispatcher.close() +}) + +test('accepts httpProxy and httpsProxy options', async (t) => { + t = tspl(t, { plan: 6 }) + const opts = { + httpProxy: 'http://example.com:8080', + httpsProxy: 'http://example.com:8443' + } + const dispatcher = new EnvHttpProxyAgent(opts) + t.ok(dispatcher[kNoProxyAgent] instanceof Agent) + t.ok(!(dispatcher[kNoProxyAgent] instanceof ProxyAgent)) + t.ok(dispatcher[kHttpProxyAgent] instanceof ProxyAgent) + t.equal(dispatcher[kHttpProxyAgent][kProxy].uri, 'http://example.com:8080/') + t.ok(dispatcher[kHttpsProxyAgent] instanceof ProxyAgent) + t.equal(dispatcher[kHttpsProxyAgent][kProxy].uri, 'http://example.com:8443/') + return dispatcher.close() +}) + +test('prefers options over env vars', async (t) => { + t = tspl(t, { plan: 2 }) + const opts = { + httpProxy: 'http://opts.example.com:8080', + httpsProxy: 'http://opts.example.com:8443' + } + process.env.http_proxy = 'http://lower.example.com:8080' + process.env.https_proxy = 'http://lower.example.com:8443' + process.env.HTTP_PROXY = 'http://upper.example.com:8080' + process.env.HTTPS_PROXY = 'http://upper.example.com:8443' + const dispatcher = new EnvHttpProxyAgent(opts) + t.equal(dispatcher[kHttpProxyAgent][kProxy].uri, 'http://opts.example.com:8080/') + t.equal(dispatcher[kHttpsProxyAgent][kProxy].uri, 'http://opts.example.com:8443/') + return dispatcher.close() +}) + +test('prefers uppercase over lower case env vars', async (t) => { + t = tspl(t, { plan: 2 }) + process.env.http_proxy = 'http://lower.example.com:8080' + process.env.https_proxy = 'http://lower.example.com:8443' + process.env.HTTP_PROXY = 'http://upper.example.com:8080' + process.env.HTTPS_PROXY = 'http://upper.example.com:8443' + const dispatcher = new EnvHttpProxyAgent() + t.equal(dispatcher[kHttpProxyAgent][kProxy].uri, 'http://upper.example.com:8080/') + t.equal(dispatcher[kHttpsProxyAgent][kProxy].uri, 'http://upper.example.com:8443/') + return dispatcher.close() +}) + +test('prefers uppercase over lower case env vars even when empty', async (t) => { + t = tspl(t, { plan: 2 }) + process.env.http_proxy = 'http://lower.example.com:8080' + process.env.https_proxy = 'http://lower.example.com:8443' + process.env.HTTP_PROXY = '' + process.env.HTTPS_PROXY = '' + const dispatcher = new EnvHttpProxyAgent() + + t.deepStrictEqual(dispatcher[kHttpProxyAgent], dispatcher[kNoProxyAgent]) + t.deepStrictEqual(dispatcher[kHttpsProxyAgent], dispatcher[kNoProxyAgent]) + return dispatcher.close() +}) + +test('creates a proxy agent only for https when only HTTPS_PROXY is set', async (t) => { + t = tspl(t, { plan: 5 }) + process.env.HTTPS_PROXY = 'http://example.com:8443' + const dispatcher = new EnvHttpProxyAgent() + t.ok(dispatcher[kNoProxyAgent] instanceof Agent) + t.ok(!(dispatcher[kNoProxyAgent] instanceof ProxyAgent)) + t.deepStrictEqual(dispatcher[kHttpProxyAgent], dispatcher[kNoProxyAgent]) + t.ok(dispatcher[kHttpsProxyAgent] instanceof ProxyAgent) + t.equal(dispatcher[kHttpsProxyAgent][kProxy].uri, 'http://example.com:8443/') + return dispatcher.close() +}) + +test('closes all agents', async (t) => { + t = tspl(t, { plan: 3 }) + process.env.HTTP_PROXY = 'http://example.com:8080' + process.env.HTTPS_PROXY = 'http://example.com:8443' + const dispatcher = new EnvHttpProxyAgent() + await dispatcher.close() + t.ok(dispatcher[kNoProxyAgent][kClosed]) + t.ok(dispatcher[kHttpProxyAgent][kClosed]) + t.ok(dispatcher[kHttpsProxyAgent][kClosed]) +}) + +test('destroys all agents', async (t) => { + t = tspl(t, { plan: 3 }) + process.env.HTTP_PROXY = 'http://example.com:8080' + process.env.HTTPS_PROXY = 'http://example.com:8443' + const dispatcher = new EnvHttpProxyAgent() + await dispatcher.destroy() + t.ok(dispatcher[kNoProxyAgent][kDestroyed]) + t.ok(dispatcher[kHttpProxyAgent][kDestroyed]) + t.ok(dispatcher[kHttpsProxyAgent][kDestroyed]) +}) + +const createEnvHttpProxyAgentWithMocks = (plan = 1, opts = {}) => { + const factory = (origin) => { + const mockAgent = new MockAgent() + const mockPool = mockAgent.get(origin) + let i = 0 + while (i < plan) { + mockPool.intercept({ path: /.*/ }).reply(200, 'OK') + i++ + } + return mockPool + } + process.env.HTTP_PROXY = 'http://localhost:8080' + process.env.HTTPS_PROXY = 'http://localhost:8443' + const dispatcher = new EnvHttpProxyAgent({ ...opts, factory }) + const agentSymbols = [kNoProxyAgent, kHttpProxyAgent, kHttpsProxyAgent] + agentSymbols.forEach((agent) => { + sinon.spy(dispatcher[agent], 'dispatch') + }) + const usesProxyAgent = async (agent, url) => { + await fetch(url, { dispatcher }) + const result = agentSymbols.every((agentSymbol) => agent === agentSymbol + ? dispatcher[agentSymbol].dispatch.called + : dispatcher[agentSymbol].dispatch.notCalled) + agentSymbols.forEach((agent) => { dispatcher[agent].dispatch.resetHistory() }) + return result + } + const doesNotProxy = usesProxyAgent.bind(this, kNoProxyAgent) + return { + dispatcher, + doesNotProxy, + usesProxyAgent + } +} + +test('uses the appropriate proxy for the protocol', async (t) => { + t = tspl(t, { plan: 2 }) + const { dispatcher, usesProxyAgent } = createEnvHttpProxyAgentWithMocks() + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://example.com/')) + t.ok(await usesProxyAgent(kHttpsProxyAgent, 'https://example.com/')) + return dispatcher.close() +}) + +describe('NO_PROXY', () => { + test('set to *', async (t) => { + t = tspl(t, { plan: 2 }) + process.env.NO_PROXY = '*' + const { dispatcher, doesNotProxy } = createEnvHttpProxyAgentWithMocks(2) + t.ok(await doesNotProxy('https://example.com')) + t.ok(await doesNotProxy('http://example.com')) + return dispatcher.close() + }) + + test('set but empty', async (t) => { + t = tspl(t, { plan: 1 }) + process.env.NO_PROXY = '' + const { dispatcher, usesProxyAgent } = createEnvHttpProxyAgentWithMocks() + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://example.com')) + return dispatcher.close() + }) + + test('no entries (comma)', async (t) => { + t = tspl(t, { plan: 1 }) + process.env.NO_PROXY = ',' + const { dispatcher, usesProxyAgent } = createEnvHttpProxyAgentWithMocks() + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://example.com')) + return dispatcher.close() + }) + + test('no entries (whitespace)', async (t) => { + t = tspl(t, { plan: 1 }) + process.env.NO_PROXY = ' ' + const { dispatcher, usesProxyAgent } = createEnvHttpProxyAgentWithMocks() + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://example.com')) + return dispatcher.close() + }) + + test('no entries (multiple whitespace / commas)', async (t) => { + t = tspl(t, { plan: 1 }) + process.env.NO_PROXY = ',\t,,,\n, ,\r' + const { dispatcher, usesProxyAgent } = createEnvHttpProxyAgentWithMocks() + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://example.com')) + return dispatcher.close() + }) + + test('single host', async (t) => { + t = tspl(t, { plan: 9 }) + process.env.NO_PROXY = 'example' + const { dispatcher, doesNotProxy, usesProxyAgent } = createEnvHttpProxyAgentWithMocks(9) + t.ok(await doesNotProxy('http://example')) + t.ok(await doesNotProxy('http://example:80')) + t.ok(await doesNotProxy('http://example:0')) + t.ok(await doesNotProxy('http://example:1337')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://sub.example')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://prefexample')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://example.no')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://a.b.example')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://host/example')) + return dispatcher.close() + }) + + test('as an option', async (t) => { + t = tspl(t, { plan: 9 }) + const { dispatcher, doesNotProxy, usesProxyAgent } = createEnvHttpProxyAgentWithMocks(9, { noProxy: 'example' }) + t.ok(await doesNotProxy('http://example')) + t.ok(await doesNotProxy('http://example:80')) + t.ok(await doesNotProxy('http://example:0')) + t.ok(await doesNotProxy('http://example:1337')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://sub.example')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://prefexample')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://example.no')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://a.b.example')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://host/example')) + return dispatcher.close() + }) + + test('subdomain', async (t) => { + t = tspl(t, { plan: 8 }) + process.env.NO_PROXY = 'sub.example' + const { dispatcher, doesNotProxy, usesProxyAgent } = createEnvHttpProxyAgentWithMocks(8) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://example')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://example:80')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://example:0')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://example:1337')) + t.ok(await doesNotProxy('http://sub.example')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://no.sub.example')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://sub-example')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://example.sub')) + return dispatcher.close() + }) + + test('host + port', async (t) => { + t = tspl(t, { plan: 12 }) + process.env.NO_PROXY = 'example:80, localhost:3000' + const { dispatcher, doesNotProxy, usesProxyAgent } = createEnvHttpProxyAgentWithMocks(12) + t.ok(await doesNotProxy('http://example')) + t.ok(await doesNotProxy('http://example:80')) + t.ok(await doesNotProxy('http://example:0')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://example:1337')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://sub.example')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://prefexample')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://example.no')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://a.b.example')) + t.ok(await doesNotProxy('http://localhost:3000/')) + t.ok(await doesNotProxy('https://localhost:3000/')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://localhost:3001/')) + t.ok(await usesProxyAgent(kHttpsProxyAgent, 'https://localhost:3001/')) + return dispatcher.close() + }) + + test('host suffix', async (t) => { + t = tspl(t, { plan: 9 }) + process.env.NO_PROXY = '.example' + const { dispatcher, doesNotProxy, usesProxyAgent } = createEnvHttpProxyAgentWithMocks(9) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://example')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://example:80')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://example:1337')) + t.ok(await doesNotProxy('http://sub.example')) + t.ok(await doesNotProxy('http://sub.example:80')) + t.ok(await doesNotProxy('http://sub.example:1337')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://prefexample')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://example.no')) + t.ok(await doesNotProxy('http://a.b.example')) + return dispatcher.close() + }) + + test('host suffix with *.', async (t) => { + t = tspl(t, { plan: 9 }) + process.env.NO_PROXY = '*.example' + const { dispatcher, doesNotProxy, usesProxyAgent } = createEnvHttpProxyAgentWithMocks(9) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://example')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://example:80')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://example:1337')) + t.ok(await doesNotProxy('http://sub.example')) + t.ok(await doesNotProxy('http://sub.example:80')) + t.ok(await doesNotProxy('http://sub.example:1337')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://prefexample')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://example.no')) + t.ok(await doesNotProxy('http://a.b.example')) + return dispatcher.close() + }) + + test('substring suffix', async (t) => { + t = tspl(t, { plan: 10 }) + process.env.NO_PROXY = '*example' + const { dispatcher, doesNotProxy, usesProxyAgent } = createEnvHttpProxyAgentWithMocks(10) + t.ok(await doesNotProxy('http://example')) + t.ok(await doesNotProxy('http://example:80')) + t.ok(await doesNotProxy('http://example:1337')) + t.ok(await doesNotProxy('http://sub.example')) + t.ok(await doesNotProxy('http://sub.example:80')) + t.ok(await doesNotProxy('http://sub.example:1337')) + t.ok(await doesNotProxy('http://prefexample')) + t.ok(await doesNotProxy('http://a.b.example')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://example.no')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://host/example')) + return dispatcher.close() + }) + + test('arbitrary wildcards are NOT supported', async (t) => { + t = tspl(t, { plan: 6 }) + process.env.NO_PROXY = '.*example' + const { dispatcher, usesProxyAgent } = createEnvHttpProxyAgentWithMocks(6) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://example')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://sub.example')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://sub.example')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://prefexample')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://x.prefexample')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://a.b.example')) + return dispatcher.close() + }) + + test('IP addresses', async (t) => { + t = tspl(t, { plan: 12 }) + process.env.NO_PROXY = '[::1],[::2]:80,10.0.0.1,10.0.0.2:80' + const { dispatcher, doesNotProxy, usesProxyAgent } = createEnvHttpProxyAgentWithMocks(12) + t.ok(await doesNotProxy('http://[::1]/')) + t.ok(await doesNotProxy('http://[::1]:80/')) + t.ok(await doesNotProxy('http://[::1]:1337/')) + t.ok(await doesNotProxy('http://[::2]/')) + t.ok(await doesNotProxy('http://[::2]:80/')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://[::2]:1337/')) + t.ok(await doesNotProxy('http://10.0.0.1/')) + t.ok(await doesNotProxy('http://10.0.0.1:80/')) + t.ok(await doesNotProxy('http://10.0.0.1:1337/')) + t.ok(await doesNotProxy('http://10.0.0.2/')) + t.ok(await doesNotProxy('http://10.0.0.2:80/')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://10.0.0.2:1337/')) + return dispatcher.close() + }) + + test('CIDR is NOT supported', async (t) => { + t = tspl(t, { plan: 2 }) + process.env.NO_PROXY = '127.0.0.1/32' + const { dispatcher, usesProxyAgent } = createEnvHttpProxyAgentWithMocks(2) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://127.0.0.1')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://127.0.0.1/32')) + return dispatcher.close() + }) + + test('127.0.0.1 does NOT match localhost', async (t) => { + t = tspl(t, { plan: 2 }) + process.env.NO_PROXY = '127.0.0.1' + const { dispatcher, doesNotProxy, usesProxyAgent } = createEnvHttpProxyAgentWithMocks(2) + t.ok(await doesNotProxy('http://127.0.0.1')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://localhost')) + return dispatcher.close() + }) + + test('protocols that have a default port', async (t) => { + t = tspl(t, { plan: 6 }) + process.env.NO_PROXY = 'xxx:21,xxx:70,xxx:80,xxx:443' + const { dispatcher, doesNotProxy, usesProxyAgent } = createEnvHttpProxyAgentWithMocks(6) + t.ok(await doesNotProxy('http://xxx')) + t.ok(await doesNotProxy('http://xxx:80')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://xxx:1337')) + t.ok(await doesNotProxy('https://xxx')) + t.ok(await doesNotProxy('https://xxx:443')) + t.ok(await usesProxyAgent(kHttpsProxyAgent, 'https://xxx:1337')) + return dispatcher.close() + }) + + test('should not be case-sensitive', async (t) => { + t = tspl(t, { plan: 6 }) + process.env.no_proxy = 'XXX YYY ZzZ' + const { dispatcher, doesNotProxy } = createEnvHttpProxyAgentWithMocks(6) + t.ok(await doesNotProxy('http://xxx')) + t.ok(await doesNotProxy('http://XXX')) + t.ok(await doesNotProxy('http://yyy')) + t.ok(await doesNotProxy('http://YYY')) + t.ok(await doesNotProxy('http://ZzZ')) + t.ok(await doesNotProxy('http://zZz')) + return dispatcher.close() + }) + + test('prefers uppercase over lower case', async (t) => { + t = tspl(t, { plan: 2 }) + process.env.no_proxy = 'sub.example.com' + process.env.NO_PROXY = 'example.com' + const { dispatcher, doesNotProxy, usesProxyAgent } = createEnvHttpProxyAgentWithMocks(6) + t.ok(await doesNotProxy('http://example.com')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://sub.example.com')) + return dispatcher.close() + }) + + test('prefers uppercase over lower case even when it is empty', async (t) => { + t = tspl(t, { plan: 1 }) + process.env.no_proxy = 'example.com' + process.env.NO_PROXY = '' + const { dispatcher, usesProxyAgent } = createEnvHttpProxyAgentWithMocks() + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://example.com')) + return dispatcher.close() + }) + + test('handles env var changes', async (t) => { + t = tspl(t, { plan: 4 }) + process.env.NO_PROXY = 'example.com' + const { dispatcher, doesNotProxy, usesProxyAgent } = createEnvHttpProxyAgentWithMocks(4) + t.ok(await doesNotProxy('http://example.com')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://sub.example.com')) + process.env.NO_PROXY = 'sub.example.com' + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://example.com')) + t.ok(await doesNotProxy('http://sub.example.com')) + return dispatcher.close() + }) + + test('ignores env var changes when set via config', async (t) => { + t = tspl(t, { plan: 4 }) + const { dispatcher, doesNotProxy, usesProxyAgent } = createEnvHttpProxyAgentWithMocks(4, { noProxy: 'example.com' }) + t.ok(await doesNotProxy('http://example.com')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://sub.example.com')) + process.env.NO_PROXY = 'sub.example.com' + t.ok(await doesNotProxy('http://example.com')) + t.ok(await usesProxyAgent(kHttpProxyAgent, 'http://sub.example.com')) + return dispatcher.close() + }) +}) diff --git a/test/types/env-http-proxy-agent.test-d.ts b/test/types/env-http-proxy-agent.test-d.ts new file mode 100644 index 00000000000..c08a720e301 --- /dev/null +++ b/test/types/env-http-proxy-agent.test-d.ts @@ -0,0 +1,110 @@ +import { Duplex, Readable, Writable } from 'stream' +import { expectAssignable } from 'tsd' +import { EnvHttpProxyAgent, setGlobalDispatcher, getGlobalDispatcher, Dispatcher } from '../..' + +expectAssignable(new EnvHttpProxyAgent()) +expectAssignable(new EnvHttpProxyAgent({ httpProxy: 'http://localhost:8080', httpsProxy: 'http://localhost:8443', noProxy: 'localhost' })) + +{ + const agent = new EnvHttpProxyAgent() + expectAssignable(setGlobalDispatcher(agent)) + expectAssignable(getGlobalDispatcher()) + + // request + expectAssignable>(agent.request({ origin: '', path: '', method: 'GET' })) + expectAssignable>(agent.request({ origin: '', path: '', method: 'GET', onInfo: ((info) => {}) })) + expectAssignable>(agent.request({ origin: new URL('http://localhost'), path: '', method: 'GET' })) + expectAssignable(agent.request({ origin: '', path: '', method: 'GET' }, (err, data) => { + expectAssignable(err) + expectAssignable(data) + })) + expectAssignable(agent.request({ origin: new URL('http://localhost'), path: '', method: 'GET' }, (err, data) => { + expectAssignable(err) + expectAssignable(data) + })) + + // stream + expectAssignable>(agent.stream({ origin: '', path: '', method: 'GET' }, data => { + expectAssignable(data) + return new Writable() + })) + expectAssignable>(agent.stream({ origin: '', path: '', method: 'GET', onInfo: ((info) => {}) }, data => { + expectAssignable(data) + return new Writable() + })) + expectAssignable>(agent.stream({ origin: new URL('http://localhost'), path: '', method: 'GET' }, data => { + expectAssignable(data) + return new Writable() + })) + expectAssignable(agent.stream( + { origin: '', path: '', method: 'GET' }, + data => { + expectAssignable(data) + return new Writable() + }, + (err, data) => { + expectAssignable(err) + expectAssignable(data) + } + )) + expectAssignable(agent.stream( + { origin: new URL('http://localhost'), path: '', method: 'GET' }, + data => { + expectAssignable(data) + return new Writable() + }, + (err, data) => { + expectAssignable(err) + expectAssignable(data) + } + )) + + // pipeline + expectAssignable(agent.pipeline({ origin: '', path: '', method: 'GET' }, data => { + expectAssignable(data) + return new Readable() + })) + expectAssignable(agent.pipeline({ origin: '', path: '', method: 'GET', onInfo: ((info) => {}) }, data => { + expectAssignable(data) + return new Readable() + })) + expectAssignable(agent.pipeline({ origin: new URL('http://localhost'), path: '', method: 'GET' }, data => { + expectAssignable(data) + return new Readable() + })) + + // upgrade + expectAssignable>(agent.upgrade({ path: '' })) + expectAssignable(agent.upgrade({ path: '' }, (err, data) => { + expectAssignable(err) + expectAssignable(data) + })) + + // connect + expectAssignable>(agent.connect({ origin: '', path: '' })) + expectAssignable>(agent.connect({ origin: new URL('http://localhost'), path: '' })) + expectAssignable(agent.connect({ origin: '', path: '' }, (err, data) => { + expectAssignable(err) + expectAssignable(data) + })) + expectAssignable(agent.connect({ origin: new URL('http://localhost'), path: '' }, (err, data) => { + expectAssignable(err) + expectAssignable(data) + })) + + // dispatch + expectAssignable(agent.dispatch({ origin: '', path: '', method: 'GET' }, {})) + expectAssignable(agent.dispatch({ origin: '', path: '', method: 'GET', maxRedirections: 1 }, {})) + + // close + expectAssignable>(agent.close()) + expectAssignable(agent.close(() => {})) + + // destroy + expectAssignable>(agent.destroy()) + expectAssignable>(agent.destroy(new Error())) + expectAssignable>(agent.destroy(null)) + expectAssignable(agent.destroy(() => {})) + expectAssignable(agent.destroy(new Error(), () => {})) + expectAssignable(agent.destroy(null, () => {})) +} diff --git a/types/env-http-proxy-agent.d.ts b/types/env-http-proxy-agent.d.ts new file mode 100644 index 00000000000..d6509dc673b --- /dev/null +++ b/types/env-http-proxy-agent.d.ts @@ -0,0 +1,21 @@ +import Agent from './agent' +import Dispatcher from './dispatcher' + +export default EnvHttpProxyAgent + +declare class EnvHttpProxyAgent extends Dispatcher { + constructor(opts?: EnvHttpProxyAgent.Options) + + dispatch(options: Agent.DispatchOptions, handler: Dispatcher.DispatchHandlers): boolean; +} + +declare namespace EnvHttpProxyAgent { + export interface Options extends Agent.Options { + /** Overrides the value of the HTTP_PROXY environment variable */ + httpProxy?: string; + /** Overrides the value of the HTTPS_PROXY environment variable */ + httpsProxy?: string; + /** Overrides the value of the NO_PROXY environment variable */ + noProxy?: string; + } +} diff --git a/types/index.d.ts b/types/index.d.ts index 05f01ea1f4d..08f0684d96d 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -14,6 +14,7 @@ import MockPool from'./mock-pool' import MockAgent from'./mock-agent' import mockErrors from'./mock-errors' import ProxyAgent from'./proxy-agent' +import EnvHttpProxyAgent from './env-http-proxy-agent' import RetryHandler from'./retry-handler' import RetryAgent from'./retry-agent' import { request, pipeline, stream, connect, upgrade } from './api' @@ -31,7 +32,7 @@ export * from './content-type' export * from './cache' export { Interceptable } from './mock-interceptor' -export { Dispatcher, BalancedPool, Pool, Client, buildConnector, errors, Agent, request, stream, pipeline, connect, upgrade, setGlobalDispatcher, getGlobalDispatcher, setGlobalOrigin, getGlobalOrigin, MockClient, MockPool, MockAgent, mockErrors, ProxyAgent, RedirectHandler, DecoratorHandler, RetryHandler, RetryAgent } +export { Dispatcher, BalancedPool, Pool, Client, buildConnector, errors, Agent, request, stream, pipeline, connect, upgrade, setGlobalDispatcher, getGlobalDispatcher, setGlobalOrigin, getGlobalOrigin, MockClient, MockPool, MockAgent, mockErrors, ProxyAgent, EnvHttpProxyAgent, RedirectHandler, DecoratorHandler, RetryHandler, RetryAgent } export default Undici declare namespace Undici {