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
14 changes: 8 additions & 6 deletions docs/docs/api/DiagnosticsChannel.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
10 changes: 6 additions & 4 deletions lib/core/diagnostics.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
14 changes: 13 additions & 1 deletion lib/web/websocket/websocket.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
84 changes: 84 additions & 0 deletions test/websocket/diagnostics-channel-handshake-response-h2.js
Original file line number Diff line number Diff line change
@@ -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')
})
Loading