inspector: support inspecting HTTP/2 request and response bodies

Signed-off-by: Darshan Sen <raisinten@gmail.com>
PR-URL: https://github.com/nodejs/node/pull/60483
Refs: https://github.com/nodejs/node/issues/53946
Reviewed-By: Ryuhei Shima <shimaryuhei@gmail.com>
Reviewed-By: Chengzhong Wu <legendecas@gmail.com>
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
This commit is contained in:
Darshan Sen authored and GitHub committed 2025-11-04 08:35:00 +00:00
1 parent 76d6be5fc5
commit c63e88e9a6
2 files changed
+133 -10

No files matched your search

+83
View File
@@ -28,6 +28,8 @@ const {
HTTP2_HEADER_STATUS,
NGHTTP2_NO_ERROR,
} = internalBinding('http2').constants;
const EventEmitter = require('events');
const { Buffer } = require('buffer');
const kRequestUrl = Symbol('kRequestUrl');
@@ -99,6 +101,7 @@ function onClientStreamCreated({ stream, headers }) {
url,
method,
headers: convertedHeaderObject,
hasPostData: !stream.writableEnded,
},
});
}
@@ -121,6 +124,66 @@ function onClientStreamError({ stream, 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 {{
* stream: import('http2').ClientHttp2Stream,
* writev: boolean,
* data: Buffer | string | Array<Buffer | {chunk: Buffer|string, encoding: string}>,
* encoding: string,
* }} event
*/
function onClientStreamBodyChunkSent({ stream, writev, data, encoding }) {
if (typeof stream[kInspectorRequestId] !== 'string') {
return;
}
let chunk;
if (writev) {
if (data.allBuffers) {
chunk = Buffer.concat(data);
} else {
const buffers = [];
for (let i = 0; i < data.length; ++i) {
if (typeof data[i].chunk === 'string') {
buffers.push(Buffer.from(data[i].chunk, data[i].encoding));
} else {
buffers.push(data[i].chunk);
}
}
chunk = Buffer.concat(buffers);
}
} else if (typeof data === 'string') {
chunk = Buffer.from(data, encoding);
} else {
chunk = data;
}
Network.dataSent({
requestId: stream[kInspectorRequestId],
timestamp: getMonotonicTime(),
dataLength: chunk.byteLength,
data: chunk,
});
}
/**
* Mark a request body as fully sent.
* @param {{ stream: import('http2').ClientHttp2Stream }} event
*/
function onClientStreamBodySent({ stream }) {
if (typeof stream[kInspectorRequestId] !== 'string') {
return;
}
Network.dataSent({
requestId: stream[kInspectorRequestId],
finished: true,
});
}
/**
* When response headers are received, emit Network.responseReceived event.
* https://chromedevtools.github.io/devtools-protocol/1-3/Network/#event-responseReceived
@@ -146,6 +209,24 @@ function onClientStreamFinish({ stream, headers }) {
charset,
},
});
// Unlike stream.on('data', ...), this does not put the stream into flowing mode.
EventEmitter.prototype.on.call(stream, 'data', (chunk) => {
/**
* 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
*/
Network.dataReceived({
requestId: stream[kInspectorRequestId],
timestamp: getMonotonicTime(),
dataLength: chunk.byteLength,
encodedDataLength: chunk.byteLength,
data: chunk,
});
});
}
/**
@@ -175,4 +256,6 @@ module.exports = registerDiagnosticChannels([
['http2.client.stream.error', onClientStreamError],
['http2.client.stream.finish', onClientStreamFinish],
['http2.client.stream.close', onClientStreamClose],
['http2.client.stream.bodyChunkSent', onClientStreamBodyChunkSent],
['http2.client.stream.bodySent', onClientStreamBodySent],
]);
+50 -10
View File
@@ -14,12 +14,15 @@ const inspector = require('node:inspector/promises');
const session = new inspector.Session();
session.connect();
const requestBody = { 'hello': 'world' };
const requestHeaders = {
'x-header1': ['value1', 'value2'],
[http2.constants.HTTP2_HEADER_ACCEPT_LANGUAGE]: 'en-US',
[http2.constants.HTTP2_HEADER_AGE]: 1000,
[http2.constants.HTTP2_HEADER_CONTENT_TYPE]: 'application/json; charset=utf-8',
[http2.constants.HTTP2_HEADER_COOKIE]: ['k1=v1', 'k2=v2'],
[http2.constants.HTTP2_HEADER_METHOD]: 'GET',
[http2.constants.HTTP2_HEADER_METHOD]: 'POST',
[http2.constants.HTTP2_HEADER_PATH]: '/hello-world',
};
@@ -54,23 +57,35 @@ const pushResponseHeaders = {
[http2.constants.HTTP2_HEADER_STATUS]: 200,
};
const styleCss = 'body { color: red; }\n';
const serverResponse = 'hello world\n';
const kTimeout = 1000;
const kDelta = 200;
const handleStream = (stream, headers) => {
const path = headers[http2.constants.HTTP2_HEADER_PATH];
let body = '';
switch (path) {
case '/hello-world':
stream.pushStream(pushRequestHeaders, common.mustSucceed((pushStream) => {
pushStream.respond(pushResponseHeaders);
pushStream.end('body { color: red; }\n');
}));
stream.on('data', (chunk) => {
body += chunk;
});
stream.respond(responseHeaders);
stream.on('end', () => {
assert.strictEqual(body, JSON.stringify(requestBody));
setTimeout(() => {
stream.end('hello world\n');
}, kTimeout);
stream.pushStream(pushRequestHeaders, common.mustSucceed((pushStream) => {
pushStream.respond(pushResponseHeaders);
pushStream.end(styleCss);
}));
stream.respond(responseHeaders);
setTimeout(() => {
stream.end(serverResponse);
}, kTimeout);
});
break;
case '/trigger-error':
stream.close(http2.constants.NGHTTP2_STREAM_CLOSED);
@@ -114,7 +129,6 @@ function verifyRequestWillBeSent({ method, params }, expectedUrl) {
assert.ok(params.requestId.startsWith('node-network-event-'));
assert.strictEqual(params.request.url, expectedUrl);
assert.strictEqual(params.request.method, 'GET');
assert.strictEqual(typeof params.request.headers, 'object');
if (expectedUrl.endsWith('/hello-world')) {
@@ -123,10 +137,17 @@ function verifyRequestWillBeSent({ method, params }, expectedUrl) {
assert.strictEqual(params.request.headers.age, '1000');
assert.strictEqual(params.request.headers['x-header1'], 'value1, value2');
assert.ok(findFrameInInitiator(__filename, params.initiator));
assert.strictEqual(params.request.hasPostData, true);
assert.strictEqual(params.request.method, 'POST');
} else if (expectedUrl.endsWith('/style.css')) {
assert.strictEqual(params.request.headers['x-header3'], 'value1, value2');
assert.strictEqual(params.request.headers['x-push'], 'true');
assert.ok(!findFrameInInitiator(__filename, params.initiator));
assert.strictEqual(params.request.hasPostData, true);
assert.strictEqual(params.request.method, 'GET');
} else {
assert.strictEqual(params.request.hasPostData, false);
assert.strictEqual(params.request.method, 'GET');
}
assert.strictEqual(typeof params.timestamp, 'number');
@@ -198,6 +219,8 @@ async function testHttp2(secure = false) {
rejectUnauthorized: false,
});
const request = client.request(requestHeaders);
request.write(JSON.stringify(requestBody));
request.end();
// Dump the responses.
request.on('data', () => {});
@@ -216,6 +239,11 @@ async function testHttp2(secure = false) {
verifyRequestWillBeSent(mainRequest, url);
verifyRequestWillBeSent(pushRequest, pushedUrl);
const { postData } = await session.post('Network.getRequestPostData', {
requestId: mainRequest.params.requestId
});
assert.strictEqual(postData, JSON.stringify(requestBody));
const [
{ value: [ mainResponse ] },
{ value: [ pushResponse ] },
@@ -230,6 +258,18 @@ async function testHttp2(secure = false) {
verifyLoadingFinished(event1);
verifyLoadingFinished(event2);
const responseBody = await session.post('Network.getResponseBody', {
requestId: mainRequest.params.requestId,
});
assert.strictEqual(responseBody.base64Encoded, false);
assert.strictEqual(responseBody.body, serverResponse);
const pushResponseBody = await session.post('Network.getResponseBody', {
requestId: pushRequest.params.requestId,
});
assert.strictEqual(pushResponseBody.base64Encoded, true);
assert.strictEqual(Buffer.from(pushResponseBody.body, 'base64').toString(), styleCss);
const mainFinished = [event1, event2]
.find((event) => event.params.requestId === mainResponse.params.requestId);
const pushFinished = [event1, event2]