Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/docs/api/Dispatcher.md
Original file line number Diff line number Diff line change
Expand Up @@ -786,8 +786,8 @@ can make progress.
## Pre-built interceptors

For the full reference of built-in interceptors (`dump`, `retry`, `redirect`,
`decompress`, `responseError`, `dns`, `cache`, `deduplicate`) and their options,
see [Interceptors](Interceptors.md).
`decompress`, `responseError`, `dns`, `cache`, `deduplicate`, `signal`) and
their options, see [Interceptors](Interceptors.md).

[Fastify]: https://fastify.dev
[GOAWAY frame]: https://webconcepts.info/concepts/http2-frame-type/0x7
Expand Down
45 changes: 44 additions & 1 deletion docs/docs/api/Interceptors.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ retries, response decompression, redirect following, DNS caching, and more.
```js
import { Agent, interceptors } from 'undici'

const { retry, redirect, decompress, dump, responseError, dns, cache, deduplicate } = interceptors
const { retry, redirect, decompress, dump, responseError, dns, cache, deduplicate, signal } = interceptors

const agent = new Agent().compose([
retry({ maxRetries: 3 }),
Expand Down Expand Up @@ -329,6 +329,49 @@ const agent = new Agent().compose(

---

## `interceptors.signal()`

Aborts the request when the `signal` dispatch option is aborted. Accepts both
[`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal)
and `EventEmitter`-style signals (any object emitting an `abort` event). The
request is aborted with `signal.reason` when available, or a
`RequestAbortedError` otherwise. If the signal is already aborted when the
request is dispatched, the request fails immediately without reaching the
server.

The abort listener is removed as soon as the request completes, errors or is
upgraded, so long-lived signals do not accumulate listeners across requests.

This is mainly useful when calling `dispatch()` directly with a custom
handler; the higher-level API methods (`request`, `stream`, `pipeline`, etc.)
already handle the `signal` option themselves.

**Parameters**

* None.

**Returns:** {Dispatcher.DispatcherComposeInterceptor}

**Example**

```js
import { Client, interceptors } from 'undici'

const client = new Client('https://example.com').compose(
interceptors.signal()
)

const ac = new AbortController()
queueMicrotask(() => ac.abort())

client.dispatch(
{ method: 'GET', path: '/', signal: ac.signal },
myCustomHandler
)
```

---

## Composing multiple interceptors

Interceptors are applied in the order they appear in the `compose()` call.
Expand Down
3 changes: 2 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ module.exports.interceptors = {
dns: require('./lib/interceptor/dns'),
cache: require('./lib/interceptor/cache'),
decompress: require('./lib/interceptor/decompress'),
deduplicate: require('./lib/interceptor/deduplicate')
deduplicate: require('./lib/interceptor/deduplicate'),
signal: require('./lib/interceptor/signal')
}

module.exports.cacheStores = {
Expand Down
93 changes: 93 additions & 0 deletions lib/interceptor/signal.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
'use strict'

const { InvalidArgumentError, RequestAbortedError } = require('../core/errors')
const { addAbortListener } = require('../core/util')
const DecoratorHandler = require('../handler/decorator-handler')

class SignalHandler extends DecoratorHandler {
/** @type {AbortSignal|import('node:events').EventEmitter} */
#signal
/** @type {import('../core/request').RequestController|null} */
#controller = null
/** @type {(() => void)|null} */
#removeAbortListener = null
/** @type {boolean} */
#aborted = false

constructor (signal, handler) {
super(handler)
this.#signal = signal
}

#abort () {
this.#aborted = true
this.#removeListener()
this.#controller.abort(this.#signal.reason ?? new RequestAbortedError())
}

#removeListener () {
if (this.#removeAbortListener !== null) {
this.#removeAbortListener()
this.#removeAbortListener = null
}
}

onRequestStart (controller, context) {
// A fresh controller is passed for every dispatch of the same request
// (e.g. when composed with the retry interceptor), so keep the reference
// up to date while registering the abort listener only once.
this.#controller = controller

if (this.#aborted || this.#signal.aborted) {
this.#abort()
return
}

if (this.#removeAbortListener === null) {
this.#removeAbortListener = addAbortListener(this.#signal, () => this.#abort())
}

super.onRequestStart(controller, context)
}

onRequestUpgrade (controller, statusCode, headers, socket) {
this.#removeListener()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's not remove the listener until the response has ended or errored

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makes sense

super.onRequestUpgrade(controller, statusCode, headers, socket)
}

onResponseEnd (controller, trailers) {
this.#removeListener()
super.onResponseEnd(controller, trailers)
}

onResponseError (controller, err) {
this.#removeListener()
super.onResponseError(controller, err)
}
}

/**
* Interceptor that aborts the request when the `signal` dispatch option is
* aborted. Supports both `AbortSignal` and `EventEmitter`-style signals.
* The abort listener is removed once the request completes, errors or is
* upgraded.
*
* @returns {import('../../types/dispatcher').default.DispatcherComposeInterceptor}
*/
module.exports = () => {
return (dispatch) => {
return function signalInterceptor (opts, handler) {
const { signal } = opts

if (!signal) {
return dispatch(opts, handler)
}

if (typeof signal.on !== 'function' && typeof signal.addEventListener !== 'function') {
throw new InvalidArgumentError('signal must be an EventEmitter or EventTarget')
}

return dispatch(opts, new SignalHandler(signal, handler))
}
}
}
Loading
Loading