diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1262b8a023e..db3eb50f76e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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' @@ -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: @@ -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 }} diff --git a/.github/workflows/nodejs-shared.yml b/.github/workflows/nodejs-shared.yml index 36e1fd57ae6..e8ec93a62bc 100644 --- a/.github/workflows/nodejs-shared.yml +++ b/.github/workflows/nodejs-shared.yml @@ -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 diff --git a/lib/global.js b/lib/global.js index b61d779e498..651ef2c1277 100644 --- a/lib/global.js +++ b/lib/global.js @@ -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') @@ -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 () { diff --git a/test/node-test/client-errors.js b/test/node-test/client-errors.js index d260f628b85..90813539d8a 100644 --- a/test/node-test/client-errors.js +++ b/test/node-test/client-errors.js @@ -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)) @@ -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)) diff --git a/test/node-test/global-dispatcher-version.js b/test/node-test/global-dispatcher-version.js new file mode 100644 index 00000000000..ed4c0a2e18a --- /dev/null +++ b/test/node-test/global-dispatcher-version.js @@ -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) +})