From 6ecbeb7fd29ff7f83464e03c25dff4faa052d91a Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Tue, 7 Apr 2026 17:58:40 +0200 Subject: [PATCH 1/2] fix: native WebSocket over H2 server after undici import (#4989) Importing undici v8 overwrites the legacy global dispatcher (Symbol.for('undici.globalDispatcher.1')) used by Node.js's bundled undici. The new Agent defaults to allowH2: true, causing ALPN to negotiate h2. Undici v8's fetch has a fallback (dispatchWithProtocolPreference) that retries with HTTP/1.1 when Extended CONNECT is unavailable, but the bundled undici does not. Fix Dispatcher1Wrapper.dispatch() to force allowH2: false for upgrade requests, since legacy consumers cannot handle the H2 WebSocket fallback. Fixes: https://github.com/nodejs/undici/issues/4989 Signed-off-by: Matteo Collina --- lib/dispatcher/dispatcher1-wrapper.js | 6 +++ test/websocket/issue-4989.js | 76 +++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 test/websocket/issue-4989.js diff --git a/lib/dispatcher/dispatcher1-wrapper.js b/lib/dispatcher/dispatcher1-wrapper.js index b5b69219dd4..71fc88e95c6 100644 --- a/lib/dispatcher/dispatcher1-wrapper.js +++ b/lib/dispatcher/dispatcher1-wrapper.js @@ -86,6 +86,12 @@ class Dispatcher1Wrapper extends Dispatcher { } dispatch (opts, handler) { + // Legacy (v1) consumers lack the H2 WebSocket fallback + // (dispatchWithProtocolPreference) so force HTTP/1.1 for upgrades. + if (opts.upgrade) { + opts = { ...opts, allowH2: false } + } + return this.#dispatcher.dispatch(opts, Dispatcher1Wrapper.wrapHandler(handler)) } diff --git a/test/websocket/issue-4989.js b/test/websocket/issue-4989.js new file mode 100644 index 00000000000..c988fb811d6 --- /dev/null +++ b/test/websocket/issue-4989.js @@ -0,0 +1,76 @@ +'use strict' + +// Regression test for https://github.com/nodejs/undici/issues/4989 +// +// Importing undici v8 sets a global dispatcher (including the legacy +// 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. +// As a result, globalThis.WebSocket (backed by the bundled undici) breaks +// when connecting to servers that advertise h2 but don't support RFC 8441. + +const { test } = require('node:test') +const { once } = require('node:events') +const { createSecureServer } = require('node:http2') + +const { tspl } = require('@matteo.collina/tspl') +const { WebSocketServer } = require('ws') +const { key, cert } = require('@metcoder95/https-pem') + +// Importing undici sets the global dispatcher — this is what triggers the bug. +require('../..') + +test('globalThis.WebSocket connects to h2+http1.1 server after undici import', async (t) => { + const planner = tspl(t, { plan: 2 }) + + // HTTP/2 server with HTTP/1.1 fallback. + // Advertises h2 in ALPN but does NOT enable Extended CONNECT (RFC 8441). + // WebSocket must fall back to HTTP/1.1 upgrade. + const server = createSecureServer({ cert, key, allowHTTP1: true }) + const wsServer = new WebSocketServer({ noServer: true }) + + server.on('upgrade', (req, socket, head) => { + wsServer.handleUpgrade(req, socket, head, (ws) => { + wsServer.emit('connection', ws, req) + }) + }) + + wsServer.on('connection', (ws) => { + ws.send('hello') + }) + + server.listen(0) + await once(server, 'listening') + + t.after(async () => { + await new Promise((resolve) => wsServer.close(resolve)) + await new Promise((resolve) => server.close(resolve)) + }) + + // globalThis.WebSocket is Node.js's native WebSocket (backed by bundled undici). + // It reads the global dispatcher set by the undici v8 import above. + const ws = new globalThis.WebSocket(`wss://localhost:${server.address().port}`) + + await Promise.race([ + new Promise((resolve, reject) => { + ws.addEventListener('open', () => { + planner.ok(true, 'connection opened') + }) + ws.addEventListener('message', (evt) => { + planner.strictEqual(evt.data, 'hello') + ws.close() + resolve() + }) + ws.addEventListener('error', () => { + reject(new Error('native WebSocket failed — global dispatcher h2 not falling back')) + }) + }), + new Promise((_resolve, reject) => + setTimeout(() => reject(new Error('Timeout after 5s')), 5000) + ) + ]) + + await planner.completed +}) From 25698294c6a9293b9a3f3ab63523a818a8453203 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Tue, 7 Apr 2026 20:56:22 +0200 Subject: [PATCH 2/2] fix: disable HTTP/2 for legacy (v1) dispatcher consumers Legacy (v1) consumers (like Node.js's bundled undici on Node 22) do not support HTTP/2. Force allowH2: false in Dispatcher1Wrapper so that the v1 global dispatcher always uses HTTP/1.1. Also add NODE_TLS_REJECT_UNAUTHORIZED for the regression test since native WebSocket uses the bundled dispatcher without rejectUnauthorized override. Signed-off-by: Matteo Collina --- lib/dispatcher/dispatcher1-wrapper.js | 6 +++--- test/websocket/issue-4989.js | 4 ++++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/dispatcher/dispatcher1-wrapper.js b/lib/dispatcher/dispatcher1-wrapper.js index 71fc88e95c6..f5813288cb3 100644 --- a/lib/dispatcher/dispatcher1-wrapper.js +++ b/lib/dispatcher/dispatcher1-wrapper.js @@ -86,9 +86,9 @@ class Dispatcher1Wrapper extends Dispatcher { } dispatch (opts, handler) { - // Legacy (v1) consumers lack the H2 WebSocket fallback - // (dispatchWithProtocolPreference) so force HTTP/1.1 for upgrades. - if (opts.upgrade) { + // Legacy (v1) consumers do not support HTTP/2, so force HTTP/1.1. + // See https://github.com/nodejs/undici/issues/4989 + if (opts.allowH2 !== false) { opts = { ...opts, allowH2: false } } diff --git a/test/websocket/issue-4989.js b/test/websocket/issue-4989.js index c988fb811d6..8e521620c31 100644 --- a/test/websocket/issue-4989.js +++ b/test/websocket/issue-4989.js @@ -19,6 +19,10 @@ const { tspl } = require('@matteo.collina/tspl') const { WebSocketServer } = require('ws') const { key, cert } = require('@metcoder95/https-pem') +// Self-signed certs require this since native WebSocket uses the +// bundled dispatcher which has no rejectUnauthorized override. +process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' + // Importing undici sets the global dispatcher — this is what triggers the bug. require('../..')