inspector: initial support websocket inspection

Refs: https://github.com/nodejs/node/issues/53946
PR-URL: https://github.com/nodejs/node/pull/59404
Reviewed-By: Chengzhong Wu <legendecas@gmail.com>
This commit is contained in:
Shima Ryuhei 2025-08-19 19:09:14 +09:00 committed by GitHub
parent 3f51cb6229
commit ee9c8cf0cb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 409 additions and 1 deletions

View File

@ -602,6 +602,48 @@ This feature is only available with the `--experimental-network-inspection` flag
Broadcasts the `Network.loadingFailed` event to connected frontends. This event indicates that Broadcasts the `Network.loadingFailed` event to connected frontends. This event indicates that
HTTP request has failed to load. HTTP request has failed to load.
### `inspector.Network.webSocketCreated([params])`
<!-- YAML
added:
- REPLACEME
-->
* `params` {Object}
This feature is only available with the `--experimental-network-inspection` flag enabled.
Broadcasts the `Network.webSocketCreated` event to connected frontends. This event indicates that
a WebSocket connection has been initiated.
### `inspector.Network.webSocketHandshakeResponseReceived([params])`
<!-- YAML
added:
- REPLACEME
-->
* `params` {Object}
This feature is only available with the `--experimental-network-inspection` flag enabled.
Broadcasts the `Network.webSocketHandshakeResponseReceived` event to connected frontends.
This event indicates that the WebSocket handshake response has been received.
### `inspector.Network.webSocketClosed([params])`
<!-- YAML
added:
- REPLACEME
-->
* `params` {Object}
This feature is only available with the `--experimental-network-inspection` flag enabled.
Broadcasts the `Network.webSocketClosed` event to connected frontends.
This event indicates that a WebSocket connection has been closed.
### `inspector.NetworkResources.put` ### `inspector.NetworkResources.put`
<!-- YAML <!-- YAML

View File

@ -219,6 +219,10 @@ const Network = {
loadingFailed: (params) => broadcastToFrontend('Network.loadingFailed', params), loadingFailed: (params) => broadcastToFrontend('Network.loadingFailed', params),
dataSent: (params) => broadcastToFrontend('Network.dataSent', params), dataSent: (params) => broadcastToFrontend('Network.dataSent', params),
dataReceived: (params) => broadcastToFrontend('Network.dataReceived', params), dataReceived: (params) => broadcastToFrontend('Network.dataReceived', params),
webSocketCreated: (params) => broadcastToFrontend('Network.webSocketCreated', params),
webSocketClosed: (params) => broadcastToFrontend('Network.webSocketClosed', params),
webSocketHandshakeResponseReceived:
(params) => broadcastToFrontend('Network.webSocketHandshakeResponseReceived', params),
}; };
const NetworkResources = { const NetworkResources = {

View File

@ -206,6 +206,39 @@ function onClientResponseFinish({ request }) {
}); });
} }
// TODO: Move Network.webSocketCreated to the actual creation time of the WebSocket.
// undici:websocket:open fires when the connection is established, but this results
// in an inaccurate stack trace.
function onWebSocketOpen({ websocket }) {
websocket[kInspectorRequestId] = getNextRequestId();
const url = websocket.url.toString();
Network.webSocketCreated({
requestId: websocket[kInspectorRequestId],
url,
});
// TODO: Use handshake response data from undici diagnostics when available.
// https://github.com/nodejs/undici/pull/4396
Network.webSocketHandshakeResponseReceived({
requestId: websocket[kInspectorRequestId],
timestamp: getMonotonicTime(),
response: {
status: 101,
statusText: 'Switching Protocols',
headers: {},
},
});
}
function onWebSocketClose({ websocket }) {
if (typeof websocket[kInspectorRequestId] !== 'string') {
return;
}
Network.webSocketClosed({
requestId: websocket[kInspectorRequestId],
timestamp: getMonotonicTime(),
});
}
function enable() { function enable() {
dc.subscribe('undici:request:create', onClientRequestStart); dc.subscribe('undici:request:create', onClientRequestStart);
dc.subscribe('undici:request:error', onClientRequestError); dc.subscribe('undici:request:error', onClientRequestError);
@ -214,6 +247,8 @@ function enable() {
dc.subscribe('undici:request:bodyChunkSent', onClientRequestBodyChunkSent); dc.subscribe('undici:request:bodyChunkSent', onClientRequestBodyChunkSent);
dc.subscribe('undici:request:bodySent', onClientRequestBodySent); dc.subscribe('undici:request:bodySent', onClientRequestBodySent);
dc.subscribe('undici:request:bodyChunkReceived', onClientRequestBodyChunkReceived); dc.subscribe('undici:request:bodyChunkReceived', onClientRequestBodyChunkReceived);
dc.subscribe('undici:websocket:open', onWebSocketOpen);
dc.subscribe('undici:websocket:close', onWebSocketClose);
} }
function disable() { function disable() {
@ -224,6 +259,8 @@ function disable() {
dc.unsubscribe('undici:request:bodyChunkSent', onClientRequestBodyChunkSent); dc.unsubscribe('undici:request:bodyChunkSent', onClientRequestBodyChunkSent);
dc.unsubscribe('undici:request:bodySent', onClientRequestBodySent); dc.unsubscribe('undici:request:bodySent', onClientRequestBodySent);
dc.unsubscribe('undici:request:bodyChunkReceived', onClientRequestBodyChunkReceived); dc.unsubscribe('undici:request:bodyChunkReceived', onClientRequestBodyChunkReceived);
dc.unsubscribe('undici:websocket:open', onWebSocketOpen);
dc.unsubscribe('undici:websocket:close', onWebSocketClose);
} }
module.exports = { module.exports = {

View File

@ -208,6 +208,35 @@ std::unique_ptr<protocol::Network::Response> createResponseFromObject(
.build(); .build();
} }
std::unique_ptr<protocol::Network::WebSocketResponse> createWebSocketResponse(
v8::Local<v8::Context> context, Local<Object> response) {
HandleScope handle_scope(context->GetIsolate());
int status;
if (!ObjectGetInt(context, response, "status").To(&status)) {
return {};
}
protocol::String statusText;
if (!ObjectGetProtocolString(context, response, "statusText")
.To(&statusText)) {
return {};
}
Local<Object> headers_obj;
if (!ObjectGetObject(context, response, "headers").ToLocal(&headers_obj)) {
return {};
}
std::unique_ptr<protocol::Network::Headers> headers =
createHeadersFromObject(context, headers_obj);
if (!headers) {
return {};
}
return protocol::Network::WebSocketResponse::create()
.setStatus(status)
.setStatusText(statusText)
.setHeaders(std::move(headers))
.build();
}
NetworkAgent::NetworkAgent( NetworkAgent::NetworkAgent(
NetworkInspector* inspector, NetworkInspector* inspector,
v8_inspector::V8Inspector* v8_inspector, v8_inspector::V8Inspector* v8_inspector,
@ -223,6 +252,64 @@ NetworkAgent::NetworkAgent(
event_notifier_map_["loadingFinished"] = &NetworkAgent::loadingFinished; event_notifier_map_["loadingFinished"] = &NetworkAgent::loadingFinished;
event_notifier_map_["dataSent"] = &NetworkAgent::dataSent; event_notifier_map_["dataSent"] = &NetworkAgent::dataSent;
event_notifier_map_["dataReceived"] = &NetworkAgent::dataReceived; event_notifier_map_["dataReceived"] = &NetworkAgent::dataReceived;
event_notifier_map_["webSocketCreated"] = &NetworkAgent::webSocketCreated;
event_notifier_map_["webSocketClosed"] = &NetworkAgent::webSocketClosed;
event_notifier_map_["webSocketHandshakeResponseReceived"] =
&NetworkAgent::webSocketHandshakeResponseReceived;
}
void NetworkAgent::webSocketCreated(v8::Local<v8::Context> context,
v8::Local<v8::Object> params) {
protocol::String request_id;
if (!ObjectGetProtocolString(context, params, "requestId").To(&request_id)) {
return;
}
protocol::String url;
if (!ObjectGetProtocolString(context, params, "url").To(&url)) {
return;
}
std::unique_ptr<protocol::Network::Initiator> initiator =
protocol::Network::Initiator::create()
.setType(protocol::Network::Initiator::TypeEnum::Script)
.setStack(
v8_inspector_->captureStackTrace(true)->buildInspectorObject(0))
.build();
frontend_->webSocketCreated(request_id, url, std::move(initiator));
}
void NetworkAgent::webSocketClosed(v8::Local<v8::Context> context,
v8::Local<v8::Object> params) {
protocol::String request_id;
if (!ObjectGetProtocolString(context, params, "requestId").To(&request_id)) {
return;
}
double timestamp;
if (!ObjectGetDouble(context, params, "timestamp").To(&timestamp)) {
return;
}
frontend_->webSocketClosed(request_id, timestamp);
}
void NetworkAgent::webSocketHandshakeResponseReceived(
v8::Local<v8::Context> context, v8::Local<v8::Object> params) {
protocol::String request_id;
if (!ObjectGetProtocolString(context, params, "requestId").To(&request_id)) {
return;
}
double timestamp;
if (!ObjectGetDouble(context, params, "timestamp").To(&timestamp)) {
return;
}
Local<Object> response_obj;
if (!ObjectGetObject(context, params, "response").ToLocal(&response_obj)) {
return;
}
auto response = createWebSocketResponse(context, response_obj);
if (!response) {
return;
}
frontend_->webSocketHandshakeResponseReceived(
request_id, timestamp, std::move(response));
} }
void NetworkAgent::emitNotification(v8::Local<v8::Context> context, void NetworkAgent::emitNotification(v8::Local<v8::Context> context,

View File

@ -93,6 +93,13 @@ class NetworkAgent : public protocol::Network::Backend {
void dataReceived(v8::Local<v8::Context> context, void dataReceived(v8::Local<v8::Context> context,
v8::Local<v8::Object> params); v8::Local<v8::Object> params);
void webSocketCreated(v8::Local<v8::Context> context,
v8::Local<v8::Object> params);
void webSocketClosed(v8::Local<v8::Context> context,
v8::Local<v8::Object> params);
void webSocketHandshakeResponseReceived(v8::Local<v8::Context> context,
v8::Local<v8::Object> params);
private: private:
NetworkInspector* inspector_; NetworkInspector* inspector_;
v8_inspector::V8Inspector* v8_inspector_; v8_inspector::V8Inspector* v8_inspector_;

View File

@ -185,6 +185,16 @@ experimental domain Network
boolean success boolean success
optional IO.StreamHandle stream optional IO.StreamHandle stream
# WebSocket response data.
type WebSocketResponse extends object
properties
# HTTP response status code.
integer status
# HTTP response status text.
string statusText
# HTTP response headers.
Headers headers
# Disables network tracking, prevents network events from being sent to the client. # Disables network tracking, prevents network events from being sent to the client.
command disable command disable
@ -285,6 +295,31 @@ experimental domain Network
integer encodedDataLength integer encodedDataLength
# Data that was received. # Data that was received.
experimental optional binary data experimental optional binary data
# Fired upon WebSocket creation.
event webSocketCreated
parameters
# Request identifier.
RequestId requestId
# WebSocket request URL.
string url
# Request initiator.
Initiator initiator
# Fired when WebSocket is closed.
event webSocketClosed
parameters
# Request identifier.
RequestId requestId
# Timestamp.
MonotonicTime timestamp
# Fired when WebSocket handshake response becomes available.
event webSocketHandshakeResponseReceived
parameters
# Request identifier.
RequestId requestId
# Timestamp.
MonotonicTime timestamp
# WebSocket response data.
WebSocketResponse response
# Support for inspecting node process state. # Support for inspecting node process state.
experimental domain NodeRuntime experimental domain NodeRuntime

View File

@ -0,0 +1,105 @@
'use strict';
const common = require('./index');
if (!common.hasCrypto)
common.skip('missing crypto');
const http = require('http');
const crypto = require('crypto');
class WebSocketServer {
constructor({
port = 0,
}) {
this.port = port;
this.server = http.createServer();
this.clients = new Set();
this.server.on('upgrade', this.handleUpgrade.bind(this));
}
start() {
return new Promise((resolve) => {
this.server.listen(this.port, () => {
this.port = this.server.address().port;
resolve();
});
}).catch((err) => {
console.error('Failed to start WebSocket server:', err);
});
}
handleUpgrade(req, socket, head) {
const key = req.headers['sec-websocket-key'];
const acceptKey = this.generateAcceptValue(key);
const responseHeaders = [
'HTTP/1.1 101 Switching Protocols',
'Upgrade: websocket',
'Connection: Upgrade',
`Sec-WebSocket-Accept: ${acceptKey}`,
];
socket.write(responseHeaders.join('\r\n') + '\r\n\r\n');
this.clients.add(socket);
socket.on('data', (buffer) => {
const opcode = buffer[0] & 0x0f;
if (opcode === 0x8) {
socket.end();
this.clients.delete(socket);
return;
}
socket.write(this.encodeMessage('Hello from server!'));
});
socket.on('close', () => {
this.clients.delete(socket);
});
socket.on('error', (err) => {
console.error('Socket error:', err);
this.clients.delete(socket);
});
}
generateAcceptValue(secWebSocketKey) {
return crypto
.createHash('sha1')
.update(secWebSocketKey + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11', 'binary')
.digest('base64');
}
decodeMessage(buffer) {
const secondByte = buffer[1];
const length = secondByte & 127;
const maskStart = 2;
const dataStart = maskStart + 4;
const masks = buffer.slice(maskStart, dataStart);
const data = buffer.slice(dataStart, dataStart + length);
const result = Buffer.alloc(length);
for (let i = 0; i < length; i++) {
result[i] = data[i] ^ masks[i % 4];
}
return result.toString();
}
encodeMessage(message) {
const msgBuffer = Buffer.from(message);
const length = msgBuffer.length;
const frame = [0x81];
if (length < 126) {
frame.push(length);
} else if (length < 65536) {
frame.push(126, (length >> 8) & 0xff, length & 0xff);
} else {
throw new Error('Message too long');
}
return Buffer.concat([Buffer.from(frame), msgBuffer]);
}
}
module.exports = WebSocketServer;

View File

@ -88,6 +88,34 @@ const EXPECTED_EVENTS = {
errorText: 'Failed to load resource' errorText: 'Failed to load resource'
} }
}, },
{
name: 'webSocketCreated',
params: {
requestId: 'websocket-id-1',
url: 'ws://example.com:8080',
}
},
{
name: 'webSocketHandshakeResponseReceived',
params: {
requestId: 'websocket-id-1',
response: {
status: 101,
statusText: 'Switching Protocols',
headers: {},
},
timestamp: 1000,
}
},
{
name: 'webSocketClosed',
params: {
requestId: 'websocket-id-1',
timestamp: 1000,
}
},
] ]
}; };
@ -124,7 +152,7 @@ const runAsyncTest = async () => {
continue; continue;
} }
session.on(`${domain}.${event.name}`, common.mustCall(({ params }) => { session.on(`${domain}.${event.name}`, common.mustCall(({ params }) => {
if (event.name === 'requestWillBeSent') { if (event.name === 'requestWillBeSent' || event.name === 'webSocketCreated') {
// Initiator is automatically captured and contains caller info. // Initiator is automatically captured and contains caller info.
// No need to validate it. // No need to validate it.
delete params.initiator; delete params.initiator;

View File

@ -0,0 +1,63 @@
// Flags: --inspect=0 --experimental-network-inspection
'use strict';
const common = require('../common');
if (!common.hasCrypto)
common.skip('missing crypto');
common.skipIfInspectorDisabled();
const assert = require('node:assert');
const { once } = require('node:events');
const WebSocketServer = require('../common/websocket-server');
const inspector = require('node:inspector/promises');
const dc = require('diagnostics_channel');
const session = new inspector.Session();
session.connect();
dc.channel('undici:websocket:socket_error').subscribe((message) => {
console.error('WebSocket error:', message);
});
function findFrameInInitiator(scriptName, initiator) {
const frame = initiator.stack.callFrames.find((it) => {
return it.url === scriptName;
});
return frame;
}
async function test() {
await session.post('Network.enable');
const server = new WebSocketServer({
responseError: true,
});
await server.start();
const url = `ws://127.0.0.1:${server.port}/`;
let requestId;
once(session, 'Network.webSocketCreated').then(common.mustCall(([message]) => {
assert.strictEqual(message.method, 'Network.webSocketCreated');
assert.strictEqual(message.params.url, url);
assert.ok(message.params.requestId);
assert.strictEqual(typeof message.params.initiator, 'object');
assert.strictEqual(message.params.initiator.type, 'script');
assert.ok(findFrameInInitiator('node:internal/deps/undici/undici', message.params.initiator));
requestId = message.params.requestId;
}));
once(session, 'Network.webSocketHandshakeResponseReceived').then(common.mustCall(([message]) => {
assert.strictEqual(message.params.requestId, requestId);
assert.strictEqual(message.params.response.status, 101);
assert.strictEqual(message.params.response.statusText, 'Switching Protocols');
assert.strictEqual(typeof message.params.timestamp, 'number');
socket.close();
}));
const socket = new WebSocket(url);
once(session, 'Network.webSocketClosed').then(common.mustCall(([message]) => {
assert.strictEqual(message.method, 'Network.webSocketClosed');
assert.strictEqual(message.params.requestId, requestId);
session.disconnect();
server.server.close();
}));
}
test().then(common.mustCall());