diff --git a/.flowconfig b/.flowconfig index 06884572a7ea..1e871b46a085 100644 --- a/.flowconfig +++ b/.flowconfig @@ -57,6 +57,7 @@ munge_underscores=true module.name_mapper='^react-native$' -> '/packages/react-native/index.js' module.name_mapper='^react-native/asset-registry$' -> '/packages/react-native/src/asset-registry.js' +module.name_mapper='^react-native/node-api$' -> '/packages/react-native/src/node-api.js' module.name_mapper='^react-native/package.json$' -> '/packages/react-native/package.json' module.name_mapper='^react-native/react-private-interface$' -> '/packages/react-native/src/react-private-interface.js' module.name_mapper='^react-native/setup-env$' -> '/packages/react-native/src/setup-env.js' diff --git a/__docs__/README.md b/__docs__/README.md index 4ff46942ed26..e202c87dc08f 100644 --- a/__docs__/README.md +++ b/__docs__/README.md @@ -52,6 +52,7 @@ TODO: Explain the different components of React Native at a high level. - [Animation Backend](../packages/react-native/ReactCommon/react/renderer/animationbackend/__docs__/AnimationBackend.md) - [Native Animated](../packages/react-native/ReactCommon/react/renderer/animated/__docs__/NativeAnimated.md) - Native Modules / TurboModules + - [Node-API](../packages/react-native/ReactCommon/react/nativemodule/nodeapi/__docs__/README.md) - JS Runtime - [Event Loop](../packages/react-native/ReactCommon/react/renderer/runtimescheduler/__docs__/README.md) - Globals and environment setup diff --git a/gradle.properties b/gradle.properties index 1028b5c5238e..52be7ead2f7a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -17,4 +17,4 @@ react.internal.useHermesStable=false # Controls whether to use Hermes from nightly builds. This will speed up builds # but should NOT be turned on for CI or release builds. -react.internal.useHermesNightly=true +react.internal.useHermesNightly=false diff --git a/packages/gradle-plugin/react-native-gradle-plugin/src/main/kotlin/com/facebook/react/ReactPlugin.kt b/packages/gradle-plugin/react-native-gradle-plugin/src/main/kotlin/com/facebook/react/ReactPlugin.kt index 5924836d2245..c7149e144f82 100644 --- a/packages/gradle-plugin/react-native-gradle-plugin/src/main/kotlin/com/facebook/react/ReactPlugin.kt +++ b/packages/gradle-plugin/react-native-gradle-plugin/src/main/kotlin/com/facebook/react/ReactPlugin.kt @@ -31,6 +31,7 @@ import com.facebook.react.utils.JsonUtils import com.facebook.react.utils.NdkConfiguratorUtils.configureReactNativeNdk import com.facebook.react.utils.NdkConfiguratorUtils.configureStrictCxxApiForLibrary import com.facebook.react.utils.NdkConfiguratorUtils.configureStubPchGeneration +import com.facebook.react.utils.ProjectUtils.isNodeApiEnabled import com.facebook.react.utils.ProjectUtils.isStrictCxxApiEnabled import com.facebook.react.utils.ProjectUtils.needsCodegenFromPackageJson import com.facebook.react.utils.PropertyUtils @@ -99,6 +100,11 @@ class ReactPlugin : Plugin { readVersionAndGroupStrings(project, propertiesFile, hermesVersionPropertiesFile) configureDependencies(project, versionAndGroupStrings) configureRepositories(project, versionAndGroupStrings) + if (project.isNodeApiEnabled && !isReactNativeBuiltFromSource(project, reactNativeDir)) { + project.logger.warn( + "WARNING: 'nodeApiEnabled' has no effect on the prebuilt React Native AAR, which doesn't include Node-API. Build React Native from source to load Node-API addons.", + ) + } } configureReactNativeNdk(project, extension) @@ -154,6 +160,12 @@ class ReactPlugin : Plugin { } } + private fun isReactNativeBuiltFromSource(project: Project, reactNativeDir: File): Boolean = + project.rootProject.findProject(":packages:react-native:ReactAndroid") != null || + project.gradle.includedBuilds.any { + it.projectDir.canonicalFile == reactNativeDir.canonicalFile + } + private fun checkJvmVersion(project: Project) { val jvmVersion = Jvm.current().javaVersion?.majorVersion if ((jvmVersion?.toIntOrNull() ?: 0) <= 16) { diff --git a/packages/gradle-plugin/react-native-gradle-plugin/src/main/kotlin/com/facebook/react/utils/NdkConfiguratorUtils.kt b/packages/gradle-plugin/react-native-gradle-plugin/src/main/kotlin/com/facebook/react/utils/NdkConfiguratorUtils.kt index f873fe373d7e..7897d354f4db 100644 --- a/packages/gradle-plugin/react-native-gradle-plugin/src/main/kotlin/com/facebook/react/utils/NdkConfiguratorUtils.kt +++ b/packages/gradle-plugin/react-native-gradle-plugin/src/main/kotlin/com/facebook/react/utils/NdkConfiguratorUtils.kt @@ -13,6 +13,7 @@ import com.android.build.api.variant.Variant import com.facebook.react.ReactExtension import com.facebook.react.internal.StubPchBuildService import com.facebook.react.utils.ProjectUtils.getReactNativeArchitectures +import com.facebook.react.utils.ProjectUtils.isNodeApiEnabled import com.facebook.react.utils.ProjectUtils.isStrictCxxApiEnabled import java.io.File import org.gradle.api.Project @@ -60,6 +61,11 @@ internal object NdkConfiguratorUtils { if (project.isStrictCxxApiEnabled && cmakeArgs.none { it.startsWith("-DRN_STRICT_API") }) { cmakeArgs.add("-DRN_STRICT_API=ON") } + if ( + project.isNodeApiEnabled && cmakeArgs.none { it.startsWith("-DRCT_NODE_API_ENABLED") } + ) { + cmakeArgs.add("-DRCT_NODE_API_ENABLED=ON") + } val architectures = project.getReactNativeArchitectures() // abiFilters are split ABI are not compatible each other, so we set the abiFilters diff --git a/packages/gradle-plugin/react-native-gradle-plugin/src/main/kotlin/com/facebook/react/utils/ProjectUtils.kt b/packages/gradle-plugin/react-native-gradle-plugin/src/main/kotlin/com/facebook/react/utils/ProjectUtils.kt index 814d20cd7dae..1e8dec1dbff2 100644 --- a/packages/gradle-plugin/react-native-gradle-plugin/src/main/kotlin/com/facebook/react/utils/ProjectUtils.kt +++ b/packages/gradle-plugin/react-native-gradle-plugin/src/main/kotlin/com/facebook/react/utils/ProjectUtils.kt @@ -11,10 +11,12 @@ import com.facebook.react.ReactExtension import com.facebook.react.model.ModelPackageJson import com.facebook.react.utils.PropertyUtils.EDGE_TO_EDGE_ENABLED import com.facebook.react.utils.PropertyUtils.HERMES_ENABLED +import com.facebook.react.utils.PropertyUtils.NODE_API_ENABLED import com.facebook.react.utils.PropertyUtils.REACT_NATIVE_ARCHITECTURES import com.facebook.react.utils.PropertyUtils.RESET_METRO_CACHE_BEFORE_BUNDLING import com.facebook.react.utils.PropertyUtils.SCOPED_EDGE_TO_EDGE_ENABLED import com.facebook.react.utils.PropertyUtils.SCOPED_HERMES_ENABLED +import com.facebook.react.utils.PropertyUtils.SCOPED_NODE_API_ENABLED import com.facebook.react.utils.PropertyUtils.SCOPED_REACT_NATIVE_ARCHITECTURES import com.facebook.react.utils.PropertyUtils.SCOPED_RESET_METRO_CACHE_BEFORE_BUNDLING import com.facebook.react.utils.PropertyUtils.SCOPED_STRICT_CXX_API_ENABLED @@ -67,6 +69,13 @@ internal object ProjectUtils { (project.hasProperty(SCOPED_STRICT_CXX_API_ENABLED) && project.property(SCOPED_STRICT_CXX_API_ENABLED).toString().toBoolean()) + internal val Project.isNodeApiEnabled: Boolean + get() = + (project.hasProperty(NODE_API_ENABLED) && + project.property(NODE_API_ENABLED).toString().toBoolean()) || + (project.hasProperty(SCOPED_NODE_API_ENABLED) && + project.property(SCOPED_NODE_API_ENABLED).toString().toBoolean()) + internal val Project.useThirdPartyJSC: Boolean get() = (project.hasProperty(USE_THIRD_PARTY_JSC) && diff --git a/packages/gradle-plugin/react-native-gradle-plugin/src/main/kotlin/com/facebook/react/utils/PropertyUtils.kt b/packages/gradle-plugin/react-native-gradle-plugin/src/main/kotlin/com/facebook/react/utils/PropertyUtils.kt index 014d7410055e..31291ae5ff2a 100644 --- a/packages/gradle-plugin/react-native-gradle-plugin/src/main/kotlin/com/facebook/react/utils/PropertyUtils.kt +++ b/packages/gradle-plugin/react-native-gradle-plugin/src/main/kotlin/com/facebook/react/utils/PropertyUtils.kt @@ -36,6 +36,13 @@ object PropertyUtils { const val STRICT_CXX_API_ENABLED = "strictCxxApiEnabled" const val SCOPED_STRICT_CXX_API_ENABLED = "react.strictCxxApiEnabled" + /** + * Public property that builds React Native and Hermes with Node-API when they're built from + * source, and defines RCT_NODE_API_ENABLED for the app's native build + */ + const val NODE_API_ENABLED = "nodeApiEnabled" + const val SCOPED_NODE_API_ENABLED = "react.nodeApiEnabled" + /** Public property that excludes jsctooling from core */ const val USE_THIRD_PARTY_JSC = "useThirdPartyJSC" const val SCOPED_USE_THIRD_PARTY_JSC = "react.useThirdPartyJSC" diff --git a/packages/gradle-plugin/react-native-gradle-plugin/src/test/kotlin/com/facebook/react/utils/ProjectUtilsTest.kt b/packages/gradle-plugin/react-native-gradle-plugin/src/test/kotlin/com/facebook/react/utils/ProjectUtilsTest.kt index cf369e547616..3d44187fbf5b 100644 --- a/packages/gradle-plugin/react-native-gradle-plugin/src/test/kotlin/com/facebook/react/utils/ProjectUtilsTest.kt +++ b/packages/gradle-plugin/react-native-gradle-plugin/src/test/kotlin/com/facebook/react/utils/ProjectUtilsTest.kt @@ -15,6 +15,7 @@ import com.facebook.react.utils.ProjectUtils.getReactNativeArchitectures import com.facebook.react.utils.ProjectUtils.isEdgeToEdgeEnabled import com.facebook.react.utils.ProjectUtils.isHermesEnabled import com.facebook.react.utils.ProjectUtils.isNewArchEnabled +import com.facebook.react.utils.ProjectUtils.isNodeApiEnabled import com.facebook.react.utils.ProjectUtils.isStrictCxxApiEnabled import com.facebook.react.utils.ProjectUtils.needsCodegenFromPackageJson import com.facebook.react.utils.ProjectUtils.shouldResetMetroCacheBeforeBundling @@ -205,6 +206,32 @@ class ProjectUtilsTest { assertThat(project.isStrictCxxApiEnabled).isFalse() } + @Test + fun isNodeApiEnabled_returnsFalseByDefault() { + assertThat(createProject().isNodeApiEnabled).isFalse() + } + + @Test + fun isNodeApiEnabled_withEnabledViaProperty_returnsTrue() { + val project = createProject() + project.extensions.extraProperties.set("nodeApiEnabled", "true") + assertThat(project.isNodeApiEnabled).isTrue() + } + + @Test + fun isNodeApiEnabled_withEnabledViaScopedProperty_returnsTrue() { + val project = createProject() + project.extensions.extraProperties.set("react.nodeApiEnabled", "true") + assertThat(project.isNodeApiEnabled).isTrue() + } + + @Test + fun isNodeApiEnabled_withFalseViaProperty_returnsFalse() { + val project = createProject() + project.extensions.extraProperties.set("nodeApiEnabled", "false") + assertThat(project.isNodeApiEnabled).isFalse() + } + @Test fun needsCodegenFromPackageJson_withCodegenConfigInPackageJson_returnsTrue() { val project = createProject() diff --git a/packages/react-native/Package.swift b/packages/react-native/Package.swift index ac1611c59c07..143dc6c680e4 100644 --- a/packages/react-native/Package.swift +++ b/packages/react-native/Package.swift @@ -18,6 +18,8 @@ let REMOVE_LEGACY_MODULE_INTEROP = let REMOVE_LEGACY_COMPONENT_INTEROP = ProcessInfo.processInfo.environment["RCT_REMOVE_LEGACY_COMPONENT_INTEROP"] == "1" +let NODE_API_ENABLED = ProcessInfo.processInfo.environment["RCT_NODE_API_ENABLED"] == "1" + /** This is the `Package.swift` file that allows to build React Native core using Swift PM. To build React Native, you need to follow these steps: @@ -343,6 +345,14 @@ let reactTurboModuleCoreMicrotasks = RNTarget( dependencies: [.reactNativeDependencies, .reactDebug, .reactFeatureFlags, .reactUtils, .reactPerfLogger, .reactCxxReact, .reactTurboModuleCore] ) +/// React-nodeapinativemodule.podspec +let reactNodeApiNativeModule = RNTarget( + name: .reactNodeApiNativeModule, + path: "ReactCommon/react/nativemodule/nodeapi", + searchPaths: [".build/artifacts/hermes/destroot/include/hermes/napi"], + dependencies: [.reactNativeDependencies, .jsi, .logger, .reactCxxReact, .reactTurboModuleCore, .hermesPrebuilt] +) + /// React-idlecallback.podspec let reactIdleCallbacksNativeModule = RNTarget( name: .reactIdleCallbacksNativeModule, @@ -429,7 +439,8 @@ let reactRuntime = RNTarget( name: .reactRuntime, path: "ReactCommon/react/runtime", excludedPaths: ["tests", "iostests", "platform"], - dependencies: [.reactNativeDependencies, .jsi, .reactJsiExecutor, .reactCxxReact, .reactJsErrorHandler, .reactPerformanceTimeline, .reactUtils, .reactFeatureFlags, .reactJsInspector, .reactJsiTooling, .reactHermes, .reactRuntimeScheduler, .hermesPrebuilt], + dependencies: [.reactNativeDependencies, .jsi, .reactJsiExecutor, .reactCxxReact, .reactJsErrorHandler, .reactPerformanceTimeline, .reactUtils, .reactFeatureFlags, .reactJsInspector, .reactJsiTooling, .reactHermes, .reactRuntimeScheduler, .hermesPrebuilt] + + (NODE_API_ENABLED ? [.reactNodeApiNativeModule] : []), defines: [ CXXSetting.define("HERMES_ENABLE_DEBUGGER", to: "1", .when(configuration: BuildConfiguration.debug)) ] @@ -745,6 +756,7 @@ let targets = [ reactTurboModuleCore, reactTurboModuleCoreDefaults, reactTurboModuleCoreMicrotasks, + reactNodeApiNativeModule, reactIdleCallbacksNativeModule, reactWebPerformanceNativeModule, reactIntersectionObserverNativeModule, @@ -757,7 +769,7 @@ let targets = [ reactAppDelegate, reactSettings, reactRuntimeExecutor, -] +].filter { NODE_API_ENABLED || $0.name != .reactNodeApiNativeModule } // MARK: Package object @@ -938,6 +950,7 @@ extension String { static let reactTurboModuleCore = "ReactCommon/turbomodule/core" static let reactTurboModuleCoreDefaults = "ReactCommon/turbomodule/core/defaults" static let reactTurboModuleCoreMicrotasks = "ReactCommon/turbomodule/core/microtasks" + static let reactNodeApiNativeModule = "React-nodeapinativemodule" static let reactIdleCallbacksNativeModule = "React-idlecallbacksnativemodule" static let reactWebPerformanceNativeModule = "React-webperformancenativemodule" static let reactIntersectionObserverNativeModule = "React-intersectionobservernativemodule" @@ -982,6 +995,7 @@ extension Target { let legacyInteropDefines: [CXXSetting] = (REMOVE_LEGACY_MODULE_INTEROP ? [.define("RCT_REMOVE_LEGACY_MODULE_INTEROP", to: "1")] : []) + (REMOVE_LEGACY_COMPONENT_INTEROP ? [.define("RCT_REMOVE_LEGACY_COMPONENT_INTEROP", to: "1")] : []) + + (NODE_API_ENABLED ? [.define("RCT_NODE_API_ENABLED", to: "1")] : []) // Every target built through this factory is React Native's own, so RN_BUILDING // keeps the react/cxxstableapi guards inert for internal sources. cxxSettings are diff --git a/packages/react-native/ReactAndroid/build.gradle.kts b/packages/react-native/ReactAndroid/build.gradle.kts index 89d031383e58..bdb31638c54c 100644 --- a/packages/react-native/ReactAndroid/build.gradle.kts +++ b/packages/react-native/ReactAndroid/build.gradle.kts @@ -539,6 +539,11 @@ fun reactNativeArchitectures(): List { return value?.toString()?.split(",") ?: listOf("armeabi-v7a", "x86", "x86_64", "arm64-v8a") } +fun nodeApiEnabled(): Boolean { + val value = project.properties["nodeApiEnabled"] ?: project.properties["react.nodeApiEnabled"] + return value?.toString()?.toBoolean() ?: false +} + fun enableWarningsAsErrors(): Boolean { val value = project.properties["enableWarningsAsErrors"] return value?.toString()?.toBoolean() ?: false @@ -620,6 +625,9 @@ android { "-DANDROID_TOOLCHAIN=clang", "-DCMAKE_POLICY_DEFAULT_CMP0069=NEW", ) + if (nodeApiEnabled()) { + arguments("-DRCT_NODE_API_ENABLED=ON") + } targets( "reactnative", diff --git a/packages/react-native/ReactAndroid/hermes-engine/build.gradle.kts b/packages/react-native/ReactAndroid/hermes-engine/build.gradle.kts index 74fedb432fcc..d0f7cde76548 100644 --- a/packages/react-native/ReactAndroid/hermes-engine/build.gradle.kts +++ b/packages/react-native/ReactAndroid/hermes-engine/build.gradle.kts @@ -206,6 +206,7 @@ val prepareHeadersForPrefab by dependsOn(buildHermesC) from("$hermesDir/API") from("$hermesDir/public") + from("$hermesDir/include") { include("hermes/napi/**") } include("**/*.h") exclude("jsi/**") into(prefabHeadersDir) @@ -260,6 +261,7 @@ val prepareHeadersForPrefabWithDebugger by dependsOn(buildHermesCWithDebugger) from("$hermesDir/API") from("$hermesDir/public") + from("$hermesDir/include") { include("hermes/napi/**") } include("**/*.h") exclude("jsi/**") into(prefabHeadersDir) @@ -294,6 +296,11 @@ fun windowsAwareCommandLine(vararg commands: String): List { return result } +fun nodeApiEnabled(): Boolean { + val value = project.properties["nodeApiEnabled"] ?: project.properties["react.nodeApiEnabled"] + return value?.toString()?.toBoolean() ?: false +} + fun reactNativeArchitectures(): List { val value = project.properties["reactNativeArchitectures"] return value?.toString()?.split(",") ?: listOf("armeabi-v7a", "x86", "x86_64", "arm64-v8a") @@ -346,6 +353,9 @@ android { ) arguments("-DHERMESVM_HEAP_HV_MODE=HEAP_HV_PREFER32") + if (!nodeApiEnabled()) { + arguments("-DHERMES_ENABLE_NAPI=False") + } targets("hermesvm") } diff --git a/packages/react-native/ReactAndroid/src/main/jni/CMakeLists.txt b/packages/react-native/ReactAndroid/src/main/jni/CMakeLists.txt index 4c5be342c0bd..dfa362525329 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/CMakeLists.txt +++ b/packages/react-native/ReactAndroid/src/main/jni/CMakeLists.txt @@ -66,6 +66,10 @@ add_react_third_party_ndk_subdir(folly) # the prefab artifacts through ReactNative-application.cmake). add_compile_definitions(RN_BUILDING) +if(RCT_NODE_API_ENABLED) + add_compile_definitions(RCT_NODE_API_ENABLED) +endif() + # Common targets add_react_common_subdir(yoga) add_react_common_subdir(runtimeexecutor) @@ -139,6 +143,9 @@ add_react_common_subdir(react/nativemodule/microtasks) add_react_common_subdir(react/nativemodule/idlecallbacks) add_react_common_subdir(react/nativemodule/intersectionobserver) add_react_common_subdir(react/nativemodule/mutationobserver) +if(RCT_NODE_API_ENABLED) + add_react_common_subdir(react/nativemodule/nodeapi) +endif() add_react_common_subdir(react/nativemodule/resizeobserver) add_react_common_subdir(react/nativemodule/viewtransition) add_react_common_subdir(react/nativemodule/webperformance) diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/hermes/tooling/CMakeLists.txt b/packages/react-native/ReactAndroid/src/main/jni/react/hermes/tooling/CMakeLists.txt index b831d0598111..7ab3ff9d7581 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/hermes/tooling/CMakeLists.txt +++ b/packages/react-native/ReactAndroid/src/main/jni/react/hermes/tooling/CMakeLists.txt @@ -23,6 +23,13 @@ add_library(hermestooling $ $ ) +if(RCT_NODE_API_ENABLED) + target_sources(hermestooling PRIVATE $) + target_include_directories(hermestooling + PUBLIC + $ + ) +endif() target_merge_so(hermestooling) target_link_libraries(hermestooling PUBLIC diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/runtime/jni/CMakeLists.txt b/packages/react-native/ReactAndroid/src/main/jni/react/runtime/jni/CMakeLists.txt index 8987aed03763..ec2935fd79e4 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/runtime/jni/CMakeLists.txt +++ b/packages/react-native/ReactAndroid/src/main/jni/react/runtime/jni/CMakeLists.txt @@ -31,5 +31,6 @@ target_link_libraries( jsi jsitooling fbjni + glog_init bridgeless ) diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/runtime/jni/OnLoad.cpp b/packages/react-native/ReactAndroid/src/main/jni/react/runtime/jni/OnLoad.cpp index f4dcbfa9a721..b7c3ba2955e6 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/runtime/jni/OnLoad.cpp +++ b/packages/react-native/ReactAndroid/src/main/jni/react/runtime/jni/OnLoad.cpp @@ -5,6 +5,7 @@ * LICENSE file in the root directory of this source tree. */ +#include #include #include @@ -12,8 +13,15 @@ #include "JReactHostInspectorTarget.h" #include "JReactInstance.h" +#ifndef WITH_GLOGINIT +#define WITH_GLOGINIT 1 +#endif + JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* /*unused*/) { return facebook::jni::initialize(vm, [] { +#if WITH_GLOGINIT + facebook::gloginit::initialize(); +#endif facebook::react::JReactMarker::setLogPerfMarkerIfNeeded(); facebook::react::JReactInstance::registerNatives(); facebook::react::JJSTimerExecutor::registerNatives(); diff --git a/packages/react-native/ReactCommon/react/nativemodule/nodeapi/CMakeLists.txt b/packages/react-native/ReactCommon/react/nativemodule/nodeapi/CMakeLists.txt new file mode 100644 index 000000000000..db05271d2011 --- /dev/null +++ b/packages/react-native/ReactCommon/react/nativemodule/nodeapi/CMakeLists.txt @@ -0,0 +1,28 @@ +# 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. + +cmake_minimum_required(VERSION 3.13) +set(CMAKE_VERBOSE_MAKEFILE on) + +include(${REACT_COMMON_DIR}/cmake-utils/react-native-flags.cmake) + +file(GLOB react_nativemodule_nodeapi_SRC CONFIGURE_DEPENDS *.cpp) +add_library(react_nativemodule_nodeapi OBJECT ${react_nativemodule_nodeapi_SRC}) + +target_include_directories(react_nativemodule_nodeapi PUBLIC ${REACT_COMMON_DIR}) + +get_target_property(hermes_include_dirs hermes-engine::hermesvm INTERFACE_INCLUDE_DIRECTORIES) +list(TRANSFORM hermes_include_dirs APPEND /hermes/napi) +target_include_directories(react_nativemodule_nodeapi PUBLIC ${hermes_include_dirs}) + +target_link_libraries(react_nativemodule_nodeapi + hermes-engine::hermesvm + jsi + logger + react_codegen_rncore + react_cxxreact + react_cxxstableapi +) +target_compile_reactnative_options(react_nativemodule_nodeapi PRIVATE) diff --git a/packages/react-native/ReactCommon/react/nativemodule/nodeapi/NativeNodeApi.cpp b/packages/react-native/ReactCommon/react/nativemodule/nodeapi/NativeNodeApi.cpp new file mode 100644 index 000000000000..0d4d5008f618 --- /dev/null +++ b/packages/react-native/ReactCommon/react/nativemodule/nodeapi/NativeNodeApi.cpp @@ -0,0 +1,112 @@ +/* + * 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 "NativeNodeApi.h" + +#include +#include +#include + +namespace facebook::react { + +namespace { + +// Node-API has no bridge from napi_value to jsi::Value, so the value crosses +// through a global property named after the env, removed straight after. +jsi::Value toJsiValue(jsi::Runtime& runtime, napi_env env, napi_value value) { + char name[32]; + snprintf(name, sizeof(name), "RN$NodeApi_%p", static_cast(env)); + + napi_value global = nullptr; + [[maybe_unused]] napi_status status = napi_get_global(env, &global); + assert(status == napi_ok); + status = napi_set_named_property(env, global, name, value); + assert(status == napi_ok); + + jsi::Value result = runtime.global().getProperty(runtime, name); + + napi_value key = nullptr; + status = napi_create_string_utf8(env, name, NAPI_AUTO_LENGTH, &key); + assert(status == napi_ok); + [[maybe_unused]] bool deleted = false; + status = napi_delete_property(env, global, key, &deleted); + assert(status == napi_ok && deleted); + return result; +} + +// Takes the exception left pending on `env`, or creates an error from `status` +// if there is none. +napi_value takeLoadError(napi_env env, napi_status status) { + napi_value error = nullptr; + if (napi_get_and_clear_last_exception(env, &error) == napi_ok && + error != nullptr) { + napi_valuetype type = napi_undefined; + if (napi_typeof(env, error, &type) == napi_ok && type != napi_undefined) { + return error; + } + } + std::string message = "Node-API status " + std::to_string(status); + napi_value messageValue = nullptr; + [[maybe_unused]] napi_status createStatus = napi_create_string_utf8( + env, message.c_str(), message.size(), &messageValue); + assert(createStatus == napi_ok); + createStatus = napi_create_error(env, nullptr, messageValue, &error); + assert(createStatus == napi_ok); + return error; +} + +} // namespace + +NativeNodeApi::NativeNodeApi( + std::shared_ptr engine, + std::shared_ptr jsInvoker) + : NativeNodeApiCxxSpec(std::move(jsInvoker)), engine_(std::move(engine)) { + // A dropped function implies the runtime, and every env it owns, is gone. + host_ = NodeApiHost::create( + [weakInvoker = std::weak_ptr(jsInvoker_)](std::function&& fn) { + auto invoker = weakInvoker.lock(); + if (!invoker) { + return false; + } + invoker->invokeAsync(std::move(fn)); + return true; + }); + NodeApiHost::retainForProcessLifetime(host_); +} + +void NativeNodeApi::dlopen( + jsi::Runtime& runtime, + jsi::Object module, + jsi::String filename) { + // Each load gets its own env, as in Node. + napi_env env = engine_->createEnv(runtime, *host_); + if (env == nullptr) { + throw jsi::JSError(runtime, "Failed to create a Node-API environment"); + } + + napi_handle_scope scope = nullptr; + [[maybe_unused]] napi_status status = napi_open_handle_scope(env, &scope); + assert(status == napi_ok); + + napi_value exports = nullptr; + std::string path = filename.utf8(runtime); + status = engine_->loadAddon(env, path.c_str(), &exports); + bool failed = status != napi_ok; + jsi::Value result = + toJsiValue(runtime, env, failed ? takeLoadError(env, status) : exports); + + [[maybe_unused]] napi_status closeStatus = + napi_close_handle_scope(env, scope); + assert(closeStatus == napi_ok); + + if (failed) { + throw jsi::JSError(runtime, std::move(result)); + } + module.setProperty(runtime, "exports", std::move(result)); +} + +} // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/nativemodule/nodeapi/NativeNodeApi.h b/packages/react-native/ReactCommon/react/nativemodule/nodeapi/NativeNodeApi.h new file mode 100644 index 000000000000..7952940a96e2 --- /dev/null +++ b/packages/react-native/ReactCommon/react/nativemodule/nodeapi/NativeNodeApi.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 +#include + +#if __has_include("FBReactNativeSpecJSI.h") // CocoaPod headers on Apple +#include "FBReactNativeSpecJSI.h" +#else +#include +#endif + +namespace facebook::react { + +class NativeNodeApi : public NativeNodeApiCxxSpec { + public: + NativeNodeApi(std::shared_ptr engine, std::shared_ptr jsInvoker); + + void dlopen(jsi::Runtime &runtime, jsi::Object module, jsi::String filename); + + private: + std::shared_ptr engine_; + std::shared_ptr host_; +}; + +} // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/nativemodule/nodeapi/NodeApiEngine.h b/packages/react-native/ReactCommon/react/nativemodule/nodeapi/NodeApiEngine.h new file mode 100644 index 000000000000..d2b19d8decd7 --- /dev/null +++ b/packages/react-native/ReactCommon/react/nativemodule/nodeapi/NodeApiEngine.h @@ -0,0 +1,38 @@ +/* + * 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 + +namespace facebook::react { + +class NodeApiHost; + +/** + * The parts of loading a Node-API addon that depend on the JavaScript engine's + * embedder API. + */ +class NodeApiEngine { + public: + virtual ~NodeApiEngine() = default; + + /** + * Creates a Node-API environment in `runtime`, owned by the runtime and + * served by `host`. + */ + virtual napi_env createEnv(jsi::Runtime &runtime, NodeApiHost &host) = 0; + + /** + * Loads the addon at `filename` into `env` and returns its exports through + * `result`. On failure, leaves an exception pending on `env`. + */ + virtual napi_status loadAddon(napi_env env, const char *filename, napi_value *result) = 0; +}; + +} // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/nativemodule/nodeapi/NodeApiHost.cpp b/packages/react-native/ReactCommon/react/nativemodule/nodeapi/NodeApiHost.cpp new file mode 100644 index 000000000000..cbdf21b8b672 --- /dev/null +++ b/packages/react-native/ReactCommon/react/nativemodule/nodeapi/NodeApiHost.cpp @@ -0,0 +1,255 @@ +/* + * 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 "NodeApiHost.h" + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace facebook::react { + +namespace { + +struct WorkItem { + // Matched together with workData on cancellation, since a freed + // napi_async_work address can be reused by another runtime's env. + void* data = nullptr; + std::shared_ptr host; + void* workData = nullptr; + void (*execute)(void* workData) = nullptr; + void (*complete)(void* workData, napi_status status) = nullptr; +}; + +class WorkerPool { + public: + static WorkerPool& instance() { + // Leaked with detached threads, like libuv's thread pool: it outlives + // every runtime and has no safe point to be joined. + static auto* pool = new WorkerPool(); + return *pool; + } + + void enqueue(WorkItem&& item) { + { + std::lock_guard lock(mutex_); + for (const WorkItem& queued : queue_) { + if (queued.data == item.data && queued.workData == item.workData) { + // Queueing the same work twice would complete it twice, and the + // second completion would follow napi_delete_async_work. + react_native_log_warn( + "Node-API: dropping napi_async_work that is already queued"); + return; + } + } + queue_.push_back(std::move(item)); + } + cv_.notify_one(); + } + + bool tryRemove(void* data, void* workData, WorkItem& result) { + std::lock_guard lock(mutex_); + for (auto it = queue_.begin(); it != queue_.end(); ++it) { + if (it->data == data && it->workData == workData) { + result = std::move(*it); + queue_.erase(it); + return true; + } + } + return false; + } + + private: + // libuv's default thread pool size. + static constexpr size_t kThreadCount = 4; + + WorkerPool() { + for (size_t i = 0; i < kThreadCount; i++) { + std::thread([this] { workerMain(); }).detach(); + } + } + + [[noreturn]] void workerMain() { + for (;;) { + WorkItem item; + { + std::unique_lock lock(mutex_); + cv_.wait(lock, [this] { return !queue_.empty(); }); + item = std::move(queue_.front()); + queue_.pop_front(); + } + item.execute(item.workData); + bool accepted = item.host->dispatchToJs( + [workData = item.workData, complete = item.complete] { + complete(workData, napi_ok); + }); + if (!accepted) { + react_native_log_warn( + "Node-API: dropping an async work completion posted after runtime teardown"); + } + } + } + + std::mutex mutex_; + std::condition_variable cv_; + std::deque queue_; +}; + +std::optional stringValue(napi_env env, napi_value value) { + size_t length = 0; + if (napi_get_value_string_utf8(env, value, nullptr, 0, &length) != napi_ok) { + return std::nullopt; + } + std::string result(length, '\0'); + if (napi_get_value_string_utf8( + env, value, result.data(), length + 1, nullptr) != napi_ok) { + return std::nullopt; + } + return result; +} + +std::string describeError(napi_env env, napi_value error) { + napi_value stack = nullptr; + napi_valuetype type = napi_undefined; + if (napi_get_named_property(env, error, "stack", &stack) == napi_ok && + napi_typeof(env, stack, &type) == napi_ok && type == napi_string) { + if (auto text = stringValue(env, stack)) { + return *text; + } + } + napi_value coerced = nullptr; + if (napi_coerce_to_string(env, error, &coerced) == napi_ok) { + if (auto text = stringValue(env, coerced)) { + return *text; + } + } + return "(unable to stringify the error)"; +} + +[[noreturn]] void abortWithFatalException(napi_env env, napi_value error) { + std::string message = "napi_fatal_exception: " + describeError(env, error); + react_native_log_fatal(message.c_str()); + abort(); +} + +// Routes the error through ErrorUtils.reportFatalError, React Native's +// equivalent of Node's 'uncaughtException'. On failure, clears any pending +// exception so the caller's fallback can make Node-API calls. +bool tryReportFatalError(napi_env env, napi_value error) { + napi_value global = nullptr; + if (napi_get_global(env, &global) != napi_ok) { + return false; + } + + napi_valuetype type = napi_undefined; + napi_value errorUtils = nullptr; + if (napi_get_named_property(env, global, "ErrorUtils", &errorUtils) != + napi_ok || + napi_typeof(env, errorUtils, &type) != napi_ok || type != napi_object) { + return false; + } + + napi_value reportFatalError = nullptr; + if (napi_get_named_property( + env, errorUtils, "reportFatalError", &reportFatalError) != napi_ok || + napi_typeof(env, reportFatalError, &type) != napi_ok || + type != napi_function) { + return false; + } + + napi_value argv[] = {error}; + if (napi_call_function(env, errorUtils, reportFatalError, 1, argv, nullptr) != + napi_ok) { + bool isPending = false; + if (napi_is_exception_pending(env, &isPending) == napi_ok && isPending) { + napi_value discarded = nullptr; + napi_get_and_clear_last_exception(env, &discarded); + } + return false; + } + return true; +} + +} // namespace + +NodeApiHost::NodeApiHost(JsDispatcher dispatchToJs) + : dispatchToJs_(std::move(dispatchToJs)) {} + +std::shared_ptr NodeApiHost::create(JsDispatcher dispatchToJs) { + return std::shared_ptr(new NodeApiHost(std::move(dispatchToJs))); +} + +void NodeApiHost::retainForProcessLifetime(std::shared_ptr host) { + static auto* mutex = new std::mutex(); + static auto* retained = new std::vector>(); + std::lock_guard lock(*mutex); + retained->push_back(std::move(host)); +} + +void NodeApiHost::postWork( + void* data, + void* workData, + void (*execute)(void* workData), + void (*complete)(void* workData, napi_status status)) noexcept { + auto* self = static_cast(data); + WorkerPool::instance().enqueue( + WorkItem{ + .data = data, + .host = self->shared_from_this(), + .workData = workData, + .execute = execute, + .complete = complete, + }); +} + +bool NodeApiHost::cancelWork(void* data, void* workData) noexcept { + WorkItem item; + if (!WorkerPool::instance().tryRemove(data, workData, item)) { + return false; + } + // Only report success while the cancelled completion can still be delivered. + return item.host->dispatchToJs( + [workData = item.workData, complete = item.complete] { + complete(workData, napi_cancelled); + }); +} + +void NodeApiHost::postTask( + void* data, + void* taskData, + void (*callback)(void* taskData)) noexcept { + auto* self = static_cast(data); + if (!self->dispatchToJs_([taskData, callback] { callback(taskData); })) { + react_native_log_warn( + "Node-API: dropping a thread-safe function call posted after runtime teardown"); + } +} + +void NodeApiHost::fatalException( + void* data, + napi_env env, + napi_value error) noexcept { + // Runs synchronously on the JS thread. + auto* self = static_cast(data); + if (self->inFatalException_) { + abortWithFatalException(env, error); + } + self->inFatalException_ = true; + bool reported = tryReportFatalError(env, error); + self->inFatalException_ = false; + if (!reported) { + abortWithFatalException(env, error); + } +} + +} // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/nativemodule/nodeapi/NodeApiHost.h b/packages/react-native/ReactCommon/react/nativemodule/nodeapi/NodeApiHost.h new file mode 100644 index 000000000000..94e3f142b732 --- /dev/null +++ b/packages/react-native/ReactCommon/react/nativemodule/nodeapi/NodeApiHost.h @@ -0,0 +1,69 @@ +/* + * 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 { + +/** + * The host integration a Node-API engine needs from React Native: a worker + * pool for napi_queue_async_work, dispatch onto the JS thread for thread-safe + * functions, and handling of napi_fatal_exception. The callbacks have C + * signatures so an engine adapter can hand them to its embedder API, passing + * the host as their `data`. + * + * One instance exists per React Native runtime. + */ +class NodeApiHost : public std::enable_shared_from_this { + public: + /** + * Dispatches a function onto the JS thread, returning whether it was + * accepted. Must be callable from any thread, must never run the function + * inline, and must deliver accepted functions in order. Returning false is + * only acceptable once the runtime is gone. + */ + using JsDispatcher = std::function &&)>; + + static std::shared_ptr create(JsDispatcher dispatchToJs); + + /** + * Engines may read the host after the last env cleanup hook has run, so no + * hook can tell when a host is no longer used. + */ + static void retainForProcessLifetime(std::shared_ptr host); + + bool dispatchToJs(std::function &&fn) + { + return dispatchToJs_(std::move(fn)); + } + + static void postWork( + void *data, + void *workData, + void (*execute)(void *workData), + void (*complete)(void *workData, napi_status status)) noexcept; + static bool cancelWork(void *data, void *workData) noexcept; + static void postTask(void *data, void *taskData, void (*callback)(void *taskData)) noexcept; + static void fatalException(void *data, napi_env env, napi_value error) noexcept; + + // The callbacks cast `data` back to the host, so it must not be copied. + NodeApiHost(const NodeApiHost &) = delete; + NodeApiHost &operator=(const NodeApiHost &) = delete; + + private: + explicit NodeApiHost(JsDispatcher dispatchToJs); + + JsDispatcher dispatchToJs_; + bool inFatalException_ = false; +}; + +} // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/nativemodule/nodeapi/React-nodeapinativemodule.podspec b/packages/react-native/ReactCommon/react/nativemodule/nodeapi/React-nodeapinativemodule.podspec new file mode 100644 index 000000000000..648c94b6ef65 --- /dev/null +++ b/packages/react-native/ReactCommon/react/nativemodule/nodeapi/React-nodeapinativemodule.podspec @@ -0,0 +1,54 @@ +# 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. + +require "json" + +package = JSON.parse(File.read(File.join(__dir__, "..", "..", "..", "..", "package.json"))) +version = package['version'] + +source = { :git => 'https://github.com/facebook/react-native.git' } +if version == '1000.0.0' + # This is an unpublished version, use the latest commit hash of the react-native repo, which we’re presumably in. + source[:commit] = `git rev-parse HEAD`.strip if system("git rev-parse --git-dir > /dev/null 2>&1") +else + source[:tag] = "v#{version}" +end + +header_search_paths = ["\"$(PODS_ROOT)/Headers/Public/hermes-engine/hermes/napi\""] + +if ENV['USE_FRAMEWORKS'] + header_search_paths << "\"$(PODS_TARGET_SRCROOT)/../../..\"" # this is needed to allow the nodeapi module access its own files +end + +Pod::Spec.new do |s| + s.name = "React-nodeapinativemodule" + s.version = version + s.summary = "React Native Node-API native module" + s.homepage = "https://reactnative.dev/" + s.license = package["license"] + s.author = "Meta Platforms, Inc. and its affiliates" + s.platforms = min_supported_versions + s.source = source + s.source_files = podspec_sources("*.{cpp,h}", "*.h") + s.header_dir = "react/nativemodule/nodeapi" + s.pod_target_xcconfig = { "CLANG_CXX_LANGUAGE_STANDARD" => rct_cxx_language_standard(), + "OTHER_CFLAGS" => "$(inherited)", + "HEADER_SEARCH_PATHS" => header_search_paths.join(' '), + "DEFINES_MODULE" => "YES" } + + resolve_use_frameworks(s, header_mappings_dir: "../..", module_name: "React_nodeapinativemodule") + + s.dependency "React-jsi" + s.dependency "React-logger" + s.dependency "hermes-engine" + add_rn_third_party_dependencies(s) + add_rncore_dependency(s) + + s.dependency "ReactCommon/turbomodule/core" + s.dependency "React-cxxstableapi" + add_dependency(s, "React-RCTFBReactNativeSpec") + + mark_as_react_native_build(s) +end diff --git a/packages/react-native/ReactCommon/react/nativemodule/nodeapi/__docs__/README.md b/packages/react-native/ReactCommon/react/nativemodule/nodeapi/__docs__/README.md new file mode 100644 index 000000000000..c7fdd6cb7fdb --- /dev/null +++ b/packages/react-native/ReactCommon/react/nativemodule/nodeapi/__docs__/README.md @@ -0,0 +1,75 @@ +# Node-API + +[🏠 Home](../../../../../../../__docs__/README.md) + +This directory contains the native module behind `react-native/node-api`, which +loads [Node-API](https://nodejs.org/api/n-api.html) addons into the JS runtime +using the engine's own Node-API implementation. + +## πŸš€ Usage + +```js +import {dlopen} from 'react-native/node-api'; + +const module = {exports: {}}; +dlopen(module, 'libmy_addon.so'); // Android +dlopen(module, '@rpath/my_addon.framework/my_addon'); // iOS +module.exports.hello(); +``` + +`dlopen` behaves like Node's +[`process.dlopen`](https://nodejs.org/api/process.html#processdlopenmodule-filename-flags): +it sets `module.exports` and caches nothing. `filename` is passed to the +system's `dlopen` as is, so how addons are named, laid out and cached is up to +the caller. + +## πŸ“ Design + +- `NativeNodeApi` is the `NativeNodeApiCxx` TurboModule. Each `dlopen` call + creates a new `napi_env`, loads the addon into it and hands its exports to + JSI. +- `NodeApiEngine` is the part that depends on the engine's embedder API: + creating an env and loading an addon. `HermesNodeApiEngine` implements it with + `hermes_napi_create_env` and `hermes_napi_load_module`. +- `NodeApiHost` is what the engine needs from React Native: a worker pool for + `napi_queue_async_work`, dispatch onto the JS thread (through the + `CallInvoker`) for thread-safe functions, and routing `napi_fatal_exception` + to `ErrorUtils.reportFatalError`. + +`HermesInstance` registers the module with `registerCxxModuleToGlobalModuleMap` +when it creates a runtime, so it's only available when Hermes is the engine. +Otherwise `react-native/node-api` throws. + +Each env is owned by its runtime and torn down with it, which runs the addon's +env cleanup hooks. Hermes can read the host's callbacks during that teardown, so +hosts are kept alive for the lifetime of the process. + +Addons compile against the Node-API headers Hermes vendors from Node.js +(`include/hermes/napi`) and link against the engine. The engine's embedder API +(`hermes_napi.h`) is only used here, not by addons. + +## βš™οΈ Enabling Node-API + +Node-API is off by default. To build React Native with it: + +- Android: `nodeApiEnabled=true` (or `react.nodeApiEnabled=true`) as a Gradle + property. +- iOS: `RCT_NODE_API_ENABLED=1` when running `pod install`, or when resolving + the Swift package. + +This defines `RCT_NODE_API_ENABLED`, which compiles in the module and its +registration. A Hermes built from source gets `HERMES_ENABLE_NAPI` to match. It +only takes effect when React Native is built from source: the prebuilt React +Native artifacts don't include Node-API. `react-native/node-api` throws in apps +built without it. + +## πŸ”— Relationship with other systems + +### Part of + +- Native Modules / TurboModules + +### Uses this + +- RNTester's Node-API example (`packages/rn-tester/NodeApiExample`, see its + [README](../../../../../../rn-tester/NodeApiExample/README.md)) diff --git a/packages/react-native/ReactCommon/react/runtime/React-RuntimeHermes.podspec b/packages/react-native/ReactCommon/react/runtime/React-RuntimeHermes.podspec index 8879807588f8..518c870f6317 100644 --- a/packages/react-native/ReactCommon/react/runtime/React-RuntimeHermes.podspec +++ b/packages/react-native/ReactCommon/react/runtime/React-RuntimeHermes.podspec @@ -27,7 +27,7 @@ Pod::Spec.new do |s| s.source = source s.source_files = podspec_sources("hermes/*.{cpp,h}", "hermes/*.h") s.header_dir = "react/runtime/hermes" - s.pod_target_xcconfig = { "HEADER_SEARCH_PATHS" => "\"${PODS_TARGET_SRCROOT}/../..\" \"${PODS_TARGET_SRCROOT}/../../hermes/executor\"", + s.pod_target_xcconfig = { "HEADER_SEARCH_PATHS" => "\"${PODS_TARGET_SRCROOT}/../..\" \"${PODS_TARGET_SRCROOT}/../../hermes/executor\" \"$(PODS_ROOT)/Headers/Public/hermes-engine/hermes/napi\"", "USE_HEADERMAP" => "YES", "CLANG_CXX_LANGUAGE_STANDARD" => rct_cxx_language_standard(), "GCC_WARN_PEDANTIC" => "YES" } @@ -37,6 +37,7 @@ Pod::Spec.new do |s| s.dependency "React-cxxstableapi" s.dependency "React-jsitracing" s.dependency "React-jsi" + s.dependency "React-logger" s.dependency "React-utils" s.dependency "React-RuntimeCore" s.dependency "React-featureflags" @@ -45,6 +46,7 @@ Pod::Spec.new do |s| add_dependency(s, "React-jsinspectortracing", :framework_name => 'jsinspector_moderntracing') s.dependency "React-hermes" + s.dependency "React-nodeapinativemodule" if ENV['RCT_NODE_API_ENABLED'] == '1' s.dependency "hermes-engine" add_dependency(s, "React-runtimeexecutor", :additional_framework_paths => ["platform/ios"]) add_dependency(s, "React-jsitooling", :framework_name => "JSITooling") diff --git a/packages/react-native/ReactCommon/react/runtime/hermes/CMakeLists.txt b/packages/react-native/ReactCommon/react/runtime/hermes/CMakeLists.txt index d9916606029f..691de22e9109 100644 --- a/packages/react-native/ReactCommon/react/runtime/hermes/CMakeLists.txt +++ b/packages/react-native/ReactCommon/react/runtime/hermes/CMakeLists.txt @@ -25,9 +25,13 @@ target_link_libraries(bridgelesshermes jsi jsitooling jsinspector + logger react_cxxstableapi ${reactnative} ) +if(RCT_NODE_API_ENABLED) + target_link_libraries(bridgelesshermes react_nativemodule_nodeapi) +endif() target_compile_reactnative_options(bridgelesshermes PRIVATE) if(${CMAKE_BUILD_TYPE} MATCHES Debug OR REACT_NATIVE_DEBUG_OPTIMIZED) diff --git a/packages/react-native/ReactCommon/react/runtime/hermes/HermesInstance.cpp b/packages/react-native/ReactCommon/react/runtime/hermes/HermesInstance.cpp index d843a8338aeb..b62f5d7ba374 100644 --- a/packages/react-native/ReactCommon/react/runtime/hermes/HermesInstance.cpp +++ b/packages/react-native/ReactCommon/react/runtime/hermes/HermesInstance.cpp @@ -10,13 +10,32 @@ #include #include #include +#include #include +#ifdef RCT_NODE_API_ENABLED +#include "HermesNodeApiEngine.h" + +#include +#include +#endif + +#include + using namespace facebook::hermes; using namespace facebook::jsi; namespace facebook::react { +static void hermesFatalHandler(const std::string& reason) { + react_native_log_fatal(reason.c_str()); +} + +static constinit std::once_flag fatalHandlerFlag; +#ifdef RCT_NODE_API_ENABLED +static constinit std::once_flag nodeApiFlag; +#endif + class HermesJSRuntime : public JSRuntime { public: HermesJSRuntime( @@ -53,6 +72,25 @@ std::unique_ptr HermesInstance::createJSRuntime( bool allocInOldGenBeforeTTI) noexcept { assert(msgQueueThread != nullptr); + std::call_once(fatalHandlerFlag, []() { + auto* fatalHandlerInterface = + jsi::castInterface(makeHermesRootAPI()); + if (fatalHandlerInterface) { + fatalHandlerInterface->setFatalHandler(hermesFatalHandler); + } + }); + +#ifdef RCT_NODE_API_ENABLED + std::call_once(nodeApiFlag, []() { + registerCxxModuleToGlobalModuleMap( + std::string(NativeNodeApi::kModuleName), + [](std::shared_ptr jsInvoker) { + return std::make_shared( + std::make_shared(), std::move(jsInvoker)); + }); + }); +#endif + auto gcConfig = ::hermes::vm::GCConfig::Builder() // Default to 3GB .withMaxHeapSize(3072 << 20) diff --git a/packages/react-native/ReactCommon/react/runtime/hermes/HermesNodeApiEngine.cpp b/packages/react-native/ReactCommon/react/runtime/hermes/HermesNodeApiEngine.cpp new file mode 100644 index 000000000000..a351ee56a5f3 --- /dev/null +++ b/packages/react-native/ReactCommon/react/runtime/hermes/HermesNodeApiEngine.cpp @@ -0,0 +1,51 @@ +/* + * 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. + */ + +#ifdef RCT_NODE_API_ENABLED + +#include "HermesNodeApiEngine.h" + +#include +#include +#include + +namespace facebook::react { + +napi_env HermesNodeApiEngine::createEnv( + jsi::Runtime& runtime, + NodeApiHost& host) { + auto* hermes = jsi::castInterface(&runtime); + if (hermes == nullptr) { + return nullptr; + } + if (hermesHost_ == nullptr) { + // Leaked: Hermes reads the struct during runtime teardown, after this + // engine is gone. The host is retained for the same reason. + hermesHost_ = new hermes_napi_host{ + .post_work = &NodeApiHost::postWork, + .cancel_work = &NodeApiHost::cancelWork, + .post_task = &NodeApiHost::postTask, + .data = &host, + .uv_loop = nullptr, + .fatal_exception = &NodeApiHost::fatalException, + .ref_loop = nullptr, + .unref_loop = nullptr, + }; + } + return hermes_napi_create_env(hermes->getVMRuntimeUnsafe(), hermesHost_); +} + +napi_status HermesNodeApiEngine::loadAddon( + napi_env env, + const char* filename, + napi_value* result) { + return hermes_napi_load_module(env, filename, result); +} + +} // namespace facebook::react + +#endif // RCT_NODE_API_ENABLED diff --git a/packages/react-native/ReactCommon/react/runtime/hermes/HermesNodeApiEngine.h b/packages/react-native/ReactCommon/react/runtime/hermes/HermesNodeApiEngine.h new file mode 100644 index 000000000000..3d5375310d22 --- /dev/null +++ b/packages/react-native/ReactCommon/react/runtime/hermes/HermesNodeApiEngine.h @@ -0,0 +1,29 @@ +/* + * 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 + +#ifdef RCT_NODE_API_ENABLED + +#include + +struct hermes_napi_host; + +namespace facebook::react { + +class HermesNodeApiEngine : public NodeApiEngine { + public: + napi_env createEnv(jsi::Runtime &runtime, NodeApiHost &host) override; + napi_status loadAddon(napi_env env, const char *filename, napi_value *result) override; + + private: + hermes_napi_host *hermesHost_ = nullptr; +}; + +} // namespace facebook::react + +#endif // RCT_NODE_API_ENABLED diff --git a/packages/react-native/__typetests__/node-api.ts b/packages/react-native/__typetests__/node-api.ts new file mode 100644 index 000000000000..2c72b9ffd414 --- /dev/null +++ b/packages/react-native/__typetests__/node-api.ts @@ -0,0 +1,19 @@ +/** + * 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. + * + * @format + */ + +import {dlopen} from 'react-native/node-api'; + +const module: {exports: unknown} = {exports: {}}; +dlopen(module, 'libaddon.so'); + +// @ts-expect-error filename is required +dlopen(module); + +// @ts-expect-error module must have exports +dlopen({}, 'libaddon.so'); diff --git a/packages/react-native/package.json b/packages/react-native/package.json index 5142e53d0a71..561499973538 100644 --- a/packages/react-native/package.json +++ b/packages/react-native/package.json @@ -49,6 +49,10 @@ "types": null, "default": "./src/asset-registry.js" }, + "./node-api": { + "types": "./src/node-api.d.ts", + "default": "./src/node-api.js" + }, "./react-private-interface": { "types": null, "default": "./src/react-private-interface.js" diff --git a/packages/react-native/scripts/cocoapods/__tests__/utils-test.rb b/packages/react-native/scripts/cocoapods/__tests__/utils-test.rb index 2f338430009c..f928d02c1147 100644 --- a/packages/react-native/scripts/cocoapods/__tests__/utils-test.rb +++ b/packages/react-native/scripts/cocoapods/__tests__/utils-test.rb @@ -41,6 +41,7 @@ def teardown ENV['RCT_REMOVE_LEGACY_COMPONENT_INTEROP'] = nil ENV['RN_STRICT_API'] = nil ENV['RN_ALLOW_FRAMEWORKS'] = nil + ENV['RCT_NODE_API_ENABLED'] = nil system_reset_commands $RN_PLATFORMS = nil end @@ -615,6 +616,76 @@ def test_setLegacyInteropRemovalFlags_whenEnabledWithPrebuiltRNCore_warns assert(Pod::UI.collected_warns.first.include?("RCT_USE_PREBUILT_RNCORE=0")) end + # ==================================== # + # Test - Set Node-API flags # + # ==================================== # + + def assert_node_api_flag(user_project_mock, enabled:) + user_project_mock.build_configurations.each do |config| + ["OTHER_CFLAGS", "OTHER_CPLUSPLUSFLAGS"].each do |key| + assert_equal(enabled, config.build_settings[key].include?("-DRCT_NODE_API_ENABLED=1")) + end + assert_equal(enabled ? '1' : nil, config.build_settings['RCT_NODE_API_ENABLED']) + end + end + + def test_setNodeApiFlags_whenUnset_doesNotAddFlag + # Arrange + user_project_mock = prepare_empty_user_project_mock() + installer = legacy_interop_installer_mock(user_project_mock) + + # Act + ReactNativePodsUtils.set_node_api_flags(installer, build_rncore_from_source: true) + + # Assert + assert_node_api_flag(user_project_mock, enabled: false) + assert_equal([], Pod::UI.collected_warns) + end + + def test_setNodeApiFlags_whenEnabled_addsFlag + # Arrange + ENV['RCT_NODE_API_ENABLED'] = '1' + user_project_mock = prepare_empty_user_project_mock() + installer = legacy_interop_installer_mock(user_project_mock) + + # Act + ReactNativePodsUtils.set_node_api_flags(installer, build_rncore_from_source: true) + + # Assert + assert_node_api_flag(user_project_mock, enabled: true) + assert_equal([], Pod::UI.collected_warns) + end + + def test_setNodeApiFlags_whenDisabledAgain_removesPreviouslyAddedFlag + # Arrange + ENV['RCT_NODE_API_ENABLED'] = '1' + user_project_mock = prepare_empty_user_project_mock() + installer = legacy_interop_installer_mock(user_project_mock) + ReactNativePodsUtils.set_node_api_flags(installer, build_rncore_from_source: true) + + # Act + ENV['RCT_NODE_API_ENABLED'] = '0' + ReactNativePodsUtils.set_node_api_flags(installer, build_rncore_from_source: true) + + # Assert + assert_node_api_flag(user_project_mock, enabled: false) + end + + def test_setNodeApiFlags_whenEnabledWithPrebuiltRNCore_warns + # Arrange + ENV['RCT_NODE_API_ENABLED'] = '1' + user_project_mock = prepare_empty_user_project_mock() + installer = legacy_interop_installer_mock(user_project_mock) + + # Act + ReactNativePodsUtils.set_node_api_flags(installer, build_rncore_from_source: false) + + # Assert + assert_equal(1, Pod::UI.collected_warns.length) + assert(Pod::UI.collected_warns.first.include?("RCT_NODE_API_ENABLED=1")) + assert(Pod::UI.collected_warns.first.include?("RCT_USE_PREBUILT_RNCORE=0")) + end + # ==================================== # # Test - Set strict C++ API flags # # ==================================== # diff --git a/packages/react-native/scripts/cocoapods/runtime.rb b/packages/react-native/scripts/cocoapods/runtime.rb index c2833d839c1e..8cd76074227b 100644 --- a/packages/react-native/scripts/cocoapods/runtime.rb +++ b/packages/react-native/scripts/cocoapods/runtime.rb @@ -14,5 +14,8 @@ def setup_bridgeless!(react_native_path: "../node_modules/react-native", use_her pod 'React-RuntimeApple', :path => "#{react_native_path}/ReactCommon/react/runtime/platform/ios" if use_hermes pod 'React-RuntimeHermes', :path => "#{react_native_path}/ReactCommon/react/runtime" + if ENV['RCT_NODE_API_ENABLED'] == '1' + pod 'React-nodeapinativemodule', :path => "#{react_native_path}/ReactCommon/react/nativemodule/nodeapi" + end end end diff --git a/packages/react-native/scripts/cocoapods/utils.rb b/packages/react-native/scripts/cocoapods/utils.rb index 6473278a2e7e..41ded188d8b4 100644 --- a/packages/react-native/scripts/cocoapods/utils.rb +++ b/packages/react-native/scripts/cocoapods/utils.rb @@ -608,6 +608,28 @@ def self.set_legacy_interop_removal_flags(installer, build_rncore_from_source:) end end + # Also set as a build setting, which Xcode passes to script phases such as + # the Hermes build and the app's own. + def self.set_node_api_flags(installer, build_rncore_from_source:) + enabled = ENV['RCT_NODE_API_ENABLED'] == '1' + self.extract_projects(installer).each do |project| + project.build_configurations.each do |config| + if enabled + self.set_flag_in_config(config, "-DRCT_NODE_API_ENABLED=1") + config.build_settings['RCT_NODE_API_ENABLED'] = '1' + else + self.remove_flag_in_config(config, "-DRCT_NODE_API_ENABLED=1") + config.build_settings.delete('RCT_NODE_API_ENABLED') + end + end + project.save() + end + if enabled && !build_rncore_from_source + Pod::UI.warn("RCT_NODE_API_ENABLED=1 has no effect on the prebuilt React.xcframework. " \ + "Set RCT_USE_PREBUILT_RNCORE=0 to compile React Native with Node-API.") + end + end + # The macros reach every target that inherits the project settings: the app, # third-party pods and app codegen. React Native's own pods define RN_BUILDING, # which keeps the guards inert for them. diff --git a/packages/react-native/scripts/react_native_pods.rb b/packages/react-native/scripts/react_native_pods.rb index f8e3f7d729d7..8c93d9ff0b3c 100644 --- a/packages/react-native/scripts/react_native_pods.rb +++ b/packages/react-native/scripts/react_native_pods.rb @@ -122,6 +122,8 @@ def use_react_native! ( # layers are still supported. Both will default to 1 in a future React Native release. ENV['RCT_REMOVE_LEGACY_MODULE_INTEROP'] = ENV['RCT_REMOVE_LEGACY_MODULE_INTEROP'] == '1' ? '1' : '0' ENV['RCT_REMOVE_LEGACY_COMPONENT_INTEROP'] = ENV['RCT_REMOVE_LEGACY_COMPONENT_INTEROP'] == '1' ? '1' : '0' + # Building React Native and Hermes with Node-API is opt-in. + ENV['RCT_NODE_API_ENABLED'] = ENV['RCT_NODE_API_ENABLED'] == '1' ? '1' : '0' # Enforcing the C++ stable API is opt-in. ENV['RN_STRICT_API'] = ENV['RN_STRICT_API'] == '1' ? '1' : '0' # Silences the stable API's "for frameworks" warning. @@ -635,6 +637,11 @@ def react_native_post_install( build_rncore_from_source: ReactNativeCoreUtils.build_rncore_from_source() ) + ReactNativePodsUtils.set_node_api_flags( + installer, + build_rncore_from_source: ReactNativeCoreUtils.build_rncore_from_source() + ) + ReactNativePodsUtils.set_strict_cxx_api_flags(installer) ReactNativePodsUtils.set_ccache_compiler_and_linker_build_settings(installer, react_native_path, ccache_enabled) diff --git a/packages/react-native/sdks/.hermesv1version b/packages/react-native/sdks/.hermesv1version new file mode 100644 index 000000000000..d1aaf93f188b --- /dev/null +++ b/packages/react-native/sdks/.hermesv1version @@ -0,0 +1 @@ +51ae2a08ce8cb06fcfa718cddab9c94ea9eee7f6 \ No newline at end of file diff --git a/packages/react-native/sdks/hermes-engine/hermes-engine.podspec b/packages/react-native/sdks/hermes-engine/hermes-engine.podspec index b6775c1dbfaa..ede30a1e0ae8 100644 --- a/packages/react-native/sdks/hermes-engine/hermes-engine.podspec +++ b/packages/react-native/sdks/hermes-engine/hermes-engine.podspec @@ -134,6 +134,20 @@ Pod::Spec.new do |spec| ss.header_dir = 'hermes/Public' end + # hermes_napi.h: Hermes' embedder API, used by React Native's Node-API host. + spec.subspec 'NodeApiEmbedder' do |ss| + ss.source_files = '' + ss.public_header_files = 'API/napi/hermes_napi.h' + ss.header_dir = 'napi' + end + + # node_api.h and friends: the Node-API headers Hermes vendors from Node.js, for addons. + spec.subspec 'NodeApi' do |ss| + ss.source_files = '' + ss.public_header_files = 'include/hermes/napi/*.h' + ss.header_dir = 'hermes/napi' + end + hermesc_path = "${PODS_ROOT}/hermes-engine/build_host_hermesc" if ENV.has_key?('HERMES_OVERRIDE_HERMESC_PATH') && File.exist?(ENV['HERMES_OVERRIDE_HERMESC_PATH']) then diff --git a/packages/react-native/sdks/hermes-engine/utils/build-hermes-xcode.sh b/packages/react-native/sdks/hermes-engine/utils/build-hermes-xcode.sh index 60506d160e5c..e1f785c2adb5 100755 --- a/packages/react-native/sdks/hermes-engine/utils/build-hermes-xcode.sh +++ b/packages/react-native/sdks/hermes-engine/utils/build-hermes-xcode.sh @@ -64,6 +64,11 @@ if [[ $PLATFORM_NAME == "catalyst" ]]; then boost_context_flag="-DHERMES_ALLOW_BOOST_CONTEXT=0" fi +enable_napi="false" +if [[ "${RCT_NODE_API_ENABLED:-0}" == "1" ]]; then + enable_napi="true" +fi + architectures=$( echo "$ARCHS" | tr " " ";" ) echo "Configure Apple framework" @@ -77,6 +82,7 @@ echo "Configure Apple framework" -DCMAKE_OSX_DEPLOYMENT_TARGET:STRING="$deployment_target" \ -DHERMES_ENABLE_DEBUGGER:BOOLEAN="$enable_debugger" \ -DHERMES_ENABLE_INTL:BOOLEAN=true \ + -DHERMES_ENABLE_NAPI:BOOLEAN="$enable_napi" \ -DHERMES_ENABLE_LIBFUZZER:BOOLEAN=false \ -DHERMES_ENABLE_FUZZILLI:BOOLEAN=false \ -DHERMES_ENABLE_TEST_SUITE:BOOLEAN=false \ diff --git a/packages/react-native/src/node-api.d.ts b/packages/react-native/src/node-api.d.ts new file mode 100644 index 000000000000..2f51f7613da2 --- /dev/null +++ b/packages/react-native/src/node-api.d.ts @@ -0,0 +1,35 @@ +/** + * 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. + * + * @format + */ + +// ---------------------------------------------------------------------------- +// Types entry point for react-native/node-api. +// +// IMPORTANT: Keep this file in sync with node-api.js. +// ---------------------------------------------------------------------------- + +/** + * Loads a Node-API addon into the calling JavaScript runtime, like Node's + * `process.dlopen()`. + * + * Opens the shared library at `filename`, calls the addon's module + * initializer with a new `napi_env` and an empty exports object, and sets + * `module.exports` to the value it returns. Nothing is cached: loading the + * same addon again initializes it again, in a new env. Each env is torn down + * with the runtime, which runs the addon's env cleanup hooks. + * + * @param module An object whose `exports` property is set to the addon's + * exports. + * @param filename Passed to the system's `dlopen()` as is, for example + * `libmy_addon.so` on Android or `@rpath/my_addon.framework/my_addon` on + * Apple platforms. + * @throws If the JavaScript engine doesn't implement Node-API, if React + * Native was built without it, or if the addon can't be loaded or throws + * while initializing. + */ +export function dlopen(module: {exports: unknown}, filename: string): void; diff --git a/packages/react-native/src/node-api.js b/packages/react-native/src/node-api.js new file mode 100644 index 000000000000..b7b5529045ba --- /dev/null +++ b/packages/react-native/src/node-api.js @@ -0,0 +1,47 @@ +/** + * 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. + * + * @flow strict + * @format + */ + +// ---------------------------------------------------------------------------- +// react-native/node-api +// +// Loads Node-API addons, like Node's `process.dlopen`. +// ---------------------------------------------------------------------------- + +import NativeNodeApi from './private/node-api/specs/NativeNodeApi'; + +/** + * Loads a Node-API addon into the calling JavaScript runtime, like Node's + * `process.dlopen()`. + * + * Opens the shared library at `filename`, calls the addon's module + * initializer with a new `napi_env` and an empty exports object, and sets + * `module.exports` to the value it returns. Nothing is cached: loading the + * same addon again initializes it again, in a new env. Each env is torn down + * with the runtime, which runs the addon's env cleanup hooks. + * + * @param module An object whose `exports` property is set to the addon's + * exports. + * @param filename Passed to the system's `dlopen()` as is, for example + * `libmy_addon.so` on Android or `@rpath/my_addon.framework/my_addon` on + * Apple platforms. + * @throws If the JavaScript engine doesn't implement Node-API, if React + * Native was built without it, or if the addon can't be loaded or throws + * while initializing. + */ +export function dlopen(module: {exports: unknown, ...}, filename: string) { + if (NativeNodeApi == null) { + throw new Error( + global.HermesInternal == null + ? 'Node-API is not available: the JavaScript engine does not implement it' + : 'Node-API is not available: React Native was built without it. Enable it with the Gradle property `nodeApiEnabled=true` (Android) or `RCT_NODE_API_ENABLED=1` (iOS), and build React Native from source', + ); + } + NativeNodeApi.dlopen(module, filename); +} diff --git a/packages/react-native/src/private/node-api/specs/NativeNodeApi.js b/packages/react-native/src/private/node-api/specs/NativeNodeApi.js new file mode 100644 index 000000000000..1a642c2c1e04 --- /dev/null +++ b/packages/react-native/src/private/node-api/specs/NativeNodeApi.js @@ -0,0 +1,21 @@ +/** + * 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. + * + * @flow strict + * @format + */ + +import type {TurboModule} from '../../../../Libraries/TurboModule/RCTExport'; +import type {UnsafeObject} from '../../../../Libraries/Types/CodegenTypes'; + +import * as TurboModuleRegistry from '../../../../Libraries/TurboModule/TurboModuleRegistry'; + +export interface Spec extends TurboModule { + readonly dlopen: (module: UnsafeObject, filename: string) => void; +} + +// Only provided by JavaScript engines that implement Node-API. +export default TurboModuleRegistry.get('NativeNodeApiCxx') as ?Spec; diff --git a/packages/rn-tester/NodeApiExample/.maestro/node-api.yml b/packages/rn-tester/NodeApiExample/.maestro/node-api.yml new file mode 100644 index 000000000000..36ddb3e08583 --- /dev/null +++ b/packages/rn-tester/NodeApiExample/.maestro/node-api.yml @@ -0,0 +1,61 @@ +appId: ${APP_ID} # iOS: com.meta.RNTester.localDevelopment | Android: com.facebook.react.uiapp +# Not run in CI, which builds RNTester without Node-API. See ../README.md to run it. +--- +- launchApp +- extendedWaitUntil: + visible: 'Components' + timeout: 30000 +- tapOn: 'APIs' +- scrollUntilVisible: + element: + id: 'Node-API' + direction: DOWN + speed: 40 +- tapOn: + id: 'Node-API' +- tapOn: + id: 'node-api-hello' +- assertVisible: 'hello\(\) β†’ "Hello from Node-API"' +- tapOn: + id: 'node-api-add' +- assertVisible: 'add\(2, 3\) β†’ 5' +- tapOn: + id: 'node-api-square-async' +- extendedWaitUntil: + visible: 'squareAsync\(7\) β†’ 49' + timeout: 5000 +- tapOn: + id: 'node-api-tick-from-thread' +- extendedWaitUntil: + visible: 'tick 3 from thread' + timeout: 5000 +- tapOn: + id: 'node-api-env-stats' +- assertVisible: 'envStats\(\) β†’ \{"created":1,"cleanedUp":0\}' +# A reload tears down the runtime, which should run the env's cleanup hook. +- tapOn: + id: 'node-api-reload' +# The log is cleared once the new runtime renders. +- extendedWaitUntil: + notVisible: 'envStats\(\) β†’ .*' + timeout: 30000 +- extendedWaitUntil: + visible: 'APIs' + timeout: 30000 +# iOS returns to the landing screen after a reload, Android doesn't. +- runFlow: + when: + notVisible: + id: 'node-api-env-stats' + commands: + - tapOn: 'APIs' + - scrollUntilVisible: + element: + id: 'Node-API' + direction: DOWN + speed: 40 + - tapOn: + id: 'Node-API' +- tapOn: + id: 'node-api-env-stats' +- assertVisible: 'envStats\(\) β†’ \{"created":2,"cleanedUp":1\}' diff --git a/packages/rn-tester/NodeApiExample/CMakeLists.txt b/packages/rn-tester/NodeApiExample/CMakeLists.txt new file mode 100644 index 000000000000..5793c9364932 --- /dev/null +++ b/packages/rn-tester/NodeApiExample/CMakeLists.txt @@ -0,0 +1,19 @@ +# 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. + +cmake_minimum_required(VERSION 3.13) +set(CMAKE_VERBOSE_MAKEFILE on) + +# Loaded at runtime with `dlopen` from react-native/node-api, so it's not +# linked into the app. +add_library(nodeapiexample SHARED addon.c) + +# TODO: Link node-api::node-api once Hermes ships it, instead of hermesvm. +find_package(hermes-engine REQUIRED CONFIG) +get_target_property(hermes_include_dirs hermes-engine::hermesvm INTERFACE_INCLUDE_DIRECTORIES) +list(TRANSFORM hermes_include_dirs APPEND /hermes/napi) +target_include_directories(nodeapiexample PRIVATE ${hermes_include_dirs}) +target_link_libraries(nodeapiexample hermes-engine::hermesvm) +target_compile_options(nodeapiexample PRIVATE -Wall -Werror) diff --git a/packages/rn-tester/NodeApiExample/README.md b/packages/rn-tester/NodeApiExample/README.md new file mode 100644 index 000000000000..d365308266d9 --- /dev/null +++ b/packages/rn-tester/NodeApiExample/README.md @@ -0,0 +1,27 @@ +# Node-API example + +The addon behind RNTester's Node-API example (`js/examples/NodeApi/NodeApiExample.js`). RNTester builds React Native without Node-API by default, so the example shows an error until you enable it. + +## Building with Node-API + +On Android: + +```sh +./gradlew :packages:rn-tester:android:app:installDebug -PnodeApiEnabled=true +``` + +On iOS, from `packages/rn-tester`, then build RNTester as usual: + +```sh +RCT_NODE_API_ENABLED=1 HERMES_COMMIT=$(cat ../react-native/sdks/.hermesv1version) pod install +``` + +## End-to-end test + +`.maestro/node-api.yml` covers a sync call, async work, a thread-safe function and env cleanup across a reload. CI builds RNTester without Node-API and doesn't run it, so run it locally against a build with Node-API enabled: + +```sh +maestro test -e APP_ID=com.facebook.react.uiapp packages/rn-tester/NodeApiExample/.maestro/node-api.yml +``` + +Use `com.meta.RNTester.localDevelopment` as the `APP_ID` on iOS. diff --git a/packages/rn-tester/NodeApiExample/addon.c b/packages/rn-tester/NodeApiExample/addon.c new file mode 100644 index 000000000000..a2e0f34c2dc5 --- /dev/null +++ b/packages/rn-tester/NodeApiExample/addon.c @@ -0,0 +1,194 @@ +/* + * 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 + +#define CHECK(call) \ + do { \ + if ((call) != napi_ok) { \ + napi_throw_error(env, NULL, #call " failed"); \ + return NULL; \ + } \ + } while (0) + +// Process-global, so they survive a reload and show whether the previous +// runtime's envs were cleaned up. +static atomic_int envsCreated = 0; +static atomic_int envsCleanedUp = 0; + +static void onEnvCleanup(void *arg) { + (void)arg; + atomic_fetch_add(&envsCleanedUp, 1); +} + +static napi_value Hello(napi_env env, napi_callback_info info) { + (void)info; + napi_value result; + CHECK(napi_create_string_utf8( + env, "Hello from Node-API", NAPI_AUTO_LENGTH, &result)); + return result; +} + +static napi_value Add(napi_env env, napi_callback_info info) { + size_t argc = 2; + napi_value argv[2]; + CHECK(napi_get_cb_info(env, info, &argc, argv, NULL, NULL)); + double a = 0; + double b = 0; + CHECK(napi_get_value_double(env, argv[0], &a)); + CHECK(napi_get_value_double(env, argv[1], &b)); + napi_value result; + CHECK(napi_create_double(env, a + b, &result)); + return result; +} + +typedef struct { + double input; + double output; + napi_deferred deferred; + napi_async_work work; +} SquareWork; + +static void squareExecute(napi_env env, void *data) { + (void)env; + SquareWork *work = data; + work->output = work->input * work->input; +} + +static void squareComplete(napi_env env, napi_status status, void *data) { + SquareWork *work = data; + napi_value result; + if (status == napi_ok && + napi_create_double(env, work->output, &result) == napi_ok) { + napi_resolve_deferred(env, work->deferred, result); + } else { + napi_value message; + napi_value error; + napi_create_string_utf8( + env, "Async work failed", NAPI_AUTO_LENGTH, &message); + napi_create_error(env, NULL, message, &error); + napi_reject_deferred(env, work->deferred, error); + } + napi_delete_async_work(env, work->work); + free(work); +} + +static napi_value SquareAsync(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value argv[1]; + CHECK(napi_get_cb_info(env, info, &argc, argv, NULL, NULL)); + SquareWork *work = calloc(1, sizeof(SquareWork)); + CHECK(napi_get_value_double(env, argv[0], &work->input)); + napi_value promise; + CHECK(napi_create_promise(env, &work->deferred, &promise)); + napi_value name; + CHECK(napi_create_string_utf8(env, "squareAsync", NAPI_AUTO_LENGTH, &name)); + CHECK(napi_create_async_work( + env, NULL, name, squareExecute, squareComplete, work, &work->work)); + CHECK(napi_queue_async_work(env, work->work)); + return promise; +} + +typedef struct { + napi_threadsafe_function tsfn; + int count; +} TickThread; + +static void callTick( + napi_env env, + napi_value callback, + void *context, + void *data) { + (void)context; + int *value = data; + if (env != NULL) { + napi_value undefined; + napi_value arg; + napi_get_undefined(env, &undefined); + napi_create_int32(env, *value, &arg); + napi_call_function(env, undefined, callback, 1, &arg, NULL); + } + free(value); +} + +static void *tickThreadMain(void *arg) { + TickThread *thread = arg; + for (int i = 1; i <= thread->count; i++) { + int *value = malloc(sizeof(int)); + *value = i; + napi_call_threadsafe_function(thread->tsfn, value, napi_tsfn_blocking); + } + napi_release_threadsafe_function(thread->tsfn, napi_tsfn_release); + free(thread); + return NULL; +} + +// Calls `callback(i)` for i in 1..count from a separate thread. +static napi_value TickFromThread(napi_env env, napi_callback_info info) { + size_t argc = 2; + napi_value argv[2]; + CHECK(napi_get_cb_info(env, info, &argc, argv, NULL, NULL)); + TickThread *thread = calloc(1, sizeof(TickThread)); + CHECK(napi_get_value_int32(env, argv[0], &thread->count)); + napi_value name; + CHECK(napi_create_string_utf8( + env, "tickFromThread", NAPI_AUTO_LENGTH, &name)); + CHECK(napi_create_threadsafe_function( + env, + argv[1], + NULL, + name, + 0, + 1, + NULL, + NULL, + NULL, + callTick, + &thread->tsfn)); + pthread_t pthread; + pthread_create(&pthread, NULL, tickThreadMain, thread); + pthread_detach(pthread); + return NULL; +} + +static napi_value EnvStats(napi_env env, napi_callback_info info) { + (void)info; + napi_value result; + napi_value created; + napi_value cleanedUp; + CHECK(napi_create_object(env, &result)); + CHECK(napi_create_int32(env, atomic_load(&envsCreated), &created)); + CHECK(napi_create_int32(env, atomic_load(&envsCleanedUp), &cleanedUp)); + CHECK(napi_set_named_property(env, result, "created", created)); + CHECK(napi_set_named_property(env, result, "cleanedUp", cleanedUp)); + return result; +} + +NAPI_MODULE_INIT() { + atomic_fetch_add(&envsCreated, 1); + CHECK(napi_add_env_cleanup_hook(env, onEnvCleanup, NULL)); + napi_property_descriptor properties[] = { + {"hello", NULL, Hello, NULL, NULL, NULL, napi_default, NULL}, + {"add", NULL, Add, NULL, NULL, NULL, napi_default, NULL}, + {"squareAsync", NULL, SquareAsync, NULL, NULL, NULL, napi_default, NULL}, + {"tickFromThread", + NULL, + TickFromThread, + NULL, + NULL, + NULL, + napi_default, + NULL}, + {"envStats", NULL, EnvStats, NULL, NULL, NULL, napi_default, NULL}, + }; + CHECK(napi_define_properties( + env, exports, sizeof(properties) / sizeof(properties[0]), properties)); + return exports; +} diff --git a/packages/rn-tester/NodeApiExample/build-apple-framework.sh b/packages/rn-tester/NodeApiExample/build-apple-framework.sh new file mode 100755 index 000000000000..58ab4327dcb0 --- /dev/null +++ b/packages/rn-tester/NodeApiExample/build-apple-framework.sh @@ -0,0 +1,66 @@ +#!/bin/bash +# 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. + +# Builds the Node-API example addon into the app's Frameworks folder, from an +# Xcode build phase. It's loaded at runtime with `dlopen` from +# react-native/node-api, so the app doesn't link it. + +set -euo pipefail + +NAME=nodeapiexample +SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FRAMEWORK="$TARGET_BUILD_DIR/$FRAMEWORKS_FOLDER_PATH/$NAME.framework" +WORK_DIR="$DERIVED_FILE_DIR/$NAME" + +if [[ "${RCT_NODE_API_ENABLED:-0}" != "1" ]]; then + echo "Skipping the Node-API example: React Native is built without Node-API" + # Xcode creates the output's directory before running the phase. + rm -rf "$FRAMEWORK" + exit 0 +fi + +# TODO: Link a node-api framework once Hermes ships it, instead of hermesvm. +if [ -n "${PODS_ROOT:-}" ]; then + NODE_API_HEADERS="$PODS_ROOT/Headers/Public/hermes-engine/hermes/napi" +else + # SwiftPM: the Hermes artifacts React Native was prebuilt with. + NODE_API_HEADERS="$SOURCE_DIR/../../react-native/.build/artifacts/hermes/destroot/include/hermes/napi" +fi + +eval "SEARCH_PATHS=($FRAMEWORK_SEARCH_PATHS)" +FRAMEWORK_FLAGS=() +for path in "${SEARCH_PATHS[@]}"; do + FRAMEWORK_FLAGS+=(-F "$path") +done + +mkdir -p "$WORK_DIR" "$FRAMEWORK" +SLICES=() +for arch in $ARCHS; do + xcrun clang \ + -target "$arch-apple-ios$IPHONEOS_DEPLOYMENT_TARGET${LLVM_TARGET_TRIPLE_SUFFIX:-}" \ + -isysroot "$SDKROOT" \ + -dynamiclib \ + -install_name "@rpath/$NAME.framework/$NAME" \ + -Wall -Werror \ + -I "$NODE_API_HEADERS" \ + "${FRAMEWORK_FLAGS[@]}" \ + -framework hermesvm \ + -o "$WORK_DIR/$NAME-$arch" \ + "$SOURCE_DIR/addon.c" + SLICES+=("$WORK_DIR/$NAME-$arch") +done +xcrun lipo -create "${SLICES[@]}" -output "$FRAMEWORK/$NAME" + +rm -f "$FRAMEWORK/Info.plist" +/usr/libexec/PlistBuddy \ + -c "Add :CFBundleExecutable string $NAME" \ + -c "Add :CFBundleIdentifier string com.facebook.react.$NAME" \ + -c "Add :CFBundlePackageType string FMWK" \ + -c "Add :CFBundleVersion string 1" \ + -c "Add :MinimumOSVersion string $IPHONEOS_DEPLOYMENT_TARGET" \ + "$FRAMEWORK/Info.plist" >/dev/null + +codesign --force --sign "${EXPANDED_CODE_SIGN_IDENTITY:--}" "$FRAMEWORK" diff --git a/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj b/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj index 39dceb0ee86c..9a5c2bc01fe4 100644 --- a/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj +++ b/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj @@ -332,6 +332,7 @@ 13B07F8E1A680F5B00A75B9A /* Resources */, 68CD48B71D2BCB2C007E06A9 /* Build JS Bundle */, 79E8BE2B119D4C5CCD2F04B3 /* [RN] Copy Hermes Framework */, + 4F7F229B62EFCD7CD0990588 /* [RN] Build Node-API Example */, 17FE348EDF12252D972FFC2F /* [CP] Embed Pods Frameworks */, DFEE284B22AD5E88BBF1026A /* [CP] Copy Pods Resources */, ); @@ -425,6 +426,23 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 4F7F229B62EFCD7CD0990588 /* [RN] Build Node-API Example */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "$(SRCROOT)/NodeApiExample/addon.c", + "$(SRCROOT)/NodeApiExample/build-apple-framework.sh", + ); + name = "[RN] Build Node-API Example"; + outputPaths = ( + "$(TARGET_BUILD_DIR)/$(FRAMEWORKS_FOLDER_PATH)/nodeapiexample.framework/nodeapiexample", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${SRCROOT}/NodeApiExample/build-apple-framework.sh\"\n"; + }; 17FE348EDF12252D972FFC2F /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; diff --git a/packages/rn-tester/android/app/src/main/jni/CMakeLists.txt b/packages/rn-tester/android/app/src/main/jni/CMakeLists.txt index 808c4ba3b7d6..ec77f175fd92 100644 --- a/packages/rn-tester/android/app/src/main/jni/CMakeLists.txt +++ b/packages/rn-tester/android/app/src/main/jni/CMakeLists.txt @@ -12,6 +12,9 @@ include(${REACT_ANDROID_DIR}/cmake-utils/ReactNative-application.cmake) add_subdirectory(${REACT_COMMON_DIR}/react/nativemodule/samples/platform/android/ sampleturbomodule_build) add_subdirectory(${REACT_COMMON_DIR}/../../rn-tester/NativeCxxModuleExample/ nativecxxmoduleexample_build) +if(RCT_NODE_API_ENABLED) + add_subdirectory(${REACT_COMMON_DIR}/../../rn-tester/NodeApiExample/ nodeapiexample_build) +endif() # RN Tester needs to link against the sample turbomobule target_link_libraries(${CMAKE_PROJECT_NAME} diff --git a/packages/rn-tester/js/examples/NodeApi/NodeApiExample.js b/packages/rn-tester/js/examples/NodeApi/NodeApiExample.js new file mode 100644 index 000000000000..8a4173e3f738 --- /dev/null +++ b/packages/rn-tester/js/examples/NodeApi/NodeApiExample.js @@ -0,0 +1,115 @@ +/** + * 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. + * + * @flow + * @format + */ + +'use strict'; + +import type {RNTesterModuleExample} from '../../types/RNTesterTypes'; + +import RNTesterText from '../../components/RNTesterText'; +import styles from '../TurboModule/TurboModuleExampleCommon'; +import * as React from 'react'; +import {useState} from 'react'; +import {DevSettings, Platform, TouchableOpacity, View} from 'react-native'; +import {dlopen} from 'react-native/node-api'; + +type Addon = { + hello: () => string, + add: (a: number, b: number) => number, + squareAsync: (n: number) => Promise, + tickFromThread: (count: number, callback: (i: number) => void) => void, + envStats: () => {created: number, cleanedUp: number}, +}; + +// Path construction is left to the caller, like with Node's process.dlopen. +const ADDON_FILENAME = + Platform.OS === 'android' + ? 'libnodeapiexample.so' + : '@rpath/nodeapiexample.framework/nodeapiexample'; + +function loadAddon(): Addon { + const module: {exports: any} = {exports: undefined}; + dlopen(module, ADDON_FILENAME); + return module.exports; +} + +function NodeApiExample(): React.Node { + const [addon, setAddon] = useState(null); + const [log, setLog] = useState>([]); + + const append = (line: string) => setLog(previous => [...previous, line]); + const run = (label: string, action: (addon: Addon) => unknown) => { + try { + const loaded = addon ?? loadAddon(); + setAddon(loaded); + const result = action(loaded); + if (result instanceof Promise) { + result.then( + value => append(`${label} β†’ ${String(value)}`), + error => append(`${label} rejected: ${String(error)}`), + ); + } else { + append(`${label} β†’ ${JSON.stringify(result) ?? 'undefined'}`); + } + } catch (error) { + append(`${label} threw: ${String(error)}`); + } + }; + + const actions: Array<[string, string, (addon: Addon) => unknown]> = [ + ['hello', 'hello()', a => a.hello()], + ['add', 'add(2, 3)', a => a.add(2, 3)], + ['square-async', 'squareAsync(7)', a => a.squareAsync(7)], + [ + 'tick-from-thread', + 'tickFromThread(3)', + a => a.tickFromThread(3, i => append(`tick ${i} from thread`)), + ], + ['env-stats', 'envStats()', a => a.envStats()], + ]; + + return ( + + {actions.map(([id, label, action]) => ( + + run(label, action)}> + {label} + + + ))} + + DevSettings.reload()}> + Reload + + + {log.map((line, index) => ( + + {line} + + ))} + + ); +} + +exports.displayName = undefined as ?string; +exports.title = 'Node-API'; +exports.category = 'Basic'; +exports.description = 'Loading a Node-API addon with dlopen'; +exports.examples = [ + { + title: 'Addon', + render: (): React.MixedElement => , + }, +] as Array; diff --git a/packages/rn-tester/js/utils/RNTesterList.android.js b/packages/rn-tester/js/utils/RNTesterList.android.js index 61844bb5f196..cf0583b52a9c 100644 --- a/packages/rn-tester/js/utils/RNTesterList.android.js +++ b/packages/rn-tester/js/utils/RNTesterList.android.js @@ -385,6 +385,11 @@ const APIs: Array = ( category: 'Basic', module: require('../examples/TurboModule/TurboModuleExample'), }, + { + key: 'NodeApiExample', + category: 'Basic', + module: require('../examples/NodeApi/NodeApiExample'), + }, { key: 'LegacyModuleExample', module: require('../examples/TurboModule/LegacyModuleExample'), diff --git a/packages/rn-tester/js/utils/RNTesterList.ios.js b/packages/rn-tester/js/utils/RNTesterList.ios.js index d0821c08709f..5f6b401a2a79 100644 --- a/packages/rn-tester/js/utils/RNTesterList.ios.js +++ b/packages/rn-tester/js/utils/RNTesterList.ios.js @@ -346,6 +346,10 @@ const APIs: Array = ( key: 'TurboModuleExample', module: require('../examples/TurboModule/TurboModuleExample'), }, + { + key: 'NodeApiExample', + module: require('../examples/NodeApi/NodeApiExample'), + }, { key: 'LegacyModuleExample', module: require('../examples/TurboModule/LegacyModuleExample'),