From 560d35b1714382ba8107315b8e32ea0370d4c2dc Mon Sep 17 00:00:00 2001 From: Carlos Fuentes Date: Fri, 23 Feb 2024 11:54:13 +0100 Subject: [PATCH 01/19] feat: add new dispatch compose --- index.js | 9 +- lib/dispatcher.js | 25 +++++- lib/{proxy-agent.js => interceptor/proxy.js} | 88 +++++++++++++++----- lib/interceptor/redirect.js | 54 ++++++++++++ lib/interceptor/retry.js | 33 ++++++++ 5 files changed, 187 insertions(+), 22 deletions(-) rename lib/{proxy-agent.js => interceptor/proxy.js} (67%) create mode 100644 lib/interceptor/redirect.js create mode 100644 lib/interceptor/retry.js diff --git a/index.js b/index.js index dd52f2e46a1..3bceedb9c30 100644 --- a/index.js +++ b/index.js @@ -14,7 +14,7 @@ const MockClient = require('./lib/mock/mock-client') const MockAgent = require('./lib/mock/mock-agent') const MockPool = require('./lib/mock/mock-pool') const mockErrors = require('./lib/mock/mock-errors') -const ProxyAgent = require('./lib/proxy-agent') +const Proxy = require('./lib/interceptor/proxy') const RetryAgent = require('./lib/retry-agent') const RetryHandler = require('./lib/handler/RetryHandler') const { getGlobalDispatcher, setGlobalDispatcher } = require('./lib/global') @@ -29,13 +29,18 @@ module.exports.Client = Client module.exports.Pool = Pool module.exports.BalancedPool = BalancedPool module.exports.Agent = Agent -module.exports.ProxyAgent = ProxyAgent +module.exports.ProxyAgent = Proxy.ProxyAgent module.exports.RetryAgent = RetryAgent module.exports.RetryHandler = RetryHandler module.exports.DecoratorHandler = DecoratorHandler module.exports.RedirectHandler = RedirectHandler module.exports.createRedirectInterceptor = createRedirectInterceptor +module.exports.interceptors = { + proxy: Proxy.interceptor, + redirect: require('./lib/interceptor/redirect'), + retry: require('./lib/interceptor/retry') +} module.exports.buildConnector = buildConnector module.exports.errors = errors diff --git a/lib/dispatcher.js b/lib/dispatcher.js index 71db7e2bb49..6dfb9cd405b 100644 --- a/lib/dispatcher.js +++ b/lib/dispatcher.js @@ -1,8 +1,11 @@ 'use strict' - const EventEmitter = require('node:events') +const kDispatcherVersion = Symbol.for('undici.dispatcher.version') + class Dispatcher extends EventEmitter { + [kDispatcherVersion] = 1 + dispatch () { throw new Error('not implemented') } @@ -14,6 +17,26 @@ class Dispatcher extends EventEmitter { destroy () { throw new Error('not implemented') } + + compose (...interceptors) { + let dispatcher = this + for (const interceptor of interceptors) { + if (interceptor == null) { + continue + } + + if (typeof interceptor !== 'function') { + throw new Error('invalid interceptor') + } + + dispatcher = interceptor(dispatcher) ?? dispatcher + + if (dispatcher[kDispatcherVersion] !== 1) { + throw new Error('invalid dispatcher') + } + } + return dispatcher + } } module.exports = Dispatcher diff --git a/lib/proxy-agent.js b/lib/interceptor/proxy.js similarity index 67% rename from lib/proxy-agent.js rename to lib/interceptor/proxy.js index c80134b742d..1c127889a1b 100644 --- a/lib/proxy-agent.js +++ b/lib/interceptor/proxy.js @@ -1,12 +1,12 @@ 'use strict' -const { kProxy, kClose, kDestroy, kInterceptors } = require('./core/symbols') const { URL } = require('node:url') -const Agent = require('./agent') -const Pool = require('./pool') -const DispatcherBase = require('./dispatcher-base') -const { InvalidArgumentError, RequestAbortedError } = require('./core/errors') -const buildConnector = require('./core/connect') +const Agent = require('../agent') +const Pool = require('../pool') +const DispatcherBase = require('../dispatcher-base') +const { kProxy, kClose, kDestroy, kInterceptors } = require('../core/symbols') +const { InvalidArgumentError, RequestAbortedError } = require('../core/errors') +const buildConnector = require('../core/connect') const kAgent = Symbol('proxy agent') const kClient = Symbol('proxy client') @@ -27,13 +27,18 @@ class ProxyAgent extends DispatcherBase { constructor (opts) { super() - if (!opts || (typeof opts === 'object' && !(opts instanceof URL) && !opts.uri)) { + if ( + !opts || + (typeof opts === 'object' && !(opts instanceof URL) && !opts.uri) + ) { throw new InvalidArgumentError('Proxy uri is mandatory') } const { clientFactory = defaultFactory } = opts if (typeof clientFactory !== 'function') { - throw new InvalidArgumentError('Proxy opts.clientFactory must be a function.') + throw new InvalidArgumentError( + 'Proxy opts.clientFactory must be a function.' + ) } const url = this.#getUrl(opts) @@ -41,22 +46,28 @@ class ProxyAgent extends DispatcherBase { this[kProxy] = { uri: href, protocol } this[kAgent] = new Agent(opts) - this[kInterceptors] = opts.interceptors?.ProxyAgent && Array.isArray(opts.interceptors.ProxyAgent) - ? opts.interceptors.ProxyAgent - : [] + this[kInterceptors] = + opts.interceptors?.ProxyAgent && + Array.isArray(opts.interceptors.ProxyAgent) + ? opts.interceptors.ProxyAgent + : [] this[kRequestTls] = opts.requestTls this[kProxyTls] = opts.proxyTls this[kProxyHeaders] = opts.headers || {} if (opts.auth && opts.token) { - throw new InvalidArgumentError('opts.auth cannot be used in combination with opts.token') + throw new InvalidArgumentError( + 'opts.auth cannot be used in combination with opts.token' + ) } else if (opts.auth) { /* @deprecated in favour of opts.token */ this[kProxyHeaders]['proxy-authorization'] = `Basic ${opts.auth}` } else if (opts.token) { this[kProxyHeaders]['proxy-authorization'] = opts.token } else if (username && password) { - this[kProxyHeaders]['proxy-authorization'] = `Basic ${Buffer.from(`${decodeURIComponent(username)}:${decodeURIComponent(password)}`).toString('base64')}` + this[kProxyHeaders]['proxy-authorization'] = `Basic ${Buffer.from( + `${decodeURIComponent(username)}:${decodeURIComponent(password)}` + ).toString('base64')}` } const connect = buildConnector({ ...opts.proxyTls }) @@ -82,7 +93,11 @@ class ProxyAgent extends DispatcherBase { }) if (statusCode !== 200) { socket.on('error', () => {}).destroy() - callback(new RequestAbortedError(`Proxy response (${statusCode}) !== 200 when HTTP Tunneling`)) + callback( + new RequestAbortedError( + `Proxy response (${statusCode}) !== 200 when HTTP Tunneling` + ) + ) } if (opts.protocol !== 'https:') { callback(null, socket) @@ -94,7 +109,10 @@ class ProxyAgent extends DispatcherBase { } else { servername = opts.servername } - this[kConnectEndpoint]({ ...opts, servername, httpSocket: socket }, callback) + this[kConnectEndpoint]( + { ...opts, servername, httpSocket: socket }, + callback + ) } catch (err) { callback(err) } @@ -173,11 +191,43 @@ function buildHeaders (headers) { * It should be removed in the next major version for performance reasons */ function throwIfProxyAuthIsSent (headers) { - const existProxyAuth = headers && Object.keys(headers) - .find((key) => key.toLowerCase() === 'proxy-authorization') + const existProxyAuth = + headers && + Object.keys(headers).find( + key => key.toLowerCase() === 'proxy-authorization' + ) if (existProxyAuth) { - throw new InvalidArgumentError('Proxy-Authorization should be sent in ProxyAgent constructor') + throw new InvalidArgumentError( + 'Proxy-Authorization should be sent in ProxyAgent constructor' + ) } } -module.exports = ProxyAgent +module.exports = { + ProxyAgent, + interceptor: opts => { + if (typeof opts === 'string') { + opts = { uri: opts } + } + + if (!opts || !opts.uri) { + throw new InvalidArgumentError('Proxy opts.uri is mandatory') + } + + const { clientFactory = defaultFactory } = opts + + if (typeof clientFactory !== 'function') { + throw new InvalidArgumentError( + 'Proxy opts.clientFactory must be a function.' + ) + } + + if (opts.auth && opts.token) { + throw new InvalidArgumentError( + 'opts.auth cannot be used in combination with opts.token' + ) + } + + return dispatcher => new ProxyAgent(dispatcher, opts) + } +} diff --git a/lib/interceptor/redirect.js b/lib/interceptor/redirect.js new file mode 100644 index 00000000000..259d73f7c35 --- /dev/null +++ b/lib/interceptor/redirect.js @@ -0,0 +1,54 @@ +const { InvalidArgumentError } = require('../core/errors') +const Dispatcher = require('../dispatcher-base') +const RedirectHandler = require('../handler/RedirectHandler') + +class RedirectDispatcher extends Dispatcher { + #opts + #dispatcher + + constructor (dispatcher, opts) { + super() + + this.#dispatcher = dispatcher + this.#opts = opts + } + + dispatch (opts, handler) { + return this.#dispatcher.dispatch( + opts, + new RedirectHandler(this.#dispatcher, opts, this.#opts, handler) + ) + } + + close (...args) { + return this.#dispatcher.close(...args) + } + + destroy (...args) { + return this.#dispatcher.destroy(...args) + } +} + +module.exports = opts => { + if (opts?.maxRedirections == null || opts?.maxRedirections === 0) { + return null + } + + if (!Number.isInteger(opts.maxRedirections) || opts.maxRedirections < 0) { + throw new InvalidArgumentError('maxRedirections must be a positive number') + } + + return dispatcher => new RedirectDispatcher(dispatcher, opts) +} + +module.exports = opts => { + if (opts?.maxRedirections == null || opts?.maxRedirections === 0) { + return null + } + + if (!Number.isInteger(opts.maxRedirections) || opts.maxRedirections < 0) { + throw new InvalidArgumentError('maxRedirections must be a positive number') + } + + return dispatcher => new RedirectDispatcher(dispatcher, opts) +} diff --git a/lib/interceptor/retry.js b/lib/interceptor/retry.js new file mode 100644 index 00000000000..976a1fa737e --- /dev/null +++ b/lib/interceptor/retry.js @@ -0,0 +1,33 @@ +const Dispatcher = require('../dispatcher-base') +const RetryHandler = require('../handler/RetryHandler') + +class RetryDispatcher extends Dispatcher { + #dispatcher + #opts + + constructor (dispatcher, opts) { + super() + + this.#dispatcher = dispatcher + this.#opts = opts + } + + dispatch (opts, handler) { + return this.#dispatcher.dispatch( + opts, + new RetryHandler(this.#dispatcher, opts, this.#opts, handler) + ) + } + + close (...args) { + return this.#dispatcher.close(...args) + } + + destroy (...args) { + return this.#dispatcher.destroy(...args) + } +} + +module.exports = opts => { + return dispatcher => new RetryDispatcher(dispatcher, opts) +} From 5337ea0d4567c66c229b83f4787467e388fe5df6 Mon Sep 17 00:00:00 2001 From: Carlos Fuentes Date: Sun, 25 Feb 2024 11:30:41 +0100 Subject: [PATCH 02/19] fix: review Co-authored-by: Matteo Collina --- lib/interceptor/redirect.js | 2 ++ lib/interceptor/retry.js | 2 ++ 2 files changed, 4 insertions(+) diff --git a/lib/interceptor/redirect.js b/lib/interceptor/redirect.js index 259d73f7c35..387d7aeaf73 100644 --- a/lib/interceptor/redirect.js +++ b/lib/interceptor/redirect.js @@ -1,3 +1,5 @@ +'use strict' + const { InvalidArgumentError } = require('../core/errors') const Dispatcher = require('../dispatcher-base') const RedirectHandler = require('../handler/RedirectHandler') diff --git a/lib/interceptor/retry.js b/lib/interceptor/retry.js index 976a1fa737e..27158049ac6 100644 --- a/lib/interceptor/retry.js +++ b/lib/interceptor/retry.js @@ -1,3 +1,5 @@ +'use strict' + const Dispatcher = require('../dispatcher-base') const RetryHandler = require('../handler/RetryHandler') From 8019e590cd44659aa24c86047fb30c2cd6e6eb82 Mon Sep 17 00:00:00 2001 From: Carlos Fuentes Date: Sun, 25 Feb 2024 11:31:02 +0100 Subject: [PATCH 03/19] revert: linting --- lib/interceptor/proxy.js | 27 ++++++++------------------- 1 file changed, 8 insertions(+), 19 deletions(-) diff --git a/lib/interceptor/proxy.js b/lib/interceptor/proxy.js index 1c127889a1b..ab07fb9d82c 100644 --- a/lib/interceptor/proxy.js +++ b/lib/interceptor/proxy.js @@ -46,28 +46,23 @@ class ProxyAgent extends DispatcherBase { this[kProxy] = { uri: href, protocol } this[kAgent] = new Agent(opts) - this[kInterceptors] = - opts.interceptors?.ProxyAgent && - Array.isArray(opts.interceptors.ProxyAgent) - ? opts.interceptors.ProxyAgent - : [] + this[kInterceptors] = opts.interceptors?.ProxyAgent && Array.isArray(opts.interceptors.ProxyAgent) + ? opts.interceptors.ProxyAgent + : [] this[kRequestTls] = opts.requestTls this[kProxyTls] = opts.proxyTls this[kProxyHeaders] = opts.headers || {} if (opts.auth && opts.token) { - throw new InvalidArgumentError( - 'opts.auth cannot be used in combination with opts.token' - ) + throw new InvalidArgumentError('opts.auth cannot be used in combination with opts.token') } else if (opts.auth) { /* @deprecated in favour of opts.token */ this[kProxyHeaders]['proxy-authorization'] = `Basic ${opts.auth}` } else if (opts.token) { this[kProxyHeaders]['proxy-authorization'] = opts.token } else if (username && password) { - this[kProxyHeaders]['proxy-authorization'] = `Basic ${Buffer.from( - `${decodeURIComponent(username)}:${decodeURIComponent(password)}` - ).toString('base64')}` + this[kProxyHeaders]['proxy-authorization'] = `Basic + ${Buffer.from(`${decodeURIComponent(username)}:${decodeURIComponent(password)}`).toString('base64')}` } const connect = buildConnector({ ...opts.proxyTls }) @@ -93,10 +88,7 @@ class ProxyAgent extends DispatcherBase { }) if (statusCode !== 200) { socket.on('error', () => {}).destroy() - callback( - new RequestAbortedError( - `Proxy response (${statusCode}) !== 200 when HTTP Tunneling` - ) + callback(new RequestAbortedError(`Proxy response (${statusCode}) !== 200 when HTTP Tunneling`) ) } if (opts.protocol !== 'https:') { @@ -109,10 +101,7 @@ class ProxyAgent extends DispatcherBase { } else { servername = opts.servername } - this[kConnectEndpoint]( - { ...opts, servername, httpSocket: socket }, - callback - ) + this[kConnectEndpoint]({ ...opts, servername, httpSocket: socket }, callback) } catch (err) { callback(err) } From 1d541700addc3fe5b2395ee8ad70badcdad131d8 Mon Sep 17 00:00:00 2001 From: Carlos Fuentes Date: Sun, 25 Feb 2024 12:23:54 +0100 Subject: [PATCH 04/19] docs: add documentation --- docs/docs/api/Dispatcher.md | 125 ++++++++++++++++++++++++++++++++++ docs/docs/api/Interceptors.md | 68 ++++++++++++++++++ docs/docsify/sidebar.md | 1 + lib/dispatcher.js | 4 +- lib/interceptor/redirect.js | 12 ---- 5 files changed, 197 insertions(+), 13 deletions(-) create mode 100644 docs/docs/api/Interceptors.md diff --git a/docs/docs/api/Dispatcher.md b/docs/docs/api/Dispatcher.md index 0c678fc8623..e2d8e033c9d 100644 --- a/docs/docs/api/Dispatcher.md +++ b/docs/docs/api/Dispatcher.md @@ -817,6 +817,131 @@ try { } ``` +### `Dispatcher.compose(interceptors[, interceptor])` + +Compose a new dispatcher from the current dispatcher and the given interceptors. + +> _Notes_: +> - The order of the interceptors is important. The first interceptor will be the first to be called. +> - It is important to note that the `interceptor` function should return a `Dispatcher` instance. +> - Any fork of the chain of `interceptors` can lead to unexpected results, it is important that an interceptor returns a `Dispatcher` instance that forwards the request to the next interceptor in the chain. + +Arguments: + +* **interceptors** `Interceptor[]`: It is an array of `Interceptor` functions passed as only argument, or several interceptors passed as separate arguments. + +Returns: `Dispatcher`. + +#### Parameter: `Interceptor` + +A function that takes a `Dispatcher` instance and returns a `Dispatcher` instance. + +#### Example 1 - Basic Compose + +```js +import { RedirectHandler, Dispatcher } from 'undici' + +class RedirectDispatcher extends Dispatcher { + #opts + #dispatcher + + constructor (dispatcher, opts) { + super() + + this.#dispatcher = dispatcher + this.#opts = opts + } + + dispatch (opts, handler) { + return this.#dispatcher.dispatch( + opts, + new RedirectHandler(this.#dispatcher, opts, this.#opts, handler) + ) + } + + close (...args) { + return this.#dispatcher.close(...args) + } + + destroy (...args) { + return this.#dispatcher.destroy(...args) + } +} + +const redirectInterceptor = dispatcher => new RedirectDispatcher(dispatcher, opts) + +const client = new Client('http://localhost:3000') + .compose(redirectInterceptor) +``` + +#### Example 2 - Chained Compose + +```js +import { RedirectHandler, Dispatcher, RetryHandler } from 'undici' + +class RedirectDispatcher extends Dispatcher { + #opts + #dispatcher + + constructor (dispatcher, opts) { + super() + + this.#dispatcher = dispatcher + this.#opts = opts + } + + dispatch (opts, handler) { + return this.#dispatcher.dispatch( + opts, + new RedirectHandler(this.#dispatcher, opts, this.#opts, handler) + ) + } + + close (...args) { + return this.#dispatcher.close(...args) + } + + destroy (...args) { + return this.#dispatcher.destroy(...args) + } +} + +class RetryDispatcher extends Dispatcher { + #dispatcher + #opts + + constructor (dispatcher, opts) { + super() + + this.#dispatcher = dispatcher + this.#opts = opts + } + + dispatch (opts, handler) { + return this.#dispatcher.dispatch( + opts, + new RetryHandler(this.#dispatcher, opts, this.#opts, handler) + ) + } + + close (...args) { + return this.#dispatcher.close(...args) + } + + destroy (...args) { + return this.#dispatcher.destroy(...args) + } +} + + +const redirectInterceptor = dispatcher => new RedirectDispatcher(dispatcher, opts) +const retryInterceptor = dispatcher => new RetryDispatcher(dispatcher, opts) + +const client = new Client('http://localhost:3000') + .compose(redirectInterceptor) + .compose(retryInterceptor) +``` + ## Instance Events ### Event: `'connect'` diff --git a/docs/docs/api/Interceptors.md b/docs/docs/api/Interceptors.md new file mode 100644 index 00000000000..cd66fc3cbab --- /dev/null +++ b/docs/docs/api/Interceptors.md @@ -0,0 +1,68 @@ +# Interceptors + +Undici provides a way to intercept requests and responses using interceptors. + +Interceptors are a way to modify the request or response before it is sent or received by the original dispatcher, apply custom logic to a network request, or even cancel the request, connect through a proxy for the origin, etc. + +Within Undici there are a set of pre-built that can be used, on top of that, you can create your own interceptors. + +## Pre-built interceptors + +### `proxy` + +The `proxy` interceptor allows you to connect to a proxy server before connecting to the origin server. + +It accepts the same arguments as the [`ProxyAgent` constructor](./ProxyAgent.md). + +#### Example - Basic Proxy Interceptor + +```js +const { Client, interceptors } = require("undici"); +const { proxy } = interceptors; + +const client = new Client("http://example.com"); + +client.compose(proxy("http://proxy.com")); +``` + +### `redirect` + +The `redirect` interceptor allows you to customize the way your dispatcher handles redirects. + +It accepts the same arguments as the [`RedirectHandler` constructor](./RedirectHandler.md). + +#### Example - Basic Redirect Interceptor + +```js +const { Client, interceptors } = require("undici"); +const { redirect } = interceptors; + +const client = new Client("http://example.com"); + +client.compose(redirect({ maxRedirections: 3, throwOnMaxRedirects: true })); +``` + +### `retry` + +The `retry` interceptor allows you to customize the way your dispatcher handles retries. + +It accepts the same arguments as the [`RetryHandler` constructor](./RetryHandler.md). + +#### Example - Basic Redirect Interceptor + +```js +const { Client, interceptors } = require("undici"); +const { retry } = interceptors; + +const client = new Client("http://example.com"); + +client.compose( + retry({ + maxRetries: 3, + minTimeout: 1000, + maxTimeout: 10000, + timeoutFactor: 2, + retryAfter: true, + }) +); +``` diff --git a/docs/docsify/sidebar.md b/docs/docsify/sidebar.md index 674c0dad0e7..abcde521c76 100644 --- a/docs/docsify/sidebar.md +++ b/docs/docsify/sidebar.md @@ -9,6 +9,7 @@ * [Agent](/docs/api/Agent.md "Undici API - Agent") * [ProxyAgent](/docs/api/ProxyAgent.md "Undici API - ProxyAgent") * [RetryAgent](/docs/api/RetryAgent.md "Undici API - RetryAgent") + * [Interceptors](/docs/api/Interceptors.md "Undici API - Interceptors") * [Connector](/docs/api/Connector.md "Custom connector") * [Errors](/docs/api/Errors.md "Undici API - Errors") * [EventSource](/docs/api/EventSource.md "Undici API - EventSource") diff --git a/lib/dispatcher.js b/lib/dispatcher.js index 6dfb9cd405b..a5a2e8edd0f 100644 --- a/lib/dispatcher.js +++ b/lib/dispatcher.js @@ -18,7 +18,9 @@ class Dispatcher extends EventEmitter { throw new Error('not implemented') } - compose (...interceptors) { + compose (...args) { + // So we handle [interceptor1, interceptor2] or interceptor1, interceptor2, ... + const interceptors = Array.isArray(args[0]) ? args[0] : args let dispatcher = this for (const interceptor of interceptors) { if (interceptor == null) { diff --git a/lib/interceptor/redirect.js b/lib/interceptor/redirect.js index 387d7aeaf73..9c789f46806 100644 --- a/lib/interceptor/redirect.js +++ b/lib/interceptor/redirect.js @@ -42,15 +42,3 @@ module.exports = opts => { return dispatcher => new RedirectDispatcher(dispatcher, opts) } - -module.exports = opts => { - if (opts?.maxRedirections == null || opts?.maxRedirections === 0) { - return null - } - - if (!Number.isInteger(opts.maxRedirections) || opts.maxRedirections < 0) { - throw new InvalidArgumentError('maxRedirections must be a positive number') - } - - return dispatcher => new RedirectDispatcher(dispatcher, opts) -} From b45ecad559e41bbc057ee8edc899db9cb92e80f6 Mon Sep 17 00:00:00 2001 From: Carlos Fuentes Date: Wed, 28 Feb 2024 11:20:07 +0100 Subject: [PATCH 05/19] fix: smaller tweaks to proxy interceptor --- index.js | 4 +- lib/interceptor/proxy.js | 221 +---------- lib/interceptor/redirect.js | 2 +- lib/interceptor/retry.js | 2 +- test/interceptors/proxy.js | 771 ++++++++++++++++++++++++++++++++++++ 5 files changed, 794 insertions(+), 206 deletions(-) create mode 100644 test/interceptors/proxy.js diff --git a/index.js b/index.js index 917aa7d1d86..8561dcd18cc 100644 --- a/index.js +++ b/index.js @@ -29,7 +29,7 @@ module.exports.Client = Client module.exports.Pool = Pool module.exports.BalancedPool = BalancedPool module.exports.Agent = Agent -module.exports.ProxyAgent = Proxy.ProxyAgent +module.exports.ProxyAgent = ProxyAgent module.exports.RetryAgent = RetryAgent module.exports.RetryHandler = RetryHandler @@ -37,7 +37,7 @@ module.exports.DecoratorHandler = DecoratorHandler module.exports.RedirectHandler = RedirectHandler module.exports.createRedirectInterceptor = createRedirectInterceptor module.exports.interceptors = { - proxy: Proxy.interceptor, + proxy: require('./lib/interceptor/proxy'), redirect: require('./lib/interceptor/redirect'), retry: require('./lib/interceptor/retry') } diff --git a/lib/interceptor/proxy.js b/lib/interceptor/proxy.js index ab07fb9d82c..d01fb815de2 100644 --- a/lib/interceptor/proxy.js +++ b/lib/interceptor/proxy.js @@ -1,222 +1,39 @@ 'use strict' -const { URL } = require('node:url') -const Agent = require('../agent') -const Pool = require('../pool') -const DispatcherBase = require('../dispatcher-base') -const { kProxy, kClose, kDestroy, kInterceptors } = require('../core/symbols') -const { InvalidArgumentError, RequestAbortedError } = require('../core/errors') -const buildConnector = require('../core/connect') +const { InvalidArgumentError } = require('../core/errors') +const ProxyAgent = require('../dispatcher/proxy-agent') +const DispatcherBase = require('../dispatcher/dispatcher-base') -const kAgent = Symbol('proxy agent') -const kClient = Symbol('proxy client') -const kProxyHeaders = Symbol('proxy headers') -const kRequestTls = Symbol('request tls settings') -const kProxyTls = Symbol('proxy tls settings') -const kConnectEndpoint = Symbol('connect endpoint function') - -function defaultProtocolPort (protocol) { - return protocol === 'https:' ? 443 : 80 -} - -function defaultFactory (origin, opts) { - return new Pool(origin, opts) -} - -class ProxyAgent extends DispatcherBase { - constructor (opts) { +class ProxyInterceptor extends DispatcherBase { + constructor (dispatcher, opts) { super() - - if ( - !opts || - (typeof opts === 'object' && !(opts instanceof URL) && !opts.uri) - ) { - throw new InvalidArgumentError('Proxy uri is mandatory') - } - - const { clientFactory = defaultFactory } = opts - if (typeof clientFactory !== 'function') { - throw new InvalidArgumentError( - 'Proxy opts.clientFactory must be a function.' - ) - } - - const url = this.#getUrl(opts) - const { href, origin, port, protocol, username, password } = url - - this[kProxy] = { uri: href, protocol } - this[kAgent] = new Agent(opts) - this[kInterceptors] = opts.interceptors?.ProxyAgent && Array.isArray(opts.interceptors.ProxyAgent) - ? opts.interceptors.ProxyAgent - : [] - this[kRequestTls] = opts.requestTls - this[kProxyTls] = opts.proxyTls - this[kProxyHeaders] = opts.headers || {} - - if (opts.auth && opts.token) { - throw new InvalidArgumentError('opts.auth cannot be used in combination with opts.token') - } else if (opts.auth) { - /* @deprecated in favour of opts.token */ - this[kProxyHeaders]['proxy-authorization'] = `Basic ${opts.auth}` - } else if (opts.token) { - this[kProxyHeaders]['proxy-authorization'] = opts.token - } else if (username && password) { - this[kProxyHeaders]['proxy-authorization'] = `Basic - ${Buffer.from(`${decodeURIComponent(username)}:${decodeURIComponent(password)}`).toString('base64')}` - } - - const connect = buildConnector({ ...opts.proxyTls }) - this[kConnectEndpoint] = buildConnector({ ...opts.requestTls }) - this[kClient] = clientFactory(url, { connect }) - this[kAgent] = new Agent({ - ...opts, - connect: async (opts, callback) => { - let requestedHost = opts.host - if (!opts.port) { - requestedHost += `:${defaultProtocolPort(opts.protocol)}` - } - try { - const { socket, statusCode } = await this[kClient].connect({ - origin, - port, - path: requestedHost, - signal: opts.signal, - headers: { - ...this[kProxyHeaders], - host: requestedHost - } - }) - if (statusCode !== 200) { - socket.on('error', () => {}).destroy() - callback(new RequestAbortedError(`Proxy response (${statusCode}) !== 200 when HTTP Tunneling`) - ) - } - if (opts.protocol !== 'https:') { - callback(null, socket) - return - } - let servername - if (this[kRequestTls]) { - servername = this[kRequestTls].servername - } else { - servername = opts.servername - } - this[kConnectEndpoint]({ ...opts, servername, httpSocket: socket }, callback) - } catch (err) { - callback(err) - } - } - }) + this.dispatcher = dispatcher + this.agent = new ProxyAgent(opts) } dispatch (opts, handler) { - const { host } = new URL(opts.origin) - const headers = buildHeaders(opts.headers) - throwIfProxyAuthIsSent(headers) - return this[kAgent].dispatch( - { - ...opts, - headers: { - ...headers, - host - } - }, - handler - ) - } - - /** - * @param {import('../types/proxy-agent').ProxyAgent.Options | string | URL} opts - * @returns {URL} - */ - #getUrl (opts) { - if (typeof opts === 'string') { - return new URL(opts) - } else if (opts instanceof URL) { - return opts - } else { - return new URL(opts.uri) - } + return this.agent.dispatch(opts, handler) } - async [kClose] () { - await this[kAgent].close() - await this[kClient].close() - } - - async [kDestroy] () { - await this[kAgent].destroy() - await this[kClient].destroy() + close () { + return this.dispatcher.close().then(() => this.agent.close()) } } -/** - * @param {string[] | Record} headers - * @returns {Record} - */ -function buildHeaders (headers) { - // When using undici.fetch, the headers list is stored - // as an array. - if (Array.isArray(headers)) { - /** @type {Record} */ - const headersPair = {} - - for (let i = 0; i < headers.length; i += 2) { - headersPair[headers[i]] = headers[i + 1] - } - - return headersPair +module.exports = opts => { + if (typeof opts === 'string') { + opts = { uri: opts } } - return headers -} + if (!opts || (!opts.uri && !(opts instanceof URL))) { + throw new InvalidArgumentError('Proxy opts.uri or instance of URL is mandatory') + } -/** - * @param {Record} headers - * - * Previous versions of ProxyAgent suggests the Proxy-Authorization in request headers - * Nevertheless, it was changed and to avoid a security vulnerability by end users - * this check was created. - * It should be removed in the next major version for performance reasons - */ -function throwIfProxyAuthIsSent (headers) { - const existProxyAuth = - headers && - Object.keys(headers).find( - key => key.toLowerCase() === 'proxy-authorization' - ) - if (existProxyAuth) { + if (opts.auth && opts.token) { throw new InvalidArgumentError( - 'Proxy-Authorization should be sent in ProxyAgent constructor' + 'opts.auth cannot be used in combination with opts.token' ) } -} - -module.exports = { - ProxyAgent, - interceptor: opts => { - if (typeof opts === 'string') { - opts = { uri: opts } - } - - if (!opts || !opts.uri) { - throw new InvalidArgumentError('Proxy opts.uri is mandatory') - } - - const { clientFactory = defaultFactory } = opts - if (typeof clientFactory !== 'function') { - throw new InvalidArgumentError( - 'Proxy opts.clientFactory must be a function.' - ) - } - - if (opts.auth && opts.token) { - throw new InvalidArgumentError( - 'opts.auth cannot be used in combination with opts.token' - ) - } - - return dispatcher => new ProxyAgent(dispatcher, opts) - } + return dispatcher => new ProxyInterceptor(dispatcher, opts) } diff --git a/lib/interceptor/redirect.js b/lib/interceptor/redirect.js index 9c789f46806..98f37b3cab0 100644 --- a/lib/interceptor/redirect.js +++ b/lib/interceptor/redirect.js @@ -1,7 +1,7 @@ 'use strict' const { InvalidArgumentError } = require('../core/errors') -const Dispatcher = require('../dispatcher-base') +const Dispatcher = require('../dispatcher/dispatcher-base') const RedirectHandler = require('../handler/RedirectHandler') class RedirectDispatcher extends Dispatcher { diff --git a/lib/interceptor/retry.js b/lib/interceptor/retry.js index 27158049ac6..27cd77bf413 100644 --- a/lib/interceptor/retry.js +++ b/lib/interceptor/retry.js @@ -1,6 +1,6 @@ 'use strict' -const Dispatcher = require('../dispatcher-base') +const Dispatcher = require('../dispatcher/dispatcher-base') const RetryHandler = require('../handler/RetryHandler') class RetryDispatcher extends Dispatcher { diff --git a/test/interceptors/proxy.js b/test/interceptors/proxy.js new file mode 100644 index 00000000000..9fa3c270f3c --- /dev/null +++ b/test/interceptors/proxy.js @@ -0,0 +1,771 @@ +'use strict' + +const { test } = require('node:test') +const { readFileSync } = require('node:fs') +const { join } = require('node:path') +const { createServer } = require('node:http') +const https = require('node:https') + +const { tspl } = require('@matteo.collina/tspl') +const { createProxy } = require('proxy') + +const { Client, interceptors } = require('../..') +const { InvalidArgumentError } = require('../../lib/core/errors') +const Pool = require('../../lib/dispatcher/pool') +const { proxy: proxyInterceptor } = interceptors + +test('should throw error when no uri is provided', (t) => { + t = tspl(t, { plan: 2 }) + t.throws(() => proxyInterceptor(), InvalidArgumentError) + t.throws(() => proxyInterceptor({}), InvalidArgumentError) +}) + +test('using auth in combination with token should throw', (t) => { + t = tspl(t, { plan: 1 }) + t.throws(() => proxyInterceptor({ + auth: 'foo', + token: 'Bearer bar', + uri: 'http://example.com' + }), + InvalidArgumentError + ) +}) + +test('should accept string, URL and object as options', (t) => { + t = tspl(t, { plan: 3 }) + t.doesNotThrow(() => proxyInterceptor('http://example.com')) + t.doesNotThrow(() => proxyInterceptor(new URL('http://example.com'))) + t.doesNotThrow(() => proxyInterceptor({ uri: 'http://example.com' })) +}) + +test('use proxy-agent to connect through proxy', async (t) => { + t = tspl(t, { plan: 6 }) + const server = await buildServer() + const proxy = await buildProxy() + delete proxy.authenticate + + const serverUrl = `http://localhost:${server.address().port}` + const proxyUrl = `http://localhost:${proxy.address().port}` + const client = new Client(serverUrl) + const parsedOrigin = new URL(serverUrl) + const dispatcher = client.compose(proxyInterceptor(proxyUrl)) + + proxy.on('connect', () => { + t.ok(true, 'should connect to proxy') + }) + + server.on('request', (req, res) => { + t.strictEqual(req.url, '/') + t.strictEqual(req.headers.host, parsedOrigin.host, 'should not use proxyUrl as host') + res.setHeader('content-type', 'application/json') + res.end(JSON.stringify({ hello: 'world' })) + }) + + const { + statusCode, + headers, + body + } = await dispatcher.request({ path: '/', method: 'GET', origin: serverUrl }) + const json = await body.json() + + t.strictEqual(statusCode, 200) + t.deepStrictEqual(json, { hello: 'world' }) + t.strictEqual(headers.connection, 'keep-alive', 'should remain the connection open') + + server.close() + proxy.close() + await dispatcher.close() +}) + +test('use proxy agent to connect through proxy using Pool', async (t) => { + t = tspl(t, { plan: 3 }) + const server = await buildServer() + const proxy = await buildProxy() + let resolveFirstConnect + let connectCount = 0 + + proxy.authenticate = async function (req) { + if (++connectCount === 2) { + t.ok(true, 'second connect should arrive while first is still inflight') + resolveFirstConnect() + } else { + await new Promise((resolve) => { + resolveFirstConnect = resolve + }) + } + + return true + } + + server.on('request', (req, res) => { + res.end() + }) + + const serverUrl = `http://localhost:${server.address().port}` + const proxyUrl = `http://localhost:${proxy.address().port}` + const clientFactory = (url, options) => { + return new Pool(url, options) + } + const client = new Client(serverUrl) + const dispatcher = client.compose(proxyInterceptor({ auth: Buffer.from('user:pass').toString('base64'), uri: proxyUrl, clientFactory })) + const firstRequest = dispatcher.request({ path: '/', method: 'GET', origin: serverUrl }) + const secondRequest = await dispatcher.request({ path: '/', method: 'GET', origin: serverUrl }) + t.strictEqual((await firstRequest).statusCode, 200) + t.strictEqual(secondRequest.statusCode, 200) + server.close() + proxy.close() + await dispatcher.close() +}) + +test('use proxy-agent to connect through proxy using path with params', async (t) => { + t = tspl(t, { plan: 6 }) + const server = await buildServer() + const proxy = await buildProxy() + delete proxy.authenticate + + const serverUrl = `http://localhost:${server.address().port}` + const proxyUrl = `http://localhost:${proxy.address().port}` + const client = new Client(serverUrl) + const parsedOrigin = new URL(serverUrl) + const dispatcher = client.compose(proxyInterceptor(proxyUrl)) + + proxy.on('connect', () => { + t.ok(true, 'should call proxy') + }) + server.on('request', (req, res) => { + t.strictEqual(req.url, '/hello?foo=bar') + t.strictEqual(req.headers.host, parsedOrigin.host, 'should not use proxyUrl as host') + res.setHeader('content-type', 'application/json') + res.end(JSON.stringify({ hello: 'world' })) + }) + + const { + statusCode, + headers, + body + } = await dispatcher.request({ origin: serverUrl, method: 'GET', path: '/hello?foo=bar' }) + const json = await body.json() + + t.strictEqual(statusCode, 200) + t.deepStrictEqual(json, { hello: 'world' }) + t.strictEqual(headers.connection, 'keep-alive', 'should remain the connection open') + + server.close() + proxy.close() + await dispatcher.close() +}) + +test('use proxy-agent to connect through proxy with basic auth in URL', async (t) => { + t = tspl(t, { plan: 7 }) + const server = await buildServer() + const proxy = await buildProxy() + + const serverUrl = `http://localhost:${server.address().port}` + const proxyUrl = `http://user:pass@localhost:${proxy.address().port}` + const client = new Client(serverUrl) + const parsedOrigin = new URL(serverUrl) + const dispatcher = client.compose(proxyInterceptor(proxyUrl)) + + proxy.authenticate = function (req, fn) { + t.ok(true, 'authentication should be called') + return req.headers['proxy-authorization'] === `Basic ${Buffer.from('user:pass').toString('base64')}` + } + proxy.on('connect', () => { + t.ok(true, 'proxy should be called') + }) + + server.on('request', (req, res) => { + t.strictEqual(req.url, '/hello?foo=bar') + t.strictEqual(req.headers.host, parsedOrigin.host, 'should not use proxyUrl as host') + res.setHeader('content-type', 'application/json') + res.end(JSON.stringify({ hello: 'world' })) + }) + + const { + statusCode, + headers, + body + } = await dispatcher.request({ origin: serverUrl, method: 'GET', path: '/hello?foo=bar' }) + const json = await body.json() + + t.strictEqual(statusCode, 200) + t.deepStrictEqual(json, { hello: 'world' }) + t.strictEqual(headers.connection, 'keep-alive', 'should remain the connection open') + + server.close() + proxy.close() + await dispatcher.close() +}) + +test('use proxy-agent with auth', async (t) => { + t = tspl(t, { plan: 7 }) + const server = await buildServer() + const proxy = await buildProxy() + + const serverUrl = `http://localhost:${server.address().port}` + const proxyUrl = `http://user:pass@localhost:${proxy.address().port}` + const client = new Client(serverUrl) + const parsedOrigin = new URL(serverUrl) + const dispatcher = client.compose(proxyInterceptor({ + auth: Buffer.from('user:pass').toString('base64'), + uri: proxyUrl + })) + + proxy.authenticate = function (req, fn) { + t.ok(true, 'authentication should be called') + return req.headers['proxy-authorization'] === `Basic ${Buffer.from('user:pass').toString('base64')}` + } + proxy.on('connect', () => { + t.ok(true, 'proxy should be called') + }) + + server.on('request', (req, res) => { + t.strictEqual(req.url, '/hello?foo=bar') + t.strictEqual(req.headers.host, parsedOrigin.host, 'should not use proxyUrl as host') + res.setHeader('content-type', 'application/json') + res.end(JSON.stringify({ hello: 'world' })) + }) + + const { + statusCode, + headers, + body + } = await dispatcher.request({ origin: serverUrl, method: 'GET', path: '/hello?foo=bar' }) + const json = await body.json() + + t.strictEqual(statusCode, 200) + t.deepStrictEqual(json, { hello: 'world' }) + t.strictEqual(headers.connection, 'keep-alive', 'should remain the connection open') + + server.close() + proxy.close() + await dispatcher.close() +}) + +test('use proxy-agent with token', async (t) => { + t = tspl(t, { plan: 7 }) + const server = await buildServer() + const proxy = await buildProxy() + + const serverUrl = `http://localhost:${server.address().port}` + const proxyUrl = `http://user:pass@localhost:${proxy.address().port}` + const client = new Client(serverUrl) + const parsedOrigin = new URL(serverUrl) + const dispatcher = client.compose(proxyInterceptor({ + token: `Bearer ${Buffer.from('user:pass').toString('base64')}`, + uri: proxyUrl + })) + + proxy.authenticate = function (req, fn) { + t.ok(true, 'authentication should be called') + return req.headers['proxy-authorization'] === `Bearer ${Buffer.from('user:pass').toString('base64')}` + } + proxy.on('connect', () => { + t.ok(true, 'proxy should be called') + }) + + server.on('request', (req, res) => { + t.strictEqual(req.url, '/hello?foo=bar') + t.strictEqual(req.headers.host, parsedOrigin.host, 'should not use proxyUrl as host') + res.setHeader('content-type', 'application/json') + res.end(JSON.stringify({ hello: 'world' })) + }) + + const { + statusCode, + headers, + body + } = await dispatcher.request({ origin: serverUrl, method: 'GET', path: '/hello?foo=bar' }) + const json = await body.json() + + t.strictEqual(statusCode, 200) + t.deepStrictEqual(json, { hello: 'world' }) + t.strictEqual(headers.connection, 'keep-alive', 'should remain the connection open') + + server.close() + proxy.close() + await dispatcher.close() +}) + +test('use proxy-agent with custom headers', async (t) => { + t = tspl(t, { plan: 2 }) + const server = await buildServer() + const proxy = await buildProxy() + + const serverUrl = `http://localhost:${server.address().port}` + const proxyUrl = `http://user:pass@localhost:${proxy.address().port}` + const client = new Client(serverUrl) + const dispatcher = client.compose(proxyInterceptor({ + uri: proxyUrl, + headers: { + 'User-Agent': 'Foobar/1.0.0' + } + })) + + proxy.on('connect', (req) => { + t.strictEqual(req.headers['user-agent'], 'Foobar/1.0.0') + }) + + server.on('request', (req, res) => { + t.strictEqual(req.headers['user-agent'], 'BarBaz/1.0.0') + res.end() + }) + + await dispatcher.request({ origin: serverUrl, method: 'GET', path: '/hello?foo=bar', headers: { 'user-agent': 'BarBaz/1.0.0' } }) + + server.close() + proxy.close() + await dispatcher.close() +}) + +// test('sending proxy-authorization in request headers should throw', async (t) => { +// t = tspl(t, { plan: 3 }) +// const server = await buildServer() +// const proxy = await buildProxy() + +// const serverUrl = `http://localhost:${server.address().port}` +// const proxyUrl = `http://localhost:${proxy.address().port}` +// const proxyAgent = new ProxyAgent(proxyUrl) + +// server.on('request', (req, res) => { +// res.end(JSON.stringify({ hello: 'world' })) +// }) + +// await t.rejects( +// request( +// serverUrl + '/hello?foo=bar', +// { +// dispatcher: proxyAgent, +// headers: { +// 'proxy-authorization': Buffer.from('user:pass').toString('base64') +// } +// } +// ), +// 'Proxy-Authorization should be sent in ProxyAgent' +// ) + +// await t.rejects( +// request( +// serverUrl + '/hello?foo=bar', +// { +// dispatcher: proxyAgent, +// headers: { +// 'PROXY-AUTHORIZATION': Buffer.from('user:pass').toString('base64') +// } +// } +// ), +// 'Proxy-Authorization should be sent in ProxyAgent' +// ) + +// await t.rejects( +// request( +// serverUrl + '/hello?foo=bar', +// { +// dispatcher: proxyAgent, +// headers: { +// 'Proxy-Authorization': Buffer.from('user:pass').toString('base64') +// } +// } +// ), +// 'Proxy-Authorization should be sent in ProxyAgent' +// ) + +// server.close() +// proxy.close() +// proxyAgent.close() +// }) + +// test('use proxy-agent with setGlobalDispatcher', async (t) => { +// t = tspl(t, { plan: 6 }) +// const defaultDispatcher = getGlobalDispatcher() + +// const server = await buildServer() +// const proxy = await buildProxy() + +// const serverUrl = `http://localhost:${server.address().port}` +// const proxyUrl = `http://localhost:${proxy.address().port}` +// const proxyAgent = new ProxyAgent(proxyUrl) +// const parsedOrigin = new URL(serverUrl) +// setGlobalDispatcher(proxyAgent) + +// after(() => setGlobalDispatcher(defaultDispatcher)) + +// proxy.on('connect', () => { +// t.ok(true, 'should call proxy') +// }) +// server.on('request', (req, res) => { +// t.strictEqual(req.url, '/hello?foo=bar') +// t.strictEqual(req.headers.host, parsedOrigin.host, 'should not use proxyUrl as host') +// res.setHeader('content-type', 'application/json') +// res.end(JSON.stringify({ hello: 'world' })) +// }) + +// const { +// statusCode, +// headers, +// body +// } = await request(serverUrl + '/hello?foo=bar') +// const json = await body.json() + +// t.strictEqual(statusCode, 200) +// t.deepStrictEqual(json, { hello: 'world' }) +// t.strictEqual(headers.connection, 'keep-alive', 'should remain the connection open') + +// server.close() +// proxy.close() +// proxyAgent.close() +// }) + +// test('ProxyAgent correctly sends headers when using fetch - #1355, #1623', async (t) => { +// t = tspl(t, { plan: 2 }) +// const defaultDispatcher = getGlobalDispatcher() + +// const server = await buildServer() +// const proxy = await buildProxy() + +// const serverUrl = `http://localhost:${server.address().port}` +// const proxyUrl = `http://localhost:${proxy.address().port}` + +// const proxyAgent = new ProxyAgent(proxyUrl) +// setGlobalDispatcher(proxyAgent) + +// after(() => setGlobalDispatcher(defaultDispatcher)) + +// const expectedHeaders = { +// host: `localhost:${server.address().port}`, +// connection: 'keep-alive', +// 'test-header': 'value', +// accept: '*/*', +// 'accept-language': '*', +// 'sec-fetch-mode': 'cors', +// 'user-agent': 'undici', +// 'accept-encoding': 'gzip, deflate' +// } + +// const expectedProxyHeaders = { +// host: `localhost:${server.address().port}`, +// connection: 'close' +// } + +// proxy.on('connect', (req, res) => { +// t.deepStrictEqual(req.headers, expectedProxyHeaders) +// }) + +// server.on('request', (req, res) => { +// t.deepStrictEqual(req.headers, expectedHeaders) +// res.end('goodbye') +// }) + +// await fetch(serverUrl, { +// headers: { 'Test-header': 'value' } +// }) + +// server.close() +// proxy.close() +// proxyAgent.close() +// t.end() +// }) + +// test('should throw when proxy does not return 200', async (t) => { +// t = tspl(t, { plan: 2 }) + +// const server = await buildServer() +// const proxy = await buildProxy() + +// const serverUrl = `http://localhost:${server.address().port}` +// const proxyUrl = `http://localhost:${proxy.address().port}` + +// proxy.authenticate = function (req, fn) { +// fn(null, false) +// } + +// const proxyAgent = new ProxyAgent(proxyUrl) +// try { +// await request(serverUrl, { dispatcher: proxyAgent }) +// t.fail() +// } catch (e) { +// t.ok(true, 'pass') +// t.ok(e) +// } + +// server.close() +// proxy.close() +// proxyAgent.close() +// await t.completed +// }) + +// test('pass ProxyAgent proxy status code error when using fetch - #2161', async (t) => { +// t = tspl(t, { plan: 1 }) +// const server = await buildServer() +// const proxy = await buildProxy() + +// const serverUrl = `http://localhost:${server.address().port}` +// const proxyUrl = `http://localhost:${proxy.address().port}` + +// proxy.authenticate = function (req, fn) { +// fn(null, false) +// } + +// const proxyAgent = new ProxyAgent(proxyUrl) +// try { +// await fetch(serverUrl, { dispatcher: proxyAgent }) +// } catch (e) { +// t.ok('cause' in e) +// } + +// server.close() +// proxy.close() +// proxyAgent.close() + +// await t.completed +// }) + +// test('Proxy via HTTP to HTTPS endpoint', async (t) => { +// t = tspl(t, { plan: 4 }) + +// const server = await buildSSLServer() +// const proxy = await buildProxy() + +// const serverUrl = `https://localhost:${server.address().port}` +// const proxyUrl = `http://localhost:${proxy.address().port}` +// const proxyAgent = new ProxyAgent({ +// uri: proxyUrl, +// requestTls: { +// ca: [ +// readFileSync(join(__dirname, 'fixtures', 'ca.pem'), 'utf8') +// ], +// key: readFileSync(join(__dirname, 'fixtures', 'client-key-2048.pem'), 'utf8'), +// cert: readFileSync(join(__dirname, 'fixtures', 'client-crt-2048.pem'), 'utf8'), +// servername: 'agent1' +// } +// }) + +// server.on('request', function (req, res) { +// t.ok(req.connection.encrypted) +// res.end(JSON.stringify(req.headers)) +// }) + +// server.on('secureConnection', () => { +// t.ok(true, 'server should be connected secured') +// }) + +// proxy.on('secureConnection', () => { +// t.fail('proxy over http should not call secureConnection') +// }) + +// proxy.on('connect', function () { +// t.ok(true, 'proxy should be connected') +// }) + +// proxy.on('request', function () { +// t.fail('proxy should never receive requests') +// }) + +// const data = await request(serverUrl, { dispatcher: proxyAgent }) +// const json = await data.body.json() +// t.deepStrictEqual(json, { +// host: `localhost:${server.address().port}`, +// connection: 'keep-alive' +// }) + +// server.close() +// proxy.close() +// proxyAgent.close() +// }) + +// test('Proxy via HTTPS to HTTPS endpoint', async (t) => { +// t = tspl(t, { plan: 5 }) +// const server = await buildSSLServer() +// const proxy = await buildSSLProxy() + +// const serverUrl = `https://localhost:${server.address().port}` +// const proxyUrl = `https://localhost:${proxy.address().port}` +// const proxyAgent = new ProxyAgent({ +// uri: proxyUrl, +// proxyTls: { +// ca: [ +// readFileSync(join(__dirname, 'fixtures', 'ca.pem'), 'utf8') +// ], +// key: readFileSync(join(__dirname, 'fixtures', 'client-key-2048.pem'), 'utf8'), +// cert: readFileSync(join(__dirname, 'fixtures', 'client-crt-2048.pem'), 'utf8'), +// servername: 'agent1', +// rejectUnauthorized: false +// }, +// requestTls: { +// ca: [ +// readFileSync(join(__dirname, 'fixtures', 'ca.pem'), 'utf8') +// ], +// key: readFileSync(join(__dirname, 'fixtures', 'client-key-2048.pem'), 'utf8'), +// cert: readFileSync(join(__dirname, 'fixtures', 'client-crt-2048.pem'), 'utf8'), +// servername: 'agent1' +// } +// }) + +// server.on('request', function (req, res) { +// t.ok(req.connection.encrypted) +// res.end(JSON.stringify(req.headers)) +// }) + +// server.on('secureConnection', () => { +// t.ok(true, 'server should be connected secured') +// }) + +// proxy.on('secureConnection', () => { +// t.ok(true, 'proxy over http should call secureConnection') +// }) + +// proxy.on('connect', function () { +// t.ok(true, 'proxy should be connected') +// }) + +// proxy.on('request', function () { +// t.fail('proxy should never receive requests') +// }) + +// const data = await request(serverUrl, { dispatcher: proxyAgent }) +// const json = await data.body.json() +// t.deepStrictEqual(json, { +// host: `localhost:${server.address().port}`, +// connection: 'keep-alive' +// }) + +// server.close() +// proxy.close() +// proxyAgent.close() +// }) + +// test('Proxy via HTTPS to HTTP endpoint', async (t) => { +// t = tspl(t, { plan: 3 }) +// const server = await buildServer() +// const proxy = await buildSSLProxy() + +// const serverUrl = `http://localhost:${server.address().port}` +// const proxyUrl = `https://localhost:${proxy.address().port}` +// const proxyAgent = new ProxyAgent({ +// uri: proxyUrl, +// proxyTls: { +// ca: [ +// readFileSync(join(__dirname, 'fixtures', 'ca.pem'), 'utf8') +// ], +// key: readFileSync(join(__dirname, 'fixtures', 'client-key-2048.pem'), 'utf8'), +// cert: readFileSync(join(__dirname, 'fixtures', 'client-crt-2048.pem'), 'utf8'), +// servername: 'agent1', +// rejectUnauthorized: false +// } +// }) + +// server.on('request', function (req, res) { +// t.ok(!req.connection.encrypted) +// res.end(JSON.stringify(req.headers)) +// }) + +// server.on('secureConnection', () => { +// t.fail('server is http') +// }) + +// proxy.on('secureConnection', () => { +// t.ok(true, 'proxy over http should call secureConnection') +// }) + +// proxy.on('request', function () { +// t.fail('proxy should never receive requests') +// }) + +// const data = await request(serverUrl, { dispatcher: proxyAgent }) +// const json = await data.body.json() +// t.deepStrictEqual(json, { +// host: `localhost:${server.address().port}`, +// connection: 'keep-alive' +// }) + +// server.close() +// proxy.close() +// proxyAgent.close() +// }) + +// test('Proxy via HTTP to HTTP endpoint', async (t) => { +// t = tspl(t, { plan: 3 }) +// const server = await buildServer() +// const proxy = await buildProxy() + +// const serverUrl = `http://localhost:${server.address().port}` +// const proxyUrl = `http://localhost:${proxy.address().port}` +// const proxyAgent = new ProxyAgent(proxyUrl) + +// server.on('request', function (req, res) { +// t.ok(!req.connection.encrypted) +// res.end(JSON.stringify(req.headers)) +// }) + +// server.on('secureConnection', () => { +// t.fail('server is http') +// }) + +// proxy.on('secureConnection', () => { +// t.fail('proxy is http') +// }) + +// proxy.on('connect', () => { +// t.ok(true, 'connect to proxy') +// }) + +// proxy.on('request', function () { +// t.fail('proxy should never receive requests') +// }) + +// const data = await request(serverUrl, { dispatcher: proxyAgent }) +// const json = await data.body.json() +// t.deepStrictEqual(json, { +// host: `localhost:${server.address().port}`, +// connection: 'keep-alive' +// }) + +// server.close() +// proxy.close() +// proxyAgent.close() +// }) + +function buildServer () { + return new Promise((resolve) => { + const server = createServer() + server.listen(0, () => resolve(server)) + }) +} + +function buildSSLServer () { + const serverOptions = { + ca: [ + readFileSync(join(__dirname, 'fixtures', 'client-ca-crt.pem'), 'utf8') + ], + key: readFileSync(join(__dirname, 'fixtures', 'key.pem'), 'utf8'), + cert: readFileSync(join(__dirname, 'fixtures', 'cert.pem'), 'utf8') + } + return new Promise((resolve) => { + const server = https.createServer(serverOptions) + server.listen(0, () => resolve(server)) + }) +} + +function buildProxy (listener) { + return new Promise((resolve) => { + const server = listener + ? createProxy(createServer(listener)) + : createProxy(createServer()) + server.listen(0, () => resolve(server)) + }) +} + +function buildSSLProxy () { + const serverOptions = { + ca: [ + readFileSync(join(__dirname, 'fixtures', 'client-ca-crt.pem'), 'utf8') + ], + key: readFileSync(join(__dirname, 'fixtures', 'key.pem'), 'utf8'), + cert: readFileSync(join(__dirname, 'fixtures', 'cert.pem'), 'utf8') + } + + return new Promise((resolve) => { + const server = createProxy(https.createServer(serverOptions)) + server.listen(0, () => resolve(server)) + }) +} From 9048eb5b3998a400ab7658c2b9cc23f539bc3b8e Mon Sep 17 00:00:00 2001 From: Carlos Fuentes Date: Wed, 28 Feb 2024 12:34:39 +0100 Subject: [PATCH 06/19] test: fix tests for proxy --- test/interceptors/proxy.js | 1073 +++++++++++++++++++----------------- 1 file changed, 580 insertions(+), 493 deletions(-) diff --git a/test/interceptors/proxy.js b/test/interceptors/proxy.js index 9fa3c270f3c..683829724db 100644 --- a/test/interceptors/proxy.js +++ b/test/interceptors/proxy.js @@ -1,44 +1,46 @@ 'use strict' -const { test } = require('node:test') +const { test, after } = require('node:test') const { readFileSync } = require('node:fs') -const { join } = require('node:path') +const { resolve } = require('node:path') const { createServer } = require('node:http') const https = require('node:https') const { tspl } = require('@matteo.collina/tspl') const { createProxy } = require('proxy') -const { Client, interceptors } = require('../..') +const { Client, interceptors, getGlobalDispatcher, setGlobalDispatcher, request } = require('../..') const { InvalidArgumentError } = require('../../lib/core/errors') const Pool = require('../../lib/dispatcher/pool') const { proxy: proxyInterceptor } = interceptors -test('should throw error when no uri is provided', (t) => { +test('should throw error when no uri is provided', t => { t = tspl(t, { plan: 2 }) t.throws(() => proxyInterceptor(), InvalidArgumentError) t.throws(() => proxyInterceptor({}), InvalidArgumentError) }) -test('using auth in combination with token should throw', (t) => { +test('using auth in combination with token should throw', t => { t = tspl(t, { plan: 1 }) - t.throws(() => proxyInterceptor({ - auth: 'foo', - token: 'Bearer bar', - uri: 'http://example.com' - }), - InvalidArgumentError + t.throws( + () => + proxyInterceptor({ + auth: 'foo', + token: 'Bearer bar', + uri: 'http://example.com' + }), + InvalidArgumentError ) }) -test('should accept string, URL and object as options', (t) => { +test('should accept string, URL and object as options', t => { t = tspl(t, { plan: 3 }) t.doesNotThrow(() => proxyInterceptor('http://example.com')) t.doesNotThrow(() => proxyInterceptor(new URL('http://example.com'))) t.doesNotThrow(() => proxyInterceptor({ uri: 'http://example.com' })) }) -test('use proxy-agent to connect through proxy', async (t) => { +test('use proxy-agent to connect through proxy', async t => { t = tspl(t, { plan: 6 }) const server = await buildServer() const proxy = await buildProxy() @@ -56,28 +58,36 @@ test('use proxy-agent to connect through proxy', async (t) => { server.on('request', (req, res) => { t.strictEqual(req.url, '/') - t.strictEqual(req.headers.host, parsedOrigin.host, 'should not use proxyUrl as host') + t.strictEqual( + req.headers.host, + parsedOrigin.host, + 'should not use proxyUrl as host' + ) res.setHeader('content-type', 'application/json') res.end(JSON.stringify({ hello: 'world' })) }) - const { - statusCode, - headers, - body - } = await dispatcher.request({ path: '/', method: 'GET', origin: serverUrl }) + const { statusCode, headers, body } = await dispatcher.request({ + path: '/', + method: 'GET', + origin: serverUrl + }) const json = await body.json() t.strictEqual(statusCode, 200) t.deepStrictEqual(json, { hello: 'world' }) - t.strictEqual(headers.connection, 'keep-alive', 'should remain the connection open') + t.strictEqual( + headers.connection, + 'keep-alive', + 'should remain the connection open' + ) server.close() proxy.close() await dispatcher.close() }) -test('use proxy agent to connect through proxy using Pool', async (t) => { +test('use proxy agent to connect through proxy using Pool', async t => { t = tspl(t, { plan: 3 }) const server = await buildServer() const proxy = await buildProxy() @@ -89,7 +99,7 @@ test('use proxy agent to connect through proxy using Pool', async (t) => { t.ok(true, 'second connect should arrive while first is still inflight') resolveFirstConnect() } else { - await new Promise((resolve) => { + await new Promise(resolve => { resolveFirstConnect = resolve }) } @@ -107,9 +117,23 @@ test('use proxy agent to connect through proxy using Pool', async (t) => { return new Pool(url, options) } const client = new Client(serverUrl) - const dispatcher = client.compose(proxyInterceptor({ auth: Buffer.from('user:pass').toString('base64'), uri: proxyUrl, clientFactory })) - const firstRequest = dispatcher.request({ path: '/', method: 'GET', origin: serverUrl }) - const secondRequest = await dispatcher.request({ path: '/', method: 'GET', origin: serverUrl }) + const dispatcher = client.compose( + proxyInterceptor({ + auth: Buffer.from('user:pass').toString('base64'), + uri: proxyUrl, + clientFactory + }) + ) + const firstRequest = dispatcher.request({ + path: '/', + method: 'GET', + origin: serverUrl + }) + const secondRequest = await dispatcher.request({ + path: '/', + method: 'GET', + origin: serverUrl + }) t.strictEqual((await firstRequest).statusCode, 200) t.strictEqual(secondRequest.statusCode, 200) server.close() @@ -117,7 +141,7 @@ test('use proxy agent to connect through proxy using Pool', async (t) => { await dispatcher.close() }) -test('use proxy-agent to connect through proxy using path with params', async (t) => { +test('use proxy-agent to connect through proxy using path with params', async t => { t = tspl(t, { plan: 6 }) const server = await buildServer() const proxy = await buildProxy() @@ -134,28 +158,36 @@ test('use proxy-agent to connect through proxy using path with params', async (t }) server.on('request', (req, res) => { t.strictEqual(req.url, '/hello?foo=bar') - t.strictEqual(req.headers.host, parsedOrigin.host, 'should not use proxyUrl as host') + t.strictEqual( + req.headers.host, + parsedOrigin.host, + 'should not use proxyUrl as host' + ) res.setHeader('content-type', 'application/json') res.end(JSON.stringify({ hello: 'world' })) }) - const { - statusCode, - headers, - body - } = await dispatcher.request({ origin: serverUrl, method: 'GET', path: '/hello?foo=bar' }) + const { statusCode, headers, body } = await dispatcher.request({ + origin: serverUrl, + method: 'GET', + path: '/hello?foo=bar' + }) const json = await body.json() t.strictEqual(statusCode, 200) t.deepStrictEqual(json, { hello: 'world' }) - t.strictEqual(headers.connection, 'keep-alive', 'should remain the connection open') + t.strictEqual( + headers.connection, + 'keep-alive', + 'should remain the connection open' + ) server.close() proxy.close() await dispatcher.close() }) -test('use proxy-agent to connect through proxy with basic auth in URL', async (t) => { +test('use proxy-agent to connect through proxy with basic auth in URL', async t => { t = tspl(t, { plan: 7 }) const server = await buildServer() const proxy = await buildProxy() @@ -168,7 +200,10 @@ test('use proxy-agent to connect through proxy with basic auth in URL', async (t proxy.authenticate = function (req, fn) { t.ok(true, 'authentication should be called') - return req.headers['proxy-authorization'] === `Basic ${Buffer.from('user:pass').toString('base64')}` + return ( + req.headers['proxy-authorization'] === + `Basic ${Buffer.from('user:pass').toString('base64')}` + ) } proxy.on('connect', () => { t.ok(true, 'proxy should be called') @@ -176,28 +211,36 @@ test('use proxy-agent to connect through proxy with basic auth in URL', async (t server.on('request', (req, res) => { t.strictEqual(req.url, '/hello?foo=bar') - t.strictEqual(req.headers.host, parsedOrigin.host, 'should not use proxyUrl as host') + t.strictEqual( + req.headers.host, + parsedOrigin.host, + 'should not use proxyUrl as host' + ) res.setHeader('content-type', 'application/json') res.end(JSON.stringify({ hello: 'world' })) }) - const { - statusCode, - headers, - body - } = await dispatcher.request({ origin: serverUrl, method: 'GET', path: '/hello?foo=bar' }) + const { statusCode, headers, body } = await dispatcher.request({ + origin: serverUrl, + method: 'GET', + path: '/hello?foo=bar' + }) const json = await body.json() t.strictEqual(statusCode, 200) t.deepStrictEqual(json, { hello: 'world' }) - t.strictEqual(headers.connection, 'keep-alive', 'should remain the connection open') + t.strictEqual( + headers.connection, + 'keep-alive', + 'should remain the connection open' + ) server.close() proxy.close() await dispatcher.close() }) -test('use proxy-agent with auth', async (t) => { +test('use proxy-agent with auth', async t => { t = tspl(t, { plan: 7 }) const server = await buildServer() const proxy = await buildProxy() @@ -206,14 +249,19 @@ test('use proxy-agent with auth', async (t) => { const proxyUrl = `http://user:pass@localhost:${proxy.address().port}` const client = new Client(serverUrl) const parsedOrigin = new URL(serverUrl) - const dispatcher = client.compose(proxyInterceptor({ - auth: Buffer.from('user:pass').toString('base64'), - uri: proxyUrl - })) + const dispatcher = client.compose( + proxyInterceptor({ + auth: Buffer.from('user:pass').toString('base64'), + uri: proxyUrl + }) + ) proxy.authenticate = function (req, fn) { t.ok(true, 'authentication should be called') - return req.headers['proxy-authorization'] === `Basic ${Buffer.from('user:pass').toString('base64')}` + return ( + req.headers['proxy-authorization'] === + `Basic ${Buffer.from('user:pass').toString('base64')}` + ) } proxy.on('connect', () => { t.ok(true, 'proxy should be called') @@ -221,28 +269,36 @@ test('use proxy-agent with auth', async (t) => { server.on('request', (req, res) => { t.strictEqual(req.url, '/hello?foo=bar') - t.strictEqual(req.headers.host, parsedOrigin.host, 'should not use proxyUrl as host') + t.strictEqual( + req.headers.host, + parsedOrigin.host, + 'should not use proxyUrl as host' + ) res.setHeader('content-type', 'application/json') res.end(JSON.stringify({ hello: 'world' })) }) - const { - statusCode, - headers, - body - } = await dispatcher.request({ origin: serverUrl, method: 'GET', path: '/hello?foo=bar' }) + const { statusCode, headers, body } = await dispatcher.request({ + origin: serverUrl, + method: 'GET', + path: '/hello?foo=bar' + }) const json = await body.json() t.strictEqual(statusCode, 200) t.deepStrictEqual(json, { hello: 'world' }) - t.strictEqual(headers.connection, 'keep-alive', 'should remain the connection open') + t.strictEqual( + headers.connection, + 'keep-alive', + 'should remain the connection open' + ) server.close() proxy.close() await dispatcher.close() }) -test('use proxy-agent with token', async (t) => { +test('use proxy-agent with token', async t => { t = tspl(t, { plan: 7 }) const server = await buildServer() const proxy = await buildProxy() @@ -251,14 +307,19 @@ test('use proxy-agent with token', async (t) => { const proxyUrl = `http://user:pass@localhost:${proxy.address().port}` const client = new Client(serverUrl) const parsedOrigin = new URL(serverUrl) - const dispatcher = client.compose(proxyInterceptor({ - token: `Bearer ${Buffer.from('user:pass').toString('base64')}`, - uri: proxyUrl - })) + const dispatcher = client.compose( + proxyInterceptor({ + token: `Bearer ${Buffer.from('user:pass').toString('base64')}`, + uri: proxyUrl + }) + ) proxy.authenticate = function (req, fn) { t.ok(true, 'authentication should be called') - return req.headers['proxy-authorization'] === `Bearer ${Buffer.from('user:pass').toString('base64')}` + return ( + req.headers['proxy-authorization'] === + `Bearer ${Buffer.from('user:pass').toString('base64')}` + ) } proxy.on('connect', () => { t.ok(true, 'proxy should be called') @@ -266,28 +327,36 @@ test('use proxy-agent with token', async (t) => { server.on('request', (req, res) => { t.strictEqual(req.url, '/hello?foo=bar') - t.strictEqual(req.headers.host, parsedOrigin.host, 'should not use proxyUrl as host') + t.strictEqual( + req.headers.host, + parsedOrigin.host, + 'should not use proxyUrl as host' + ) res.setHeader('content-type', 'application/json') res.end(JSON.stringify({ hello: 'world' })) }) - const { - statusCode, - headers, - body - } = await dispatcher.request({ origin: serverUrl, method: 'GET', path: '/hello?foo=bar' }) + const { statusCode, headers, body } = await dispatcher.request({ + origin: serverUrl, + method: 'GET', + path: '/hello?foo=bar' + }) const json = await body.json() t.strictEqual(statusCode, 200) t.deepStrictEqual(json, { hello: 'world' }) - t.strictEqual(headers.connection, 'keep-alive', 'should remain the connection open') + t.strictEqual( + headers.connection, + 'keep-alive', + 'should remain the connection open' + ) server.close() proxy.close() await dispatcher.close() }) -test('use proxy-agent with custom headers', async (t) => { +test('use proxy-agent with custom headers', async t => { t = tspl(t, { plan: 2 }) const server = await buildServer() const proxy = await buildProxy() @@ -295,14 +364,16 @@ test('use proxy-agent with custom headers', async (t) => { const serverUrl = `http://localhost:${server.address().port}` const proxyUrl = `http://user:pass@localhost:${proxy.address().port}` const client = new Client(serverUrl) - const dispatcher = client.compose(proxyInterceptor({ - uri: proxyUrl, - headers: { - 'User-Agent': 'Foobar/1.0.0' - } - })) + const dispatcher = client.compose( + proxyInterceptor({ + uri: proxyUrl, + headers: { + 'User-Agent': 'Foobar/1.0.0' + } + }) + ) - proxy.on('connect', (req) => { + proxy.on('connect', req => { t.strictEqual(req.headers['user-agent'], 'Foobar/1.0.0') }) @@ -311,422 +382,438 @@ test('use proxy-agent with custom headers', async (t) => { res.end() }) - await dispatcher.request({ origin: serverUrl, method: 'GET', path: '/hello?foo=bar', headers: { 'user-agent': 'BarBaz/1.0.0' } }) + await dispatcher.request({ + origin: serverUrl, + method: 'GET', + path: '/hello?foo=bar', + headers: { 'user-agent': 'BarBaz/1.0.0' } + }) + + server.close() + proxy.close() + await dispatcher.close() +}) + +test('sending proxy-authorization in request headers should throw', async t => { + t = tspl(t, { plan: 3 }) + const server = await buildServer() + const proxy = await buildProxy() + + const serverUrl = `http://localhost:${server.address().port}` + const proxyUrl = `http://localhost:${proxy.address().port}` + const client = new Client(serverUrl) + const dispatcher = client.compose( + proxyInterceptor({ + uri: proxyUrl + }) + ) + + server.on('request', (req, res) => { + res.end(JSON.stringify({ hello: 'world' })) + }) + + await t.rejects( + dispatcher.request( + { + origin: serverUrl, + method: 'GET', + path: '/hello?foo=bar', + headers: { + 'proxy-authorization': Buffer.from('user:pass').toString('base64') + } + } + ), + 'Proxy-Authorization should be sent in ProxyAgent' + ) + + await t.rejects( + dispatcher.request( + { + origin: serverUrl, + method: 'GET', + path: '/hello?foo=bar', + headers: { + 'PROXY-AUTHORIZATION': Buffer.from('user:pass').toString('base64') + } + } + ), + 'Proxy-Authorization should be sent in ProxyAgent' + ) + + await t.rejects( + dispatcher.request( + { + origin: serverUrl, + method: 'GET', + path: '/hello?foo=bar', + headers: { + 'Proxy-Authorization': Buffer.from('user:pass').toString('base64') + } + } + ), + 'Proxy-Authorization should be sent in ProxyAgent' + ) server.close() proxy.close() await dispatcher.close() }) -// test('sending proxy-authorization in request headers should throw', async (t) => { -// t = tspl(t, { plan: 3 }) -// const server = await buildServer() -// const proxy = await buildProxy() - -// const serverUrl = `http://localhost:${server.address().port}` -// const proxyUrl = `http://localhost:${proxy.address().port}` -// const proxyAgent = new ProxyAgent(proxyUrl) - -// server.on('request', (req, res) => { -// res.end(JSON.stringify({ hello: 'world' })) -// }) - -// await t.rejects( -// request( -// serverUrl + '/hello?foo=bar', -// { -// dispatcher: proxyAgent, -// headers: { -// 'proxy-authorization': Buffer.from('user:pass').toString('base64') -// } -// } -// ), -// 'Proxy-Authorization should be sent in ProxyAgent' -// ) - -// await t.rejects( -// request( -// serverUrl + '/hello?foo=bar', -// { -// dispatcher: proxyAgent, -// headers: { -// 'PROXY-AUTHORIZATION': Buffer.from('user:pass').toString('base64') -// } -// } -// ), -// 'Proxy-Authorization should be sent in ProxyAgent' -// ) - -// await t.rejects( -// request( -// serverUrl + '/hello?foo=bar', -// { -// dispatcher: proxyAgent, -// headers: { -// 'Proxy-Authorization': Buffer.from('user:pass').toString('base64') -// } -// } -// ), -// 'Proxy-Authorization should be sent in ProxyAgent' -// ) - -// server.close() -// proxy.close() -// proxyAgent.close() -// }) - -// test('use proxy-agent with setGlobalDispatcher', async (t) => { -// t = tspl(t, { plan: 6 }) -// const defaultDispatcher = getGlobalDispatcher() - -// const server = await buildServer() -// const proxy = await buildProxy() - -// const serverUrl = `http://localhost:${server.address().port}` -// const proxyUrl = `http://localhost:${proxy.address().port}` -// const proxyAgent = new ProxyAgent(proxyUrl) -// const parsedOrigin = new URL(serverUrl) -// setGlobalDispatcher(proxyAgent) - -// after(() => setGlobalDispatcher(defaultDispatcher)) - -// proxy.on('connect', () => { -// t.ok(true, 'should call proxy') -// }) -// server.on('request', (req, res) => { -// t.strictEqual(req.url, '/hello?foo=bar') -// t.strictEqual(req.headers.host, parsedOrigin.host, 'should not use proxyUrl as host') -// res.setHeader('content-type', 'application/json') -// res.end(JSON.stringify({ hello: 'world' })) -// }) - -// const { -// statusCode, -// headers, -// body -// } = await request(serverUrl + '/hello?foo=bar') -// const json = await body.json() - -// t.strictEqual(statusCode, 200) -// t.deepStrictEqual(json, { hello: 'world' }) -// t.strictEqual(headers.connection, 'keep-alive', 'should remain the connection open') - -// server.close() -// proxy.close() -// proxyAgent.close() -// }) - -// test('ProxyAgent correctly sends headers when using fetch - #1355, #1623', async (t) => { -// t = tspl(t, { plan: 2 }) -// const defaultDispatcher = getGlobalDispatcher() - -// const server = await buildServer() -// const proxy = await buildProxy() - -// const serverUrl = `http://localhost:${server.address().port}` -// const proxyUrl = `http://localhost:${proxy.address().port}` - -// const proxyAgent = new ProxyAgent(proxyUrl) -// setGlobalDispatcher(proxyAgent) - -// after(() => setGlobalDispatcher(defaultDispatcher)) - -// const expectedHeaders = { -// host: `localhost:${server.address().port}`, -// connection: 'keep-alive', -// 'test-header': 'value', -// accept: '*/*', -// 'accept-language': '*', -// 'sec-fetch-mode': 'cors', -// 'user-agent': 'undici', -// 'accept-encoding': 'gzip, deflate' -// } - -// const expectedProxyHeaders = { -// host: `localhost:${server.address().port}`, -// connection: 'close' -// } - -// proxy.on('connect', (req, res) => { -// t.deepStrictEqual(req.headers, expectedProxyHeaders) -// }) - -// server.on('request', (req, res) => { -// t.deepStrictEqual(req.headers, expectedHeaders) -// res.end('goodbye') -// }) - -// await fetch(serverUrl, { -// headers: { 'Test-header': 'value' } -// }) - -// server.close() -// proxy.close() -// proxyAgent.close() -// t.end() -// }) - -// test('should throw when proxy does not return 200', async (t) => { -// t = tspl(t, { plan: 2 }) - -// const server = await buildServer() -// const proxy = await buildProxy() - -// const serverUrl = `http://localhost:${server.address().port}` -// const proxyUrl = `http://localhost:${proxy.address().port}` - -// proxy.authenticate = function (req, fn) { -// fn(null, false) -// } - -// const proxyAgent = new ProxyAgent(proxyUrl) -// try { -// await request(serverUrl, { dispatcher: proxyAgent }) -// t.fail() -// } catch (e) { -// t.ok(true, 'pass') -// t.ok(e) -// } - -// server.close() -// proxy.close() -// proxyAgent.close() -// await t.completed -// }) - -// test('pass ProxyAgent proxy status code error when using fetch - #2161', async (t) => { -// t = tspl(t, { plan: 1 }) -// const server = await buildServer() -// const proxy = await buildProxy() - -// const serverUrl = `http://localhost:${server.address().port}` -// const proxyUrl = `http://localhost:${proxy.address().port}` - -// proxy.authenticate = function (req, fn) { -// fn(null, false) -// } - -// const proxyAgent = new ProxyAgent(proxyUrl) -// try { -// await fetch(serverUrl, { dispatcher: proxyAgent }) -// } catch (e) { -// t.ok('cause' in e) -// } - -// server.close() -// proxy.close() -// proxyAgent.close() - -// await t.completed -// }) - -// test('Proxy via HTTP to HTTPS endpoint', async (t) => { -// t = tspl(t, { plan: 4 }) - -// const server = await buildSSLServer() -// const proxy = await buildProxy() - -// const serverUrl = `https://localhost:${server.address().port}` -// const proxyUrl = `http://localhost:${proxy.address().port}` -// const proxyAgent = new ProxyAgent({ -// uri: proxyUrl, -// requestTls: { -// ca: [ -// readFileSync(join(__dirname, 'fixtures', 'ca.pem'), 'utf8') -// ], -// key: readFileSync(join(__dirname, 'fixtures', 'client-key-2048.pem'), 'utf8'), -// cert: readFileSync(join(__dirname, 'fixtures', 'client-crt-2048.pem'), 'utf8'), -// servername: 'agent1' -// } -// }) - -// server.on('request', function (req, res) { -// t.ok(req.connection.encrypted) -// res.end(JSON.stringify(req.headers)) -// }) - -// server.on('secureConnection', () => { -// t.ok(true, 'server should be connected secured') -// }) - -// proxy.on('secureConnection', () => { -// t.fail('proxy over http should not call secureConnection') -// }) - -// proxy.on('connect', function () { -// t.ok(true, 'proxy should be connected') -// }) - -// proxy.on('request', function () { -// t.fail('proxy should never receive requests') -// }) - -// const data = await request(serverUrl, { dispatcher: proxyAgent }) -// const json = await data.body.json() -// t.deepStrictEqual(json, { -// host: `localhost:${server.address().port}`, -// connection: 'keep-alive' -// }) - -// server.close() -// proxy.close() -// proxyAgent.close() -// }) - -// test('Proxy via HTTPS to HTTPS endpoint', async (t) => { -// t = tspl(t, { plan: 5 }) -// const server = await buildSSLServer() -// const proxy = await buildSSLProxy() - -// const serverUrl = `https://localhost:${server.address().port}` -// const proxyUrl = `https://localhost:${proxy.address().port}` -// const proxyAgent = new ProxyAgent({ -// uri: proxyUrl, -// proxyTls: { -// ca: [ -// readFileSync(join(__dirname, 'fixtures', 'ca.pem'), 'utf8') -// ], -// key: readFileSync(join(__dirname, 'fixtures', 'client-key-2048.pem'), 'utf8'), -// cert: readFileSync(join(__dirname, 'fixtures', 'client-crt-2048.pem'), 'utf8'), -// servername: 'agent1', -// rejectUnauthorized: false -// }, -// requestTls: { -// ca: [ -// readFileSync(join(__dirname, 'fixtures', 'ca.pem'), 'utf8') -// ], -// key: readFileSync(join(__dirname, 'fixtures', 'client-key-2048.pem'), 'utf8'), -// cert: readFileSync(join(__dirname, 'fixtures', 'client-crt-2048.pem'), 'utf8'), -// servername: 'agent1' -// } -// }) - -// server.on('request', function (req, res) { -// t.ok(req.connection.encrypted) -// res.end(JSON.stringify(req.headers)) -// }) - -// server.on('secureConnection', () => { -// t.ok(true, 'server should be connected secured') -// }) - -// proxy.on('secureConnection', () => { -// t.ok(true, 'proxy over http should call secureConnection') -// }) - -// proxy.on('connect', function () { -// t.ok(true, 'proxy should be connected') -// }) - -// proxy.on('request', function () { -// t.fail('proxy should never receive requests') -// }) - -// const data = await request(serverUrl, { dispatcher: proxyAgent }) -// const json = await data.body.json() -// t.deepStrictEqual(json, { -// host: `localhost:${server.address().port}`, -// connection: 'keep-alive' -// }) - -// server.close() -// proxy.close() -// proxyAgent.close() -// }) - -// test('Proxy via HTTPS to HTTP endpoint', async (t) => { -// t = tspl(t, { plan: 3 }) -// const server = await buildServer() -// const proxy = await buildSSLProxy() - -// const serverUrl = `http://localhost:${server.address().port}` -// const proxyUrl = `https://localhost:${proxy.address().port}` -// const proxyAgent = new ProxyAgent({ -// uri: proxyUrl, -// proxyTls: { -// ca: [ -// readFileSync(join(__dirname, 'fixtures', 'ca.pem'), 'utf8') -// ], -// key: readFileSync(join(__dirname, 'fixtures', 'client-key-2048.pem'), 'utf8'), -// cert: readFileSync(join(__dirname, 'fixtures', 'client-crt-2048.pem'), 'utf8'), -// servername: 'agent1', -// rejectUnauthorized: false -// } -// }) - -// server.on('request', function (req, res) { -// t.ok(!req.connection.encrypted) -// res.end(JSON.stringify(req.headers)) -// }) - -// server.on('secureConnection', () => { -// t.fail('server is http') -// }) - -// proxy.on('secureConnection', () => { -// t.ok(true, 'proxy over http should call secureConnection') -// }) - -// proxy.on('request', function () { -// t.fail('proxy should never receive requests') -// }) - -// const data = await request(serverUrl, { dispatcher: proxyAgent }) -// const json = await data.body.json() -// t.deepStrictEqual(json, { -// host: `localhost:${server.address().port}`, -// connection: 'keep-alive' -// }) - -// server.close() -// proxy.close() -// proxyAgent.close() -// }) - -// test('Proxy via HTTP to HTTP endpoint', async (t) => { -// t = tspl(t, { plan: 3 }) -// const server = await buildServer() -// const proxy = await buildProxy() - -// const serverUrl = `http://localhost:${server.address().port}` -// const proxyUrl = `http://localhost:${proxy.address().port}` -// const proxyAgent = new ProxyAgent(proxyUrl) - -// server.on('request', function (req, res) { -// t.ok(!req.connection.encrypted) -// res.end(JSON.stringify(req.headers)) -// }) - -// server.on('secureConnection', () => { -// t.fail('server is http') -// }) - -// proxy.on('secureConnection', () => { -// t.fail('proxy is http') -// }) - -// proxy.on('connect', () => { -// t.ok(true, 'connect to proxy') -// }) - -// proxy.on('request', function () { -// t.fail('proxy should never receive requests') -// }) - -// const data = await request(serverUrl, { dispatcher: proxyAgent }) -// const json = await data.body.json() -// t.deepStrictEqual(json, { -// host: `localhost:${server.address().port}`, -// connection: 'keep-alive' -// }) - -// server.close() -// proxy.close() -// proxyAgent.close() -// }) +test('use proxy-agent with setGlobalDispatcher', async (t) => { + t = tspl(t, { plan: 6 }) + + const server = await buildServer() + const proxy = await buildProxy() + delete proxy.authenticate + + const serverUrl = `http://localhost:${server.address().port}` + const proxyUrl = `http://localhost:${proxy.address().port}` + const parsedOrigin = new URL(serverUrl) + const defaultDispatcher = getGlobalDispatcher() + + setGlobalDispatcher(defaultDispatcher.compose(proxyInterceptor(proxyUrl))) + after(() => setGlobalDispatcher(defaultDispatcher)) + + proxy.on('connect', () => { + t.ok(true, 'should connect to proxy') + }) + + server.on('request', (req, res) => { + t.strictEqual(req.url, '/') + t.strictEqual( + req.headers.host, + parsedOrigin.host, + 'should not use proxyUrl as host' + ) + res.setHeader('content-type', 'application/json') + res.end(JSON.stringify({ hello: 'world' })) + }) + + const { statusCode, headers, body } = await request({ + path: '/', + method: 'GET', + origin: serverUrl + }) + const json = await body.json() + + t.strictEqual(statusCode, 200) + t.deepStrictEqual(json, { hello: 'world' }) + t.strictEqual( + headers.connection, + 'keep-alive', + 'should remain the connection open' + ) + + server.close() + proxy.close() +}) + +test('ProxyAgent correctly sends headers when using fetch - #1355, #1623', async (t) => { + t = tspl(t, { plan: 2 }) + const defaultDispatcher = getGlobalDispatcher() + + const server = await buildServer() + const proxy = await buildProxy() + + const serverUrl = `http://localhost:${server.address().port}` + const proxyUrl = `http://localhost:${proxy.address().port}` + setGlobalDispatcher(defaultDispatcher.compose(proxyInterceptor(proxyUrl))) + + after(() => setGlobalDispatcher(defaultDispatcher)) + + const expectedHeaders = { + host: `localhost:${server.address().port}`, + connection: 'keep-alive', + 'test-header': 'value', + accept: '*/*', + 'accept-language': '*', + 'sec-fetch-mode': 'cors', + 'user-agent': 'node', + 'accept-encoding': 'gzip, deflate' + } + + const expectedProxyHeaders = { + host: `localhost:${server.address().port}`, + connection: 'close' + } + + proxy.on('connect', (req, res) => { + t.deepStrictEqual(req.headers, expectedProxyHeaders) + }) + + server.on('request', (req, res) => { + t.deepStrictEqual(req.headers, expectedHeaders) + res.end('goodbye') + }) + + await fetch(serverUrl, { + headers: { 'Test-header': 'value' } + }) + + server.close() + proxy.close() + t.end() +}) + +test('should throw when proxy does not return 200', async (t) => { + t = tspl(t, { plan: 1 }) + + const server = await buildServer() + const proxy = await buildProxy() + + const serverUrl = `http://localhost:${server.address().port}` + const proxyUrl = `http://localhost:${proxy.address().port}` + + proxy.authenticate = function (req, fn) { + return false + } + + const client = new Client(serverUrl).compose(proxyInterceptor(proxyUrl)) + try { + await client.request({ path: '/', method: 'GET' }) + t.fail() + } catch (e) { + t.ok(e) + } + + server.close() + proxy.close() + await t.completed +}) + +test('pass ProxyAgent proxy status code error when using fetch - #2161', async (t) => { + t = tspl(t, { plan: 1 }) + + const server = await buildServer() + const proxy = await buildProxy() + + const serverUrl = `http://localhost:${server.address().port}` + const proxyUrl = `http://localhost:${proxy.address().port}` + + proxy.authenticate = function (req, fn) { + return false + } + + const client = new Client(serverUrl).compose(proxyInterceptor(proxyUrl)) + try { + await fetch(serverUrl, { dispatcher: client }) + t.fail() + } catch (e) { + t.ok('cause' in e) + } + + server.close() + proxy.close() + await t.completed +}) + +test('Proxy via HTTP to HTTPS endpoint', async (t) => { + t = tspl(t, { plan: 4 }) + + const server = await buildSSLServer() + const proxy = await buildProxy() + + const serverUrl = `https://localhost:${server.address().port}` + const proxyUrl = `http://localhost:${proxy.address().port}` + const client = new Client(serverUrl).compose(proxyInterceptor({ + uri: proxyUrl, + requestTls: { + ca: [ + readFileSync(resolve(__dirname, '../', 'fixtures', 'ca.pem'), 'utf8') + ], + key: readFileSync(resolve(__dirname, '../', 'fixtures', 'client-key-2048.pem'), 'utf8'), + cert: readFileSync(resolve(__dirname, '../', 'fixtures', 'client-crt-2048.pem'), 'utf8'), + servername: 'agent1' + } + })) + + server.on('request', function (req, res) { + t.ok(req.connection.encrypted) + res.end(JSON.stringify(req.headers)) + }) + + server.on('secureConnection', () => { + t.ok(true, 'server should be connected secured') + }) + + proxy.on('secureConnection', () => { + t.fail('proxy over http should not call secureConnection') + }) + + proxy.on('connect', function () { + t.ok(true, 'proxy should be connected') + }) + + proxy.on('request', function () { + t.fail('proxy should never receive requests') + }) + + const data = await client.request({ path: '/', origin: serverUrl, method: 'GET' }) + const json = await data.body.json() + t.deepStrictEqual(json, { + host: `localhost:${server.address().port}`, + connection: 'keep-alive' + }) + + server.close() + proxy.close() + await client.close() +}) + +test('Proxy via HTTPS to HTTPS endpoint', async (t) => { + t = tspl(t, { plan: 5 }) + const server = await buildSSLServer() + const proxy = await buildSSLProxy() + + const serverUrl = `https://localhost:${server.address().port}` + const proxyUrl = `https://localhost:${proxy.address().port}` + const proxyAgent = new Client(serverUrl).compose(proxyInterceptor({ + uri: proxyUrl, + proxyTls: { + ca: [ + readFileSync(resolve(__dirname, '../', 'fixtures', 'ca.pem'), 'utf8') + ], + key: readFileSync(resolve(__dirname, '../', 'fixtures', 'client-key-2048.pem'), 'utf8'), + cert: readFileSync(resolve(__dirname, '../', 'fixtures', 'client-crt-2048.pem'), 'utf8'), + servername: 'agent1', + rejectUnauthorized: false + }, + requestTls: { + ca: [ + readFileSync(resolve(__dirname, '../', 'fixtures', 'ca.pem'), 'utf8') + ], + key: readFileSync(resolve(__dirname, '../', 'fixtures', 'client-key-2048.pem'), 'utf8'), + cert: readFileSync(resolve(__dirname, '../', 'fixtures', 'client-crt-2048.pem'), 'utf8'), + servername: 'agent1' + } + })) + + server.on('request', function (req, res) { + t.ok(req.connection.encrypted) + res.end(JSON.stringify(req.headers)) + }) + + server.on('secureConnection', () => { + t.ok(true, 'server should be connected secured') + }) + + proxy.on('secureConnection', () => { + t.ok(true, 'proxy over http should call secureConnection') + }) + + proxy.on('connect', function () { + t.ok(true, 'proxy should be connected') + }) + + proxy.on('request', function () { + t.fail('proxy should never receive requests') + }) + + const data = await request(serverUrl, { dispatcher: proxyAgent }) + const json = await data.body.json() + t.deepStrictEqual(json, { + host: `localhost:${server.address().port}`, + connection: 'keep-alive' + }) + + server.close() + proxy.close() + proxyAgent.close() +}) + +test('Proxy via HTTPS to HTTP endpoint', async (t) => { + t = tspl(t, { plan: 3 }) + const server = await buildServer() + const proxy = await buildSSLProxy() + + const serverUrl = `http://localhost:${server.address().port}` + const proxyUrl = `https://localhost:${proxy.address().port}` + const proxyAgent = new Client(serverUrl).compose(proxyInterceptor({ + uri: proxyUrl, + proxyTls: { + ca: [ + readFileSync(resolve(__dirname, '../', 'fixtures', 'ca.pem'), 'utf8') + ], + key: readFileSync(resolve(__dirname, '../', 'fixtures', 'client-key-2048.pem'), 'utf8'), + cert: readFileSync(resolve(__dirname, '../', 'fixtures', 'client-crt-2048.pem'), 'utf8'), + servername: 'agent1', + rejectUnauthorized: false + } + })) + + server.on('request', function (req, res) { + t.ok(!req.connection.encrypted) + res.end(JSON.stringify(req.headers)) + }) + + server.on('secureConnection', () => { + t.fail('server is http') + }) + + proxy.on('secureConnection', () => { + t.ok(true, 'proxy over http should call secureConnection') + }) + + proxy.on('request', function () { + t.fail('proxy should never receive requests') + }) + + const data = await request(serverUrl, { dispatcher: proxyAgent }) + const json = await data.body.json() + t.deepStrictEqual(json, { + host: `localhost:${server.address().port}`, + connection: 'keep-alive' + }) + + server.close() + proxy.close() + await proxyAgent.close() +}) + +test('Proxy via HTTP to HTTP endpoint', async (t) => { + t = tspl(t, { plan: 3 }) + const server = await buildServer() + const proxy = await buildProxy() + + const serverUrl = `http://localhost:${server.address().port}` + const proxyUrl = `http://localhost:${proxy.address().port}` + const proxyAgent = new Client(serverUrl).compose(proxyInterceptor(proxyUrl)) + + server.on('request', function (req, res) { + t.ok(!req.connection.encrypted) + res.end(JSON.stringify(req.headers)) + }) + + server.on('secureConnection', () => { + t.fail('server is http') + }) + + proxy.on('secureConnection', () => { + t.fail('proxy is http') + }) + + proxy.on('connect', () => { + t.ok(true, 'connect to proxy') + }) + + proxy.on('request', function () { + t.fail('proxy should never receive requests') + }) + + const data = await request(serverUrl, { dispatcher: proxyAgent }) + const json = await data.body.json() + t.deepStrictEqual(json, { + host: `localhost:${server.address().port}`, + connection: 'keep-alive' + }) + + server.close() + proxy.close() + await proxyAgent.close() +}) function buildServer () { - return new Promise((resolve) => { + return new Promise(resolve => { const server = createServer() server.listen(0, () => resolve(server)) }) @@ -735,19 +822,19 @@ function buildServer () { function buildSSLServer () { const serverOptions = { ca: [ - readFileSync(join(__dirname, 'fixtures', 'client-ca-crt.pem'), 'utf8') + readFileSync(resolve(__dirname, '../', 'fixtures', 'client-ca-crt.pem'), 'utf8') ], - key: readFileSync(join(__dirname, 'fixtures', 'key.pem'), 'utf8'), - cert: readFileSync(join(__dirname, 'fixtures', 'cert.pem'), 'utf8') + key: readFileSync(resolve(__dirname, '../', 'fixtures', 'key.pem'), 'utf8'), + cert: readFileSync(resolve(__dirname, '../', 'fixtures', 'cert.pem'), 'utf8') } - return new Promise((resolve) => { + return new Promise(resolve => { const server = https.createServer(serverOptions) server.listen(0, () => resolve(server)) }) } function buildProxy (listener) { - return new Promise((resolve) => { + return new Promise(resolve => { const server = listener ? createProxy(createServer(listener)) : createProxy(createServer()) @@ -758,13 +845,13 @@ function buildProxy (listener) { function buildSSLProxy () { const serverOptions = { ca: [ - readFileSync(join(__dirname, 'fixtures', 'client-ca-crt.pem'), 'utf8') + readFileSync(resolve(__dirname, '../', 'fixtures', 'client-ca-crt.pem'), 'utf8') ], - key: readFileSync(join(__dirname, 'fixtures', 'key.pem'), 'utf8'), - cert: readFileSync(join(__dirname, 'fixtures', 'cert.pem'), 'utf8') + key: readFileSync(resolve(__dirname, '../', 'fixtures', 'key.pem'), 'utf8'), + cert: readFileSync(resolve(__dirname, '../', 'fixtures', 'cert.pem'), 'utf8') } - return new Promise((resolve) => { + return new Promise(resolve => { const server = createProxy(https.createServer(serverOptions)) server.listen(0, () => resolve(server)) }) From 7656e1bc2fb79a682f420d5db58d0fa8b6a489f2 Mon Sep 17 00:00:00 2001 From: Carlos Fuentes Date: Thu, 29 Feb 2024 10:15:45 +0100 Subject: [PATCH 07/19] refactor: expose interceptor as is --- index.js | 2 +- lib/interceptor/proxy.js | 44 ++--- lib/interceptor/redirect.js | 2 +- lib/interceptor/retry.js | 2 +- test/interceptors/proxy.js | 328 ++++++++++++++++++++++++------------ 5 files changed, 250 insertions(+), 128 deletions(-) diff --git a/index.js b/index.js index 8561dcd18cc..24577eb432c 100644 --- a/index.js +++ b/index.js @@ -37,7 +37,7 @@ module.exports.DecoratorHandler = DecoratorHandler module.exports.RedirectHandler = RedirectHandler module.exports.createRedirectInterceptor = createRedirectInterceptor module.exports.interceptors = { - proxy: require('./lib/interceptor/proxy'), + Proxy: require('./lib/interceptor/proxy'), redirect: require('./lib/interceptor/redirect'), retry: require('./lib/interceptor/retry') } diff --git a/lib/interceptor/proxy.js b/lib/interceptor/proxy.js index d01fb815de2..d86d83f52bf 100644 --- a/lib/interceptor/proxy.js +++ b/lib/interceptor/proxy.js @@ -2,10 +2,32 @@ const { InvalidArgumentError } = require('../core/errors') const ProxyAgent = require('../dispatcher/proxy-agent') -const DispatcherBase = require('../dispatcher/dispatcher-base') +const Dispatcher = require('../dispatcher/dispatcher') -class ProxyInterceptor extends DispatcherBase { +class ProxyInterceptor extends Dispatcher { constructor (dispatcher, opts) { + if (dispatcher == null) { + throw new InvalidArgumentError( + 'Dispatcher instance is mandatory for ProxyInterceptor' + ) + } + + if (typeof opts === 'string') { + opts = { uri: opts } + } + + if (!opts || (!opts.uri && !(opts instanceof URL))) { + throw new InvalidArgumentError( + 'Proxy opts.uri or instance of URL is mandatory' + ) + } + + if (opts.auth && opts.token) { + throw new InvalidArgumentError( + 'opts.auth cannot be used in combination with opts.token' + ) + } + super() this.dispatcher = dispatcher this.agent = new ProxyAgent(opts) @@ -20,20 +42,4 @@ class ProxyInterceptor extends DispatcherBase { } } -module.exports = opts => { - if (typeof opts === 'string') { - opts = { uri: opts } - } - - if (!opts || (!opts.uri && !(opts instanceof URL))) { - throw new InvalidArgumentError('Proxy opts.uri or instance of URL is mandatory') - } - - if (opts.auth && opts.token) { - throw new InvalidArgumentError( - 'opts.auth cannot be used in combination with opts.token' - ) - } - - return dispatcher => new ProxyInterceptor(dispatcher, opts) -} +module.exports = ProxyInterceptor diff --git a/lib/interceptor/redirect.js b/lib/interceptor/redirect.js index 98f37b3cab0..82d5deacc45 100644 --- a/lib/interceptor/redirect.js +++ b/lib/interceptor/redirect.js @@ -1,7 +1,7 @@ 'use strict' const { InvalidArgumentError } = require('../core/errors') -const Dispatcher = require('../dispatcher/dispatcher-base') +const Dispatcher = require('../dispatcher/dispatcher') const RedirectHandler = require('../handler/RedirectHandler') class RedirectDispatcher extends Dispatcher { diff --git a/lib/interceptor/retry.js b/lib/interceptor/retry.js index 27cd77bf413..1504d330342 100644 --- a/lib/interceptor/retry.js +++ b/lib/interceptor/retry.js @@ -1,6 +1,6 @@ 'use strict' -const Dispatcher = require('../dispatcher/dispatcher-base') +const Dispatcher = require('../dispatcher/dispatcher') const RetryHandler = require('../handler/RetryHandler') class RetryDispatcher extends Dispatcher { diff --git a/test/interceptors/proxy.js b/test/interceptors/proxy.js index 683829724db..5f7d6bd699a 100644 --- a/test/interceptors/proxy.js +++ b/test/interceptors/proxy.js @@ -9,22 +9,29 @@ const https = require('node:https') const { tspl } = require('@matteo.collina/tspl') const { createProxy } = require('proxy') -const { Client, interceptors, getGlobalDispatcher, setGlobalDispatcher, request } = require('../..') +const { + Client, + interceptors, + getGlobalDispatcher, + setGlobalDispatcher, + request, + Pool, + Dispatcher +} = require('../..') const { InvalidArgumentError } = require('../../lib/core/errors') -const Pool = require('../../lib/dispatcher/pool') -const { proxy: proxyInterceptor } = interceptors +const { Proxy } = interceptors test('should throw error when no uri is provided', t => { t = tspl(t, { plan: 2 }) - t.throws(() => proxyInterceptor(), InvalidArgumentError) - t.throws(() => proxyInterceptor({}), InvalidArgumentError) + t.throws(() => new Proxy(), InvalidArgumentError) + t.throws(() => new Proxy(null, {}), InvalidArgumentError) }) test('using auth in combination with token should throw', t => { t = tspl(t, { plan: 1 }) t.throws( () => - proxyInterceptor({ + new Proxy({}, { auth: 'foo', token: 'Bearer bar', uri: 'http://example.com' @@ -35,12 +42,78 @@ test('using auth in combination with token should throw', t => { test('should accept string, URL and object as options', t => { t = tspl(t, { plan: 3 }) - t.doesNotThrow(() => proxyInterceptor('http://example.com')) - t.doesNotThrow(() => proxyInterceptor(new URL('http://example.com'))) - t.doesNotThrow(() => proxyInterceptor({ uri: 'http://example.com' })) + t.doesNotThrow(() => new Proxy({}, 'http://example.com')) + t.doesNotThrow(() => new Proxy({}, new URL('http://example.com'))) + t.doesNotThrow(() => new Proxy({}, { uri: 'http://example.com' })) }) -test('use proxy-agent to connect through proxy', async t => { +test('use proxy-agent to connect through proxy', { skip: true }, async t => { + t = tspl(t, { plan: 8 }) + const CustomDispatcher = class extends Dispatcher { + constructor (dispatcher) { + super() + this.dispatcher = dispatcher + } + + dispatch (opts, handler) { + t.ok(true, 'should call dispatch') + return this.dispatcher.dispatch(opts, handler) + } + + close () { + return this.dispatcher.close() + } + } + const server = await buildServer() + const proxy = await buildProxy() + delete proxy.authenticate + + const serverUrl = `http://localhost:${server.address().port}` + const proxyUrl = `http://localhost:${proxy.address().port}` + const client = new Client(serverUrl) + const parsedOrigin = new URL(serverUrl) + const dispatcher = client.compose([ + (dispatcher) => new CustomDispatcher(dispatcher), + (dispatcher) => new Proxy(dispatcher, proxyUrl), + (dispatcher) => new CustomDispatcher(dispatcher) + ]) + + proxy.on('connect', () => { + t.ok(true, 'should connect to proxy') + }) + + server.on('request', (req, res) => { + t.strictEqual(req.url, '/') + t.strictEqual( + req.headers.host, + parsedOrigin.host, + 'should not use proxyUrl as host' + ) + res.setHeader('content-type', 'application/json') + res.end(JSON.stringify({ hello: 'world' })) + }) + + const { statusCode, headers, body } = await dispatcher.request({ + path: '/', + method: 'GET', + origin: serverUrl + }) + const json = await body.json() + + t.strictEqual(statusCode, 200) + t.deepStrictEqual(json, { hello: 'world' }) + t.strictEqual( + headers.connection, + 'keep-alive', + 'should remain the connection open' + ) + + server.close() + proxy.close() + await dispatcher.close() +}) + +test('use proxy-interceptor to connect through proxy when composing dispatcher', async t => { t = tspl(t, { plan: 6 }) const server = await buildServer() const proxy = await buildProxy() @@ -50,7 +123,10 @@ test('use proxy-agent to connect through proxy', async t => { const proxyUrl = `http://localhost:${proxy.address().port}` const client = new Client(serverUrl) const parsedOrigin = new URL(serverUrl) - const dispatcher = client.compose(proxyInterceptor(proxyUrl)) + + const dispatcher = client.compose( + dispatcher => new Proxy(dispatcher, proxyUrl) + ) proxy.on('connect', () => { t.ok(true, 'should connect to proxy') @@ -118,7 +194,7 @@ test('use proxy agent to connect through proxy using Pool', async t => { } const client = new Client(serverUrl) const dispatcher = client.compose( - proxyInterceptor({ + dispatcher => new Proxy(dispatcher, { auth: Buffer.from('user:pass').toString('base64'), uri: proxyUrl, clientFactory @@ -151,7 +227,7 @@ test('use proxy-agent to connect through proxy using path with params', async t const proxyUrl = `http://localhost:${proxy.address().port}` const client = new Client(serverUrl) const parsedOrigin = new URL(serverUrl) - const dispatcher = client.compose(proxyInterceptor(proxyUrl)) + const dispatcher = client.compose(dispatcher => new Proxy(dispatcher, proxyUrl)) proxy.on('connect', () => { t.ok(true, 'should call proxy') @@ -196,7 +272,7 @@ test('use proxy-agent to connect through proxy with basic auth in URL', async t const proxyUrl = `http://user:pass@localhost:${proxy.address().port}` const client = new Client(serverUrl) const parsedOrigin = new URL(serverUrl) - const dispatcher = client.compose(proxyInterceptor(proxyUrl)) + const dispatcher = client.compose(dispatcher => new Proxy(dispatcher, proxyUrl)) proxy.authenticate = function (req, fn) { t.ok(true, 'authentication should be called') @@ -250,7 +326,7 @@ test('use proxy-agent with auth', async t => { const client = new Client(serverUrl) const parsedOrigin = new URL(serverUrl) const dispatcher = client.compose( - proxyInterceptor({ + dispatcher => new Proxy(dispatcher, { auth: Buffer.from('user:pass').toString('base64'), uri: proxyUrl }) @@ -308,7 +384,7 @@ test('use proxy-agent with token', async t => { const client = new Client(serverUrl) const parsedOrigin = new URL(serverUrl) const dispatcher = client.compose( - proxyInterceptor({ + dispatcher => new Proxy(dispatcher, { token: `Bearer ${Buffer.from('user:pass').toString('base64')}`, uri: proxyUrl }) @@ -365,7 +441,7 @@ test('use proxy-agent with custom headers', async t => { const proxyUrl = `http://user:pass@localhost:${proxy.address().port}` const client = new Client(serverUrl) const dispatcher = client.compose( - proxyInterceptor({ + dispatcher => new Proxy(dispatcher, { uri: proxyUrl, headers: { 'User-Agent': 'Foobar/1.0.0' @@ -403,7 +479,7 @@ test('sending proxy-authorization in request headers should throw', async t => { const proxyUrl = `http://localhost:${proxy.address().port}` const client = new Client(serverUrl) const dispatcher = client.compose( - proxyInterceptor({ + dispatcher => new Proxy(dispatcher, { uri: proxyUrl }) ) @@ -413,44 +489,38 @@ test('sending proxy-authorization in request headers should throw', async t => { }) await t.rejects( - dispatcher.request( - { - origin: serverUrl, - method: 'GET', - path: '/hello?foo=bar', - headers: { - 'proxy-authorization': Buffer.from('user:pass').toString('base64') - } + dispatcher.request({ + origin: serverUrl, + method: 'GET', + path: '/hello?foo=bar', + headers: { + 'proxy-authorization': Buffer.from('user:pass').toString('base64') } - ), + }), 'Proxy-Authorization should be sent in ProxyAgent' ) await t.rejects( - dispatcher.request( - { - origin: serverUrl, - method: 'GET', - path: '/hello?foo=bar', - headers: { - 'PROXY-AUTHORIZATION': Buffer.from('user:pass').toString('base64') - } + dispatcher.request({ + origin: serverUrl, + method: 'GET', + path: '/hello?foo=bar', + headers: { + 'PROXY-AUTHORIZATION': Buffer.from('user:pass').toString('base64') } - ), + }), 'Proxy-Authorization should be sent in ProxyAgent' ) await t.rejects( - dispatcher.request( - { - origin: serverUrl, - method: 'GET', - path: '/hello?foo=bar', - headers: { - 'Proxy-Authorization': Buffer.from('user:pass').toString('base64') - } + dispatcher.request({ + origin: serverUrl, + method: 'GET', + path: '/hello?foo=bar', + headers: { + 'Proxy-Authorization': Buffer.from('user:pass').toString('base64') } - ), + }), 'Proxy-Authorization should be sent in ProxyAgent' ) @@ -459,7 +529,7 @@ test('sending proxy-authorization in request headers should throw', async t => { await dispatcher.close() }) -test('use proxy-agent with setGlobalDispatcher', async (t) => { +test('use proxy-agent with setGlobalDispatcher', async t => { t = tspl(t, { plan: 6 }) const server = await buildServer() @@ -471,7 +541,7 @@ test('use proxy-agent with setGlobalDispatcher', async (t) => { const parsedOrigin = new URL(serverUrl) const defaultDispatcher = getGlobalDispatcher() - setGlobalDispatcher(defaultDispatcher.compose(proxyInterceptor(proxyUrl))) + setGlobalDispatcher(defaultDispatcher.compose(dispatcher => new Proxy(dispatcher, proxyUrl))) after(() => setGlobalDispatcher(defaultDispatcher)) proxy.on('connect', () => { @@ -508,7 +578,7 @@ test('use proxy-agent with setGlobalDispatcher', async (t) => { proxy.close() }) -test('ProxyAgent correctly sends headers when using fetch - #1355, #1623', async (t) => { +test('ProxyAgent correctly sends headers when using fetch - #1355, #1623', async t => { t = tspl(t, { plan: 2 }) const defaultDispatcher = getGlobalDispatcher() @@ -517,7 +587,7 @@ test('ProxyAgent correctly sends headers when using fetch - #1355, #1623', async const serverUrl = `http://localhost:${server.address().port}` const proxyUrl = `http://localhost:${proxy.address().port}` - setGlobalDispatcher(defaultDispatcher.compose(proxyInterceptor(proxyUrl))) + setGlobalDispatcher(defaultDispatcher.compose(dispatcher => new Proxy(dispatcher, proxyUrl))) after(() => setGlobalDispatcher(defaultDispatcher)) @@ -555,7 +625,7 @@ test('ProxyAgent correctly sends headers when using fetch - #1355, #1623', async t.end() }) -test('should throw when proxy does not return 200', async (t) => { +test('should throw when proxy does not return 200', async t => { t = tspl(t, { plan: 1 }) const server = await buildServer() @@ -568,7 +638,7 @@ test('should throw when proxy does not return 200', async (t) => { return false } - const client = new Client(serverUrl).compose(proxyInterceptor(proxyUrl)) + const client = new Client(serverUrl).compose(dispatcher => new Proxy(dispatcher, proxyUrl)) try { await client.request({ path: '/', method: 'GET' }) t.fail() @@ -581,7 +651,7 @@ test('should throw when proxy does not return 200', async (t) => { await t.completed }) -test('pass ProxyAgent proxy status code error when using fetch - #2161', async (t) => { +test('pass ProxyAgent proxy status code error when using fetch - #2161', async t => { t = tspl(t, { plan: 1 }) const server = await buildServer() @@ -594,7 +664,7 @@ test('pass ProxyAgent proxy status code error when using fetch - #2161', async ( return false } - const client = new Client(serverUrl).compose(proxyInterceptor(proxyUrl)) + const client = new Client(serverUrl).compose(dispatcher => new Proxy(dispatcher, proxyUrl)) try { await fetch(serverUrl, { dispatcher: client }) t.fail() @@ -607,7 +677,7 @@ test('pass ProxyAgent proxy status code error when using fetch - #2161', async ( await t.completed }) -test('Proxy via HTTP to HTTPS endpoint', async (t) => { +test('Proxy via HTTP to HTTPS endpoint', async t => { t = tspl(t, { plan: 4 }) const server = await buildSSLServer() @@ -615,17 +685,25 @@ test('Proxy via HTTP to HTTPS endpoint', async (t) => { const serverUrl = `https://localhost:${server.address().port}` const proxyUrl = `http://localhost:${proxy.address().port}` - const client = new Client(serverUrl).compose(proxyInterceptor({ - uri: proxyUrl, - requestTls: { - ca: [ - readFileSync(resolve(__dirname, '../', 'fixtures', 'ca.pem'), 'utf8') - ], - key: readFileSync(resolve(__dirname, '../', 'fixtures', 'client-key-2048.pem'), 'utf8'), - cert: readFileSync(resolve(__dirname, '../', 'fixtures', 'client-crt-2048.pem'), 'utf8'), - servername: 'agent1' - } - })) + const client = new Client(serverUrl).compose( + dispatcher => new Proxy(dispatcher, { + uri: proxyUrl, + requestTls: { + ca: [ + readFileSync(resolve(__dirname, '../', 'fixtures', 'ca.pem'), 'utf8') + ], + key: readFileSync( + resolve(__dirname, '../', 'fixtures', 'client-key-2048.pem'), + 'utf8' + ), + cert: readFileSync( + resolve(__dirname, '../', 'fixtures', 'client-crt-2048.pem'), + 'utf8' + ), + servername: 'agent1' + } + }) + ) server.on('request', function (req, res) { t.ok(req.connection.encrypted) @@ -648,7 +726,11 @@ test('Proxy via HTTP to HTTPS endpoint', async (t) => { t.fail('proxy should never receive requests') }) - const data = await client.request({ path: '/', origin: serverUrl, method: 'GET' }) + const data = await client.request({ + path: '/', + origin: serverUrl, + method: 'GET' + }) const json = await data.body.json() t.deepStrictEqual(json, { host: `localhost:${server.address().port}`, @@ -660,33 +742,47 @@ test('Proxy via HTTP to HTTPS endpoint', async (t) => { await client.close() }) -test('Proxy via HTTPS to HTTPS endpoint', async (t) => { +test('Proxy via HTTPS to HTTPS endpoint', async t => { t = tspl(t, { plan: 5 }) const server = await buildSSLServer() const proxy = await buildSSLProxy() const serverUrl = `https://localhost:${server.address().port}` const proxyUrl = `https://localhost:${proxy.address().port}` - const proxyAgent = new Client(serverUrl).compose(proxyInterceptor({ - uri: proxyUrl, - proxyTls: { - ca: [ - readFileSync(resolve(__dirname, '../', 'fixtures', 'ca.pem'), 'utf8') - ], - key: readFileSync(resolve(__dirname, '../', 'fixtures', 'client-key-2048.pem'), 'utf8'), - cert: readFileSync(resolve(__dirname, '../', 'fixtures', 'client-crt-2048.pem'), 'utf8'), - servername: 'agent1', - rejectUnauthorized: false - }, - requestTls: { - ca: [ - readFileSync(resolve(__dirname, '../', 'fixtures', 'ca.pem'), 'utf8') - ], - key: readFileSync(resolve(__dirname, '../', 'fixtures', 'client-key-2048.pem'), 'utf8'), - cert: readFileSync(resolve(__dirname, '../', 'fixtures', 'client-crt-2048.pem'), 'utf8'), - servername: 'agent1' - } - })) + const proxyAgent = new Client(serverUrl).compose( + dispatcher => new Proxy(dispatcher, { + uri: proxyUrl, + proxyTls: { + ca: [ + readFileSync(resolve(__dirname, '../', 'fixtures', 'ca.pem'), 'utf8') + ], + key: readFileSync( + resolve(__dirname, '../', 'fixtures', 'client-key-2048.pem'), + 'utf8' + ), + cert: readFileSync( + resolve(__dirname, '../', 'fixtures', 'client-crt-2048.pem'), + 'utf8' + ), + servername: 'agent1', + rejectUnauthorized: false + }, + requestTls: { + ca: [ + readFileSync(resolve(__dirname, '../', 'fixtures', 'ca.pem'), 'utf8') + ], + key: readFileSync( + resolve(__dirname, '../', 'fixtures', 'client-key-2048.pem'), + 'utf8' + ), + cert: readFileSync( + resolve(__dirname, '../', 'fixtures', 'client-crt-2048.pem'), + 'utf8' + ), + servername: 'agent1' + } + }) + ) server.on('request', function (req, res) { t.ok(req.connection.encrypted) @@ -721,25 +817,33 @@ test('Proxy via HTTPS to HTTPS endpoint', async (t) => { proxyAgent.close() }) -test('Proxy via HTTPS to HTTP endpoint', async (t) => { +test('Proxy via HTTPS to HTTP endpoint', async t => { t = tspl(t, { plan: 3 }) const server = await buildServer() const proxy = await buildSSLProxy() const serverUrl = `http://localhost:${server.address().port}` const proxyUrl = `https://localhost:${proxy.address().port}` - const proxyAgent = new Client(serverUrl).compose(proxyInterceptor({ - uri: proxyUrl, - proxyTls: { - ca: [ - readFileSync(resolve(__dirname, '../', 'fixtures', 'ca.pem'), 'utf8') - ], - key: readFileSync(resolve(__dirname, '../', 'fixtures', 'client-key-2048.pem'), 'utf8'), - cert: readFileSync(resolve(__dirname, '../', 'fixtures', 'client-crt-2048.pem'), 'utf8'), - servername: 'agent1', - rejectUnauthorized: false - } - })) + const proxyAgent = new Client(serverUrl).compose( + dispatcher => new Proxy(dispatcher, { + uri: proxyUrl, + proxyTls: { + ca: [ + readFileSync(resolve(__dirname, '../', 'fixtures', 'ca.pem'), 'utf8') + ], + key: readFileSync( + resolve(__dirname, '../', 'fixtures', 'client-key-2048.pem'), + 'utf8' + ), + cert: readFileSync( + resolve(__dirname, '../', 'fixtures', 'client-crt-2048.pem'), + 'utf8' + ), + servername: 'agent1', + rejectUnauthorized: false + } + }) + ) server.on('request', function (req, res) { t.ok(!req.connection.encrypted) @@ -770,14 +874,14 @@ test('Proxy via HTTPS to HTTP endpoint', async (t) => { await proxyAgent.close() }) -test('Proxy via HTTP to HTTP endpoint', async (t) => { +test('Proxy via HTTP to HTTP endpoint', async t => { t = tspl(t, { plan: 3 }) const server = await buildServer() const proxy = await buildProxy() const serverUrl = `http://localhost:${server.address().port}` const proxyUrl = `http://localhost:${proxy.address().port}` - const proxyAgent = new Client(serverUrl).compose(proxyInterceptor(proxyUrl)) + const proxyAgent = new Client(serverUrl).compose(dispatcher => new Proxy(dispatcher, proxyUrl)) server.on('request', function (req, res) { t.ok(!req.connection.encrypted) @@ -822,10 +926,16 @@ function buildServer () { function buildSSLServer () { const serverOptions = { ca: [ - readFileSync(resolve(__dirname, '../', 'fixtures', 'client-ca-crt.pem'), 'utf8') + readFileSync( + resolve(__dirname, '../', 'fixtures', 'client-ca-crt.pem'), + 'utf8' + ) ], key: readFileSync(resolve(__dirname, '../', 'fixtures', 'key.pem'), 'utf8'), - cert: readFileSync(resolve(__dirname, '../', 'fixtures', 'cert.pem'), 'utf8') + cert: readFileSync( + resolve(__dirname, '../', 'fixtures', 'cert.pem'), + 'utf8' + ) } return new Promise(resolve => { const server = https.createServer(serverOptions) @@ -845,10 +955,16 @@ function buildProxy (listener) { function buildSSLProxy () { const serverOptions = { ca: [ - readFileSync(resolve(__dirname, '../', 'fixtures', 'client-ca-crt.pem'), 'utf8') + readFileSync( + resolve(__dirname, '../', 'fixtures', 'client-ca-crt.pem'), + 'utf8' + ) ], key: readFileSync(resolve(__dirname, '../', 'fixtures', 'key.pem'), 'utf8'), - cert: readFileSync(resolve(__dirname, '../', 'fixtures', 'cert.pem'), 'utf8') + cert: readFileSync( + resolve(__dirname, '../', 'fixtures', 'cert.pem'), + 'utf8' + ) } return new Promise(resolve => { From 5588a1db9e1634d4846daccb0eee9589197cf81c Mon Sep 17 00:00:00 2001 From: Carlos Fuentes Date: Fri, 1 Mar 2024 12:28:19 +0100 Subject: [PATCH 08/19] test: add testing for retry --- lib/interceptor/redirect.js | 2 +- lib/interceptor/retry.js | 9 +- test/interceptors/retry.js | 483 ++++++++++++++++++++++++++++++++++++ 3 files changed, 491 insertions(+), 3 deletions(-) create mode 100644 test/interceptors/retry.js diff --git a/lib/interceptor/redirect.js b/lib/interceptor/redirect.js index 82d5deacc45..77ef2c30cf3 100644 --- a/lib/interceptor/redirect.js +++ b/lib/interceptor/redirect.js @@ -2,7 +2,7 @@ const { InvalidArgumentError } = require('../core/errors') const Dispatcher = require('../dispatcher/dispatcher') -const RedirectHandler = require('../handler/RedirectHandler') +const RedirectHandler = require('../handler/redirect-handler') class RedirectDispatcher extends Dispatcher { #opts diff --git a/lib/interceptor/retry.js b/lib/interceptor/retry.js index 1504d330342..c19f357e5e6 100644 --- a/lib/interceptor/retry.js +++ b/lib/interceptor/retry.js @@ -1,7 +1,7 @@ 'use strict' const Dispatcher = require('../dispatcher/dispatcher') -const RetryHandler = require('../handler/RetryHandler') +const RetryHandler = require('../handler/retry-handler') class RetryDispatcher extends Dispatcher { #dispatcher @@ -15,9 +15,14 @@ class RetryDispatcher extends Dispatcher { } dispatch (opts, handler) { + opts.retryOptions = { ...this.#opts, ...opts.retryOptions } + return this.#dispatcher.dispatch( opts, - new RetryHandler(this.#dispatcher, opts, this.#opts, handler) + new RetryHandler(opts, { + handler, + dispatch: this.#dispatcher.dispatch.bind(this.#dispatcher) + }) ) } diff --git a/test/interceptors/retry.js b/test/interceptors/retry.js new file mode 100644 index 00000000000..1cf37398f19 --- /dev/null +++ b/test/interceptors/retry.js @@ -0,0 +1,483 @@ +'use strict' + +const { tspl } = require('@matteo.collina/tspl') +const { test, after } = require('node:test') +const { createServer } = require('node:http') +const { once } = require('node:events') + +const { RetryHandler, Client, interceptors } = require('../..') +const { RequestHandler } = require('../../lib/api/api-request') +const { retry } = interceptors + +test('Should retry status code', async t => { + t = tspl(t, { plan: 4 }) + + let counter = 0 + const server = createServer() + const retryOptions = { + retry: (err, { state, opts }, done) => { + counter++ + + if (err.statusCode === 500 || err.message.includes('other side closed')) { + setTimeout(done, 500) + return + } + + return done(err) + } + } + const requestOptions = { + method: 'GET', + path: '/', + headers: { + 'content-type': 'application/json' + } + } + + server.on('request', (req, res) => { + switch (counter) { + case 0: + req.destroy() + t.ok(true, 'pass') + return + case 1: + res.writeHead(500) + res.end('failed') + t.ok(true, 'pass') + return + case 2: + res.writeHead(200) + res.end('hello world!') + return + default: + t.fail() + } + }) + + server.listen(0) + + await once(server, 'listening') + + const client = new Client( + `http://localhost:${server.address().port}` + ).compose(retry(retryOptions)) + + after(async () => { + await client.close() + server.close() + + await once(server, 'close') + }) + + const response = await client.request(requestOptions) + + t.equal(response.statusCode, 200) + t.equal(await response.body.text(), 'hello world!') +}) + +test('Should use retry-after header for retries', async t => { + t = tspl(t, { plan: 3 }) + + let counter = 0 + const server = createServer() + let checkpoint + const dispatchOptions = { + method: 'PUT', + path: '/', + headers: { + 'content-type': 'application/json' + } + } + + server.on('request', (req, res) => { + switch (counter) { + case 0: + res.writeHead(429, { + 'retry-after': 1 + }) + res.end('rate limit') + checkpoint = Date.now() + counter++ + return + case 1: + res.writeHead(200) + res.end('hello world!') + t.ok(Date.now() - checkpoint >= 500) + counter++ + return + default: + t.fail('unexpected request') + } + }) + + server.listen(0) + + await once(server, 'listening') + + const client = new Client( + `http://localhost:${server.address().port}` + ).compose(retry()) + + after(async () => { + await client.close() + server.close() + + await once(server, 'close') + }) + + const response = await client.request(dispatchOptions) + + t.equal(response.statusCode, 200) + t.equal(await response.body.text(), 'hello world!') +}) + +test('Should use retry-after header for retries (date)', async t => { + t = tspl(t, { plan: 3 }) + + let counter = 0 + const server = createServer() + let checkpoint + const reuestOptions = { + method: 'PUT', + path: '/', + headers: { + 'content-type': 'application/json' + } + } + + server.on('request', (req, res) => { + switch (counter) { + case 0: + res.writeHead(429, { + 'retry-after': new Date( + new Date().setSeconds(new Date().getSeconds() + 1) + ).toUTCString() + }) + res.end('rate limit') + checkpoint = Date.now() + counter++ + return + case 1: + res.writeHead(200) + res.end('hello world!') + t.ok(Date.now() - checkpoint >= 1) + counter++ + return + default: + t.fail('unexpected request') + } + }) + + server.listen(0) + + await once(server, 'listening') + + const client = new Client( + `http://localhost:${server.address().port}` + ).compose(retry()) + + after(async () => { + await client.close() + server.close() + + await once(server, 'close') + }) + + const response = await client.request(reuestOptions) + + t.equal(response.statusCode, 200) + t.equal(await response.body.text(), 'hello world!') +}) + +test('Should retry with defaults', async t => { + t = tspl(t, { plan: 2 }) + + let counter = 0 + const server = createServer() + const requestOptions = { + method: 'GET', + path: '/', + headers: { + 'content-type': 'application/json' + } + } + + server.on('request', (req, res) => { + switch (counter) { + case 0: + req.destroy() + counter++ + return + case 1: + res.writeHead(500) + res.end('failed') + counter++ + return + case 2: + res.writeHead(200) + res.end('hello world!') + counter++ + return + default: + t.fail() + } + }) + + server.listen(0) + + await once(server, 'listening') + + const client = new Client( + `http://localhost:${server.address().port}` + ).compose(retry()) + + after(async () => { + await client.close() + server.close() + + await once(server, 'close') + }) + + const response = await client.request(requestOptions) + + t.equal(response.statusCode, 200) + t.equal(await response.body.text(), 'hello world!') +}) + +test('Should handle 206 partial content', async t => { + t = tspl(t, { plan: 5 }) + + let counter = 0 + + // Took from: https://github.com/nxtedition/nxt-lib/blob/4b001ebc2f22cf735a398f35ff800dd553fe5933/test/undici/retry.js#L47 + let x = 0 + const server = createServer((req, res) => { + if (x === 0) { + t.ok(true, 'pass') + res.setHeader('etag', 'asd') + res.write('abc') + setTimeout(() => { + res.destroy() + }, 1e2) + } else if (x === 1) { + t.deepStrictEqual(req.headers.range, 'bytes=3-') + res.setHeader('content-range', 'bytes 3-6/6') + res.setHeader('etag', 'asd') + res.statusCode = 206 + res.end('def') + } + x++ + }) + + const retryOptions = { + retry: function (err, _, done) { + counter++ + + if (err.code && err.code === 'UND_ERR_DESTROYED') { + return done(false) + } + + if (err.statusCode === 206) return done(err) + + setTimeout(done, 800) + } + } + const requestOptions = { + method: 'GET', + path: '/', + headers: { + 'content-type': 'application/json' + }, + retryOptions + } + + server.listen(0) + + await once(server, 'listening') + + const client = new Client( + `http://localhost:${server.address().port}` + ).compose(retry()) + + after(async () => { + await client.close() + server.close() + + await once(server, 'close') + }) + + const response = await client.request(requestOptions) + + t.equal(response.statusCode, 200) + t.strictEqual(await response.body.text(), 'abcdef') + t.strictEqual(counter, 1) +}) + +test('Should handle 206 partial content - bad-etag', async t => { + t = tspl(t, { plan: 3 }) + + // Took from: https://github.com/nxtedition/nxt-lib/blob/4b001ebc2f22cf735a398f35ff800dd553fe5933/test/undici/retry.js#L47 + let x = 0 + const server = createServer((req, res) => { + if (x === 0) { + t.ok(true, 'pass') + res.setHeader('etag', 'asd') + res.write('abc') + setTimeout(() => { + res.destroy() + }, 1e2) + } else if (x === 1) { + t.deepStrictEqual(req.headers.range, 'bytes=3-') + res.setHeader('content-range', 'bytes 3-6/6') + res.setHeader('etag', 'erwsd') + res.statusCode = 206 + res.end('def') + } + x++ + }) + + const requestOptions = { + method: 'GET', + path: '/', + headers: { + 'content-type': 'application/json' + }, + retryOptions: { + retry: (err, { state, opts }, done) => { + if (err.message.includes('other side closed')) { + setTimeout(done, 100) + return + } + + return done(err) + } + } + } + + server.listen(0) + + await once(server, 'listening') + + const client = new Client( + `http://localhost:${server.address().port}` + ).compose(retry()) + + after(async () => { + await client.close() + server.close() + + await once(server, 'close') + }) + + try { + const response = await client.request(requestOptions) + await response.body.text() + } catch (error) { + t.strict(error, { + message: 'ETag mismatch', + code: 'UND_ERR_REQ_RETRY', + name: 'RequestRetryError' + }) + } +}) + +test('retrying a request with a body', async t => { + t = tspl(t, { plan: 2 }) + let counter = 0 + const server = createServer() + const requestOptions = { + method: 'POST', + path: '/', + headers: { + 'content-type': 'application/json' + }, + body: JSON.stringify({ hello: 'world' }), + retryOptions: { + retry: (err, { state, opts }, done) => { + counter++ + + if ( + err.statusCode === 500 || + err.message.includes('other side closed') + ) { + setTimeout(done, 500) + return + } + + return done(err) + } + } + } + + server.on('request', (req, res) => { + switch (counter) { + case 0: + req.destroy() + return + case 1: + res.writeHead(500) + res.end('failed') + return + case 2: + res.writeHead(200) + res.end('hello world!') + return + default: + t.fail() + } + }) + + server.listen(0) + + await once(server, 'listening') + const client = new Client( + `http://localhost:${server.address().port}` + ).compose(retry()) + + after(async () => { + await client.close() + server.close() + + await once(server, 'close') + }) + + const response = await client.request(requestOptions) + t.equal(response.statusCode, 200) + t.equal(await response.body.text(), 'hello world!') +}) + +test('should not error if request is not meant to be retried', async t => { + t = tspl(t, { plan: 2 }) + + const server = createServer() + server.on('request', (req, res) => { + res.writeHead(400) + res.end('Bad request') + }) + + server.listen(0) + + await once(server, 'listening') + + const client = new Client( + `http://localhost:${server.address().port}` + ).compose(retry()) + + after(async () => { + await client.close() + server.close() + + await once(server, 'close') + }) + + const response = await client.request({ + method: 'GET', + path: '/', + headers: { + 'content-type': 'application/json' + } + }) + + t.equal(response.statusCode, 400) + t.equal(await response.body.text(), 'Bad request') +}) From 04c8acb868354c744122565a11cd50acce4805d9 Mon Sep 17 00:00:00 2001 From: Carlos Fuentes Date: Sun, 3 Mar 2024 12:01:36 +0100 Subject: [PATCH 09/19] refactor: rewrite interceptors --- lib/dispatcher/dispatcher.js | 16 +++++------- lib/interceptor/retry.js | 50 +++++++++++------------------------- test/interceptors/retry.js | 3 +-- 3 files changed, 23 insertions(+), 46 deletions(-) diff --git a/lib/dispatcher/dispatcher.js b/lib/dispatcher/dispatcher.js index a5a2e8edd0f..854fe0d8526 100644 --- a/lib/dispatcher/dispatcher.js +++ b/lib/dispatcher/dispatcher.js @@ -1,11 +1,7 @@ 'use strict' const EventEmitter = require('node:events') -const kDispatcherVersion = Symbol.for('undici.dispatcher.version') - class Dispatcher extends EventEmitter { - [kDispatcherVersion] = 1 - dispatch () { throw new Error('not implemented') } @@ -21,7 +17,6 @@ class Dispatcher extends EventEmitter { compose (...args) { // So we handle [interceptor1, interceptor2] or interceptor1, interceptor2, ... const interceptors = Array.isArray(args[0]) ? args[0] : args - let dispatcher = this for (const interceptor of interceptors) { if (interceptor == null) { continue @@ -31,13 +26,16 @@ class Dispatcher extends EventEmitter { throw new Error('invalid interceptor') } - dispatcher = interceptor(dispatcher) ?? dispatcher + const newDispatch = interceptor(this) - if (dispatcher[kDispatcherVersion] !== 1) { - throw new Error('invalid dispatcher') + if (newDispatch == null || typeof newDispatch !== 'function' || newDispatch.length !== 2) { + throw new Error('invalid interceptor') } + + this.dispatch = newDispatch } - return dispatcher + + return this } } diff --git a/lib/interceptor/retry.js b/lib/interceptor/retry.js index c19f357e5e6..251bd8a3e25 100644 --- a/lib/interceptor/retry.js +++ b/lib/interceptor/retry.js @@ -1,40 +1,20 @@ 'use strict' - -const Dispatcher = require('../dispatcher/dispatcher') const RetryHandler = require('../handler/retry-handler') -class RetryDispatcher extends Dispatcher { - #dispatcher - #opts - - constructor (dispatcher, opts) { - super() - - this.#dispatcher = dispatcher - this.#opts = opts - } - - dispatch (opts, handler) { - opts.retryOptions = { ...this.#opts, ...opts.retryOptions } - - return this.#dispatcher.dispatch( - opts, - new RetryHandler(opts, { - handler, - dispatch: this.#dispatcher.dispatch.bind(this.#dispatcher) - }) - ) +module.exports = globalOpts => { + return dispatcher => { + const bindedDispatch = dispatcher.dispatch.bind(dispatcher) + + return function retryInterceptor (opts, handler) { + opts.retryOptions = { ...globalOpts, ...opts.retryOptions } + + return bindedDispatch( + opts, + new RetryHandler(opts, { + handler, + dispatch: bindedDispatch + }) + ) + } } - - close (...args) { - return this.#dispatcher.close(...args) - } - - destroy (...args) { - return this.#dispatcher.destroy(...args) - } -} - -module.exports = opts => { - return dispatcher => new RetryDispatcher(dispatcher, opts) } diff --git a/test/interceptors/retry.js b/test/interceptors/retry.js index 1cf37398f19..6e6f997cafa 100644 --- a/test/interceptors/retry.js +++ b/test/interceptors/retry.js @@ -5,8 +5,7 @@ const { test, after } = require('node:test') const { createServer } = require('node:http') const { once } = require('node:events') -const { RetryHandler, Client, interceptors } = require('../..') -const { RequestHandler } = require('../../lib/api/api-request') +const { Client, interceptors } = require('../..') const { retry } = interceptors test('Should retry status code', async t => { From 8ac252d4eb3da3b2ac6024d0e227e43ec97e2691 Mon Sep 17 00:00:00 2001 From: Carlos Fuentes Date: Sun, 3 Mar 2024 12:25:47 +0100 Subject: [PATCH 10/19] refactor: proxy interceptor --- index.js | 2 +- lib/interceptor/proxy.js | 44 +++----------------- test/interceptors/proxy.js | 82 +++++++++++++++++--------------------- 3 files changed, 43 insertions(+), 85 deletions(-) diff --git a/index.js b/index.js index 1a518a49d1b..9ffeb572b09 100644 --- a/index.js +++ b/index.js @@ -37,7 +37,7 @@ module.exports.DecoratorHandler = DecoratorHandler module.exports.RedirectHandler = RedirectHandler module.exports.createRedirectInterceptor = createRedirectInterceptor module.exports.interceptors = { - Proxy: require('./lib/interceptor/proxy'), + proxy: require('./lib/interceptor/proxy'), redirect: require('./lib/interceptor/redirect'), retry: require('./lib/interceptor/retry') } diff --git a/lib/interceptor/proxy.js b/lib/interceptor/proxy.js index d86d83f52bf..7bae27d5a29 100644 --- a/lib/interceptor/proxy.js +++ b/lib/interceptor/proxy.js @@ -1,45 +1,11 @@ 'use strict' - -const { InvalidArgumentError } = require('../core/errors') const ProxyAgent = require('../dispatcher/proxy-agent') -const Dispatcher = require('../dispatcher/dispatcher') - -class ProxyInterceptor extends Dispatcher { - constructor (dispatcher, opts) { - if (dispatcher == null) { - throw new InvalidArgumentError( - 'Dispatcher instance is mandatory for ProxyInterceptor' - ) - } - - if (typeof opts === 'string') { - opts = { uri: opts } - } - - if (!opts || (!opts.uri && !(opts instanceof URL))) { - throw new InvalidArgumentError( - 'Proxy opts.uri or instance of URL is mandatory' - ) - } - if (opts.auth && opts.token) { - throw new InvalidArgumentError( - 'opts.auth cannot be used in combination with opts.token' - ) +module.exports = opts => { + const agent = new ProxyAgent(opts) + return () => { + return function proxyInterceptor (opts, handler) { + return agent.dispatch(opts, handler) } - - super() - this.dispatcher = dispatcher - this.agent = new ProxyAgent(opts) - } - - dispatch (opts, handler) { - return this.agent.dispatch(opts, handler) - } - - close () { - return this.dispatcher.close().then(() => this.agent.close()) } } - -module.exports = ProxyInterceptor diff --git a/test/interceptors/proxy.js b/test/interceptors/proxy.js index 5f7d6bd699a..74415b5c87e 100644 --- a/test/interceptors/proxy.js +++ b/test/interceptors/proxy.js @@ -15,23 +15,22 @@ const { getGlobalDispatcher, setGlobalDispatcher, request, - Pool, - Dispatcher + Pool } = require('../..') const { InvalidArgumentError } = require('../../lib/core/errors') -const { Proxy } = interceptors +const { proxy: proxyInterceptor } = interceptors test('should throw error when no uri is provided', t => { t = tspl(t, { plan: 2 }) - t.throws(() => new Proxy(), InvalidArgumentError) - t.throws(() => new Proxy(null, {}), InvalidArgumentError) + t.throws(() => proxyInterceptor(), InvalidArgumentError) + t.throws(() => proxyInterceptor({}), InvalidArgumentError) }) test('using auth in combination with token should throw', t => { t = tspl(t, { plan: 1 }) t.throws( () => - new Proxy({}, { + proxyInterceptor({ auth: 'foo', token: 'Bearer bar', uri: 'http://example.com' @@ -42,26 +41,20 @@ test('using auth in combination with token should throw', t => { test('should accept string, URL and object as options', t => { t = tspl(t, { plan: 3 }) - t.doesNotThrow(() => new Proxy({}, 'http://example.com')) - t.doesNotThrow(() => new Proxy({}, new URL('http://example.com'))) - t.doesNotThrow(() => new Proxy({}, { uri: 'http://example.com' })) + t.doesNotThrow(() => proxyInterceptor('http://example.com')) + t.doesNotThrow(() => proxyInterceptor(new URL('http://example.com'))) + t.doesNotThrow(() => proxyInterceptor({ uri: 'http://example.com' })) }) -test('use proxy-agent to connect through proxy', { skip: true }, async t => { - t = tspl(t, { plan: 8 }) - const CustomDispatcher = class extends Dispatcher { - constructor (dispatcher) { - super() - this.dispatcher = dispatcher - } - - dispatch (opts, handler) { - t.ok(true, 'should call dispatch') - return this.dispatcher.dispatch(opts, handler) - } +test('should work with nested dispatch', async t => { + t = tspl(t, { plan: 7 }) + let counter = 0 + const customDispatch = dispatcher => { + const binded = dispatcher.dispatch.bind(dispatcher) + return (opts, handler) => { + counter++ - close () { - return this.dispatcher.close() + return binded(opts, handler) } } const server = await buildServer() @@ -73,9 +66,9 @@ test('use proxy-agent to connect through proxy', { skip: true }, async t => { const client = new Client(serverUrl) const parsedOrigin = new URL(serverUrl) const dispatcher = client.compose([ - (dispatcher) => new CustomDispatcher(dispatcher), - (dispatcher) => new Proxy(dispatcher, proxyUrl), - (dispatcher) => new CustomDispatcher(dispatcher) + customDispatch, // not called + proxyInterceptor(proxyUrl), // chain restarted here + customDispatch ]) proxy.on('connect', () => { @@ -107,13 +100,14 @@ test('use proxy-agent to connect through proxy', { skip: true }, async t => { 'keep-alive', 'should remain the connection open' ) + t.equal(counter, 1, 'should call customDispatch twice') server.close() proxy.close() await dispatcher.close() }) -test('use proxy-interceptor to connect through proxy when composing dispatcher', async t => { +test('use proxy-agent to connect through proxy', async t => { t = tspl(t, { plan: 6 }) const server = await buildServer() const proxy = await buildProxy() @@ -124,9 +118,7 @@ test('use proxy-interceptor to connect through proxy when composing dispatcher', const client = new Client(serverUrl) const parsedOrigin = new URL(serverUrl) - const dispatcher = client.compose( - dispatcher => new Proxy(dispatcher, proxyUrl) - ) + const dispatcher = client.compose(proxyInterceptor(proxyUrl)) proxy.on('connect', () => { t.ok(true, 'should connect to proxy') @@ -194,7 +186,7 @@ test('use proxy agent to connect through proxy using Pool', async t => { } const client = new Client(serverUrl) const dispatcher = client.compose( - dispatcher => new Proxy(dispatcher, { + proxyInterceptor({ auth: Buffer.from('user:pass').toString('base64'), uri: proxyUrl, clientFactory @@ -227,7 +219,7 @@ test('use proxy-agent to connect through proxy using path with params', async t const proxyUrl = `http://localhost:${proxy.address().port}` const client = new Client(serverUrl) const parsedOrigin = new URL(serverUrl) - const dispatcher = client.compose(dispatcher => new Proxy(dispatcher, proxyUrl)) + const dispatcher = client.compose(proxyInterceptor(proxyUrl)) proxy.on('connect', () => { t.ok(true, 'should call proxy') @@ -272,7 +264,7 @@ test('use proxy-agent to connect through proxy with basic auth in URL', async t const proxyUrl = `http://user:pass@localhost:${proxy.address().port}` const client = new Client(serverUrl) const parsedOrigin = new URL(serverUrl) - const dispatcher = client.compose(dispatcher => new Proxy(dispatcher, proxyUrl)) + const dispatcher = client.compose(proxyInterceptor(proxyUrl)) proxy.authenticate = function (req, fn) { t.ok(true, 'authentication should be called') @@ -326,7 +318,7 @@ test('use proxy-agent with auth', async t => { const client = new Client(serverUrl) const parsedOrigin = new URL(serverUrl) const dispatcher = client.compose( - dispatcher => new Proxy(dispatcher, { + proxyInterceptor({ auth: Buffer.from('user:pass').toString('base64'), uri: proxyUrl }) @@ -384,7 +376,7 @@ test('use proxy-agent with token', async t => { const client = new Client(serverUrl) const parsedOrigin = new URL(serverUrl) const dispatcher = client.compose( - dispatcher => new Proxy(dispatcher, { + proxyInterceptor({ token: `Bearer ${Buffer.from('user:pass').toString('base64')}`, uri: proxyUrl }) @@ -441,7 +433,7 @@ test('use proxy-agent with custom headers', async t => { const proxyUrl = `http://user:pass@localhost:${proxy.address().port}` const client = new Client(serverUrl) const dispatcher = client.compose( - dispatcher => new Proxy(dispatcher, { + proxyInterceptor({ uri: proxyUrl, headers: { 'User-Agent': 'Foobar/1.0.0' @@ -479,7 +471,7 @@ test('sending proxy-authorization in request headers should throw', async t => { const proxyUrl = `http://localhost:${proxy.address().port}` const client = new Client(serverUrl) const dispatcher = client.compose( - dispatcher => new Proxy(dispatcher, { + proxyInterceptor({ uri: proxyUrl }) ) @@ -541,7 +533,7 @@ test('use proxy-agent with setGlobalDispatcher', async t => { const parsedOrigin = new URL(serverUrl) const defaultDispatcher = getGlobalDispatcher() - setGlobalDispatcher(defaultDispatcher.compose(dispatcher => new Proxy(dispatcher, proxyUrl))) + setGlobalDispatcher(defaultDispatcher.compose(proxyInterceptor(proxyUrl))) after(() => setGlobalDispatcher(defaultDispatcher)) proxy.on('connect', () => { @@ -587,7 +579,7 @@ test('ProxyAgent correctly sends headers when using fetch - #1355, #1623', async const serverUrl = `http://localhost:${server.address().port}` const proxyUrl = `http://localhost:${proxy.address().port}` - setGlobalDispatcher(defaultDispatcher.compose(dispatcher => new Proxy(dispatcher, proxyUrl))) + setGlobalDispatcher(defaultDispatcher.compose(proxyInterceptor(proxyUrl))) after(() => setGlobalDispatcher(defaultDispatcher)) @@ -638,7 +630,7 @@ test('should throw when proxy does not return 200', async t => { return false } - const client = new Client(serverUrl).compose(dispatcher => new Proxy(dispatcher, proxyUrl)) + const client = new Client(serverUrl).compose(proxyInterceptor(proxyUrl)) try { await client.request({ path: '/', method: 'GET' }) t.fail() @@ -664,7 +656,7 @@ test('pass ProxyAgent proxy status code error when using fetch - #2161', async t return false } - const client = new Client(serverUrl).compose(dispatcher => new Proxy(dispatcher, proxyUrl)) + const client = new Client(serverUrl).compose(proxyInterceptor(proxyUrl)) try { await fetch(serverUrl, { dispatcher: client }) t.fail() @@ -686,7 +678,7 @@ test('Proxy via HTTP to HTTPS endpoint', async t => { const serverUrl = `https://localhost:${server.address().port}` const proxyUrl = `http://localhost:${proxy.address().port}` const client = new Client(serverUrl).compose( - dispatcher => new Proxy(dispatcher, { + proxyInterceptor({ uri: proxyUrl, requestTls: { ca: [ @@ -750,7 +742,7 @@ test('Proxy via HTTPS to HTTPS endpoint', async t => { const serverUrl = `https://localhost:${server.address().port}` const proxyUrl = `https://localhost:${proxy.address().port}` const proxyAgent = new Client(serverUrl).compose( - dispatcher => new Proxy(dispatcher, { + proxyInterceptor({ uri: proxyUrl, proxyTls: { ca: [ @@ -825,7 +817,7 @@ test('Proxy via HTTPS to HTTP endpoint', async t => { const serverUrl = `http://localhost:${server.address().port}` const proxyUrl = `https://localhost:${proxy.address().port}` const proxyAgent = new Client(serverUrl).compose( - dispatcher => new Proxy(dispatcher, { + proxyInterceptor({ uri: proxyUrl, proxyTls: { ca: [ @@ -881,7 +873,7 @@ test('Proxy via HTTP to HTTP endpoint', async t => { const serverUrl = `http://localhost:${server.address().port}` const proxyUrl = `http://localhost:${proxy.address().port}` - const proxyAgent = new Client(serverUrl).compose(dispatcher => new Proxy(dispatcher, proxyUrl)) + const proxyAgent = new Client(serverUrl).compose(proxyInterceptor(proxyUrl)) server.on('request', function (req, res) { t.ok(!req.connection.encrypted) From 8c8c064dc7d6f2f347fb77b9930622f6878953ce Mon Sep 17 00:00:00 2001 From: Carlos Fuentes Date: Sun, 3 Mar 2024 20:26:01 +0100 Subject: [PATCH 11/19] feat: redirect interceptor --- lib/interceptor/redirect.js | 49 +-- test/interceptors/redirect.js | 672 ++++++++++++++++++++++++++++++++++ 2 files changed, 685 insertions(+), 36 deletions(-) create mode 100644 test/interceptors/redirect.js diff --git a/lib/interceptor/redirect.js b/lib/interceptor/redirect.js index 77ef2c30cf3..4862fcf135c 100644 --- a/lib/interceptor/redirect.js +++ b/lib/interceptor/redirect.js @@ -1,44 +1,21 @@ 'use strict' - -const { InvalidArgumentError } = require('../core/errors') -const Dispatcher = require('../dispatcher/dispatcher') const RedirectHandler = require('../handler/redirect-handler') -class RedirectDispatcher extends Dispatcher { - #opts - #dispatcher - - constructor (dispatcher, opts) { - super() - - this.#dispatcher = dispatcher - this.#opts = opts - } - - dispatch (opts, handler) { - return this.#dispatcher.dispatch( - opts, - new RedirectHandler(this.#dispatcher, opts, this.#opts, handler) - ) - } - - close (...args) { - return this.#dispatcher.close(...args) - } +module.exports = opts => { + const globalMaxRedirections = opts?.maxRedirections + return dispatcher => { + const dispatch = dispatcher.dispatch.bind(dispatcher) - destroy (...args) { - return this.#dispatcher.destroy(...args) - } -} + return function redirectInterceptor (opts, handler) { + const { maxRedirections = globalMaxRedirections } = opts -module.exports = opts => { - if (opts?.maxRedirections == null || opts?.maxRedirections === 0) { - return null - } + if (!maxRedirections) { + return dispatch(opts, handler) + } - if (!Number.isInteger(opts.maxRedirections) || opts.maxRedirections < 0) { - throw new InvalidArgumentError('maxRedirections must be a positive number') + const redirectHandler = new RedirectHandler(dispatch, maxRedirections, opts, handler) + opts = { ...opts, maxRedirections: 0 } // Stop sub dispatcher from also redirecting. + return dispatch(opts, redirectHandler) + } } - - return dispatcher => new RedirectDispatcher(dispatcher, opts) } diff --git a/test/interceptors/redirect.js b/test/interceptors/redirect.js new file mode 100644 index 00000000000..239c9597568 --- /dev/null +++ b/test/interceptors/redirect.js @@ -0,0 +1,672 @@ +'use strict' + +const { tspl } = require('@matteo.collina/tspl') +const { test, after } = require('node:test') +const undici = require('../..') +const { + startRedirectingServer, + startRedirectingWithBodyServer, + startRedirectingChainServers, + startRedirectingWithoutLocationServer, + startRedirectingWithAuthorization, + startRedirectingWithCookie, + startRedirectingWithQueryParams +} = require('../utils/redirecting-servers') +const { createReadable, createReadableStream } = require('../utils/stream') + +const { + interceptors: { redirect } +} = undici + +for (const factory of [ + (server, opts) => + new undici.Agent(opts).compose(redirect(opts?.maxRedirections)), + (server, opts) => + new undici.Pool(`http://${server}`, opts).compose( + redirect(opts?.maxRedirections) + ), + (server, opts) => + new undici.Client(`http://${server}`, opts).compose( + redirect(opts?.maxRedirections) + ) +]) { + const request = (t, server, opts, ...args) => { + const dispatcher = factory(server, opts) + after(() => dispatcher.close()) + return undici.request(args[0], { ...args[1], dispatcher }, args[2]) + } + + test('should always have a history with the final URL even if no redirections were followed', async t => { + t = tspl(t, { plan: 4 }) + + const server = await startRedirectingServer() + + const { + statusCode, + headers, + body: bodyStream, + context: { history } + } = await request(t, server, undefined, `http://${server}/200?key=value`, { + maxRedirections: 10 + }) + + const body = await bodyStream.text() + + t.strictEqual(statusCode, 200) + t.ok(!headers.location) + t.deepStrictEqual( + history.map(x => x.toString()), + [`http://${server}/200?key=value`] + ) + t.strictEqual( + body, + `GET /5 key=value :: host@${server} connection@keep-alive` + ) + + await t.completed + }) + + test('should not follow redirection by default if not using RedirectAgent', async t => { + t = tspl(t, { plan: 3 }) + + const server = await startRedirectingServer() + + const { + statusCode, + headers, + body: bodyStream + } = await request(t, server, undefined, `http://${server}`) + const body = await bodyStream.text() + + t.strictEqual(statusCode, 302) + t.strictEqual(headers.location, `http://${server}/302/1`) + t.strictEqual(body.length, 0) + + await t.completed + }) + + test('should follow redirection after a HTTP 300', async t => { + t = tspl(t, { plan: 4 }) + + const server = await startRedirectingServer() + + const { + statusCode, + headers, + body: bodyStream, + context: { history } + } = await request(t, server, undefined, `http://${server}/300?key=value`, { + maxRedirections: 10 + }) + + const body = await bodyStream.text() + + t.strictEqual(statusCode, 200) + t.ok(!headers.location) + t.deepStrictEqual( + history.map(x => x.toString()), + [ + `http://${server}/300?key=value`, + `http://${server}/300/1?key=value`, + `http://${server}/300/2?key=value`, + `http://${server}/300/3?key=value`, + `http://${server}/300/4?key=value`, + `http://${server}/300/5?key=value` + ] + ) + t.strictEqual( + body, + `GET /5 key=value :: host@${server} connection@keep-alive` + ) + + await t.completed + }) + + test('should follow redirection after a HTTP 300 default', async t => { + t = tspl(t, { plan: 4 }) + + const server = await startRedirectingServer() + + const { + statusCode, + headers, + body: bodyStream, + context: { history } + } = await request( + t, + server, + { maxRedirections: 10 }, + `http://${server}/300?key=value` + ) + const body = await bodyStream.text() + + t.strictEqual(statusCode, 200) + t.ok(!headers.location) + t.deepStrictEqual( + history.map(x => x.toString()), + [ + `http://${server}/300?key=value`, + `http://${server}/300/1?key=value`, + `http://${server}/300/2?key=value`, + `http://${server}/300/3?key=value`, + `http://${server}/300/4?key=value`, + `http://${server}/300/5?key=value` + ] + ) + t.strictEqual( + body, + `GET /5 key=value :: host@${server} connection@keep-alive` + ) + + await t.completed + }) + + test('should follow redirection after a HTTP 301', async t => { + t = tspl(t, { plan: 3 }) + + const server = await startRedirectingServer() + + const { + statusCode, + headers, + body: bodyStream + } = await request(t, server, undefined, `http://${server}/301`, { + method: 'POST', + body: 'REQUEST', + maxRedirections: 10 + }) + + const body = await bodyStream.text() + + t.strictEqual(statusCode, 200) + t.ok(!headers.location) + t.strictEqual( + body, + `POST /5 :: host@${server} connection@keep-alive content-length@7 :: REQUEST` + ) + }) + + test('should follow redirection after a HTTP 302', async t => { + t = tspl(t, { plan: 3 }) + const server = await startRedirectingServer() + + const { + statusCode, + headers, + body: bodyStream + } = await request(t, server, undefined, `http://${server}/302`, { + method: 'PUT', + body: Buffer.from('REQUEST'), + maxRedirections: 10 + }) + + const body = await bodyStream.text() + + t.strictEqual(statusCode, 200) + t.ok(!headers.location) + t.strictEqual( + body, + `PUT /5 :: host@${server} connection@keep-alive content-length@7 :: REQUEST` + ) + }) + + test('should follow redirection after a HTTP 303 changing method to GET', async t => { + t = tspl(t, { plan: 3 }) + + const server = await startRedirectingServer() + + const { + statusCode, + headers, + body: bodyStream + } = await request(t, server, undefined, `http://${server}/303`, { + method: 'PATCH', + body: 'REQUEST', + maxRedirections: 10 + }) + + const body = await bodyStream.text() + + t.strictEqual(statusCode, 200) + t.ok(!headers.location) + t.strictEqual(body, `GET /5 :: host@${server} connection@keep-alive`) + + await t.completed + }) + + test('should remove Host and request body related headers when following HTTP 303 (array)', async t => { + t = tspl(t, { plan: 3 }) + + const server = await startRedirectingServer() + + const { + statusCode, + headers, + body: bodyStream + } = await request(t, server, undefined, `http://${server}/303`, { + method: 'PATCH', + headers: [ + 'Content-Encoding', + 'gzip', + 'X-Foo1', + '1', + 'X-Foo2', + '2', + 'Content-Type', + 'application/json', + 'X-Foo3', + '3', + 'Host', + 'localhost', + 'X-Bar', + '4' + ], + maxRedirections: 10 + }) + + const body = await bodyStream.text() + + t.strictEqual(statusCode, 200) + t.ok(!headers.location) + t.strictEqual( + body, + `GET /5 :: host@${server} connection@keep-alive x-foo1@1 x-foo2@2 x-foo3@3 x-bar@4` + ) + + await t.completed + }) + + test('should remove Host and request body related headers when following HTTP 303 (object)', async t => { + t = tspl(t, { plan: 3 }) + + const server = await startRedirectingServer() + + const { + statusCode, + headers, + body: bodyStream + } = await request(t, server, undefined, `http://${server}/303`, { + method: 'PATCH', + headers: { + 'Content-Encoding': 'gzip', + 'X-Foo1': '1', + 'X-Foo2': '2', + 'Content-Type': 'application/json', + 'X-Foo3': '3', + Host: 'localhost', + 'X-Bar': '4' + }, + maxRedirections: 10 + }) + + const body = await bodyStream.text() + + t.strictEqual(statusCode, 200) + t.ok(!headers.location) + t.strictEqual( + body, + `GET /5 :: host@${server} connection@keep-alive x-foo1@1 x-foo2@2 x-foo3@3 x-bar@4` + ) + + await t.completed + }) + + test('should follow redirection after a HTTP 307', async t => { + t = tspl(t, { plan: 3 }) + + const server = await startRedirectingServer() + + const { + statusCode, + headers, + body: bodyStream + } = await request(t, server, undefined, `http://${server}/307`, { + method: 'DELETE', + maxRedirections: 10 + }) + + const body = await bodyStream.text() + + t.strictEqual(statusCode, 200) + t.ok(!headers.location) + t.strictEqual(body, `DELETE /5 :: host@${server} connection@keep-alive`) + + await t.completed + }) + + test('should follow redirection after a HTTP 308', async t => { + t = tspl(t, { plan: 3 }) + + const server = await startRedirectingServer() + + const { + statusCode, + headers, + body: bodyStream + } = await request(t, server, undefined, `http://${server}/308`, { + method: 'OPTIONS', + maxRedirections: 10 + }) + + const body = await bodyStream.text() + + t.strictEqual(statusCode, 200) + t.ok(!headers.location) + t.strictEqual(body, `OPTIONS /5 :: host@${server} connection@keep-alive`) + + await t.completed + }) + + test('should ignore HTTP 3xx response bodies', async t => { + t = tspl(t, { plan: 4 }) + + const server = await startRedirectingWithBodyServer() + + const { + statusCode, + headers, + body: bodyStream, + context: { history } + } = await request(t, server, undefined, `http://${server}/`, { + maxRedirections: 10 + }) + + const body = await bodyStream.text() + + t.strictEqual(statusCode, 200) + t.ok(!headers.location) + t.deepStrictEqual( + history.map(x => x.toString()), + [`http://${server}/`, `http://${server}/end`] + ) + t.strictEqual(body, 'FINAL') + + await t.completed + }) + + test('should ignore query after redirection', async t => { + t = tspl(t, { plan: 3 }) + + const server = await startRedirectingWithQueryParams() + + const { + statusCode, + headers, + context: { history } + } = await request(t, server, undefined, `http://${server}/`, { + maxRedirections: 10, + query: { param1: 'first' } + }) + + t.strictEqual(statusCode, 200) + t.ok(!headers.location) + t.deepStrictEqual( + history.map(x => x.toString()), + [`http://${server}/`, `http://${server}/?param2=second`] + ) + + await t.completed + }) + + test('should follow a redirect chain up to the allowed number of times', async t => { + t = tspl(t, { plan: 4 }) + + const server = await startRedirectingServer() + + const { + statusCode, + headers, + body: bodyStream, + context: { history } + } = await request(t, server, undefined, `http://${server}/300`, { + maxRedirections: 2 + }) + + const body = await bodyStream.text() + + t.strictEqual(statusCode, 300) + t.strictEqual(headers.location, `http://${server}/300/3`) + t.deepStrictEqual( + history.map(x => x.toString()), + [ + `http://${server}/300`, + `http://${server}/300/1`, + `http://${server}/300/2` + ] + ) + t.strictEqual(body.length, 0) + + await t.completed + }) + + test('should follow a redirect chain up to the allowed number of times for redirectionLimitReached', async t => { + t = tspl(t, { plan: 1 }) + + const server = await startRedirectingServer() + + try { + await request(t, server, undefined, `http://${server}/300`, { + maxRedirections: 2, + throwOnMaxRedirect: true + }) + } catch (error) { + if (error.message.startsWith('max redirects')) { + t.ok(true, 'Max redirects handled correctly') + } else { + t.fail(`Unexpected error: ${error.message}`) + } + } + + await t.completed + }) + + test('when a Location response header is NOT present', async t => { + t = tspl(t, { plan: 6 * 3 }) + + const redirectCodes = [300, 301, 302, 303, 307, 308] + const server = await startRedirectingWithoutLocationServer() + + for (const code of redirectCodes) { + const { + statusCode, + headers, + body: bodyStream + } = await request(t, server, undefined, `http://${server}/${code}`, { + maxRedirections: 10 + }) + + const body = await bodyStream.text() + + t.strictEqual(statusCode, code) + t.ok(!headers.location) + t.strictEqual(body.length, 0) + } + await t.completed + }) + + test('should not allow invalid maxRedirections arguments', async t => { + t = tspl(t, { plan: 1 }) + + try { + await request(t, 'localhost', undefined, 'http://localhost', { + method: 'GET', + maxRedirections: 'INVALID' + }) + + t.fail('Did not throw') + } catch (err) { + t.strictEqual(err.message, 'maxRedirections must be a positive number') + } + await t.completed + }) + + test('should not allow invalid maxRedirections arguments default', async t => { + t = tspl(t, { plan: 1 }) + + try { + await request( + t, + 'localhost', + { + maxRedirections: 'INVALID' + }, + 'http://localhost', + { + method: 'GET' + } + ) + + t.fail('Did not throw') + } catch (err) { + t.strictEqual(err.message, 'maxRedirections must be a positive number') + } + + await t.completed + }) + + test('should not follow redirects when using ReadableStream request bodies', async t => { + t = tspl(t, { plan: 3 }) + + const server = await startRedirectingServer() + + const { + statusCode, + headers, + body: bodyStream + } = await request(t, server, undefined, `http://${server}/301`, { + method: 'POST', + body: createReadableStream('REQUEST'), + maxRedirections: 10 + }) + + const body = await bodyStream.text() + + t.strictEqual(statusCode, 301) + t.strictEqual(headers.location, `http://${server}/301/2`) + t.strictEqual(body.length, 0) + + await t.completed + }) + + test('should not follow redirects when using Readable request bodies', async t => { + t = tspl(t, { plan: 3 }) + + const server = await startRedirectingServer() + + const { + statusCode, + headers, + body: bodyStream + } = await request(t, server, undefined, `http://${server}/301`, { + method: 'POST', + body: createReadable('REQUEST'), + maxRedirections: 10 + }) + + const body = await bodyStream.text() + + t.strictEqual(statusCode, 301) + t.strictEqual(headers.location, `http://${server}/301/1`) + t.strictEqual(body.length, 0) + await t.completed + }) +} + +test('should follow redirections when going cross origin', async t => { + t = tspl(t, { plan: 4 }) + + const [server1, server2, server3] = await startRedirectingChainServers() + + const { + statusCode, + headers, + body: bodyStream, + context: { history } + } = await undici.request(`http://${server1}`, { + method: 'POST', + maxRedirections: 10 + }) + + const body = await bodyStream.text() + + t.strictEqual(statusCode, 200) + t.ok(!headers.location) + t.deepStrictEqual( + history.map(x => x.toString()), + [ + `http://${server1}/`, + `http://${server2}/`, + `http://${server3}/`, + `http://${server2}/end`, + `http://${server3}/end`, + `http://${server1}/end` + ] + ) + t.strictEqual(body, 'POST') + + await t.completed +}) + +test('should handle errors (callback)', async t => { + t = tspl(t, { plan: 1 }) + + undici.request( + 'http://localhost:0', + { + maxRedirections: 10 + }, + error => { + t.match(error.code, /EADDRNOTAVAIL|ECONNREFUSED/) + } + ) + + await t.completed +}) + +test('should handle errors (promise)', async t => { + t = tspl(t, { plan: 1 }) + + try { + await undici.request('http://localhost:0', { maxRedirections: 10 }) + t.fail('Did not throw') + } catch (error) { + t.match(error.code, /EADDRNOTAVAIL|ECONNREFUSED/) + } + + await t.completed +}) + +test('removes authorization header on third party origin', async t => { + t = tspl(t, { plan: 1 }) + + const [server1] = await startRedirectingWithAuthorization('secret') + const { body: bodyStream } = await undici.request(`http://${server1}`, { + maxRedirections: 10, + headers: { + authorization: 'secret' + } + }) + + const body = await bodyStream.text() + + t.strictEqual(body, '') + + await t.completed +}) + +test('removes cookie header on third party origin', async t => { + t = tspl(t, { plan: 1 }) + const [server1] = await startRedirectingWithCookie('a=b') + const { body: bodyStream } = await undici.request(`http://${server1}`, { + maxRedirections: 10, + headers: { + cookie: 'a=b' + } + }) + + const body = await bodyStream.text() + + t.strictEqual(body, '') + + await t.completed +}) From 0fd9ea72c2838f8dafb6bb28c20e6e5333ad5844 Mon Sep 17 00:00:00 2001 From: Carlos Fuentes Date: Wed, 6 Mar 2024 10:53:22 +0100 Subject: [PATCH 12/19] refactor: change the compose behaviour --- lib/dispatcher/dispatcher.js | 10 ++++++---- lib/interceptor/proxy.js | 8 ++++---- lib/interceptor/redirect.js | 11 +++++++---- lib/interceptor/retry.js | 8 +++----- test/interceptors/proxy.js | 5 ++--- 5 files changed, 22 insertions(+), 20 deletions(-) diff --git a/lib/dispatcher/dispatcher.js b/lib/dispatcher/dispatcher.js index 854fe0d8526..2af0c2ed793 100644 --- a/lib/dispatcher/dispatcher.js +++ b/lib/dispatcher/dispatcher.js @@ -17,6 +17,8 @@ class Dispatcher extends EventEmitter { compose (...args) { // So we handle [interceptor1, interceptor2] or interceptor1, interceptor2, ... const interceptors = Array.isArray(args[0]) ? args[0] : args + let dispatch = this.dispatch.bind(this) + for (const interceptor of interceptors) { if (interceptor == null) { continue @@ -26,15 +28,15 @@ class Dispatcher extends EventEmitter { throw new Error('invalid interceptor') } - const newDispatch = interceptor(this) + dispatch = interceptor(dispatch) - if (newDispatch == null || typeof newDispatch !== 'function' || newDispatch.length !== 2) { + if (dispatch == null || typeof dispatch !== 'function' || dispatch.length !== 2) { throw new Error('invalid interceptor') } - - this.dispatch = newDispatch } + this.dispatch = dispatch + return this } } diff --git a/lib/interceptor/proxy.js b/lib/interceptor/proxy.js index 7bae27d5a29..faa416a29d1 100644 --- a/lib/interceptor/proxy.js +++ b/lib/interceptor/proxy.js @@ -3,9 +3,9 @@ const ProxyAgent = require('../dispatcher/proxy-agent') module.exports = opts => { const agent = new ProxyAgent(opts) - return () => { - return function proxyInterceptor (opts, handler) { - return agent.dispatch(opts, handler) - } + return dispatch => { + dispatch = agent.dispatch.bind(agent) + + return dispatch } } diff --git a/lib/interceptor/redirect.js b/lib/interceptor/redirect.js index 4862fcf135c..63663eef041 100644 --- a/lib/interceptor/redirect.js +++ b/lib/interceptor/redirect.js @@ -3,9 +3,7 @@ const RedirectHandler = require('../handler/redirect-handler') module.exports = opts => { const globalMaxRedirections = opts?.maxRedirections - return dispatcher => { - const dispatch = dispatcher.dispatch.bind(dispatcher) - + return dispatch => { return function redirectInterceptor (opts, handler) { const { maxRedirections = globalMaxRedirections } = opts @@ -13,7 +11,12 @@ module.exports = opts => { return dispatch(opts, handler) } - const redirectHandler = new RedirectHandler(dispatch, maxRedirections, opts, handler) + const redirectHandler = new RedirectHandler( + dispatch, + maxRedirections, + opts, + handler + ) opts = { ...opts, maxRedirections: 0 } // Stop sub dispatcher from also redirecting. return dispatch(opts, redirectHandler) } diff --git a/lib/interceptor/retry.js b/lib/interceptor/retry.js index 251bd8a3e25..f89b56d3665 100644 --- a/lib/interceptor/retry.js +++ b/lib/interceptor/retry.js @@ -2,17 +2,15 @@ const RetryHandler = require('../handler/retry-handler') module.exports = globalOpts => { - return dispatcher => { - const bindedDispatch = dispatcher.dispatch.bind(dispatcher) - + return dispatch => { return function retryInterceptor (opts, handler) { opts.retryOptions = { ...globalOpts, ...opts.retryOptions } - return bindedDispatch( + return dispatch( opts, new RetryHandler(opts, { handler, - dispatch: bindedDispatch + dispatch }) ) } diff --git a/test/interceptors/proxy.js b/test/interceptors/proxy.js index 74415b5c87e..094214328e3 100644 --- a/test/interceptors/proxy.js +++ b/test/interceptors/proxy.js @@ -49,12 +49,11 @@ test('should accept string, URL and object as options', t => { test('should work with nested dispatch', async t => { t = tspl(t, { plan: 7 }) let counter = 0 - const customDispatch = dispatcher => { - const binded = dispatcher.dispatch.bind(dispatcher) + const customDispatch = dispatch => { return (opts, handler) => { counter++ - return binded(opts, handler) + return dispatch(opts, handler) } } const server = await buildServer() From 53f4cfa9d36afc338603c855c04dcac51f77096e Mon Sep 17 00:00:00 2001 From: Carlos Fuentes Date: Wed, 6 Mar 2024 11:14:43 +0100 Subject: [PATCH 13/19] docs: update docs --- docs/docs/api/Dispatcher.md | 177 ++++++++++++++++++++-------------- docs/docs/api/Interceptors.md | 68 ------------- docs/docsify/sidebar.md | 1 - 3 files changed, 104 insertions(+), 142 deletions(-) delete mode 100644 docs/docs/api/Interceptors.md diff --git a/docs/docs/api/Dispatcher.md b/docs/docs/api/Dispatcher.md index d393c722820..7a7f537c701 100644 --- a/docs/docs/api/Dispatcher.md +++ b/docs/docs/api/Dispatcher.md @@ -828,118 +828,149 @@ Compose a new dispatcher from the current dispatcher and the given interceptors. Arguments: -* **interceptors** `Interceptor[]`: It is an array of `Interceptor` functions passed as only argument, or several interceptors passed as separate arguments. +* **interceptors** `Interceptor[interceptor[]]`: It is an array of `Interceptor` functions passed as only argument, or several interceptors passed as separate arguments. It can also support sevearl interceptors passed as positional arguments Returns: `Dispatcher`. #### Parameter: `Interceptor` -A function that takes a `Dispatcher` instance and returns a `Dispatcher` instance. +A function that takes a `dispatch` instance and returns a `dispatch`-like function. #### Example 1 - Basic Compose ```js -import { RedirectHandler, Dispatcher } from 'undici' +const { Client, RedirectHandler } = require('undici') -class RedirectDispatcher extends Dispatcher { - #opts - #dispatcher +const redirectInterceptor = dispatch => { + return (opts, handler) => { + const { maxRedirections = globalMaxRedirections } = opts - constructor (dispatcher, opts) { - super() - - this.#dispatcher = dispatcher - this.#opts = opts - } - - dispatch (opts, handler) { - return this.#dispatcher.dispatch( - opts, - new RedirectHandler(this.#dispatcher, opts, this.#opts, handler) - ) - } - - close (...args) { - return this.#dispatcher.close(...args) - } + if (!maxRedirections) { + return dispatch(opts, handler) + } - destroy (...args) { - return this.#dispatcher.destroy(...args) - } + const redirectHandler = new RedirectHandler( + dispatch, + maxRedirections, + opts, + handler + ) + opts = { ...opts, maxRedirections: 0 } // Stop sub dispatcher from also redirecting. + return dispatch(opts, redirectHandler) + } } -const redirectInterceptor = dispatcher => new RedirectDispatcher(dispatcher, opts) - const client = new Client('http://localhost:3000') .compose(redirectInterceptor) + +await client.request({ path: '/', method: 'GET' }) ``` #### Example 2 - Chained Compose ```js -import { RedirectHandler, Dispatcher, RetryHandler } from 'undici' +const { Client, RedirectHandler, RetryHandler } = require('undici') -class RedirectDispatcher extends Dispatcher { - #opts - #dispatcher +const redirectInterceptor = dispatch => { + return (opts, handler) => { + const { maxRedirections = globalMaxRedirections } = opts - constructor (dispatcher, opts) { - super() + if (!maxRedirections) { + return dispatch(opts, handler) + } - this.#dispatcher = dispatcher - this.#opts = opts - } + const redirectHandler = new RedirectHandler( + dispatch, + maxRedirections, + opts, + handler + ) + opts = { ...opts, maxRedirections: 0 } // Stop sub dispatcher from also redirecting. + return dispatch(opts, redirectHandler) + } +} - dispatch (opts, handler) { - return this.#dispatcher.dispatch( +const retryInterceptor = dispatch => { + return function retryInterceptor (opts, handler) { + opts.retryOptions = { ...globalOpts, ...opts.retryOptions } + + return dispatch( opts, - new RedirectHandler(this.#dispatcher, opts, this.#opts, handler) + new RetryHandler(opts, { + handler, + dispatch + }) ) } +} - close (...args) { - return this.#dispatcher.close(...args) - } +const client = new Client('http://localhost:3000') + .compose(redirectInterceptor) + .compose(retryInterceptor) - destroy (...args) { - return this.#dispatcher.destroy(...args) - } -} +await client.request({ path: '/', method: 'GET' }) +``` -class RetryDispatcher extends Dispatcher { - #dispatcher - #opts +#### Pre-built interceptors - constructor (dispatcher, opts) { - super() +##### `proxy` - this.#dispatcher = dispatcher - this.#opts = opts - } +The `proxy` interceptor allows you to connect to a proxy server before connecting to the origin server. - dispatch (opts, handler) { - return this.#dispatcher.dispatch( - opts, - new RetryHandler(this.#dispatcher, opts, this.#opts, handler) - ) - } +It accepts the same arguments as the [`ProxyAgent` constructor](./ProxyAgent.md). - close (...args) { - return this.#dispatcher.close(...args) - } +**Example - Basic Proxy Interceptor** - destroy (...args) { - return this.#dispatcher.destroy(...args) - } -} +```js +const { Client, interceptors } = require("undici"); +const { proxy } = interceptors; +const client = new Client("http://example.com").compose( + proxy("http://proxy.com") +); -const redirectInterceptor = dispatcher => new RedirectDispatcher(dispatcher, opts) -const retryInterceptor = dispatcher => new RetryDispatcher(dispatcher, opts) +client.request({ path: "/" }); +``` -const client = new Client('http://localhost:3000') - .compose(redirectInterceptor) - .compose(retryInterceptor) +##### `redirect` + +The `redirect` interceptor allows you to customize the way your dispatcher handles redirects. + +It accepts the same arguments as the [`RedirectHandler` constructor](./RedirectHandler.md). + +**Example - Basic Redirect Interceptor** + +```js +const { Client, interceptors } = require("undici"); +const { redirect } = interceptors; + +const client = new Client("http://example.com").compose( + redirect({ maxRedirections: 3, throwOnMaxRedirects: true }) +); +client.request({ path: "/" }) +``` + +##### `retry` + +The `retry` interceptor allows you to customize the way your dispatcher handles retries. + +It accepts the same arguments as the [`RetryHandler` constructor](./RetryHandler.md). + +**Example - Basic Redirect Interceptor** + +```js +const { Client, interceptors } = require("undici"); +const { retry } = interceptors; + +const client = new Client("http://example.com").compose( + retry({ + maxRetries: 3, + minTimeout: 1000, + maxTimeout: 10000, + timeoutFactor: 2, + retryAfter: true, + }) +); ``` ## Instance Events diff --git a/docs/docs/api/Interceptors.md b/docs/docs/api/Interceptors.md deleted file mode 100644 index cd66fc3cbab..00000000000 --- a/docs/docs/api/Interceptors.md +++ /dev/null @@ -1,68 +0,0 @@ -# Interceptors - -Undici provides a way to intercept requests and responses using interceptors. - -Interceptors are a way to modify the request or response before it is sent or received by the original dispatcher, apply custom logic to a network request, or even cancel the request, connect through a proxy for the origin, etc. - -Within Undici there are a set of pre-built that can be used, on top of that, you can create your own interceptors. - -## Pre-built interceptors - -### `proxy` - -The `proxy` interceptor allows you to connect to a proxy server before connecting to the origin server. - -It accepts the same arguments as the [`ProxyAgent` constructor](./ProxyAgent.md). - -#### Example - Basic Proxy Interceptor - -```js -const { Client, interceptors } = require("undici"); -const { proxy } = interceptors; - -const client = new Client("http://example.com"); - -client.compose(proxy("http://proxy.com")); -``` - -### `redirect` - -The `redirect` interceptor allows you to customize the way your dispatcher handles redirects. - -It accepts the same arguments as the [`RedirectHandler` constructor](./RedirectHandler.md). - -#### Example - Basic Redirect Interceptor - -```js -const { Client, interceptors } = require("undici"); -const { redirect } = interceptors; - -const client = new Client("http://example.com"); - -client.compose(redirect({ maxRedirections: 3, throwOnMaxRedirects: true })); -``` - -### `retry` - -The `retry` interceptor allows you to customize the way your dispatcher handles retries. - -It accepts the same arguments as the [`RetryHandler` constructor](./RetryHandler.md). - -#### Example - Basic Redirect Interceptor - -```js -const { Client, interceptors } = require("undici"); -const { retry } = interceptors; - -const client = new Client("http://example.com"); - -client.compose( - retry({ - maxRetries: 3, - minTimeout: 1000, - maxTimeout: 10000, - timeoutFactor: 2, - retryAfter: true, - }) -); -``` diff --git a/docs/docsify/sidebar.md b/docs/docsify/sidebar.md index abcde521c76..674c0dad0e7 100644 --- a/docs/docsify/sidebar.md +++ b/docs/docsify/sidebar.md @@ -9,7 +9,6 @@ * [Agent](/docs/api/Agent.md "Undici API - Agent") * [ProxyAgent](/docs/api/ProxyAgent.md "Undici API - ProxyAgent") * [RetryAgent](/docs/api/RetryAgent.md "Undici API - RetryAgent") - * [Interceptors](/docs/api/Interceptors.md "Undici API - Interceptors") * [Connector](/docs/api/Connector.md "Custom connector") * [Errors](/docs/api/Errors.md "Undici API - Errors") * [EventSource](/docs/api/EventSource.md "Undici API - EventSource") From 6580d8483d90d58377de007a815e1bd8c7d7c398 Mon Sep 17 00:00:00 2001 From: Carlos Fuentes Date: Wed, 6 Mar 2024 12:27:38 +0100 Subject: [PATCH 14/19] test: add testing for compose --- docs/docs/api/Dispatcher.md | 6 ++---- lib/dispatcher/dispatcher.js | 8 +++----- lib/interceptor/retry.js | 13 +++++++------ test/dispatcher.js | 20 ++++++++++++++++++++ 4 files changed, 32 insertions(+), 15 deletions(-) diff --git a/docs/docs/api/Dispatcher.md b/docs/docs/api/Dispatcher.md index 7a7f537c701..216dc80dd53 100644 --- a/docs/docs/api/Dispatcher.md +++ b/docs/docs/api/Dispatcher.md @@ -843,7 +843,7 @@ const { Client, RedirectHandler } = require('undici') const redirectInterceptor = dispatch => { return (opts, handler) => { - const { maxRedirections = globalMaxRedirections } = opts + const { maxRedirections } = opts if (!maxRedirections) { return dispatch(opts, handler) @@ -873,7 +873,7 @@ const { Client, RedirectHandler, RetryHandler } = require('undici') const redirectInterceptor = dispatch => { return (opts, handler) => { - const { maxRedirections = globalMaxRedirections } = opts + const { maxRedirections } = opts if (!maxRedirections) { return dispatch(opts, handler) @@ -892,8 +892,6 @@ const redirectInterceptor = dispatch => { const retryInterceptor = dispatch => { return function retryInterceptor (opts, handler) { - opts.retryOptions = { ...globalOpts, ...opts.retryOptions } - return dispatch( opts, new RetryHandler(opts, { diff --git a/lib/dispatcher/dispatcher.js b/lib/dispatcher/dispatcher.js index 2af0c2ed793..3f8e09361c9 100644 --- a/lib/dispatcher/dispatcher.js +++ b/lib/dispatcher/dispatcher.js @@ -25,19 +25,17 @@ class Dispatcher extends EventEmitter { } if (typeof interceptor !== 'function') { - throw new Error('invalid interceptor') + throw new TypeError(`invalid interceptor, expected function received ${typeof interceptor}`) } dispatch = interceptor(dispatch) if (dispatch == null || typeof dispatch !== 'function' || dispatch.length !== 2) { - throw new Error('invalid interceptor') + throw new TypeError('invalid interceptor') } } - this.dispatch = dispatch - - return this + return Object.setPrototypeOf({ dispatch }, this) } } diff --git a/lib/interceptor/retry.js b/lib/interceptor/retry.js index f89b56d3665..1c16fd845a9 100644 --- a/lib/interceptor/retry.js +++ b/lib/interceptor/retry.js @@ -4,14 +4,15 @@ const RetryHandler = require('../handler/retry-handler') module.exports = globalOpts => { return dispatch => { return function retryInterceptor (opts, handler) { - opts.retryOptions = { ...globalOpts, ...opts.retryOptions } - return dispatch( opts, - new RetryHandler(opts, { - handler, - dispatch - }) + new RetryHandler( + { ...opts, retryOptions: { ...globalOpts, ...opts.retryOptions } }, + { + handler, + dispatch + } + ) ) } } diff --git a/test/dispatcher.js b/test/dispatcher.js index 95a9bc59a83..1febdf53b4b 100644 --- a/test/dispatcher.js +++ b/test/dispatcher.js @@ -20,3 +20,23 @@ test('dispatcher implementation', (t) => { t.throws(() => poorImplementation.close(), Error, 'throws on unimplemented close') t.throws(() => poorImplementation.destroy(), Error, 'throws on unimplemented destroy') }) + +test('dispatcher.compose', (t) => { + t = tspl(t, { plan: 10 }) + + const dispatcher = new Dispatcher() + const interceptor = () => (opts, handler) => {} + // Should return a new dispatcher + t.ok(Object.getPrototypeOf(dispatcher.compose(interceptor)) instanceof Dispatcher) + t.ok(Object.getPrototypeOf(dispatcher.compose(interceptor, interceptor)) instanceof Dispatcher) + t.ok(Object.getPrototypeOf(dispatcher.compose([interceptor, interceptor])) instanceof Dispatcher) + t.ok(dispatcher.compose(interceptor) !== dispatcher) + t.throws(() => dispatcher.dispatch({}), Error, 'invalid interceptor') + t.throws(() => dispatcher.dispatch(() => null), Error, 'invalid interceptor') + t.throws(() => dispatcher.dispatch(dispatch => dispatch, () => () => {}, Error, 'invalid interceptor')) + + const composed = dispatcher.compose(interceptor) + t.equal(typeof composed.dispatch, 'function', 'returns an object with a dispatch method') + t.equal(typeof composed.close, 'function', 'returns an object with a close method') + t.equal(typeof composed.destroy, 'function', 'returns an object with a destroy method') +}) From d66530f013434ca3b36aa27e81092975ec681700 Mon Sep 17 00:00:00 2001 From: Carlos Fuentes Date: Fri, 8 Mar 2024 10:35:51 +0100 Subject: [PATCH 15/19] feat: composed dispatcher --- lib/dispatcher/dispatcher.js | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/lib/dispatcher/dispatcher.js b/lib/dispatcher/dispatcher.js index 3f8e09361c9..b1e0098ec4b 100644 --- a/lib/dispatcher/dispatcher.js +++ b/lib/dispatcher/dispatcher.js @@ -35,7 +35,30 @@ class Dispatcher extends EventEmitter { } } - return Object.setPrototypeOf({ dispatch }, this) + return new ComposedDispatcher(this, dispatch) + } +} + +class ComposedDispatcher extends Dispatcher { + #dispatcher = null + #dispatch = null + + constructor (dispatcher, dispatch) { + super() + this.#dispatcher = dispatcher + this.#dispatch = dispatch + } + + dispatch (...args) { + this.#dispatch(...args) + } + + close (...args) { + return this.#dispatcher.close(...args) + } + + destroy (...args) { + return this.#dispatcher.destroy(...args) } } From b932ad20048fdcce3c103e586c2d92fcee5e2e78 Mon Sep 17 00:00:00 2001 From: Carlos Fuentes Date: Fri, 8 Mar 2024 10:47:08 +0100 Subject: [PATCH 16/19] docs: adjust documentation --- docs/docs/api/Client.md | 3 ++- docs/docs/api/Dispatcher.md | 6 +++--- lib/dispatcher/client.js | 16 +++++++++++++--- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/docs/docs/api/Client.md b/docs/docs/api/Client.md index b9e26f09752..6e21751ecab 100644 --- a/docs/docs/api/Client.md +++ b/docs/docs/api/Client.md @@ -29,7 +29,8 @@ Returns: `Client` * **pipelining** `number | null` (optional) - Default: `1` - The amount of concurrent requests to be sent over the single TCP/TLS connection according to [RFC7230](https://tools.ietf.org/html/rfc7230#section-6.3.2). Carefully consider your workload and environment before enabling concurrent requests as pipelining may reduce performance if used incorrectly. Pipelining is sensitive to network stack settings as well as head of line blocking caused by e.g. long running requests. Set to `0` to disable keep-alive connections. * **connect** `ConnectOptions | Function | null` (optional) - Default: `null`. * **strictContentLength** `Boolean` (optional) - Default: `true` - Whether to treat request content length mismatches as errors. If true, an error is thrown when the request content-length header doesn't match the length of the request body. -* **interceptors** `{ Client: DispatchInterceptor[] }` - Default: `[RedirectInterceptor]` - A list of interceptors that are applied to the dispatch method. Additional logic can be applied (such as, but not limited to: 302 status code handling, authentication, cookies, compression and caching). Note that the behavior of interceptors is Experimental and might change at any given time. + +* **interceptors** `{ Client: DispatchInterceptor[] }` - Default: `[RedirectInterceptor]` - A list of interceptors that are applied to the dispatch method. Additional logic can be applied (such as, but not limited to: 302 status code handling, authentication, cookies, compression and caching). Note that the behavior of interceptors is Experimental and might change at any given time. **Note: this is deprecated in favor of [Dispatcher#compose](./Dispatcher.md#dispatcher). Support will be droped in next major.** * **autoSelectFamily**: `boolean` (optional) - Default: depends on local Node version, on Node 18.13.0 and above is `false`. Enables a family autodetection algorithm that loosely implements section 5 of [RFC 8305](https://tools.ietf.org/html/rfc8305#section-5). See [here](https://nodejs.org/api/net.html#socketconnectoptions-connectlistener) for more details. This option is ignored if not supported by the current Node version. * **autoSelectFamilyAttemptTimeout**: `number` - Default: depends on local Node version, on Node 18.13.0 and above is `250`. The amount of time in milliseconds to wait for a connection attempt to finish before trying the next address when using the `autoSelectFamily` option. See [here](https://nodejs.org/api/net.html#socketconnectoptions-connectlistener) for more details. * **allowH2**: `boolean` - Default: `false`. Enables support for H2 if the server has assigned bigger priority to it through ALPN negotiation. diff --git a/docs/docs/api/Dispatcher.md b/docs/docs/api/Dispatcher.md index 216dc80dd53..24571e07dbf 100644 --- a/docs/docs/api/Dispatcher.md +++ b/docs/docs/api/Dispatcher.md @@ -822,9 +822,9 @@ try { Compose a new dispatcher from the current dispatcher and the given interceptors. > _Notes_: -> - The order of the interceptors is important. The first interceptor will be the first to be called. -> - It is important to note that the `interceptor` function should return a `Dispatcher` instance. -> - Any fork of the chain of `interceptors` can lead to unexpected results, it is important that an interceptor returns a `Dispatcher` instance that forwards the request to the next interceptor in the chain. +> - The order of the interceptors matters. The first interceptor will be the first to be called. +> - It is important to note that the `interceptor` function should return a function that follows the `Dispatcher.dispatch` signature. +> - Any fork of the chain of `interceptors` can lead to unexpected results. Arguments: diff --git a/lib/dispatcher/client.js b/lib/dispatcher/client.js index d90ed6ad914..60e68135549 100644 --- a/lib/dispatcher/client.js +++ b/lib/dispatcher/client.js @@ -59,6 +59,7 @@ const { } = require('../core/symbols.js') const connectH1 = require('./client-h1.js') const connectH2 = require('./client-h2.js') +let deprecatedInterceptorWarned = false const kClosedResolve = Symbol('kClosedResolve') @@ -207,9 +208,18 @@ class Client extends DispatcherBase { }) } - this[kInterceptors] = interceptors?.Client && Array.isArray(interceptors.Client) - ? interceptors.Client - : [createRedirectInterceptor({ maxRedirections })] + if (interceptors?.Client && Array.isArray(interceptors.Client)) { + this[kInterceptors] = interceptors.Client + if (!deprecatedInterceptorWarned) { + deprecatedInterceptorWarned = true + process.emitWarning('Client.Options#interceptor is deprecated. Use Dispatcher#compose instead.', { + code: 'UNDICI-CLIENT-INTERCEPTOR-DEPRECATED' + }) + } + } else { + this[kInterceptors] = [createRedirectInterceptor({ maxRedirections })] + } + this[kUrl] = util.parseOrigin(url) this[kConnector] = connect this[kPipelining] = pipelining != null ? pipelining : 1 From 20d3a3355b7a59a4b06c219d979b136145176b11 Mon Sep 17 00:00:00 2001 From: Carlos Fuentes Date: Fri, 8 Mar 2024 11:00:02 +0100 Subject: [PATCH 17/19] refactor: apply review --- lib/interceptor/redirect.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/interceptor/redirect.js b/lib/interceptor/redirect.js index 63663eef041..d2e789d8efb 100644 --- a/lib/interceptor/redirect.js +++ b/lib/interceptor/redirect.js @@ -5,7 +5,7 @@ module.exports = opts => { const globalMaxRedirections = opts?.maxRedirections return dispatch => { return function redirectInterceptor (opts, handler) { - const { maxRedirections = globalMaxRedirections } = opts + const { maxRedirections = globalMaxRedirections, ...baseOpts } = opts if (!maxRedirections) { return dispatch(opts, handler) @@ -17,8 +17,8 @@ module.exports = opts => { opts, handler ) - opts = { ...opts, maxRedirections: 0 } // Stop sub dispatcher from also redirecting. - return dispatch(opts, redirectHandler) + + return dispatch(baseOpts, redirectHandler) } } } From 2f5982e86e779d90fccc146bb6a7ba4670d53d82 Mon Sep 17 00:00:00 2001 From: Carlos Fuentes Date: Fri, 8 Mar 2024 11:52:50 +0100 Subject: [PATCH 18/19] docs: tweaks --- docs/docs/api/Dispatcher.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/docs/api/Dispatcher.md b/docs/docs/api/Dispatcher.md index 24571e07dbf..3a3948c54ab 100644 --- a/docs/docs/api/Dispatcher.md +++ b/docs/docs/api/Dispatcher.md @@ -828,13 +828,13 @@ Compose a new dispatcher from the current dispatcher and the given interceptors. Arguments: -* **interceptors** `Interceptor[interceptor[]]`: It is an array of `Interceptor` functions passed as only argument, or several interceptors passed as separate arguments. It can also support sevearl interceptors passed as positional arguments +* **interceptors** `Interceptor[interceptor[]]`: It is an array of `Interceptor` functions passed as only argument, or several interceptors passed as separate arguments. Returns: `Dispatcher`. #### Parameter: `Interceptor` -A function that takes a `dispatch` instance and returns a `dispatch`-like function. +A function that takes a `dispatch` method and returns a `dispatch`-like function. #### Example 1 - Basic Compose @@ -885,7 +885,7 @@ const redirectInterceptor = dispatch => { opts, handler ) - opts = { ...opts, maxRedirections: 0 } // Stop sub dispatcher from also redirecting. + opts = { ...opts, maxRedirections: 0 } return dispatch(opts, redirectHandler) } } From 620f9bc005565925064ccf4e0973622b2f441f6b Mon Sep 17 00:00:00 2001 From: Carlos Fuentes Date: Wed, 13 Mar 2024 09:57:09 +0100 Subject: [PATCH 19/19] feat: drop proxy --- docs/docs/api/Dispatcher.md | 19 - index.js | 1 - lib/interceptor/proxy.js | 11 - test/interceptors/proxy.js | 965 ------------------------------------ 4 files changed, 996 deletions(-) delete mode 100644 lib/interceptor/proxy.js delete mode 100644 test/interceptors/proxy.js diff --git a/docs/docs/api/Dispatcher.md b/docs/docs/api/Dispatcher.md index 3a3948c54ab..88c3f11e7ce 100644 --- a/docs/docs/api/Dispatcher.md +++ b/docs/docs/api/Dispatcher.md @@ -911,25 +911,6 @@ await client.request({ path: '/', method: 'GET' }) #### Pre-built interceptors -##### `proxy` - -The `proxy` interceptor allows you to connect to a proxy server before connecting to the origin server. - -It accepts the same arguments as the [`ProxyAgent` constructor](./ProxyAgent.md). - -**Example - Basic Proxy Interceptor** - -```js -const { Client, interceptors } = require("undici"); -const { proxy } = interceptors; - -const client = new Client("http://example.com").compose( - proxy("http://proxy.com") -); - -client.request({ path: "/" }); -``` - ##### `redirect` The `redirect` interceptor allows you to customize the way your dispatcher handles redirects. diff --git a/index.js b/index.js index 9ffeb572b09..dcf0be90798 100644 --- a/index.js +++ b/index.js @@ -37,7 +37,6 @@ module.exports.DecoratorHandler = DecoratorHandler module.exports.RedirectHandler = RedirectHandler module.exports.createRedirectInterceptor = createRedirectInterceptor module.exports.interceptors = { - proxy: require('./lib/interceptor/proxy'), redirect: require('./lib/interceptor/redirect'), retry: require('./lib/interceptor/retry') } diff --git a/lib/interceptor/proxy.js b/lib/interceptor/proxy.js deleted file mode 100644 index faa416a29d1..00000000000 --- a/lib/interceptor/proxy.js +++ /dev/null @@ -1,11 +0,0 @@ -'use strict' -const ProxyAgent = require('../dispatcher/proxy-agent') - -module.exports = opts => { - const agent = new ProxyAgent(opts) - return dispatch => { - dispatch = agent.dispatch.bind(agent) - - return dispatch - } -} diff --git a/test/interceptors/proxy.js b/test/interceptors/proxy.js deleted file mode 100644 index 094214328e3..00000000000 --- a/test/interceptors/proxy.js +++ /dev/null @@ -1,965 +0,0 @@ -'use strict' - -const { test, after } = require('node:test') -const { readFileSync } = require('node:fs') -const { resolve } = require('node:path') -const { createServer } = require('node:http') -const https = require('node:https') - -const { tspl } = require('@matteo.collina/tspl') -const { createProxy } = require('proxy') - -const { - Client, - interceptors, - getGlobalDispatcher, - setGlobalDispatcher, - request, - Pool -} = require('../..') -const { InvalidArgumentError } = require('../../lib/core/errors') -const { proxy: proxyInterceptor } = interceptors - -test('should throw error when no uri is provided', t => { - t = tspl(t, { plan: 2 }) - t.throws(() => proxyInterceptor(), InvalidArgumentError) - t.throws(() => proxyInterceptor({}), InvalidArgumentError) -}) - -test('using auth in combination with token should throw', t => { - t = tspl(t, { plan: 1 }) - t.throws( - () => - proxyInterceptor({ - auth: 'foo', - token: 'Bearer bar', - uri: 'http://example.com' - }), - InvalidArgumentError - ) -}) - -test('should accept string, URL and object as options', t => { - t = tspl(t, { plan: 3 }) - t.doesNotThrow(() => proxyInterceptor('http://example.com')) - t.doesNotThrow(() => proxyInterceptor(new URL('http://example.com'))) - t.doesNotThrow(() => proxyInterceptor({ uri: 'http://example.com' })) -}) - -test('should work with nested dispatch', async t => { - t = tspl(t, { plan: 7 }) - let counter = 0 - const customDispatch = dispatch => { - return (opts, handler) => { - counter++ - - return dispatch(opts, handler) - } - } - const server = await buildServer() - const proxy = await buildProxy() - delete proxy.authenticate - - const serverUrl = `http://localhost:${server.address().port}` - const proxyUrl = `http://localhost:${proxy.address().port}` - const client = new Client(serverUrl) - const parsedOrigin = new URL(serverUrl) - const dispatcher = client.compose([ - customDispatch, // not called - proxyInterceptor(proxyUrl), // chain restarted here - customDispatch - ]) - - proxy.on('connect', () => { - t.ok(true, 'should connect to proxy') - }) - - server.on('request', (req, res) => { - t.strictEqual(req.url, '/') - t.strictEqual( - req.headers.host, - parsedOrigin.host, - 'should not use proxyUrl as host' - ) - res.setHeader('content-type', 'application/json') - res.end(JSON.stringify({ hello: 'world' })) - }) - - const { statusCode, headers, body } = await dispatcher.request({ - path: '/', - method: 'GET', - origin: serverUrl - }) - const json = await body.json() - - t.strictEqual(statusCode, 200) - t.deepStrictEqual(json, { hello: 'world' }) - t.strictEqual( - headers.connection, - 'keep-alive', - 'should remain the connection open' - ) - t.equal(counter, 1, 'should call customDispatch twice') - - server.close() - proxy.close() - await dispatcher.close() -}) - -test('use proxy-agent to connect through proxy', async t => { - t = tspl(t, { plan: 6 }) - const server = await buildServer() - const proxy = await buildProxy() - delete proxy.authenticate - - const serverUrl = `http://localhost:${server.address().port}` - const proxyUrl = `http://localhost:${proxy.address().port}` - const client = new Client(serverUrl) - const parsedOrigin = new URL(serverUrl) - - const dispatcher = client.compose(proxyInterceptor(proxyUrl)) - - proxy.on('connect', () => { - t.ok(true, 'should connect to proxy') - }) - - server.on('request', (req, res) => { - t.strictEqual(req.url, '/') - t.strictEqual( - req.headers.host, - parsedOrigin.host, - 'should not use proxyUrl as host' - ) - res.setHeader('content-type', 'application/json') - res.end(JSON.stringify({ hello: 'world' })) - }) - - const { statusCode, headers, body } = await dispatcher.request({ - path: '/', - method: 'GET', - origin: serverUrl - }) - const json = await body.json() - - t.strictEqual(statusCode, 200) - t.deepStrictEqual(json, { hello: 'world' }) - t.strictEqual( - headers.connection, - 'keep-alive', - 'should remain the connection open' - ) - - server.close() - proxy.close() - await dispatcher.close() -}) - -test('use proxy agent to connect through proxy using Pool', async t => { - t = tspl(t, { plan: 3 }) - const server = await buildServer() - const proxy = await buildProxy() - let resolveFirstConnect - let connectCount = 0 - - proxy.authenticate = async function (req) { - if (++connectCount === 2) { - t.ok(true, 'second connect should arrive while first is still inflight') - resolveFirstConnect() - } else { - await new Promise(resolve => { - resolveFirstConnect = resolve - }) - } - - return true - } - - server.on('request', (req, res) => { - res.end() - }) - - const serverUrl = `http://localhost:${server.address().port}` - const proxyUrl = `http://localhost:${proxy.address().port}` - const clientFactory = (url, options) => { - return new Pool(url, options) - } - const client = new Client(serverUrl) - const dispatcher = client.compose( - proxyInterceptor({ - auth: Buffer.from('user:pass').toString('base64'), - uri: proxyUrl, - clientFactory - }) - ) - const firstRequest = dispatcher.request({ - path: '/', - method: 'GET', - origin: serverUrl - }) - const secondRequest = await dispatcher.request({ - path: '/', - method: 'GET', - origin: serverUrl - }) - t.strictEqual((await firstRequest).statusCode, 200) - t.strictEqual(secondRequest.statusCode, 200) - server.close() - proxy.close() - await dispatcher.close() -}) - -test('use proxy-agent to connect through proxy using path with params', async t => { - t = tspl(t, { plan: 6 }) - const server = await buildServer() - const proxy = await buildProxy() - delete proxy.authenticate - - const serverUrl = `http://localhost:${server.address().port}` - const proxyUrl = `http://localhost:${proxy.address().port}` - const client = new Client(serverUrl) - const parsedOrigin = new URL(serverUrl) - const dispatcher = client.compose(proxyInterceptor(proxyUrl)) - - proxy.on('connect', () => { - t.ok(true, 'should call proxy') - }) - server.on('request', (req, res) => { - t.strictEqual(req.url, '/hello?foo=bar') - t.strictEqual( - req.headers.host, - parsedOrigin.host, - 'should not use proxyUrl as host' - ) - res.setHeader('content-type', 'application/json') - res.end(JSON.stringify({ hello: 'world' })) - }) - - const { statusCode, headers, body } = await dispatcher.request({ - origin: serverUrl, - method: 'GET', - path: '/hello?foo=bar' - }) - const json = await body.json() - - t.strictEqual(statusCode, 200) - t.deepStrictEqual(json, { hello: 'world' }) - t.strictEqual( - headers.connection, - 'keep-alive', - 'should remain the connection open' - ) - - server.close() - proxy.close() - await dispatcher.close() -}) - -test('use proxy-agent to connect through proxy with basic auth in URL', async t => { - t = tspl(t, { plan: 7 }) - const server = await buildServer() - const proxy = await buildProxy() - - const serverUrl = `http://localhost:${server.address().port}` - const proxyUrl = `http://user:pass@localhost:${proxy.address().port}` - const client = new Client(serverUrl) - const parsedOrigin = new URL(serverUrl) - const dispatcher = client.compose(proxyInterceptor(proxyUrl)) - - proxy.authenticate = function (req, fn) { - t.ok(true, 'authentication should be called') - return ( - req.headers['proxy-authorization'] === - `Basic ${Buffer.from('user:pass').toString('base64')}` - ) - } - proxy.on('connect', () => { - t.ok(true, 'proxy should be called') - }) - - server.on('request', (req, res) => { - t.strictEqual(req.url, '/hello?foo=bar') - t.strictEqual( - req.headers.host, - parsedOrigin.host, - 'should not use proxyUrl as host' - ) - res.setHeader('content-type', 'application/json') - res.end(JSON.stringify({ hello: 'world' })) - }) - - const { statusCode, headers, body } = await dispatcher.request({ - origin: serverUrl, - method: 'GET', - path: '/hello?foo=bar' - }) - const json = await body.json() - - t.strictEqual(statusCode, 200) - t.deepStrictEqual(json, { hello: 'world' }) - t.strictEqual( - headers.connection, - 'keep-alive', - 'should remain the connection open' - ) - - server.close() - proxy.close() - await dispatcher.close() -}) - -test('use proxy-agent with auth', async t => { - t = tspl(t, { plan: 7 }) - const server = await buildServer() - const proxy = await buildProxy() - - const serverUrl = `http://localhost:${server.address().port}` - const proxyUrl = `http://user:pass@localhost:${proxy.address().port}` - const client = new Client(serverUrl) - const parsedOrigin = new URL(serverUrl) - const dispatcher = client.compose( - proxyInterceptor({ - auth: Buffer.from('user:pass').toString('base64'), - uri: proxyUrl - }) - ) - - proxy.authenticate = function (req, fn) { - t.ok(true, 'authentication should be called') - return ( - req.headers['proxy-authorization'] === - `Basic ${Buffer.from('user:pass').toString('base64')}` - ) - } - proxy.on('connect', () => { - t.ok(true, 'proxy should be called') - }) - - server.on('request', (req, res) => { - t.strictEqual(req.url, '/hello?foo=bar') - t.strictEqual( - req.headers.host, - parsedOrigin.host, - 'should not use proxyUrl as host' - ) - res.setHeader('content-type', 'application/json') - res.end(JSON.stringify({ hello: 'world' })) - }) - - const { statusCode, headers, body } = await dispatcher.request({ - origin: serverUrl, - method: 'GET', - path: '/hello?foo=bar' - }) - const json = await body.json() - - t.strictEqual(statusCode, 200) - t.deepStrictEqual(json, { hello: 'world' }) - t.strictEqual( - headers.connection, - 'keep-alive', - 'should remain the connection open' - ) - - server.close() - proxy.close() - await dispatcher.close() -}) - -test('use proxy-agent with token', async t => { - t = tspl(t, { plan: 7 }) - const server = await buildServer() - const proxy = await buildProxy() - - const serverUrl = `http://localhost:${server.address().port}` - const proxyUrl = `http://user:pass@localhost:${proxy.address().port}` - const client = new Client(serverUrl) - const parsedOrigin = new URL(serverUrl) - const dispatcher = client.compose( - proxyInterceptor({ - token: `Bearer ${Buffer.from('user:pass').toString('base64')}`, - uri: proxyUrl - }) - ) - - proxy.authenticate = function (req, fn) { - t.ok(true, 'authentication should be called') - return ( - req.headers['proxy-authorization'] === - `Bearer ${Buffer.from('user:pass').toString('base64')}` - ) - } - proxy.on('connect', () => { - t.ok(true, 'proxy should be called') - }) - - server.on('request', (req, res) => { - t.strictEqual(req.url, '/hello?foo=bar') - t.strictEqual( - req.headers.host, - parsedOrigin.host, - 'should not use proxyUrl as host' - ) - res.setHeader('content-type', 'application/json') - res.end(JSON.stringify({ hello: 'world' })) - }) - - const { statusCode, headers, body } = await dispatcher.request({ - origin: serverUrl, - method: 'GET', - path: '/hello?foo=bar' - }) - const json = await body.json() - - t.strictEqual(statusCode, 200) - t.deepStrictEqual(json, { hello: 'world' }) - t.strictEqual( - headers.connection, - 'keep-alive', - 'should remain the connection open' - ) - - server.close() - proxy.close() - await dispatcher.close() -}) - -test('use proxy-agent with custom headers', async t => { - t = tspl(t, { plan: 2 }) - const server = await buildServer() - const proxy = await buildProxy() - - const serverUrl = `http://localhost:${server.address().port}` - const proxyUrl = `http://user:pass@localhost:${proxy.address().port}` - const client = new Client(serverUrl) - const dispatcher = client.compose( - proxyInterceptor({ - uri: proxyUrl, - headers: { - 'User-Agent': 'Foobar/1.0.0' - } - }) - ) - - proxy.on('connect', req => { - t.strictEqual(req.headers['user-agent'], 'Foobar/1.0.0') - }) - - server.on('request', (req, res) => { - t.strictEqual(req.headers['user-agent'], 'BarBaz/1.0.0') - res.end() - }) - - await dispatcher.request({ - origin: serverUrl, - method: 'GET', - path: '/hello?foo=bar', - headers: { 'user-agent': 'BarBaz/1.0.0' } - }) - - server.close() - proxy.close() - await dispatcher.close() -}) - -test('sending proxy-authorization in request headers should throw', async t => { - t = tspl(t, { plan: 3 }) - const server = await buildServer() - const proxy = await buildProxy() - - const serverUrl = `http://localhost:${server.address().port}` - const proxyUrl = `http://localhost:${proxy.address().port}` - const client = new Client(serverUrl) - const dispatcher = client.compose( - proxyInterceptor({ - uri: proxyUrl - }) - ) - - server.on('request', (req, res) => { - res.end(JSON.stringify({ hello: 'world' })) - }) - - await t.rejects( - dispatcher.request({ - origin: serverUrl, - method: 'GET', - path: '/hello?foo=bar', - headers: { - 'proxy-authorization': Buffer.from('user:pass').toString('base64') - } - }), - 'Proxy-Authorization should be sent in ProxyAgent' - ) - - await t.rejects( - dispatcher.request({ - origin: serverUrl, - method: 'GET', - path: '/hello?foo=bar', - headers: { - 'PROXY-AUTHORIZATION': Buffer.from('user:pass').toString('base64') - } - }), - 'Proxy-Authorization should be sent in ProxyAgent' - ) - - await t.rejects( - dispatcher.request({ - origin: serverUrl, - method: 'GET', - path: '/hello?foo=bar', - headers: { - 'Proxy-Authorization': Buffer.from('user:pass').toString('base64') - } - }), - 'Proxy-Authorization should be sent in ProxyAgent' - ) - - server.close() - proxy.close() - await dispatcher.close() -}) - -test('use proxy-agent with setGlobalDispatcher', async t => { - t = tspl(t, { plan: 6 }) - - const server = await buildServer() - const proxy = await buildProxy() - delete proxy.authenticate - - const serverUrl = `http://localhost:${server.address().port}` - const proxyUrl = `http://localhost:${proxy.address().port}` - const parsedOrigin = new URL(serverUrl) - const defaultDispatcher = getGlobalDispatcher() - - setGlobalDispatcher(defaultDispatcher.compose(proxyInterceptor(proxyUrl))) - after(() => setGlobalDispatcher(defaultDispatcher)) - - proxy.on('connect', () => { - t.ok(true, 'should connect to proxy') - }) - - server.on('request', (req, res) => { - t.strictEqual(req.url, '/') - t.strictEqual( - req.headers.host, - parsedOrigin.host, - 'should not use proxyUrl as host' - ) - res.setHeader('content-type', 'application/json') - res.end(JSON.stringify({ hello: 'world' })) - }) - - const { statusCode, headers, body } = await request({ - path: '/', - method: 'GET', - origin: serverUrl - }) - const json = await body.json() - - t.strictEqual(statusCode, 200) - t.deepStrictEqual(json, { hello: 'world' }) - t.strictEqual( - headers.connection, - 'keep-alive', - 'should remain the connection open' - ) - - server.close() - proxy.close() -}) - -test('ProxyAgent correctly sends headers when using fetch - #1355, #1623', async t => { - t = tspl(t, { plan: 2 }) - const defaultDispatcher = getGlobalDispatcher() - - const server = await buildServer() - const proxy = await buildProxy() - - const serverUrl = `http://localhost:${server.address().port}` - const proxyUrl = `http://localhost:${proxy.address().port}` - setGlobalDispatcher(defaultDispatcher.compose(proxyInterceptor(proxyUrl))) - - after(() => setGlobalDispatcher(defaultDispatcher)) - - const expectedHeaders = { - host: `localhost:${server.address().port}`, - connection: 'keep-alive', - 'test-header': 'value', - accept: '*/*', - 'accept-language': '*', - 'sec-fetch-mode': 'cors', - 'user-agent': 'node', - 'accept-encoding': 'gzip, deflate' - } - - const expectedProxyHeaders = { - host: `localhost:${server.address().port}`, - connection: 'close' - } - - proxy.on('connect', (req, res) => { - t.deepStrictEqual(req.headers, expectedProxyHeaders) - }) - - server.on('request', (req, res) => { - t.deepStrictEqual(req.headers, expectedHeaders) - res.end('goodbye') - }) - - await fetch(serverUrl, { - headers: { 'Test-header': 'value' } - }) - - server.close() - proxy.close() - t.end() -}) - -test('should throw when proxy does not return 200', async t => { - t = tspl(t, { plan: 1 }) - - const server = await buildServer() - const proxy = await buildProxy() - - const serverUrl = `http://localhost:${server.address().port}` - const proxyUrl = `http://localhost:${proxy.address().port}` - - proxy.authenticate = function (req, fn) { - return false - } - - const client = new Client(serverUrl).compose(proxyInterceptor(proxyUrl)) - try { - await client.request({ path: '/', method: 'GET' }) - t.fail() - } catch (e) { - t.ok(e) - } - - server.close() - proxy.close() - await t.completed -}) - -test('pass ProxyAgent proxy status code error when using fetch - #2161', async t => { - t = tspl(t, { plan: 1 }) - - const server = await buildServer() - const proxy = await buildProxy() - - const serverUrl = `http://localhost:${server.address().port}` - const proxyUrl = `http://localhost:${proxy.address().port}` - - proxy.authenticate = function (req, fn) { - return false - } - - const client = new Client(serverUrl).compose(proxyInterceptor(proxyUrl)) - try { - await fetch(serverUrl, { dispatcher: client }) - t.fail() - } catch (e) { - t.ok('cause' in e) - } - - server.close() - proxy.close() - await t.completed -}) - -test('Proxy via HTTP to HTTPS endpoint', async t => { - t = tspl(t, { plan: 4 }) - - const server = await buildSSLServer() - const proxy = await buildProxy() - - const serverUrl = `https://localhost:${server.address().port}` - const proxyUrl = `http://localhost:${proxy.address().port}` - const client = new Client(serverUrl).compose( - proxyInterceptor({ - uri: proxyUrl, - requestTls: { - ca: [ - readFileSync(resolve(__dirname, '../', 'fixtures', 'ca.pem'), 'utf8') - ], - key: readFileSync( - resolve(__dirname, '../', 'fixtures', 'client-key-2048.pem'), - 'utf8' - ), - cert: readFileSync( - resolve(__dirname, '../', 'fixtures', 'client-crt-2048.pem'), - 'utf8' - ), - servername: 'agent1' - } - }) - ) - - server.on('request', function (req, res) { - t.ok(req.connection.encrypted) - res.end(JSON.stringify(req.headers)) - }) - - server.on('secureConnection', () => { - t.ok(true, 'server should be connected secured') - }) - - proxy.on('secureConnection', () => { - t.fail('proxy over http should not call secureConnection') - }) - - proxy.on('connect', function () { - t.ok(true, 'proxy should be connected') - }) - - proxy.on('request', function () { - t.fail('proxy should never receive requests') - }) - - const data = await client.request({ - path: '/', - origin: serverUrl, - method: 'GET' - }) - const json = await data.body.json() - t.deepStrictEqual(json, { - host: `localhost:${server.address().port}`, - connection: 'keep-alive' - }) - - server.close() - proxy.close() - await client.close() -}) - -test('Proxy via HTTPS to HTTPS endpoint', async t => { - t = tspl(t, { plan: 5 }) - const server = await buildSSLServer() - const proxy = await buildSSLProxy() - - const serverUrl = `https://localhost:${server.address().port}` - const proxyUrl = `https://localhost:${proxy.address().port}` - const proxyAgent = new Client(serverUrl).compose( - proxyInterceptor({ - uri: proxyUrl, - proxyTls: { - ca: [ - readFileSync(resolve(__dirname, '../', 'fixtures', 'ca.pem'), 'utf8') - ], - key: readFileSync( - resolve(__dirname, '../', 'fixtures', 'client-key-2048.pem'), - 'utf8' - ), - cert: readFileSync( - resolve(__dirname, '../', 'fixtures', 'client-crt-2048.pem'), - 'utf8' - ), - servername: 'agent1', - rejectUnauthorized: false - }, - requestTls: { - ca: [ - readFileSync(resolve(__dirname, '../', 'fixtures', 'ca.pem'), 'utf8') - ], - key: readFileSync( - resolve(__dirname, '../', 'fixtures', 'client-key-2048.pem'), - 'utf8' - ), - cert: readFileSync( - resolve(__dirname, '../', 'fixtures', 'client-crt-2048.pem'), - 'utf8' - ), - servername: 'agent1' - } - }) - ) - - server.on('request', function (req, res) { - t.ok(req.connection.encrypted) - res.end(JSON.stringify(req.headers)) - }) - - server.on('secureConnection', () => { - t.ok(true, 'server should be connected secured') - }) - - proxy.on('secureConnection', () => { - t.ok(true, 'proxy over http should call secureConnection') - }) - - proxy.on('connect', function () { - t.ok(true, 'proxy should be connected') - }) - - proxy.on('request', function () { - t.fail('proxy should never receive requests') - }) - - const data = await request(serverUrl, { dispatcher: proxyAgent }) - const json = await data.body.json() - t.deepStrictEqual(json, { - host: `localhost:${server.address().port}`, - connection: 'keep-alive' - }) - - server.close() - proxy.close() - proxyAgent.close() -}) - -test('Proxy via HTTPS to HTTP endpoint', async t => { - t = tspl(t, { plan: 3 }) - const server = await buildServer() - const proxy = await buildSSLProxy() - - const serverUrl = `http://localhost:${server.address().port}` - const proxyUrl = `https://localhost:${proxy.address().port}` - const proxyAgent = new Client(serverUrl).compose( - proxyInterceptor({ - uri: proxyUrl, - proxyTls: { - ca: [ - readFileSync(resolve(__dirname, '../', 'fixtures', 'ca.pem'), 'utf8') - ], - key: readFileSync( - resolve(__dirname, '../', 'fixtures', 'client-key-2048.pem'), - 'utf8' - ), - cert: readFileSync( - resolve(__dirname, '../', 'fixtures', 'client-crt-2048.pem'), - 'utf8' - ), - servername: 'agent1', - rejectUnauthorized: false - } - }) - ) - - server.on('request', function (req, res) { - t.ok(!req.connection.encrypted) - res.end(JSON.stringify(req.headers)) - }) - - server.on('secureConnection', () => { - t.fail('server is http') - }) - - proxy.on('secureConnection', () => { - t.ok(true, 'proxy over http should call secureConnection') - }) - - proxy.on('request', function () { - t.fail('proxy should never receive requests') - }) - - const data = await request(serverUrl, { dispatcher: proxyAgent }) - const json = await data.body.json() - t.deepStrictEqual(json, { - host: `localhost:${server.address().port}`, - connection: 'keep-alive' - }) - - server.close() - proxy.close() - await proxyAgent.close() -}) - -test('Proxy via HTTP to HTTP endpoint', async t => { - t = tspl(t, { plan: 3 }) - const server = await buildServer() - const proxy = await buildProxy() - - const serverUrl = `http://localhost:${server.address().port}` - const proxyUrl = `http://localhost:${proxy.address().port}` - const proxyAgent = new Client(serverUrl).compose(proxyInterceptor(proxyUrl)) - - server.on('request', function (req, res) { - t.ok(!req.connection.encrypted) - res.end(JSON.stringify(req.headers)) - }) - - server.on('secureConnection', () => { - t.fail('server is http') - }) - - proxy.on('secureConnection', () => { - t.fail('proxy is http') - }) - - proxy.on('connect', () => { - t.ok(true, 'connect to proxy') - }) - - proxy.on('request', function () { - t.fail('proxy should never receive requests') - }) - - const data = await request(serverUrl, { dispatcher: proxyAgent }) - const json = await data.body.json() - t.deepStrictEqual(json, { - host: `localhost:${server.address().port}`, - connection: 'keep-alive' - }) - - server.close() - proxy.close() - await proxyAgent.close() -}) - -function buildServer () { - return new Promise(resolve => { - const server = createServer() - server.listen(0, () => resolve(server)) - }) -} - -function buildSSLServer () { - const serverOptions = { - ca: [ - readFileSync( - resolve(__dirname, '../', 'fixtures', 'client-ca-crt.pem'), - 'utf8' - ) - ], - key: readFileSync(resolve(__dirname, '../', 'fixtures', 'key.pem'), 'utf8'), - cert: readFileSync( - resolve(__dirname, '../', 'fixtures', 'cert.pem'), - 'utf8' - ) - } - return new Promise(resolve => { - const server = https.createServer(serverOptions) - server.listen(0, () => resolve(server)) - }) -} - -function buildProxy (listener) { - return new Promise(resolve => { - const server = listener - ? createProxy(createServer(listener)) - : createProxy(createServer()) - server.listen(0, () => resolve(server)) - }) -} - -function buildSSLProxy () { - const serverOptions = { - ca: [ - readFileSync( - resolve(__dirname, '../', 'fixtures', 'client-ca-crt.pem'), - 'utf8' - ) - ], - key: readFileSync(resolve(__dirname, '../', 'fixtures', 'key.pem'), 'utf8'), - cert: readFileSync( - resolve(__dirname, '../', 'fixtures', 'cert.pem'), - 'utf8' - ) - } - - return new Promise(resolve => { - const server = createProxy(https.createServer(serverOptions)) - server.listen(0, () => resolve(server)) - }) -}