From 305c2811cf990ac7d4beaf918e88e27df3c45e26 Mon Sep 17 00:00:00 2001 From: Moti Zilberman Date: Thu, 14 Mar 2024 10:33:47 -0700 Subject: [PATCH] Implement native `console` object in RuntimeTarget (#43456) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/43456 Changelog: [Internal] Implements the [WHATWG `console` spec](https://console.spec.whatwg.org/) directly in `RuntimeTarget`, based on the Hermes-powered `addConsoleMessage` method first used in D54494298. Benefits: * This allows the console API to work independently of the polyfill shipped in RN, including very early during JS execution. * It also opens the door to better stack traces (once we start reporting those) and richer functionality in the `console` object itself. Reviewed By: robhogan Differential Revision: D54826073 fbshipit-source-id: d5b0bd004bf35c2fce91742ae84ea86225ec1c61 --- .../chrome/HermesRuntimeTargetDelegate.cpp | 3 + .../jsinspector-modern/RuntimeTarget.cpp | 69 +-- .../RuntimeTargetConsole.cpp | 447 ++++++++++++++++++ 3 files changed, 451 insertions(+), 68 deletions(-) create mode 100644 packages/react-native/ReactCommon/jsinspector-modern/RuntimeTargetConsole.cpp diff --git a/packages/react-native/ReactCommon/hermes/inspector-modern/chrome/HermesRuntimeTargetDelegate.cpp b/packages/react-native/ReactCommon/hermes/inspector-modern/chrome/HermesRuntimeTargetDelegate.cpp index c73867a0fdb..40963367b87 100644 --- a/packages/react-native/ReactCommon/hermes/inspector-modern/chrome/HermesRuntimeTargetDelegate.cpp +++ b/packages/react-native/ReactCommon/hermes/inspector-modern/chrome/HermesRuntimeTargetDelegate.cpp @@ -125,6 +125,9 @@ class HermesRuntimeTargetDelegate::Impl final : public RuntimeTargetDelegate { case ConsoleAPIType::kTimeEnd: type = HermesConsoleAPIType::kTimeEnd; break; + case ConsoleAPIType::kCount: + type = HermesConsoleAPIType::kCount; + break; default: throw std::logic_error{"Unknown console message type"}; } diff --git a/packages/react-native/ReactCommon/jsinspector-modern/RuntimeTarget.cpp b/packages/react-native/ReactCommon/jsinspector-modern/RuntimeTarget.cpp index 797d79de65f..ecee63a5d5f 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/RuntimeTarget.cpp +++ b/packages/react-native/ReactCommon/jsinspector-modern/RuntimeTarget.cpp @@ -34,77 +34,10 @@ RuntimeTarget::RuntimeTarget( jsExecutor_(jsExecutor) {} void RuntimeTarget::installGlobals() { + // NOTE: RuntimeTarget::installConsoleHandler is in RuntimeTargetConsole.cpp installConsoleHandler(); } -void RuntimeTarget::installConsoleHandler() { - jsExecutor_([selfWeak = weak_from_this(), - selfExecutor = executorFromThis()](jsi::Runtime& runtime) { - // TODO(moti): Switch from implementing __inspectorLog to directly - // installing a `console` object. - runtime.global().setProperty( - runtime, - "__inspectorLog", - jsi::Function::createFromHostFunction( - runtime, - jsi::PropNameID::forAscii(runtime, "__inspectorLog"), - 4, - [selfWeak, selfExecutor]( - jsi::Runtime& rt, - const jsi::Value& /*thisVal*/, - const jsi::Value* args, - size_t count) { - if (count < 4) { - throw jsi::JSError( - rt, - "__inspectorLog requires at least 4 arguments: logLevel, str, args, framesToSkip"); - } - std::chrono::time_point timestamp = - std::chrono::system_clock::now(); - std::string level = args[0].asString(rt).utf8(rt); - ConsoleAPIType type = ConsoleAPIType::kLog; - if (level == "debug") { - type = ConsoleAPIType::kDebug; - } else if (level == "log") { - type = ConsoleAPIType::kLog; - } else if (level == "warning") { - type = ConsoleAPIType::kWarning; - } else if (level == "error") { - type = ConsoleAPIType::kError; - } - // NOTE: args[1] is the processed string message - ignore it. - jsi::Array argsArray = args[2].asObject(rt).asArray(rt); - std::vector argsVec; - for (size_t i = 0, length = argsArray.length(rt); i != length; - ++i) { - argsVec.emplace_back(argsArray.getValueAtIndex(rt, i)); - } - // TODO(moti): Handle framesToSkip in some way. Note that the - // runtime doesn't even capture a stack trace at the moment. - ConsoleMessage consoleMessage{ - std::chrono::duration_cast< - std::chrono::duration>( - timestamp.time_since_epoch()) - .count(), - type, - std::move(argsVec)}; - if (auto self = selfWeak.lock()) { - // Q: Why is it safe to use self->delegate_ here? - // A: Because the caller of InspectorTarget::registerRuntime - // is explicitly required to guarantee that the delegate not - // only outlives the target, but also outlives all JS code - // execution that occurs on the JS thread. - self->delegate_.addConsoleMessage( - rt, std::move(consoleMessage)); - // To ensure we never destroy `self` on the JS thread, send - // our shared_ptr back to the inspector thread. - selfExecutor([self = std::move(self)](auto&) { (void)self; }); - } - return jsi::Value::undefined(); - })); - }); -} - std::shared_ptr RuntimeTarget::createAgent( FrontendChannel channel, SessionState& sessionState) { diff --git a/packages/react-native/ReactCommon/jsinspector-modern/RuntimeTargetConsole.cpp b/packages/react-native/ReactCommon/jsinspector-modern/RuntimeTargetConsole.cpp new file mode 100644 index 00000000000..6ac0b02fad9 --- /dev/null +++ b/packages/react-native/ReactCommon/jsinspector-modern/RuntimeTargetConsole.cpp @@ -0,0 +1,447 @@ +/* + * 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 + +using namespace facebook::jsi; +using namespace std::string_literals; + +namespace facebook::react::jsinspector_modern { + +namespace { + +struct ConsoleState { + /** + * https://console.spec.whatwg.org/#counting + */ + std::unordered_map countMap; + + /** + * https://console.spec.whatwg.org/#timing + */ + std::unordered_map timerTable; + + ConsoleState() = default; + ConsoleState(const ConsoleState&) = delete; + ConsoleState& operator=(const ConsoleState&) = delete; + ConsoleState(ConsoleState&&) = delete; + ConsoleState& operator=(ConsoleState&&) = delete; + ~ConsoleState() = default; +}; + +/** + * `console` methods that have no behaviour other than emitting a + * Runtime.consoleAPICalled message. + */ +constexpr const std::pair + kForwardingConsoleMethods[] = { + {"clear", ConsoleAPIType::kClear}, + {"debug", ConsoleAPIType::kDebug}, + {"dir", ConsoleAPIType::kDir}, + {"dirxml", ConsoleAPIType::kDirXML}, + {"error", ConsoleAPIType::kError}, + {"group", ConsoleAPIType::kStartGroup}, + {"groupCollapsed", ConsoleAPIType::kStartGroupCollapsed}, + {"groupEnd", ConsoleAPIType::kEndGroup}, + {"info", ConsoleAPIType::kInfo}, + {"log", ConsoleAPIType::kLog}, + {"table", ConsoleAPIType::kTable}, + {"trace", ConsoleAPIType::kTrace}, + {"warn", ConsoleAPIType::kWarning}, +}; + +/** + * JS `Object.create()` + */ +jsi::Object objectCreate(jsi::Runtime& runtime, jsi::Value prototype) { + auto objectGlobal = runtime.global().getPropertyAsObject(runtime, "Object"); + auto createFn = objectGlobal.getPropertyAsFunction(runtime, "create"); + return createFn.callWithThis(runtime, objectGlobal, prototype) + .getObject(runtime); +} + +bool toBoolean(jsi::Runtime& runtime, const jsi::Value& val) { + // Based on Operations.cpp:toBoolean in the Hermes VM. + if (val.isUndefined() || val.isNull()) { + return false; + } + if (val.isBool()) { + return val.getBool(); + } + if (val.isNumber()) { + double m = val.getNumber(); + return m != 0 && !std::isnan(m); + } + if (val.isSymbol() || val.isObject()) { + return true; + } + if (val.isString()) { + std::string s = val.getString(runtime).utf8(runtime); + return !s.empty(); + } + assert(false && "All cases should be covered"); + return false; +} + +/** + * Get the current time in milliseconds as a double. + */ +double getTimestampMs() { + return std::chrono::duration_cast>( + std::chrono::system_clock::now().time_since_epoch()) + .count(); +} + +} // namespace + +void RuntimeTarget::installConsoleHandler() { + jsExecutor_([selfWeak = weak_from_this(), + selfExecutor = executorFromThis()](jsi::Runtime& runtime) { + jsi::Value consolePrototype = jsi::Value::null(); + auto originalConsoleVal = runtime.global().getProperty(runtime, "console"); + std::shared_ptr originalConsole; + if (originalConsoleVal.isObject()) { + originalConsole = + std::make_shared(originalConsoleVal.getObject(runtime)); + consolePrototype = std::move(originalConsoleVal); + } else { + consolePrototype = jsi::Object(runtime); + } + auto console = objectCreate(runtime, std::move(consolePrototype)); + auto state = std::make_shared(); + + /** + * An executor that runs synchronously and provides a safe reference to our + * RuntimeTargetDelegate for use on the JS thread. + * \see RuntimeTargetDelegate for information on which methods are safe to + * call on the JS thread. + * \warning The callback will not run if the RuntimeTarget has been + * destroyed. + */ + auto delegateExecutorSync = [selfWeak, selfExecutor](auto&& func) { + if (auto self = selfWeak.lock()) { + // Q: Why is it safe to use self->delegate_ here? + // A: Because the caller of InspectorTarget::registerRuntime + // is explicitly required to guarantee that the delegate not + // only outlives the target, but also outlives all JS code + // execution that occurs on the JS thread. + func(self->delegate_); + // To ensure we never destroy `self` on the JS thread, send + // our shared_ptr back to the inspector thread. + selfExecutor([self = std::move(self)](auto&) { (void)self; }); + } + }; + + /** + * Call \param innerFn and forward any arguments to the original console + * method named \param methodName, if possible. + */ + auto forwardToOriginalConsole = [originalConsole, delegateExecutorSync]( + const char* methodName, + auto&& innerFn) { + return [originalConsole, + delegateExecutorSync, + innerFn = std::forward(innerFn), + methodName]( + jsi::Runtime& runtime, + const jsi::Value& thisVal, + const jsi::Value* args, + size_t count) mutable { + jsi::Value retVal = innerFn(runtime, thisVal, args, count); + if (originalConsole) { + auto val = originalConsole->getProperty(runtime, methodName); + if (val.isObject()) { + auto obj = val.getObject(runtime); + if (obj.isFunction(runtime)) { + auto func = obj.getFunction(runtime); + func.callWithThis(runtime, *originalConsole, args, count); + } + } + } + return retVal; + }; + }; + + /** + * Install a console method with the given name and body. The body receives + * the usual JSI host function parameters plus a ConsoleState reference, a + * reference to the RuntimeTargetDelegate for sending messages to the + * client, and the timestamp of the call. After the body runs (or is skipped + * due to RuntimeTarget having been destroyed), the method of the same name + * is also called on originalConsole (if it exists). + */ + auto installConsoleMethod = + [&](const char* methodName, + std::function&& body) { + console.setProperty( + runtime, + methodName, + jsi::Function::createFromHostFunction( + runtime, + jsi::PropNameID::forAscii(runtime, methodName), + 0, + forwardToOriginalConsole( + methodName, + [body = std::move(body), state, delegateExecutorSync]( + jsi::Runtime& runtime, + const jsi::Value& /*thisVal*/, + const jsi::Value* args, + size_t count) mutable { + auto timestampMs = getTimestampMs(); + delegateExecutorSync( + [&runtime, + args, + count, + body = std::move(body), + state, + timestampMs](auto& runtimeTargetDelegate) { + body( + runtime, + args, + count, + runtimeTargetDelegate, + *state, + timestampMs); + }); + return jsi::Value::undefined(); + }))); + }; + + /** + * console.count + */ + installConsoleMethod( + "count", + [](jsi::Runtime& runtime, + const jsi::Value* args, + size_t count, + RuntimeTargetDelegate& runtimeTargetDelegate, + ConsoleState& state, + auto timestampMs) { + std::string label = "default"; + if (count > 0 && !args[0].isUndefined()) { + label = args[0].toString(runtime).utf8(runtime); + } + auto it = state.countMap.find(label); + if (it == state.countMap.end()) { + it = state.countMap.insert({label, 1}).first; + } else { + it->second++; + } + std::vector vec; + vec.emplace_back(jsi::String::createFromUtf8( + runtime, label + ": "s + std::to_string(it->second))); + runtimeTargetDelegate.addConsoleMessage( + runtime, {timestampMs, ConsoleAPIType::kCount, std::move(vec)}); + }); + + /** + * console.countReset + */ + installConsoleMethod( + "countReset", + [](jsi::Runtime& runtime, + const jsi::Value* args, + size_t count, + RuntimeTargetDelegate& runtimeTargetDelegate, + ConsoleState& state, + auto timestampMs) { + std::string label = "default"; + if (count > 0 && !args[0].isUndefined()) { + label = args[0].toString(runtime).utf8(runtime); + } + auto it = state.countMap.find(label); + if (it == state.countMap.end()) { + std::vector vec; + vec.emplace_back(jsi::String::createFromUtf8( + runtime, "Count for '"s + label + "' does not exist")); + runtimeTargetDelegate.addConsoleMessage( + runtime, + {timestampMs, ConsoleAPIType::kWarning, std::move(vec)}); + } else { + it->second = 0; + } + }); + + /** + * console.time + */ + installConsoleMethod( + "time", + [](jsi::Runtime& runtime, + const jsi::Value* args, + size_t count, + RuntimeTargetDelegate& runtimeTargetDelegate, + ConsoleState& state, + auto timestampMs) { + std::string label = "default"; + if (count > 0 && !args[0].isUndefined()) { + label = args[0].toString(runtime).utf8(runtime); + } + auto it = state.timerTable.find(label); + if (it == state.timerTable.end()) { + state.timerTable.insert({label, timestampMs}); + } else { + std::vector vec; + vec.emplace_back(jsi::String::createFromUtf8( + runtime, "Timer '"s + label + "' already exists")); + runtimeTargetDelegate.addConsoleMessage( + runtime, + {timestampMs, ConsoleAPIType::kWarning, std::move(vec)}); + } + }); + + /** + * console.timeEnd + */ + installConsoleMethod( + "timeEnd", + [](jsi::Runtime& runtime, + const jsi::Value* args, + size_t count, + RuntimeTargetDelegate& runtimeTargetDelegate, + ConsoleState& state, + auto timestampMs) { + std::string label = "default"; + if (count > 0 && !args[0].isUndefined()) { + label = args[0].toString(runtime).utf8(runtime); + } + auto it = state.timerTable.find(label); + if (it == state.timerTable.end()) { + std::vector vec; + vec.emplace_back(jsi::String::createFromUtf8( + runtime, "Timer '"s + label + "' does not exist")); + runtimeTargetDelegate.addConsoleMessage( + runtime, + {timestampMs, ConsoleAPIType::kWarning, std::move(vec)}); + } else { + std::vector vec; + vec.emplace_back(jsi::String::createFromUtf8( + runtime, + label + ": "s + std::to_string(timestampMs - it->second) + + " ms")); + state.timerTable.erase(it); + runtimeTargetDelegate.addConsoleMessage( + runtime, + {timestampMs, ConsoleAPIType::kTimeEnd, std::move(vec)}); + } + }); + + /** + * console.timeLog + */ + installConsoleMethod( + "timeLog", + [](jsi::Runtime& runtime, + const jsi::Value* args, + size_t count, + RuntimeTargetDelegate& runtimeTargetDelegate, + ConsoleState& state, + auto timestampMs) { + std::string label = "default"; + if (count > 0 && !args[0].isUndefined()) { + label = args[0].toString(runtime).utf8(runtime); + } + auto it = state.timerTable.find(label); + if (it == state.timerTable.end()) { + std::vector vec; + vec.emplace_back(jsi::String::createFromUtf8( + runtime, "Timer '"s + label + "' does not exist")); + runtimeTargetDelegate.addConsoleMessage( + runtime, + {timestampMs, ConsoleAPIType::kWarning, std::move(vec)}); + } else { + std::vector vec; + vec.emplace_back(jsi::String::createFromUtf8( + runtime, + label + ": "s + std::to_string(timestampMs - it->second) + + " ms")); + if (count > 1) { + for (size_t i = 1; i != count; ++i) { + vec.emplace_back(runtime, args[i]); + } + } + runtimeTargetDelegate.addConsoleMessage( + runtime, {timestampMs, ConsoleAPIType::kLog, std::move(vec)}); + } + }); + + /** + * console.assert + */ + installConsoleMethod( + "assert", + [](jsi::Runtime& runtime, + const jsi::Value* args, + size_t count, + RuntimeTargetDelegate& runtimeTargetDelegate, + ConsoleState& /*state*/, + auto timestampMs) { + if (count >= 1 && toBoolean(runtime, args[0])) { + return; + } + std::deque data; + + if (count > 1) { + for (size_t i = 1; i != count; ++i) { + data.emplace_back(runtime, args[i]); + } + } + if (data.empty()) { + data.emplace_back( + jsi::String::createFromUtf8(runtime, "Assertion failed")); + } else if (data.front().isString()) { + data.front() = jsi::String::createFromUtf8( + runtime, + "Assertion failed: "s + + data.front().asString(runtime).utf8(runtime)); + } else { + data.emplace_front( + jsi::String::createFromUtf8(runtime, "Assertion failed")); + } + runtimeTargetDelegate.addConsoleMessage( + runtime, + {timestampMs, + ConsoleAPIType::kAssert, + std::vector( + make_move_iterator(data.begin()), + make_move_iterator(data.end()))}); + }); + + for (auto& [name, type] : kForwardingConsoleMethods) { + installConsoleMethod( + name, + [type = type]( + jsi::Runtime& runtime, + const jsi::Value* args, + size_t count, + RuntimeTargetDelegate& runtimeTargetDelegate, + ConsoleState& /*state*/, + auto timestampMs) { + std::vector argsVec; + for (size_t i = 0; i != count; ++i) { + argsVec.emplace_back(runtime, args[i]); + } + runtimeTargetDelegate.addConsoleMessage( + runtime, {timestampMs, type, std::move(argsVec)}); + }); + } + + runtime.global().setProperty(runtime, "console", console); + }); +} + +} // namespace facebook::react::jsinspector_modern