From 7886abd243b3edade566bb6ec5cf55fa2c03152a Mon Sep 17 00:00:00 2001 From: Moti Zilberman Date: Fri, 19 Jan 2024 12:26:15 -0800 Subject: [PATCH] Add stub page target implementation to jsinspector-modern (#42397) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/42397 Changelog: [Internal] Adds a stub `PageTarget` class to serve as the entry point to the modern CDP backend in React Native. The primary method exposed by `PageTarget` is `connect()` which is designed to fit directly as a connection callback passed to `InspectorPackagerConnection::addPage()`. This constructs a `PageTargetSession` containing a `PageAgent` where the actual CDP message handling/routing will occur. For now, `PageAgent` implements no CDP methods, and always responds with a "not implemented" error. Basic unit tests are included, though we might want to migrate to a more integration-style test suite (with fewer mocks and real bindings to RN) once we've implemented more of the protocol. ## What is a Page In Chrome's implementation of CDP, a Page represents a single browser tab. A Chrome DevTools session connects to one Page at a time (though it can potentially inspect multiple JavaScript contexts owned by that Page, such as those found in frames and workers). In our system, a Page will correspond 1:1 to React Native's concept of a *Host* (implemented as `RCTHost`, `RCTBridge`, `ReactHostImpl` or `ReactInstanceManager`, depending on the platform). In all cases, the Host is the object that has a stable identity across reloads, and manages the lifetime of an *Instance* where the JSVM and other application state lives. There can be multiple Hosts in a React Native process, though this is somewhat unusual; those would be treated as independent "tabs" from the perspective of the debugger. NOTE: The concepts of Target, Session and Agent are new (to this codebase) and are *broadly* inspired by the [corresponding Chromium / V8 concepts](https://chromium.googlesource.com/chromium/src/+/master/third_party/blink/public/devtools_protocol/#Agents_Targets-and-Sessions), though some details differ. ## Next steps Each core platform implementation in React Native (iOS Bridgeless, iOS Bridge, Android Bridgeless, Android Bridge), as well as out-of-tree platforms that want to support the new debugger, will need to create and register a `PageTarget` instance. We'll do this piecemeal in subsequent diffs. We'll also gradually add APIs and logic to `PageTarget` / `PageAgent` to allow us to implement some "interesting" CDP methods - some of them directly (e.g. handling reload commands) and others by dispatching to nested agents (e.g. a JS debugging agent powered by Hermes). Reviewed By: huntie Differential Revision: D50936932 fbshipit-source-id: ebe5856d7badb361d4971dd9aabeb9982f8aed1b --- .../jsinspector-modern/CONCEPTS.md | 15 +++ .../jsinspector-modern/InspectorInterfaces.h | 7 ++ .../jsinspector-modern/InspectorUtilities.cpp | 39 +++++++ .../jsinspector-modern/InspectorUtilities.h | 52 +++++++++ .../jsinspector-modern/PageAgent.cpp | 26 +++++ .../jsinspector-modern/PageAgent.h | 41 +++++++ .../jsinspector-modern/PageTarget.cpp | 85 ++++++++++++++ .../jsinspector-modern/PageTarget.h | 32 ++++++ .../jsinspector-modern/Parsing.cpp | 22 ++++ .../ReactCommon/jsinspector-modern/Parsing.h | 60 ++++++++++ .../ReactCommon/jsinspector-modern/ReactCdp.h | 10 ++ .../jsinspector-modern/tests/InspectorMocks.h | 9 ++ .../tests/PageTargetTest.cpp | 105 ++++++++++++++++++ 13 files changed, 503 insertions(+) create mode 100644 packages/react-native/ReactCommon/jsinspector-modern/CONCEPTS.md create mode 100644 packages/react-native/ReactCommon/jsinspector-modern/InspectorUtilities.cpp create mode 100644 packages/react-native/ReactCommon/jsinspector-modern/InspectorUtilities.h create mode 100644 packages/react-native/ReactCommon/jsinspector-modern/PageAgent.cpp create mode 100644 packages/react-native/ReactCommon/jsinspector-modern/PageAgent.h create mode 100644 packages/react-native/ReactCommon/jsinspector-modern/PageTarget.cpp create mode 100644 packages/react-native/ReactCommon/jsinspector-modern/PageTarget.h create mode 100644 packages/react-native/ReactCommon/jsinspector-modern/Parsing.cpp create mode 100644 packages/react-native/ReactCommon/jsinspector-modern/Parsing.h create mode 100644 packages/react-native/ReactCommon/jsinspector-modern/ReactCdp.h create mode 100644 packages/react-native/ReactCommon/jsinspector-modern/tests/PageTargetTest.cpp diff --git a/packages/react-native/ReactCommon/jsinspector-modern/CONCEPTS.md b/packages/react-native/ReactCommon/jsinspector-modern/CONCEPTS.md new file mode 100644 index 00000000000..618d13080df --- /dev/null +++ b/packages/react-native/ReactCommon/jsinspector-modern/CONCEPTS.md @@ -0,0 +1,15 @@ +# jsinspector-modern concepts + +## CDP object model + +### Target + +A debuggable entity that a debugger frontend can connect to. + +### Session + +A single connection between a debugger frontend and a target. There can be multiple active sessions connected to the same target. + +### Agent + +A handler for a subset of CDP messages for a specific target as part of a specific session. diff --git a/packages/react-native/ReactCommon/jsinspector-modern/InspectorInterfaces.h b/packages/react-native/ReactCommon/jsinspector-modern/InspectorInterfaces.h index ff07be1b25f..e4f1c221033 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/InspectorInterfaces.h +++ b/packages/react-native/ReactCommon/jsinspector-modern/InspectorInterfaces.h @@ -129,4 +129,11 @@ extern IInspector& getInspectorInstance(); /// should only be used in tests. extern std::unique_ptr makeTestInspectorInstance(); +/** + * A callback that can be used to send debugger messages (method responses and + * events) to the frontend. The message must be a JSON-encoded string. + * The callback may be called from any thread. + */ +using FrontendChannel = std::function; + } // namespace facebook::react::jsinspector_modern diff --git a/packages/react-native/ReactCommon/jsinspector-modern/InspectorUtilities.cpp b/packages/react-native/ReactCommon/jsinspector-modern/InspectorUtilities.cpp new file mode 100644 index 00000000000..0c4c99ca6ce --- /dev/null +++ b/packages/react-native/ReactCommon/jsinspector-modern/InspectorUtilities.cpp @@ -0,0 +1,39 @@ +/* + * 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 "InspectorUtilities.h" + +#include + +namespace facebook::react::jsinspector_modern { + +CallbackLocalConnection::CallbackLocalConnection( + std::function handler) + : handler_(std::move(handler)) {} + +void CallbackLocalConnection::sendMessage(std::string message) { + assert(handler_ && "Handler has been disconnected"); + handler_(std::move(message)); +} + +void CallbackLocalConnection::disconnect() { + handler_ = nullptr; +} + +RAIIRemoteConnection::RAIIRemoteConnection( + std::unique_ptr remote) + : remote_(std::move(remote)) {} + +void RAIIRemoteConnection::onMessage(std::string message) { + remote_->onMessage(std::move(message)); +} + +RAIIRemoteConnection::~RAIIRemoteConnection() { + remote_->onDisconnect(); +} + +} // namespace facebook::react::jsinspector_modern diff --git a/packages/react-native/ReactCommon/jsinspector-modern/InspectorUtilities.h b/packages/react-native/ReactCommon/jsinspector-modern/InspectorUtilities.h new file mode 100644 index 00000000000..41b6f7c84e3 --- /dev/null +++ b/packages/react-native/ReactCommon/jsinspector-modern/InspectorUtilities.h @@ -0,0 +1,52 @@ +/* + * 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 "InspectorInterfaces.h" + +// Utilities that are useful when integrating with InspectorInterfaces.h but +// do not need to be exported. + +namespace facebook::react::jsinspector_modern { + +/** + * Wraps a callback function in ILocalConnection. + */ +class CallbackLocalConnection : public ILocalConnection { + public: + /** + * Creates a new Connection that uses the given callback to send messages to + * the backend. + */ + explicit CallbackLocalConnection(std::function handler); + + void sendMessage(std::string message) override; + + void disconnect() override; + + private: + std::function handler_; +}; + +/** + * Wraps an IRemoteConnection in a simpler interface that calls `onDisconnect` + * implicitly upon destruction. + */ +class RAIIRemoteConnection { + public: + explicit RAIIRemoteConnection(std::unique_ptr remote); + + void onMessage(std::string message); + + ~RAIIRemoteConnection(); + + private: + std::unique_ptr remote_; +}; + +} // namespace facebook::react::jsinspector_modern diff --git a/packages/react-native/ReactCommon/jsinspector-modern/PageAgent.cpp b/packages/react-native/ReactCommon/jsinspector-modern/PageAgent.cpp new file mode 100644 index 00000000000..d2302ad99cb --- /dev/null +++ b/packages/react-native/ReactCommon/jsinspector-modern/PageAgent.cpp @@ -0,0 +1,26 @@ +/* + * 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 +#include +#include + +namespace facebook::react::jsinspector_modern { + +PageAgent::PageAgent(FrontendChannel frontendChannel) + : frontendChannel_(frontendChannel) {} + +void PageAgent::handleRequest(const cdp::PreparsedRequest& req) { + folly::dynamic res = folly::dynamic::object("id", req.id)( + "error", + folly::dynamic::object("code", -32601)( + "message", req.method + " not implemented yet")); + std::string json = folly::toJson(res); + frontendChannel_(json); +} + +} // namespace facebook::react::jsinspector_modern diff --git a/packages/react-native/ReactCommon/jsinspector-modern/PageAgent.h b/packages/react-native/ReactCommon/jsinspector-modern/PageAgent.h new file mode 100644 index 00000000000..1bdc5fac4b0 --- /dev/null +++ b/packages/react-native/ReactCommon/jsinspector-modern/PageAgent.h @@ -0,0 +1,41 @@ +/* + * 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 + +namespace facebook::react::jsinspector_modern { + +/** + * An Agent that handles requests from the Chrome DevTools Protocol for the + * given page. + * The constructor, destructor and all public methods must be called on the + * same thread. + */ +class PageAgent { + public: + /** + * \param frontendChannel A channel used to send responses and events to the + * frontend. + */ + explicit PageAgent(FrontendChannel frontendChannel); + + /** + * Handle a CDP request. The response will be sent over the provided + * \c FrontendChannel synchronously or asynchronously. + * \param req The parsed request. + */ + void handleRequest(const cdp::PreparsedRequest& req); + + private: + FrontendChannel frontendChannel_; +}; + +} // namespace facebook::react::jsinspector_modern diff --git a/packages/react-native/ReactCommon/jsinspector-modern/PageTarget.cpp b/packages/react-native/ReactCommon/jsinspector-modern/PageTarget.cpp new file mode 100644 index 00000000000..fcb5d64427c --- /dev/null +++ b/packages/react-native/ReactCommon/jsinspector-modern/PageTarget.cpp @@ -0,0 +1,85 @@ +/* + * 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 "PageTarget.h" +#include "InspectorUtilities.h" +#include "PageAgent.h" +#include "Parsing.h" + +#include +#include + +#include + +namespace facebook::react::jsinspector_modern { + +namespace { + +/** + * A Session connected to a PageTarget, passing CDP messages to and from a + * PageAgent which it owns. + */ +class PageTargetSession { + public: + explicit PageTargetSession(std::unique_ptr remote) + : remote_(std::make_shared(std::move(remote))), + frontendChannel_( + [remoteWeak = std::weak_ptr(remote_)](std::string_view message) { + if (auto remote = remoteWeak.lock()) { + remote->onMessage(std::string(message)); + } + }), + pageAgent_(frontendChannel_) {} + /** + * Called by CallbackLocalConnection to send a message to this Session's + * Agent. + */ + void operator()(std::string message) { + cdp::PreparsedRequest request; + // Messages may be invalid JSON, or have unexpected types. + try { + request = cdp::preparse(message); + } catch (const cdp::ParseError& e) { + frontendChannel_(folly::toJson(folly::dynamic::object("id", nullptr)( + "error", + folly::dynamic::object("code", -32700)("message", e.what())))); + return; + } catch (const cdp::TypeError& e) { + frontendChannel_(folly::toJson(folly::dynamic::object("id", nullptr)( + "error", + folly::dynamic::object("code", -32600)("message", e.what())))); + return; + } + + // Catch exceptions that may arise from accessing dynamic params during + // request handling. + try { + pageAgent_.handleRequest(request); + } catch (const cdp::TypeError& e) { + frontendChannel_(folly::toJson(folly::dynamic::object("id", request.id)( + "error", + folly::dynamic::object("code", -32600)("message", e.what())))); + return; + } + } + + private: + // Owned by this instance, but shared (weakly) with the frontend channel + std::shared_ptr remote_; + FrontendChannel frontendChannel_; + PageAgent pageAgent_; +}; + +} // namespace + +std::unique_ptr PageTarget::connect( + std::unique_ptr connectionToFrontend) { + return std::make_unique( + PageTargetSession(std::move(connectionToFrontend))); +} + +} // namespace facebook::react::jsinspector_modern diff --git a/packages/react-native/ReactCommon/jsinspector-modern/PageTarget.h b/packages/react-native/ReactCommon/jsinspector-modern/PageTarget.h new file mode 100644 index 00000000000..91ee04f92d9 --- /dev/null +++ b/packages/react-native/ReactCommon/jsinspector-modern/PageTarget.h @@ -0,0 +1,32 @@ +/* + * 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 + +namespace facebook::react::jsinspector_modern { + +/** + * The top-level Target in a React Native app. This is equivalent to the + * "Host" in React Native's architecture - the entity that manages the + * lifecycle of a React Instance. + */ +class PageTarget { + public: + /** + * Creates a new Session connected to this PageTarget, wrapped in an + * interface which is compatible with \c IInspector::addPage. + * The caller is responsible for destroying the connection before PageTarget + * is destroyed, on the same thread where PageTarget's constructor and + * destructor execute. + */ + std::unique_ptr connect( + std::unique_ptr connectionToFrontend); +}; + +} // namespace facebook::react::jsinspector_modern diff --git a/packages/react-native/ReactCommon/jsinspector-modern/Parsing.cpp b/packages/react-native/ReactCommon/jsinspector-modern/Parsing.cpp new file mode 100644 index 00000000000..ca52a3fcfe9 --- /dev/null +++ b/packages/react-native/ReactCommon/jsinspector-modern/Parsing.cpp @@ -0,0 +1,22 @@ +/* + * 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 +#include +#include + +namespace facebook::react::jsinspector_modern::cdp { + +PreparsedRequest preparse(std::string_view message) { + folly::dynamic parsed = folly::parseJson(message); + return PreparsedRequest{ + .id = parsed["id"].getInt(), + .method = parsed["method"].getString(), + .params = parsed.count("params") ? parsed["params"] : nullptr}; +} + +} // namespace facebook::react::jsinspector_modern::cdp diff --git a/packages/react-native/ReactCommon/jsinspector-modern/Parsing.h b/packages/react-native/ReactCommon/jsinspector-modern/Parsing.h new file mode 100644 index 00000000000..94d6401b6a1 --- /dev/null +++ b/packages/react-native/ReactCommon/jsinspector-modern/Parsing.h @@ -0,0 +1,60 @@ +/* + * 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 + +namespace facebook::react::jsinspector_modern { + +namespace cdp { +using RequestId = long long; + +/** + * An incoming CDP request that has been parsed into a more usable form. + */ +struct PreparsedRequest { + public: + /** + * The ID of the request. + */ + RequestId id; + + /** + * The name of the method being invoked. + */ + std::string method; + + /** + * The parameters passed to the method, if any. + */ + folly::dynamic params; +}; + +/** + * Parse a JSON-encoded CDP request into its constituent parts. + * \throws ParseError If the input cannot be parsed. + * \throws TypeError If the input does not conform to the expected format. + */ +PreparsedRequest preparse(std::string_view message); + +/** + * A type error that may be thrown while preparsing a request, or while + * accessing dynamic params on a request. + */ +using TypeError = folly::TypeError; + +/** + * A parse error that may be thrown while preparsing a request. + */ +using ParseError = folly::json::parse_error; +} // namespace cdp + +} // namespace facebook::react::jsinspector_modern diff --git a/packages/react-native/ReactCommon/jsinspector-modern/ReactCdp.h b/packages/react-native/ReactCommon/jsinspector-modern/ReactCdp.h new file mode 100644 index 00000000000..15b60e56548 --- /dev/null +++ b/packages/react-native/ReactCommon/jsinspector-modern/ReactCdp.h @@ -0,0 +1,10 @@ +/* + * 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 diff --git a/packages/react-native/ReactCommon/jsinspector-modern/tests/InspectorMocks.h b/packages/react-native/ReactCommon/jsinspector-modern/tests/InspectorMocks.h index 1cd7af99494..f2831e6d329 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/tests/InspectorMocks.h +++ b/packages/react-native/ReactCommon/jsinspector-modern/tests/InspectorMocks.h @@ -49,6 +49,15 @@ class MockWebSocket : public IWebSocket { MOCK_METHOD(void, send, (std::string_view message), (override)); }; +class MockRemoteConnection : public IRemoteConnection { + public: + MockRemoteConnection() = default; + + // IRemoteConnection methods + MOCK_METHOD(void, onMessage, (std::string message), (override)); + MOCK_METHOD(void, onDisconnect, (), (override)); +}; + class MockLocalConnection : public ILocalConnection { public: explicit MockLocalConnection( diff --git a/packages/react-native/ReactCommon/jsinspector-modern/tests/PageTargetTest.cpp b/packages/react-native/ReactCommon/jsinspector-modern/tests/PageTargetTest.cpp new file mode 100644 index 00000000000..ad994918c29 --- /dev/null +++ b/packages/react-native/ReactCommon/jsinspector-modern/tests/PageTargetTest.cpp @@ -0,0 +1,105 @@ +/* + * 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 +#include +#include +#include + +#include +#include + +#include + +#include "FollyDynamicMatchers.h" +#include "InspectorMocks.h" +#include "UniquePtrFactory.h" + +using namespace ::testing; + +namespace facebook::react::jsinspector_modern { + +namespace { + +/** + * Simplified test harness focused on sending messages to and from a PageTarget. + */ +class PageTargetProtocolTest : public Test { + public: + PageTargetProtocolTest() { + toPage_ = page_.connect(remoteConnections_.make_unique()); + + // In protocol tests, we'll always get an onDisconnect call when we tear + // down the test. Expect it in order to satisfy the strict mock. + EXPECT_CALL(*remoteConnections_[0], onDisconnect()); + } + + protected: + std::unique_ptr toPage_; + + MockRemoteConnection& fromPage() { + return *remoteConnections_[0]; + } + + private: + PageTarget page_; + UniquePtrFactory> remoteConnections_; +}; + +} // namespace + +TEST_F(PageTargetProtocolTest, UnrecognizedMethod) { + EXPECT_CALL( + fromPage(), + onMessage(JsonParsed(AllOf( + AtJsonPtr("/error/code", Eq(-32601)), AtJsonPtr("/id", Eq(1)))))) + .RetiresOnSaturation(); + toPage_->sendMessage(R"({ + "id": 1, + "method": "SomeUnrecognizedMethod", + "params": [1, 2] + })"); +} + +TEST_F(PageTargetProtocolTest, TypeErrorInMethodName) { + EXPECT_CALL( + fromPage(), + onMessage(JsonParsed(AllOf( + AtJsonPtr("/error/code", Eq(-32600)), + AtJsonPtr("/id", Eq(nullptr)))))) + .RetiresOnSaturation(); + toPage_->sendMessage(R"({ + "id": 1, + "method": 42, + "params": [1, 2] + })"); +} + +TEST_F(PageTargetProtocolTest, MissingId) { + EXPECT_CALL( + fromPage(), + onMessage(JsonParsed(AllOf( + AtJsonPtr("/error/code", Eq(-32600)), + AtJsonPtr("/id", Eq(nullptr)))))) + .RetiresOnSaturation(); + toPage_->sendMessage(R"({ + "method": "SomeUnrecognizedMethod", + "params": [1, 2] + })"); +} + +TEST_F(PageTargetProtocolTest, MalformedJson) { + EXPECT_CALL( + fromPage(), + onMessage(JsonParsed(AllOf( + AtJsonPtr("/error/code", Eq(-32700)), + AtJsonPtr("/id", Eq(nullptr)))))) + .RetiresOnSaturation(); + toPage_->sendMessage("{"); +} + +} // namespace facebook::react::jsinspector_modern