Skip to content

Commit 7cbabfc

Browse files
huntiemeta-codesync[bot]
authored andcommitted
Add WebSocket CDP network reporting (iOS)
Summary: **Motivation** React Native DevTools' Network panel records fetch, XHR, and Image traffic, but WebSocket connections are invisible — neither the core CDP backend nor any existing inspector integration implements the CDP `Network.webSocket*` events. TODO: **Goal (as of this diff)**: Parity with Expo's current WS event coverage. Network Initiator is added down the stack. **This diff** Adds first-party reporting for six of the seven WebSocket CDP events (`webSocketCreated`, `webSocketWillSendHandshakeRequest`, `webSocketHandshakeResponseReceived`, `webSocketFrameSent`, `webSocketFrameReceived`, `webSocketClosed`), following the layering of the existing HTTP pipeline: - `CdpNetwork` (jsinspector-modern/network): new WebSocket CDP types and event params with `folly::dynamic` serialization. - `NetworkHandler`: new `onWebSocket*` emitters, active only while the Network domain is enabled. - `NetworkReporter` (react/networking): new `reportWebSocket*` platform-facing methods, compiled to no-ops unless `REACT_NATIVE_DEBUGGER_ENABLED`. - iOS: new `RCTInspectorWebSocketReporter` wrapper in React-CoreModules, with call sites in `RCTWebSocketModule`. Each connection gets a UUID request ID (associated object on `SRWebSocket`); the real handshake response is read from `SRWebSocket.receivedHTTPHeaders`. React-CoreModules gains a dependency on React-networking (no cycle: React-networking's transitive closure never reaches React-CoreModules). - Reporting is gated behind a new `fuseboxWebSocketEventsEnabled` feature flag (experimentation, default false), checked alongside `enableNetworkEventReporting` and a connected debugger at the call-site wrapper. **Notes** `webSocketFrameError` is intentionally omitted for now. Android integration follows separately. Changelog: [Internal] Differential Revision: D111561998
1 parent cb0cf07 commit 7cbabfc

40 files changed

Lines changed: 1607 additions & 48 deletions
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
#import <CFNetwork/CFNetwork.h>
9+
#import <Foundation/Foundation.h>
10+
11+
NS_ASSUME_NONNULL_BEGIN
12+
13+
/**
14+
* [Experimental] An interface for reporting WebSocket events to the modern
15+
* debugger server.
16+
*
17+
* In a production (non dev or profiling) build, CDP reporting is disabled
18+
* and all methods are a no-op.
19+
*
20+
* This is a helper class wrapping `facebook::react::NetworkReporter`.
21+
*/
22+
@interface RCTInspectorWebSocketReporter : NSObject
23+
24+
/**
25+
* Report that a WebSocket connection is about to be created.
26+
*
27+
* Corresponds to `Network.webSocketCreated` in CDP.
28+
*/
29+
+ (void)reportWebSocketCreated:(nullable NSString *)requestId url:(NSURL *)url;
30+
31+
/**
32+
* Report that a WebSocket handshake (HTTP upgrade) request is about to be
33+
* sent, along with its final request headers.
34+
*
35+
* Corresponds to `Network.webSocketWillSendHandshakeRequest` in CDP.
36+
*/
37+
+ (void)reportWillSendHandshakeRequest:(nullable NSString *)requestId request:(NSURLRequest *)request;
38+
39+
/**
40+
* Report that the WebSocket handshake response was received and the
41+
* connection is established. `httpMessage` is the raw handshake response,
42+
* e.g. `SRWebSocket.receivedHTTPHeaders`. If NULL or incomplete, a minimal
43+
* `101 Switching Protocols` response is reported instead.
44+
*
45+
* Corresponds to `Network.webSocketHandshakeResponseReceived` in CDP.
46+
*/
47+
+ (void)reportHandshakeResponseReceived:(nullable NSString *)requestId
48+
httpMessage:(nullable CFHTTPMessageRef)httpMessage;
49+
50+
/**
51+
* Report a WebSocket message sent over an open connection. `message` must be
52+
* an `NSString` (text message) or `NSData` (binary message).
53+
*
54+
* Corresponds to `Network.webSocketFrameSent` in CDP.
55+
*/
56+
+ (void)reportMessageSent:(nullable NSString *)requestId message:(nullable id)message;
57+
58+
/**
59+
* Report a WebSocket message received over an open connection. `message`
60+
* must be an `NSString` (text message) or `NSData` (binary message).
61+
*
62+
* Corresponds to `Network.webSocketFrameReceived` in CDP.
63+
*/
64+
+ (void)reportMessageReceived:(nullable NSString *)requestId message:(nullable id)message;
65+
66+
/**
67+
* Report that a WebSocket connection was closed, whether cleanly or due to
68+
* an error.
69+
*
70+
* Corresponds to `Network.webSocketClosed` in CDP.
71+
*/
72+
+ (void)reportWebSocketClosed:(nullable NSString *)requestId;
73+
74+
@end
75+
76+
NS_ASSUME_NONNULL_END
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
#import "RCTInspectorWebSocketReporter.h"
9+
10+
#import <react/featureflags/ReactNativeFeatureFlags.h>
11+
#import <react/networking/NetworkReporter.h>
12+
13+
using facebook::react::Headers;
14+
using facebook::react::NetworkReporter;
15+
using facebook::react::ReactNativeFeatureFlags;
16+
17+
namespace {
18+
19+
/**
20+
* Convert an `NSString` to a `std::string`, mapping `nil` (and any string whose
21+
* UTF-8 representation is unavailable) to an empty string.
22+
*/
23+
std::string toStdString(NSString *string)
24+
{
25+
const char *utf8 = string.UTF8String;
26+
return utf8 != nullptr ? std::string(utf8) : std::string();
27+
}
28+
29+
/**
30+
* Returns whether WebSocket events should be reported for the given request
31+
* ID. Reporting requires the `enableNetworkEventReporting` and
32+
* `fuseboxWebSocketEventsEnabled` feature flags, and a connected CDP debugger
33+
* with the Network domain enabled (dev or profiling builds only).
34+
*/
35+
BOOL isReportingEnabled(NSString *requestId)
36+
{
37+
return requestId != nil && ReactNativeFeatureFlags::enableNetworkEventReporting() &&
38+
ReactNativeFeatureFlags::fuseboxWebSocketEventsEnabled() && NetworkReporter::getInstance().isDebuggingEnabled();
39+
}
40+
41+
Headers convertNSDictionaryToHeaders(const NSDictionary<NSString *, NSString *> *headers)
42+
{
43+
Headers result;
44+
for (NSString *key in headers) {
45+
result[toStdString(key)] = toStdString(headers[key]);
46+
}
47+
return result;
48+
}
49+
50+
/**
51+
* Convert an `NSString` (text) or `NSData` (binary) WebSocket message to a
52+
* CDP `payloadData` string — UTF-8 for text messages, base64 for binary.
53+
* \returns Whether the message was a supported type, writing to the out
54+
* params on success.
55+
*/
56+
bool convertMessageToPayloadData(id message, std::string &payloadData, bool &isBinary)
57+
{
58+
if ([message isKindOfClass:[NSString class]]) {
59+
payloadData = toStdString((NSString *)message);
60+
isBinary = false;
61+
return true;
62+
}
63+
64+
if ([message isKindOfClass:[NSData class]]) {
65+
payloadData = toStdString([(NSData *)message base64EncodedStringWithOptions:0]);
66+
isBinary = true;
67+
return true;
68+
}
69+
70+
return false;
71+
}
72+
73+
} // namespace
74+
75+
@implementation RCTInspectorWebSocketReporter
76+
77+
+ (void)reportWebSocketCreated:(NSString *)requestId url:(NSURL *)url
78+
{
79+
if (!isReportingEnabled(requestId)) {
80+
return;
81+
}
82+
83+
NetworkReporter::getInstance().reportWebSocketCreated(toStdString(requestId), toStdString(url.absoluteString));
84+
}
85+
86+
+ (void)reportWillSendHandshakeRequest:(NSString *)requestId request:(NSURLRequest *)request
87+
{
88+
if (!isReportingEnabled(requestId)) {
89+
return;
90+
}
91+
92+
NetworkReporter::getInstance().reportWebSocketWillSendHandshakeRequest(
93+
toStdString(requestId), convertNSDictionaryToHeaders(request.allHTTPHeaderFields));
94+
}
95+
96+
+ (void)reportHandshakeResponseReceived:(NSString *)requestId httpMessage:(nullable CFHTTPMessageRef)httpMessage
97+
{
98+
if (!isReportingEnabled(requestId)) {
99+
return;
100+
}
101+
102+
// Fall back to a minimal `101 Switching Protocols` response (guaranteed by
103+
// RFC 6455 for an open connection) if the handshake response is unavailable.
104+
uint16_t statusCode = 101;
105+
Headers headers;
106+
107+
if (httpMessage != NULL && CFHTTPMessageIsHeaderComplete(httpMessage) != 0) {
108+
statusCode = (uint16_t)CFHTTPMessageGetResponseStatusCode(httpMessage);
109+
NSDictionary<NSString *, NSString *> *responseHeaders =
110+
(NSDictionary<NSString *, NSString *> *)CFBridgingRelease(CFHTTPMessageCopyAllHeaderFields(httpMessage));
111+
headers = convertNSDictionaryToHeaders(responseHeaders);
112+
}
113+
114+
NetworkReporter::getInstance().reportWebSocketHandshakeResponseReceived(toStdString(requestId), statusCode, headers);
115+
}
116+
117+
+ (void)reportMessageSent:(NSString *)requestId message:(id)message
118+
{
119+
if (!isReportingEnabled(requestId)) {
120+
return;
121+
}
122+
123+
std::string payloadData;
124+
bool isBinary = false;
125+
if (!convertMessageToPayloadData(message, payloadData, isBinary)) {
126+
return;
127+
}
128+
129+
NetworkReporter::getInstance().reportWebSocketMessageSent(toStdString(requestId), payloadData, isBinary);
130+
}
131+
132+
+ (void)reportMessageReceived:(NSString *)requestId message:(id)message
133+
{
134+
if (!isReportingEnabled(requestId)) {
135+
return;
136+
}
137+
138+
std::string payloadData;
139+
bool isBinary = false;
140+
if (!convertMessageToPayloadData(message, payloadData, isBinary)) {
141+
return;
142+
}
143+
144+
NetworkReporter::getInstance().reportWebSocketMessageReceived(toStdString(requestId), payloadData, isBinary);
145+
}
146+
147+
+ (void)reportWebSocketClosed:(NSString *)requestId
148+
{
149+
if (!isReportingEnabled(requestId)) {
150+
return;
151+
}
152+
153+
NetworkReporter::getInstance().reportWebSocketClosed(toStdString(requestId));
154+
}
155+
156+
@end

packages/react-native/React/CoreModules/RCTWebSocketModule.mm

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,17 @@
1616
#import <SocketRocket/SRWebSocket.h>
1717

1818
#import "CoreModulesPlugins.h"
19+
#import "RCTInspectorWebSocketReporter.h"
20+
21+
@interface SRWebSocket (React)
22+
23+
/**
24+
* The CDP request ID used to report this connection's events to the modern
25+
* debugger server, via `RCTInspectorWebSocketReporter`.
26+
*/
27+
@property (nonatomic, copy) NSString *inspectorRequestId;
28+
29+
@end
1930

2031
@implementation SRWebSocket (React)
2132

@@ -29,6 +40,16 @@ - (void)setReactTag:(NSNumber *)reactTag
2940
objc_setAssociatedObject(self, @selector(reactTag), reactTag, OBJC_ASSOCIATION_COPY_NONATOMIC);
3041
}
3142

43+
- (NSString *)inspectorRequestId
44+
{
45+
return objc_getAssociatedObject(self, _cmd);
46+
}
47+
48+
- (void)setInspectorRequestId:(NSString *)inspectorRequestId
49+
{
50+
objc_setAssociatedObject(self, @selector(inspectorRequestId), inspectorRequestId, OBJC_ASSOCIATION_COPY_NONATOMIC);
51+
}
52+
3253
@end
3354

3455
@interface RCTWebSocketModule () <SRWebSocketDelegate, NativeWebSocketModuleSpec>
@@ -131,16 +152,23 @@ - (void)invalidate
131152
[webSocket setDelegateDispatchQueue:[self methodQueue]];
132153
webSocket.delegate = self;
133154
webSocket.reactTag = @(socketID);
155+
webSocket.inspectorRequestId = [[NSUUID UUID] UUIDString];
134156
if (!_sockets) {
135157
_sockets = [NSMutableDictionary new];
136158
}
137159
_sockets[@(socketID)] = webSocket;
160+
161+
[RCTInspectorWebSocketReporter reportWebSocketCreated:webSocket.inspectorRequestId url:URL];
162+
[RCTInspectorWebSocketReporter reportWillSendHandshakeRequest:webSocket.inspectorRequestId request:request];
163+
138164
[webSocket open];
139165
}
140166

141167
RCT_EXPORT_METHOD(send : (NSString *)message forSocketID : (double)socketID)
142168
{
143-
[_sockets[@(socketID)] sendString:message error:nil];
169+
SRWebSocket *webSocket = _sockets[@(socketID)];
170+
[RCTInspectorWebSocketReporter reportMessageSent:webSocket.inspectorRequestId message:message];
171+
[webSocket sendString:message error:nil];
144172
}
145173

146174
RCT_EXPORT_METHOD(sendBinary : (NSString *)base64String forSocketID : (double)socketID)
@@ -150,7 +178,9 @@ - (void)invalidate
150178

151179
- (void)sendData:(NSData *)data forSocketID:(NSNumber *__nonnull)socketID
152180
{
153-
[_sockets[socketID] sendData:data error:nil];
181+
SRWebSocket *webSocket = _sockets[socketID];
182+
[RCTInspectorWebSocketReporter reportMessageSent:webSocket.inspectorRequestId message:data];
183+
[webSocket sendData:data error:nil];
154184
}
155185

156186
RCT_EXPORT_METHOD(ping : (double)socketID)
@@ -182,6 +212,7 @@ - (void)webSocket:(SRWebSocket *)webSocket didReceiveMessage:(id)message
182212
if (!socketID) {
183213
return;
184214
}
215+
[RCTInspectorWebSocketReporter reportMessageReceived:webSocket.inspectorRequestId message:message];
185216
id contentHandler = _contentHandlers[socketID];
186217
if (contentHandler) {
187218
message = [contentHandler processWebsocketMessage:message forSocketID:socketID withType:&type];
@@ -203,6 +234,8 @@ - (void)webSocketDidOpen:(SRWebSocket *)webSocket
203234
if (!socketID) {
204235
return;
205236
}
237+
[RCTInspectorWebSocketReporter reportHandshakeResponseReceived:webSocket.inspectorRequestId
238+
httpMessage:webSocket.receivedHTTPHeaders];
206239
[self sendEventWithName:@"websocketOpen"
207240
body:@{@"id" : socketID, @"protocol" : webSocket.protocol ? webSocket.protocol : @""}];
208241
}
@@ -213,6 +246,7 @@ - (void)webSocket:(SRWebSocket *)webSocket didFailWithError:(NSError *)error
213246
if (!socketID) {
214247
return;
215248
}
249+
[RCTInspectorWebSocketReporter reportWebSocketClosed:webSocket.inspectorRequestId];
216250
_contentHandlers[socketID] = nil;
217251
_sockets[socketID] = nil;
218252
NSDictionary *body =
@@ -229,6 +263,7 @@ - (void)webSocket:(SRWebSocket *)webSocket
229263
if (!socketID) {
230264
return;
231265
}
266+
[RCTInspectorWebSocketReporter reportWebSocketClosed:webSocket.inspectorRequestId];
232267
_contentHandlers[socketID] = nil;
233268
_sockets[socketID] = nil;
234269
[self sendEventWithName:@"websocketClosed"

packages/react-native/React/CoreModules/React-CoreModules.podspec

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ Pod::Spec.new do |s|
5858
add_dependency(s, "React-jsinspector", :framework_name => 'jsinspector_modern')
5959
add_dependency(s, "React-jsinspectorcdp", :framework_name => 'jsinspector_moderncdp')
6060
add_dependency(s, "React-jsinspectortracing", :framework_name => 'jsinspector_moderntracing')
61+
add_dependency(s, "React-networking", :framework_name => 'React_networking')
6162

6263
add_dependency(s, "React-RCTFBReactNativeSpec")
6364
add_dependency(s, "ReactCommon", :subspec => "turbomodule/core", :additional_framework_paths => ["react/nativemodule/core"])

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlags.kt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<854bb2a573f2c69d495aa1721f903ab0>>
7+
* @generated SignedSource<<df3f81d0083a8ebe9d0fbcb86e652405>>
88
*/
99

1010
/**
@@ -390,6 +390,12 @@ public object ReactNativeFeatureFlags {
390390
@JvmStatic
391391
public fun fuseboxScreenshotCaptureEnabled(): Boolean = accessor.fuseboxScreenshotCaptureEnabled()
392392

393+
/**
394+
* Enable reporting of WebSocket network events (`Network.webSocket*` CDP events) to the React Native DevTools CDP backend. Requires `fuseboxNetworkInspectionEnabled`.
395+
*/
396+
@JvmStatic
397+
public fun fuseboxWebSocketEventsEnabled(): Boolean = accessor.fuseboxWebSocketEventsEnabled()
398+
393399
/**
394400
* When enabled, uses optimized platform-specific paths to apply animated props synchronously. On Android, this uses a batched int/double buffer protocol with a single JNI call. On iOS, this passes AnimatedProps directly through the delegate chain and applies them via cloneProps, avoiding the folly::dynamic round-trip.
395401
*/

0 commit comments

Comments
 (0)