diff --git a/docs/docs/api/DiagnosticsChannel.md b/docs/docs/api/DiagnosticsChannel.md index acf25e08218..cff8546f6f0 100644 --- a/docs/docs/api/DiagnosticsChannel.md +++ b/docs/docs/api/DiagnosticsChannel.md @@ -182,22 +182,24 @@ diagnosticsChannel.channel('undici:websocket:open').subscribe(({ console.log(websocket) // the WebSocket instance // Handshake response details - console.log(handshakeResponse.status) // 101 for successful WebSocket upgrade - console.log(handshakeResponse.statusText) // 'Switching Protocols' + console.log(handshakeResponse.status) // 101 for HTTP/1.1, 200 for HTTP/2 extended CONNECT + console.log(handshakeResponse.statusText) // 'Switching Protocols' for HTTP/1.1, commonly 'OK' for HTTP/2 in Node.js console.log(handshakeResponse.headers) // Object containing response headers }) ``` ### Handshake Response Object -The `handshakeResponse` object contains the HTTP response that upgraded the connection to WebSocket: +The `handshakeResponse` object contains the HTTP response that established the WebSocket connection: -- `status` (number): The HTTP status code (101 for successful WebSocket upgrade) -- `statusText` (string): The HTTP status message ('Switching Protocols' for successful upgrade) +- `status` (number): The HTTP status code (`101` for HTTP/1.1 upgrade, `200` for HTTP/2 extended CONNECT) +- `statusText` (string): The HTTP status message (`'Switching Protocols'` for HTTP/1.1, commonly `'OK'` for HTTP/2 in Node.js) - `headers` (object): The HTTP response headers from the server, including: + - `sec-websocket-accept` and other WebSocket-related headers - `upgrade: 'websocket'` - `connection: 'upgrade'` - - `sec-websocket-accept` and other WebSocket-related headers + + The `upgrade` and `connection` headers are only present for HTTP/1.1 handshakes. This information is particularly useful for debugging and monitoring WebSocket connections, as it provides access to the initial HTTP handshake response that established the WebSocket connection. diff --git a/lib/core/diagnostics.js b/lib/core/diagnostics.js index ccd6870ca6d..454ab379be3 100644 --- a/lib/core/diagnostics.js +++ b/lib/core/diagnostics.js @@ -177,10 +177,12 @@ function trackWebSocketEvents (debugLog = websocketDebuglog) { diagnosticsChannel.subscribe('undici:websocket:open', evt => { - const { - address: { address, port } - } = evt - debugLog('connection opened %s%s', address, port ? `:${port}` : '') + if (evt.address != null) { + const { address, port } = evt.address + debugLog('connection opened %s%s', address, port ? `:${port}` : '') + } else { + debugLog('connection opened') + } }) diagnosticsChannel.subscribe('undici:websocket:close', diff --git a/lib/web/websocket/websocket.js b/lib/web/websocket/websocket.js index 64ead0d41c3..da94ab5b352 100644 --- a/lib/web/websocket/websocket.js +++ b/lib/web/websocket/websocket.js @@ -25,6 +25,18 @@ const { SendQueue } = require('./sender') const { WebsocketFrameSend } = require('./frame') const { channels } = require('../../core/diagnostics') +function getSocketAddress (socket) { + if (typeof socket?.address === 'function') { + return socket.address() + } + + if (typeof socket?.session?.socket?.address === 'function') { + return socket.session.socket.address() + } + + return null +} + /** * @typedef {object} Handler * @property {(response: any, extensions?: string[]) => void} onConnectionEstablished @@ -491,7 +503,7 @@ class WebSocket extends EventTarget { // Convert headers to a plain object for the event const headers = response.headersList.entries channels.open.publish({ - address: response.socket.address(), + address: getSocketAddress(response.socket), protocol: this.#protocol, extensions: this.#extensions, websocket: this, diff --git a/test/websocket/diagnostics-channel-handshake-response-h2.js b/test/websocket/diagnostics-channel-handshake-response-h2.js new file mode 100644 index 00000000000..89abd69530d --- /dev/null +++ b/test/websocket/diagnostics-channel-handshake-response-h2.js @@ -0,0 +1,84 @@ +'use strict' + +const { test } = require('node:test') +const dc = require('node:diagnostics_channel') +const { once } = require('node:events') +const { createSecureServer } = require('node:http2') +const { WebSocketServer, WebSocket: WSWebsocket } = require('ws') +const { key, cert } = require('@metcoder95/https-pem') +const { Agent, WebSocket } = require('../..') +const { uid } = require('../../lib/web/websocket/constants') +const { runtimeFeatures } = require('../../lib/util/runtime-features') + +const crypto = runtimeFeatures.has('crypto') + ? require('node:crypto') + : null + +test('diagnostics channel - undici:websocket:open includes handshake response over h2', { skip: crypto == null }, async (t) => { + t.plan(9) + + const server = createSecureServer({ cert, key, settings: { enableConnectProtocol: true } }) + const wsServer = new WebSocketServer({ noServer: true }) + + server.on('stream', (stream, headers) => { + stream.respond({ + ':status': 200, + 'sec-websocket-accept': crypto.hash('sha1', `${headers['sec-websocket-key']}${uid}`, 'base64') + }) + + const ws = new WSWebsocket(null, null, { autoPong: true }) + ws.setSocket(stream, Buffer.alloc(0), { + maxPayload: 104857600, + skipUTF8Validation: false + }) + + wsServer.emit('connection', ws, stream) + }) + + wsServer.on('connection', (ws) => { + setTimeout(() => { + ws.close(1000, 'test') + }, 50) + }) + + server.listen(0) + await once(server, 'listening') + + const dispatcher = new Agent({ + allowH2: true, + connect: { + rejectUnauthorized: false + } + }) + + const openListener = (data) => { + t.assert.ok(data.address, 'address should be defined') + t.assert.strictEqual(typeof data.address.address, 'string', 'address.address should be a string') + t.assert.strictEqual(typeof data.address.port, 'number', 'address.port should be a number') + t.assert.ok(data.handshakeResponse, 'handshakeResponse should be defined') + t.assert.strictEqual(data.handshakeResponse.status, 200, 'status should be 200') + t.assert.strictEqual(data.handshakeResponse.statusText, 'OK', 'statusText should be OK for h2') + t.assert.ok(data.handshakeResponse.headers, 'headers should be defined') + t.assert.ok(typeof data.handshakeResponse.headers === 'object', 'headers should be an object') + t.assert.ok(typeof data.handshakeResponse.headers['sec-websocket-accept'] === 'string', 'sec-websocket-accept header should be a string') + } + + dc.channel('undici:websocket:open').subscribe(openListener) + + const ws = new WebSocket(`wss://localhost:${server.address().port}`, { dispatcher }) + + t.after(async () => { + dc.channel('undici:websocket:open').unsubscribe(openListener) + server.close() + await dispatcher.close() + await new Promise((resolve) => wsServer.close(resolve)) + }) + + await Promise.race([ + once(ws, 'open'), + once(ws, 'error').then(([event]) => { + throw event.error ?? new Error('unexpected websocket error') + }) + ]) + await once(ws, 'close') +})