Skip to content

Commit c8ca16c

Browse files
committed
inspector: support inspecting HTTP/2 request and response bodies
Signed-off-by: Darshan Sen <[email protected]>
1 parent ba7cdf4 commit c8ca16c

File tree

5 files changed

+212
-10
lines changed

5 files changed

+212
-10
lines changed

doc/api/diagnostics_channel.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1243,6 +1243,19 @@ Emitted when an error occurs during the processing of a stream on the client.
12431243

12441244
Emitted when a stream is received on the client.
12451245

1246+
##### Event: `'http2.client.stream.bodyChunkSent'`
1247+
1248+
* `stream` {ClientHttp2Stream}
1249+
* `chunk` {Buffer}
1250+
1251+
Emitted when a chunk of the client stream body is being sent.
1252+
1253+
##### Event: `'http2.client.stream.bodySent'`
1254+
1255+
* `stream` {ClientHttp2Stream}
1256+
1257+
Emitted after the client stream body has been fully sent.
1258+
12461259
##### Event: `'http2.client.stream.close'`
12471260

12481261
* `stream` {ClientHttp2Stream}

lib/internal/http2/core.js

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,10 +186,14 @@ const { UV_EOF } = internalBinding('uv');
186186
const { StreamPipe } = internalBinding('stream_pipe');
187187
const { _connectionListener: httpConnectionListener } = http;
188188

189+
const { Buffer } = require('buffer');
190+
189191
const dc = require('diagnostics_channel');
190192
const onClientStreamCreatedChannel = dc.channel('http2.client.stream.created');
191193
const onClientStreamStartChannel = dc.channel('http2.client.stream.start');
192194
const onClientStreamErrorChannel = dc.channel('http2.client.stream.error');
195+
const onClientStreamBodyChunkSentChannel = dc.channel('http2.client.stream.bodyChunkSent');
196+
const onClientStreamBodySentChannel = dc.channel('http2.client.stream.bodySent');
193197
const onClientStreamFinishChannel = dc.channel('http2.client.stream.finish');
194198
const onClientStreamCloseChannel = dc.channel('http2.client.stream.close');
195199
const onServerStreamCreatedChannel = dc.channel('http2.server.stream.created');
@@ -2300,6 +2304,34 @@ class Http2Stream extends Duplex {
23002304
req = writeGeneric(this, data, encoding, writeCallback);
23012305

23022306
trackWriteState(this, req.bytes);
2307+
2308+
if (this.session[kType] === NGHTTP2_SESSION_CLIENT && onClientStreamBodyChunkSentChannel.hasSubscribers) {
2309+
let chunk;
2310+
2311+
if (ArrayIsArray(data)) {
2312+
const buffers = [];
2313+
for (let i = 0; i < data.length; ++i) {
2314+
if (typeof data[i] === 'object') {
2315+
if (typeof data[i].chunk === 'string') {
2316+
buffers.push(Buffer.from(data[i].chunk, data[i].encoding));
2317+
} else {
2318+
buffers.push(data[i].chunk);
2319+
}
2320+
}
2321+
}
2322+
2323+
chunk = Buffer.concat(buffers);
2324+
} else if (typeof data === 'string') {
2325+
chunk = Buffer.from(data);
2326+
} else {
2327+
chunk = data;
2328+
}
2329+
2330+
onClientStreamBodyChunkSentChannel.publish({
2331+
stream: this,
2332+
chunk,
2333+
});
2334+
}
23032335
}
23042336

23052337
_write(data, encoding, cb) {
@@ -2317,6 +2349,10 @@ class Http2Stream extends Duplex {
23172349
}
23182350
debugStreamObj(this, 'shutting down writable on _final');
23192351
ReflectApply(shutdownWritable, this, [cb]);
2352+
2353+
if (this.session[kType] === NGHTTP2_SESSION_CLIENT && onClientStreamBodySentChannel.hasSubscribers) {
2354+
onClientStreamBodySentChannel.publish({ stream: this });
2355+
}
23202356
}
23212357

23222358
_read(nread) {

lib/internal/inspector/network_http2.js

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ function onClientStreamCreated({ stream, headers }) {
9999
url,
100100
method,
101101
headers: convertedHeaderObject,
102+
hasPostData: !stream.writableEnded,
102103
},
103104
});
104105
}
@@ -121,6 +122,39 @@ function onClientStreamError({ stream, error }) {
121122
});
122123
}
123124

125+
/**
126+
* When a chunk of the request body is being sent, cache it until `getRequestPostData` request.
127+
* https://chromedevtools.github.io/devtools-protocol/1-3/Network/#method-getRequestPostData
128+
* @param {{ stream: import('http2').ClientHttp2Stream, chunk: Buffer }} event
129+
*/
130+
function onClientStreamBodyChunkSent({ stream, chunk }) {
131+
if (typeof stream[kInspectorRequestId] !== 'string') {
132+
return;
133+
}
134+
135+
Network.dataSent({
136+
requestId: stream[kInspectorRequestId],
137+
timestamp: getMonotonicTime(),
138+
dataLength: chunk.byteLength,
139+
data: chunk,
140+
});
141+
}
142+
143+
/**
144+
* Mark a request body as fully sent.
145+
* @param {{ stream: import('http2').ClientHttp2Stream }} event
146+
*/
147+
function onClientStreamBodySent({ stream }) {
148+
if (typeof stream[kInspectorRequestId] !== 'string') {
149+
return;
150+
}
151+
152+
Network.dataSent({
153+
requestId: stream[kInspectorRequestId],
154+
finished: true,
155+
});
156+
}
157+
124158
/**
125159
* When response headers are received, emit Network.responseReceived event.
126160
* https://chromedevtools.github.io/devtools-protocol/1-3/Network/#event-responseReceived
@@ -146,6 +180,23 @@ function onClientStreamFinish({ stream, headers }) {
146180
charset,
147181
},
148182
});
183+
184+
stream.on('data', (chunk) => {
185+
/**
186+
* When a chunk of the response body has been received, cache it until `getResponseBody` request
187+
* https://chromedevtools.github.io/devtools-protocol/1-3/Network/#method-getResponseBody or
188+
* stream it with `streamResourceContent` request.
189+
* https://chromedevtools.github.io/devtools-protocol/tot/Network/#method-streamResourceContent
190+
*/
191+
192+
Network.dataReceived({
193+
requestId: stream[kInspectorRequestId],
194+
timestamp: getMonotonicTime(),
195+
dataLength: chunk.byteLength,
196+
encodedDataLength: chunk.byteLength,
197+
data: chunk,
198+
});
199+
});
149200
}
150201

151202
/**
@@ -175,4 +226,6 @@ module.exports = registerDiagnosticChannels([
175226
['http2.client.stream.error', onClientStreamError],
176227
['http2.client.stream.finish', onClientStreamFinish],
177228
['http2.client.stream.close', onClientStreamClose],
229+
['http2.client.stream.bodyChunkSent', onClientStreamBodyChunkSent],
230+
['http2.client.stream.bodySent', onClientStreamBodySent],
178231
]);
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
'use strict';
2+
3+
const common = require('../common');
4+
if (!common.hasCrypto)
5+
common.skip('missing crypto');
6+
7+
// This test ensures that the built-in HTTP/2 diagnostics channels are reporting
8+
// the diagnostics messages for the 'http2.client.stream.bodyChunkSent' and
9+
// 'http2.client.stream.bodySent' channels when ClientHttp2Streams bodies are
10+
// being sent.
11+
12+
const assert = require('assert');
13+
const dc = require('diagnostics_channel');
14+
const http2 = require('http2');
15+
const { Duplex } = require('stream');
16+
17+
let bodyChunkSent = false;
18+
19+
dc.subscribe('http2.client.stream.bodyChunkSent', common.mustCall(({ stream, chunk }) => {
20+
// Since ClientHttp2Stream is not exported from any module, this just checks
21+
// if the stream is an instance of Duplex.
22+
assert.ok(stream instanceof Duplex);
23+
assert.strictEqual(stream.constructor.name, 'ClientHttp2Stream');
24+
25+
assert.strictEqual(Buffer.isBuffer(chunk), true);
26+
assert.strictEqual(chunk.toString(), 'foobarbaz');
27+
28+
bodyChunkSent = true;
29+
}));
30+
31+
dc.subscribe('http2.client.stream.bodySent', common.mustCall(({ stream }) => {
32+
// 'http2.client.stream.bodyChunkSent' must run first.
33+
assert.ok(bodyChunkSent);
34+
35+
// Since ClientHttp2Stream is not exported from any module, this just checks
36+
// if the stream is an instance of Duplex.
37+
assert.ok(stream instanceof Duplex);
38+
assert.strictEqual(stream.constructor.name, 'ClientHttp2Stream');
39+
}));
40+
41+
const server = http2.createServer();
42+
server.on('stream', common.mustCall((stream) => {
43+
stream.respond({}, { endStream: true });
44+
}));
45+
46+
server.listen(0, common.mustCall(() => {
47+
const port = server.address().port;
48+
const client = http2.connect(`http://localhost:${port}`);
49+
50+
const stream = client.request({ [http2.constants.HTTP2_HEADER_METHOD]: 'POST' });
51+
stream.write('foo');
52+
stream.write(Buffer.from('bar'));
53+
stream.write(new TextEncoder().encode('baz'));
54+
stream.end();
55+
56+
stream.on('response', common.mustCall(() => {
57+
client.close();
58+
server.close();
59+
}));
60+
}, 1));

test/parallel/test-inspector-network-http2.js

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,15 @@ const inspector = require('node:inspector/promises');
1414
const session = new inspector.Session();
1515
session.connect();
1616

17+
const requestBody = { 'hello': 'world' };
18+
1719
const requestHeaders = {
1820
'x-header1': ['value1', 'value2'],
1921
[http2.constants.HTTP2_HEADER_ACCEPT_LANGUAGE]: 'en-US',
2022
[http2.constants.HTTP2_HEADER_AGE]: 1000,
23+
[http2.constants.HTTP2_HEADER_CONTENT_TYPE]: 'application/json; charset=utf-8',
2124
[http2.constants.HTTP2_HEADER_COOKIE]: ['k1=v1', 'k2=v2'],
22-
[http2.constants.HTTP2_HEADER_METHOD]: 'GET',
25+
[http2.constants.HTTP2_HEADER_METHOD]: 'POST',
2326
[http2.constants.HTTP2_HEADER_PATH]: '/hello-world',
2427
};
2528

@@ -54,23 +57,35 @@ const pushResponseHeaders = {
5457
[http2.constants.HTTP2_HEADER_STATUS]: 200,
5558
};
5659

60+
const styleCss = 'body { color: red; }\n';
61+
const serverResponse = 'hello world\n';
62+
5763
const kTimeout = 1000;
5864
const kDelta = 200;
5965

6066
const handleStream = (stream, headers) => {
6167
const path = headers[http2.constants.HTTP2_HEADER_PATH];
68+
let body = '';
6269
switch (path) {
6370
case '/hello-world':
64-
stream.pushStream(pushRequestHeaders, common.mustSucceed((pushStream) => {
65-
pushStream.respond(pushResponseHeaders);
66-
pushStream.end('body { color: red; }\n');
67-
}));
71+
stream.on('data', (chunk) => {
72+
body += chunk;
73+
});
6874

69-
stream.respond(responseHeaders);
75+
stream.on('end', () => {
76+
assert.strictEqual(body, JSON.stringify(requestBody));
7077

71-
setTimeout(() => {
72-
stream.end('hello world\n');
73-
}, kTimeout);
78+
stream.pushStream(pushRequestHeaders, common.mustSucceed((pushStream) => {
79+
pushStream.respond(pushResponseHeaders);
80+
pushStream.end(styleCss);
81+
}));
82+
83+
stream.respond(responseHeaders);
84+
85+
setTimeout(() => {
86+
stream.end(serverResponse);
87+
}, kTimeout);
88+
});
7489
break;
7590
case '/trigger-error':
7691
stream.close(http2.constants.NGHTTP2_STREAM_CLOSED);
@@ -114,7 +129,6 @@ function verifyRequestWillBeSent({ method, params }, expectedUrl) {
114129

115130
assert.ok(params.requestId.startsWith('node-network-event-'));
116131
assert.strictEqual(params.request.url, expectedUrl);
117-
assert.strictEqual(params.request.method, 'GET');
118132
assert.strictEqual(typeof params.request.headers, 'object');
119133

120134
if (expectedUrl.endsWith('/hello-world')) {
@@ -123,10 +137,17 @@ function verifyRequestWillBeSent({ method, params }, expectedUrl) {
123137
assert.strictEqual(params.request.headers.age, '1000');
124138
assert.strictEqual(params.request.headers['x-header1'], 'value1, value2');
125139
assert.ok(findFrameInInitiator(__filename, params.initiator));
140+
assert.strictEqual(params.request.hasPostData, true);
141+
assert.strictEqual(params.request.method, 'POST');
126142
} else if (expectedUrl.endsWith('/style.css')) {
127143
assert.strictEqual(params.request.headers['x-header3'], 'value1, value2');
128144
assert.strictEqual(params.request.headers['x-push'], 'true');
129145
assert.ok(!findFrameInInitiator(__filename, params.initiator));
146+
assert.strictEqual(params.request.hasPostData, true);
147+
assert.strictEqual(params.request.method, 'GET');
148+
} else {
149+
assert.strictEqual(params.request.hasPostData, false);
150+
assert.strictEqual(params.request.method, 'GET');
130151
}
131152

132153
assert.strictEqual(typeof params.timestamp, 'number');
@@ -198,6 +219,8 @@ async function testHttp2(secure = false) {
198219
rejectUnauthorized: false,
199220
});
200221
const request = client.request(requestHeaders);
222+
request.write(JSON.stringify(requestBody));
223+
request.end();
201224

202225
// Dump the responses.
203226
request.on('data', () => {});
@@ -216,6 +239,11 @@ async function testHttp2(secure = false) {
216239
verifyRequestWillBeSent(mainRequest, url);
217240
verifyRequestWillBeSent(pushRequest, pushedUrl);
218241

242+
const { postData } = await session.post('Network.getRequestPostData', {
243+
requestId: mainRequest.params.requestId
244+
});
245+
assert.strictEqual(postData, JSON.stringify(requestBody));
246+
219247
const [
220248
{ value: [ mainResponse ] },
221249
{ value: [ pushResponse ] },
@@ -230,6 +258,18 @@ async function testHttp2(secure = false) {
230258
verifyLoadingFinished(event1);
231259
verifyLoadingFinished(event2);
232260

261+
const responseBody = await session.post('Network.getResponseBody', {
262+
requestId: mainRequest.params.requestId,
263+
});
264+
assert.strictEqual(responseBody.base64Encoded, false);
265+
assert.strictEqual(responseBody.body, serverResponse);
266+
267+
const pushResponseBody = await session.post('Network.getResponseBody', {
268+
requestId: pushRequest.params.requestId,
269+
});
270+
assert.strictEqual(pushResponseBody.base64Encoded, true);
271+
assert.strictEqual(Buffer.from(pushResponseBody.body, 'base64').toString(), styleCss);
272+
233273
const mainFinished = [event1, event2]
234274
.find((event) => event.params.requestId === mainResponse.params.requestId);
235275
const pushFinished = [event1, event2]

0 commit comments

Comments
 (0)