Skip to content

Commit a1ee3ca

Browse files
metcoder95mcollina
andauthored
feat: add new dispatch compose (#2826)
* feat: add new dispatch compose * fix: review Co-authored-by: Matteo Collina <[email protected]> * revert: linting * docs: add documentation * fix: smaller tweaks to proxy interceptor * test: fix tests for proxy * refactor: expose interceptor as is * test: add testing for retry * refactor: rewrite interceptors * refactor: proxy interceptor * feat: redirect interceptor * refactor: change the compose behaviour * docs: update docs * test: add testing for compose * feat: composed dispatcher * docs: adjust documentation * refactor: apply review * docs: tweaks * feat: drop proxy --------- Co-authored-by: Matteo Collina <[email protected]>
1 parent f84ec80 commit a1ee3ca

File tree

10 files changed

+1418
-5
lines changed

10 files changed

+1418
-5
lines changed

docs/docs/api/Client.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ Returns: `Client`
2929
* **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.
3030
* **connect** `ConnectOptions | Function | null` (optional) - Default: `null`.
3131
* **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.
32-
* **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.
32+
<!-- TODO: Remove once we drop its support -->
33+
* **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.**
3334
* **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.
3435
* **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.
3536
* **allowH2**: `boolean` - Default: `false`. Enables support for H2 if the server has assigned bigger priority to it through ALPN negotiation.

docs/docs/api/Dispatcher.md

+135
Original file line numberDiff line numberDiff line change
@@ -817,6 +817,141 @@ try {
817817
}
818818
```
819819

820+
### `Dispatcher.compose(interceptors[, interceptor])`
821+
822+
Compose a new dispatcher from the current dispatcher and the given interceptors.
823+
824+
> _Notes_:
825+
> - The order of the interceptors matters. The first interceptor will be the first to be called.
826+
> - It is important to note that the `interceptor` function should return a function that follows the `Dispatcher.dispatch` signature.
827+
> - Any fork of the chain of `interceptors` can lead to unexpected results.
828+
829+
Arguments:
830+
831+
* **interceptors** `Interceptor[interceptor[]]`: It is an array of `Interceptor` functions passed as only argument, or several interceptors passed as separate arguments.
832+
833+
Returns: `Dispatcher`.
834+
835+
#### Parameter: `Interceptor`
836+
837+
A function that takes a `dispatch` method and returns a `dispatch`-like function.
838+
839+
#### Example 1 - Basic Compose
840+
841+
```js
842+
const { Client, RedirectHandler } = require('undici')
843+
844+
const redirectInterceptor = dispatch => {
845+
return (opts, handler) => {
846+
const { maxRedirections } = opts
847+
848+
if (!maxRedirections) {
849+
return dispatch(opts, handler)
850+
}
851+
852+
const redirectHandler = new RedirectHandler(
853+
dispatch,
854+
maxRedirections,
855+
opts,
856+
handler
857+
)
858+
opts = { ...opts, maxRedirections: 0 } // Stop sub dispatcher from also redirecting.
859+
return dispatch(opts, redirectHandler)
860+
}
861+
}
862+
863+
const client = new Client('http://localhost:3000')
864+
.compose(redirectInterceptor)
865+
866+
await client.request({ path: '/', method: 'GET' })
867+
```
868+
869+
#### Example 2 - Chained Compose
870+
871+
```js
872+
const { Client, RedirectHandler, RetryHandler } = require('undici')
873+
874+
const redirectInterceptor = dispatch => {
875+
return (opts, handler) => {
876+
const { maxRedirections } = opts
877+
878+
if (!maxRedirections) {
879+
return dispatch(opts, handler)
880+
}
881+
882+
const redirectHandler = new RedirectHandler(
883+
dispatch,
884+
maxRedirections,
885+
opts,
886+
handler
887+
)
888+
opts = { ...opts, maxRedirections: 0 }
889+
return dispatch(opts, redirectHandler)
890+
}
891+
}
892+
893+
const retryInterceptor = dispatch => {
894+
return function retryInterceptor (opts, handler) {
895+
return dispatch(
896+
opts,
897+
new RetryHandler(opts, {
898+
handler,
899+
dispatch
900+
})
901+
)
902+
}
903+
}
904+
905+
const client = new Client('http://localhost:3000')
906+
.compose(redirectInterceptor)
907+
.compose(retryInterceptor)
908+
909+
await client.request({ path: '/', method: 'GET' })
910+
```
911+
912+
#### Pre-built interceptors
913+
914+
##### `redirect`
915+
916+
The `redirect` interceptor allows you to customize the way your dispatcher handles redirects.
917+
918+
It accepts the same arguments as the [`RedirectHandler` constructor](./RedirectHandler.md).
919+
920+
**Example - Basic Redirect Interceptor**
921+
922+
```js
923+
const { Client, interceptors } = require("undici");
924+
const { redirect } = interceptors;
925+
926+
const client = new Client("http://example.com").compose(
927+
redirect({ maxRedirections: 3, throwOnMaxRedirects: true })
928+
);
929+
client.request({ path: "/" })
930+
```
931+
932+
##### `retry`
933+
934+
The `retry` interceptor allows you to customize the way your dispatcher handles retries.
935+
936+
It accepts the same arguments as the [`RetryHandler` constructor](./RetryHandler.md).
937+
938+
**Example - Basic Redirect Interceptor**
939+
940+
```js
941+
const { Client, interceptors } = require("undici");
942+
const { retry } = interceptors;
943+
944+
const client = new Client("http://example.com").compose(
945+
retry({
946+
maxRetries: 3,
947+
minTimeout: 1000,
948+
maxTimeout: 10000,
949+
timeoutFactor: 2,
950+
retryAfter: true,
951+
})
952+
);
953+
```
954+
820955
## Instance Events
821956

822957
### Event: `'connect'`

index.js

+4
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ module.exports.RetryHandler = RetryHandler
3636
module.exports.DecoratorHandler = DecoratorHandler
3737
module.exports.RedirectHandler = RedirectHandler
3838
module.exports.createRedirectInterceptor = createRedirectInterceptor
39+
module.exports.interceptors = {
40+
redirect: require('./lib/interceptor/redirect'),
41+
retry: require('./lib/interceptor/retry')
42+
}
3943

4044
module.exports.buildConnector = buildConnector
4145
module.exports.errors = errors

lib/dispatcher/client.js

+13-3
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ const {
5959
} = require('../core/symbols.js')
6060
const connectH1 = require('./client-h1.js')
6161
const connectH2 = require('./client-h2.js')
62+
let deprecatedInterceptorWarned = false
6263

6364
const kClosedResolve = Symbol('kClosedResolve')
6465

@@ -207,9 +208,18 @@ class Client extends DispatcherBase {
207208
})
208209
}
209210

210-
this[kInterceptors] = interceptors?.Client && Array.isArray(interceptors.Client)
211-
? interceptors.Client
212-
: [createRedirectInterceptor({ maxRedirections })]
211+
if (interceptors?.Client && Array.isArray(interceptors.Client)) {
212+
this[kInterceptors] = interceptors.Client
213+
if (!deprecatedInterceptorWarned) {
214+
deprecatedInterceptorWarned = true
215+
process.emitWarning('Client.Options#interceptor is deprecated. Use Dispatcher#compose instead.', {
216+
code: 'UNDICI-CLIENT-INTERCEPTOR-DEPRECATED'
217+
})
218+
}
219+
} else {
220+
this[kInterceptors] = [createRedirectInterceptor({ maxRedirections })]
221+
}
222+
213223
this[kUrl] = util.parseOrigin(url)
214224
this[kConnector] = connect
215225
this[kPipelining] = pipelining != null ? pipelining : 1

lib/dispatcher/dispatcher.js

+47-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
'use strict'
2-
32
const EventEmitter = require('node:events')
43

54
class Dispatcher extends EventEmitter {
@@ -14,6 +13,53 @@ class Dispatcher extends EventEmitter {
1413
destroy () {
1514
throw new Error('not implemented')
1615
}
16+
17+
compose (...args) {
18+
// So we handle [interceptor1, interceptor2] or interceptor1, interceptor2, ...
19+
const interceptors = Array.isArray(args[0]) ? args[0] : args
20+
let dispatch = this.dispatch.bind(this)
21+
22+
for (const interceptor of interceptors) {
23+
if (interceptor == null) {
24+
continue
25+
}
26+
27+
if (typeof interceptor !== 'function') {
28+
throw new TypeError(`invalid interceptor, expected function received ${typeof interceptor}`)
29+
}
30+
31+
dispatch = interceptor(dispatch)
32+
33+
if (dispatch == null || typeof dispatch !== 'function' || dispatch.length !== 2) {
34+
throw new TypeError('invalid interceptor')
35+
}
36+
}
37+
38+
return new ComposedDispatcher(this, dispatch)
39+
}
40+
}
41+
42+
class ComposedDispatcher extends Dispatcher {
43+
#dispatcher = null
44+
#dispatch = null
45+
46+
constructor (dispatcher, dispatch) {
47+
super()
48+
this.#dispatcher = dispatcher
49+
this.#dispatch = dispatch
50+
}
51+
52+
dispatch (...args) {
53+
this.#dispatch(...args)
54+
}
55+
56+
close (...args) {
57+
return this.#dispatcher.close(...args)
58+
}
59+
60+
destroy (...args) {
61+
return this.#dispatcher.destroy(...args)
62+
}
1763
}
1864

1965
module.exports = Dispatcher

lib/interceptor/redirect.js

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
'use strict'
2+
const RedirectHandler = require('../handler/redirect-handler')
3+
4+
module.exports = opts => {
5+
const globalMaxRedirections = opts?.maxRedirections
6+
return dispatch => {
7+
return function redirectInterceptor (opts, handler) {
8+
const { maxRedirections = globalMaxRedirections, ...baseOpts } = opts
9+
10+
if (!maxRedirections) {
11+
return dispatch(opts, handler)
12+
}
13+
14+
const redirectHandler = new RedirectHandler(
15+
dispatch,
16+
maxRedirections,
17+
opts,
18+
handler
19+
)
20+
21+
return dispatch(baseOpts, redirectHandler)
22+
}
23+
}
24+
}

lib/interceptor/retry.js

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
'use strict'
2+
const RetryHandler = require('../handler/retry-handler')
3+
4+
module.exports = globalOpts => {
5+
return dispatch => {
6+
return function retryInterceptor (opts, handler) {
7+
return dispatch(
8+
opts,
9+
new RetryHandler(
10+
{ ...opts, retryOptions: { ...globalOpts, ...opts.retryOptions } },
11+
{
12+
handler,
13+
dispatch
14+
}
15+
)
16+
)
17+
}
18+
}
19+
}

test/dispatcher.js

+20
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,23 @@ test('dispatcher implementation', (t) => {
2020
t.throws(() => poorImplementation.close(), Error, 'throws on unimplemented close')
2121
t.throws(() => poorImplementation.destroy(), Error, 'throws on unimplemented destroy')
2222
})
23+
24+
test('dispatcher.compose', (t) => {
25+
t = tspl(t, { plan: 10 })
26+
27+
const dispatcher = new Dispatcher()
28+
const interceptor = () => (opts, handler) => {}
29+
// Should return a new dispatcher
30+
t.ok(Object.getPrototypeOf(dispatcher.compose(interceptor)) instanceof Dispatcher)
31+
t.ok(Object.getPrototypeOf(dispatcher.compose(interceptor, interceptor)) instanceof Dispatcher)
32+
t.ok(Object.getPrototypeOf(dispatcher.compose([interceptor, interceptor])) instanceof Dispatcher)
33+
t.ok(dispatcher.compose(interceptor) !== dispatcher)
34+
t.throws(() => dispatcher.dispatch({}), Error, 'invalid interceptor')
35+
t.throws(() => dispatcher.dispatch(() => null), Error, 'invalid interceptor')
36+
t.throws(() => dispatcher.dispatch(dispatch => dispatch, () => () => {}, Error, 'invalid interceptor'))
37+
38+
const composed = dispatcher.compose(interceptor)
39+
t.equal(typeof composed.dispatch, 'function', 'returns an object with a dispatch method')
40+
t.equal(typeof composed.close, 'function', 'returns an object with a close method')
41+
t.equal(typeof composed.destroy, 'function', 'returns an object with a destroy method')
42+
})

0 commit comments

Comments
 (0)