mirror of
https://github.com/zebrajr/node.git
synced 2025-12-06 00:20:08 +01:00
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:
parent
3f51cb6229
commit
ee9c8cf0cb
|
|
@ -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
|
||||
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`
|
||||
|
||||
<!-- YAML
|
||||
|
|
|
|||
|
|
@ -219,6 +219,10 @@ const Network = {
|
|||
loadingFailed: (params) => broadcastToFrontend('Network.loadingFailed', params),
|
||||
dataSent: (params) => broadcastToFrontend('Network.dataSent', 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 = {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
dc.subscribe('undici:request:create', onClientRequestStart);
|
||||
dc.subscribe('undici:request:error', onClientRequestError);
|
||||
|
|
@ -214,6 +247,8 @@ function enable() {
|
|||
dc.subscribe('undici:request:bodyChunkSent', onClientRequestBodyChunkSent);
|
||||
dc.subscribe('undici:request:bodySent', onClientRequestBodySent);
|
||||
dc.subscribe('undici:request:bodyChunkReceived', onClientRequestBodyChunkReceived);
|
||||
dc.subscribe('undici:websocket:open', onWebSocketOpen);
|
||||
dc.subscribe('undici:websocket:close', onWebSocketClose);
|
||||
}
|
||||
|
||||
function disable() {
|
||||
|
|
@ -224,6 +259,8 @@ function disable() {
|
|||
dc.unsubscribe('undici:request:bodyChunkSent', onClientRequestBodyChunkSent);
|
||||
dc.unsubscribe('undici:request:bodySent', onClientRequestBodySent);
|
||||
dc.unsubscribe('undici:request:bodyChunkReceived', onClientRequestBodyChunkReceived);
|
||||
dc.unsubscribe('undici:websocket:open', onWebSocketOpen);
|
||||
dc.unsubscribe('undici:websocket:close', onWebSocketClose);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
|
|
|||
|
|
@ -208,6 +208,35 @@ std::unique_ptr<protocol::Network::Response> createResponseFromObject(
|
|||
.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(
|
||||
NetworkInspector* inspector,
|
||||
v8_inspector::V8Inspector* v8_inspector,
|
||||
|
|
@ -223,6 +252,64 @@ NetworkAgent::NetworkAgent(
|
|||
event_notifier_map_["loadingFinished"] = &NetworkAgent::loadingFinished;
|
||||
event_notifier_map_["dataSent"] = &NetworkAgent::dataSent;
|
||||
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(×tamp)) {
|
||||
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(×tamp)) {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -93,6 +93,13 @@ class NetworkAgent : public protocol::Network::Backend {
|
|||
void dataReceived(v8::Local<v8::Context> context,
|
||||
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:
|
||||
NetworkInspector* inspector_;
|
||||
v8_inspector::V8Inspector* v8_inspector_;
|
||||
|
|
|
|||
|
|
@ -185,6 +185,16 @@ experimental domain Network
|
|||
boolean success
|
||||
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.
|
||||
command disable
|
||||
|
||||
|
|
@ -285,6 +295,31 @@ experimental domain Network
|
|||
integer encodedDataLength
|
||||
# Data that was received.
|
||||
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.
|
||||
experimental domain NodeRuntime
|
||||
|
|
|
|||
105
test/common/websocket-server.js
Normal file
105
test/common/websocket-server.js
Normal 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;
|
||||
|
|
@ -88,6 +88,34 @@ const EXPECTED_EVENTS = {
|
|||
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;
|
||||
}
|
||||
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.
|
||||
// No need to validate it.
|
||||
delete params.initiator;
|
||||
|
|
|
|||
63
test/parallel/test-inspector-network-websocket.js
Normal file
63
test/parallel/test-inspector-network-websocket.js
Normal 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());
|
||||
Loading…
Reference in New Issue
Block a user