From a03d5a049b333ce971130cff8da437c156dd9c2e Mon Sep 17 00:00:00 2001 From: GrinZero <774933704@qq.com> Date: Tue, 28 Jul 2026 11:02:00 +0800 Subject: [PATCH 1/4] inspector,http: support http body tracking Signed-off-by: GrinZero <774933704@qq.com> --- doc/api/diagnostics_channel.md | 22 ++ lib/_http_client.js | 25 +++ lib/_http_common.js | 15 ++ lib/_http_outgoing.js | 19 +- lib/internal/http.js | 2 + lib/internal/inspector/network_http.js | 158 +++++++++++-- src/inspector/network_agent.cc | 156 ++++++++++++- src/inspector/network_agent.h | 2 + .../parallel/test-diagnostics-channel-http.js | 83 +++++-- ...st-inspector-emit-protocol-event-errors.js | 86 +++++++ .../test-inspector-emit-protocol-event.js | 78 +++++++ test/parallel/test-inspector-network-http.js | 210 ++++++++++++++++++ 12 files changed, 819 insertions(+), 37 deletions(-) diff --git a/doc/api/diagnostics_channel.md b/doc/api/diagnostics_channel.md index d250817aa203..60a76e1f9467 100644 --- a/doc/api/diagnostics_channel.md +++ b/doc/api/diagnostics_channel.md @@ -1653,6 +1653,20 @@ Unlike `http.client.request.start`, this event is emitted before the request has Emitted when client starts a request. +##### Event: `'http.client.request.bodyChunkSent'` + +* `request` {http.ClientRequest} +* `chunk` {Buffer|string|Uint8Array} +* `encoding` {string|null|undefined} + +Emitted when a client request body chunk is sent. + +##### Event: `'http.client.request.bodySent'` + +* `request` {http.ClientRequest} + +Emitted when a client request body with at least one chunk has been sent. + ##### Event: `'http.client.request.error'` * `request` {http.ClientRequest} @@ -1660,6 +1674,14 @@ Emitted when client starts a request. Emitted when an error occurs during a client request. +##### Event: `'http.client.response.bodyChunkReceived'` + +* `request` {http.ClientRequest} +* `response` {http.IncomingMessage} +* `chunk` {Buffer|Uint8Array} + +Emitted when raw bytes of a client response body chunk are received. + ##### Event: `'http.client.response.finish'` * `request` {http.ClientRequest} diff --git a/lib/_http_client.js b/lib/_http_client.js index 21cf9e8d407d..0dae25d51b26 100644 --- a/lib/_http_client.js +++ b/lib/_http_client.js @@ -66,6 +66,8 @@ const { URL, urlToHttpOptions, isURL } = require('internal/url'); const { kOutHeaders, kNeedDrain, + kOnOutgoingBodyChunkSent, + kOnOutgoingBodySent, isTraceHTTPEnabled, traceBegin, traceEnd, @@ -105,6 +107,10 @@ const onClientRequestCreatedChannel = dc.channel('http.client.request.created'); const onClientRequestStartChannel = dc.channel('http.client.request.start'); const onClientRequestErrorChannel = dc.channel('http.client.request.error'); const onClientResponseFinishChannel = dc.channel('http.client.response.finish'); +const onClientRequestBodyChunkSentChannel = + dc.channel('http.client.request.bodyChunkSent'); +const onClientRequestBodySentChannel = + dc.channel('http.client.request.bodySent'); function emitErrorEvent(request, error) { if (onClientRequestErrorChannel.hasSubscribers) { @@ -695,6 +701,25 @@ ClientRequest.prototype._implicitHeader = function _implicitHeader() { this[kOutHeaders]); }; +ClientRequest.prototype[kOnOutgoingBodyChunkSent] = + function onOutgoingBodyChunkSent(chunk, encoding) { + if (onClientRequestBodyChunkSentChannel.hasSubscribers) { + onClientRequestBodyChunkSentChannel.publish({ + request: this, + chunk, + encoding, + }); + } + }; + +ClientRequest.prototype[kOnOutgoingBodySent] = function onOutgoingBodySent() { + if (onClientRequestBodySentChannel.hasSubscribers) { + onClientRequestBodySentChannel.publish({ + request: this, + }); + } +}; + ClientRequest.prototype.abort = function abort() { if (this.aborted) { return; diff --git a/lib/_http_common.js b/lib/_http_common.js index d5e7bdedee39..b281720a8b35 100644 --- a/lib/_http_common.js +++ b/lib/_http_common.js @@ -27,6 +27,7 @@ const { Uint8Array, } = primordials; const { setImmediate } = require('timers'); +const dc = require('diagnostics_channel'); const { methods, allMethods, HTTPParser } = internalBinding('http_parser'); const { getOptionValue } = require('internal/options'); @@ -42,6 +43,7 @@ const { const kIncomingMessage = Symbol('IncomingMessage'); const kSkipPendingData = Symbol('SkipPendingData'); +const kClientRequest = Symbol('kClientRequest'); const kOnMessageBegin = HTTPParser.kOnMessageBegin | 0; const kOnHeaders = HTTPParser.kOnHeaders | 0; const kOnHeadersComplete = HTTPParser.kOnHeadersComplete | 0; @@ -50,6 +52,9 @@ const kOnMessageComplete = HTTPParser.kOnMessageComplete | 0; const kOnExecute = HTTPParser.kOnExecute | 0; const kOnTimeout = HTTPParser.kOnTimeout | 0; +const onClientResponseBodyChunkReceivedChannel = + dc.channel('http.client.response.bodyChunkReceived'); + const MAX_HEADER_PAIRS = 2000; // Only called in the slow case where slow means @@ -120,6 +125,7 @@ function parserOnHeadersComplete(versionMajor, versionMinor, headers, method, // client only incoming.statusCode = statusCode; incoming.statusMessage = statusMessage; + incoming[kClientRequest] = socket?._httpMessage; } return parser.onIncoming(incoming, shouldKeepAlive); @@ -134,6 +140,15 @@ function parserOnBody(b) { // Pretend this was the result of a stream._read call. if (!stream._dumped) { + const request = stream[kClientRequest]; + if (request !== undefined && + onClientResponseBodyChunkReceivedChannel.hasSubscribers) { + onClientResponseBodyChunkReceivedChannel.publish({ + request, + response: stream, + chunk: b, + }); + } const ret = stream.push(b); if (!ret) readStop(this.socket); diff --git a/lib/_http_outgoing.js b/lib/_http_outgoing.js index 0b728f6ae4d4..6d62fef538b6 100644 --- a/lib/_http_outgoing.js +++ b/lib/_http_outgoing.js @@ -38,7 +38,13 @@ const { getDefaultHighWaterMark } = require('internal/streams/state'); const assert = require('internal/assert'); const EE = require('events'); const Stream = require('stream'); -const { kOutHeaders, utcDateHeader, kNeedDrain } = require('internal/http'); +const { + kOnOutgoingBodyChunkSent, + kOnOutgoingBodySent, + kOutHeaders, + utcDateHeader, + kNeedDrain, +} = require('internal/http'); const { Buffer } = require('buffer'); const { _checkIsHttpToken: checkIsHttpToken, @@ -99,6 +105,7 @@ const kEndCallbacks = Symbol('kEndCallbacks'); const kFlushError = Symbol('kFlushError'); const kHighWaterMark = Symbol('kHighWaterMark'); const kRejectNonStandardBodyWrites = Symbol('kRejectNonStandardBodyWrites'); +const kHasOutgoingBodyChunks = Symbol('kHasOutgoingBodyChunks'); const nop = () => {}; @@ -175,6 +182,7 @@ function OutgoingMessage(options) { this[kHighWaterMark] = options?.highWaterMark ?? getDefaultHighWaterMark(); this[kRejectNonStandardBodyWrites] = options?.rejectNonStandardBodyWrites ?? false; this[kLenientCache] = null; + this[kHasOutgoingBodyChunks] = false; } ObjectSetPrototypeOf(OutgoingMessage.prototype, Stream.prototype); ObjectSetPrototypeOf(OutgoingMessage, Stream); @@ -1081,6 +1089,11 @@ function write_(msg, chunk, encoding, callback, fromEnd) { process.nextTick(connectionCorkNT, msg); } + if (chunk.length !== 0) { + msg[kHasOutgoingBodyChunks] = true; + msg[kOnOutgoingBodyChunkSent]?.(chunk, encoding); + } + let ret; if (msg.chunkedEncoding && chunk.length !== 0) { len ??= typeof chunk === 'string' ? Buffer.byteLength(chunk, encoding) : chunk.byteLength; @@ -1316,6 +1329,10 @@ OutgoingMessage.prototype.end = function end(chunk, encoding, callback) { this.finished = true; + if (this[kHasOutgoingBodyChunks]) { + this[kOnOutgoingBodySent]?.(); + } + // There is the first message on the outgoing queue, and we've sent // everything to the socket. debug('outgoing message end.'); diff --git a/lib/internal/http.js b/lib/internal/http.js index 999ade29ed07..d4579a94aa83 100644 --- a/lib/internal/http.js +++ b/lib/internal/http.js @@ -275,6 +275,8 @@ function getGlobalAgent(proxyEnv, Agent) { } module.exports = { + kOnOutgoingBodyChunkSent: Symbol('kOnOutgoingBodyChunkSent'), + kOnOutgoingBodySent: Symbol('kOnOutgoingBodySent'), kOutHeaders: Symbol('kOutHeaders'), kNeedDrain: Symbol('kNeedDrain'), kProxyConfig: Symbol('kProxyConfig'), diff --git a/lib/internal/inspector/network_http.js b/lib/internal/inspector/network_http.js index 1bb08762bdb4..df5bbf98f3d3 100644 --- a/lib/internal/inspector/network_http.js +++ b/lib/internal/inspector/network_http.js @@ -2,7 +2,9 @@ const { ArrayIsArray, + ArrayPrototypePush, DateNow, + MathMax, ObjectEntries, String, StringPrototypeStartsWith, @@ -18,10 +20,15 @@ const { sniffMimeType, } = require('internal/inspector/network'); const { Network } = require('inspector'); -const EventEmitter = require('events'); -const { kEmptyObject } = require('internal/util'); +const { Buffer } = require('buffer'); +const { + getStructuredStack, + kEmptyObject, +} = require('internal/util'); const kRequestUrl = Symbol('kRequestUrl'); +const kRequestWillBeSent = Symbol('kRequestWillBeSent'); +const kInitiator = Symbol('kInitiator'); function isAbsoluteURLPath(path) { return typeof path === 'string' && @@ -36,7 +43,7 @@ function getRequestURL(request, host) { return `${request.protocol}//${host}${request.path}`; } -// Convert a Headers object (Map) to a plain object (Map) +// Convert a headers object (Map) to a plain object (Map). const convertHeaderObject = (headers = kEmptyObject) => { // The 'host' header that contains the host and port of the URL. let host; @@ -68,37 +75,100 @@ const convertHeaderObject = (headers = kEmptyObject) => { return [dict, host, charset, mimeType]; }; +function getCallSiteUrl(callSite) { + return callSite.getScriptNameOrSourceURL() ?? callSite.getFileName() ?? ''; +} + +function isInternalCallSite(callSite) { + const url = getCallSiteUrl(callSite); + return url === 'structured-stack' || StringPrototypeStartsWith(url, 'node:'); +} + +function createInitiator() { + const callSites = getStructuredStack(); + const callFrames = []; + let start = 0; + + for (let i = 0; i < callSites.length; i++) { + if (!isInternalCallSite(callSites[i])) { + start = i; + break; + } + } + + for (let i = start; i < callSites.length; i++) { + const callSite = callSites[i]; + ArrayPrototypePush(callFrames, { + functionName: callSite.getFunctionName() ?? callSite.getMethodName() ?? '', + scriptId: '', + url: getCallSiteUrl(callSite), + lineNumber: MathMax((callSite.getLineNumber() ?? 1) - 1, 0), + columnNumber: MathMax((callSite.getColumnNumber() ?? 1) - 1, 0), + }); + } + + return { + type: 'script', + stack: { callFrames }, + }; +} + /** - * When a client request is created, emit Network.requestWillBeSent event. - * https://chromedevtools.github.io/devtools-protocol/1-3/Network/#event-requestWillBeSent + * When a client request is created, assign its inspector request id. * @param {{ request: import('http').ClientRequest }} event */ function onClientRequestCreated({ request }) { request[kInspectorRequestId] = getNextRequestId(); + request[kInitiator] = createInitiator(); +} + +/** + * Emit Network.requestWillBeSent once the request body state is known. + * https://chromedevtools.github.io/devtools-protocol/1-3/Network/#event-requestWillBeSent + * @param {import('http').ClientRequest} request + * @param {boolean} hasPostData + */ +function emitRequestWillBeSent(request, hasPostData) { + if (request[kRequestWillBeSent] || + typeof request[kInspectorRequestId] !== 'string') { + return; + } const { 0: headers, 1: host, 2: charset } = convertHeaderObject(request.getHeaders()); const url = getRequestURL(request, host); request[kRequestUrl] = url; + request[kRequestWillBeSent] = true; Network.requestWillBeSent({ requestId: request[kInspectorRequestId], timestamp: getMonotonicTime(), wallTime: DateNow(), charset, + initiator: request[kInitiator], request: { url, method: request.method, headers, + hasPostData, }, }); } +/** + * When a client request starts without a body, emit Network.requestWillBeSent. + * @param {{ request: import('http').ClientRequest }} event + */ +function onClientRequestStart({ request }) { + emitRequestWillBeSent(request, false); +} + /** * When a client request errors, emit Network.loadingFailed event. * https://chromedevtools.github.io/devtools-protocol/1-3/Network/#event-loadingFailed * @param {{ request: import('http').ClientRequest, error: any }} event */ function onClientRequestError({ request, error }) { + emitRequestWillBeSent(request, false); if (typeof request[kInspectorRequestId] !== 'string') { return; } @@ -110,12 +180,75 @@ function onClientRequestError({ request, 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 {{ request: import('http').ClientRequest, chunk: Uint8Array | string, encoding?: string }} event + */ +function onClientRequestBodyChunkSent({ request, chunk, encoding }) { + if (typeof request[kInspectorRequestId] !== 'string') { + return; + } + + emitRequestWillBeSent(request, true); + + const buffer = typeof chunk === 'string' ? + Buffer.from(chunk, encoding ?? undefined) : + Buffer.from(chunk); + Network.dataSent({ + requestId: request[kInspectorRequestId], + timestamp: getMonotonicTime(), + dataLength: buffer.byteLength, + data: buffer, + }); +} + +/** + * Mark a request body as fully sent. + * @param {{ request: import('http').ClientRequest }} event + */ +function onClientRequestBodySent({ request }) { + if (typeof request[kInspectorRequestId] !== 'string') { + return; + } + + Network.dataSent({ + requestId: request[kInspectorRequestId], + timestamp: getMonotonicTime(), + dataLength: 0, + data: Buffer.alloc(0), + finished: true, + }); +} + +/** + * When a chunk of the response body is received, cache the raw bytes until + * `getResponseBody` request. + * https://chromedevtools.github.io/devtools-protocol/1-3/Network/#method-getResponseBody + * @param {{ request: import('http').ClientRequest, chunk: Uint8Array }} event + */ +function onClientResponseBodyChunkReceived({ request, chunk }) { + if (typeof request[kInspectorRequestId] !== 'string') { + return; + } + + Network.dataReceived({ + requestId: request[kInspectorRequestId], + timestamp: getMonotonicTime(), + dataLength: chunk.byteLength, + encodedDataLength: chunk.byteLength, + data: chunk, + }); +} + /** * When response headers are received, emit Network.responseReceived event. * https://chromedevtools.github.io/devtools-protocol/1-3/Network/#event-responseReceived * @param {{ request: import('http').ClientRequest, error: any }} event */ function onClientResponseFinish({ request, response }) { + emitRequestWillBeSent(request, false); if (typeof request[kInspectorRequestId] !== 'string') { return; } @@ -136,17 +269,6 @@ function onClientResponseFinish({ request, response }) { }, }); - // Unlike response.on('data', ...), this does not put the stream into flowing mode. - EventEmitter.prototype.on.call(response, 'data', (chunk) => { - Network.dataReceived({ - requestId: request[kInspectorRequestId], - timestamp: getMonotonicTime(), - dataLength: chunk.byteLength, - encodedDataLength: chunk.byteLength, - data: chunk, - }); - }); - // Wait until the response body is consumed by user code. response.once('end', () => { Network.loadingFinished({ @@ -158,6 +280,10 @@ function onClientResponseFinish({ request, response }) { module.exports = registerDiagnosticChannels([ ['http.client.request.created', onClientRequestCreated], + ['http.client.request.start', onClientRequestStart], + ['http.client.request.bodyChunkSent', onClientRequestBodyChunkSent], + ['http.client.request.bodySent', onClientRequestBodySent], ['http.client.request.error', onClientRequestError], + ['http.client.response.bodyChunkReceived', onClientResponseBodyChunkReceived], ['http.client.response.finish', onClientResponseFinish], ]); diff --git a/src/inspector/network_agent.cc b/src/inspector/network_agent.cc index ace8ba522871..d833b8e48f37 100644 --- a/src/inspector/network_agent.cc +++ b/src/inspector/network_agent.cc @@ -6,6 +6,7 @@ #include "inspector/network_resource_manager.h" #include "inspector/protocol_helper.h" #include "network_inspector.h" +#include "node/inspector/protocol/Runtime.h" #include "node_metadata.h" #include "util-inl.h" #include "uv.h" @@ -29,6 +30,72 @@ static void ThrowEventError(v8::Isolate* isolate, const std::string& message) { v8::String::NewFromUtf8(isolate, message.c_str()).ToLocalChecked())); } +static std::unique_ptr V8ToProtocolValue( + Isolate* isolate, v8::Local context, Local value) { + if (value->IsNullOrUndefined()) { + return protocol::Value::null(); + } + if (value->IsBoolean()) { + return protocol::FundamentalValue::create(value.As()->Value()); + } + if (value->IsInt32()) { + return protocol::FundamentalValue::create(value.As()->Value()); + } + if (value->IsNumber()) { + return protocol::FundamentalValue::create(value.As()->Value()); + } + if (value->IsString()) { + return protocol::StringValue::create(ToProtocolString(isolate, value)); + } + if (value->IsArray()) { + Local array = value.As(); + std::unique_ptr list = protocol::ListValue::create(); + list->reserve(array->Length()); + for (uint32_t i = 0; i < array->Length(); i++) { + Local element; + if (!array->Get(context, i).ToLocal(&element)) { + return nullptr; + } + std::unique_ptr protocol_value = + V8ToProtocolValue(isolate, context, element); + if (!protocol_value) { + return nullptr; + } + list->pushValue(std::move(protocol_value)); + } + return list; + } + if (value->IsObject()) { + Local object = value.As(); + Local property_names; + if (!object->GetOwnPropertyNames(context).ToLocal(&property_names)) { + return nullptr; + } + std::unique_ptr dict = + protocol::DictionaryValue::create(); + for (uint32_t i = 0; i < property_names->Length(); i++) { + Local property_name; + if (!property_names->Get(context, i).ToLocal(&property_name) || + !property_name->IsString()) { + return nullptr; + } + Local property; + if (!object->Get(context, property_name).ToLocal(&property)) { + return nullptr; + } + std::unique_ptr protocol_value = + V8ToProtocolValue(isolate, context, property); + if (!protocol_value) { + return nullptr; + } + dict->setValue(ToProtocolString(isolate, property_name), + std::move(protocol_value)); + } + return dict; + } + return nullptr; +} + // Create a protocol::Network::Headers from the v8 object. std::unique_ptr NetworkAgent::createHeadersFromObject(v8::Local context, @@ -65,6 +132,74 @@ NetworkAgent::createHeadersFromObject(v8::Local context, return std::make_unique(std::move(dict)); } +std::unique_ptr +NetworkAgent::createInitiatorFromObject(v8::Local context, + Local initiator_obj) { + HandleScope handle_scope(Isolate::GetCurrent()); + Isolate* isolate = env_->isolate(); + + protocol::String type; + if (!ObjectGetProtocolString(context, initiator_obj, "type").To(&type)) { + ThrowEventError(isolate, "Missing initiator.type in event"); + return {}; + } + + std::unique_ptr initiator = + protocol::Network::Initiator::create().setType(type).build(); + + Local stack_obj; + if (ObjectGetObject(context, initiator_obj, "stack").ToLocal(&stack_obj)) { + std::unique_ptr stack_value = + V8ToProtocolValue(isolate, context, stack_obj); + if (!stack_value) { + ThrowEventError(isolate, "Invalid initiator.stack in event"); + return {}; + } + + protocol::DictionaryValue* stack_dict = + protocol::DictionaryValue::cast(stack_value.get()); + if (!stack_dict || stack_dict->get("callFrames") == nullptr) { + ThrowEventError(isolate, "Invalid initiator.stack in event"); + return {}; + } + + protocol::ErrorSupport errors; + std::unique_ptr stack = + protocol::ValueConversions::fromValue(stack_value.get(), + &errors); + if (!stack) { + ThrowEventError(isolate, "Invalid initiator.stack in event"); + return {}; + } + initiator->setStack(std::move(stack)); + } + + protocol::String url; + if (ObjectGetProtocolString(context, initiator_obj, "url").To(&url)) { + initiator->setUrl(url); + } + + double line_number; + if (ObjectGetDouble(context, initiator_obj, "lineNumber").To(&line_number)) { + initiator->setLineNumber(line_number); + } + + double column_number; + if (ObjectGetDouble(context, initiator_obj, "columnNumber") + .To(&column_number)) { + initiator->setColumnNumber(column_number); + } + + protocol::String request_id; + if (ObjectGetProtocolString(context, initiator_obj, "requestId") + .To(&request_id)) { + initiator->setRequestId(request_id); + } + + return initiator; +} + // Create a protocol::Network::Request from the v8 object. std::unique_ptr NetworkAgent::createRequestFromObject(v8::Local context, @@ -460,12 +595,21 @@ void NetworkAgent::requestWillBeSent(v8::Local context, return; } - std::unique_ptr initiator = - protocol::Network::Initiator::create() - .setType(protocol::Network::Initiator::TypeEnum::Script) - .setStack( - v8_inspector_->captureStackTrace(true)->buildInspectorObject(0)) - .build(); + std::unique_ptr initiator; + Local initiator_obj; + if (ObjectGetObject(context, params, "initiator").ToLocal(&initiator_obj)) { + initiator = createInitiatorFromObject(context, initiator_obj); + if (!initiator) { + return; + } + } else { + initiator = + protocol::Network::Initiator::create() + .setType(protocol::Network::Initiator::TypeEnum::Script) + .setStack( + v8_inspector_->captureStackTrace(true)->buildInspectorObject(0)) + .build(); + } if (requests_.contains(request_id)) { // Duplicate entry, ignore it. diff --git a/src/inspector/network_agent.h b/src/inspector/network_agent.h index 2136a45baf45..a16e1781e4c9 100644 --- a/src/inspector/network_agent.h +++ b/src/inspector/network_agent.h @@ -81,6 +81,8 @@ class NetworkAgent : public protocol::Network::Backend { private: std::unique_ptr createHeadersFromObject( v8::Local context, v8::Local headers_obj); + std::unique_ptr createInitiatorFromObject( + v8::Local context, v8::Local initiator_obj); std::unique_ptr createRequestFromObject( v8::Local context, v8::Local request); std::unique_ptr createResponseFromObject( diff --git a/test/parallel/test-diagnostics-channel-http.js b/test/parallel/test-diagnostics-channel-http.js index fd371a5d259f..ea342771cb6e 100644 --- a/test/parallel/test-diagnostics-channel-http.js +++ b/test/parallel/test-diagnostics-channel-http.js @@ -11,10 +11,18 @@ const isIncomingMessage = (object) => object instanceof http.IncomingMessage; const isOutgoingMessage = (object) => object instanceof http.OutgoingMessage; const isNetSocket = (socket) => socket instanceof net.Socket; const isError = (error) => error instanceof Error; +let postBodyChunkSent = 0; +let postBodySent = false; +let clientResponseBodyChunksReceived = 0; + +const verifyPostBody = common.mustCall(() => { + assert.strictEqual(postBodySent, true); + assert.strictEqual(clientResponseBodyChunksReceived, 2); +}); dc.subscribe('http.client.request.start', common.mustCall(({ request }) => { assert.strictEqual(isOutgoingMessage(request), true); -}, 2)); +}, 3)); dc.subscribe('http.client.request.error', common.mustCall(({ request, error }) => { assert.strictEqual(isOutgoingMessage(request), true); @@ -27,8 +35,40 @@ dc.subscribe('http.client.response.finish', common.mustCall(({ }) => { assert.strictEqual(isOutgoingMessage(request), true); assert.strictEqual(isIncomingMessage(response), true); +}, 2)); + +dc.subscribe('http.client.request.bodyChunkSent', common.mustCall(({ + request, + chunk, + encoding, +}) => { + assert.strictEqual(isOutgoingMessage(request), true); + assert.ok(typeof chunk === 'string' || chunk instanceof Uint8Array); + assert.ok( + typeof encoding === 'string' || + encoding === null || + encoding === undefined, + ); + postBodyChunkSent++; +}, 2)); + +dc.subscribe('http.client.request.bodySent', common.mustCall(({ request }) => { + assert.strictEqual(isOutgoingMessage(request), true); + assert.strictEqual(postBodyChunkSent, 2); + postBodySent = true; })); +dc.subscribe('http.client.response.bodyChunkReceived', common.mustCall(({ + request, + response, + chunk, +}) => { + assert.strictEqual(isOutgoingMessage(request), true); + assert.strictEqual(isIncomingMessage(response), true); + assert.ok(chunk instanceof Uint8Array); + clientResponseBodyChunksReceived++; +}, 2)); + dc.subscribe('http.server.request.start', common.mustCall(({ request, response, @@ -39,7 +79,7 @@ dc.subscribe('http.server.request.start', common.mustCall(({ assert.strictEqual(isOutgoingMessage(response), true); assert.strictEqual(isNetSocket(socket), true); assert.strictEqual(isHTTPServer(server), true); -})); +}, 2)); dc.subscribe('http.server.response.finish', common.mustCall(({ request, @@ -51,7 +91,7 @@ dc.subscribe('http.server.response.finish', common.mustCall(({ assert.strictEqual(isOutgoingMessage(response), true); assert.strictEqual(isNetSocket(socket), true); assert.strictEqual(isHTTPServer(server), true); -})); +}, 2)); dc.subscribe('http.server.response.created', common.mustCall(({ request, @@ -59,18 +99,21 @@ dc.subscribe('http.server.response.created', common.mustCall(({ }) => { assert.strictEqual(isIncomingMessage(request), true); assert.strictEqual(isOutgoingMessage(response), true); -})); +}, 2)); dc.subscribe('http.client.request.created', common.mustCall(({ request }) => { assert.strictEqual(isOutgoingMessage(request), true); assert.strictEqual(isHTTPServer(server), true); -}, 2)); +}, 3)); const server = http.createServer(common.mustCall((req, res) => { - res.end('done'); -})); + req.resume(); + req.on('end', () => { + res.end('done'); + }); +}, 2)); -server.listen(async () => { +server.listen(common.mustCall(async () => { const { port } = server.address(); const invalidRequest = http.get({ host: addresses.INVALID_HOST, @@ -78,10 +121,22 @@ server.listen(async () => { await new Promise((resolve) => { invalidRequest.on('error', resolve); }); - http.get(`http://localhost:${port}`, (res) => { + http.get(`http://localhost:${port}`, common.mustCall((res) => { res.resume(); - res.on('end', () => { - server.close(); - }); - }); -}); + res.on('end', common.mustCall(() => { + const post = http.request({ + hostname: 'localhost', + port, + method: 'POST', + }, common.mustCall((postRes) => { + postRes.resume(); + postRes.on('end', common.mustCall(() => { + verifyPostBody(); + server.close(); + })); + })); + post.write('foo'); + post.end(Buffer.from('bar')); + })); + })); +})); diff --git a/test/parallel/test-inspector-emit-protocol-event-errors.js b/test/parallel/test-inspector-emit-protocol-event-errors.js index 1a76a491c219..0bd4cac1680e 100644 --- a/test/parallel/test-inspector-emit-protocol-event-errors.js +++ b/test/parallel/test-inspector-emit-protocol-event-errors.js @@ -171,6 +171,92 @@ const NETWORK_ERROR_CASES = [ networkRequest({ request: omit(networkRequest().request, 'headers') }), 'Missing request.headers in event', ], + [ + 'requestWillBeSent', + networkRequest({ + requestId: 'request-id-missing-initiator-type', + initiator: {}, + }), + 'Missing initiator.type in event', + ], + [ + 'requestWillBeSent', + networkRequest({ + requestId: 'request-id-missing-initiator-stack-callframes', + initiator: { + type: 'script', + stack: {}, + }, + }), + 'Invalid initiator.stack in event', + ], + [ + 'requestWillBeSent', + networkRequest({ + requestId: 'request-id-invalid-initiator-stack-type', + initiator: { + type: 'script', + stack: [], + }, + }), + 'Invalid initiator.stack in event', + ], + [ + 'requestWillBeSent', + networkRequest({ + requestId: 'request-id-bigint-initiator-stack', + initiator: { + type: 'script', + stack: { + callFrames: [1n], + }, + }, + }), + 'Invalid initiator.stack in event', + ], + [ + 'requestWillBeSent', + networkRequest({ + requestId: 'request-id-symbol-initiator-stack', + initiator: { + type: 'script', + stack: { + callFrames: [Symbol('frame')], + }, + }, + }), + 'Invalid initiator.stack in event', + ], + [ + 'requestWillBeSent', + networkRequest({ + requestId: 'request-id-getter-throw-initiator-stack', + initiator: { + type: 'script', + stack: { + get callFrames() { + throw new Error('boom'); + }, + }, + }, + }), + 'Invalid initiator.stack in event', + ], + [ + 'requestWillBeSent', + networkRequest({ + requestId: 'request-id-ownkeys-throw-initiator-stack', + initiator: { + type: 'script', + stack: new Proxy({}, { + ownKeys() { + throw new Error('boom'); + }, + }), + }, + }), + 'Invalid initiator.stack in event', + ], [ 'responseReceived', diff --git a/test/parallel/test-inspector-emit-protocol-event.js b/test/parallel/test-inspector-emit-protocol-event.js index 7cb2135435f9..8fd460445a7b 100644 --- a/test/parallel/test-inspector-emit-protocol-event.js +++ b/test/parallel/test-inspector-emit-protocol-event.js @@ -6,6 +6,7 @@ common.skipIfInspectorDisabled(); const inspector = require('node:inspector/promises'); const assert = require('node:assert'); +const { once } = require('node:events'); const EXPECTED_EVENTS = { Network: [ @@ -194,6 +195,83 @@ for (const [domain, events] of Object.entries(EXPECTED_EVENTS)) { } } + session.removeAllListeners('Network.requestWillBeSent'); + { + const expectedInitiator = { + type: 'script', + stack: { + description: 'custom stack', + callFrames: [{ + functionName: 'customFunction', + scriptId: 'customScript', + url: 'file:///custom.js', + lineNumber: 12, + columnNumber: 34, + }], + parent: { + description: 'parent stack', + callFrames: [{ + functionName: 'parentFunction', + scriptId: 'parentScript', + url: 'file:///parent.js', + lineNumber: 56, + columnNumber: 78, + }], + }, + }, + url: 'file:///initiator.js', + lineNumber: 90, + columnNumber: 12, + requestId: 'previous-request-id', + }; + const customInitiator = { + ...expectedInitiator, + stack: { + ...expectedInitiator.stack, + ignoredNull: null, + ignoredUndefined: undefined, + ignoredBoolean: true, + ignoredNumber: 1.5, + }, + }; + const requestWillBeSent = once(session, 'Network.requestWillBeSent'); + inspector.Network.requestWillBeSent({ + ...EXPECTED_EVENTS.Network[0].params, + requestId: 'request-id-custom-initiator', + initiator: customInitiator, + }); + const [{ params }] = await requestWillBeSent; + assert.deepStrictEqual(params.initiator, expectedInitiator); + } + + session.removeAllListeners('Network.requestWillBeSent'); + { + const customInitiator = { type: 'other' }; + const requestWillBeSent = once(session, 'Network.requestWillBeSent'); + inspector.Network.requestWillBeSent({ + ...EXPECTED_EVENTS.Network[0].params, + requestId: 'request-id-custom-initiator-no-stack', + initiator: customInitiator, + }); + const [{ params }] = await requestWillBeSent; + assert.deepStrictEqual(params.initiator, customInitiator); + } + + session.removeAllListeners('Network.requestWillBeSent'); + { + const duplicateParams = { + ...EXPECTED_EVENTS.Network[0].params, + requestId: 'request-id-duplicate-custom-initiator', + initiator: { type: 'other' }, + }; + session.on('Network.requestWillBeSent', common.mustCall(({ params }) => { + assert.strictEqual(params.requestId, duplicateParams.requestId); + assert.deepStrictEqual(params.initiator, duplicateParams.initiator); + })); + inspector.Network.requestWillBeSent(duplicateParams); + inspector.Network.requestWillBeSent(duplicateParams); + } + // Check that no events are emitted after disabling the domain. await session.post('Network.disable'); session.on('Network.requestWillBeSent', common.mustNotCall()); diff --git a/test/parallel/test-inspector-network-http.js b/test/parallel/test-inspector-network-http.js index 88d717d83c89..1341eff78e02 100644 --- a/test/parallel/test-inspector-network-http.js +++ b/test/parallel/test-inspector-network-http.js @@ -8,9 +8,11 @@ const assert = require('node:assert'); const { once } = require('node:events'); const { addresses } = require('../common/internet'); const fixtures = require('../common/fixtures'); +const dc = require('node:diagnostics_channel'); const http = require('node:http'); const https = require('node:https'); const inspector = require('node:inspector/promises'); +const { setImmediate } = require('node:timers/promises'); const session = new inspector.Session(); session.connect(); @@ -113,6 +115,7 @@ function verifyRequestWillBeSent({ method, params }, expect) { assert.ok(params.requestId.startsWith('node-network-event-')); assert.strictEqual(params.request.url, expect.url); assert.strictEqual(params.request.method, expect.method ?? 'GET'); + assert.strictEqual(params.request.hasPostData, expect.hasPostData ?? false); 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'); @@ -124,6 +127,7 @@ function verifyRequestWillBeSent({ method, params }, expect) { assert.strictEqual(typeof params.initiator, 'object'); assert.strictEqual(params.initiator.type, 'script'); assert.ok(findFrameInInitiator(__filename, params.initiator)); + assert.ok(!findFrameInInitiator('node:internal/inspector/network_http', params.initiator)); return params; } @@ -194,6 +198,20 @@ function verifyHttpResponse(response) { })); } +function verifyHttpResponseWithEncoding(response) { + assert.strictEqual(response.statusCode, 200); + const chunks = []; + + response.setEncoding('hex'); + response.on('data', (chunk) => { + chunks.push(chunk); + }); + + response.on('end', common.mustCall(() => { + assert.strictEqual(chunks.join(''), Buffer.from('\nhello world\n').toString('hex')); + })); +} + function drainHttpResponse(response) { response.resume(); } @@ -203,6 +221,7 @@ function createRequestTracker(url, responseExpect, requestExpect = {}) { .then(([event]) => verifyRequestWillBeSent(event, { url, method: requestExpect.method, + hasPostData: requestExpect.hasPostData, })); const responseReceivedFuture = once(session, 'Network.responseReceived') @@ -287,6 +306,7 @@ async function testHttpPostWithAbsoluteUrlPath() { charset: 'utf-8', }, { method: 'POST', + hasPostData: true, }); const responsePromise = new Promise((resolve, reject) => { @@ -317,6 +337,96 @@ async function testHttpPostWithAbsoluteUrlPath() { })); } +async function testHttpPostRequestBody() { + const url = `http://127.0.0.1:${httpServer.address().port}/echo-post`; + const { + requestWillBeSentFuture, + responseReceivedFuture, + loadingFinishedFuture, + } = createRequestTracker(url, { + url, + mimeType: 'application/json', + charset: 'utf-8', + }, { + method: 'POST', + hasPostData: true, + }); + + const responsePromise = new Promise((resolve, reject) => { + const req = http.request({ + host: '127.0.0.1', + port: httpServer.address().port, + path: '/echo-post', + method: 'POST', + headers: { + ...requestHeaders, + 'Content-Type': 'text/plain; charset=utf-8', + }, + }, resolve); + req.on('error', reject); + req.write('foo'); + req.end('bar'); + }); + + const response = await responsePromise; + drainHttpResponse(response); + + const requestWillBeSent = await requestWillBeSentFuture; + const responseReceived = await responseReceivedFuture; + await loadingFinishedFuture; + const requestBody = await session.post('Network.getRequestPostData', { + requestId: requestWillBeSent.requestId, + }); + assert.strictEqual(requestBody.postData, 'foobar'); + await assertResponseBody(responseReceived, JSON.stringify({ + method: 'POST', + body: 'foobar', + })); +} + +async function testHttpPostBinaryRequestBody() { + const url = `http://127.0.0.1:${httpServer.address().port}/echo-post`; + const { + requestWillBeSentFuture, + responseReceivedFuture, + loadingFinishedFuture, + } = createRequestTracker(url, { + url, + mimeType: 'application/json', + charset: 'utf-8', + }, { + method: 'POST', + hasPostData: true, + }); + + const responsePromise = new Promise((resolve, reject) => { + const req = http.request({ + host: '127.0.0.1', + port: httpServer.address().port, + path: '/echo-post', + method: 'POST', + headers: { + ...requestHeaders, + 'Content-Type': 'application/octet-stream', + }, + }, resolve); + req.on('error', reject); + req.end(Buffer.from([0xff, 0x00, 0x7f])); + }); + + const response = await responsePromise; + drainHttpResponse(response); + + const requestWillBeSent = await requestWillBeSentFuture; + await responseReceivedFuture; + await loadingFinishedFuture; + await assert.rejects(session.post('Network.getRequestPostData', { + requestId: requestWillBeSent.requestId, + }), { + code: 'ERR_INSPECTOR_COMMAND', + }); +} + async function testHttpsGet() { const url = `https://127.0.0.1:${httpsServer.address().port}/hello-world`; const { @@ -342,6 +452,96 @@ async function testHttpsGet() { await assertResponseBody(responseReceived, '\nhello world\n'); } +async function testHttpsPostRequestBody() { + const url = `https://127.0.0.1:${httpsServer.address().port}/echo-post`; + const { + requestWillBeSentFuture, + responseReceivedFuture, + loadingFinishedFuture, + } = createRequestTracker(url, { + url, + mimeType: 'application/json', + charset: 'utf-8', + }, { + method: 'POST', + hasPostData: true, + }); + + const responsePromise = new Promise((resolve, reject) => { + const req = https.request({ + host: '127.0.0.1', + port: httpsServer.address().port, + path: '/echo-post', + method: 'POST', + rejectUnauthorized: false, + headers: { + ...requestHeaders, + 'Content-Type': 'text/plain; charset=utf-8', + }, + }, resolve); + req.on('error', reject); + req.end('secure body'); + }); + + const response = await responsePromise; + drainHttpResponse(response); + + const requestWillBeSent = await requestWillBeSentFuture; + const responseReceived = await responseReceivedFuture; + await loadingFinishedFuture; + const requestBody = await session.post('Network.getRequestPostData', { + requestId: requestWillBeSent.requestId, + }); + assert.strictEqual(requestBody.postData, 'secure body'); + await assertResponseBody(responseReceived, JSON.stringify({ + method: 'POST', + body: 'secure body', + })); +} + +async function testHttpResponseBodyWithEncoding() { + const url = `http://127.0.0.1:${httpServer.address().port}/hello-world`; + const { + requestWillBeSentFuture, + responseReceivedFuture, + loadingFinishedFuture, + } = createRequestTracker(url, getDefaultResponseExpect(url)); + + http.get({ + host: '127.0.0.1', + port: httpServer.address().port, + path: '/hello-world', + headers: requestHeaders, + }, common.mustCall(verifyHttpResponseWithEncoding)); + + await requestWillBeSentFuture; + const responseReceived = await responseReceivedFuture; + await loadingFinishedFuture; + await assertResponseBody(responseReceived, '\nhello world\n'); +} + +async function testUntrackedBodyDiagnosticsEvent() { + session.on('Network.requestWillBeSent', common.mustNotCall()); + session.on('Network.responseReceived', common.mustNotCall()); + session.on('Network.dataReceived', common.mustNotCall()); + + dc.channel('http.client.request.bodyChunkSent').publish({ + request: {}, + chunk: Buffer.from('foo'), + encoding: undefined, + }); + dc.channel('http.client.request.bodySent').publish({ + request: {}, + }); + dc.channel('http.client.response.bodyChunkReceived').publish({ + request: {}, + response: {}, + chunk: Buffer.from('bar'), + }); + + await setImmediate(); +} + async function testHttpError() { const url = `http://${addresses.INVALID_HOST}/`; const requestWillBeSentFuture = once(session, 'Network.requestWillBeSent') @@ -387,8 +587,18 @@ const testNetworkInspection = async () => { session.removeAllListeners(); await testHttpPostWithAbsoluteUrlPath(); session.removeAllListeners(); + await testHttpPostRequestBody(); + session.removeAllListeners(); + await testHttpPostBinaryRequestBody(); + session.removeAllListeners(); await testHttpsGet(); session.removeAllListeners(); + await testHttpsPostRequestBody(); + session.removeAllListeners(); + await testHttpResponseBodyWithEncoding(); + session.removeAllListeners(); + await testUntrackedBodyDiagnosticsEvent(); + session.removeAllListeners(); await testHttpError(); session.removeAllListeners(); await testHttpsError(); From 71b18a4e9a5cddc86a09d78d06f6a879b617c953 Mon Sep 17 00:00:00 2001 From: GrinZero <774933704@qq.com> Date: Sun, 23 Aug 2026 20:25:17 +0800 Subject: [PATCH 2/4] inspector: guard protocol value recursion Signed-off-by: GrinZero <774933704@qq.com> --- src/inspector/network_agent.cc | 92 ++++++++++++------- ...st-inspector-emit-protocol-event-errors.js | 57 ++++++++++++ 2 files changed, 118 insertions(+), 31 deletions(-) diff --git a/src/inspector/network_agent.cc b/src/inspector/network_agent.cc index d833b8e48f37..f7b74daaa947 100644 --- a/src/inspector/network_agent.cc +++ b/src/inspector/network_agent.cc @@ -16,14 +16,21 @@ namespace node { namespace inspector { +using v8::Array; +using v8::Boolean; +using v8::Context; using v8::HandleScope; +using v8::Int32; using v8::Isolate; using v8::Local; +using v8::LocalVector; +using v8::Number; using v8::Object; using v8::Uint8Array; using v8::Value; constexpr size_t kDefaultMaxTotalBufferSize = 100 * 1024 * 1024; // 100MB +constexpr size_t kMaxProtocolValueDepth = 100; static void ThrowEventError(v8::Isolate* isolate, const std::string& message) { isolate->ThrowException(v8::Exception::TypeError( @@ -31,24 +38,44 @@ static void ThrowEventError(v8::Isolate* isolate, const std::string& message) { } static std::unique_ptr V8ToProtocolValue( - Isolate* isolate, v8::Local context, Local value) { + Isolate* isolate, + Local context, + Local value, + LocalVector* ancestors) { if (value->IsNullOrUndefined()) { return protocol::Value::null(); } if (value->IsBoolean()) { - return protocol::FundamentalValue::create(value.As()->Value()); + return protocol::FundamentalValue::create(value.As()->Value()); } if (value->IsInt32()) { - return protocol::FundamentalValue::create(value.As()->Value()); + return protocol::FundamentalValue::create(value.As()->Value()); } if (value->IsNumber()) { - return protocol::FundamentalValue::create(value.As()->Value()); + return protocol::FundamentalValue::create(value.As()->Value()); } if (value->IsString()) { return protocol::StringValue::create(ToProtocolString(isolate, value)); } + + if (!value->IsObject()) { + return nullptr; + } + + Local object = value.As(); + if (ancestors->size() >= kMaxProtocolValueDepth) { + return nullptr; + } + for (const auto& ancestor : *ancestors) { + if (ancestor == object) { + return nullptr; + } + } + ancestors->push_back(object); + auto pop_ancestor = OnScopeLeave([ancestors]() { ancestors->pop_back(); }); + if (value->IsArray()) { - Local array = value.As(); + Local array = value.As(); std::unique_ptr list = protocol::ListValue::create(); list->reserve(array->Length()); for (uint32_t i = 0; i < array->Length(); i++) { @@ -57,7 +84,7 @@ static std::unique_ptr V8ToProtocolValue( return nullptr; } std::unique_ptr protocol_value = - V8ToProtocolValue(isolate, context, element); + V8ToProtocolValue(isolate, context, element, ancestors); if (!protocol_value) { return nullptr; } @@ -65,35 +92,38 @@ static std::unique_ptr V8ToProtocolValue( } return list; } - if (value->IsObject()) { - Local object = value.As(); - Local property_names; - if (!object->GetOwnPropertyNames(context).ToLocal(&property_names)) { + + Local property_names; + if (!object->GetOwnPropertyNames(context).ToLocal(&property_names)) { + return nullptr; + } + std::unique_ptr dict = + protocol::DictionaryValue::create(); + for (uint32_t i = 0; i < property_names->Length(); i++) { + Local property_name; + if (!property_names->Get(context, i).ToLocal(&property_name) || + !property_name->IsString()) { return nullptr; } - std::unique_ptr dict = - protocol::DictionaryValue::create(); - for (uint32_t i = 0; i < property_names->Length(); i++) { - Local property_name; - if (!property_names->Get(context, i).ToLocal(&property_name) || - !property_name->IsString()) { - return nullptr; - } - Local property; - if (!object->Get(context, property_name).ToLocal(&property)) { - return nullptr; - } - std::unique_ptr protocol_value = - V8ToProtocolValue(isolate, context, property); - if (!protocol_value) { - return nullptr; - } - dict->setValue(ToProtocolString(isolate, property_name), - std::move(protocol_value)); + Local property; + if (!object->Get(context, property_name).ToLocal(&property)) { + return nullptr; + } + std::unique_ptr protocol_value = + V8ToProtocolValue(isolate, context, property, ancestors); + if (!protocol_value) { + return nullptr; } - return dict; + dict->setValue(ToProtocolString(isolate, property_name), + std::move(protocol_value)); } - return nullptr; + return dict; +} + +static std::unique_ptr V8ToProtocolValue( + Isolate* isolate, Local context, Local value) { + LocalVector ancestors(isolate); + return V8ToProtocolValue(isolate, context, value, &ancestors); } // Create a protocol::Network::Headers from the v8 object. diff --git a/test/parallel/test-inspector-emit-protocol-event-errors.js b/test/parallel/test-inspector-emit-protocol-event-errors.js index 0bd4cac1680e..8a7a54612298 100644 --- a/test/parallel/test-inspector-emit-protocol-event-errors.js +++ b/test/parallel/test-inspector-emit-protocol-event-errors.js @@ -29,6 +29,30 @@ function networkRequest(overrides = {}) { }; } +const MAX_PROTOCOL_VALUE_DEPTH = 100; + +function circularObjectStack() { + const stack = { callFrames: [] }; + stack.parent = stack; + return stack; +} + +function circularArrayStack() { + const callFrames = []; + callFrames.push(callFrames); + return { callFrames }; +} + +function deeplyNestedStack(depth) { + const stack = { callFrames: [] }; + let current = stack; + for (let i = 0; i < depth; i++) { + current.parent = { callFrames: [] }; + current = current.parent; + } + return stack; +} + function networkResponse(overrides = {}) { return { requestId: 'response-id', @@ -257,6 +281,39 @@ const NETWORK_ERROR_CASES = [ }), 'Invalid initiator.stack in event', ], + [ + 'requestWillBeSent', + networkRequest({ + requestId: 'request-id-circular-object-initiator-stack', + initiator: { + type: 'script', + stack: circularObjectStack(), + }, + }), + 'Invalid initiator.stack in event', + ], + [ + 'requestWillBeSent', + networkRequest({ + requestId: 'request-id-circular-array-initiator-stack', + initiator: { + type: 'script', + stack: circularArrayStack(), + }, + }), + 'Invalid initiator.stack in event', + ], + [ + 'requestWillBeSent', + networkRequest({ + requestId: 'request-id-deep-initiator-stack', + initiator: { + type: 'script', + stack: deeplyNestedStack(MAX_PROTOCOL_VALUE_DEPTH + 1), + }, + }), + 'Invalid initiator.stack in event', + ], [ 'responseReceived', From f84e2fadbdebae1d31049f03590d376b46df71ea Mon Sep 17 00:00:00 2001 From: GrinZero <774933704@qq.com> Date: Wed, 30 Sep 2026 14:06:30 +0800 Subject: [PATCH 3/4] inspector: reduce http inspection overhead Encode initiator stacks directly into CBOR to avoid the intermediate protocol value tree. Retain asynchronous caller frames, cycle checks and the maximum nesting depth. Pass Buffer and Uint8Array chunks directly to the native request-body cache, which synchronously copies the bytes, and omit unused fields from the body-completion notification. Add regression coverage for reused request buffers, delayed and async initiators, and stack serialization. Signed-off-by: GrinZero <774933704@qq.com> Assisted-by: Codex --- lib/internal/inspector/network_http.js | 7 +- src/inspector/network_agent.cc | 148 +++++++++--------- ...ector-network-http-body-buffer-mutation.js | 60 +++++++ ...-inspector-network-http-initiator-async.js | 38 +++++ ...nspector-network-http-initiator-delayed.js | 68 ++++++++ ...spector-network-initiator-serialization.js | 61 ++++++++ 6 files changed, 300 insertions(+), 82 deletions(-) create mode 100644 test/parallel/test-inspector-network-http-body-buffer-mutation.js create mode 100644 test/parallel/test-inspector-network-http-initiator-async.js create mode 100644 test/parallel/test-inspector-network-http-initiator-delayed.js create mode 100644 test/parallel/test-inspector-network-initiator-serialization.js diff --git a/lib/internal/inspector/network_http.js b/lib/internal/inspector/network_http.js index df5bbf98f3d3..b90e31346023 100644 --- a/lib/internal/inspector/network_http.js +++ b/lib/internal/inspector/network_http.js @@ -195,7 +195,9 @@ function onClientRequestBodyChunkSent({ request, chunk, encoding }) { const buffer = typeof chunk === 'string' ? Buffer.from(chunk, encoding ?? undefined) : - Buffer.from(chunk); + chunk; + // The native request-body cache synchronously copies the bytes, so there is + // no need to copy Buffer or Uint8Array chunks before passing them to it. Network.dataSent({ requestId: request[kInspectorRequestId], timestamp: getMonotonicTime(), @@ -215,9 +217,6 @@ function onClientRequestBodySent({ request }) { Network.dataSent({ requestId: request[kInspectorRequestId], - timestamp: getMonotonicTime(), - dataLength: 0, - data: Buffer.alloc(0), finished: true, }); } diff --git a/src/inspector/network_agent.cc b/src/inspector/network_agent.cc index f7b74daaa947..98d5d7eb549e 100644 --- a/src/inspector/network_agent.cc +++ b/src/inspector/network_agent.cc @@ -1,5 +1,6 @@ #include "network_agent.h" #include +#include "crdtp/cbor.h" #include "debug_utils-inl.h" #include "env-inl.h" #include "inspector/inspector_object_utils.h" @@ -37,93 +38,88 @@ static void ThrowEventError(v8::Isolate* isolate, const std::string& message) { v8::String::NewFromUtf8(isolate, message.c_str()).ToLocalChecked())); } -static std::unique_ptr V8ToProtocolValue( - Isolate* isolate, - Local context, - Local value, - LocalVector* ancestors) { +// Encode directly into the format consumed by the imported V8 StackTrace +// parser, avoiding an intermediate tree of protocol::Value allocations. +static bool EncodeV8Value(Isolate* isolate, + Local context, + Local value, + LocalVector* ancestors, + std::vector* bytes, + bool* has_call_frames = nullptr) { + namespace cbor = crdtp::cbor; if (value->IsNullOrUndefined()) { - return protocol::Value::null(); + bytes->push_back(cbor::EncodeNull()); + return true; } if (value->IsBoolean()) { - return protocol::FundamentalValue::create(value.As()->Value()); + bytes->push_back(value.As()->Value() ? cbor::EncodeTrue() + : cbor::EncodeFalse()); + return true; } if (value->IsInt32()) { - return protocol::FundamentalValue::create(value.As()->Value()); + cbor::EncodeInt32(value.As()->Value(), bytes); + return true; } if (value->IsNumber()) { - return protocol::FundamentalValue::create(value.As()->Value()); + cbor::EncodeDouble(value.As()->Value(), bytes); + return true; } if (value->IsString()) { - return protocol::StringValue::create(ToProtocolString(isolate, value)); - } - - if (!value->IsObject()) { - return nullptr; + Utf8Value string(isolate, value); + cbor::EncodeString8( + crdtp::span(reinterpret_cast(*string), + string.length()), + bytes); + return true; } + if (!value->IsObject()) return false; Local object = value.As(); - if (ancestors->size() >= kMaxProtocolValueDepth) { - return nullptr; - } + if (ancestors->size() >= kMaxProtocolValueDepth) return false; for (const auto& ancestor : *ancestors) { - if (ancestor == object) { - return nullptr; - } + if (ancestor == object) return false; } ancestors->push_back(object); auto pop_ancestor = OnScopeLeave([ancestors]() { ancestors->pop_back(); }); - + cbor::EnvelopeEncoder envelope; + envelope.EncodeStart(bytes); if (value->IsArray()) { Local array = value.As(); - std::unique_ptr list = protocol::ListValue::create(); - list->reserve(array->Length()); + bytes->push_back(cbor::EncodeIndefiniteLengthArrayStart()); for (uint32_t i = 0; i < array->Length(); i++) { Local element; - if (!array->Get(context, i).ToLocal(&element)) { - return nullptr; - } - std::unique_ptr protocol_value = - V8ToProtocolValue(isolate, context, element, ancestors); - if (!protocol_value) { - return nullptr; + if (!array->Get(context, i).ToLocal(&element) || + !EncodeV8Value(isolate, context, element, ancestors, bytes)) { + return false; } - list->pushValue(std::move(protocol_value)); - } - return list; - } - - Local property_names; - if (!object->GetOwnPropertyNames(context).ToLocal(&property_names)) { - return nullptr; - } - std::unique_ptr dict = - protocol::DictionaryValue::create(); - for (uint32_t i = 0; i < property_names->Length(); i++) { - Local property_name; - if (!property_names->Get(context, i).ToLocal(&property_name) || - !property_name->IsString()) { - return nullptr; } - Local property; - if (!object->Get(context, property_name).ToLocal(&property)) { - return nullptr; + } else { + Local property_names; + if (!object->GetOwnPropertyNames(context).ToLocal(&property_names)) { + return false; } - std::unique_ptr protocol_value = - V8ToProtocolValue(isolate, context, property, ancestors); - if (!protocol_value) { - return nullptr; + bytes->push_back(cbor::EncodeIndefiniteLengthMapStart()); + for (uint32_t i = 0; i < property_names->Length(); i++) { + Local property_name; + if (!property_names->Get(context, i).ToLocal(&property_name) || + !property_name->IsString()) { + return false; + } + if (has_call_frames != nullptr && + property_name.As()->StringEquals( + FIXED_ONE_BYTE_STRING(isolate, "callFrames"))) { + *has_call_frames = true; + } + Local property; + if (!object->Get(context, property_name).ToLocal(&property) || + !EncodeV8Value(isolate, context, property_name, ancestors, bytes) || + !EncodeV8Value(isolate, context, property, ancestors, bytes)) { + return false; + } } - dict->setValue(ToProtocolString(isolate, property_name), - std::move(protocol_value)); } - return dict; -} - -static std::unique_ptr V8ToProtocolValue( - Isolate* isolate, Local context, Local value) { - LocalVector ancestors(isolate); - return V8ToProtocolValue(isolate, context, value, &ancestors); + bytes->push_back(cbor::EncodeStop()); + return envelope.EncodeStop(bytes); } // Create a protocol::Network::Headers from the v8 object. @@ -179,25 +175,21 @@ NetworkAgent::createInitiatorFromObject(v8::Local context, Local stack_obj; if (ObjectGetObject(context, initiator_obj, "stack").ToLocal(&stack_obj)) { - std::unique_ptr stack_value = - V8ToProtocolValue(isolate, context, stack_obj); - if (!stack_value) { + LocalVector ancestors(isolate); + std::vector bytes; + bool has_call_frames = false; + if (!EncodeV8Value(isolate, + context, + stack_obj, + &ancestors, + &bytes, + &has_call_frames) || + !has_call_frames) { ThrowEventError(isolate, "Invalid initiator.stack in event"); return {}; } - - protocol::DictionaryValue* stack_dict = - protocol::DictionaryValue::cast(stack_value.get()); - if (!stack_dict || stack_dict->get("callFrames") == nullptr) { - ThrowEventError(isolate, "Invalid initiator.stack in event"); - return {}; - } - - protocol::ErrorSupport errors; - std::unique_ptr stack = - protocol::ValueConversions::fromValue(stack_value.get(), - &errors); + auto stack = v8_inspector::protocol::Runtime::API::StackTrace::fromBinary( + bytes.data(), bytes.size()); if (!stack) { ThrowEventError(isolate, "Invalid initiator.stack in event"); return {}; diff --git a/test/parallel/test-inspector-network-http-body-buffer-mutation.js b/test/parallel/test-inspector-network-http-body-buffer-mutation.js new file mode 100644 index 000000000000..3fa231fbedd9 --- /dev/null +++ b/test/parallel/test-inspector-network-http-body-buffer-mutation.js @@ -0,0 +1,60 @@ +// Flags: --experimental-network-inspection +'use strict'; +const common = require('../common'); +common.skipIfInspectorDisabled(); + +const assert = require('node:assert'); +const http = require('node:http'); +const { Session } = require('node:inspector/promises'); + +// The inspector cache must own its bytes even when the HTTP caller reuses +// a Buffer or a view into a larger Uint8Array after writing it. +async function main() { + const session = new Session(); + session.connect(); + await session.post('Network.enable'); + + const server = http.createServer(common.mustCall((req, res) => { + const chunks = []; + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', common.mustCall(() => { + assert.strictEqual(Buffer.concat(chunks).toString(), 'buffer-view-ü'); + res.end(); + })); + })); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + + let requestId; + session.on('Network.requestWillBeSent', common.mustCall(({ params }) => { + requestId = params.requestId; + assert.strictEqual(params.request.hasPostData, true); + })); + const finished = new Promise((resolve) => { + session.once('Network.loadingFinished', resolve); + }); + const req = http.request({ + host: '127.0.0.1', + port: server.address().port, + method: 'POST', + headers: { 'Content-Type': 'text/plain; charset=utf-8' }, + }, common.mustCall((res) => res.resume())); + req.on('error', common.mustNotCall()); + + const buffer = Buffer.from('buffer-'); + await new Promise((resolve) => req.write(buffer, resolve)); + buffer.fill(0x78); + + const backing = new Uint8Array(Buffer.from('!view-?')); + const view = backing.subarray(1, backing.length - 1); + await new Promise((resolve) => req.write(view, resolve)); + backing.fill(0x79); + req.end('ü', 'utf8'); + + await finished; + const { postData } = await session.post('Network.getRequestPostData', { requestId }); + assert.strictEqual(postData, 'buffer-view-ü'); + session.disconnect(); + await new Promise((resolve) => server.close(resolve)); +} + +main().then(common.mustCall()); diff --git a/test/parallel/test-inspector-network-http-initiator-async.js b/test/parallel/test-inspector-network-http-initiator-async.js new file mode 100644 index 000000000000..c81e58bdf143 --- /dev/null +++ b/test/parallel/test-inspector-network-http-initiator-async.js @@ -0,0 +1,38 @@ +// Flags: --experimental-network-inspection +'use strict'; +const common = require('../common'); +common.skipIfInspectorDisabled(); +const assert = require('node:assert'); +const http = require('node:http'); +const { Session } = require('node:inspector/promises'); + +async function main() { + const session = new Session(); + session.connect(); + await session.post('Network.enable'); + const server = http.createServer(common.mustCall((req, res) => res.end())); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const event = new Promise((resolve) => session.once('Network.requestWillBeSent', resolve)); + const finished = new Promise((resolve) => session.once('Network.loadingFinished', resolve)); + async function requestFromAsync() { + await Promise.resolve(); + const req = http.request({ port: server.address().port }, (res) => res.resume()); + req.on('error', common.mustNotCall()); + req.end(); + } + + async function parentAsync() { + await requestFromAsync(); + } + await parentAsync(); + const { params } = await event; + const frames = params.initiator.stack.callFrames; + assert.strictEqual(frames[0].functionName, 'requestFromAsync'); + assert.strictEqual(frames[0].url, __filename); + assert.ok(frames.some((frame) => frame.functionName === 'parentAsync' && frame.url === __filename)); + assert.ok(frames.some((frame) => frame.functionName === 'main' && frame.url === __filename)); + await finished; + session.disconnect(); + await new Promise((resolve) => server.close(resolve)); +} +main().then(common.mustCall()); diff --git a/test/parallel/test-inspector-network-http-initiator-delayed.js b/test/parallel/test-inspector-network-http-initiator-delayed.js new file mode 100644 index 000000000000..f090f16018dd --- /dev/null +++ b/test/parallel/test-inspector-network-http-initiator-delayed.js @@ -0,0 +1,68 @@ +// Flags: --experimental-network-inspection +'use strict'; +const common = require('../common'); +common.skipIfInspectorDisabled(); + +const assert = require('node:assert'); +const http = require('node:http'); +const { Session } = require('node:inspector/promises'); + +async function main() { + const session = new Session(); + session.connect(); + await session.post('Network.enable'); + const server = http.createServer(common.mustCall((req, res) => res.end())); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + + // Capture the original caller even if the body is sent from a later callback, + // and do not depend on user changes to Error stack formatting or limits. + const oldLimit = Error.stackTraceLimit; + const oldPrepare = Error.prepareStackTrace; + Error.stackTraceLimit = 0; + Error.prepareStackTrace = common.mustNotCall(); + let request; + function createRequest() { + // Keep this next line paired with the marker used below. + request = http.request({ + host: '127.0.0.1', + port: server.address().port, + method: 'POST', + headers: { 'Content-Type': 'text/plain; charset=utf-8' }, + }, (res) => res.resume()); + request.on('error', common.mustNotCall()); + } + + function nested(depth) { + if (depth === 0) createRequest(); + else nested(depth - 1); + } + + function originalCaller() { + nested(220); + } + const source = require('node:fs').readFileSync(__filename, 'utf8').split('\n'); + const creationLine = source.findIndex((line) => line.trim().startsWith('request = http.request(')); + originalCaller(); + Error.stackTraceLimit = oldLimit; + Error.prepareStackTrace = oldPrepare; + + const event = new Promise((resolve) => session.once('Network.requestWillBeSent', resolve)); + const finished = new Promise((resolve) => session.once('Network.loadingFinished', resolve)); + setImmediate(function sendFromLaterCallback() { + request.end('hello'); + }); + const { params } = await event; + const frames = params.initiator.stack.callFrames; + assert.strictEqual(frames[0].url, __filename); + assert.strictEqual(frames[0].functionName, 'createRequest'); + assert.strictEqual(frames[0].lineNumber, creationLine); + assert.ok(frames[0].columnNumber >= 0); + assert.ok(frames.some((frame) => frame.functionName === 'originalCaller')); + assert.ok(!frames.some((frame) => frame.functionName === 'sendFromLaterCallback')); + await finished; + const { postData } = await session.post('Network.getRequestPostData', { requestId: params.requestId }); + assert.strictEqual(postData, 'hello'); + session.disconnect(); + await new Promise((resolve) => server.close(resolve)); +} +main().then(common.mustCall()); diff --git a/test/parallel/test-inspector-network-initiator-serialization.js b/test/parallel/test-inspector-network-initiator-serialization.js new file mode 100644 index 000000000000..f45baed71cd1 --- /dev/null +++ b/test/parallel/test-inspector-network-initiator-serialization.js @@ -0,0 +1,61 @@ +// Flags: --experimental-network-inspection +'use strict'; +const common = require('../common'); +common.skipIfInspectorDisabled(); + +const assert = require('node:assert'); +const { Network, Session } = require('node:inspector'); +const session = new Session(); +session.connect(); +session.post('Network.enable'); + +const frame = { + functionName: '函数ü🙂', + scriptId: '42', + url: 'file:///路径.js', + lineNumber: 4, + columnNumber: 6, +}; +const stack = { + callFrames: [frame, { ...frame, functionName: 'parent' }], + description: 'async', + parent: { callFrames: [frame] }, + parentId: { id: '17', debuggerId: '18' }, +}; +session.once('Network.requestWillBeSent', common.mustCall(({ params }) => { + assert.deepStrictEqual(params.initiator.stack, stack); +})); +let id = 0; +function emit(stack) { + Network.requestWillBeSent({ + requestId: `serialization-${id++}`, + timestamp: 1, + wallTime: 1, + initiator: { type: 'script', stack }, + request: { url: 'http://example.org/', method: 'GET', headers: {} }, + }); +} +emit(stack); + +const cyclic = { callFrames: [], parent: null }; +cyclic.parent = cyclic; +const cyclicArray = []; +cyclicArray.push(cyclicArray); +let deep = { callFrames: [] }; +for (let i = 0; i < 101; i++) deep = { callFrames: [], parent: deep }; +for (const invalid of [ + cyclic, + { callFrames: [], extra: cyclicArray }, + deep, + {}, + { callFrames: [], extra: Symbol() }, +]) { + assert.throws(() => emit(invalid), { name: 'TypeError', message: 'Invalid initiator.stack in event' }); +} +// A shared object in separate branches is not an ancestor cycle. +emit({ callFrames: [frame, frame], extra: { value: null, flag: true, number: 1.5 } }); +assert.throws(() => emit({ + callFrames: [], + get extra() { throw new Error('getter error'); }, +}), { name: 'TypeError', message: 'Invalid initiator.stack in event' }); +session.disconnect(); From f8cc3b7ccc69d0ff55d16d8a0c01a8e6ef3ca159 Mon Sep 17 00:00:00 2001 From: GrinZero <774933704@qq.com> Date: Wed, 30 Sep 2026 15:25:37 +0800 Subject: [PATCH 4/4] test: declare inspector dependency Add --inspect=0 to the new network-inspection tests so the test runner recognizes their Inspector dependency. Builds without Inspector must omit these flags before the tests can skip unsupported functionality. Keep the regression assertions unchanged. Assisted-by: Codex Signed-off-by: GrinZero <774933704@qq.com> --- .../test-inspector-network-http-body-buffer-mutation.js | 2 +- test/parallel/test-inspector-network-http-initiator-async.js | 2 +- test/parallel/test-inspector-network-http-initiator-delayed.js | 2 +- test/parallel/test-inspector-network-initiator-serialization.js | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/test/parallel/test-inspector-network-http-body-buffer-mutation.js b/test/parallel/test-inspector-network-http-body-buffer-mutation.js index 3fa231fbedd9..7c4275fe72e2 100644 --- a/test/parallel/test-inspector-network-http-body-buffer-mutation.js +++ b/test/parallel/test-inspector-network-http-body-buffer-mutation.js @@ -1,4 +1,4 @@ -// Flags: --experimental-network-inspection +// Flags: --inspect=0 --experimental-network-inspection 'use strict'; const common = require('../common'); common.skipIfInspectorDisabled(); diff --git a/test/parallel/test-inspector-network-http-initiator-async.js b/test/parallel/test-inspector-network-http-initiator-async.js index c81e58bdf143..1f5449c28b93 100644 --- a/test/parallel/test-inspector-network-http-initiator-async.js +++ b/test/parallel/test-inspector-network-http-initiator-async.js @@ -1,4 +1,4 @@ -// Flags: --experimental-network-inspection +// Flags: --inspect=0 --experimental-network-inspection 'use strict'; const common = require('../common'); common.skipIfInspectorDisabled(); diff --git a/test/parallel/test-inspector-network-http-initiator-delayed.js b/test/parallel/test-inspector-network-http-initiator-delayed.js index f090f16018dd..c34e659aecdf 100644 --- a/test/parallel/test-inspector-network-http-initiator-delayed.js +++ b/test/parallel/test-inspector-network-http-initiator-delayed.js @@ -1,4 +1,4 @@ -// Flags: --experimental-network-inspection +// Flags: --inspect=0 --experimental-network-inspection 'use strict'; const common = require('../common'); common.skipIfInspectorDisabled(); diff --git a/test/parallel/test-inspector-network-initiator-serialization.js b/test/parallel/test-inspector-network-initiator-serialization.js index f45baed71cd1..882ff60e88e0 100644 --- a/test/parallel/test-inspector-network-initiator-serialization.js +++ b/test/parallel/test-inspector-network-initiator-serialization.js @@ -1,4 +1,4 @@ -// Flags: --experimental-network-inspection +// Flags: --inspect=0 --experimental-network-inspection 'use strict'; const common = require('../common'); common.skipIfInspectorDisabled();