From 584ca8b5a63abc71652298681d1e88cebd80c67f Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Sun, 13 Sep 2026 13:16:36 +0000 Subject: [PATCH] fix: preserve HTTP/2 for legacy fetch consumers --- lib/dispatcher/dispatcher1-wrapper.js | 5 +- test/fixtures/global-fetch-h2-only.js | 61 +++++++++++++++++++++ test/node-test/global-dispatcher-version.js | 35 ++++++++++++ test/websocket/issue-4989.js | 7 ++- 4 files changed, 103 insertions(+), 5 deletions(-) create mode 100644 test/fixtures/global-fetch-h2-only.js diff --git a/lib/dispatcher/dispatcher1-wrapper.js b/lib/dispatcher/dispatcher1-wrapper.js index e206984d5a4..c39ed75e2e6 100644 --- a/lib/dispatcher/dispatcher1-wrapper.js +++ b/lib/dispatcher/dispatcher1-wrapper.js @@ -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 } } diff --git a/test/fixtures/global-fetch-h2-only.js b/test/fixtures/global-fetch-h2-only.js new file mode 100644 index 00000000000..ea0558bb277 --- /dev/null +++ b/test/fixtures/global-fetch-h2-only.js @@ -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 +}) diff --git a/test/node-test/global-dispatcher-version.js b/test/node-test/global-dispatcher-version.js index 01fafb15a4b..1f55243982b 100644 --- a/test/node-test/global-dispatcher-version.js +++ b/test/node-test/global-dispatcher-version.js @@ -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 @@ -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') @@ -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') @@ -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') diff --git a/test/websocket/issue-4989.js b/test/websocket/issue-4989.js index 8e521620c31..f5630a99da9 100644 --- a/test/websocket/issue-4989.js +++ b/test/websocket/issue-4989.js @@ -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. @@ -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). @@ -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) })