diff --git a/packages/react-native/Libraries/Network/RCTInspectorNetworkReporter.h b/packages/react-native/Libraries/Network/RCTInspectorNetworkReporter.h index b1636d38c18..800ea99a765 100644 --- a/packages/react-native/Libraries/Network/RCTInspectorNetworkReporter.h +++ b/packages/react-native/Libraries/Network/RCTInspectorNetworkReporter.h @@ -20,13 +20,41 @@ */ @interface RCTInspectorNetworkReporter : NSObject +/** + * Report a network request that is about to be sent. + * + * - Corresponds to `Network.requestWillBeSent` in CDP. + * - Corresponds to `PerformanceResourceTiming.requestStart` (specifically, + * marking when the native request was initiated). + */ + (void)reportRequestStart:(NSNumber *)requestId request:(NSURLRequest *)request encodedDataLength:(int)encodedDataLength; + +/** + * Report when HTTP response headers have been received, corresponding to + * when the first byte of the response is available. + * + * - Corresponds to `Network.responseReceived` in CDP. + * - Corresponds to `PerformanceResourceTiming.responseStart`. + */ + (void)reportResponseStart:(NSNumber *)requestId response:(NSURLResponse *)response statusCode:(int)statusCode headers:(NSDictionary *)headers; + +/** + * Report when a network request is complete and we are no longer receiving + * response data. + * + * - Corresponds to `Network.loadingFinished` in CDP. + * - Corresponds to `PerformanceResourceTiming.responseEnd`. + */ + (void)reportResponseEnd:(NSNumber *)requestId encodedDataLength:(int)encodedDataLength; +/** + * Store response body preview. This is an optional reporting method, and is a + * no-op if CDP debugging is disabled. + */ ++ (void)maybeStoreResponseBody:(NSNumber *)requestId data:(NSData *)data base64Encoded:(bool)base64Encoded; @end diff --git a/packages/react-native/Libraries/Network/RCTInspectorNetworkReporter.mm b/packages/react-native/Libraries/Network/RCTInspectorNetworkReporter.mm index 143acfb5c1c..8abe4fe2e77 100644 --- a/packages/react-native/Libraries/Network/RCTInspectorNetworkReporter.mm +++ b/packages/react-native/Libraries/Network/RCTInspectorNetworkReporter.mm @@ -7,6 +7,9 @@ #import "RCTInspectorNetworkReporter.h" +#import "RCTNetworkConversions.h" + +#import #import using namespace facebook::react::jsinspector_modern; @@ -85,4 +88,31 @@ std::string convertRequestBodyToStringTruncated(NSURLRequest *request) NetworkReporter::getInstance().reportResponseEnd(requestId.stringValue.UTF8String, encodedDataLength); } ++ (void)maybeStoreResponseBody:(NSNumber *)requestId data:(id)data base64Encoded:(bool)base64Encoded +{ +#ifdef REACT_NATIVE_DEBUGGER_ENABLED + // Debug build: Process response body and report to NetworkReporter + auto &networkReporter = NetworkReporter::getInstance(); + if (!networkReporter.isDebuggingEnabled()) { + return; + } + + if ([data isKindOfClass:[NSData class]] && [(NSData *)data length] > 0) { + @try { + NSString *encodedString = [(NSData *)data base64EncodedStringWithOptions:0]; + if (encodedString != nullptr) { + networkReporter.storeResponseBody( + requestId.stringValue.UTF8String, RCTStringViewFromNSString(encodedString), base64Encoded); + } else { + RCTLogWarn(@"Failed to encode response data for request %@", requestId); + } + } @catch (NSException *exception) { + RCTLogWarn(@"Exception while encoding response data: %@", exception.reason); + } + } else if ([data isKindOfClass:[NSString class]] && [(NSString *)data length] > 0) { + networkReporter.storeResponseBody( + requestId.stringValue.UTF8String, RCTStringViewFromNSString((NSString *)data), base64Encoded); + } +#endif +} @end diff --git a/packages/react-native/Libraries/Network/RCTNetworkConversions.h b/packages/react-native/Libraries/Network/RCTNetworkConversions.h new file mode 100644 index 00000000000..ecc6aeb7e8c --- /dev/null +++ b/packages/react-native/Libraries/Network/RCTNetworkConversions.h @@ -0,0 +1,23 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +#ifdef __cplusplus + +#import + +NS_ASSUME_NONNULL_BEGIN + +inline std::string_view RCTStringViewFromNSString(NSString *string) +{ + return std::string_view{string.UTF8String, string.length}; +} + +NS_ASSUME_NONNULL_END + +#endif diff --git a/packages/react-native/Libraries/Network/RCTNetworking.mm b/packages/react-native/Libraries/Network/RCTNetworking.mm index a0e50a8ae32..a92a2b418af 100644 --- a/packages/react-native/Libraries/Network/RCTNetworking.mm +++ b/packages/react-native/Libraries/Network/RCTNetworking.mm @@ -558,6 +558,20 @@ RCT_EXPORT_MODULE() } } + if (facebook::react::ReactNativeFeatureFlags::enableNetworkEventReporting()) { + id responseDataForPreview; + if ([responseType isEqualToString:@"blob"]) { + responseDataForPreview = data; + } else if ([responseData isKindOfClass:[NSString class]]) { + responseDataForPreview = responseData; + } + bool base64Encoded = [responseType isEqualToString:@"base64"] || [responseType isEqualToString:@"blob"]; + + [RCTInspectorNetworkReporter maybeStoreResponseBody:task.requestID + data:responseDataForPreview + base64Encoded:base64Encoded]; + } + [self sendEventWithName:@"didReceiveNetworkData" body:@[ task.requestID, responseData ]]; } diff --git a/packages/react-native/ReactCommon/jsinspector-modern/NetworkIOAgent.cpp b/packages/react-native/ReactCommon/jsinspector-modern/NetworkIOAgent.cpp index 15315a161d0..6624e5aa649 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/NetworkIOAgent.cpp +++ b/packages/react-native/ReactCommon/jsinspector-modern/NetworkIOAgent.cpp @@ -14,6 +14,7 @@ #include #include +#include #include #include @@ -290,8 +291,8 @@ bool NetworkIOAgent::handleRequest( // @cdp Network.getResponseBody support is experimental. if (req.method == "Network.getResponseBody") { - // TODO(T218468200) - return false; + handleGetResponseBody(req); + return true; } } @@ -470,4 +471,55 @@ void NetworkIOAgent::handleIoClose(const cdp::PreparsedRequest& req) { } } +void NetworkIOAgent::handleGetResponseBody(const cdp::PreparsedRequest& req) { + long long requestId = req.id; + if (!req.params.isObject()) { + frontendChannel_(cdp::jsonError( + requestId, + cdp::ErrorCode::InvalidParams, + "Invalid params: not an object.")); + return; + } + if ((req.params.count("requestId") == 0u) || + !req.params.at("requestId").isString()) { + frontendChannel_(cdp::jsonError( + requestId, + cdp::ErrorCode::InvalidParams, + "Invalid params: requestId is missing or not a string.")); + return; + } + + auto& networkReporter = NetworkReporter::getInstance(); + + if (!networkReporter.isDebuggingEnabled()) { + frontendChannel_(cdp::jsonError( + requestId, + cdp::ErrorCode::InvalidRequest, + "Invalid request: The \"Network\" domain is not enabled.")); + return; + } + + auto storedResponse = + networkReporter.getResponseBody(req.params.at("requestId").asString()); + + if (!storedResponse) { + frontendChannel_(cdp::jsonError( + requestId, + cdp::ErrorCode::InternalError, + "Internal error: Could not retrieve response body for the given requestId.")); + return; + } + + std::string responseBody; + bool base64Encoded = false; + std::tie(responseBody, base64Encoded) = *storedResponse; + + auto result = GetResponseBodyResult{ + .body = responseBody, + .base64Encoded = base64Encoded, + }; + + frontendChannel_(cdp::jsonResult(requestId, result.toDynamic())); +} + } // namespace facebook::react::jsinspector_modern diff --git a/packages/react-native/ReactCommon/jsinspector-modern/NetworkIOAgent.h b/packages/react-native/ReactCommon/jsinspector-modern/NetworkIOAgent.h index d935420b7cf..a13491c78e9 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/NetworkIOAgent.h +++ b/packages/react-native/ReactCommon/jsinspector-modern/NetworkIOAgent.h @@ -88,6 +88,17 @@ struct IOReadResult { } }; +struct GetResponseBodyResult { + std::string body; + bool base64Encoded; + folly::dynamic toDynamic() const { + folly::dynamic params = folly::dynamic::object; + params["body"] = body; + params["base64Encoded"] = base64Encoded; + return params; + } +}; + /** * Passed to `loadNetworkResource`, provides callbacks for processing incoming * data and other events. @@ -259,6 +270,11 @@ class NetworkIOAgent { * Reports CDP ok if the stream is found, or a CDP error if not. */ void handleIoClose(const cdp::PreparsedRequest& req); + + /** + * Handle a Network.getResponseBody CDP request. + */ + void handleGetResponseBody(const cdp::PreparsedRequest& req); }; } // namespace facebook::react::jsinspector_modern diff --git a/packages/react-native/ReactCommon/jsinspector-modern/network/BoundedRequestBuffer.cpp b/packages/react-native/ReactCommon/jsinspector-modern/network/BoundedRequestBuffer.cpp new file mode 100644 index 00000000000..bfd1481c112 --- /dev/null +++ b/packages/react-native/ReactCommon/jsinspector-modern/network/BoundedRequestBuffer.cpp @@ -0,0 +1,77 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include "BoundedRequestBuffer.h" + +namespace facebook::react::jsinspector_modern { + +bool BoundedRequestBuffer::put( + const std::string& requestId, + std::string_view data, + bool base64Encoded) noexcept { + if (data.size() > REQUEST_BUFFER_MAX_SIZE_BYTES) { + return false; + } + + // Remove existing request with the same ID, if any + if (auto it = responses_.find(requestId); it != responses_.end()) { + currentSize_ -= it->second->data.size(); + responses_.erase(it); + // Update order: remove requestId from deque + for (auto orderIt = order_.begin(); orderIt != order_.end(); ++orderIt) { + if (*orderIt == requestId) { + order_.erase(orderIt); + break; + } + } + } + + // Evict oldest requests if necessary to make space + while (currentSize_ + data.size() > REQUEST_BUFFER_MAX_SIZE_BYTES && + !order_.empty()) { + const auto& oldestId = order_.front(); + auto it = responses_.find(oldestId); + if (it != responses_.end()) { + currentSize_ -= it->second->data.size(); + responses_.erase(it); + } + order_.pop_front(); + } + + // If still no space, reject the new data (this should not be reached) + if (currentSize_ + data.size() > REQUEST_BUFFER_MAX_SIZE_BYTES) { + return false; + } + + currentSize_ += data.size(); + // `data` is copied at the point of insertion + responses_.emplace( + requestId, + std::make_shared( + ResponseBody{std::string(data), base64Encoded})); + order_.push_back(requestId); + + return true; +} + +std::shared_ptr +BoundedRequestBuffer::get(const std::string& requestId) const { + auto it = responses_.find(requestId); + if (it != responses_.end()) { + return it->second; + } + + return nullptr; +} + +void BoundedRequestBuffer::clear() { + responses_.clear(); + order_.clear(); + currentSize_ = 0; +} + +} // namespace facebook::react::jsinspector_modern diff --git a/packages/react-native/ReactCommon/jsinspector-modern/network/BoundedRequestBuffer.h b/packages/react-native/ReactCommon/jsinspector-modern/network/BoundedRequestBuffer.h new file mode 100644 index 00000000000..7ec1d3f9bbd --- /dev/null +++ b/packages/react-native/ReactCommon/jsinspector-modern/network/BoundedRequestBuffer.h @@ -0,0 +1,67 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#pragma once + +#include +#include +#include +#include + +namespace facebook::react::jsinspector_modern { + +/** + * Maximum memory size (in bytes) to store buffered text and image request + * bodies. + */ +constexpr size_t REQUEST_BUFFER_MAX_SIZE_BYTES = 100 * 1024 * 1024; // 100MB + +/** + * A class to store network response previews keyed by requestId, with a fixed + * memory limit. Evicts oldest responses when memory is exceeded. + */ +class BoundedRequestBuffer { + public: + struct ResponseBody { + std::string data; + bool base64Encoded; + }; + + /** + * Store a response preview with the given requestId and data. + * If adding the data exceeds the memory limit, removes oldest requests until + * there is enough space or the buffer is empty. + * \param requestId Unique identifier for the request. + * \param data The request preview data (e.g. text or image body). + * \param base64Encoded True if the data is base64-encoded, false otherwise. + * \return True if the response body was stored, false otherwise. + */ + bool put( + const std::string& requestId, + std::string_view data, + bool base64Encoded) noexcept; + + /** + * Retrieve a response preview by requestId. + * \param requestId The unique identifier for the request. + * \return A shared pointer to the request data if found, otherwise nullptr. + */ + std::shared_ptr get(const std::string& requestId) const; + + /** + * Remove all entries from the buffer. + */ + void clear(); + + private: + std::unordered_map> + responses_; + std::deque order_; + size_t currentSize_ = 0; +}; + +} // namespace facebook::react::jsinspector_modern diff --git a/packages/react-native/ReactCommon/jsinspector-modern/network/CMakeLists.txt b/packages/react-native/ReactCommon/jsinspector-modern/network/CMakeLists.txt index 162debbcf27..9c9aef21efa 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/network/CMakeLists.txt +++ b/packages/react-native/ReactCommon/jsinspector-modern/network/CMakeLists.txt @@ -23,6 +23,7 @@ target_include_directories(jsinspector_network PUBLIC ${REACT_COMMON_DIR}) target_link_libraries(jsinspector_network folly_runtime + glog jsinspector_cdp react_performance_timeline react_timing) diff --git a/packages/react-native/ReactCommon/jsinspector-modern/network/NetworkReporter.cpp b/packages/react-native/ReactCommon/jsinspector-modern/network/NetworkReporter.cpp index 9f207a0a236..0bcd8be4b79 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/network/NetworkReporter.cpp +++ b/packages/react-native/ReactCommon/jsinspector-modern/network/NetworkReporter.cpp @@ -12,7 +12,6 @@ #endif #ifdef REACT_NATIVE_DEBUGGER_ENABLED -#include #include #endif #include @@ -21,6 +20,7 @@ #ifdef REACT_NATIVE_DEBUGGER_ENABLED #include #endif +#include #include namespace facebook::react::jsinspector_modern { @@ -69,6 +69,7 @@ bool NetworkReporter::disableDebugging() { } debuggingEnabled_.store(false, std::memory_order_release); + requestBodyBuffer_.clear(); return true; } @@ -272,4 +273,25 @@ void NetworkReporter::reportResponseEnd( #endif } +void NetworkReporter::storeResponseBody( + const std::string& requestId, + std::string_view body, + bool base64Encoded) { + std::lock_guard lock(requestBodyMutex_); + requestBodyBuffer_.put(requestId, body, base64Encoded); +} + +std::optional> NetworkReporter::getResponseBody( + const std::string& requestId) { + std::lock_guard lock(requestBodyMutex_); + auto responseBody = requestBodyBuffer_.get(requestId); + + if (responseBody == nullptr) { + return std::nullopt; + } + + return std::make_optional>( + responseBody->data, responseBody->base64Encoded); +} + } // namespace facebook::react::jsinspector_modern diff --git a/packages/react-native/ReactCommon/jsinspector-modern/network/NetworkReporter.h b/packages/react-native/ReactCommon/jsinspector-modern/network/NetworkReporter.h index bd56c6048db..839985385f1 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/network/NetworkReporter.h +++ b/packages/react-native/ReactCommon/jsinspector-modern/network/NetworkReporter.h @@ -7,14 +7,17 @@ #pragma once +#include "BoundedRequestBuffer.h" #include "NetworkTypes.h" +#include #include #include #include #include #include +#include #include namespace facebook::react::jsinspector_modern { @@ -76,6 +79,13 @@ class NetworkReporter { */ bool disableDebugging(); + /** + * Returns whether network tracking over CDP is currently enabled. + */ + inline bool isDebuggingEnabled() const { + return debuggingEnabled_.load(std::memory_order_acquire); + } + /** * Report a network request that is about to be sent. * @@ -142,9 +152,31 @@ class NetworkReporter { */ void reportResponseEnd(const std::string& requestId, int encodedDataLength); - private: - FrontendChannel frontendChannel_; + /** + * Store the fetched response body for a text or image network response. + * These may be retrieved by CDP clients to to render a response preview via + * `Network.getReponseBody`. + * + * Reponse bodies are stored in a bounded buffer with a fixed maximum memory + * size, where oldest responses will be evicted if the buffer is exceeded. + * + * Should be called after checking \ref NetworkReporter::isDebuggingEnabled. + */ + void storeResponseBody( + const std::string& requestId, + std::string_view body, + bool base64Encoded); + /** + * Retrieve a stored response body for a given request ID. + * + * \returns An optional tuple of [responseBody, base64Encoded]. Returns + * nullopt if no entry is found in the buffer. + */ + std::optional> getResponseBody( + const std::string& requestId); + + private: NetworkReporter() = default; NetworkReporter(const NetworkReporter&) = delete; NetworkReporter& operator=(const NetworkReporter&) = delete; @@ -156,8 +188,14 @@ class NetworkReporter { return debuggingEnabled_.load(std::memory_order_relaxed); } + FrontendChannel frontendChannel_; + std::unordered_map perfTimingsBuffer_{}; std::mutex perfTimingsMutex_; + + // Only populated when CDP debugging is enabled. + BoundedRequestBuffer requestBodyBuffer_{}; + std::mutex requestBodyMutex_; }; } // namespace facebook::react::jsinspector_modern