Implement Network.getResponseBody (#52332)

Summary:
Pull Request resolved: https://github.com/facebook/react-native/pull/52332

Adds support for the [`Network.getResponseBody`](https://chromedevtools.github.io/devtools-protocol/tot/Network/#method-getResponseBody) CDP event in `jsinspector-modern` and configures this for iOS. This enables us to populate the "Preview" and "Response" tabs in the React Native DevTools Network panel.

This is integrated with `RCTNetworking.mm` to support synchronously received `text` or `blob` data types, with incremental response support added next in D77457109.

**Implementation notes**

- Adds a new `BoundedRequestBuffer` construct to safely buffer response previews at a max memory size.
- `RCTNetworking` will always call `maybeStoreResponseBody` (when feature flag enabled), but is unaware whether there is an active CDP debugging session with network support or not.

Changelog: [Internal]

Reviewed By: rubennorte

Differential Revision: D74319394

fbshipit-source-id: c9dbb44551c15d1b1a7cce56b35bf829f8a99dc7
This commit is contained in:
Alex Hunt
2025-07-03 05:01:57 -07:00
committed by Facebook GitHub Bot
parent 9253fc3b42
commit 94c97db4da
11 changed files with 373 additions and 5 deletions
@@ -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<NSString *, NSString *> *)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
@@ -7,6 +7,9 @@
#import "RCTInspectorNetworkReporter.h"
#import "RCTNetworkConversions.h"
#import <React/RCTLog.h>
#import <jsinspector-modern/network/NetworkReporter.h>
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
@@ -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 <UIKit/UIKit.h>
#ifdef __cplusplus
#import <string>
NS_ASSUME_NONNULL_BEGIN
inline std::string_view RCTStringViewFromNSString(NSString *string)
{
return std::string_view{string.UTF8String, string.length};
}
NS_ASSUME_NONNULL_END
#endif
@@ -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 ]];
}
@@ -14,6 +14,7 @@
#include <jsinspector-modern/network/NetworkReporter.h>
#include <sstream>
#include <tuple>
#include <utility>
#include <variant>
@@ -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
@@ -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
@@ -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>(
ResponseBody{std::string(data), base64Encoded}));
order_.push_back(requestId);
return true;
}
std::shared_ptr<const BoundedRequestBuffer::ResponseBody>
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
@@ -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 <deque>
#include <memory>
#include <string>
#include <unordered_map>
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<const ResponseBody> get(const std::string& requestId) const;
/**
* Remove all entries from the buffer.
*/
void clear();
private:
std::unordered_map<std::string, std::shared_ptr<const ResponseBody>>
responses_;
std::deque<std::string> order_;
size_t currentSize_ = 0;
};
} // namespace facebook::react::jsinspector_modern
@@ -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)
@@ -12,7 +12,6 @@
#endif
#ifdef REACT_NATIVE_DEBUGGER_ENABLED
#include <folly/dynamic.h>
#include <jsinspector-modern/cdp/CdpJson.h>
#endif
#include <react/featureflags/ReactNativeFeatureFlags.h>
@@ -21,6 +20,7 @@
#ifdef REACT_NATIVE_DEBUGGER_ENABLED
#include <chrono>
#endif
#include <glog/logging.h>
#include <stdexcept>
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<std::mutex> lock(requestBodyMutex_);
requestBodyBuffer_.put(requestId, body, base64Encoded);
}
std::optional<std::tuple<std::string, bool>> NetworkReporter::getResponseBody(
const std::string& requestId) {
std::lock_guard<std::mutex> lock(requestBodyMutex_);
auto responseBody = requestBodyBuffer_.get(requestId);
if (responseBody == nullptr) {
return std::nullopt;
}
return std::make_optional<std::tuple<std::string, bool>>(
responseBody->data, responseBody->base64Encoded);
}
} // namespace facebook::react::jsinspector_modern
@@ -7,14 +7,17 @@
#pragma once
#include "BoundedRequestBuffer.h"
#include "NetworkTypes.h"
#include <folly/dynamic.h>
#include <react/timing/primitives.h>
#include <atomic>
#include <functional>
#include <mutex>
#include <string>
#include <tuple>
#include <unordered_map>
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<std::tuple<std::string, bool>> 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<std::string, ResourceTimingData> perfTimingsBuffer_{};
std::mutex perfTimingsMutex_;
// Only populated when CDP debugging is enabled.
BoundedRequestBuffer requestBodyBuffer_{};
std::mutex requestBodyMutex_;
};
} // namespace facebook::react::jsinspector_modern