diff --git a/doc/api/diagnostics_channel.md b/doc/api/diagnostics_channel.md index b47f98ce64211c..79f9e4dec49461 100644 --- a/doc/api/diagnostics_channel.md +++ b/doc/api/diagnostics_channel.md @@ -1576,6 +1576,21 @@ 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 chunk of the client request body is being sent. + +##### Event: `'http.client.request.bodySent'` + +* `request` {http.ClientRequest} + +Emitted after the client request body has been fully sent, if a request body +was written. + ##### Event: `'http.client.request.error'` * `request` {http.ClientRequest} @@ -1583,6 +1598,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} + +Emitted when a chunk of the client response body is received. + ##### Event: `'http.client.response.finish'` * `request` {http.ClientRequest} diff --git a/lib/_http_client.js b/lib/_http_client.js index b7f0aa759b0643..410348ae46656c 100644 --- a/lib/_http_client.js +++ b/lib/_http_client.js @@ -60,6 +60,7 @@ const { Buffer } = require('buffer'); const { defaultTriggerAsyncIdScope } = require('internal/async_hooks'); const { URL, urlToHttpOptions, isURL } = require('internal/url'); const { + kIsClientRequest, kOutHeaders, kNeedDrain, isTraceHTTPEnabled, @@ -192,6 +193,7 @@ function rewriteForProxiedHttp(req, reqOptions) { function ClientRequest(input, options, cb) { OutgoingMessage.call(this); + this[kIsClientRequest] = true; if (typeof input === 'string') { const urlStr = input; diff --git a/lib/_http_common.js b/lib/_http_common.js index 3c389ba054decc..39a8e56aab9d50 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'); @@ -50,6 +51,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 +124,7 @@ function parserOnHeadersComplete(versionMajor, versionMinor, headers, method, // client only incoming.statusCode = statusCode; incoming.statusMessage = statusMessage; + incoming.req = socket?._httpMessage; } return parser.onIncoming(incoming, shouldKeepAlive); @@ -134,6 +139,13 @@ function parserOnBody(b) { // Pretend this was the result of a stream._read call. if (!stream._dumped) { + if (stream.req && onClientResponseBodyChunkReceivedChannel.hasSubscribers) { + onClientResponseBodyChunkReceivedChannel.publish({ + request: stream.req, + 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 5a83849086294f..91e51229624f67 100644 --- a/lib/_http_outgoing.js +++ b/lib/_http_outgoing.js @@ -36,9 +36,10 @@ const { const { getDefaultHighWaterMark } = require('internal/streams/state'); const assert = require('internal/assert'); +const dc = require('diagnostics_channel'); const EE = require('events'); const Stream = require('stream'); -const { kOutHeaders, utcDate, kNeedDrain } = require('internal/http'); +const { kIsClientRequest, kOutHeaders, utcDate, kNeedDrain } = require('internal/http'); const { Buffer } = require('buffer'); const { _checkIsHttpToken: checkIsHttpToken, @@ -86,6 +87,12 @@ const kBytesWritten = Symbol('kBytesWritten'); const kErrored = Symbol('errored'); const kHighWaterMark = Symbol('kHighWaterMark'); const kRejectNonStandardBodyWrites = Symbol('kRejectNonStandardBodyWrites'); +const kClientRequestBodyChunksWritten = Symbol('kClientRequestBodyChunksWritten'); + +const onClientRequestBodyChunkSentChannel = + dc.channel('http.client.request.bodyChunkSent'); +const onClientRequestBodySentChannel = + dc.channel('http.client.request.bodySent'); const nop = () => {}; @@ -950,6 +957,17 @@ function write_(msg, chunk, encoding, callback, fromEnd) { } } + if (msg[kIsClientRequest]) { + msg[kClientRequestBodyChunksWritten] = true; + if (onClientRequestBodyChunkSentChannel.hasSubscribers) { + onClientRequestBodyChunkSentChannel.publish({ + request: msg, + chunk, + encoding, + }); + } + } + if (!fromEnd && msg.socket && !msg.socket.writableCorked) { msg.socket.cork(); process.nextTick(connectionCorkNT, msg.socket); @@ -1103,6 +1121,12 @@ OutgoingMessage.prototype.end = function end(chunk, encoding, callback) { this.finished = true; + if (this[kIsClientRequest] && + this[kClientRequestBodyChunksWritten] && + onClientRequestBodySentChannel.hasSubscribers) { + onClientRequestBodySentChannel.publish({ request: this }); + } + // 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 54f1121eb712c0..ee3491278bfe16 100644 --- a/lib/internal/http.js +++ b/lib/internal/http.js @@ -262,6 +262,7 @@ function getGlobalAgent(proxyEnv, Agent) { } module.exports = { + kIsClientRequest: Symbol('kIsClientRequest'), 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 46bdd827c094a1..6a388341976a9a 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, @@ -17,10 +19,13 @@ const { registerDiagnosticChannels, sniffMimeType, } = require('internal/inspector/network'); +const { getStructuredStack } = require('internal/util'); const { Network } = require('inspector'); -const EventEmitter = require('events'); +const { Buffer } = require('buffer'); const kRequestUrl = Symbol('kRequestUrl'); +const kRequestWillBeSent = Symbol('kRequestWillBeSent'); +const kInitiator = Symbol('kInitiator'); function isAbsoluteURLPath(path) { return typeof path === 'string' && @@ -67,37 +72,81 @@ const convertHeaderObject = (headers = {}) => { return [dict, host, charset, mimeType]; }; +function createInitiator() { + const callSites = getStructuredStack(); + const callFrames = []; + for (let i = 0; i < callSites.length; i++) { + const callSite = callSites[i]; + ArrayPrototypePush(callFrames, { + functionName: callSite.getFunctionName() ?? callSite.getMethodName() ?? '', + scriptId: '', + url: callSite.getScriptNameOrSourceURL() ?? callSite.getFileName() ?? '', + 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(); + // Ensure that the stack obtained here is the one created at the time of actual construction. + request[kInitiator] = createInitiator(); +} + +/** + * Emit Network.requestWillBeSent once the request body state is known. + * @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; } @@ -109,12 +158,73 @@ 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) : Buffer.from(chunk); + Network.dataSent({ + requestId: request[kInspectorRequestId], + timestamp: getMonotonicTime(), + dataLength: buffer.byteLength, + data: buffer, + }); +} + +/** + * Mark a request body as fully sent. + * @param {{ request: import('http').ClientRequest }} event + */ +function onClientRequestBodySent({ request }) { + if (typeof request[kInspectorRequestId] !== 'string') { + return; + } + + Network.dataSent({ + requestId: request[kInspectorRequestId], + timestamp: getMonotonicTime(), + dataLength: 0, + data: Buffer.alloc(0), + finished: true, + }); +} + +/** + * When a chunk of the response body is received, cache the raw bytes until + * `getResponseBody` request. + * https://chromedevtools.github.io/devtools-protocol/1-3/Network/#method-getResponseBody + * @param {{ request: import('http').ClientRequest, chunk: Uint8Array }} event + */ +function onClientResponseBodyChunkReceived({ request, chunk }) { + if (typeof request[kInspectorRequestId] !== 'string') { + return; + } + + Network.dataReceived({ + requestId: request[kInspectorRequestId], + timestamp: getMonotonicTime(), + dataLength: chunk.byteLength, + encodedDataLength: chunk.byteLength, + data: chunk, + }); +} + /** * When response headers are received, emit Network.responseReceived event. * https://chromedevtools.github.io/devtools-protocol/1-3/Network/#event-responseReceived * @param {{ request: import('http').ClientRequest, error: any }} event */ function onClientResponseFinish({ request, response }) { + emitRequestWillBeSent(request, false); if (typeof request[kInspectorRequestId] !== 'string') { return; } @@ -135,17 +245,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({ @@ -157,6 +256,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/node.gyp b/node.gyp index b129c3db8d88c1..cb89acee7eaaf2 100644 --- a/node.gyp +++ b/node.gyp @@ -1418,6 +1418,7 @@ # TODO(legendecas): make node_inspector.gypi a dependable target. '<(SHARED_INTERMEDIATE_DIR)', # for inspector '<(SHARED_INTERMEDIATE_DIR)/src', # for inspector + '<(SHARED_INTERMEDIATE_DIR)/inspector-generated-output-root/include', ], 'dependencies': [ 'deps/inspector_protocol/inspector_protocol.gyp:crdtp', diff --git a/src/inspector/network_agent.cc b/src/inspector/network_agent.cc index ace8ba52287186..77de90843f9ebb 100644 --- a/src/inspector/network_agent.cc +++ b/src/inspector/network_agent.cc @@ -6,6 +6,7 @@ #include "inspector/network_resource_manager.h" #include "inspector/protocol_helper.h" #include "network_inspector.h" +#include "node/inspector/protocol/Runtime.h" #include "node_metadata.h" #include "util-inl.h" #include "uv.h" @@ -15,9 +16,12 @@ namespace node { namespace inspector { +using v8::Array; +using v8::Context; using v8::HandleScope; using v8::Isolate; using v8::Local; +using v8::MaybeLocal; using v8::Object; using v8::Uint8Array; using v8::Value; @@ -29,6 +33,79 @@ static void ThrowEventError(v8::Isolate* isolate, const std::string& message) { v8::String::NewFromUtf8(isolate, message.c_str()).ToLocalChecked())); } +static MaybeLocal GetProperty(Local context, + Local object, + Local key) { + return object->Get(context, key); +} + +// Convert JS-provided event payloads into protocol values so existing inspector +// protocol schema validators can reject malformed structured fields. +static std::unique_ptr V8ToProtocolValue( + Local context, Local value) { + Isolate* isolate = Isolate::GetCurrent(); + if (value->IsNullOrUndefined()) { + return protocol::Value::null(); + } + if (value->IsBoolean()) { + return protocol::FundamentalValue::create(value.As()->Value()); + } + if (value->IsInt32()) { + return protocol::FundamentalValue::create(value.As()->Value()); + } + if (value->IsNumber()) { + return protocol::FundamentalValue::create(value.As()->Value()); + } + if (value->IsString()) { + return protocol::StringValue::create(ToProtocolString(isolate, value)); + } + if (value->IsArray()) { + Local array = value.As(); + std::unique_ptr list = protocol::ListValue::create(); + list->reserve(array->Length()); + for (uint32_t i = 0; i < array->Length(); i++) { + Local element; + if (!array->Get(context, i).ToLocal(&element)) { + return nullptr; + } + std::unique_ptr protocol_value = + V8ToProtocolValue(context, element); + if (!protocol_value) { + return nullptr; + } + list->pushValue(std::move(protocol_value)); + } + return list; + } + if (value->IsObject()) { + Local object = value.As(); + Local property_names; + if (!object->GetOwnPropertyNames(context).ToLocal(&property_names)) { + return nullptr; + } + std::unique_ptr dict = + protocol::DictionaryValue::create(); + for (uint32_t i = 0; i < property_names->Length(); i++) { + // `property_names` is a JSArray returned from GetOwnPropertyNames, so + // indexed access always succeeds. User-defined getters can still throw + // when reading the property value, which is what we guard against. + Local key = property_names->Get(context, i).ToLocalChecked(); + Local property; + if (!GetProperty(context, object, key).ToLocal(&property)) { + return nullptr; + } + std::unique_ptr protocol_value = + V8ToProtocolValue(context, property); + if (!protocol_value) { + return nullptr; + } + dict->setValue(ToProtocolString(isolate, key), std::move(protocol_value)); + } + return dict; + } + return nullptr; +} + // Create a protocol::Network::Headers from the v8 object. std::unique_ptr NetworkAgent::createHeadersFromObject(v8::Local context, @@ -65,6 +142,62 @@ NetworkAgent::createHeadersFromObject(v8::Local context, return std::make_unique(std::move(dict)); } +std::unique_ptr +NetworkAgent::createInitiatorFromObject(v8::Local context, + Local initiator_obj) { + HandleScope handle_scope(Isolate::GetCurrent()); + Isolate* isolate = env_->isolate(); + + protocol::String type; + if (!ObjectGetProtocolString(context, initiator_obj, "type").To(&type)) { + ThrowEventError(isolate, "Missing initiator.type in event"); + return {}; + } + + std::unique_ptr initiator = + protocol::Network::Initiator::create().setType(type).build(); + + Local stack_obj; + if (ObjectGetObject(context, initiator_obj, "stack").ToLocal(&stack_obj)) { + std::unique_ptr stack_value = + V8ToProtocolValue(context, stack_obj); + if (!stack_value) { + ThrowEventError(isolate, "Invalid initiator.stack in event"); + return {}; + } + + protocol::ErrorSupport errors; + initiator->setStack( + protocol::ValueConversions::fromValue(stack_value.get(), + &errors)); + } + + 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 +593,21 @@ void NetworkAgent::requestWillBeSent(v8::Local context, return; } - std::unique_ptr initiator = - protocol::Network::Initiator::create() - .setType(protocol::Network::Initiator::TypeEnum::Script) - .setStack( - v8_inspector_->captureStackTrace(true)->buildInspectorObject(0)) - .build(); + std::unique_ptr initiator; + Local initiator_obj; + if (ObjectGetObject(context, params, "initiator").ToLocal(&initiator_obj)) { + initiator = createInitiatorFromObject(context, initiator_obj); + if (!initiator) { + return; + } + } else { + initiator = + protocol::Network::Initiator::create() + .setType(protocol::Network::Initiator::TypeEnum::Script) + .setStack( + v8_inspector_->captureStackTrace(true)->buildInspectorObject(0)) + .build(); + } if (requests_.contains(request_id)) { // Duplicate entry, ignore it. diff --git a/src/inspector/network_agent.h b/src/inspector/network_agent.h index 2136a45baf45f6..a16e1781e4c9c6 100644 --- a/src/inspector/network_agent.h +++ b/src/inspector/network_agent.h @@ -81,6 +81,8 @@ class NetworkAgent : public protocol::Network::Backend { private: std::unique_ptr createHeadersFromObject( v8::Local context, v8::Local headers_obj); + std::unique_ptr createInitiatorFromObject( + v8::Local context, v8::Local initiator_obj); std::unique_ptr createRequestFromObject( v8::Local context, v8::Local request); std::unique_ptr createResponseFromObject( diff --git a/test/parallel/test-diagnostics-channel-http.js b/test/parallel/test-diagnostics-channel-http.js index fd371a5d259f0b..ed89f876d74abd 100644 --- a/test/parallel/test-diagnostics-channel-http.js +++ b/test/parallel/test-diagnostics-channel-http.js @@ -14,6 +14,16 @@ const isError = (error) => error instanceof Error; dc.subscribe('http.client.request.start', common.mustCall(({ request }) => { assert.strictEqual(isOutgoingMessage(request), true); +}, 4)); + +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.strictEqual(typeof encoding === 'string' || encoding == null, true); +}, 3)); + +dc.subscribe('http.client.request.bodySent', common.mustCall(({ request }) => { + assert.strictEqual(isOutgoingMessage(request), true); }, 2)); dc.subscribe('http.client.request.error', common.mustCall(({ request, error }) => { @@ -21,13 +31,23 @@ dc.subscribe('http.client.request.error', common.mustCall(({ request, error }) = assert.strictEqual(isError(error), 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); +}, 3)); + dc.subscribe('http.client.response.finish', common.mustCall(({ request, response }) => { assert.strictEqual(isOutgoingMessage(request), true); assert.strictEqual(isIncomingMessage(response), true); -})); +}, 3)); dc.subscribe('http.server.request.start', common.mustCall(({ request, @@ -39,7 +59,7 @@ dc.subscribe('http.server.request.start', common.mustCall(({ assert.strictEqual(isOutgoingMessage(response), true); assert.strictEqual(isNetSocket(socket), true); assert.strictEqual(isHTTPServer(server), true); -})); +}, 3)); dc.subscribe('http.server.response.finish', common.mustCall(({ request, @@ -51,7 +71,7 @@ dc.subscribe('http.server.response.finish', common.mustCall(({ assert.strictEqual(isOutgoingMessage(response), true); assert.strictEqual(isNetSocket(socket), true); assert.strictEqual(isHTTPServer(server), true); -})); +}, 3)); dc.subscribe('http.server.response.created', common.mustCall(({ request, @@ -59,16 +79,29 @@ dc.subscribe('http.server.response.created', common.mustCall(({ }) => { assert.strictEqual(isIncomingMessage(request), true); assert.strictEqual(isOutgoingMessage(response), true); -})); +}, 3)); dc.subscribe('http.client.request.created', common.mustCall(({ request }) => { assert.strictEqual(isOutgoingMessage(request), true); assert.strictEqual(isHTTPServer(server), true); -}, 2)); +}, 4)); const server = http.createServer(common.mustCall((req, res) => { - res.end('done'); -})); + const chunks = []; + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', common.mustCall(() => { + if (req.method === 'POST' && req.url === '/string-body') { + assert.strictEqual(Buffer.concat(chunks).toString(), 'foobar'); + } else if (req.method === 'POST' && req.url === '/binary-body') { + assert.deepStrictEqual(Buffer.concat(chunks), Buffer.from([0, 1, 2, 3])); + } else { + assert.strictEqual(req.method, 'GET'); + assert.strictEqual(req.url, '/'); + assert.strictEqual(Buffer.concat(chunks).byteLength, 0); + } + res.end('done'); + })); +}, 3)); server.listen(async () => { const { port } = server.address(); @@ -78,10 +111,33 @@ server.listen(async () => { await new Promise((resolve) => { invalidRequest.on('error', resolve); }); - http.get(`http://localhost:${port}`, (res) => { - res.resume(); - res.on('end', () => { - server.close(); + await new Promise((resolve, reject) => { + http.get(`http://localhost:${port}`, (res) => { + res.setEncoding('utf8'); + res.resume(); + res.on('end', resolve); + }).on('error', reject); + }); + await new Promise((resolve, reject) => { + const req = http.request(`http://localhost:${port}/string-body`, { + method: 'POST', + }, (res) => { + res.resume(); + res.on('end', resolve); + }); + req.on('error', reject); + req.write('foo'); + req.end('bar'); + }); + await new Promise((resolve, reject) => { + const req = http.request(`http://localhost:${port}/binary-body`, { + method: 'POST', + }, (res) => { + res.resume(); + res.on('end', resolve); }); + req.on('error', reject); + req.end(Buffer.from([0, 1, 2, 3])); }); + server.close(); }); diff --git a/test/parallel/test-inspector-emit-protocol-event-errors.js b/test/parallel/test-inspector-emit-protocol-event-errors.js index 1a76a491c2195c..74cde3e8e85d51 100644 --- a/test/parallel/test-inspector-emit-protocol-event-errors.js +++ b/test/parallel/test-inspector-emit-protocol-event-errors.js @@ -171,6 +171,39 @@ const NETWORK_ERROR_CASES = [ networkRequest({ request: omit(networkRequest().request, 'headers') }), 'Missing request.headers in event', ], + [ + 'requestWillBeSent', + networkRequest({ + initiator: { + type: 'script', + stack: { + callFrames: [], + unsupportedValue: 1n, + }, + }, + }), + 'Invalid initiator.stack in event', + ], + [ + 'requestWillBeSent', + networkRequest({ + initiator: { + type: 'script', + stack: (() => { + const stack = {}; + Object.defineProperty(stack, 'callFrames', { + enumerable: true, + configurable: true, + get() { + throw new Error('boom'); + }, + }); + return stack; + })(), + }, + }), + 'Invalid initiator.stack in event', + ], [ 'responseReceived', diff --git a/test/parallel/test-inspector-emit-protocol-event.js b/test/parallel/test-inspector-emit-protocol-event.js index 567c92e3eeba6a..96ec7172f392a0 100644 --- a/test/parallel/test-inspector-emit-protocol-event.js +++ b/test/parallel/test-inspector-emit-protocol-event.js @@ -194,6 +194,79 @@ for (const [domain, events] of Object.entries(EXPECTED_EVENTS)) { } } + // Verify a user-supplied initiator (with stack) is preserved end-to-end. + // Covers the true branch of `if (ObjectGetObject(... "stack"))` in + // NetworkAgent::createInitiatorFromObject. + session.removeAllListeners('Network.requestWillBeSent'); + const userInitiator = { + type: 'script', + stack: { callFrames: [] }, + url: 'https://nodejs.org/test.js', + lineNumber: 12, + columnNumber: 34, + }; + session.on('Network.requestWillBeSent', common.mustCall(({ params }) => { + assert.strictEqual(params.requestId, 'request-with-user-initiator'); + assert.deepStrictEqual(params.initiator, userInitiator); + })); + inspector.Network.requestWillBeSent({ + requestId: 'request-with-user-initiator', + request: { + url: 'https://nodejs.org/en', + method: 'GET', + headers: {}, + }, + timestamp: 1000, + wallTime: 1000, + initiator: userInitiator, + }); + + // Verify a user-supplied initiator without `stack` is forwarded as-is. + // Covers the false branch of `if (ObjectGetObject(... "stack"))` in + // NetworkAgent::createInitiatorFromObject. + session.removeAllListeners('Network.requestWillBeSent'); + const initiatorWithoutStack = { + type: 'script', + url: 'https://nodejs.org/no-stack.js', + lineNumber: 7, + columnNumber: 8, + }; + session.on('Network.requestWillBeSent', common.mustCall(({ params }) => { + assert.strictEqual(params.requestId, 'request-without-initiator-stack'); + assert.deepStrictEqual(params.initiator, initiatorWithoutStack); + })); + inspector.Network.requestWillBeSent({ + requestId: 'request-without-initiator-stack', + request: { + url: 'https://nodejs.org/en', + method: 'GET', + headers: {}, + }, + timestamp: 1000, + wallTime: 1000, + initiator: initiatorWithoutStack, + }); + + // Verify a duplicate requestId is silently ignored. Covers the early + // return when `requests_.contains(request_id)` in NetworkAgent::requestWillBeSent. + session.removeAllListeners('Network.requestWillBeSent'); + const duplicateId = 'duplicate-request-id'; + const duplicateParams = { + requestId: duplicateId, + request: { + url: 'https://nodejs.org/en', + method: 'GET', + headers: {}, + }, + timestamp: 1000, + wallTime: 1000, + }; + session.on('Network.requestWillBeSent', common.mustCall(({ params }) => { + assert.strictEqual(params.requestId, duplicateId); + }, 1)); + inspector.Network.requestWillBeSent(duplicateParams); + inspector.Network.requestWillBeSent(duplicateParams); + // Check tht no events are emitted after disabling the domain. await session.post('Network.disable'); session.on('Network.requestWillBeSent', common.mustNotCall()); diff --git a/test/parallel/test-inspector-network-arbitrary-data.js b/test/parallel/test-inspector-network-arbitrary-data.js index 2df76010f53082..ee7375bf373e0c 100644 --- a/test/parallel/test-inspector-network-arbitrary-data.js +++ b/test/parallel/test-inspector-network-arbitrary-data.js @@ -9,11 +9,51 @@ const { Network } = require('node:inspector'); const test = require('node:test'); const assert = require('node:assert'); const { waitUntil } = require('../common/inspector-helper'); +const { setImmediate: waitForTurn } = require('node:timers/promises'); const session = new inspector.Session(); session.connect(); +function createRequestPayload(overrides = {}) { + return { + requestId: '1', + timestamp: 1, + wallTime: 1, + request: { + url: 'https://example.com', + method: 'GET', + headers: { + mKey: 'mValue', + }, + }, + ...overrides, + }; +} + +async function assertInvalidInitiatorStack(stack, requestId) { + session.removeAllListeners(); + await session.post('Network.enable'); + + session.on('Network.requestWillBeSent', common.mustNotCall()); + + assert.throws(() => { + Network.requestWillBeSent(createRequestPayload({ + requestId, + initiator: { + type: 'script', + stack, + }, + })); + }, { + name: 'TypeError', + message: 'Invalid initiator.stack in event', + }); + + await waitForTurn(); +} + test('should emit Network.requestWillBeSent with unicode', async () => { + session.removeAllListeners(); await session.post('Network.enable'); const expectedValue = 'CJK 汉字 🍱 🧑‍🧑‍🧒‍🧒'; @@ -24,10 +64,7 @@ test('should emit Network.requestWillBeSent with unicode', async () => { assert.strictEqual(event.params.request.headers.mKey, expectedValue); }); - Network.requestWillBeSent({ - requestId: '1', - timestamp: 1, - wallTime: 1, + Network.requestWillBeSent(createRequestPayload({ request: { url: expectedValue, method: expectedValue, @@ -35,7 +72,171 @@ test('should emit Network.requestWillBeSent with unicode', async () => { mKey: expectedValue, }, }, - }); + })); + + await requestWillBeSentFuture; +}); + +test('should emit Network.requestWillBeSent with custom initiator', async () => { + session.removeAllListeners(); + await session.post('Network.enable'); + + const requestWillBeSentFuture = waitUntil(session, 'Network.requestWillBeSent') + .then(([event]) => { + const { initiator } = event.params; + assert.strictEqual(initiator.type, 'parser'); + assert.strictEqual(initiator.url, 'node:https://initiator.test/app.js'); + assert.strictEqual(initiator.lineNumber, 12); + assert.strictEqual(initiator.columnNumber, 34); + assert.strictEqual(initiator.requestId, 'parent-request-id'); + assert.strictEqual(initiator.stack.description, 'custom stack'); + assert.deepStrictEqual(initiator.stack.callFrames, [{ + functionName: 'run', + scriptId: '99', + url: 'file:///custom-frame.js', + lineNumber: 3, + columnNumber: 5, + }]); + assert.deepStrictEqual(initiator.stack.parent.callFrames, [{ + functionName: 'parentRun', + scriptId: '100', + url: 'file:///parent-frame.js', + lineNumber: 8, + columnNumber: 13, + }]); + assert.deepStrictEqual(initiator.stack.parentId, { + id: 'async-stack-id', + debuggerId: 'debugger-1', + }); + }); + + Network.requestWillBeSent(createRequestPayload({ + requestId: 'custom-initiator-request', + initiator: { + type: 'parser', + url: 'node:https://initiator.test/app.js', + lineNumber: 12, + columnNumber: 34, + requestId: 'parent-request-id', + stack: { + description: 'custom stack', + callFrames: [{ + functionName: 'run', + scriptId: '99', + url: 'file:///custom-frame.js', + lineNumber: 3, + columnNumber: 5, + }], + parent: { + callFrames: [{ + functionName: 'parentRun', + scriptId: '100', + url: 'file:///parent-frame.js', + lineNumber: 8, + columnNumber: 13, + }], + extraNumber: 1.5, + extraBoolean: true, + extraNull: null, + }, + parentId: { + id: 'async-stack-id', + debuggerId: 'debugger-1', + }, + extraArray: ['frame', 1, false, null, { nested: 'value' }], + }, + }, + })); await requestWillBeSentFuture; }); + +test('should throw if initiator.type is missing', async () => { + session.removeAllListeners(); + await session.post('Network.enable'); + + session.on('Network.requestWillBeSent', common.mustNotCall()); + + assert.throws(() => { + Network.requestWillBeSent(createRequestPayload({ + requestId: 'missing-initiator-type', + initiator: { + stack: { + callFrames: [], + }, + }, + })); + }, { + name: 'TypeError', + message: 'Missing initiator.type in event', + }); + + await waitForTurn(); +}); + +test('should throw if initiator.stack is invalid', async () => { + await assertInvalidInitiatorStack({ + callFrames: [], + unsupportedValue: 1n, + }, 'invalid-initiator-stack'); +}); + +test('should throw if initiator.stack contains an invalid array element', + async () => { + await assertInvalidInitiatorStack({ + callFrames: [], + extraArray: [1n], + }, 'invalid-initiator-stack-array-element'); + }); + +test('should throw if initiator.stack contains an array accessor that throws', + async () => { + const extraArray = []; + Object.defineProperty(extraArray, 0, { + enumerable: true, + get() { + throw new Error('array getter boom'); + }, + }); + extraArray.length = 1; + + await assertInvalidInitiatorStack({ + callFrames: [], + extraArray, + }, 'invalid-initiator-stack-array-getter'); + }); + +test('should throw if initiator.stack has a property accessor that throws', + async () => { + const stack = { callFrames: [] }; + Object.defineProperty(stack, 'broken', { + enumerable: true, + get() { + throw new Error('getter boom'); + }, + }); + + await assertInvalidInitiatorStack( + stack, + 'invalid-initiator-stack-property-getter', + ); + }); + +test('should throw if initiator.stack property enumeration throws', async () => { + const stack = new Proxy({ callFrames: [] }, { + ownKeys() { + throw new Error('ownKeys boom'); + }, + getOwnPropertyDescriptor() { + return { + enumerable: true, + configurable: true, + }; + }, + }); + + await assertInvalidInitiatorStack( + stack, + 'invalid-initiator-stack-own-keys', + ); +}); diff --git a/test/parallel/test-inspector-network-http.js b/test/parallel/test-inspector-network-http.js index 88d717d83c896a..6bdc26fb6dc98d 100644 --- a/test/parallel/test-inspector-network-http.js +++ b/test/parallel/test-inspector-network-http.js @@ -5,7 +5,9 @@ const common = require('../common'); common.skipIfInspectorDisabled(); const assert = require('node:assert'); +const dc = require('node:diagnostics_channel'); const { once } = require('node:events'); +const { setImmediate: waitForTurn } = require('node:timers/promises'); const { addresses } = require('../common/internet'); const fixtures = require('../common/fixtures'); const http = require('node:http'); @@ -22,6 +24,16 @@ const requestHeaders = { 'x-header1': ['value1', 'value2'] }; +const requestBodyHeaders = { + ...requestHeaders, + 'content-type': 'text/plain; charset=utf-8', +}; + +const binaryRequestBodyHeaders = { + ...requestHeaders, + 'content-type': 'application/octet-stream', +}; + const setResponseHeaders = (res) => { res.setHeader('server', 'node'); res.setHeader('etag', 12345); @@ -52,7 +64,7 @@ function getPathName(req) { return new URL(req.url, `http://${req.headers.host}`).pathname; } -const handleRequest = (req, res) => { +const handleRequest = common.mustCall((req, res) => { const path = getPathName(req); switch (path) { case '/hello-world': @@ -65,6 +77,17 @@ const handleRequest = (req, res) => { res.end('hello world\n'); }, kTimeout); break; + case '/text-body': { + const chunks = []; + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', common.mustCall(() => { + assert.strictEqual(Buffer.concat(chunks).toString(), 'foobar'); + setResponseHeaders(res); + res.writeHead(200); + res.end('hello world\n'); + })); + break; + } case '/echo-post': { const chunks = []; req.on('data', (chunk) => { @@ -81,10 +104,21 @@ const handleRequest = (req, res) => { }); break; } + case '/binary-body': { + const chunks = []; + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', common.mustCall(() => { + assert.deepStrictEqual(Buffer.concat(chunks), Buffer.from([0, 1, 2, 3])); + setResponseHeaders(res); + res.writeHead(200); + res.end('hello world\n'); + })); + break; + } default: assert.fail(`Unexpected path: ${path}`); } -}; +}, 7); const httpServer = http.createServer(handleRequest); @@ -113,11 +147,15 @@ 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'); assert.strictEqual(params.request.headers.age, '1000'); assert.strictEqual(params.request.headers['x-header1'], 'value1, value2'); + if (expect.contentType) { + assert.strictEqual(params.request.headers['content-type'], expect.contentType); + } assert.strictEqual(typeof params.timestamp, 'number'); assert.strictEqual(typeof params.wallTime, 'number'); @@ -173,8 +211,11 @@ function verifyLoadingFailed({ method, params }) { assert.strictEqual(typeof params.errorText, 'string'); } -function verifyHttpResponse(response) { +function verifyHttpResponse(response, expectedBody = '\nhello world\n', responseEncoding) { assert.strictEqual(response.statusCode, 200); + if (responseEncoding) { + response.setEncoding(responseEncoding); + } const chunks = []; // Verifies that the inspector does not put the response into flowing mode. @@ -189,8 +230,8 @@ function verifyHttpResponse(response) { })); response.on('end', common.mustCall(() => { - const body = Buffer.concat(chunks).toString(); - assert.strictEqual(body, '\nhello world\n'); + const body = responseEncoding ? chunks.join('') : Buffer.concat(chunks).toString(); + assert.strictEqual(body, expectedBody); })); } @@ -203,6 +244,8 @@ function createRequestTracker(url, responseExpect, requestExpect = {}) { .then(([event]) => verifyRequestWillBeSent(event, { url, method: requestExpect.method, + hasPostData: requestExpect.hasPostData, + contentType: requestExpect.contentType, })); const responseReceivedFuture = once(session, 'Network.responseReceived') @@ -226,6 +269,31 @@ async function assertResponseBody(responseReceived, expectedBody, expectedBase64 assert.strictEqual(responseBody.body, expectedBody); } +async function testUntrackedBodyEventsAreIgnored() { + const onDataSent = common.mustNotCall(); + const onDataReceived = common.mustNotCall(); + session.on('Network.dataSent', onDataSent); + session.on('Network.dataReceived', onDataReceived); + + dc.channel('http.client.request.bodyChunkSent').publish({ + request: {}, + chunk: 'ignored', + encoding: 'utf8', + }); + dc.channel('http.client.request.bodySent').publish({ + request: {}, + }); + dc.channel('http.client.response.bodyChunkReceived').publish({ + request: {}, + chunk: Buffer.from('ignored'), + }); + + await waitForTurn(); + + session.off('Network.dataSent', onDataSent); + session.off('Network.dataReceived', onDataReceived); +} + async function testHttpGet() { const url = `http://127.0.0.1:${httpServer.address().port}/hello-world`; const { @@ -287,6 +355,8 @@ async function testHttpPostWithAbsoluteUrlPath() { charset: 'utf-8', }, { method: 'POST', + hasPostData: true, + contentType: 'application/json', }); const responsePromise = new Promise((resolve, reject) => { @@ -345,7 +415,10 @@ async function testHttpsGet() { async function testHttpError() { const url = `http://${addresses.INVALID_HOST}/`; const requestWillBeSentFuture = once(session, 'Network.requestWillBeSent') - .then(([event]) => verifyRequestWillBeSent(event, { url })); + .then(([event]) => verifyRequestWillBeSent(event, { + url, + method: 'GET', + })); session.on('Network.responseReceived', common.mustNotCall()); session.on('Network.loadingFinished', common.mustNotCall()); @@ -364,7 +437,10 @@ async function testHttpError() { async function testHttpsError() { const url = `https://${addresses.INVALID_HOST}/`; const requestWillBeSentFuture = once(session, 'Network.requestWillBeSent') - .then(([event]) => verifyRequestWillBeSent(event, { url })); + .then(([event]) => verifyRequestWillBeSent(event, { + url, + method: 'GET', + })); session.on('Network.responseReceived', common.mustNotCall()); session.on('Network.loadingFinished', common.mustNotCall()); @@ -380,7 +456,103 @@ async function testHttpsError() { await loadingFailedFuture; } +async function makeHttpRequest( + requestModule, + options, + bodyWriter, + expectedBody = 'hello world\n', + responseEncoding, +) { + return new Promise((resolve, reject) => { + const req = requestModule.request(options, common.mustCall((res) => { + verifyHttpResponse(res, expectedBody, responseEncoding); + resolve(res); + })); + req.on('error', reject); + bodyWriter(req); + }); +} + +async function testTextBodyRequest({ requestModule, protocol, port, requestOptions }) { + const url = `${protocol}://127.0.0.1:${port}/text-body`; + requestOptions ??= {}; + const responseEncoding = protocol === 'http' ? 'utf8' : undefined; + const { + requestWillBeSentFuture, + responseReceivedFuture, + loadingFinishedFuture, + } = createRequestTracker(url, getDefaultResponseExpect(url), { + method: 'POST', + hasPostData: true, + contentType: 'text/plain; charset=utf-8', + }); + + await makeHttpRequest(requestModule, { + host: '127.0.0.1', + port, + path: '/text-body', + method: 'POST', + ...requestOptions, + headers: requestBodyHeaders, + }, (req) => { + req.write('foo'); + req.end('bar'); + }, 'hello world\n', responseEncoding); + + await requestWillBeSentFuture; + const responseReceived = await responseReceivedFuture; + const loadingFinished = await loadingFinishedFuture; + + assert.ok(loadingFinished.timestamp >= responseReceived.timestamp); + + const requestBody = await session.post('Network.getRequestPostData', { + requestId: responseReceived.requestId, + }); + assert.strictEqual(requestBody.postData, 'foobar'); + + const responseBody = await session.post('Network.getResponseBody', { + requestId: responseReceived.requestId, + }); + assert.strictEqual(responseBody.base64Encoded, false); + assert.strictEqual(responseBody.body, 'hello world\n'); +} + +async function testBinaryBodyRequest() { + const url = `http://127.0.0.1:${httpServer.address().port}/binary-body`; + const { + requestWillBeSentFuture, + responseReceivedFuture, + loadingFinishedFuture, + } = createRequestTracker(url, getDefaultResponseExpect(url), { + method: 'POST', + hasPostData: true, + contentType: 'application/octet-stream', + }); + + await makeHttpRequest(http, { + host: '127.0.0.1', + port: httpServer.address().port, + path: '/binary-body', + method: 'POST', + headers: binaryRequestBodyHeaders, + }, (req) => { + req.end(Buffer.from([0, 1, 2, 3])); + }, 'hello world\n'); + + await requestWillBeSentFuture; + const responseReceived = await responseReceivedFuture; + await loadingFinishedFuture; + + await assert.rejects(session.post('Network.getRequestPostData', { + requestId: responseReceived.requestId, + }), { + code: 'ERR_INSPECTOR_COMMAND', + }); +} + const testNetworkInspection = async () => { + await testUntrackedBodyEventsAreIgnored(); + session.removeAllListeners(); await testHttpGet(); session.removeAllListeners(); await testHttpGetWithAbsoluteUrlPath(); @@ -389,6 +561,21 @@ const testNetworkInspection = async () => { session.removeAllListeners(); await testHttpsGet(); session.removeAllListeners(); + await testTextBodyRequest({ + requestModule: http, + protocol: 'http', + port: httpServer.address().port, + }); + session.removeAllListeners(); + await testTextBodyRequest({ + requestModule: https, + protocol: 'https', + port: httpsServer.address().port, + requestOptions: { rejectUnauthorized: false }, + }); + session.removeAllListeners(); + await testBinaryBodyRequest(); + session.removeAllListeners(); await testHttpError(); session.removeAllListeners(); await testHttpsError();