From 4bd2033b518c9c6d0270052bfd98a3367319773b Mon Sep 17 00:00:00 2001 From: GrinZero <774933704@qq.com> Date: Wed, 8 Jul 2026 08:43:57 +0800 Subject: [PATCH] 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 | 156 +++++++++++-- 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 | 75 +++++++ .../test-inspector-emit-protocol-event.js | 53 +++++ test/parallel/test-inspector-network-http.js | 210 ++++++++++++++++++ 12 files changed, 782 insertions(+), 36 deletions(-) diff --git a/doc/api/diagnostics_channel.md b/doc/api/diagnostics_channel.md index c21e83e114572b..df896ad45b1639 100644 --- a/doc/api/diagnostics_channel.md +++ b/doc/api/diagnostics_channel.md @@ -1579,6 +1579,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} @@ -1586,6 +1600,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 891da4e3f9a984..75bdea356fe638 100644 --- a/lib/_http_client.js +++ b/lib/_http_client.js @@ -62,6 +62,8 @@ const { URL, urlToHttpOptions, isURL } = require('internal/url'); const { kOutHeaders, kNeedDrain, + kOnOutgoingBodyChunkSent, + kOnOutgoingBodySent, isTraceHTTPEnabled, traceBegin, traceEnd, @@ -101,6 +103,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) { @@ -652,6 +658,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 d5e7bdedee39fb..b281720a8b35dd 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 7694fe4b5b3f3e..aaaa227c5eaf26 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, utcDate, kNeedDrain } = require('internal/http'); +const { + kOnOutgoingBodyChunkSent, + kOnOutgoingBodySent, + kOutHeaders, + utcDate, + kNeedDrain, +} = require('internal/http'); const { Buffer } = require('buffer'); const { _checkIsHttpToken: checkIsHttpToken, @@ -87,6 +93,7 @@ const kBytesWritten = Symbol('kBytesWritten'); const kErrored = Symbol('errored'); const kHighWaterMark = Symbol('kHighWaterMark'); const kRejectNonStandardBodyWrites = Symbol('kRejectNonStandardBodyWrites'); +const kHasOutgoingBodyChunks = Symbol('kHasOutgoingBodyChunks'); const nop = () => {}; @@ -155,6 +162,7 @@ function OutgoingMessage(options) { this[kErrored] = null; this[kHighWaterMark] = options?.highWaterMark ?? getDefaultHighWaterMark(); this[kRejectNonStandardBodyWrites] = options?.rejectNonStandardBodyWrites ?? false; + this[kHasOutgoingBodyChunks] = false; } ObjectSetPrototypeOf(OutgoingMessage.prototype, Stream.prototype); ObjectSetPrototypeOf(OutgoingMessage, Stream); @@ -1002,6 +1010,11 @@ function write_(msg, chunk, encoding, callback, fromEnd) { process.nextTick(connectionCorkNT, msg.socket); } + 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; @@ -1151,6 +1164,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 116d699f505a4f..a39a4476f9fac8 100644 --- a/lib/internal/http.js +++ b/lib/internal/http.js @@ -268,6 +268,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 1bb08762bdb418..0c093cb47b8944 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' && @@ -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 ace8ba52287186..d833b8e48f3732 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 2136a45baf45f6..a16e1781e4c9c6 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 fd371a5d259f0b..ea342771cb6e61 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 1a76a491c2195c..4ee1aa031a4cbc 100644 --- a/test/parallel/test-inspector-emit-protocol-event-errors.js +++ b/test/parallel/test-inspector-emit-protocol-event-errors.js @@ -171,6 +171,81 @@ 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-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 567c92e3eeba6a..1f79179d53deb2 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,58 @@ for (const [domain, events] of Object.entries(EXPECTED_EVENTS)) { } } + session.removeAllListeners('Network.requestWillBeSent'); + { + const customInitiator = { + type: 'script', + stack: { + callFrames: [{ + functionName: 'customFunction', + scriptId: 'customScript', + url: 'file:///custom.js', + lineNumber: 12, + columnNumber: 34, + }], + }, + }; + 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, customInitiator); + } + + 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 tht 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 88d717d83c896a..1341eff78e022d 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();