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
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ jobs:
fail-fast: false
max-parallel: 0
matrix:
node-version: ['20', '22', '24', '25']
node-version: ['20', '22', '24', '25', '26']
runs-on: ['ubuntu-latest', 'windows-latest', 'macos-latest']
exclude:
- node-version: '20'
Expand All @@ -78,7 +78,7 @@ jobs:
fail-fast: false
max-parallel: 0
matrix:
node-version: ['24', '25']
node-version: ['24', '25', '26']
runs-on: ['ubuntu-latest']
uses: ./.github/workflows/nodejs.yml
with:
Expand Down Expand Up @@ -273,7 +273,7 @@ jobs:
fail-fast: false
max-parallel: 0
matrix:
node-version: ['24', '25']
node-version: ['24', '26']
runs-on: ['ubuntu-latest']
with:
node-version: ${{ matrix.node-version }}
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/nodejs-shared.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ jobs:
rm -rf deps/undici
./configure --shared-builtin-undici/undici-path ${{ github.workspace }}/undici/loader.js --ninja --prefix=./final
make
if grep -q '^build-ffi-tests:' Makefile; then
make build-ffi-tests
fi
make install
echo "$(pwd)/final/bin" >> $GITHUB_PATH

Expand Down
11 changes: 10 additions & 1 deletion lib/global.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

// We include a version number for the Dispatcher API. In case of breaking changes,
// this version number must be increased to avoid conflicts.
const globalDispatcher = Symbol.for('undici.globalDispatcher.1')
const globalDispatcher = Symbol.for('undici.globalDispatcher.2')
const legacyGlobalDispatcher = Symbol.for('undici.globalDispatcher.1')
const { InvalidArgumentError } = require('./core/errors')
const Agent = require('./dispatcher/agent')

Expand All @@ -14,12 +15,20 @@ function setGlobalDispatcher (agent) {
if (!agent || typeof agent.dispatch !== 'function') {
throw new InvalidArgumentError('Argument agent must implement Agent')
}

Object.defineProperty(globalThis, globalDispatcher, {
value: agent,
writable: true,
enumerable: false,
configurable: false
})

Object.defineProperty(globalThis, legacyGlobalDispatcher, {
value: agent,
writable: true,
enumerable: false,
configurable: false
})
}

function getGlobalDispatcher () {
Expand Down
106 changes: 63 additions & 43 deletions test/node-test/client-errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -122,33 +122,71 @@ test('GET errors and reconnect with pipelining 3', async (t) => {
await p.completed
})

function errorAndPipelining (type) {
test(`POST with a ${type} that errors and pipelining 1 should reconnect`, async (t) => {
const p = tspl(t, { plan: 12 })
function installErrorAndReconnectServer (server, p, { contentLength, trackPostWithPlan }) {
let sawPost = false
let sawGet = false

const server = createServer({ joinDuplicateHeaders: true })
server.once('request', (req, res) => {
server.on('request', (req, res) => {
if (req.method === 'GET') {
if (sawGet) {
req.socket?.destroy()
return
}

sawGet = true
p.strictEqual('/', req.url)
p.strictEqual('GET', req.method)
res.setHeader('content-type', 'text/plain')
res.end('hello')
return
}

if (sawPost) {
// Node.js 26 can surface additional POST attempts around the queued GET.
// Tear them down and keep the test focused on the reconnect behavior.
req.resume()
req.socket?.destroy()
return
}

sawPost = true

if (trackPostWithPlan) {
p.strictEqual('/', req.url)
p.strictEqual('POST', req.method)
p.strictEqual('42', req.headers['content-length'])
p.strictEqual(req.headers['content-length'], contentLength)
} else {
assert.strictEqual('/', req.url)
assert.strictEqual('POST', req.method)
assert.strictEqual(req.headers['content-length'], contentLength)
}

const bufs = []
req.on('data', (buf) => {
bufs.push(buf)
})
const bufs = []
req.on('data', (buf) => {
bufs.push(buf)
})

req.on('aborted', () => {
// we will abruptly close the connection here
// but this will still end
req.on('aborted', () => {
// we will abruptly close the connection here
// but this will still end
if (trackPostWithPlan) {
p.strictEqual('a string', Buffer.concat(bufs).toString('utf8'))
})
} else {
assert.strictEqual('a string', Buffer.concat(bufs).toString('utf8'))
}
})
})
}

server.once('request', (req, res) => {
p.strictEqual('/', req.url)
p.strictEqual('GET', req.method)
res.setHeader('content-type', 'text/plain')
res.end('hello')
})
function errorAndPipelining (type) {
test(`POST with a ${type} that errors and pipelining 1 should reconnect`, async (t) => {
const trackPostWithPlan = type !== consts.STREAM
const p = tspl(t, { plan: trackPostWithPlan ? 12 : 8 })

const server = createServer({ joinDuplicateHeaders: true })
installErrorAndReconnectServer(server, p, {
contentLength: '42',
trackPostWithPlan
})
t.after(closeServerAsPromise(server))

Expand Down Expand Up @@ -199,31 +237,13 @@ errorAndPipelining(consts.ASYNC_ITERATOR)

function errorAndChunkedEncodingPipelining (type) {
test(`POST with chunked encoding, ${type} body that errors and pipelining 1 should reconnect`, async (t) => {
const p = tspl(t, { plan: 12 })
const trackPostWithPlan = type !== consts.STREAM
const p = tspl(t, { plan: trackPostWithPlan ? 12 : 8 })

const server = createServer({ joinDuplicateHeaders: true })
server.once('request', (req, res) => {
p.strictEqual('/', req.url)
p.strictEqual('POST', req.method)
p.strictEqual(req.headers['content-length'], undefined)

const bufs = []
req.on('data', (buf) => {
bufs.push(buf)
})

req.on('aborted', () => {
// we will abruptly close the connection here
// but this will still end
p.strictEqual('a string', Buffer.concat(bufs).toString('utf8'))
})

server.once('request', (req, res) => {
p.strictEqual('/', req.url)
p.strictEqual('GET', req.method)
res.setHeader('content-type', 'text/plain')
res.end('hello')
})
installErrorAndReconnectServer(server, p, {
contentLength: undefined,
trackPostWithPlan
})
t.after(closeServerAsPromise(server))

Expand Down
95 changes: 95 additions & 0 deletions test/node-test/global-dispatcher-version.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
'use strict'

const assert = require('node:assert')
const { test } = require('node:test')
const { spawnSync } = require('node:child_process')
const { join } = require('node:path')

const cwd = join(__dirname, '../..')

function runNode (source) {
return spawnSync(process.execPath, ['-e', source], {
cwd,
encoding: 'utf8'
})
}

test('setGlobalDispatcher does not break Node.js global fetch', () => {
const script = `
const { Agent, setGlobalDispatcher } = require('./index.js')
const http = require('node:http')
const { once } = require('node:events')

;(async () => {
const server = http.createServer((req, res) => res.end('ok'))
server.listen(0)
await once(server, 'listening')

setGlobalDispatcher(new Agent())
const url = 'http://127.0.0.1:' + server.address().port
const res = await fetch(url)
process.stdout.write(await res.text())

server.close()
})().catch((err) => {
console.error(err?.cause?.stack || err?.stack || err)
process.exit(1)
})
`

const result = runNode(script)
assert.strictEqual(result.status, 0, result.stderr)
assert.strictEqual(result.stdout, 'ok')
})

test('setGlobalDispatcher mirrors the dispatcher under the v1 symbol that Node.js global fetch uses', () => {
const script = `
const { Agent, setGlobalDispatcher } = require('./index.js')
const http = require('node:http')
const { once } = require('node:events')

;(async () => {
const dispatcherV1Symbol = Symbol.for('undici.globalDispatcher.1')
const dispatcherV2Symbol = Symbol.for('undici.globalDispatcher.2')
const server = http.createServer((req, res) => res.end('ok'))
server.listen(0)
await once(server, 'listening')

let count = 0
class CountingAgent extends Agent {
dispatch (opts, handler) {
count++
return super.dispatch(opts, handler)
}
}

const agent = new CountingAgent()
setGlobalDispatcher(agent)

const url = 'http://127.0.0.1:' + server.address().port
const res = await fetch(url)
const body = await res.text()

process.stdout.write(JSON.stringify({
body,
count,
mirroredV1: globalThis[dispatcherV1Symbol] === agent,
mirroredV2: globalThis[dispatcherV2Symbol] === agent
}))

server.close()
})().catch((err) => {
console.error(err?.cause?.stack || err?.stack || err)
process.exit(1)
})
`

const result = runNode(script)
assert.strictEqual(result.status, 0, result.stderr)

const payload = JSON.parse(result.stdout)
assert.strictEqual(payload.body, 'ok')
assert.strictEqual(payload.count, 1)
assert.strictEqual(payload.mirroredV1, true)
assert.strictEqual(payload.mirroredV2, true)
})
Loading