Skip to content
Merged
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
5 changes: 3 additions & 2 deletions lib/dispatcher/dispatcher1-wrapper.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,10 @@ class Dispatcher1Wrapper extends Dispatcher {
}

dispatch (opts, handler) {
// Legacy (v1) consumers do not support HTTP/2, so force HTTP/1.1.
// Legacy (v1) WebSocket consumers do not support falling back to HTTP/1.1
// when HTTP/2 Extended CONNECT is unavailable.
// See https://github.com/nodejs/undici/issues/4989
if (opts.allowH2 !== false) {
if (opts.upgrade && opts.allowH2 !== false) {
opts = { ...opts, allowH2: false }
}

Expand Down
61 changes: 61 additions & 0 deletions test/fixtures/global-fetch-h2-only.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
'use strict'

const { createSecureServer } = require('node:http2')
const { once } = require('node:events')
const pem = require('@metcoder95/https-pem')
const { Agent, Pool, setGlobalDispatcher } = require('../..')

async function main () {
let factoryCalls = 0
let streams = 0
const server = createSecureServer(await pem.generate({ opts: { keySize: 2048 } }))
const dispatcher = new Agent({
connect: {
rejectUnauthorized: false
},
factory (origin, options) {
factoryCalls++
return new Pool(origin, {
...options,
connections: 5
})
}
})

server.on('stream', (stream) => {
streams++
stream.respond({
':status': 200,
'content-type': 'text/plain'
})
stream.end('ok')
})

try {
server.listen(0)
await once(server, 'listening')
setGlobalDispatcher(dispatcher)

// This fixture specifically verifies Node.js's built-in fetch.
// eslint-disable-next-line no-restricted-globals
const response = await fetch(`https://localhost:${server.address().port}`)
const body = await response.text()

process.stdout.write(JSON.stringify({
body,
factoryCalls,
streams
}))
} finally {
try {
await dispatcher.close()
} finally {
await new Promise((resolve) => server.close(resolve))
}
}
}

main().catch((err) => {
console.error(err?.cause?.stack || err?.stack || err)
process.exitCode = 1
})
35 changes: 35 additions & 0 deletions test/node-test/global-dispatcher-version.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const assert = require('node:assert')
const { test } = require('node:test')
const { spawnSync } = require('node:child_process')
const { join } = require('node:path')
const Dispatcher1Wrapper = require('../../lib/dispatcher/dispatcher1-wrapper')

const cwd = join(__dirname, '../..')
const isNode26Plus = Number(process.versions.node.split('.')[0]) >= 26
Expand All @@ -15,6 +16,14 @@ function runNode (source) {
})
}

function runNodeFile (filename) {
return spawnSync(process.execPath, [join(cwd, filename)], {
cwd,
encoding: 'utf8',
timeout: 30_000
})
}

test('setGlobalDispatcher does not break Node.js global fetch', () => {
const script = `
const { Agent, setGlobalDispatcher } = require('./index.js')
Expand Down Expand Up @@ -98,6 +107,16 @@ test('setGlobalDispatcher mirrors a v1-compatible dispatcher that Node.js global
assert.strictEqual(payload.mirroredV2, true)
})

test('setGlobalDispatcher lets Node.js global fetch reach an HTTP/2-only server', () => {
const result = runNodeFile('test/fixtures/global-fetch-h2-only.js')
assert.strictEqual(result.status, 0, result.stderr)
assert.deepStrictEqual(JSON.parse(result.stdout), {
body: 'ok',
factoryCalls: 1,
streams: 1
})
})

test('setGlobalDispatcher lets Node.js global fetch use a MockAgent interceptor', () => {
const script = `
const { MockAgent, setGlobalDispatcher } = require('./index.js')
Expand Down Expand Up @@ -132,6 +151,22 @@ test('setGlobalDispatcher lets Node.js global fetch use a MockAgent interceptor'
})
})

test('Dispatcher1Wrapper only disables HTTP/2 for upgrade requests', () => {
const dispatchedOptions = []
const dispatcher = new Dispatcher1Wrapper({
dispatch (opts) {
dispatchedOptions.push(opts)
return true
}
})

dispatcher.dispatch({ allowH2: true }, {})
dispatcher.dispatch({ allowH2: true, upgrade: 'websocket' }, {})

assert.strictEqual(dispatchedOptions[0].allowH2, true)
assert.strictEqual(dispatchedOptions[1].allowH2, false)
})

test('Dispatcher1Wrapper bridges legacy handlers to a new Agent', () => {
const script = `
const { Agent, Dispatcher1Wrapper } = require('./index.js')
Expand Down
7 changes: 4 additions & 3 deletions test/websocket/issue-4989.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
// Symbol.for('undici.globalDispatcher.1') used by Node.js's bundled undici).
// The new Agent defaults allowH2 → true, so TLS ALPN negotiates h2.
// Undici v8's own fetch has a dispatchWithProtocolPreference fallback that
// retries with allowH2: false when Extended CONNECT is unavailable, but
// Node.js's bundled undici fetch does NOT have this fallback.
// retries with allowH2: false when Extended CONNECT is unavailable, but older
// versions of Undici bundled in Node.js do not have this fallback.
// As a result, globalThis.WebSocket (backed by the bundled undici) breaks
// when connecting to servers that advertise h2 but don't support RFC 8441.

Expand All @@ -27,7 +27,7 @@ process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'
require('../..')

test('globalThis.WebSocket connects to h2+http1.1 server after undici import', async (t) => {
const planner = tspl(t, { plan: 2 })
const planner = tspl(t, { plan: 3 })

// HTTP/2 server with HTTP/1.1 fallback.
// Advertises h2 in ALPN but does NOT enable Extended CONNECT (RFC 8441).
Expand All @@ -36,6 +36,7 @@ test('globalThis.WebSocket connects to h2+http1.1 server after undici import', a
const wsServer = new WebSocketServer({ noServer: true })

server.on('upgrade', (req, socket, head) => {
planner.strictEqual(socket.alpnProtocol, 'http/1.1')
wsServer.handleUpgrade(req, socket, head, (ws) => {
wsServer.emit('connection', ws, req)
})
Expand Down
Loading