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
16 changes: 8 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,8 @@ jobs:
fail-fast: false
max-parallel: 0
matrix:
node-version: ['20', '22', '24', '25']
node-version: ['22', '24', '25']
runs-on: ['ubuntu-latest', 'windows-latest', 'macos-latest']
exclude:
- node-version: '20'
runs-on: windows-latest
uses: ./.github/workflows/nodejs.yml
with:
# Disable coverage on Node.js 25 until https://github.com/nodejs/node/issues/61971 is resolved.
Expand All @@ -78,7 +75,7 @@ jobs:
fail-fast: false
max-parallel: 0
matrix:
node-version: ['24', '25']
node-version: ['22', '24', '25']
runs-on: ['ubuntu-latest']
uses: ./.github/workflows/nodejs.yml
with:
Expand All @@ -93,7 +90,7 @@ jobs:
fail-fast: false
max-parallel: 0
matrix:
node-version: ['20', '22', '24', '25']
node-version: ['22', '24', '25']
runs-on: ubuntu-latest
timeout-minutes: 120
steps:
Expand Down Expand Up @@ -179,7 +176,7 @@ jobs:
fail-fast: false
max-parallel: 0
matrix:
node-version: ['20', '22', '24', '25']
node-version: ['22', '24', '25']
runs-on: ubuntu-latest
timeout-minutes: 120
steps:
Expand Down Expand Up @@ -273,7 +270,10 @@ jobs:
fail-fast: false
max-parallel: 0
matrix:
node-version: ['24', '25']
# Node.js 22 is intentionally excluded here: --shared-builtin-undici/undici-path
# embedding is only validated for supported/current majors.
# Start validating shared builtin embedding from Node.js 26 onward.
node-version: ['26']
runs-on: ['ubuntu-latest']
with:
node-version: ${{ matrix.node-version }}
Expand Down
14 changes: 12 additions & 2 deletions .github/workflows/nodejs-shared.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,21 +59,29 @@ jobs:
const req = await fetch('https://nodejs.org/download${{ inputs.node-download-server-path }}/index.json')
const releases = await req.json()
const latest = releases.find((r) => r.version.startsWith('v${{ inputs.node-version }}'))
return latest.version
const latest = releases.find((r) => r.version.startsWith('v${{ inputs.node-version }}.'))
return latest?.version || ''
- name: Skip until Node.js ${{ inputs.node-version }} is released
if: steps.release.outputs.result == ''
run: echo 'No matching Node.js ${{ inputs.node-version }} release is available yet; skipping shared builtin embedding checks.'

- name: Download and extract source for Node.js ${{ steps.release.outputs.result }}
if: steps.release.outputs.result != ''
run: curl https://nodejs.org/download${{ inputs.node-download-server-path }}/${{ steps.release.outputs.result }}/node-${{ steps.release.outputs.result }}.tar.xz | tar xfJ -

- name: Install ninja
if: steps.release.outputs.result != ''
run: sudo apt-get install ninja-build

- name: ccache
if: steps.release.outputs.result != ''
uses: hendrikmuhs/ccache-action@bfa03e1de4d7f7c3e80ad9109feedd05c4f5a716 #v1.2.19
with:
key: node(external_undici)${{ inputs.node-version }}

- name: Build node ${{ steps.release.outputs.result }} with --shared-builtin-undici/undici-path
if: steps.release.outputs.result != ''
working-directory: ./node-${{ steps.release.outputs.result }}
run: |
export CC="ccache gcc"
Expand All @@ -85,6 +93,7 @@ jobs:
echo "$(pwd)/final/bin" >> $GITHUB_PATH
- name: Print version information
if: steps.release.outputs.result != ''
run: |
echo OS: $(node -p "os.version()")
echo Node.js: $(node --version)
Expand All @@ -95,6 +104,7 @@ jobs:
echo Node.js built-in undici version: $(node -p "process.versions.undici") # undefined for external Undici
- name: Run tests
if: steps.release.outputs.result != ''
working-directory: ./node-${{ steps.release.outputs.result }}
run: tools/test.py -p dots --flaky-tests=dontcare

4 changes: 2 additions & 2 deletions .github/workflows/nodejs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -110,12 +110,12 @@ jobs:
NODE_V8_COVERAGE: ${{ inputs.codecov == true && './coverage/tmp' || '' }}
UNDICI_NO_WASM_SIMD: ${{ inputs['no-wasm-simd'] }}

- name: Test cache-interceptor ${{ inputs.node-version != '20' && 'with' || 'without' }} sqlite
- name: Test cache-interceptor with sqlite
run: npm run test:cache-interceptor
id: test-cache-interceptor
env:
CI: true
NODE_OPTIONS: ${{ inputs.node-version != '20' && '--experimental-sqlite' || '' }}
NODE_OPTIONS: --experimental-sqlite
NODE_V8_COVERAGE: ${{ inputs.codecov == true && './coverage/tmp' || '' }}
UNDICI_NO_WASM_SIMD: ${{ inputs['no-wasm-simd'] }}

Expand Down
4 changes: 1 addition & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,6 @@ test/request-timeout.10mb.bin
CLAUDE.md
.claude

# Ignore .pi
# Local tooling
.pi

# Ignore .githuman
.githuman
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -619,6 +619,12 @@ See [Dispatcher.upgrade](./docs/docs/api/Dispatcher.md#dispatcherupgradeoptions-
Sets the global dispatcher used by Common API Methods. Global dispatcher is shared among compatible undici modules,
including undici that is bundled internally with node.js.

Undici stores this dispatcher under `Symbol.for('undici.globalDispatcher.2')`.

On Node.js 22, `setGlobalDispatcher()` also mirrors the configured dispatcher to
`Symbol.for('undici.globalDispatcher.1')` using `Dispatcher1Wrapper`, so Node.js built-in `fetch`
can keep using the legacy handler contract while Undici uses the new handler API.

### `undici.getGlobalDispatcher()`

Gets the global dispatcher used by Common API Methods.
Expand Down
4 changes: 2 additions & 2 deletions docs/docs/api/Client.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Returns: `Client`
* **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. **Security Warning:** Disabling this option can expose your application to HTTP Request Smuggling attacks, where mismatched content-length headers cause servers and proxies to interpret request boundaries differently. This can lead to cache poisoning, credential hijacking, and bypassing security controls. Only disable this in controlled environments where you fully trust the request source.
* **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.
* **allowH2**: `boolean` - Default: `true`. Enables support for H2 if the server has assigned bigger priority to it through ALPN negotiation.
* **useH2c**: `boolean` - Default: `false`. Enforces h2c for non-https connections.
* **maxConcurrentStreams**: `number` - Default: `100`. Dictates the maximum number of concurrent streams for a single H2 session. It can be overridden by a SETTINGS remote frame.
* **initialWindowSize**: `number` (optional) - Default: `262144` (256KB). Sets the HTTP/2 stream-level flow-control window size (SETTINGS_INITIAL_WINDOW_SIZE). Must be a positive integer greater than 0. This default is higher than Node.js core's default (65535 bytes) to improve throughput, Node's choice is very conservative for current high-bandwith networks. See [RFC 7540 Section 6.9.2](https://datatracker.ietf.org/doc/html/rfc7540#section-6.9.2) for more details.
Expand Down Expand Up @@ -282,4 +282,4 @@ console.log('requests completed')

### Event: `'error'`

Invoked for users errors such as throwing in the `onError` handler.
Invoked for user errors such as throwing in the `onResponseError` handler.
81 changes: 58 additions & 23 deletions docs/docs/api/Dispatcher.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,41 @@ Returns: `Boolean` - `false` if dispatcher is busy and further dispatch calls wo
* **onResponseEnd** `(controller: DispatchController, trailers: Record<string, string | string[]>) => void` - Invoked when response payload and trailers have been received and the request has completed. Not required for `upgrade` requests.
* **onResponseError** `(controller: DispatchController, error: Error) => void` - Invoked when an error has occurred. May not throw.

#### Migration from legacy handler API

If you were previously using `onConnect/onHeaders/onData/onComplete/onError`, switch to the new callbacks:

- `onConnect(abort)` → `onRequestStart(controller)` and call `controller.abort(reason)`
- `onHeaders(status, rawHeaders, resume, statusText)` → `onResponseStart(controller, status, headers, statusText)`
- `onData(chunk)` → `onResponseData(controller, chunk)`
- `onComplete(trailers)` → `onResponseEnd(controller, trailers)`
- `onError(err)` → `onResponseError(controller, err)`
- `onUpgrade(status, rawHeaders, socket)` → `onRequestUpgrade(controller, status, headers, socket)`

To access raw header arrays (for preserving duplicates/casing), read them from the controller:

- `controller.rawHeaders` for response headers
- `controller.rawTrailers` for trailers

Pause/resume now uses the controller:

- Call `controller.pause()` and `controller.resume()` instead of returning `false` from handlers.

#### Compatibility notes

Undici now stores the global dispatcher under `Symbol.for('undici.globalDispatcher.2')`.
This avoids conflicts with runtimes (such as Node.js built-in `fetch`) that still rely on the legacy dispatcher handler interface.

On Node.js 22, `setGlobalDispatcher()` also mirrors the configured dispatcher to `Symbol.for('undici.globalDispatcher.1')` using a `Dispatcher1Wrapper`, so Node's built-in `fetch` can keep using the legacy handler contract.

If you need to expose a new dispatcher/agent to legacy v1 handler consumers (`onConnect/onHeaders/onData/onComplete/onError/onUpgrade`), use `Dispatcher1Wrapper`:

```js
import { Agent, Dispatcher1Wrapper } from 'undici'

const legacyCompatibleDispatcher = new Dispatcher1Wrapper(new Agent())
```

#### Example 1 - Dispatch GET request

```js
Expand All @@ -236,21 +271,21 @@ client.dispatch({
'x-foo': 'bar'
}
}, {
onConnect: () => {
onRequestStart: () => {
console.log('Connected!')
},
onError: (error) => {
onResponseError: (_controller, error) => {
console.error(error)
},
onHeaders: (statusCode, headers) => {
console.log(`onHeaders | statusCode: ${statusCode} | headers: ${headers}`)
onResponseStart: (_controller, statusCode, headers) => {
console.log(`onResponseStart | statusCode: ${statusCode} | headers: ${JSON.stringify(headers)}`)
},
onData: (chunk) => {
console.log('onData: chunk received')
onResponseData: (_controller, chunk) => {
console.log('onResponseData: chunk received')
data.push(chunk)
},
onComplete: (trailers) => {
console.log(`onComplete | trailers: ${trailers}`)
onResponseEnd: (_controller, trailers) => {
console.log(`onResponseEnd | trailers: ${JSON.stringify(trailers)}`)
const res = Buffer.concat(data).toString('utf8')
console.log(`Data: ${res}`)
client.close()
Expand Down Expand Up @@ -288,15 +323,15 @@ client.dispatch({
method: 'GET',
upgrade: 'websocket'
}, {
onConnect: () => {
console.log('Undici Client - onConnect')
onRequestStart: () => {
console.log('Undici Client - onRequestStart')
},
onError: (error) => {
console.log('onError') // shouldn't print
onResponseError: () => {
console.log('onResponseError') // shouldn't print
},
onUpgrade: (statusCode, headers, socket) => {
console.log('Undici Client - onUpgrade')
console.log(`onUpgrade Headers: ${headers}`)
onRequestUpgrade: (_controller, statusCode, headers, socket) => {
console.log('Undici Client - onRequestUpgrade')
console.log(`onRequestUpgrade Headers: ${JSON.stringify(headers)}`)
socket.on('data', buffer => {
console.log(buffer.toString('utf8'))
})
Expand Down Expand Up @@ -339,21 +374,21 @@ client.dispatch({
},
body: JSON.stringify({ message: 'Hello' })
}, {
onConnect: () => {
onRequestStart: () => {
console.log('Connected!')
},
onError: (error) => {
onResponseError: (_controller, error) => {
console.error(error)
},
onHeaders: (statusCode, headers) => {
console.log(`onHeaders | statusCode: ${statusCode} | headers: ${headers}`)
onResponseStart: (_controller, statusCode, headers) => {
console.log(`onResponseStart | statusCode: ${statusCode} | headers: ${JSON.stringify(headers)}`)
},
onData: (chunk) => {
console.log('onData: chunk received')
onResponseData: (_controller, chunk) => {
console.log('onResponseData: chunk received')
data.push(chunk)
},
onComplete: (trailers) => {
console.log(`onComplete | trailers: ${trailers}`)
onResponseEnd: (_controller, trailers) => {
console.log(`onResponseEnd | trailers: ${JSON.stringify(trailers)}`)
const res = Buffer.concat(data).toString('utf8')
console.log(`Response Data: ${res}`)
client.close()
Expand Down
2 changes: 1 addition & 1 deletion docs/docs/api/H2CClient.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,4 +260,4 @@ console.log("requests completed");

### Event: `'error'`

Invoked for users errors such as throwing in the `onError` handler.
Invoked for user errors such as throwing in the `onResponseError` handler.
23 changes: 14 additions & 9 deletions docs/docs/api/RedirectHandler.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,57 +34,62 @@ Returns: `RedirectHandler`

### Methods

#### `onConnect(abort)`
#### `onRequestStart(controller, context)`

Called when the connection is established.
Called when the request starts.

Parameters:

- **abort** `function` - The abort function.
- **controller** `DispatchController` - The request controller.
- **context** `object` - The dispatch context.

#### `onUpgrade(statusCode, headers, socket)`
#### `onRequestUpgrade(controller, statusCode, headers, socket)`

Called when an upgrade is requested.

Parameters:

- **controller** `DispatchController` - The request controller.
- **statusCode** `number` - The HTTP status code.
- **headers** `object` - The headers received in the response.
- **socket** `object` - The socket object.

#### `onError(error)`
#### `onResponseError(controller, error)`

Called when an error occurs.

Parameters:

- **controller** `DispatchController` - The request controller.
- **error** `Error` - The error that occurred.

#### `onHeaders(statusCode, headers, resume, statusText)`
#### `onResponseStart(controller, statusCode, headers, statusText)`

Called when headers are received.

Parameters:

- **controller** `DispatchController` - The request controller.
- **statusCode** `number` - The HTTP status code.
- **headers** `object` - The headers received in the response.
- **resume** `function` - The resume function.
- **statusText** `string` - The status text.

#### `onData(chunk)`
#### `onResponseData(controller, chunk)`

Called when data is received.

Parameters:

- **controller** `DispatchController` - The request controller.
- **chunk** `Buffer` - The data chunk received.

#### `onComplete(trailers)`
#### `onResponseEnd(controller, trailers)`

Called when the request is complete.

Parameters:

- **controller** `DispatchController` - The request controller.
- **trailers** `object` - The trailers received.

#### `onBodySent(chunk)`
Expand Down
25 changes: 12 additions & 13 deletions docs/docs/api/RetryHandler.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,17 +82,16 @@ const handler = new RetryHandler(
return client.dispatch(...args);
},
handler: {
onConnect() {},
onBodySent() {},
onHeaders(status, _rawHeaders, resume, _statusMessage) {
onRequestStart() {},
onBodySent(chunk) {},
onResponseStart(_controller, status, headers) {
// do something with headers
},
onData(chunk) {
onResponseData(_controller, chunk) {
chunks.push(chunk);
return true;
},
onComplete() {},
onError() {
onResponseEnd() {},
onResponseError(_controller, err) {
// handle error properly
},
},
Expand All @@ -107,12 +106,12 @@ const client = new Client(`http://localhost:${server.address().port}`);
const handler = new RetryHandler(dispatchOptions, {
dispatch: client.dispatch.bind(client),
handler: {
onConnect() {},
onBodySent() {},
onHeaders(status, _rawHeaders, resume, _statusMessage) {},
onData(chunk) {},
onComplete() {},
onError(err) {},
onRequestStart() {},
onBodySent(chunk) {},
onResponseStart(_controller, status, headers) {},
onResponseData(_controller, chunk) {},
onResponseEnd() {},
onResponseError(_controller, err) {},
},
});
```
Loading
Loading