diff --git a/packages/react-native/ReactCommon/jsinspector-modern/React-jsinspector.podspec b/packages/react-native/ReactCommon/jsinspector-modern/React-jsinspector.podspec index 12c619ccd9e..aec8970f45b 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/React-jsinspector.podspec +++ b/packages/react-native/ReactCommon/jsinspector-modern/React-jsinspector.podspec @@ -52,4 +52,5 @@ Pod::Spec.new do |s| s.dependency "React-featureflags" s.dependency "DoubleConversion" s.dependency "React-runtimeexecutor", version + s.dependency "React-jsi" end diff --git a/packages/react-native/ReactCommon/jsinspector-modern/RuntimeAgent.cpp b/packages/react-native/ReactCommon/jsinspector-modern/RuntimeAgent.cpp index 5691eb4876f..7879d193959 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/RuntimeAgent.cpp +++ b/packages/react-native/ReactCommon/jsinspector-modern/RuntimeAgent.cpp @@ -11,24 +11,65 @@ namespace facebook::react::jsinspector_modern { RuntimeAgent::RuntimeAgent( FrontendChannel frontendChannel, - RuntimeTarget& target, + RuntimeTargetController& targetController, const ExecutionContextDescription& executionContextDescription, SessionState& sessionState, std::unique_ptr delegate) : frontendChannel_(std::move(frontendChannel)), - target_(target), + targetController_(targetController), sessionState_(sessionState), delegate_(std::move(delegate)), executionContextDescription_(executionContextDescription) { - (void)target_; - (void)sessionState_; + for (auto& name : sessionState_.subscribedBindingNames) { + targetController_.installBindingHandler(name); + } } bool RuntimeAgent::handleRequest(const cdp::PreparsedRequest& req) { + if (req.method == "Runtime.addBinding") { + std::string bindingName = req.params["name"].getString(); + // TODO: Respect @cdp Runtime.addBinding's executionContextId and + // executionContextName params. + sessionState_.subscribedBindingNames.emplace(bindingName); + + targetController_.installBindingHandler(bindingName); + + folly::dynamic res = folly::dynamic::object("id", req.id)( + "result", folly::dynamic::object()); + std::string json = folly::toJson(res); + frontendChannel_(json); + + return true; + } + if (req.method == "Runtime.removeBinding") { + sessionState_.subscribedBindingNames.erase(req.params["name"].getString()); + + folly::dynamic res = folly::dynamic::object("id", req.id)( + "result", folly::dynamic::object()); + std::string json = folly::toJson(res); + frontendChannel_(json); + + return true; + } if (delegate_) { return delegate_->handleRequest(req); } return false; } +void RuntimeAgent::notifyBindingCalled( + const std::string& bindingName, + const std::string& payload) { + if (!sessionState_.subscribedBindingNames.count(bindingName)) { + return; + } + + frontendChannel_( + folly::toJson(folly::dynamic::object("method", "Runtime.bindingCalled")( + "params", + folly::dynamic::object( + "executionContextId", executionContextDescription_.id)( + "name", bindingName)("payload", payload)))); +} + } // namespace facebook::react::jsinspector_modern diff --git a/packages/react-native/ReactCommon/jsinspector-modern/RuntimeAgent.h b/packages/react-native/ReactCommon/jsinspector-modern/RuntimeAgent.h index 1f8f076352f..4f676634d63 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/RuntimeAgent.h +++ b/packages/react-native/ReactCommon/jsinspector-modern/RuntimeAgent.h @@ -16,7 +16,7 @@ namespace facebook::react::jsinspector_modern { -class RuntimeTarget; +class RuntimeTargetController; /** * An Agent that handles requests from the Chrome DevTools Protocol @@ -30,9 +30,9 @@ class RuntimeAgent final { /** * \param frontendChannel A channel used to send responses and events to the * frontend. - * \param target The RuntimeTarget that this agent is attached to. The - * caller is responsible for ensuring that the RuntimeTarget outlives this - * object. + * \param targetController An interface to the RuntimeTarget that this agent + * is attached to. The caller is responsible for ensuring that the + * RuntimeTarget and controller outlive this object. * \param executionContextDescription A description of the execution context * represented by this runtime. This is used for disambiguating the * source/destination of CDP messages when there are multiple runtimes @@ -43,7 +43,7 @@ class RuntimeAgent final { */ RuntimeAgent( FrontendChannel frontendChannel, - RuntimeTarget& target, + RuntimeTargetController& targetController, const ExecutionContextDescription& executionContextDescription, SessionState& sessionState, std::unique_ptr delegate); @@ -65,9 +65,13 @@ class RuntimeAgent final { return executionContextDescription_; } + void notifyBindingCalled( + const std::string& bindingName, + const std::string& payload); + private: FrontendChannel frontendChannel_; - RuntimeTarget& target_; + RuntimeTargetController& targetController_; SessionState& sessionState_; const std::unique_ptr delegate_; const ExecutionContextDescription executionContextDescription_; diff --git a/packages/react-native/ReactCommon/jsinspector-modern/RuntimeTarget.cpp b/packages/react-native/ReactCommon/jsinspector-modern/RuntimeTarget.cpp index 4feb014ea81..747ae1fc099 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/RuntimeTarget.cpp +++ b/packages/react-native/ReactCommon/jsinspector-modern/RuntimeTarget.cpp @@ -7,6 +7,8 @@ #include +using namespace facebook::jsi; + namespace facebook::react::jsinspector_modern { std::shared_ptr RuntimeTarget::create( @@ -33,7 +35,7 @@ std::shared_ptr RuntimeTarget::createAgent( SessionState& sessionState) { auto runtimeAgent = std::make_shared( channel, - *this, + controller_, executionContextDescription_, sessionState, delegate_.createAgentDelegate( @@ -50,4 +52,51 @@ RuntimeTarget::~RuntimeTarget() { "RuntimeAgent objects must be destroyed before their RuntimeTarget. Did you call InstanceTarget::unregisterRuntime()?"); } +void RuntimeTarget::installBindingHandler(const std::string& bindingName) { + jsExecutor_([bindingName, + selfExecutor = executorFromThis()](jsi::Runtime& runtime) { + auto globalObj = runtime.global(); + try { + auto bindingNamePropID = jsi::PropNameID::forUtf8(runtime, bindingName); + globalObj.setProperty( + runtime, + bindingNamePropID, + jsi::Function::createFromHostFunction( + runtime, + bindingNamePropID, + 1, + [bindingName, selfExecutor]( + jsi::Runtime& rt, + const jsi::Value&, + const jsi::Value* args, + size_t count) -> jsi::Value { + if (count != 1 || !args[0].isString()) { + throw jsi::JSError( + rt, "Invalid arguments: should be exactly one string."); + } + std::string payload = args[0].getString(rt).utf8(rt); + + selfExecutor([bindingName, payload](auto& self) { + self.agents_.forEach([bindingName, payload](auto& agent) { + agent.notifyBindingCalled(bindingName, payload); + }); + }); + + return jsi::Value::undefined(); + })); + } catch (jsi::JSError&) { + // Per Chrome's implementation, @cdp Runtime.createBinding swallows + // JavaScript exceptions that occur while setting up the binding. + } + }); +} + +RuntimeTargetController::RuntimeTargetController(RuntimeTarget& target) + : target_(target) {} + +void RuntimeTargetController::installBindingHandler( + const std::string& bindingName) { + target_.installBindingHandler(bindingName); +} + } // namespace facebook::react::jsinspector_modern diff --git a/packages/react-native/ReactCommon/jsinspector-modern/RuntimeTarget.h b/packages/react-native/ReactCommon/jsinspector-modern/RuntimeTarget.h index fda93d69c75..2ccab82d422 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/RuntimeTarget.h +++ b/packages/react-native/ReactCommon/jsinspector-modern/RuntimeTarget.h @@ -34,6 +34,7 @@ namespace facebook::react::jsinspector_modern { class RuntimeAgent; class RuntimeAgentDelegate; +class RuntimeTarget; /** * Receives events from a RuntimeTarget. This is a shared interface that @@ -49,6 +50,24 @@ class RuntimeTargetDelegate { const ExecutionContextDescription& executionContextDescription) = 0; }; +/** + * The limited interface that RuntimeTarget exposes to its connected agents. + */ +class RuntimeTargetController { + public: + explicit RuntimeTargetController(RuntimeTarget& target); + + /** + * Adds a function with the given name on the runtime's global object, that + * when called will send a Runtime.bindingCalled event through all connected + * sessions that have registered to receive binding events for that name. + */ + void installBindingHandler(const std::string& bindingName); + + private: + RuntimeTarget& target_; +}; + /** * A Target corresponding to a JavaScript runtime. */ @@ -123,6 +142,19 @@ class JSINSPECTOR_EXPORT RuntimeTarget RuntimeTargetDelegate& delegate_; RuntimeExecutor jsExecutor_; WeakList agents_; + RuntimeTargetController controller_{*this}; + + /** + * Adds a function with the given name on the runtime's global object, that + * when called will send a Runtime.bindingCalled event through all connected + * sessions that have registered to receive binding events for that name. + */ + void installBindingHandler(const std::string& bindingName); + + // Necessary to allow RuntimeAgent to access RuntimeTarget's internals in a + // controlled way (i.e. only RuntimeTargetController gets friend access, while + // RuntimeAgent itself doesn't). + friend class RuntimeTargetController; }; } // namespace facebook::react::jsinspector_modern diff --git a/packages/react-native/ReactCommon/jsinspector-modern/SessionState.h b/packages/react-native/ReactCommon/jsinspector-modern/SessionState.h index b4fe7f8df21..9c9aa939ba0 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/SessionState.h +++ b/packages/react-native/ReactCommon/jsinspector-modern/SessionState.h @@ -7,7 +7,9 @@ #pragma once +#include #include +#include namespace facebook::react::jsinspector_modern { @@ -17,6 +19,15 @@ struct SessionState { bool isLogDomainEnabled{false}; bool isRuntimeDomainEnabled{false}; + /** + * The set of bindings registered during this session using @cdp + * Runtime.addBinding. Even though bindings get added to the global scope as + * functions that can outlive a session, they are treated as session state, + * matching Chrome's behaviour (a binding not added by the current session + * will not emit events on it). + */ + std::unordered_set subscribedBindingNames; + // Here, we will eventually allow RuntimeAgents to store their own arbitrary // state (e.g. some sort of K/V storage of folly::dynamic?) diff --git a/packages/react-native/ReactCommon/jsinspector-modern/tests/JsiIntegrationTest.cpp b/packages/react-native/ReactCommon/jsinspector-modern/tests/JsiIntegrationTest.cpp index ebe9a6b3910..7ea5c9b708f 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/tests/JsiIntegrationTest.cpp +++ b/packages/react-native/ReactCommon/jsinspector-modern/tests/JsiIntegrationTest.cpp @@ -5,7 +5,9 @@ * LICENSE file in the root directory of this source tree. */ +#include #include +#include #include #include @@ -109,6 +111,22 @@ class JsiIntegrationPortableTest : public Test, private PageTargetDelegate { std::make_shared(std::string(code)), ""); } + /** + * Expect a message matching the provided gmock \c matcher and return a holder + * that will eventually contain the parsed JSON payload. + */ + template + std::shared_ptr> expectMessageFromPage( + Matcher&& matcher) { + std::shared_ptr result = + std::make_shared>(std::nullopt); + EXPECT_CALL(fromPage(), onMessage(matcher)) + .WillOnce( + ([result](auto message) { *result = folly::parseJson(message); })) + .RetiresOnSaturation(); + return result; + } + std::shared_ptr page_ = PageTarget::create(*this, inspectorExecutor_); InstanceTarget* instance_{}; @@ -162,11 +180,8 @@ TYPED_TEST(JsiIntegrationPortableTest, ConnectWithoutCrashing) { TYPED_TEST(JsiIntegrationPortableTest, ErrorOnUnknownMethod) { this->connect(); - EXPECT_CALL( - this->fromPage(), - onMessage(JsonParsed( - AllOf(AtJsonPtr("/id", 1), AtJsonPtr("/error/code", -32601))))) - .RetiresOnSaturation(); + this->expectMessageFromPage( + JsonParsed(AllOf(AtJsonPtr("/id", 1), AtJsonPtr("/error/code", -32601)))); this->toPage_->sendMessage(R"({ "id": 1, @@ -179,65 +194,58 @@ TYPED_TEST(JsiIntegrationPortableTest, ExecutionContextNotifications) { InSequence s; - EXPECT_CALL(this->fromPage(), onMessage(JsonEq(R"({ - "method": "Runtime.executionContextCreated", - "params": { - "context": { - "id": 1, - "origin": "", - "name": "main" - } - } - })"))) - .RetiresOnSaturation(); - EXPECT_CALL(this->fromPage(), onMessage(JsonEq(R"({ - "id": 1, - "result": {} - })"))); + this->expectMessageFromPage(JsonEq(R"({ + "method": "Runtime.executionContextCreated", + "params": { + "context": { + "id": 1, + "origin": "", + "name": "main" + } + } + })")); + this->expectMessageFromPage(JsonEq(R"({ + "id": 1, + "result": {} + })")); this->toPage_->sendMessage(R"({ "id": 1, "method": "Runtime.enable" })"); - EXPECT_CALL(this->fromPage(), onMessage(JsonEq(R"({ - "method": "Runtime.executionContextDestroyed", - "params": { - "executionContextId": 1 - } - })"))) - .RetiresOnSaturation(); - EXPECT_CALL(this->fromPage(), onMessage(JsonEq(R"({ - "method": "Runtime.executionContextsCleared" - })"))) - .RetiresOnSaturation(); + this->expectMessageFromPage(JsonEq(R"({ + "method": "Runtime.executionContextDestroyed", + "params": { + "executionContextId": 1 + } + })")); + this->expectMessageFromPage(JsonEq(R"({ + "method": "Runtime.executionContextsCleared" + })")); - EXPECT_CALL(this->fromPage(), onMessage(JsonEq(R"({ - "method": "Runtime.executionContextCreated", - "params": { - "context": { - "id": 2, - "origin": "", - "name": "main" - } - } - })"))) - .RetiresOnSaturation(); + this->expectMessageFromPage(JsonEq(R"({ + "method": "Runtime.executionContextCreated", + "params": { + "context": { + "id": 2, + "origin": "", + "name": "main" + } + } + })")); // Simulate a reload triggered by the app (not by the debugger). this->reload(); - EXPECT_CALL(this->fromPage(), onMessage(JsonEq(R"({ - "method": "Runtime.executionContextDestroyed", - "params": { - "executionContextId": 2 - } - })"))) - .RetiresOnSaturation(); - - EXPECT_CALL(this->fromPage(), onMessage(JsonEq(R"({ - "method": "Runtime.executionContextsCleared" - })"))) - .RetiresOnSaturation(); - EXPECT_CALL(this->fromPage(), onMessage(JsonEq(R"({ + this->expectMessageFromPage(JsonEq(R"({ + "method": "Runtime.executionContextDestroyed", + "params": { + "executionContextId": 2 + } + })")); + this->expectMessageFromPage(JsonEq(R"({ + "method": "Runtime.executionContextsCleared" + })")); + this->expectMessageFromPage(JsonEq(R"({ "method": "Runtime.executionContextCreated", "params": { "context": { @@ -246,33 +254,215 @@ TYPED_TEST(JsiIntegrationPortableTest, ExecutionContextNotifications) { "name": "main" } } - })"))) - .RetiresOnSaturation(); - EXPECT_CALL(this->fromPage(), onMessage(JsonEq(R"({ - "id": 2, - "result": {} - })"))) - .RetiresOnSaturation(); + })")); + this->expectMessageFromPage(JsonEq(R"({ + "id": 2, + "result": {} + })")); this->toPage_->sendMessage(R"({ "id": 2, "method": "Page.reload" })"); } +TYPED_TEST(JsiIntegrationPortableTest, AddBinding) { + this->connect(); + + InSequence s; + + auto executionContextInfo = this->expectMessageFromPage(JsonParsed( + AllOf(AtJsonPtr("/method", "Runtime.executionContextCreated")))); + this->expectMessageFromPage(JsonEq(R"({ + "id": 1, + "result": {} + })")); + this->toPage_->sendMessage(R"({ + "id": 1, + "method": "Runtime.enable" + })"); + auto executionContextId = + executionContextInfo->value()["params"]["context"]["id"]; + + this->expectMessageFromPage(JsonEq(R"({ + "id": 2, + "result": {} + })")); + this->toPage_->sendMessage(R"({ + "id": 2, + "method": "Runtime.addBinding", + "params": {"name": "foo"} + })"); + + this->expectMessageFromPage(JsonParsed(AllOf( + AtJsonPtr("/method", "Runtime.bindingCalled"), + AtJsonPtr("/params/name", "foo"), + AtJsonPtr("/params/payload", "bar"), + AtJsonPtr("/params/executionContextId", executionContextId)))); + this->eval("globalThis.foo('bar');"); +} + +TYPED_TEST(JsiIntegrationPortableTest, AddedBindingSurvivesReload) { + this->connect(); + + InSequence s; + + this->expectMessageFromPage(JsonEq(R"({ + "id": 1, + "result": {} + })")); + this->toPage_->sendMessage(R"({ + "id": 1, + "method": "Runtime.addBinding", + "params": {"name": "foo"} + })"); + + this->reload(); + + // Get the new context ID by sending Runtime.enable now. + auto executionContextInfo = this->expectMessageFromPage(JsonParsed( + AllOf(AtJsonPtr("/method", "Runtime.executionContextCreated")))); + this->expectMessageFromPage(JsonEq(R"({ + "id": 1, + "result": {} + })")); + this->toPage_->sendMessage(R"({ + "id": 1, + "method": "Runtime.enable" + })"); + auto executionContextId = + executionContextInfo->value()["params"]["context"]["id"]; + + this->expectMessageFromPage(JsonParsed(AllOf( + AtJsonPtr("/method", "Runtime.bindingCalled"), + AtJsonPtr("/params/name", "foo"), + AtJsonPtr("/params/payload", "bar"), + AtJsonPtr("/params/executionContextId", executionContextId)))); + this->eval("globalThis.foo('bar');"); +} + +TYPED_TEST(JsiIntegrationPortableTest, RemovedBindingRemainsInstalled) { + this->connect(); + + InSequence s; + + this->expectMessageFromPage(JsonEq(R"({ + "id": 1, + "result": {} + })")); + this->toPage_->sendMessage(R"({ + "id": 1, + "method": "Runtime.addBinding", + "params": {"name": "foo"} + })"); + + this->expectMessageFromPage(JsonEq(R"({ + "id": 2, + "result": {} + })")); + this->toPage_->sendMessage(R"({ + "id": 2, + "method": "Runtime.removeBinding", + "params": {"name": "foo"} + })"); + + this->eval("globalThis.foo('bar');"); +} + +TYPED_TEST(JsiIntegrationPortableTest, RemovedBindingDoesNotSurviveReload) { + this->connect(); + + InSequence s; + + this->expectMessageFromPage(JsonEq(R"({ + "id": 1, + "result": {} + })")); + this->toPage_->sendMessage(R"({ + "id": 1, + "method": "Runtime.addBinding", + "params": {"name": "foo"} + })"); + + this->expectMessageFromPage(JsonEq(R"({ + "id": 2, + "result": {} + })")); + this->toPage_->sendMessage(R"({ + "id": 2, + "method": "Runtime.removeBinding", + "params": {"name": "foo"} + })"); + + this->reload(); + + EXPECT_TRUE(this->eval("typeof globalThis.foo === 'undefined'").getBool()); +} + +TYPED_TEST(JsiIntegrationPortableTest, AddBindingClobbersExistingProperty) { + this->connect(); + + InSequence s; + + this->eval(R"( + globalThis.foo = 'clobbered value'; + )"); + + this->expectMessageFromPage(JsonEq(R"({ + "id": 1, + "result": {} + })")); + this->toPage_->sendMessage(R"({ + "id": 1, + "method": "Runtime.addBinding", + "params": {"name": "foo"} + })"); + + this->expectMessageFromPage(JsonParsed(AllOf( + AtJsonPtr("/method", "Runtime.bindingCalled"), + AtJsonPtr("/params/name", "foo"), + AtJsonPtr("/params/payload", "bar")))); + this->eval("globalThis.foo('bar');"); +} + +TYPED_TEST(JsiIntegrationPortableTest, ExceptionDuringAddBindingIsIgnored) { + this->connect(); + + InSequence s; + + this->eval(R"( + Object.defineProperty(globalThis, 'foo', { + get: function () { return 42; }, + set: function () { throw new Error('nope'); }, + }); + )"); + + this->expectMessageFromPage(JsonEq(R"({ + "id": 1, + "result": {} + })")); + this->toPage_->sendMessage(R"({ + "id": 1, + "method": "Runtime.addBinding", + "params": {"name": "foo"} + })"); + + EXPECT_TRUE(this->eval("globalThis.foo === 42").getBool()); +} + //////////////////////////////////////////////////////////////////////////////// TEST_F(JsiIntegrationHermesTest, EvaluateExpression) { connect(); - EXPECT_CALL(fromPage(), onMessage(JsonEq(R"({ - "id": 1, - "result": { - "result": { - "type": "number", - "value": 42 - } - } - })"))); + expectMessageFromPage(JsonEq(R"({ + "id": 1, + "result": { + "result": { + "type": "number", + "value": 42 + } + } + })")); toPage_->sendMessage(R"({ "id": 1, "method": "Runtime.evaluate",