Repository: nodejs/node
Pull request: inspector: support inspecting HTTP/2 request and response bodies

Now, we can track the request and response bodies of HTTP/2 calls through the Network tab of Chrome DevTools for Node.js.

Refs: https://github.com/nodejs/node/issues/53946

---

### Demo

https://github.com/user-attachments/assets/61ecac91-3af8-4757-ad14-cc6cf36ed656

<!--
Before submitting a pull request, please read:

- the CONTRIBUTING guide at https://github.com/nodejs/node/blob/HEAD/CONTRIBUTING.md
- the commit message formatting guidelines at
  https://github.com/nodejs/node/blob/HEAD/doc/contributing/pull-requests.md#commit-message-guidelines

For code changes:
1. Include tests for any bug fixes or new features.
2. Update documentation if relevant.
3. Ensure that `make -j4 test` (UNIX), or `vcbuild test` (Windows) passes.

If you believe this PR should be highlighted in the Node.js CHANGELOG
please add the `notable-change` label.

Developer's Certificate of Origin 1.1

By making a contribution to this project, I certify that:

(a) The contribution was created in whole or in part by me and I
    have the right to submit it under the open source license
    indicated in the file; or

(b) The contribution is based upon previous work that, to the best
    of my knowledge, is covered under an appropriate open source
    license and I have the right under that license to submit that
    work with modifications, whether created in whole or in part
    by me, under the same open source license (unless I am
    permitted to submit under a different license), as indicated
    in the file; or

(c) The contribution was provided directly to me by some other
    person who certified (a), (b) or (c) and I have not modified
    it.

(d) I understand and agree that this project and the contribution
    are public and that a record of the contribution (including all
    personal information I submit with it, including my sign-off) is
    maintained indefinitely and may be redistributed consistent with
    this project or the open source license(s) involved.
-->


Changed files:
filename: lib/internal/inspector/network_http2.js
patch: @@ -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],
 ]);

filename: test/parallel/test-inspector-network-http2.js
patch: @@ -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]