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..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();