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
42 changes: 42 additions & 0 deletions doc/api/inspector.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions lib/inspector.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
37 changes: 37 additions & 0 deletions lib/internal/inspector/network_undici.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add a TODO to indicate that Network.webSocketCreated should be moved to an earlier time point when the WebSocket is created. Currently, undici:websocket:open is fired when the WebSocket connection is established, which means that the timing is later than expected, and the initiator stacktrace is inaccurate.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’ve added the TODO.

requestId: websocket[kInspectorRequestId],
url,
});
// TODO: Use handshake response data from undici diagnostics when available.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be helpful to link to nodejs/undici#4396 indicating when the handshake data will be available.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the review. I’ve added it.

// 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);
Expand All @@ -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() {
Expand All @@ -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 = {
Expand Down
87 changes: 87 additions & 0 deletions src/inspector/network_agent.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(&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,
Expand Down
7 changes: 7 additions & 0 deletions src/inspector/network_agent.h
Original file line number Diff line number Diff line change
Expand Up @@ -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_;
Expand Down
35 changes: 35 additions & 0 deletions src/inspector/node_protocol.pdl
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
105 changes: 105 additions & 0 deletions test/common/websocket-server.js
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading