inspector: support undici traffic data inspection

Support undici sent and received data inspection in Chrome DevTools.

PR-URL: https://github.com/nodejs/node/pull/58953
Reviewed-By: Ryuhei Shima <shimaryuhei@gmail.com>
Reviewed-By: Moshe Atlow <moshe@atlow.co.il>
This commit is contained in:
Chengzhong Wu 2025-07-07 22:25:34 +01:00 committed by GitHub
parent a86765a35a
commit ba49d71dbf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 195 additions and 115 deletions

View File

@ -2,6 +2,7 @@
const {
NumberMAX_SAFE_INTEGER,
StringPrototypeToLowerCase,
Symbol,
} = primordials;
@ -52,8 +53,8 @@ function sniffMimeType(contentType) {
let charset;
try {
const mimeTypeObj = new MIMEType(contentType);
mimeType = mimeTypeObj.essence || '';
charset = mimeTypeObj.params.get('charset') || '';
mimeType = StringPrototypeToLowerCase(mimeTypeObj.essence || '');
charset = StringPrototypeToLowerCase(mimeTypeObj.params.get('charset') || '');
} catch {
mimeType = '';
charset = '';

View File

@ -2,6 +2,7 @@
const {
DateNow,
StringPrototypeToLowerCase,
} = primordials;
const {
@ -13,16 +14,25 @@ const {
} = require('internal/inspector/network');
const dc = require('diagnostics_channel');
const { Network } = require('inspector');
const { Buffer } = require('buffer');
// Convert an undici request headers array to a plain object (Map<string, string>)
function requestHeadersArrayToDictionary(headers) {
const dict = {};
let charset;
let mimeType;
for (let idx = 0; idx < headers.length; idx += 2) {
const key = `${headers[idx]}`;
const value = `${headers[idx + 1]}`;
dict[key] = value;
if (StringPrototypeToLowerCase(key) === 'content-type') {
const result = sniffMimeType(value);
charset = result.charset;
mimeType = result.mimeType;
}
}
return dict;
return [dict, charset, mimeType];
};
// Convert an undici response headers array to a plain object (Map<string, string>)
@ -32,7 +42,7 @@ function responseHeadersArrayToDictionary(headers) {
let mimeType;
for (let idx = 0; idx < headers.length; idx += 2) {
const key = `${headers[idx]}`;
const lowerCasedKey = key.toLowerCase();
const lowerCasedKey = StringPrototypeToLowerCase(key);
const value = `${headers[idx + 1]}`;
const prevValue = dict[key];
@ -63,8 +73,7 @@ function onClientRequestStart({ request }) {
const url = `${request.origin}${request.path}`;
request[kInspectorRequestId] = getNextRequestId();
const headers = requestHeadersArrayToDictionary(request.headers);
const { charset } = sniffMimeType(headers);
const { 0: headers, 1: charset } = requestHeadersArrayToDictionary(request.headers);
Network.requestWillBeSent({
requestId: request[kInspectorRequestId],
@ -74,7 +83,8 @@ function onClientRequestStart({ request }) {
request: {
url,
method: request.method,
headers: requestHeadersArrayToDictionary(request.headers),
headers: headers,
hasPostData: request.body != null,
},
});
}
@ -97,6 +107,40 @@ function onClientRequestError({ request, error }) {
});
}
/**
* When a chunk of the request body is being sent, cache it until `getRequestPostData` request.
* https://chromedevtools.github.io/devtools-protocol/1-3/Network/#method-getRequestPostData
* @param {{ request: undici.Request, chunk: Uint8Array | string }} event
*/
function onClientRequestBodyChunkSent({ request, chunk }) {
if (typeof request[kInspectorRequestId] !== 'string') {
return;
}
const buffer = Buffer.from(chunk);
Network.dataSent({
requestId: request[kInspectorRequestId],
timestamp: getMonotonicTime(),
dataLength: buffer.byteLength,
data: buffer,
});
}
/**
* Mark a request body as fully sent.
* @param {{request: undici.Request}} event
*/
function onClientRequestBodySent({ request }) {
if (typeof request[kInspectorRequestId] !== 'string') {
return;
}
Network.dataSent({
requestId: request[kInspectorRequestId],
finished: true,
});
}
/**
* When response headers are received, emit Network.responseReceived event.
* https://chromedevtools.github.io/devtools-protocol/1-3/Network/#event-responseReceived
@ -126,6 +170,27 @@ function onClientResponseHeaders({ request, response }) {
});
}
/**
* When a chunk of the response body has been received, cache it until `getResponseBody` request
* https://chromedevtools.github.io/devtools-protocol/1-3/Network/#method-getResponseBody or
* stream it with `streamResourceContent` request.
* https://chromedevtools.github.io/devtools-protocol/tot/Network/#method-streamResourceContent
* @param {{ request: undici.Request, chunk: Uint8Array | string }} event
*/
function onClientRequestBodyChunkReceived({ request, chunk }) {
if (typeof request[kInspectorRequestId] !== 'string') {
return;
}
Network.dataReceived({
requestId: request[kInspectorRequestId],
timestamp: getMonotonicTime(),
dataLength: chunk.byteLength,
encodedDataLength: chunk.byteLength,
data: chunk,
});
}
/**
* When a response is completed, emit Network.loadingFinished event.
* https://chromedevtools.github.io/devtools-protocol/1-3/Network/#event-loadingFinished
@ -146,6 +211,9 @@ function enable() {
dc.subscribe('undici:request:error', onClientRequestError);
dc.subscribe('undici:request:headers', onClientResponseHeaders);
dc.subscribe('undici:request:trailers', onClientResponseFinish);
dc.subscribe('undici:request:bodyChunkSent', onClientRequestBodyChunkSent);
dc.subscribe('undici:request:bodySent', onClientRequestBodySent);
dc.subscribe('undici:request:bodyChunkReceived', onClientRequestBodyChunkReceived);
}
function disable() {
@ -153,6 +221,9 @@ function disable() {
dc.unsubscribe('undici:request:error', onClientRequestError);
dc.unsubscribe('undici:request:headers', onClientResponseHeaders);
dc.unsubscribe('undici:request:trailers', onClientResponseFinish);
dc.unsubscribe('undici:request:bodyChunkSent', onClientRequestBodyChunkSent);
dc.unsubscribe('undici:request:bodySent', onClientRequestBodySent);
dc.unsubscribe('undici:request:bodyChunkReceived', onClientRequestBodyChunkReceived);
}
module.exports = {

View File

@ -5,6 +5,7 @@ const common = require('../common');
common.skipIfInspectorDisabled();
const assert = require('node:assert');
const { once } = require('node:events');
const { addresses } = require('../common/internet');
const fixtures = require('../common/fixtures');
const http = require('node:http');
@ -42,11 +43,19 @@ const setResponseHeaders = (res) => {
const handleRequest = (req, res) => {
const path = req.url;
switch (path) {
case '/hello-world':
case '/hello-world': {
setResponseHeaders(res);
res.writeHead(200);
res.end('hello world\n');
const chunks = [];
req.on('data', (chunk) => {
chunks.push(chunk);
});
req.on('end', () => {
assert.strictEqual(Buffer.concat(chunks).toString(), 'foobar');
res.writeHead(200);
res.end('hello world\n');
});
break;
}
default:
assert(false, `Unexpected path: ${path}`);
}
@ -73,127 +82,126 @@ function findFrameInInitiator(scriptName, initiator) {
return frame;
}
const testHttpGet = () => new Promise((resolve, reject) => {
session.on('Network.requestWillBeSent', common.mustCall(({ params }) => {
assert.ok(params.requestId.startsWith('node-network-event-'));
assert.strictEqual(params.request.url, `http://127.0.0.1:${httpServer.address().port}/hello-world`);
assert.strictEqual(params.request.method, 'GET');
assert.strictEqual(typeof params.request.headers, 'object');
assert.strictEqual(params.request.headers['accept-language'], 'en-US');
assert.strictEqual(params.request.headers.cookie, 'k1=v1; k2=v2');
assert.strictEqual(params.request.headers.age, '1000');
assert.strictEqual(params.request.headers['x-header1'], 'value1, value2');
assert.strictEqual(typeof params.timestamp, 'number');
assert.strictEqual(typeof params.wallTime, 'number');
function verifyRequestWillBeSent({ method, params }, expect) {
assert.strictEqual(method, 'Network.requestWillBeSent');
assert.strictEqual(typeof params.initiator, 'object');
assert.strictEqual(params.initiator.type, 'script');
assert.ok(findFrameInInitiator(__filename, params.initiator));
}));
session.on('Network.responseReceived', common.mustCall(({ params }) => {
assert.ok(params.requestId.startsWith('node-network-event-'));
assert.strictEqual(typeof params.timestamp, 'number');
assert.strictEqual(params.type, 'Fetch');
assert.strictEqual(params.response.status, 200);
assert.strictEqual(params.response.statusText, 'OK');
assert.strictEqual(params.response.url, `http://127.0.0.1:${httpServer.address().port}/hello-world`);
assert.strictEqual(typeof params.response.headers, 'object');
assert.strictEqual(params.response.headers.server, 'node');
assert.strictEqual(params.response.headers.etag, '12345');
assert.strictEqual(params.response.headers['Set-Cookie'], 'key1=value1\nkey2=value2');
assert.strictEqual(params.response.headers['x-header2'], 'value1, value2');
assert.strictEqual(params.response.mimeType, 'text/plain');
assert.strictEqual(params.response.charset, 'utf-8');
}));
session.on('Network.loadingFinished', common.mustCall(({ params }) => {
assert.ok(params.requestId.startsWith('node-network-event-'));
assert.strictEqual(typeof params.timestamp, 'number');
resolve();
}));
assert.ok(params.requestId.startsWith('node-network-event-'));
assert.strictEqual(params.request.url, expect.url);
assert.strictEqual(params.request.method, expect.method);
assert.strictEqual(typeof params.request.headers, 'object');
assert.strictEqual(params.request.headers['accept-language'], 'en-US');
assert.strictEqual(params.request.headers.cookie, 'k1=v1; k2=v2');
assert.strictEqual(params.request.headers.age, '1000');
assert.strictEqual(params.request.headers['x-header1'], 'value1, value2');
assert.strictEqual(typeof params.timestamp, 'number');
assert.strictEqual(typeof params.wallTime, 'number');
fetch(`http://127.0.0.1:${httpServer.address().port}/hello-world`, {
assert.strictEqual(typeof params.initiator, 'object');
assert.strictEqual(params.initiator.type, 'script');
assert.ok(findFrameInInitiator(__filename, params.initiator));
return params;
}
function verifyResponseReceived({ method, params }, expect) {
assert.strictEqual(method, 'Network.responseReceived');
assert.ok(params.requestId.startsWith('node-network-event-'));
assert.strictEqual(typeof params.timestamp, 'number');
assert.strictEqual(params.type, 'Fetch');
assert.strictEqual(params.response.status, 200);
assert.strictEqual(params.response.statusText, 'OK');
assert.strictEqual(params.response.url, expect.url);
assert.strictEqual(typeof params.response.headers, 'object');
assert.strictEqual(params.response.headers.server, 'node');
assert.strictEqual(params.response.headers.etag, '12345');
assert.strictEqual(params.response.headers['Set-Cookie'], 'key1=value1\nkey2=value2');
assert.strictEqual(params.response.headers['x-header2'], 'value1, value2');
assert.strictEqual(params.response.mimeType, 'text/plain');
assert.strictEqual(params.response.charset, 'utf-8');
return params;
}
function verifyLoadingFinished({ method, params }) {
assert.strictEqual(method, 'Network.loadingFinished');
assert.ok(params.requestId.startsWith('node-network-event-'));
assert.strictEqual(typeof params.timestamp, 'number');
return params;
}
function verifyLoadingFailed({ method, params }) {
assert.strictEqual(method, 'Network.loadingFailed');
assert.ok(params.requestId.startsWith('node-network-event-'));
assert.strictEqual(typeof params.timestamp, 'number');
assert.strictEqual(params.type, 'Fetch');
assert.strictEqual(typeof params.errorText, 'string');
}
async function testRequest(url) {
const requestWillBeSentFuture = once(session, 'Network.requestWillBeSent')
.then(([event]) => verifyRequestWillBeSent(event, { url, method: 'POST' }));
const responseReceivedFuture = once(session, 'Network.responseReceived')
.then(([event]) => verifyResponseReceived(event, { url }));
const loadingFinishedFuture = once(session, 'Network.loadingFinished')
.then(([event]) => verifyLoadingFinished(event));
await fetch(url, {
method: 'POST',
body: 'foobar',
headers: requestHeaders,
}).then(common.mustCall());
});
});
const testHttpsGet = () => new Promise((resolve, reject) => {
session.on('Network.requestWillBeSent', common.mustCall(({ params }) => {
assert.ok(params.requestId.startsWith('node-network-event-'));
assert.strictEqual(params.request.url, `https://127.0.0.1:${httpsServer.address().port}/hello-world`);
assert.strictEqual(params.request.method, 'GET');
assert.strictEqual(typeof params.request.headers, 'object');
assert.strictEqual(params.request.headers['accept-language'], 'en-US');
assert.strictEqual(params.request.headers.cookie, 'k1=v1; k2=v2');
assert.strictEqual(params.request.headers.age, '1000');
assert.strictEqual(params.request.headers['x-header1'], 'value1, value2');
assert.strictEqual(typeof params.timestamp, 'number');
assert.strictEqual(typeof params.wallTime, 'number');
}));
session.on('Network.responseReceived', common.mustCall(({ params }) => {
assert.ok(params.requestId.startsWith('node-network-event-'));
assert.strictEqual(typeof params.timestamp, 'number');
assert.strictEqual(params.type, 'Fetch');
assert.strictEqual(params.response.status, 200);
assert.strictEqual(params.response.statusText, 'OK');
assert.strictEqual(params.response.url, `https://127.0.0.1:${httpsServer.address().port}/hello-world`);
assert.strictEqual(typeof params.response.headers, 'object');
assert.strictEqual(params.response.headers.server, 'node');
assert.strictEqual(params.response.headers.etag, '12345');
assert.strictEqual(params.response.headers['Set-Cookie'], 'key1=value1\nkey2=value2');
assert.strictEqual(params.response.headers['x-header2'], 'value1, value2');
assert.strictEqual(params.response.mimeType, 'text/plain');
assert.strictEqual(params.response.charset, 'utf-8');
}));
session.on('Network.loadingFinished', common.mustCall(({ params }) => {
assert.ok(params.requestId.startsWith('node-network-event-'));
assert.strictEqual(typeof params.timestamp, 'number');
resolve();
}));
await requestWillBeSentFuture;
const responseReceived = await responseReceivedFuture;
const loadingFinished = await loadingFinishedFuture;
assert.ok(loadingFinished.timestamp >= responseReceived.timestamp);
fetch(`https://127.0.0.1:${httpsServer.address().port}/hello-world`, {
headers: requestHeaders,
}).then(common.mustCall());
});
const requestBody = await session.post('Network.getRequestPostData', {
requestId: responseReceived.requestId,
});
assert.strictEqual(requestBody.postData, 'foobar');
const testHttpError = () => new Promise((resolve, reject) => {
session.on('Network.requestWillBeSent', common.mustCall());
session.on('Network.loadingFailed', common.mustCall(({ params }) => {
assert.ok(params.requestId.startsWith('node-network-event-'));
assert.strictEqual(typeof params.timestamp, 'number');
assert.strictEqual(params.type, 'Fetch');
assert.strictEqual(typeof params.errorText, 'string');
resolve();
}));
const responseBody = await session.post('Network.getResponseBody', {
requestId: responseReceived.requestId,
});
assert.strictEqual(responseBody.base64Encoded, false);
assert.strictEqual(responseBody.body, 'hello world\n');
}
async function testRequestError(url) {
const requestWillBeSentFuture = once(session, 'Network.requestWillBeSent')
.then(([event]) => verifyRequestWillBeSent(event, { url, method: 'GET' }));
session.on('Network.responseReceived', common.mustNotCall());
session.on('Network.loadingFinished', common.mustNotCall());
fetch(`http://${addresses.INVALID_HOST}`).catch(common.mustCall());
});
const loadingFailedFuture = once(session, 'Network.loadingFailed')
.then(([event]) => verifyLoadingFailed(event));
fetch(url, {
headers: requestHeaders,
}).catch(common.mustCall());
const testHttpsError = () => new Promise((resolve, reject) => {
session.on('Network.requestWillBeSent', common.mustCall());
session.on('Network.loadingFailed', common.mustCall(({ params }) => {
assert.ok(params.requestId.startsWith('node-network-event-'));
assert.strictEqual(typeof params.timestamp, 'number');
assert.strictEqual(params.type, 'Fetch');
assert.strictEqual(typeof params.errorText, 'string');
resolve();
}));
session.on('Network.responseReceived', common.mustNotCall());
session.on('Network.loadingFinished', common.mustNotCall());
fetch(`https://${addresses.INVALID_HOST}`).catch(common.mustCall());
});
await requestWillBeSentFuture;
await loadingFailedFuture;
}
const testNetworkInspection = async () => {
await testHttpGet();
// HTTP
await testRequest(`http://127.0.0.1:${httpServer.address().port}/hello-world`);
session.removeAllListeners();
await testHttpsGet();
// HTTPS
await testRequest(`https://127.0.0.1:${httpsServer.address().port}/hello-world`);
session.removeAllListeners();
await testHttpError();
// HTTP with invalid host
await testRequestError(`http://${addresses.INVALID_HOST}/`);
session.removeAllListeners();
await testHttpsError();
// HTTPS with invalid host
await testRequestError(`https://${addresses.INVALID_HOST}/`);
session.removeAllListeners();
};