From c140813daa0a905e21dfccd793b3ad9742c134d1 Mon Sep 17 00:00:00 2001 From: GrinZero <774933704@qq.com> Date: Fri, 16 Oct 2026 11:35:03 +0800 Subject: [PATCH] inspector,http: support http body tracking Track HTTP client request bodies through diagnostics channels so Network.getRequestPostData can return text request payloads. Capture response bytes before stream decoding to preserve response bodies when applications use setEncoding(). Capture initiator stacks at request construction and validate them with the inspector protocol schema. Encode stacks directly into CBOR with cycle and depth guards, and pass byte chunks directly to the native request-body cache. Add coverage for body diagnostics, request and response payloads, reused buffers, delayed and async initiators, and stack validation. Assisted-by: Codex Signed-off-by: GrinZero <774933704@qq.com> --- doc/api/diagnostics_channel.md | 22 ++ lib/internal/http.js | 2 + lib/internal/http/client.js | 25 +++ lib/internal/http/common.js | 15 ++ lib/internal/http/outgoing.js | 19 +- lib/internal/inspector/network_http.js | 157 +++++++++++-- src/inspector/network_agent.cc | 178 ++++++++++++++- src/inspector/network_agent.h | 2 + .../parallel/test-diagnostics-channel-http.js | 83 +++++-- ...st-inspector-emit-protocol-event-errors.js | 143 ++++++++++++ .../test-inspector-emit-protocol-event.js | 78 +++++++ ...ector-network-http-body-buffer-mutation.js | 60 +++++ ...-inspector-network-http-initiator-async.js | 38 ++++ ...nspector-network-http-initiator-delayed.js | 68 ++++++ test/parallel/test-inspector-network-http.js | 210 ++++++++++++++++++ ...spector-network-initiator-serialization.js | 61 +++++ 16 files changed, 1124 insertions(+), 37 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/doc/api/diagnostics_channel.md b/doc/api/diagnostics_channel.md index 25e544ccf27b..fe2fb0cd7e10 100644 --- a/doc/api/diagnostics_channel.md +++ b/doc/api/diagnostics_channel.md @@ -1655,6 +1655,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} @@ -1662,6 +1676,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/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/http/client.js b/lib/internal/http/client.js index 1952e7b69afb..7518e88b4d6a 100644 --- a/lib/internal/http/client.js +++ b/lib/internal/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/internal/http/common.js b/lib/internal/http/common.js index ed1defc49f4f..6feefdbb4f5d 100644 --- a/lib/internal/http/common.js +++ b/lib/internal/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/internal/http/outgoing.js b/lib/internal/http/outgoing.js index ad0ad6dc372c..59f21f92b976 100644 --- a/lib/internal/http/outgoing.js +++ b/lib/internal/http/outgoing.js @@ -37,7 +37,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, @@ -111,6 +117,7 @@ const kEndCallbacks = Symbol('kEndCallbacks'); const kFlushError = Symbol('kFlushError'); const kHighWaterMark = Symbol('kHighWaterMark'); const kRejectNonStandardBodyWrites = Symbol('kRejectNonStandardBodyWrites'); +const kHasOutgoingBodyChunks = Symbol('kHasOutgoingBodyChunks'); const nop = () => {}; @@ -199,6 +206,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); @@ -1138,6 +1146,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; @@ -1373,6 +1386,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/inspector/network_http.js b/lib/internal/inspector/network_http.js index 1bb08762bdb4..b90e31346023 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,74 @@ 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) : + 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(), + 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], + 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 +268,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 +279,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..98d5d7eb549e 100644 --- a/src/inspector/network_agent.cc +++ b/src/inspector/network_agent.cc @@ -1,11 +1,13 @@ #include "network_agent.h" #include +#include "crdtp/cbor.h" #include "debug_utils-inl.h" #include "env-inl.h" #include "inspector/inspector_object_utils.h" #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" @@ -15,20 +17,111 @@ 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( v8::String::NewFromUtf8(isolate, message.c_str()).ToLocalChecked())); } +// 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()) { + bytes->push_back(cbor::EncodeNull()); + return true; + } + if (value->IsBoolean()) { + bytes->push_back(value.As()->Value() ? cbor::EncodeTrue() + : cbor::EncodeFalse()); + return true; + } + if (value->IsInt32()) { + cbor::EncodeInt32(value.As()->Value(), bytes); + return true; + } + if (value->IsNumber()) { + cbor::EncodeDouble(value.As()->Value(), bytes); + return true; + } + if (value->IsString()) { + 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 false; + for (const auto& ancestor : *ancestors) { + 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(); + bytes->push_back(cbor::EncodeIndefiniteLengthArrayStart()); + for (uint32_t i = 0; i < array->Length(); i++) { + Local element; + if (!array->Get(context, i).ToLocal(&element) || + !EncodeV8Value(isolate, context, element, ancestors, bytes)) { + return false; + } + } + } else { + Local property_names; + if (!object->GetOwnPropertyNames(context).ToLocal(&property_names)) { + return false; + } + 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; + } + } + } + bytes->push_back(cbor::EncodeStop()); + return envelope.EncodeStop(bytes); +} + // Create a protocol::Network::Headers from the v8 object. std::unique_ptr NetworkAgent::createHeadersFromObject(v8::Local context, @@ -65,6 +158,70 @@ 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)) { + 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 {}; + } + auto stack = v8_inspector::protocol::Runtime::API::StackTrace::fromBinary( + bytes.data(), bytes.size()); + 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 +617,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..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', @@ -171,6 +195,125 @@ 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', + ], + [ + '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', 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-body-buffer-mutation.js b/test/parallel/test-inspector-network-http-body-buffer-mutation.js new file mode 100644 index 000000000000..7c4275fe72e2 --- /dev/null +++ b/test/parallel/test-inspector-network-http-body-buffer-mutation.js @@ -0,0 +1,60 @@ +// Flags: --inspect=0 --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..1f5449c28b93 --- /dev/null +++ b/test/parallel/test-inspector-network-http-initiator-async.js @@ -0,0 +1,38 @@ +// Flags: --inspect=0 --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..c34e659aecdf --- /dev/null +++ b/test/parallel/test-inspector-network-http-initiator-delayed.js @@ -0,0 +1,68 @@ +// Flags: --inspect=0 --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-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(); 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..882ff60e88e0 --- /dev/null +++ b/test/parallel/test-inspector-network-initiator-serialization.js @@ -0,0 +1,61 @@ +// Flags: --inspect=0 --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();