Skip to content

Commit 66b08b1

Browse files
authored
fix: isolate global dispatcher v2 and add Dispatcher1Wrapper bridge (#4827)
1 parent f601c1a commit 66b08b1

File tree

8 files changed

+251
-2
lines changed

8 files changed

+251
-2
lines changed

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -563,6 +563,12 @@ See [Dispatcher.upgrade](./docs/docs/api/Dispatcher.md#dispatcherupgradeoptions-
563563
Sets the global dispatcher used by Common API Methods. Global dispatcher is shared among compatible undici modules,
564564
including undici that is bundled internally with node.js.
565565

566+
Undici stores this dispatcher under `Symbol.for('undici.globalDispatcher.2')`.
567+
568+
On Node.js 22, `setGlobalDispatcher()` also mirrors the configured dispatcher to
569+
`Symbol.for('undici.globalDispatcher.1')` using `Dispatcher1Wrapper`, so Node.js built-in `fetch`
570+
can keep using the legacy handler contract while Undici uses the new handler API.
571+
566572
### `undici.getGlobalDispatcher()`
567573

568574
Gets the global dispatcher used by Common API Methods.

docs/docs/api/Dispatcher.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,21 @@ Pause/resume now uses the controller:
232232

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

235+
#### Compatibility notes
236+
237+
Undici now stores the global dispatcher under `Symbol.for('undici.globalDispatcher.2')`.
238+
This avoids conflicts with runtimes (such as Node.js built-in `fetch`) that still rely on the legacy dispatcher handler interface.
239+
240+
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.
241+
242+
If you need to expose a new dispatcher/agent to legacy v1 handler consumers (`onConnect/onHeaders/onData/onComplete/onError/onUpgrade`), use `Dispatcher1Wrapper`:
243+
244+
```js
245+
import { Agent, Dispatcher1Wrapper } from 'undici'
246+
247+
const legacyCompatibleDispatcher = new Dispatcher1Wrapper(new Agent())
248+
```
249+
235250
#### Example 1 - Dispatch GET request
236251

237252
```js

index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const Pool = require('./lib/dispatcher/pool')
66
const BalancedPool = require('./lib/dispatcher/balanced-pool')
77
const RoundRobinPool = require('./lib/dispatcher/round-robin-pool')
88
const Agent = require('./lib/dispatcher/agent')
9+
const Dispatcher1Wrapper = require('./lib/dispatcher/dispatcher1-wrapper')
910
const ProxyAgent = require('./lib/dispatcher/proxy-agent')
1011
const EnvHttpProxyAgent = require('./lib/dispatcher/env-http-proxy-agent')
1112
const RetryAgent = require('./lib/dispatcher/retry-agent')
@@ -34,6 +35,7 @@ module.exports.Pool = Pool
3435
module.exports.BalancedPool = BalancedPool
3536
module.exports.RoundRobinPool = RoundRobinPool
3637
module.exports.Agent = Agent
38+
module.exports.Dispatcher1Wrapper = Dispatcher1Wrapper
3739
module.exports.ProxyAgent = ProxyAgent
3840
module.exports.EnvHttpProxyAgent = EnvHttpProxyAgent
3941
module.exports.RetryAgent = RetryAgent
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
'use strict'
2+
3+
const Dispatcher = require('./dispatcher')
4+
const { InvalidArgumentError } = require('../core/errors')
5+
const { toRawHeaders } = require('../core/util')
6+
7+
class LegacyHandlerWrapper {
8+
#handler
9+
10+
constructor (handler) {
11+
this.#handler = handler
12+
}
13+
14+
onRequestStart (controller, context) {
15+
this.#handler.onConnect?.((reason) => controller.abort(reason), context)
16+
}
17+
18+
onRequestUpgrade (controller, statusCode, headers, socket) {
19+
const rawHeaders = controller?.rawHeaders ?? toRawHeaders(headers ?? {})
20+
this.#handler.onUpgrade?.(statusCode, rawHeaders, socket)
21+
}
22+
23+
onResponseStart (controller, statusCode, headers, statusMessage) {
24+
const rawHeaders = controller?.rawHeaders ?? toRawHeaders(headers ?? {})
25+
26+
if (this.#handler.onHeaders?.(statusCode, rawHeaders, () => controller.resume(), statusMessage) === false) {
27+
controller.pause()
28+
}
29+
}
30+
31+
onResponseData (controller, chunk) {
32+
if (this.#handler.onData?.(chunk) === false) {
33+
controller.pause()
34+
}
35+
}
36+
37+
onResponseEnd (controller, trailers) {
38+
const rawTrailers = controller?.rawTrailers ?? toRawHeaders(trailers ?? {})
39+
this.#handler.onComplete?.(rawTrailers)
40+
}
41+
42+
onResponseError (_controller, err) {
43+
if (!this.#handler.onError) {
44+
throw err
45+
}
46+
47+
this.#handler.onError(err)
48+
}
49+
50+
onBodySent (chunk) {
51+
this.#handler.onBodySent?.(chunk)
52+
}
53+
54+
onRequestSent () {
55+
this.#handler.onRequestSent?.()
56+
}
57+
58+
onResponseStarted () {
59+
this.#handler.onResponseStarted?.()
60+
}
61+
}
62+
63+
class Dispatcher1Wrapper extends Dispatcher {
64+
#dispatcher
65+
66+
constructor (dispatcher) {
67+
super()
68+
69+
if (!dispatcher || typeof dispatcher.dispatch !== 'function') {
70+
throw new InvalidArgumentError('Argument dispatcher must implement dispatch')
71+
}
72+
73+
this.#dispatcher = dispatcher
74+
}
75+
76+
static wrapHandler (handler) {
77+
if (!handler || typeof handler !== 'object') {
78+
throw new InvalidArgumentError('handler must be an object')
79+
}
80+
81+
if (typeof handler.onRequestStart === 'function') {
82+
return handler
83+
}
84+
85+
return new LegacyHandlerWrapper(handler)
86+
}
87+
88+
dispatch (opts, handler) {
89+
return this.#dispatcher.dispatch(opts, Dispatcher1Wrapper.wrapHandler(handler))
90+
}
91+
92+
close (...args) {
93+
return this.#dispatcher.close(...args)
94+
}
95+
96+
destroy (...args) {
97+
return this.#dispatcher.destroy(...args)
98+
}
99+
}
100+
101+
module.exports = Dispatcher1Wrapper

lib/global.js

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,13 @@
22

33
// We include a version number for the Dispatcher API. In case of breaking changes,
44
// this version number must be increased to avoid conflicts.
5-
const globalDispatcher = Symbol.for('undici.globalDispatcher.1')
5+
const globalDispatcher = Symbol.for('undici.globalDispatcher.2')
6+
const legacyGlobalDispatcher = Symbol.for('undici.globalDispatcher.1')
67
const { InvalidArgumentError } = require('./core/errors')
78
const Agent = require('./dispatcher/agent')
9+
const Dispatcher1Wrapper = require('./dispatcher/dispatcher1-wrapper')
10+
11+
const nodeMajor = Number(process.versions.node.split('.', 1)[0])
812

913
if (getGlobalDispatcher() === undefined) {
1014
setGlobalDispatcher(new Agent())
@@ -14,12 +18,24 @@ function setGlobalDispatcher (agent) {
1418
if (!agent || typeof agent.dispatch !== 'function') {
1519
throw new InvalidArgumentError('Argument agent must implement Agent')
1620
}
21+
1722
Object.defineProperty(globalThis, globalDispatcher, {
1823
value: agent,
1924
writable: true,
2025
enumerable: false,
2126
configurable: false
2227
})
28+
29+
if (nodeMajor === 22) {
30+
const legacyAgent = agent instanceof Dispatcher1Wrapper ? agent : new Dispatcher1Wrapper(agent)
31+
32+
Object.defineProperty(globalThis, legacyGlobalDispatcher, {
33+
value: legacyAgent,
34+
writable: true,
35+
enumerable: false,
36+
configurable: false
37+
})
38+
}
2339
}
2440

2541
function getGlobalDispatcher () {
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
'use strict'
2+
3+
const assert = require('node:assert')
4+
const { test } = require('node:test')
5+
const { spawnSync } = require('node:child_process')
6+
const { join } = require('node:path')
7+
8+
const cwd = join(__dirname, '../..')
9+
10+
function runNode (source) {
11+
return spawnSync(process.execPath, ['-e', source], {
12+
cwd,
13+
encoding: 'utf8'
14+
})
15+
}
16+
17+
test('setGlobalDispatcher does not break Node.js global fetch', () => {
18+
const script = `
19+
const { Agent, setGlobalDispatcher } = require('./index.js')
20+
const http = require('node:http')
21+
const { once } = require('node:events')
22+
23+
;(async () => {
24+
const server = http.createServer((req, res) => res.end('ok'))
25+
server.listen(0)
26+
await once(server, 'listening')
27+
28+
setGlobalDispatcher(new Agent())
29+
const url = 'http://127.0.0.1:' + server.address().port
30+
const res = await fetch(url)
31+
process.stdout.write(await res.text())
32+
33+
server.close()
34+
})().catch((err) => {
35+
console.error(err?.cause?.stack || err?.stack || err)
36+
process.exit(1)
37+
})
38+
`
39+
40+
const result = runNode(script)
41+
assert.strictEqual(result.status, 0, result.stderr)
42+
assert.strictEqual(result.stdout, 'ok')
43+
})
44+
45+
test('setGlobalDispatcher mirrors a v1-compatible dispatcher on Node.js 22', () => {
46+
const script = `
47+
const { Agent, Dispatcher1Wrapper, setGlobalDispatcher } = require('./index.js')
48+
const nodeMajor = Number(process.versions.node.split('.', 1)[0])
49+
50+
setGlobalDispatcher(new Agent())
51+
52+
if (nodeMajor !== 22) {
53+
process.stdout.write('skipped')
54+
} else {
55+
const dispatcherV1 = globalThis[Symbol.for('undici.globalDispatcher.1')]
56+
57+
if (!(dispatcherV1 instanceof Dispatcher1Wrapper)) {
58+
throw new Error('expected v1 global dispatcher to be a Dispatcher1Wrapper on Node.js 22')
59+
}
60+
61+
process.stdout.write('mirrored')
62+
}
63+
`
64+
65+
const result = runNode(script)
66+
assert.strictEqual(result.status, 0, result.stderr)
67+
68+
const expected = Number(process.versions.node.split('.', 1)[0]) === 22 ? 'mirrored' : 'skipped'
69+
assert.strictEqual(result.stdout, expected)
70+
})
71+
72+
test('Dispatcher1Wrapper bridges legacy handlers to a new Agent', () => {
73+
const script = `
74+
const { Agent, Dispatcher1Wrapper } = require('./index.js')
75+
const http = require('node:http')
76+
const { once } = require('node:events')
77+
78+
;(async () => {
79+
const server = http.createServer((req, res) => res.end('ok'))
80+
server.listen(0)
81+
await once(server, 'listening')
82+
83+
const dispatcherV1 = Symbol.for('undici.globalDispatcher.1')
84+
globalThis[dispatcherV1] = new Dispatcher1Wrapper(new Agent())
85+
86+
const url = 'http://127.0.0.1:' + server.address().port
87+
const res = await fetch(url)
88+
process.stdout.write(await res.text())
89+
90+
server.close()
91+
})().catch((err) => {
92+
console.error(err?.cause?.stack || err?.stack || err)
93+
process.exit(1)
94+
})
95+
`
96+
97+
const result = runNode(script)
98+
assert.strictEqual(result.status, 0, result.stderr)
99+
assert.strictEqual(result.stdout, 'ok')
100+
})

types/dispatcher1-wrapper.d.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import Dispatcher from './dispatcher'
2+
3+
export default Dispatcher1Wrapper
4+
5+
declare class Dispatcher1Wrapper extends Dispatcher {
6+
constructor (dispatcher: Dispatcher)
7+
}

types/index.d.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import H2CClient from './h2c-client'
1111
import buildConnector from './connector'
1212
import errors from './errors'
1313
import Agent from './agent'
14+
import Dispatcher1Wrapper from './dispatcher1-wrapper'
1415
import MockClient from './mock-client'
1516
import MockPool from './mock-pool'
1617
import MockAgent from './mock-agent'
@@ -43,7 +44,7 @@ export { Interceptable } from './mock-interceptor'
4344

4445
declare function globalThisInstall (): void
4546

46-
export { Dispatcher, BalancedPool, RoundRobinPool, Pool, Client, buildConnector, errors, Agent, request, stream, pipeline, connect, upgrade, setGlobalDispatcher, getGlobalDispatcher, setGlobalOrigin, getGlobalOrigin, interceptors, cacheStores, MockClient, MockPool, MockAgent, SnapshotAgent, MockCallHistory, MockCallHistoryLog, mockErrors, ProxyAgent, EnvHttpProxyAgent, RedirectHandler, DecoratorHandler, RetryHandler, RetryAgent, H2CClient, globalThisInstall as install }
47+
export { Dispatcher, BalancedPool, RoundRobinPool, Pool, Client, buildConnector, errors, Agent, Dispatcher1Wrapper, request, stream, pipeline, connect, upgrade, setGlobalDispatcher, getGlobalDispatcher, setGlobalOrigin, getGlobalOrigin, interceptors, cacheStores, MockClient, MockPool, MockAgent, SnapshotAgent, MockCallHistory, MockCallHistoryLog, mockErrors, ProxyAgent, EnvHttpProxyAgent, RedirectHandler, DecoratorHandler, RetryHandler, RetryAgent, H2CClient, globalThisInstall as install }
4748
export default Undici
4849

4950
declare namespace Undici {
@@ -59,6 +60,7 @@ declare namespace Undici {
5960
const buildConnector: typeof import('./connector').default
6061
const errors: typeof import('./errors').default
6162
const Agent: typeof import('./agent').default
63+
const Dispatcher1Wrapper: typeof import('./dispatcher1-wrapper').default
6264
const setGlobalDispatcher: typeof import('./global-dispatcher').setGlobalDispatcher
6365
const getGlobalDispatcher: typeof import('./global-dispatcher').getGlobalDispatcher
6466
const request: typeof import('./api').request

0 commit comments

Comments
 (0)