Repository: nodejs/node
Pull request: inspector: add http2 tracking support

This allows tracking HTTP/2 calls through the Network tab of Chrome DevTools for Node.js.

Refs: https://github.com/nodejs/node/issues/53946

---

Text for release notes:

### HTTP/2 Network Inspection Support in Node.js

Node.js now supports inspection of HTTP/2 network calls in Chrome DevTools for Node.js.

#### Usage

Write a `test.js` script that makes HTTP/2 requests.
```js
const http2 = require('http2');

const client = http2.connect('https://nghttp2.org');

const req = client.request([
  ':path', '/',
  ':method', 'GET',
]);
```

Run it with these options:
```sh
$ node --inspect-wait --experimental-network-inspection test.js
```

Open `about:inspect` on Google Chrome and click on `Open dedicated DevTools for Node`.
The `Network` tab will let you track your HTTP/2 calls.

<img width="721" height="808" alt="demo" src="https://github.com/user-attachments/assets/0da1009c-f732-40df-bedf-3003f7569ac2" />

Contributed by Darshan Sen in https://github.com/nodejs/node/pull/59611

<!--
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: @@ -0,0 +1,190 @@
+'use strict';
+
+const {
+  ArrayIsArray,
+  DateNow,
+  ObjectEntries,
+  String,
+  Symbol,
+} = primordials;
+
+const {
+  kInspectorRequestId,
+  kResourceType,
+  getMonotonicTime,
+  getNextRequestId,
+  sniffMimeType,
+} = require('internal/inspector/network');
+const dc = require('diagnostics_channel');
+const { Network } = require('inspector');
+const {
+  HTTP2_HEADER_AUTHORITY,
+  HTTP2_HEADER_CONTENT_TYPE,
+  HTTP2_HEADER_COOKIE,
+  HTTP2_HEADER_METHOD,
+  HTTP2_HEADER_PATH,
+  HTTP2_HEADER_SCHEME,
+  HTTP2_HEADER_SET_COOKIE,
+  HTTP2_HEADER_STATUS,
+  NGHTTP2_NO_ERROR,
+} = internalBinding('http2').constants;
+
+const kRequestUrl = Symbol('kRequestUrl');
+
+// Convert a Headers object (Map<string, number | string | string[]>) to a plain object (Map<string, string>)
+function convertHeaderObject(headers = {}) {
+  let scheme;
+  let authority;
+  let path;
+  let method;
+  let statusCode;
+  let charset;
+  let mimeType;
+  const dict = {};
+
+  for (const { 0: key, 1: value } of ObjectEntries(headers)) {
+    const lowerCasedKey = key.toLowerCase();
+
+    if (lowerCasedKey === HTTP2_HEADER_SCHEME) {
+      scheme = value;
+    } else if (lowerCasedKey === HTTP2_HEADER_AUTHORITY) {
+      authority = value;
+    } else if (lowerCasedKey === HTTP2_HEADER_PATH) {
+      path = value;
+    } else if (lowerCasedKey === HTTP2_HEADER_METHOD) {
+      method = value;
+    } else if (lowerCasedKey === HTTP2_HEADER_STATUS) {
+      statusCode = value;
+    } else if (lowerCasedKey === HTTP2_HEADER_CONTENT_TYPE) {
+      const result = sniffMimeType(value);
+      charset = result.charset;
+      mimeType = result.mimeType;
+    }
+
+    if (typeof value === 'string') {
+      dict[key] = value;
+    } else if (ArrayIsArray(value)) {
+      if (lowerCasedKey === HTTP2_HEADER_COOKIE) dict[key] = value.join('; ');
+      // ChromeDevTools frontend treats 'set-cookie' as a special case
+      // https://github.com/ChromeDevTools/devtools-frontend/blob/4275917f84266ef40613db3c1784a25f902ea74e/front_end/core/sdk/NetworkRequest.ts#L1368
+      else if (lowerCasedKey === HTTP2_HEADER_SET_COOKIE) dict[key] = value.join('\n');
+      else dict[key] = value.join(', ');
+    } else {
+      dict[key] = String(value);
+    }
+  }
+
+  const url = `${scheme}://${authority}${path}`;
+
+  return [dict, url, method, statusCode, charset, mimeType];
+}
+
+/**
+ * When a client stream is created, emit Network.requestWillBeSent event.
+ * https://chromedevtools.github.io/devtools-protocol/1-3/Network/#event-requestWillBeSent
+ * @param {{ stream: import('http2').ClientHttp2Stream, headers: object }} event
+ */
+function onClientStreamCreated({ stream, headers }) {
+  stream[kInspectorRequestId] = getNextRequestId();
+
+  const { 0: convertedHeaderObject, 1: url, 2: method, 4: charset } = convertHeaderObject(headers);
+  stream[kRequestUrl] = url;
+
+  Network.requestWillBeSent({
+    requestId: stream[kInspectorRequestId],
+    timestamp: getMonotonicTime(),
+    wallTime: DateNow(),
+    charset,
+    request: {
+      url,
+      method,
+      headers: convertedHeaderObject,
+    },
+  });
+}
+
+/**
+ * When a client stream errors, emit Network.loadingFailed event.
+ * https://chromedevtools.github.io/devtools-protocol/1-3/Network/#event-loadingFailed
+ * @param {{ stream: import('http2').ClientHttp2Stream, error: any }} event
+ */
+function onClientStreamError({ stream, error }) {
+  if (typeof stream[kInspectorRequestId] !== 'string') {
+    return;
+  }
+
+  Network.loadingFailed({
+    requestId: stream[kInspectorRequestId],
+    timestamp: getMonotonicTime(),
+    type: kResourceType.Other,
+    errorText: error.message,
+  });
+}
+
+/**
+ * When response headers are received, emit Network.responseReceived event.
+ * https://chromedevtools.github.io/devtools-protocol/1-3/Network/#event-responseReceived
+ * @param {{ stream: import('http2').ClientHttp2Stream, headers: object }} event
+ */
+function onClientStreamFinish({ stream, headers }) {
+  if (typeof stream[kInspectorRequestId] !== 'string') {
+    return;
+  }
+
+  const { 0: convertedHeaderObject, 3: statusCode, 4: charset, 5: mimeType } = convertHeaderObject(headers);
+
+  Network.responseReceived({
+    requestId: stream[kInspectorRequestId],
+    timestamp: getMonotonicTime(),
+    type: kResourceType.Other,
+    response: {
+      url: stream[kRequestUrl],
+      status: statusCode,
+      statusText: '',
+      headers: convertedHeaderObject,
+      mimeType,
+      charset,
+    },
+  });
+}
+
+/**
+ * When user code completes consuming the response body, emit Network.loadingFinished event.
+ * https://chromedevtools.github.io/devtools-protocol/1-3/Network/#event-loadingFinished
+ * @param {{ stream: import('http2').ClientHttp2Stream }} event
+ */
+function onClientStreamClose({ stream }) {
+  if (typeof stream[kInspectorRequestId] !== 'string') {
+    return;
+  }
+
+  if (stream.rstCode !== NGHTTP2_NO_ERROR) {
+    // This is an error case, so only Network.loadingFailed should be emitted
+    // which is already done by onClientStreamError().
+    return;
+  }
+
+  Network.loadingFinished({
+    requestId: stream[kInspectorRequestId],
+    timestamp: getMonotonicTime(),
+  });
+}
+
+function enable() {
+  dc.subscribe('http2.client.stream.created', onClientStreamCreated);
+  dc.subscribe('http2.client.stream.error', onClientStreamError);
+  dc.subscribe('http2.client.stream.finish', onClientStreamFinish);
+  dc.subscribe('http2.client.stream.close', onClientStreamClose);
+}
+
+function disable() {
+  dc.unsubscribe('http2.client.stream.created', onClientStreamCreated);
+  dc.unsubscribe('http2.client.stream.error', onClientStreamError);
+  dc.unsubscribe('http2.client.stream.finish', onClientStreamFinish);
+  dc.unsubscribe('http2.client.stream.close', onClientStreamClose);
+}
+
+module.exports = {
+  enable,
+  disable,
+};

filename: lib/internal/inspector_network_tracking.js
patch: @@ -2,11 +2,13 @@
 
 function enable() {
   require('internal/inspector/network_http').enable();
+  require('internal/inspector/network_http2').enable();
   require('internal/inspector/network_undici').enable();
 }
 
 function disable() {
   require('internal/inspector/network_http').disable();
+  require('internal/inspector/network_http2').disable();
   require('internal/inspector/network_undici').disable();
 }
 

filename: src/node_builtins.cc
patch: @@ -122,8 +122,8 @@ BuiltinLoader::BuiltinCategories BuiltinLoader::GetBuiltinCategories() const {
 #if !HAVE_INSPECTOR
     "inspector", "inspector/promises", "internal/util/inspector",
         "internal/inspector/network", "internal/inspector/network_http",
-        "internal/inspector/network_undici", "internal/inspector_async_hook",
-        "internal/inspector_network_tracking",
+        "internal/inspector/network_http2", "internal/inspector/network_undici",
+        "internal/inspector_async_hook", "internal/inspector_network_tracking",
 #endif  // !HAVE_INSPECTOR
 
 #if !NODE_USE_V8_PLATFORM || !defined(NODE_HAVE_I18N_SUPPORT)

filename: test/parallel/test-inspector-network-http2.js
patch: @@ -0,0 +1,302 @@
+// Flags: --inspect=0 --experimental-network-inspection
+'use strict';
+const common = require('../common');
+if (!common.hasCrypto)
+  common.skip('missing crypto');
+common.skipIfInspectorDisabled();
+
+const assert = require('node:assert');
+const fixtures = require('../common/fixtures');
+const { on, once } = require('node:events');
+const http2 = require('node:http2');
+const inspector = require('node:inspector/promises');
+
+const session = new inspector.Session();
+session.connect();
+
+const requestHeaders = {
+  'x-header1': ['value1', 'value2'],
+  [http2.constants.HTTP2_HEADER_ACCEPT_LANGUAGE]: 'en-US',
+  [http2.constants.HTTP2_HEADER_AGE]: 1000,
+  [http2.constants.HTTP2_HEADER_COOKIE]: ['k1=v1', 'k2=v2'],
+  [http2.constants.HTTP2_HEADER_METHOD]: 'GET',
+  [http2.constants.HTTP2_HEADER_PATH]: '/hello-world',
+};
+
+const requestErrorHeaders = {
+  'x-header1': ['value1', 'value2'],
+  [http2.constants.HTTP2_HEADER_ACCEPT_LANGUAGE]: 'en-US',
+  [http2.constants.HTTP2_HEADER_AGE]: 1000,
+  [http2.constants.HTTP2_HEADER_COOKIE]: ['k1=v1', 'k2=v2'],
+  [http2.constants.HTTP2_HEADER_METHOD]: 'GET',
+  [http2.constants.HTTP2_HEADER_PATH]: '/trigger-error',
+};
+
+const responseHeaders = {
+  'x-header2': ['value1', 'value2'],
+  [http2.constants.HTTP2_HEADER_CONTENT_TYPE]: 'text/plain; charset=utf-8',
+  [http2.constants.HTTP2_HEADER_ETAG]: 12345,
+  [http2.constants.HTTP2_HEADER_SERVER]: 'node',
+  [http2.constants.HTTP2_HEADER_SET_COOKIE]: ['key1=value1', 'key2=value2'],
+  [http2.constants.HTTP2_HEADER_STATUS]: 200,
+};
+
+const pushRequestHeaders = {
+  'x-header3': ['value1', 'value2'],
+  'x-push': 'true',
+  [http2.constants.HTTP2_HEADER_PATH]: '/style.css',
+};
+
+const pushResponseHeaders = {
+  'x-header4': ['value1', 'value2'],
+  'x-push': 'true',
+  [http2.constants.HTTP2_HEADER_CONTENT_TYPE]: 'text/css',
+  [http2.constants.HTTP2_HEADER_STATUS]: 200,
+};
+
+const kTimeout = 1000;
+const kDelta = 200;
+
+const handleStream = (stream, headers) => {
+  const path = headers[http2.constants.HTTP2_HEADER_PATH];
+  switch (path) {
+    case '/hello-world':
+      stream.pushStream(pushRequestHeaders, common.mustSucceed((pushStream) => {
+        pushStream.respond(pushResponseHeaders);
+        pushStream.end('body { color: red; }\n');
+      }));
+
+      stream.respond(responseHeaders);
+
+      setTimeout(() => {
+        stream.end('hello world\n');
+      }, kTimeout);
+      break;
+    case '/trigger-error':
+      stream.close(http2.constants.NGHTTP2_STREAM_CLOSED);
+      stream.on('error', common.expectsError({
+        code: 'ERR_HTTP2_STREAM_ERROR',
+        name: 'Error',
+        message: 'Stream closed with error code NGHTTP2_STREAM_CLOSED'
+      }));
+      break;
+    default:
+      assert(false, `Unexpected path: ${path}`);
+  }
+};
+
+const http2Server = http2.createServer();
+
+const http2SecureServer = http2.createSecureServer({
+  key: fixtures.readKey('agent1-key.pem'),
+  cert: fixtures.readKey('agent1-cert.pem'),
+});
+
+http2Server.on('stream', handleStream);
+http2SecureServer.on('stream', handleStream);
+
+const terminate = () => {
+  session.disconnect();
+  http2Server.close();
+  http2SecureServer.close();
+  inspector.close();
+};
+
+function findFrameInInitiator(scriptName, initiator) {
+  const frame = initiator.stack.callFrames.find((it) => {
+    return it.url === scriptName;
+  });
+  return frame;
+}
+
+function verifyRequestWillBeSent({ method, params }, expectedUrl) {
+  assert.strictEqual(method, 'Network.requestWillBeSent');
+
+  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')) {
+    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.ok(findFrameInInitiator(__filename, params.initiator));
+  } 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(typeof params.timestamp, 'number');
+  assert.strictEqual(typeof params.wallTime, 'number');
+
+  assert.strictEqual(typeof params.initiator, 'object');
+  assert.strictEqual(params.initiator.type, 'script');
+
+  return params;
+}
+
+function verifyResponseReceived({ method, params }, expectedUrl) {
+  assert.strictEqual(method, 'Network.responseReceived');
+
+  assert.ok(params.requestId.startsWith('node-network-event-'));
+  assert.strictEqual(typeof params.timestamp, 'number');
+  assert.strictEqual(params.type, 'Other');
+  assert.strictEqual(params.response.status, 200);
+  assert.strictEqual(params.response.statusText, '');
+  assert.strictEqual(params.response.url, expectedUrl);
+  assert.strictEqual(typeof params.response.headers, 'object');
+  if (expectedUrl.endsWith('/hello-world')) {
+    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');
+  } else if (expectedUrl.endsWith('/style.css')) {
+    assert.strictEqual(params.response.headers['x-header4'], 'value1, value2');
+    assert.strictEqual(params.response.headers['x-push'], 'true');
+    assert.strictEqual(params.response.mimeType, 'text/css');
+    assert.strictEqual(params.response.charset, '');
+  }
+
+  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, 'Other');
+  assert.strictEqual(typeof params.errorText, 'string');
+}
+
+async function testHttp2(secure = false) {
+  const port = (secure ? http2SecureServer : http2Server).address().port;
+  const origin = (secure ? 'https' : 'http') + `://localhost:${port}`;
+  const url = `${origin}/hello-world`;
+  const pushedUrl = `${origin}/style.css`;
+
+  const requestWillBeSent = on(session, 'Network.requestWillBeSent');
+  const responseReceived = on(session, 'Network.responseReceived');
+  const loadingFinished = on(session, 'Network.loadingFinished');
+
+  session.on('Network.loadingFailed', common.mustNotCall());
+
+  const client = http2.connect(origin, {
+    rejectUnauthorized: false,
+  });
+  const request = client.request(requestHeaders);
+
+  // Dump the responses.
+  request.on('data', () => {});
+  client.on('stream', (pushStream) => {
+    pushStream.on('data', () => {});
+  });
+  request.on('end', () => {
+    client.close();
+  });
+  request.end();
+
+  const [
+    { value: [ mainRequest ] },
+    { value: [ pushRequest ] },
+  ] = await Promise.all([requestWillBeSent.next(), requestWillBeSent.next()]);
+  verifyRequestWillBeSent(mainRequest, url);
+  verifyRequestWillBeSent(pushRequest, pushedUrl);
+
+  const [
+    { value: [ mainResponse ] },
+    { value: [ pushResponse ] },
+  ] = await Promise.all([responseReceived.next(), responseReceived.next()]);
+  verifyResponseReceived(mainResponse, url);
+  verifyResponseReceived(pushResponse, pushedUrl);
+
+  const [
+    { value: [ event1 ] },
+    { value: [ event2 ] },
+  ] = await Promise.all([loadingFinished.next(), loadingFinished.next()]);
+  verifyLoadingFinished(event1);
+  verifyLoadingFinished(event2);
+
+  const mainFinished = [event1, event2]
+    .find((event) => event.params.requestId === mainResponse.params.requestId);
+  const pushFinished = [event1, event2]
+    .find((event) => event.params.requestId === pushResponse.params.requestId);
+
+  assert.ok(mainFinished.params.timestamp >= mainResponse.params.timestamp);
+  assert.ok(pushFinished.params.timestamp >= pushResponse.params.timestamp);
+
+  const delta =
+    (mainFinished.params.timestamp - mainResponse.params.timestamp) * 1000;
+  assert.ok(delta > kDelta);
+}
+
+async function testHttp2Error(secure = false) {
+  const port = (secure ? http2SecureServer : http2Server).address().port;
+  const origin = (secure ? 'https' : 'http') + `://localhost:${port}`;
+  const errorUrl = `${origin}/trigger-error`;
+
+  const requestWillBeSent = once(session, 'Network.requestWillBeSent');
+  session.on('Network.responseReceived', common.mustNotCall());
+  session.on('Network.loadingFinished', common.mustNotCall());
+  const loadingFailed = once(session, 'Network.loadingFailed');
+
+  const client = http2.connect(origin, {
+    rejectUnauthorized: false,
+  });
+  const request = client.request(requestErrorHeaders);
+
+  request.on('close', common.mustCall(() => {
+    assert.strictEqual(request.rstCode, http2.constants.NGHTTP2_STREAM_CLOSED);
+    client.close();
+  }));
+  request.on('error', common.expectsError({
+    code: 'ERR_HTTP2_STREAM_ERROR',
+    name: 'Error',
+    message: 'Stream closed with error code NGHTTP2_STREAM_CLOSED'
+  }));
+  request.end();
+
+  const [ requestEvent ] = await requestWillBeSent;
+  verifyRequestWillBeSent(requestEvent, errorUrl);
+
+  const [ failed ] = await loadingFailed;
+  verifyLoadingFailed(failed);
+}
+
+const testNetworkInspection = async () => {
+  await testHttp2();
+  session.removeAllListeners();
+  await testHttp2(true);
+  session.removeAllListeners();
+  await testHttp2Error();
+  session.removeAllListeners();
+  await testHttp2Error(true);
+  session.removeAllListeners();
+};
+
+http2Server.listen(0, async () => {
+  http2SecureServer.listen(0, async () => {
+    try {
+      await session.post('Network.enable');
+      await testNetworkInspection();
+      await session.post('Network.disable');
+    } catch (e) {
+      assert.fail(e);
+    } finally {
+      terminate();
+    }
+  });
+});