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
This commit is contained in:
Moti Zilberman
2024-01-19 12:26:15 -08:00
committed by Facebook GitHub Bot
parent 965f2eb1fb
commit 7886abd243
13 changed files with 503 additions and 0 deletions
@@ -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.
@@ -129,4 +129,11 @@ extern IInspector& getInspectorInstance();
/// should only be used in tests.
extern std::unique_ptr<IInspector> 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<void(std::string_view messageJson)>;
} // namespace facebook::react::jsinspector_modern
@@ -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 <cassert>
namespace facebook::react::jsinspector_modern {
CallbackLocalConnection::CallbackLocalConnection(
std::function<void(std::string)> 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<IRemoteConnection> 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
@@ -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<void(std::string)> handler);
void sendMessage(std::string message) override;
void disconnect() override;
private:
std::function<void(std::string)> handler_;
};
/**
* Wraps an IRemoteConnection in a simpler interface that calls `onDisconnect`
* implicitly upon destruction.
*/
class RAIIRemoteConnection {
public:
explicit RAIIRemoteConnection(std::unique_ptr<IRemoteConnection> remote);
void onMessage(std::string message);
~RAIIRemoteConnection();
private:
std::unique_ptr<IRemoteConnection> remote_;
};
} // namespace facebook::react::jsinspector_modern
@@ -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 <folly/dynamic.h>
#include <folly/json.h>
#include <jsinspector-modern/PageAgent.h>
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
@@ -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 <jsinspector-modern/InspectorInterfaces.h>
#include <jsinspector-modern/Parsing.h>
#include <functional>
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
@@ -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 <folly/dynamic.h>
#include <folly/json.h>
#include <memory>
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<IRemoteConnection> remote)
: remote_(std::make_shared<RAIIRemoteConnection>(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<RAIIRemoteConnection> remote_;
FrontendChannel frontendChannel_;
PageAgent pageAgent_;
};
} // namespace
std::unique_ptr<ILocalConnection> PageTarget::connect(
std::unique_ptr<IRemoteConnection> connectionToFrontend) {
return std::make_unique<CallbackLocalConnection>(
PageTargetSession(std::move(connectionToFrontend)));
}
} // namespace facebook::react::jsinspector_modern
@@ -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 <jsinspector-modern/InspectorInterfaces.h>
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<ILocalConnection> connect(
std::unique_ptr<IRemoteConnection> connectionToFrontend);
};
} // namespace facebook::react::jsinspector_modern
@@ -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 <folly/dynamic.h>
#include <folly/json.h>
#include <jsinspector-modern/Parsing.h>
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
@@ -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 <folly/dynamic.h>
#include <folly/json.h>
#include <string>
#include <string_view>
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
@@ -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 <jsinspector-modern/PageTarget.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(
@@ -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 <folly/dynamic.h>
#include <folly/json.h>
#include <gmock/gmock.h>
#include <gtest/gtest.h>
#include <jsinspector-modern/InspectorInterfaces.h>
#include <jsinspector-modern/PageTarget.h>
#include <memory>
#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<ILocalConnection> toPage_;
MockRemoteConnection& fromPage() {
return *remoteConnections_[0];
}
private:
PageTarget page_;
UniquePtrFactory<StrictMock<MockRemoteConnection>> 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