diff --git a/__docs__/README.md b/__docs__/README.md index 9f5195a9f81be6..7a54682e1a0cd6 100644 --- a/__docs__/README.md +++ b/__docs__/README.md @@ -55,7 +55,9 @@ TODO: Explain the different components of React Native at a high level. - Globals and environment setup - Error handling - Developer Tools - - React DevTools + - React Native DevTools + - Infrastructure + - [Inspector proxy protocol](../packages/dev-middleware/src/inspector-proxy/__docs__/README.md) - LogBox - Misc - Web APIs @@ -91,8 +93,6 @@ TODO: Explain the different components of React Native at a high level. - ESLint - Integration / E2E - [Fantom](../private/react-native-fantom/__docs__/README.md) -- Tooling - - React Native DevTools ### Used by this diff --git a/packages/dev-middleware/src/inspector-proxy/__docs__/README.md b/packages/dev-middleware/src/inspector-proxy/__docs__/README.md new file mode 100644 index 00000000000000..7a6d5f893855ee --- /dev/null +++ b/packages/dev-middleware/src/inspector-proxy/__docs__/README.md @@ -0,0 +1,298 @@ +# Inspector Proxy Protocol + +[🏠 Home](../../../../../__docs__/README.md) + +The inspector-proxy protocol facilitates Chrome DevTools Protocol (CDP) target +discovery and communication between **debuggers** (e.g., Chrome DevTools, VS +Code) and **devices** (processes containing React Native hosts). The proxy +multiplexes connections over a single WebSocket per device, allowing multiple +debuggers to connect to multiple pages on the same device. + +## πŸš€ Usage + +### Target Discovery (HTTP) + +We implement a subset of the +[Chrome DevTools Protocol](https://chromedevtools.github.io/devtools-protocol/)'s +[HTTP endpoints](https://chromedevtools.github.io/devtools-protocol/#:~:text=a%20reconnect%20button.-,HTTP%20Endpoints,-If%20started%20with) +to allow debuggers to discover targets. + +| Endpoint | Description | +| --------------------------- | ------------------------ | +| `GET /json` or `/json/list` | List of debuggable pages | +| `GET /json/version` | Protocol version info | + +### Device Registration (WebSocket) + +Devices register themselves with the proxy by connecting to `/inspector/device`: + +```text +ws://{host}/inspector/device?device={id}&name={name}&app={bundle_id}&profiling={true|false} +``` + +| Parameter | Required | Description | +| ----------- | -------- | ------------------------------------------------------------ | +| `device` | No\* | Logical device identifier. Auto-generated if omitted. | +| `name` | No | Human-readable device name. Defaults to "Unknown". | +| `app` | No | App bundle identifier. Defaults to "Unknown". | +| `profiling` | No | "true" if this is a profiling build. (Used for logging only) | + +\*Recommended for connection persistence across app restarts. + +#### Requirements for the `device` parameter + +The intent of the logical device ID is to help with target discovery and +especially *re*discovery - to reduce the number of times users need to +explicitly close and restart the debugger frontend (e.g. after an app crash). + +If provided, the logical device ID: + +1. SHOULD be stable for the current combination of physical device (or emulator + instance) and app. +2. SHOULD be stable across installs/launches of the same app on the same device + (or emulator instance), though it MAY be user-resettable (so as to not + require any special privacy permissions). +3. MUST be unique across different apps on the same physical device (or + emulator). +4. MUST be unique across physical devices (or emulators). +5. MUST be unique for each concurrent _instance_ of the same app on the same + physical device (or emulator). + +NOTE: The uniqueness requirements are stronger (MUST) than the stability +requirements (SHOULD). In particular, on platforms that allow multiple instances +of the same app to run concurrently, requirements 1 and/or 2 MAY be violated in +order to meet requirement 5. This is relevant, for example, on desktop +platforms. + +### Debugger Connection (WebSocket) + +Debuggers connect to `/inspector/debug` to form a CDP session with a page: + +```text +ws://{host}/inspector/debug?device={device_id}&page={page_id} +``` + +Both `device` and `page` query parameters are required. + +## πŸ“ Design + +### Architecture + +```text +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Debugger │────▢│ Inspector Proxy │◀────│ Device β”‚ +β”‚ (Chrome/VSCode) β”‚ β”‚ (Node.js) β”‚ β”‚ (iOS/Android) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + WebSocket HTTP + WebSocket WebSocket + /inspector/debug /json, /json/list /inspector/device + /json/version +``` + +### Device ↔ Proxy Protocol + +All messages are JSON-encoded WebSocket text frames: + +```typescript +interface Message { + event: string; + payload?: /* depends on event */; +} +``` + +#### Proxy β†’ Device Messages + +| Event | Payload | Description | +| -------------- | ------------------------------------------ | --------------------------------------------- | +| `getPages` | _(none)_ | Request current page list. Sent periodically. | +| `connect` | `{ pageId: string }` | Prepare for debugger connection to page. | +| `disconnect` | `{ pageId: string }` | Terminate debugger session for page. | +| `wrappedEvent` | `{ pageId: string, wrappedEvent: string }` | Forward CDP message (JSON string) to page. | + +#### Device β†’ Proxy Messages + +| Event | Payload | Description | +| -------------- | ------------------------------------------ | ----------------------------------------------------- | +| `getPages` | `Page[]` | Current list of inspectable pages. | +| `disconnect` | `{ pageId: string }` | Notify that page disconnected or rejected connection. | +| `wrappedEvent` | `{ pageId: string, wrappedEvent: string }` | Forward CDP message (JSON string) from page. | + +#### Page Object + +```typescript +interface Page { + id: string; // Unique page identifier (typically numeric string) + title: string; // Display title + app: string; // App bundle identifier + description?: string; // Additional description + capabilities?: { + nativePageReloads?: boolean; // Target keeps the socket open across reloads + nativeSourceCodeFetching?: boolean; // Target supports Network.loadNetworkResource + prefersFuseboxFrontend?: boolean; // Target is designed for React Native DevTools + }; +} +``` + +### Connection Lifecycle + +**Device Registration:** + +```text +Device Proxy + β”‚ β”‚ + │──── WS Connect ─────────────────▢│ + β”‚ /inspector/device?... β”‚ + β”‚ β”‚ + │◀──── getPages ───────────────────│ (periodically) + β”‚ β”‚ + │───── getPages response ─────────▢│ + β”‚ (page list) β”‚ +``` + +**Debugger Session:** + +```text +Debugger Proxy Device + β”‚ β”‚ β”‚ + │── WS Connect ───▢│ β”‚ + β”‚ ?device&page │── connect ────────────────▢│ + β”‚ β”‚ {pageId} β”‚ + β”‚ β”‚ β”‚ + │── CDP Request ──▢│── wrappedEvent ───────────▢│ + β”‚ β”‚ {pageId, wrappedEvent} β”‚ + β”‚ β”‚ β”‚ + β”‚ │◀── wrappedEvent ───────────│ + │◀── CDP Response ─│ {pageId, wrappedEvent} β”‚ + β”‚ β”‚ β”‚ + │── WS Close ─────▢│── disconnect ─────────────▢│ + β”‚ β”‚ {pageId} β”‚ +``` + +**Connection Rejection:** + +If a device cannot accept a `connect` (e.g., page doesn't exist), it should send +a `disconnect` back to the proxy for that `pageId`. + +### Connection Semantics + +1. **One Debugger Per Page**: New debugger connections to an already-connected + page disconnect the existing debugger. + +2. **Device Reconnection**: If a device reconnects with the same `device` ID, + the proxy may attempt to preserve active debugger sessions by forwarding them + to the new device connection. + +### WebSocket Close Reasons + +The proxy uses specific close reasons that DevTools frontends may recognize: + +| Reason | Context | +| ----------------------- | --------------------------------------- | +| `[PAGE_NOT_FOUND]` | Debugger connected to non-existent page | +| `[CONNECTION_LOST]` | Device disconnected | +| `[RECREATING_DEVICE]` | Device is reconnecting | +| `[NEW_DEBUGGER_OPENED]` | Another debugger took over this page | +| `[UNREGISTERED_DEVICE]` | Device ID not found | +| `[INCORRECT_URL]` | Missing device/page query parameters | + +### PageDescription (HTTP Response) + +The `/json` endpoint returns enriched page descriptions based on those reported +by the device. + +```typescript +interface PageDescription { + // Used for target selection + id: string; // "{deviceId}-{pageId}" + + // Used for display + title: string; + description: string; + deviceName: string; + + // Used for target matching + appId: string; + + // Used for debugger connection + webSocketDebuggerUrl: string; + + // React Native-specific metadata + reactNative: { + logicalDeviceId: string; // Used for target matching + capabilities: { + nativePageReloads?: boolean; // Used for target filtering + prefersFuseboxFrontend?: boolean; // Used for frontend selection + }; + }; +} +``` + +## πŸ”— Relationship with other systems + +### Part of this + +- **Device.js** - Per-device connection handler in the proxy +- **InspectorProxy.js** - Main proxy HTTP/WebSocket server + +### Used by this + +- **Chrome DevTools Protocol (CDP)** - The wrapped messages are CDP messages + exchanged between DevTools frontends and JavaScript runtimes. +- **WebSocket** - Transport layer for device and debugger connections. + +### Uses this + +- **InspectorPackagerConnection (C++)** - Shared device-side protocol + implementation in `ReactCommon/jsinspector-modern/`. +- **Platform layers** - iOS (`RCTInspectorDevServerHelper.mm`), Android + (`DevServerHelper.kt`), and ReactCxxPlatform (`Inspector.cpp`) provide + WebSocket I/O and threading. +- **openDebuggerMiddleware** - Uses `/json` to discover targets for the + `/open-debugger` endpoint. +- **OpenDebuggerKeyboardHandler** - Uses `/json` to display target selection in + the CLI. + +--- + +## Legacy Features + +The following features exist for backward compatibility with older React Native +targets that lack modern capabilities. New implementations should set +appropriate capability flags and may ignore this section. + +### Synthetic Reloadable Page (Page ID `-1`) + +For targets without the `nativePageReloads` capability, the proxy exposes a +synthetic page with ID `-1` titled "React Native Experimental (Improved Chrome +Reloads)". Debuggers connecting to this page are automatically redirected to the +most recent React Native page, surviving page reloads. + +When a new React Native page appears while a debugger is connected to `-1`: + +1. Proxy sends `disconnect` for the old page, `connect` for the new page +2. Proxy sends `Runtime.enable` and `Debugger.enable` CDP commands to the new + page +3. When `Runtime.executionContextCreated` is received, proxy sends + `Runtime.executionContextsCleared` to debugger, then `Debugger.resume` to + device + +### URL Rewriting + +For targets without the `nativeSourceCodeFetching` capability, the proxy +rewrites URLs in CDP messages: + +- **Debugger.scriptParsed** (device β†’ debugger): Device-relative URLs are + rewritten to debugger-relative URLs +- **Debugger.setBreakpointByUrl** (debugger β†’ device): URLs are rewritten back + to device-relative form +- **Debugger.getScriptSource**: Intercepted and handled by proxy via HTTP fetch +- **Network.loadNetworkResource**: Returns CDP error (code -32601) to force + frontend fallback + +Additionally, if a script URL matches `^[0-9a-z]+$` (alphanumeric ID), the proxy +prepends `file://` to ensure Chrome downloads source maps. + +### Legacy Reload Notification + +For targets without `nativePageReloads`, when a `disconnect` event is received +for a page, the proxy sends `{method: 'reload'}` to the connected debugger to +signal a page reload. diff --git a/packages/react-native/ReactCommon/jsinspector-modern/tests/InspectorPackagerConnectionTest.cpp b/packages/react-native/ReactCommon/jsinspector-modern/tests/InspectorPackagerConnectionTest.cpp index a75f47029bfcee..696dbdedf0f359 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/tests/InspectorPackagerConnectionTest.cpp +++ b/packages/react-native/ReactCommon/jsinspector-modern/tests/InspectorPackagerConnectionTest.cpp @@ -6,21 +6,12 @@ */ #include -#include -#include -#include #include #include #include -#include -#include - -#include - #include "FollyDynamicMatchers.h" -#include "InspectorMocks.h" -#include "UniquePtrFactory.h" +#include "InspectorPackagerConnectionTest.h" using namespace ::testing; using namespace std::literals::chrono_literals; @@ -29,115 +20,6 @@ using folly::dynamic, folly::toJson; namespace facebook::react::jsinspector_modern { -namespace { - -template -class InspectorPackagerConnectionTestBase : public testing::Test { - protected: - InspectorPackagerConnectionTestBase() - : packagerConnection_( - InspectorPackagerConnection{ - "ws://mock-host:12345", - "my-device", - "my-app", - packagerConnectionDelegates_.make_unique(asyncExecutor_)}) { - auto makeSocket = webSockets_.lazily_make_unique< - const std::string&, - std::weak_ptr>(); - ON_CALL(*packagerConnectionDelegate(), connectWebSocket(_, _)) - .WillByDefault([makeSocket](auto&&... args) { - auto socket = makeSocket(std::forward(args)...); - socket->getDelegate().didOpen(); - return std::move(socket); - }); - EXPECT_CALL(*packagerConnectionDelegate(), connectWebSocket(_, _)) - .Times(AnyNumber()); - } - - void TearDown() override { - // Forcibly clean up all pages currently registered with the inspector in - // order to isolate state between tests. NOTE: Using TearDown instead of a - // destructor so that we can use FAIL() etc. - std::vector pagesToRemove; - auto pages = getInspectorInstance().getPages(); - int liveConnectionCount = 0; - for (size_t i = 0; i != localConnections_.objectsVended(); ++i) { - if (localConnections_[i] != nullptr) { - liveConnectionCount++; - // localConnections_[i] is a strict mock and will complain when we - // removePage if the call is unexpected. - EXPECT_CALL(*localConnections_[i], disconnect()); - } - } - for (auto& page : pages) { - getInspectorInstance().removePage(page.id); - } - if (!pages.empty() && (liveConnectionCount != 0)) { - if (!::testing::Test::HasFailure()) { - FAIL() - << "Test case ended with " << liveConnectionCount - << " open connection(s) and " << pages.size() - << " registered page(s). You must manually call removePage for each page."; - } - } - ::testing::Test::TearDown(); - } - - MockInspectorPackagerConnectionDelegate* packagerConnectionDelegate() { - // We only create one PackagerConnectionDelegate per test. - EXPECT_EQ(packagerConnectionDelegates_.objectsVended(), 1); - return packagerConnectionDelegates_[0]; - } - - Executor asyncExecutor_; - - UniquePtrFactory - packagerConnectionDelegates_; - /** - * webSockets_ will hold the WebSocket instance(s) owned by - * packagerConnection_ while also allowing us to access them during - * the test. We can send messages *to* packagerConnection_ by - * calling webSockets_[i]->getDelegate().didReceiveMessage(...). Messages - * *from* packagerConnection_ will be found as calls to - * webSockets_[i]->send, which is a mock method installed by gmock. - * These are strict mocks, so method calls will fail if they are not - * expected with a corresponding call to EXPECT_CALL(...) - for example - * if unexpected WebSocket messages are sent. - */ - UniquePtrFactory> webSockets_; - /** - * localConnections_ will hold the LocalConnection instances owned - * by packagerConnection_ while also allowing us to access them - * during the test. - * These are strict mocks, so method calls will fail if they are not - * expected with a corresponding call to EXPECT_CALL(...). - */ - UniquePtrFactory> localConnections_; - std::optional packagerConnection_; -}; - -using InspectorPackagerConnectionTest = - InspectorPackagerConnectionTestBase; - -/** - * Fixture class for tests that run on a ManualExecutor. Work scheduled - * on the executor is *not* run automatically; it must be manually advanced - * in the body of the test. - */ -class InspectorPackagerConnectionTestAsync - : public InspectorPackagerConnectionTestBase { - public: - virtual void TearDown() override { - // Assert there are no pending tasks on the ManualExecutor. - auto tasksCleared = asyncExecutor_.clear(); - EXPECT_EQ(tasksCleared, 0) - << "There were still pending tasks on asyncExecutor_ at the end of the test. Use advance() or run() as needed."; - InspectorPackagerConnectionTestBase::TearDown(); - } -}; - -} // namespace - TEST_F(InspectorPackagerConnectionTest, TestConnectThenDestroy) { packagerConnection_->connect(); diff --git a/packages/react-native/ReactCommon/jsinspector-modern/tests/InspectorPackagerConnectionTest.h b/packages/react-native/ReactCommon/jsinspector-modern/tests/InspectorPackagerConnectionTest.h new file mode 100644 index 00000000000000..53365f30168f1d --- /dev/null +++ b/packages/react-native/ReactCommon/jsinspector-modern/tests/InspectorPackagerConnectionTest.h @@ -0,0 +1,140 @@ +/* + * 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 +#include +#include +#include + +#include +#include + +#include + +#include "InspectorMocks.h" +#include "UniquePtrFactory.h" + +namespace facebook::react::jsinspector_modern { + +/** + * Base test fixture for InspectorPackagerConnection tests. + * + * The Executor template parameter controls how asynchronous work is executed: + * - folly::QueuedImmediateExecutor: Work is executed immediately inline. + * - folly::ManualExecutor: Work must be manually advanced in the test. + */ +template +class InspectorPackagerConnectionTestBase : public testing::Test { + protected: + InspectorPackagerConnectionTestBase() + : packagerConnection_( + InspectorPackagerConnection{ + "ws://mock-host:12345", + "my-device", + "my-app", + packagerConnectionDelegates_.make_unique(asyncExecutor_)}) + { + auto makeSocket = webSockets_.lazily_make_unique>(); + ON_CALL(*packagerConnectionDelegate(), connectWebSocket(testing::_, testing::_)) + .WillByDefault([makeSocket](auto &&...args) { + auto socket = makeSocket(std::forward(args)...); + socket->getDelegate().didOpen(); + return std::move(socket); + }); + EXPECT_CALL(*packagerConnectionDelegate(), connectWebSocket(testing::_, testing::_)).Times(testing::AnyNumber()); + } + + void TearDown() override + { + // Forcibly clean up all pages currently registered with the inspector in + // order to isolate state between tests. NOTE: Using TearDown instead of a + // destructor so that we can use FAIL() etc. + std::vector pagesToRemove; + auto pages = getInspectorInstance().getPages(); + int liveConnectionCount = 0; + for (size_t i = 0; i != localConnections_.objectsVended(); ++i) { + if (localConnections_[i] != nullptr) { + liveConnectionCount++; + // localConnections_[i] is a strict mock and will complain when we + // removePage if the call is unexpected. + EXPECT_CALL(*localConnections_[i], disconnect()); + } + } + for (auto &page : pages) { + getInspectorInstance().removePage(page.id); + } + if (!pages.empty() && (liveConnectionCount != 0)) { + if (!::testing::Test::HasFailure()) { + FAIL() << "Test case ended with " << liveConnectionCount << " open connection(s) and " << pages.size() + << " registered page(s). You must manually call removePage for each page."; + } + } + ::testing::Test::TearDown(); + } + + MockInspectorPackagerConnectionDelegate *packagerConnectionDelegate() + { + // We only create one PackagerConnectionDelegate per test. + EXPECT_EQ(packagerConnectionDelegates_.objectsVended(), 1); + return packagerConnectionDelegates_[0]; + } + + Executor asyncExecutor_; + + UniquePtrFactory packagerConnectionDelegates_; + /** + * webSockets_ will hold the WebSocket instance(s) owned by + * packagerConnection_ while also allowing us to access them during + * the test. We can send messages *to* packagerConnection_ by + * calling webSockets_[i]->getDelegate().didReceiveMessage(...). Messages + * *from* packagerConnection_ will be found as calls to + * webSockets_[i]->send, which is a mock method installed by gmock. + * These are strict mocks, so method calls will fail if they are not + * expected with a corresponding call to EXPECT_CALL(...) - for example + * if unexpected WebSocket messages are sent. + */ + UniquePtrFactory> webSockets_; + /** + * localConnections_ will hold the LocalConnection instances owned + * by packagerConnection_ while also allowing us to access them + * during the test. + * These are strict mocks, so method calls will fail if they are not + * expected with a corresponding call to EXPECT_CALL(...). + */ + UniquePtrFactory> localConnections_; + std::optional packagerConnection_; +}; + +/** + * Standard test fixture that uses QueuedImmediateExecutor. + * Work scheduled on the executor is run immediately inline. + */ +using InspectorPackagerConnectionTest = InspectorPackagerConnectionTestBase; + +/** + * Test fixture that uses ManualExecutor. + * Work scheduled on the executor is *not* run automatically; it must be + * manually advanced in the body of the test. + */ +class InspectorPackagerConnectionTestAsync : public InspectorPackagerConnectionTestBase { + public: + void TearDown() override + { + // Assert there are no pending tasks on the ManualExecutor. + auto tasksCleared = asyncExecutor_.clear(); + EXPECT_EQ(tasksCleared, 0) + << "There were still pending tasks on asyncExecutor_ at the end of the test. Use advance() or run() as needed."; + InspectorPackagerConnectionTestBase::TearDown(); + } +}; + +} // namespace facebook::react::jsinspector_modern