From 9a954fbf4a0571d44136cb6f1db6a1d6c8395e26 Mon Sep 17 00:00:00 2001 From: Chengzhong Wu Date: Wed, 8 Jan 2025 16:56:42 +0000 Subject: [PATCH] inspector: add undici http tracking support Add basic undici http tracking support via inspector protocol. This allows tracking `fetch` calls with an inspector. PR-URL: https://github.com/nodejs/node/pull/56488 Refs: https://github.com/nodejs/node/issues/53946 Reviewed-By: James M Snell Reviewed-By: Benjamin Gruenbaum Reviewed-By: Ethan Arrowood Reviewed-By: Matteo Collina --- lib/internal/inspector/network.js | 23 ++ lib/internal/inspector/network_http.js | 6 +- lib/internal/inspector/network_undici.js | 141 +++++++++++++ lib/internal/inspector_network_tracking.js | 6 +- src/node_builtins.cc | 3 +- test/parallel/test-inspector-network-fetch.js | 196 ++++++++++++++++++ 6 files changed, 367 insertions(+), 8 deletions(-) create mode 100644 lib/internal/inspector/network_undici.js create mode 100644 test/parallel/test-inspector-network-fetch.js diff --git a/lib/internal/inspector/network.js b/lib/internal/inspector/network.js index 18424bee569302..f46268ddc49621 100644 --- a/lib/internal/inspector/network.js +++ b/lib/internal/inspector/network.js @@ -8,6 +8,28 @@ const { const { now } = require('internal/perf/utils'); const kInspectorRequestId = Symbol('kInspectorRequestId'); +// https://chromedevtools.github.io/devtools-protocol/1-3/Network/#type-ResourceType +const kResourceType = { + Document: 'Document', + Stylesheet: 'Stylesheet', + Image: 'Image', + Media: 'Media', + Font: 'Font', + Script: 'Script', + TextTrack: 'TextTrack', + XHR: 'XHR', + Fetch: 'Fetch', + Prefetch: 'Prefetch', + EventSource: 'EventSource', + WebSocket: 'WebSocket', + Manifest: 'Manifest', + SignedExchange: 'SignedExchange', + Ping: 'Ping', + CSPViolationReport: 'CSPViolationReport', + Preflight: 'Preflight', + Other: 'Other', +}; + /** * Return a monotonically increasing time in seconds since an arbitrary point in the past. * @returns {number} @@ -26,6 +48,7 @@ function getNextRequestId() { module.exports = { kInspectorRequestId, + kResourceType, getMonotonicTime, getNextRequestId, }; diff --git a/lib/internal/inspector/network_http.js b/lib/internal/inspector/network_http.js index 87a33b419b1aed..16669f308f3a8e 100644 --- a/lib/internal/inspector/network_http.js +++ b/lib/internal/inspector/network_http.js @@ -10,13 +10,13 @@ const { const { kInspectorRequestId, + kResourceType, getMonotonicTime, getNextRequestId, } = require('internal/inspector/network'); const dc = require('diagnostics_channel'); const { Network } = require('inspector'); -const kResourceType = 'Other'; const kRequestUrl = Symbol('kRequestUrl'); // Convert a Headers object (Map) to a plain object (Map) @@ -79,7 +79,7 @@ function onClientRequestError({ request, error }) { Network.loadingFailed({ requestId: request[kInspectorRequestId], timestamp: getMonotonicTime(), - type: kResourceType, + type: kResourceType.Other, errorText: error.message, }); } @@ -96,7 +96,7 @@ function onClientResponseFinish({ request, response }) { Network.responseReceived({ requestId: request[kInspectorRequestId], timestamp: getMonotonicTime(), - type: kResourceType, + type: kResourceType.Other, response: { url: request[kRequestUrl], status: response.statusCode, diff --git a/lib/internal/inspector/network_undici.js b/lib/internal/inspector/network_undici.js new file mode 100644 index 00000000000000..7afc5970117127 --- /dev/null +++ b/lib/internal/inspector/network_undici.js @@ -0,0 +1,141 @@ +'use strict'; + +const { + DateNow, +} = primordials; + +const { + kInspectorRequestId, + kResourceType, + getMonotonicTime, + getNextRequestId, +} = require('internal/inspector/network'); +const dc = require('diagnostics_channel'); +const { Network } = require('inspector'); + +// Convert an undici request headers array to a plain object (Map) +function requestHeadersArrayToDictionary(headers) { + const dict = {}; + for (let idx = 0; idx < headers.length; idx += 2) { + const key = `${headers[idx]}`; + const value = `${headers[idx + 1]}`; + dict[key] = value; + } + return dict; +}; + +// Convert an undici response headers array to a plain object (Map) +function responseHeadersArrayToDictionary(headers) { + const dict = {}; + for (let idx = 0; idx < headers.length; idx += 2) { + const key = `${headers[idx]}`; + const value = `${headers[idx + 1]}`; + const prevValue = dict[key]; + + if (typeof prevValue === 'string') { + // ChromeDevTools frontend treats 'set-cookie' as a special case + // https://github.com/ChromeDevTools/devtools-frontend/blob/4275917f84266ef40613db3c1784a25f902ea74e/front_end/core/sdk/NetworkRequest.ts#L1368 + if (key.toLowerCase() === 'set-cookie') dict[key] = `${prevValue}\n${value}`; + else dict[key] = `${prevValue}, ${value}`; + } else { + dict[key] = value; + } + } + return dict; +}; + +/** + * When a client request starts, emit Network.requestWillBeSent event. + * https://chromedevtools.github.io/devtools-protocol/1-3/Network/#event-requestWillBeSent + * @param {{ request: undici.Request }} event + */ +function onClientRequestStart({ request }) { + const url = `${request.origin}${request.path}`; + request[kInspectorRequestId] = getNextRequestId(); + Network.requestWillBeSent({ + requestId: request[kInspectorRequestId], + timestamp: getMonotonicTime(), + wallTime: DateNow(), + request: { + url, + method: request.method, + headers: requestHeadersArrayToDictionary(request.headers), + }, + }); +} + +/** + * When a client request errors, emit Network.loadingFailed event. + * https://chromedevtools.github.io/devtools-protocol/1-3/Network/#event-loadingFailed + * @param {{ request: undici.Request, error: any }} event + */ +function onClientRequestError({ request, error }) { + if (typeof request[kInspectorRequestId] !== 'string') { + return; + } + Network.loadingFailed({ + requestId: request[kInspectorRequestId], + timestamp: getMonotonicTime(), + // TODO(legendecas): distinguish between `undici.request` and `undici.fetch`. + type: kResourceType.Fetch, + errorText: error.message, + }); +} + +/** + * When response headers are received, emit Network.responseReceived event. + * https://chromedevtools.github.io/devtools-protocol/1-3/Network/#event-responseReceived + * @param {{ request: undici.Request, response: undici.Response }} event + */ +function onClientResponseHeaders({ request, response }) { + if (typeof request[kInspectorRequestId] !== 'string') { + return; + } + const url = `${request.origin}${request.path}`; + Network.responseReceived({ + requestId: request[kInspectorRequestId], + timestamp: getMonotonicTime(), + // TODO(legendecas): distinguish between `undici.request` and `undici.fetch`. + type: kResourceType.Fetch, + response: { + url, + status: response.statusCode, + statusText: response.statusText, + headers: responseHeadersArrayToDictionary(response.headers), + }, + }); +} + +/** + * When a response is completed, emit Network.loadingFinished event. + * https://chromedevtools.github.io/devtools-protocol/1-3/Network/#event-loadingFinished + * @param {{ request: undici.Request, response: undici.Response }} event + */ +function onClientResponseFinish({ request }) { + if (typeof request[kInspectorRequestId] !== 'string') { + return; + } + Network.loadingFinished({ + requestId: request[kInspectorRequestId], + timestamp: getMonotonicTime(), + }); +} + +function enable() { + dc.subscribe('undici:request:create', onClientRequestStart); + dc.subscribe('undici:request:error', onClientRequestError); + dc.subscribe('undici:request:headers', onClientResponseHeaders); + dc.subscribe('undici:request:trailers', onClientResponseFinish); +} + +function disable() { + dc.subscribe('undici:request:create', onClientRequestStart); + dc.subscribe('undici:request:error', onClientRequestError); + dc.subscribe('undici:request:headers', onClientResponseHeaders); + dc.subscribe('undici:request:trailers', onClientResponseFinish); +} + +module.exports = { + enable, + disable, +}; diff --git a/lib/internal/inspector_network_tracking.js b/lib/internal/inspector_network_tracking.js index 9158bb48f745f8..5748259fb680c1 100644 --- a/lib/internal/inspector_network_tracking.js +++ b/lib/internal/inspector_network_tracking.js @@ -2,14 +2,12 @@ function enable() { require('internal/inspector/network_http').enable(); - // TODO: add undici request/websocket tracking. - // https://github.com/nodejs/node/issues/53946 + require('internal/inspector/network_undici').enable(); } function disable() { require('internal/inspector/network_http').disable(); - // TODO: add undici request/websocket tracking. - // https://github.com/nodejs/node/issues/53946 + require('internal/inspector/network_undici').disable(); } module.exports = { diff --git a/src/node_builtins.cc b/src/node_builtins.cc index 2496d36239ac42..894fd515202cc3 100644 --- a/src/node_builtins.cc +++ b/src/node_builtins.cc @@ -120,7 +120,8 @@ BuiltinLoader::BuiltinCategories BuiltinLoader::GetBuiltinCategories() const { #if !HAVE_INSPECTOR "inspector", "inspector/promises", "internal/util/inspector", "internal/inspector/network", "internal/inspector/network_http", - "internal/inspector_async_hook", "internal/inspector_network_tracking", + "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) diff --git a/test/parallel/test-inspector-network-fetch.js b/test/parallel/test-inspector-network-fetch.js new file mode 100644 index 00000000000000..26f6d52ff40694 --- /dev/null +++ b/test/parallel/test-inspector-network-fetch.js @@ -0,0 +1,196 @@ +// Flags: --inspect=0 --experimental-network-inspection +'use strict'; +const common = require('../common'); + +common.skipIfInspectorDisabled(); + +const assert = require('node:assert'); +const { addresses } = require('../common/internet'); +const fixtures = require('../common/fixtures'); +const http = require('node:http'); +const https = require('node:https'); +const inspector = require('node:inspector/promises'); + +// Disable certificate validation for the global fetch. +const undici = require('../../deps/undici/src/index.js'); +undici.setGlobalDispatcher(new undici.Agent({ + connect: { + rejectUnauthorized: false, + }, +})); + +const session = new inspector.Session(); +session.connect(); + +const requestHeaders = [ + ['accept-language', 'en-US'], + ['cookie', 'k1=v1'], + ['cookie', 'k2=v2'], + ['age', 1000], + ['x-header1', 'value1'], + ['x-header1', 'value2'], +]; + +const setResponseHeaders = (res) => { + res.setHeader('server', 'node'); + res.setHeader('etag', 12345); + res.setHeader('Set-Cookie', ['key1=value1', 'key2=value2']); + res.setHeader('x-header2', ['value1', 'value2']); +}; + +const handleRequest = (req, res) => { + const path = req.url; + switch (path) { + case '/hello-world': + setResponseHeaders(res); + res.writeHead(200); + res.end('hello world\n'); + break; + default: + assert(false, `Unexpected path: ${path}`); + } +}; + +const httpServer = http.createServer(handleRequest); + +const httpsServer = https.createServer({ + key: fixtures.readKey('agent1-key.pem'), + cert: fixtures.readKey('agent1-cert.pem') +}, handleRequest); + +const terminate = () => { + session.disconnect(); + httpServer.close(); + httpsServer.close(); + inspector.close(); +}; + +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'); + })); + 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'); + })); + session.on('Network.loadingFinished', common.mustCall(({ params }) => { + assert.ok(params.requestId.startsWith('node-network-event-')); + assert.strictEqual(typeof params.timestamp, 'number'); + resolve(); + })); + + fetch(`http://127.0.0.1:${httpServer.address().port}/hello-world`, { + 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'); + })); + session.on('Network.loadingFinished', common.mustCall(({ params }) => { + assert.ok(params.requestId.startsWith('node-network-event-')); + assert.strictEqual(typeof params.timestamp, 'number'); + resolve(); + })); + + fetch(`https://127.0.0.1:${httpsServer.address().port}/hello-world`, { + headers: requestHeaders, + }).then(common.mustCall()); +}); + +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(); + })); + session.on('Network.responseReceived', common.mustNotCall()); + session.on('Network.loadingFinished', common.mustNotCall()); + + fetch(`http://${addresses.INVALID_HOST}`).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()); +}); + +const testNetworkInspection = async () => { + await testHttpGet(); + session.removeAllListeners(); + await testHttpsGet(); + session.removeAllListeners(); + await testHttpError(); + session.removeAllListeners(); + await testHttpsError(); + session.removeAllListeners(); +}; + +httpServer.listen(0, () => { + httpsServer.listen(0, async () => { + try { + await session.post('Network.enable'); + await testNetworkInspection(); + await session.post('Network.disable'); + } catch (e) { + assert.fail(e); + } finally { + terminate(); + } + }); +});