diff --git a/packages/react-native/React/DevSupport/RCTInspectorDevServerHelper.mm b/packages/react-native/React/DevSupport/RCTInspectorDevServerHelper.mm index 34036a24962..a52d0dd6ca2 100644 --- a/packages/react-native/React/DevSupport/RCTInspectorDevServerHelper.mm +++ b/packages/react-native/React/DevSupport/RCTInspectorDevServerHelper.mm @@ -14,6 +14,7 @@ #import #import +#import #import #import @@ -180,7 +181,11 @@ static void sendEventToAllConnections(NSString *event) NSString *key = [inspectorURL absoluteString]; id connection = socketConnections[key]; if (!connection || !connection.isConnected) { - connection = [[RCTCxxInspectorPackagerConnection alloc] initWithURL:inspectorURL]; + if (facebook::react::jsinspector_modern::InspectorFlags::getInstance().getFuseboxEnabled()) { + connection = [[RCTCxxInspectorPackagerConnection alloc] initWithURL:inspectorURL]; + } else { + connection = [[RCTInspectorPackagerConnection alloc] initWithURL:inspectorURL]; + } socketConnections[key] = connection; [connection connect]; diff --git a/packages/react-native/React/Inspector/RCTInspectorPackagerConnection.m b/packages/react-native/React/Inspector/RCTInspectorPackagerConnection.m new file mode 100644 index 00000000000..7d934b43343 --- /dev/null +++ b/packages/react-native/React/Inspector/RCTInspectorPackagerConnection.m @@ -0,0 +1,325 @@ +/* + * 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 + +#if RCT_DEV || RCT_REMOTE_PROFILE + +#import +#import +#import +#import +#import + +// This is a port of the Android impl, at +// ReactAndroid/src/main/java/com/facebook/react/devsupport/InspectorPackagerConnection.java +// please keep consistent :) + +const int RECONNECT_DELAY_MS = 2000; + +@interface RCTInspectorPackagerConnection () { + NSURL *_url; + NSMutableDictionary *_inspectorConnections; + SRWebSocket *_webSocket; + BOOL _closed; + BOOL _suppressConnectionErrors; +} +@end + +@interface RCTInspectorRemoteConnection () { + __weak RCTInspectorPackagerConnection *_owningPackagerConnection; + NSString *_pageId; +} +- (instancetype)initWithPackagerConnection:(RCTInspectorPackagerConnection *)owningPackagerConnection + pageId:(NSString *)pageId; +@end + +static NSDictionary *makePageIdPayload(NSString *pageId) +{ + return @{@"pageId" : pageId}; +} + +@implementation RCTInspectorPackagerConnection + +RCT_NOT_IMPLEMENTED(-(instancetype)init) + +- (instancetype)initWithURL:(NSURL *)url +{ + if (self = [super init]) { + _url = url; + _inspectorConnections = [NSMutableDictionary new]; + } + return self; +} + +- (void)handleProxyMessage:(NSDictionary *)message +{ + NSString *event = message[@"event"]; + NSDictionary *payload = message[@"payload"]; + if ([@"getPages" isEqualToString:event]) { + [self sendEvent:event payload:[self pages]]; + } else if ([@"wrappedEvent" isEqualToString:event]) { + [self handleWrappedEvent:payload]; + } else if ([@"connect" isEqualToString:event]) { + [self handleConnect:payload]; + } else if ([@"disconnect" isEqualToString:event]) { + [self handleDisconnect:payload]; + } else { + RCTLogError(@"Unknown event: %@", event); + } +} + +- (void)sendEventToAllConnections:(NSString *)event +{ + for (NSString *pageId in _inspectorConnections) { + [_inspectorConnections[pageId] sendMessage:event]; + } +} + +- (void)closeAllConnections +{ + for (NSString *pageId in _inspectorConnections) { + [[_inspectorConnections objectForKey:pageId] disconnect]; + } + [_inspectorConnections removeAllObjects]; +} + +- (void)handleConnect:(NSDictionary *)payload +{ + NSString *pageId = payload[@"pageId"]; + RCTInspectorLocalConnection *existingConnection = _inspectorConnections[pageId]; + if (existingConnection) { + [_inspectorConnections removeObjectForKey:pageId]; + [existingConnection disconnect]; + RCTLogWarn(@"Already connected: %@", pageId); + return; + } + + RCTInspectorRemoteConnection *remoteConnection = + [[RCTInspectorRemoteConnection alloc] initWithPackagerConnection:self pageId:pageId]; + + RCTInspectorLocalConnection *inspectorConnection = [RCTInspector connectPage:[pageId integerValue] + forRemoteConnection:remoteConnection]; + _inspectorConnections[pageId] = inspectorConnection; +} + +- (void)handleDisconnect:(NSDictionary *)payload +{ + NSString *pageId = payload[@"pageId"]; + RCTInspectorLocalConnection *inspectorConnection = _inspectorConnections[pageId]; + if (inspectorConnection) { + [self removeConnectionForPage:pageId]; + [inspectorConnection disconnect]; + } +} + +- (void)removeConnectionForPage:(NSString *)pageId +{ + [_inspectorConnections removeObjectForKey:pageId]; +} + +- (void)handleWrappedEvent:(NSDictionary *)payload +{ + NSString *pageId = payload[@"pageId"]; + NSString *wrappedEvent = payload[@"wrappedEvent"]; + RCTInspectorLocalConnection *inspectorConnection = _inspectorConnections[pageId]; + if (!inspectorConnection) { + RCTLogWarn(@"Not connected to page: %@ , failed trying to handle event: %@", pageId, wrappedEvent); + return; + } + [inspectorConnection sendMessage:wrappedEvent]; +} + +- (NSArray *)pages +{ + NSArray *pages = [RCTInspector pages]; + NSMutableArray *array = [NSMutableArray arrayWithCapacity:pages.count]; + + for (RCTInspectorPage *page in pages) { + NSDictionary *jsonPage = @{ + @"id" : [@(page.id) stringValue], + @"title" : page.title, + @"app" : [[NSBundle mainBundle] bundleIdentifier], + @"vm" : page.vm, + }; + [array addObject:jsonPage]; + } + return array; +} + +- (void)sendWrappedEvent:(NSString *)pageId message:(NSString *)message +{ + NSDictionary *payload = @{ + @"pageId" : pageId, + @"wrappedEvent" : message, + }; + [self sendEvent:@"wrappedEvent" payload:payload]; +} + +- (void)sendEvent:(NSString *)name payload:(id)payload +{ + NSDictionary *jsonMessage = @{ + @"event" : name, + @"payload" : payload, + }; + [self sendToPackager:jsonMessage]; +} + +// analogous to InspectorPackagerConnection.Connection.onFailure(...) +- (void)webSocket:(__unused SRWebSocket *)webSocket didFailWithError:(NSError *)error +{ + if (_webSocket) { + [self abort:@"Websocket exception" withCause:error]; + } + if (!_closed && [error code] != ECONNREFUSED) { + [self reconnect]; + } +} + +// analogous to InspectorPackagerConnection.Connection.onMessage(...) +- (void)webSocket:(__unused SRWebSocket *)webSocket didReceiveMessageWithString:(nonnull NSString *)messageText +{ + NSError *error = nil; + id parsedJSON = RCTJSONParse(messageText, &error); + if (error) { + RCTLogWarn(@"Unrecognized inspector message, string was not valid JSON: %@", messageText); + return; + } + + [self handleProxyMessage:parsedJSON]; +} + +// analogous to InspectorPackagerConnection.Connection.onClosed(...) +- (void)webSocket:(__unused SRWebSocket *)webSocket + didCloseWithCode:(__unused NSInteger)code + reason:(__unused NSString *)reason + wasClean:(__unused BOOL)wasClean +{ + _webSocket = nil; + [self closeAllConnections]; + if (!_closed) { + [self reconnect]; + } +} + +- (bool)isConnected +{ + return _webSocket != nil; +} + +- (void)connect +{ + if (_closed) { + RCTLogError(@"Illegal state: Can't connect after having previously been closed."); + return; + } + + // The corresponding android code has a lot of custom config options for + // timeouts, but our previous class, RCTSRWebSocket didn't have the same + // implemented options. Might be worth reinvestigating for SRWebSocket? + _webSocket = [[SRWebSocket alloc] initWithURL:_url]; + _webSocket.delegate = self; + [_webSocket open]; +} + +- (void)reconnect +{ + if (_closed) { + RCTLogError(@"Illegal state: Can't reconnect after having previously been closed."); + return; + } + + if (_suppressConnectionErrors) { + RCTLogWarn(@"Couldn't connect to packager, will silently retry"); + _suppressConnectionErrors = true; + } + + __weak RCTInspectorPackagerConnection *weakSelf = self; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, RECONNECT_DELAY_MS * NSEC_PER_MSEC), dispatch_get_main_queue(), ^{ + RCTInspectorPackagerConnection *strongSelf = weakSelf; + if (strongSelf && !strongSelf->_closed) { + [strongSelf connect]; + } + }); +} + +- (void)closeQuietly +{ + _closed = true; + [self disposeWebSocket]; +} + +- (void)sendToPackager:(NSDictionary *)messageObject +{ + __weak RCTInspectorPackagerConnection *weakSelf = self; + dispatch_async(dispatch_get_main_queue(), ^{ + RCTInspectorPackagerConnection *strongSelf = weakSelf; + if (strongSelf && !strongSelf->_closed) { + NSError *error; + NSString *messageText = RCTJSONStringify(messageObject, &error); + if (error) { + RCTLogWarn(@"Couldn't send event to packager: %@", error); + } else { + [strongSelf->_webSocket sendString:messageText error:nil]; + } + } + }); +} + +- (void)abort:(NSString *)message withCause:(NSError *)cause +{ + // Don't log ECONNREFUSED at all; it's expected in cases where the server isn't listening. + if (![cause.domain isEqual:NSPOSIXErrorDomain] || cause.code != ECONNREFUSED) { + RCTLogInfo(@"Error occurred, shutting down websocket connection: %@ %@", message, cause); + } + + [self closeAllConnections]; + [self disposeWebSocket]; +} + +- (void)disposeWebSocket +{ + if (_webSocket) { + [_webSocket closeWithCode:1000 reason:@"End of session"]; + _webSocket.delegate = nil; + _webSocket = nil; + } +} + +@end + +@implementation RCTInspectorRemoteConnection + +RCT_NOT_IMPLEMENTED(-(instancetype)init) + +- (instancetype)initWithPackagerConnection:(RCTInspectorPackagerConnection *)owningPackagerConnection + pageId:(NSString *)pageId +{ + if (self = [super init]) { + _owningPackagerConnection = owningPackagerConnection; + _pageId = pageId; + } + return self; +} + +- (void)onMessage:(NSString *)message +{ + [_owningPackagerConnection sendWrappedEvent:_pageId message:message]; +} + +- (void)onDisconnect +{ + RCTInspectorPackagerConnection *owningPackagerConnectionStrong = _owningPackagerConnection; + if (owningPackagerConnectionStrong) { + [owningPackagerConnectionStrong removeConnectionForPage:_pageId]; + [owningPackagerConnectionStrong sendEvent:@"disconnect" payload:makePageIdPayload(_pageId)]; + } +} + +@end + +#endif diff --git a/packages/react-native/ReactAndroid/api/ReactAndroid.api b/packages/react-native/ReactAndroid/api/ReactAndroid.api index 28c4c940600..03d6e5bf89b 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -2184,6 +2184,13 @@ public final class com/facebook/react/devsupport/InspectorFlags { public static final fun getFuseboxEnabled ()Z } +public class com/facebook/react/devsupport/InspectorPackagerConnection : com/facebook/react/devsupport/IInspectorPackagerConnection { + public fun (Ljava/lang/String;Ljava/lang/String;)V + public fun closeQuietly ()V + public fun connect ()V + public fun sendEventToAllConnections (Ljava/lang/String;)V +} + public class com/facebook/react/devsupport/JSCHeapCapture : com/facebook/fbreact/specs/NativeJSCHeapCaptureSpec { public fun (Lcom/facebook/react/bridge/ReactApplicationContext;)V public fun captureComplete (Ljava/lang/String;Ljava/lang/String;)V diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevServerHelper.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevServerHelper.java index 0a478c9828b..a6990a1c977 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevServerHelper.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevServerHelper.java @@ -210,8 +210,13 @@ public class DevServerHelper { new AsyncTask() { @Override protected Void doInBackground(Void... params) { - mInspectorPackagerConnection = - new CxxInspectorPackagerConnection(getInspectorDeviceUrl(), mPackageName); + if (InspectorFlags.getFuseboxEnabled()) { + mInspectorPackagerConnection = + new CxxInspectorPackagerConnection(getInspectorDeviceUrl(), mPackageName); + } else { + mInspectorPackagerConnection = + new InspectorPackagerConnection(getInspectorDeviceUrl(), mPackageName); + } mInspectorPackagerConnection.connect(); return null; } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/InspectorPackagerConnection.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/InspectorPackagerConnection.java new file mode 100644 index 00000000000..84107fb5e4c --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/InspectorPackagerConnection.java @@ -0,0 +1,314 @@ +/* + * 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. + */ + +package com.facebook.react.devsupport; + +import android.os.AsyncTask; +import android.os.Handler; +import android.os.Looper; +import androidx.annotation.Nullable; +import com.facebook.common.logging.FLog; +import com.facebook.react.bridge.Inspector; +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.WebSocket; +import okhttp3.WebSocketListener; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +public class InspectorPackagerConnection implements IInspectorPackagerConnection { + private static final String TAG = "InspectorPackagerConnection"; + + private final Connection mConnection; + private final Map mInspectorConnections; + private final String mPackageName; + + public InspectorPackagerConnection(String url, String packageName) { + mConnection = new Connection(url); + mInspectorConnections = new HashMap<>(); + mPackageName = packageName; + } + + public void connect() { + mConnection.connect(); + } + + public void closeQuietly() { + mConnection.close(); + } + + public void sendEventToAllConnections(String event) { + for (Map.Entry inspectorConnectionEntry : + mInspectorConnections.entrySet()) { + Inspector.LocalConnection inspectorConnection = inspectorConnectionEntry.getValue(); + inspectorConnection.sendMessage(event); + } + } + + void handleProxyMessage(JSONObject message) throws JSONException, IOException { + String event = message.getString("event"); + switch (event) { + case "getPages": + sendEvent("getPages", getPages()); + break; + case "wrappedEvent": + handleWrappedEvent(message.getJSONObject("payload")); + break; + case "connect": + handleConnect(message.getJSONObject("payload")); + break; + case "disconnect": + handleDisconnect(message.getJSONObject("payload")); + break; + default: + throw new IllegalArgumentException("Unknown event: " + event); + } + } + + void closeAllConnections() { + for (Map.Entry entry : mInspectorConnections.entrySet()) { + entry.getValue().disconnect(); + } + mInspectorConnections.clear(); + } + + private void handleConnect(JSONObject payload) throws JSONException { + final String pageId = payload.getString("pageId"); + Inspector.LocalConnection inspectorConnection = mInspectorConnections.remove(pageId); + if (inspectorConnection != null) { + throw new IllegalStateException("Already connected: " + pageId); + } + + try { + // TODO: Use strings for id's too + inspectorConnection = + Inspector.connect( + Integer.parseInt(pageId), + new Inspector.RemoteConnection() { + @Override + public void onMessage(String message) { + try { + sendWrappedEvent(pageId, message); + } catch (JSONException e) { + FLog.w(TAG, "Couldn't send event to packager", e); + } + } + + @Override + public void onDisconnect() { + try { + mInspectorConnections.remove(pageId); + sendEvent("disconnect", makePageIdPayload(pageId)); + } catch (JSONException e) { + FLog.w(TAG, "Couldn't send event to packager", e); + } + } + }); + mInspectorConnections.put(pageId, inspectorConnection); + } catch (Exception e) { + FLog.w(TAG, "Failed to open page: " + pageId, e); + sendEvent("disconnect", makePageIdPayload(pageId)); + } + } + + private void handleDisconnect(JSONObject payload) throws JSONException { + final String pageId = payload.getString("pageId"); + Inspector.LocalConnection inspectorConnection = mInspectorConnections.remove(pageId); + if (inspectorConnection == null) { + return; + } + + inspectorConnection.disconnect(); + } + + private void handleWrappedEvent(JSONObject payload) throws JSONException { + final String pageId = payload.getString("pageId"); + String wrappedEvent = payload.getString("wrappedEvent"); + Inspector.LocalConnection inspectorConnection = mInspectorConnections.get(pageId); + if (inspectorConnection == null) { + // This tends to happen during reloads, so don't panic. + FLog.w(TAG, "PageID " + pageId + " is disconnected. Dropping event: " + wrappedEvent); + return; + } + inspectorConnection.sendMessage(wrappedEvent); + } + + private JSONArray getPages() throws JSONException { + List pages = Inspector.getPages(); + JSONArray array = new JSONArray(); + for (Inspector.Page page : pages) { + JSONObject jsonPage = new JSONObject(); + jsonPage.put("id", String.valueOf(page.getId())); + jsonPage.put("title", page.getTitle()); + jsonPage.put("app", mPackageName); + jsonPage.put("vm", page.getVM()); + array.put(jsonPage); + } + return array; + } + + private void sendWrappedEvent(String pageId, String message) throws JSONException { + JSONObject payload = new JSONObject(); + payload.put("pageId", pageId); + payload.put("wrappedEvent", message); + sendEvent("wrappedEvent", payload); + } + + private void sendEvent(String name, Object payload) throws JSONException { + JSONObject jsonMessage = new JSONObject(); + jsonMessage.put("event", name); + jsonMessage.put("payload", payload); + mConnection.send(jsonMessage); + } + + private JSONObject makePageIdPayload(String pageId) throws JSONException { + JSONObject payload = new JSONObject(); + payload.put("pageId", pageId); + return payload; + } + + private class Connection extends WebSocketListener { + private static final int RECONNECT_DELAY_MS = 2000; + + private final String mUrl; + + private OkHttpClient mHttpClient; + private @Nullable WebSocket mWebSocket; + private final Handler mHandler; + private boolean mClosed; + private boolean mSuppressConnectionErrors; + + public Connection(String url) { + mUrl = url; + mHandler = new Handler(Looper.getMainLooper()); + } + + @Override + public void onOpen(WebSocket webSocket, Response response) { + mWebSocket = webSocket; + } + + @Override + public void onFailure(WebSocket webSocket, Throwable t, Response response) { + if (mWebSocket != null) { + abort("Websocket exception", t); + } + if (!mClosed) { + reconnect(); + } + } + + @Override + public void onMessage(WebSocket webSocket, String text) { + try { + handleProxyMessage(new JSONObject(text)); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Override + public void onClosed(WebSocket webSocket, int code, String reason) { + mWebSocket = null; + closeAllConnections(); + if (!mClosed) { + reconnect(); + } + } + + public void connect() { + if (mClosed) { + throw new IllegalStateException("Can't connect closed client"); + } + if (mHttpClient == null) { + mHttpClient = + new OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .writeTimeout(10, TimeUnit.SECONDS) + .readTimeout(0, TimeUnit.MINUTES) // Disable timeouts for read + .build(); + } + + Request request = new Request.Builder().url(mUrl).build(); + mHttpClient.newWebSocket(request, this); + } + + private void reconnect() { + if (mClosed) { + throw new IllegalStateException("Can't reconnect closed client"); + } + if (!mSuppressConnectionErrors) { + FLog.w(TAG, "Couldn't connect to packager, will silently retry"); + mSuppressConnectionErrors = true; + } + mHandler.postDelayed( + new Runnable() { + @Override + public void run() { + // check that we haven't been closed in the meantime + if (!mClosed) { + connect(); + } + } + }, + RECONNECT_DELAY_MS); + } + + public void close() { + mClosed = true; + if (mWebSocket != null) { + try { + mWebSocket.close(1000, "End of session"); + } catch (Exception e) { + // swallow, no need to handle it here + } + mWebSocket = null; + } + } + + public void send(final JSONObject object) { + new AsyncTask() { + @Override + protected Void doInBackground(WebSocket... sockets) { + if (sockets == null || sockets.length == 0) { + return null; + } + try { + sockets[0].send(object.toString()); + } catch (Exception e) { + FLog.w(TAG, "Couldn't send event to packager", e); + } + return null; + } + }.execute(mWebSocket); + } + + private void abort(String message, Throwable cause) { + FLog.e(TAG, "Error occurred, shutting down websocket connection: " + message, cause); + closeAllConnections(); + closeWebSocketQuietly(); + } + + private void closeWebSocketQuietly() { + if (mWebSocket != null) { + try { + mWebSocket.close(1000, "End of session"); + } catch (Exception e) { + // swallow, no need to handle it here + } + mWebSocket = null; + } + } + } +}