Skip to content

Commit a434502

Browse files
authored
fix: native WebSocket over H2 server after undici import (#4990)
* 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: #4989 Signed-off-by: Matteo Collina <hello@matteocollina.com> * 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 <hello@matteocollina.com> --------- Signed-off-by: Matteo Collina <hello@matteocollina.com>
1 parent d3ea390 commit a434502

2 files changed

Lines changed: 86 additions & 0 deletions

File tree

lib/dispatcher/dispatcher1-wrapper.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,12 @@ class Dispatcher1Wrapper extends Dispatcher {
8686
}
8787

8888
dispatch (opts, handler) {
89+
// Legacy (v1) consumers do not support HTTP/2, so force HTTP/1.1.
90+
// See https://github.com/nodejs/undici/issues/4989
91+
if (opts.allowH2 !== false) {
92+
opts = { ...opts, allowH2: false }
93+
}
94+
8995
return this.#dispatcher.dispatch(opts, Dispatcher1Wrapper.wrapHandler(handler))
9096
}
9197

test/websocket/issue-4989.js

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
'use strict'
2+
3+
// Regression test for https://github.com/nodejs/undici/issues/4989
4+
//
5+
// Importing undici v8 sets a global dispatcher (including the legacy
6+
// Symbol.for('undici.globalDispatcher.1') used by Node.js's bundled undici).
7+
// The new Agent defaults allowH2 → true, so TLS ALPN negotiates h2.
8+
// Undici v8's own fetch has a dispatchWithProtocolPreference fallback that
9+
// retries with allowH2: false when Extended CONNECT is unavailable, but
10+
// Node.js's bundled undici fetch does NOT have this fallback.
11+
// As a result, globalThis.WebSocket (backed by the bundled undici) breaks
12+
// when connecting to servers that advertise h2 but don't support RFC 8441.
13+
14+
const { test } = require('node:test')
15+
const { once } = require('node:events')
16+
const { createSecureServer } = require('node:http2')
17+
18+
const { tspl } = require('@matteo.collina/tspl')
19+
const { WebSocketServer } = require('ws')
20+
const { key, cert } = require('@metcoder95/https-pem')
21+
22+
// Self-signed certs require this since native WebSocket uses the
23+
// bundled dispatcher which has no rejectUnauthorized override.
24+
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'
25+
26+
// Importing undici sets the global dispatcher — this is what triggers the bug.
27+
require('../..')
28+
29+
test('globalThis.WebSocket connects to h2+http1.1 server after undici import', async (t) => {
30+
const planner = tspl(t, { plan: 2 })
31+
32+
// HTTP/2 server with HTTP/1.1 fallback.
33+
// Advertises h2 in ALPN but does NOT enable Extended CONNECT (RFC 8441).
34+
// WebSocket must fall back to HTTP/1.1 upgrade.
35+
const server = createSecureServer({ cert, key, allowHTTP1: true })
36+
const wsServer = new WebSocketServer({ noServer: true })
37+
38+
server.on('upgrade', (req, socket, head) => {
39+
wsServer.handleUpgrade(req, socket, head, (ws) => {
40+
wsServer.emit('connection', ws, req)
41+
})
42+
})
43+
44+
wsServer.on('connection', (ws) => {
45+
ws.send('hello')
46+
})
47+
48+
server.listen(0)
49+
await once(server, 'listening')
50+
51+
t.after(async () => {
52+
await new Promise((resolve) => wsServer.close(resolve))
53+
await new Promise((resolve) => server.close(resolve))
54+
})
55+
56+
// globalThis.WebSocket is Node.js's native WebSocket (backed by bundled undici).
57+
// It reads the global dispatcher set by the undici v8 import above.
58+
const ws = new globalThis.WebSocket(`wss://localhost:${server.address().port}`)
59+
60+
await Promise.race([
61+
new Promise((resolve, reject) => {
62+
ws.addEventListener('open', () => {
63+
planner.ok(true, 'connection opened')
64+
})
65+
ws.addEventListener('message', (evt) => {
66+
planner.strictEqual(evt.data, 'hello')
67+
ws.close()
68+
resolve()
69+
})
70+
ws.addEventListener('error', () => {
71+
reject(new Error('native WebSocket failed — global dispatcher h2 not falling back'))
72+
})
73+
}),
74+
new Promise((_resolve, reject) =>
75+
setTimeout(() => reject(new Error('Timeout after 5s')), 5000)
76+
)
77+
])
78+
79+
await planner.completed
80+
})

0 commit comments

Comments
 (0)