Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Extensible DispatchHandler #1338

Merged
merged 17 commits into from
Sep 6, 2022
Merged
1 change: 1 addition & 0 deletions docs/api/Agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Extends: [`ClientOptions`](Pool.md#parameter-pooloptions)

* **factory** `(origin: URL, opts: Object) => Dispatcher` - Default: `(origin, opts) => new Pool(origin, opts)`
* **maxRedirections** `Integer` - Default: `0`. The number of HTTP redirection to follow unless otherwise specified in `DispatchOptions`.
* **interceptors** `{ Agent: 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).

## Instance Properties

Expand Down
1 change: 1 addition & 0 deletions docs/api/Client.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ 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).

#### Parameter: `ConnectOptions`

Expand Down
60 changes: 60 additions & 0 deletions docs/api/DispatchInterceptor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
#Interface: DispatchInterceptor

Extends: `Function`

A function that can be applied to the `Dispatcher.Dispatch` function before it is invoked with a dispatch request.

This allows one to write logic to intercept both the outgoing request, and the incoming response.

### Parameter: `Dispatcher.Dispatch`

The base dispatch function you are decorating.

### ReturnType: `Dispatcher.Dispatch`

A dispatch function that has been altered to provide additional logic

### Basic Example

Here is an example of an interceptor being used to provide a JWT bearer token

```js
'use strict'

const insertHeaderInterceptor = dispatch => {
return function InterceptedDispatch(opts, handler){
opts.headers.push('Authorization', 'Bearer [Some token]')
return dispatch(opts, handler)
}
}

const client = new Client('https://localhost:3000', {
interceptors: { Client: [insertHeaderInterceptor] }
})

```

### Basic Example 2

Here is a contrived example of an interceptor stripping the headers from a response.

```js
'use strict'

const clearHeadersInterceptor = dispatch => {
const { DecoratorHandler } = require('undici')
class ResultInterceptor extends DecoratorHandler {
onHeaders (statusCode, headers, resume) {
return super.onHeaders(statusCode, [], resume)
}
}
return function InterceptedDispatch(opts, handler){
return dispatch(opts, new ResultInterceptor(handler))
}
}

const client = new Client('https://localhost:3000', {
interceptors: { Client: [clearHeadersInterceptor] }
})

```
1 change: 1 addition & 0 deletions docs/api/Pool.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Extends: [`ClientOptions`](Client.md#parameter-clientoptions)

* **factory** `(origin: URL, opts: Object) => Dispatcher` - Default: `(origin, opts) => new Client(origin, opts)`
* **connections** `number | null` (optional) - Default: `null` - The number of `Client` instances to create. When set to `null`, the `Pool` instance will create an unlimited amount of `Client` instances.
* **interceptors** `{ Pool: DispatchInterceptor[] } }` - Default: `{ Pool: [] }` - 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).

## Instance Properties

Expand Down
7 changes: 6 additions & 1 deletion index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import Dispatcher = require('./types/dispatcher')
import { setGlobalDispatcher, getGlobalDispatcher } from './types/global-dispatcher'
import { setGlobalOrigin, getGlobalOrigin } from './types/global-origin'
import Pool = require('./types/pool')
import { RedirectHandler, DecoratorHandler } from './types/handlers'

import BalancedPool = require('./types/balanced-pool')
import Client = require('./types/client')
import buildConnector = require('./types/connector')
Expand All @@ -20,14 +22,17 @@ export * from './types/formdata'
export * from './types/diagnostics-channel'
export { Interceptable } from './types/mock-interceptor'

export { Dispatcher, BalancedPool, Pool, Client, buildConnector, errors, Agent, request, stream, pipeline, connect, upgrade, setGlobalDispatcher, getGlobalDispatcher, setGlobalOrigin, getGlobalOrigin, MockClient, MockPool, MockAgent, mockErrors, ProxyAgent }
export { Dispatcher, BalancedPool, Pool, Client, buildConnector, errors, Agent, request, stream, pipeline, connect, upgrade, setGlobalDispatcher, getGlobalDispatcher, setGlobalOrigin, getGlobalOrigin, MockClient, MockPool, MockAgent, mockErrors, ProxyAgent, RedirectHandler, DecoratorHandler }
export default Undici

declare function Undici(url: string, opts: Pool.Options): Pool

declare namespace Undici {
var Dispatcher: typeof import('./types/dispatcher')
var Pool: typeof import('./types/pool');
var RedirectHandler: typeof import ('./types/handlers').RedirectHandler
var DecoratorHandler: typeof import ('./types/handlers').DecoratorHandler
var createRedirectInterceptor: typeof import ('./types/interceptors').createRedirectInterceptor
var BalancedPool: typeof import('./types/balanced-pool');
var Client: typeof import('./types/client');
var buildConnector: typeof import('./types/connector');
Expand Down
7 changes: 7 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ const MockPool = require('./lib/mock/mock-pool')
const mockErrors = require('./lib/mock/mock-errors')
const ProxyAgent = require('./lib/proxy-agent')
const { getGlobalDispatcher, setGlobalDispatcher } = require('./lib/global')
const DecoratorHandler = require('./lib/handler/DecoratorHandler')
const RedirectHandler = require('./lib/handler/RedirectHandler')
const createRedirectInterceptor = require('./lib/interceptor/redirectInterceptor')

const nodeVersion = process.versions.node.split('.')
const nodeMajor = Number(nodeVersion[0])
Expand All @@ -30,6 +33,10 @@ module.exports.BalancedPool = BalancedPool
module.exports.Agent = Agent
module.exports.ProxyAgent = ProxyAgent

module.exports.DecoratorHandler = DecoratorHandler
module.exports.RedirectHandler = RedirectHandler
module.exports.createRedirectInterceptor = createRedirectInterceptor

module.exports.buildConnector = buildConnector
module.exports.errors = errors

Expand Down
17 changes: 9 additions & 8 deletions lib/agent.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
'use strict'

const { InvalidArgumentError } = require('./core/errors')
const { kClients, kRunning, kClose, kDestroy, kDispatch } = require('./core/symbols')
const { kClients, kRunning, kClose, kDestroy, kDispatch, kInterceptors } = require('./core/symbols')
const DispatcherBase = require('./dispatcher-base')
const Pool = require('./pool')
const Client = require('./client')
const util = require('./core/util')
const RedirectHandler = require('./handler/redirect')
const createRedirectInterceptor = require('./interceptor/redirectInterceptor')
const { WeakRef, FinalizationRegistry } = require('./compat/dispatcher-weakref')()

const kOnConnect = Symbol('onConnect')
Expand Down Expand Up @@ -44,7 +44,14 @@ class Agent extends DispatcherBase {
connect = { ...connect }
}

this[kInterceptors] = options.interceptors && options.interceptors.Agent && Array.isArray(options.interceptors.Agent)
? options.interceptors.Agent
: [createRedirectInterceptor({ maxRedirections })]

this[kOptions] = { ...util.deepClone(options), connect }
this[kOptions].interceptors = options.interceptors
? { ...options.interceptors }
: undefined
this[kMaxRedirections] = maxRedirections
this[kFactory] = factory
this[kClients] = new Map()
Expand Down Expand Up @@ -108,12 +115,6 @@ class Agent extends DispatcherBase {
this[kFinalizer].register(dispatcher, key)
}

const { maxRedirections = this[kMaxRedirections] } = opts
if (maxRedirections != null && maxRedirections !== 0) {
opts = { ...opts, maxRedirections: 0 } // Stop sub dispatcher from also redirecting.
handler = new RedirectHandler(this, maxRedirections, opts, handler)
}

return dispatcher.dispatch(opts, handler)
}

Expand Down
5 changes: 4 additions & 1 deletion lib/balanced-pool.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const {
kGetDispatcher
} = require('./pool-base')
const Pool = require('./pool')
const { kUrl } = require('./core/symbols')
const { kUrl, kInterceptors } = require('./core/symbols')
const { parseOrigin } = require('./core/util')
const kFactory = Symbol('factory')

Expand Down Expand Up @@ -53,6 +53,9 @@ class BalancedPool extends PoolBase {
throw new InvalidArgumentError('factory must be a function.')
}

this[kInterceptors] = opts.interceptors && opts.interceptors.BalancedPool && Array.isArray(opts.interceptors.BalancedPool)
? opts.interceptors.BalancedPool
: []
this[kFactory] = factory

for (const upstream of upstreams) {
Expand Down
14 changes: 7 additions & 7 deletions lib/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ const net = require('net')
const util = require('./core/util')
const Request = require('./core/request')
const DispatcherBase = require('./dispatcher-base')
const RedirectHandler = require('./handler/redirect')
const {
RequestContentLengthMismatchError,
ResponseContentLengthMismatchError,
Expand Down Expand Up @@ -60,7 +59,8 @@ const {
kCounter,
kClose,
kDestroy,
kDispatch
kDispatch,
kInterceptors
} = require('./core/symbols')

const kClosedResolve = Symbol('kClosedResolve')
Expand All @@ -82,6 +82,7 @@ try {

class Client extends DispatcherBase {
constructor (url, {
interceptors,
maxHeaderSize,
headersTimeout,
socketTimeout,
Expand Down Expand Up @@ -179,6 +180,9 @@ class Client extends DispatcherBase {
})
}

this[kInterceptors] = interceptors && interceptors.Client && Array.isArray(interceptors.Client)
? interceptors.Client
: [createRedirectInterceptor({ maxRedirections })]
this[kUrl] = util.parseOrigin(url)
this[kConnector] = connect
this[kSocket] = null
Expand Down Expand Up @@ -254,11 +258,6 @@ class Client extends DispatcherBase {
}

[kDispatch] (opts, handler) {
const { maxRedirections = this[kMaxRedirections] } = opts
if (maxRedirections) {
handler = new RedirectHandler(this, maxRedirections, opts, handler)
}

const origin = opts.origin || this[kUrl].origin

const request = new Request(origin, opts, handler)
Expand Down Expand Up @@ -319,6 +318,7 @@ class Client extends DispatcherBase {
}

const constants = require('./llhttp/constants')
const createRedirectInterceptor = require('./interceptor/redirectInterceptor')
const EMPTY_BUF = Buffer.alloc(0)

async function lazyllhttp () {
Expand Down
3 changes: 2 additions & 1 deletion lib/core/symbols.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,5 +48,6 @@ module.exports = {
kMaxRedirections: Symbol('maxRedirections'),
kMaxRequests: Symbol('maxRequestsPerClient'),
kProxy: Symbol('proxy agent options'),
kCounter: Symbol('socket request counter')
kCounter: Symbol('socket request counter'),
kInterceptors: Symbol('dispatch interceptors')
}
36 changes: 34 additions & 2 deletions lib/dispatcher-base.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@ const {
ClientClosedError,
InvalidArgumentError
} = require('./core/errors')
const { kDestroy, kClose, kDispatch } = require('./core/symbols')
const { kDestroy, kClose, kDispatch, kInterceptors } = require('./core/symbols')

const kDestroyed = Symbol('destroyed')
const kClosed = Symbol('closed')
const kOnDestroyed = Symbol('onDestroyed')
const kOnClosed = Symbol('onClosed')
const kInterceptedDispatch = Symbol('Intercepted Dispatch')

class DispatcherBase extends Dispatcher {
constructor () {
Expand All @@ -31,6 +32,23 @@ class DispatcherBase extends Dispatcher {
return this[kClosed]
}

get interceptors () {
return this[kInterceptors]
}

set interceptors (newInterceptors) {
if (newInterceptors) {
for (let i = newInterceptors.length - 1; i >= 0; i--) {
const interceptor = this[kInterceptors][i]
if (typeof interceptor !== 'function') {
throw new InvalidArgumentError('interceptor must be an function')
}
}
}

this[kInterceptors] = newInterceptors
}

close (callback) {
if (callback === undefined) {
return new Promise((resolve, reject) => {
Expand Down Expand Up @@ -125,6 +143,20 @@ class DispatcherBase extends Dispatcher {
})
}

[kInterceptedDispatch] (opts, handler) {
if (!this[kInterceptors] || this[kInterceptors].length === 0) {
this[kInterceptedDispatch] = this[kDispatch]
return this[kDispatch](opts, handler)
}

let dispatch = this[kDispatch].bind(this)
for (let i = this[kInterceptors].length - 1; i >= 0; i--) {
dispatch = this[kInterceptors][i](dispatch)
}
this[kInterceptedDispatch] = dispatch
return dispatch(opts, handler)
}

dispatch (opts, handler) {
if (!handler || typeof handler !== 'object') {
throw new InvalidArgumentError('handler must be an object')
Expand All @@ -143,7 +175,7 @@ class DispatcherBase extends Dispatcher {
throw new ClientClosedError()
}

return this[kDispatch](opts, handler)
return this[kInterceptedDispatch](opts, handler)
} catch (err) {
if (typeof handler.onError !== 'function') {
throw new InvalidArgumentError('invalid onError method')
Expand Down
35 changes: 35 additions & 0 deletions lib/handler/DecoratorHandler.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
'use strict'

module.exports = class DecoratorHandler {
constructor (handler) {
this.handler = handler
}

onConnect (...args) {
return this.handler.onConnect(...args)
}

onError (...args) {
return this.handler.onError(...args)
}

onUpgrade (...args) {
return this.handler.onUpgrade(...args)
}

onHeaders (...args) {
return this.handler.onHeaders(...args)
}

onData (...args) {
return this.handler.onData(...args)
}

onComplete (...args) {
return this.handler.onComplete(...args)
}

onBodySent (...args) {
return this.handler.onBodySent(...args)
}
}
6 changes: 3 additions & 3 deletions lib/handler/redirect.js → lib/handler/RedirectHandler.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,14 @@ class BodyAsyncIterable {
}

class RedirectHandler {
constructor (dispatcher, maxRedirections, opts, handler) {
constructor (dispatch, maxRedirections, opts, handler) {
if (maxRedirections != null && (!Number.isInteger(maxRedirections) || maxRedirections < 0)) {
throw new InvalidArgumentError('maxRedirections must be a positive number')
}

util.validateHandler(handler, opts.method, opts.upgrade)

this.dispatcher = dispatcher
this.dispatch = dispatch
this.location = null
this.abort = null
this.opts = { ...opts, maxRedirections: 0 } // opts must be a copy
Expand Down Expand Up @@ -156,7 +156,7 @@ class RedirectHandler {
this.location = null
this.abort = null

this.dispatcher.dispatch(this.opts, this)
this.dispatch(this.opts, this)
} else {
this.handler.onComplete(trailers)
}
Expand Down