Skip to content

Commit 020512b

Browse files
huntiemeta-codesync[bot]
authored andcommitted
Add CDP support for WebSocket events (iOS) (#57543)
Summary: Pull Request resolved: #57543 **Context** This stack implements WebSocket event debugging for the Network panel in React Native DevTools. Discussed with Expo earlier this year, we want upstream parity before Expo adopts the 1P Network panel. This is a comparatively small surface: 6 CDP events, no CDP methods. **Minimum goal**: Parity with Expo's current WS event coverage. We improve on this with Request Initiator support in D111561995. **This diff** Adds first-party reporting for six of the seven WebSocket CDP events (`webSocketCreated`, `webSocketWillSendHandshakeRequest`, `webSocketHandshakeResponseReceived`, `webSocketFrameSent`, `webSocketFrameReceived`, `webSocketClosed`), mirroring the layering of the existing HTTP pipeline: - **C++ core**: WebSocket CDP types and reporting APIs through `CdpNetwork` → `NetworkHandler` → `NetworkReporter`. Compiled to no-ops in production builds; inert unless the Network domain is enabled. - **iOS**: A new `RCTInspectorWebSocketReporter` bridges `RCTWebSocketModule` to `NetworkReporter`, reporting connection lifecycle, real handshake headers, and sent/received messages. Android follows separately. - **Gating**: A new `fuseboxWebSocketEventsEnabled` feature flag (experimentation, default off), checked alongside `enableNetworkEventReporting`. **Notes** - **Omitted**: [`Network.webSocketFrameError`](https://chromedevtools.github.io/devtools-protocol/tot/Network/#event-webSocketFrameError). Neither SocketRocket nor OkHttp exposes a frame-scoped error; connection failures are terminal and already reported via `webSocketClosed`. Cheap to bolt on once a genuine per-message error source exists (e.g. OkHttp `send()` returning false). - **Omitted**: Optional [`WebSocketResponse`](https://chromedevtools.github.io/devtools-protocol/tot/Network/#type-WebSocketResponse) fields (`headersText`, `requestHeaders`, `requestHeadersText`) — we report structured headers only; raw handshake text isn't available from OkHttp on Android. - No `performance-timeline` integration — WebSocket events report to CDP only; unlike HTTP, there is no `PerformanceResourceTiming` counterpart. **Rollout plan** (New `enableNetworkEventReporting` flag) - 0.88 - Canary channel - 0.89 - Stable channel Changelog: [Internal] Reviewed By: GijsWeterings, javache Differential Revision: D111561998 fbshipit-source-id: ecd46ddb6baa91f1c1d77598ee62b2e410b45bd9
1 parent 2dfdcbf commit 020512b

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)