Skip to content

Spike: move the napi host and addon loading into React Native core #495

Open

Description

@kraenhansen

Prove the RFC in #447: an app with no react-native-node-api native host can load an addon, with core providing the napi host and addon loading, and Hermes providing a node-api library to link against.

Steps

  • On a branch of a meta/hermes fork, add the node-api library to the Hermes build.
  • On a branch of a react/react-native fork, pin that Hermes commit and move the napi host and addon loading into core. Android first.
  • Build an app from that source, without our native host, with an addon linked against node-api.
  • Check that the addon loads, its calls land in Hermes, and the relevant tests pass.
  • Publish both branches as draft PRs on the forks, so the RFC has concrete diffs to point at.

The node-api library

A library that forwards to Hermes' Node-API symbols, which addons link against so they never record the engine's library name. At runtime it has no code of its own. It takes over #496's facade, and its names become the stable names that prebuilds made with the node-api stub (#494) record.

  • Android: two builds of libnode-api.so with the same soname, for the same ABIs as libhermesvm.so. A stub whose functions all abort goes in the AAR's prefab module, which is only used for linking. An empty library with a DT_NEEDED on libhermesvm.so goes in jni/<abi>/, which is packaged into the APK. One library can't do both: the NDK links with --no-undefined and lld doesn't resolve through a dependency's DT_NEEDED, while at runtime bionic would pick definitions in libnode-api.so over libhermesvm.so. Prebuilds don't need Hermes' stub, since cmake-rn and ferric build node-api-stub.
  • Apple: node-api.xcframework, with the same slices as hermesvm.xcframework (iOS, simulator, Mac Catalyst, tvOS, visionOS) and built by the same create_universal_framework step. Each slice re-exports the Node-API symbols of the matching hermesvm slice. ld64 follows re-exports when linking, so unlike Android, one build works for both linking and runtime. The install name is @rpath/node-api.framework/node-api, and on macOS, where frameworks are versioned bundles, @rpath/node-api.framework/Versions/A/node-api. The prebuild stub has to be an xcframework with the same slices and install names. Each slice holds one framework with one binary, so node-api can't share hermesvm's xcframework; the two are built together and shipped side by side in the same Hermes artifact.
  • Headers: Hermes already vendors the Node.js headers in include/hermes/napi/ (see its VENDORED.md). The library puts that directory on the include path, so addons can #include <node_api.h>.
  • Linking: on Apple, addons depend on a node-api pod or SwiftPM product. On Android, the CMake target name depends on which package provides it (see below).

The Android target name

An addon's CMakeLists.txt is often built both ways: as a prebuild with cmake-rn or ferric, and from source inside an app. If the target has the same name in both, the author writes one target_link_libraries line. If it doesn't, the author needs a conditional, or keeps two configs. Prefab always names targets <package>::<module>, so the name depends on which package the module is in:

Option Target For Against
A module in the Hermes AAR, next to hermesvm hermes-engine::node-api No extra artifact. Says where it comes from, and matches how React Native links Hermes (hermes-engine::hermesvm). Names the engine in every addon. Differs from cmake-rn's node-api target unless cmake-rn adopts the same name.
A separate node-api AAR published by Hermes node-api::node-api The same name everywhere, and doesn't name the engine. One more artifact for Hermes to publish and apps to depend on.
A node-api package published by React Native, wrapping Hermes' libraries node-api::node-api The same, and Hermes only adds a module. React Native re-packages Hermes' output.

The prefab package name is the Gradle root project name. For the Hermes AAR that's hermes-engine, even though its Maven artifact is hermes-android.

With the first option, addons can still use one name. An addon could alias hermes-engine::node-api to node-api::node-api itself, but React Native provides node-api::node-api by default instead, the same way cmake-rn provides weak-node-api today: by passing node-api_DIR, so that find_package(node-api REQUIRED CONFIG) loads a config file React Native wrote. An addon's CMake is then the same in an app as in a cmake-rn prebuild:

find_package(node-api REQUIRED CONFIG)
target_link_libraries(my_addon PRIVATE node-api::node-api)
  • Where the files come from: the React Native Gradle plugin takes hermes-android from the app's dependencies and extracts its node-api stub and include/hermes/napi into the app's build directory. It writes a node-api-config.cmake there that defines node-api::node-api as an imported shared library, using ${ANDROID_ABI} to pick the stub and the extracted headers as its include directory.
  • How it reaches every build: the plugin passes -Dnode-api_DIR=<dir> to the app's native build and to every Android library's. It already goes through all libraries from the app (ReactPlugin.kt), including ones that don't apply its plugin, and adds -DRN_STRICT_API to their native builds (configureStrictCxxApiForLibrary). So this covers libraries with their own externalNativeBuild, and their CMake configure tasks have to run after the extraction.
  • No Hermes dependency in libraries: the files don't come from AGP's prefab, so an addon's library module doesn't need to depend on hermes-android. Hermes only has to ship the stub and the headers in its AAR, not necessarily as a prefab module.

If this doesn't work out, a CMake dependency provider that handles find_package(node-api) is the alternative (CMake 3.24 and later), at the cost of allowing only one provider per build.

The alias shouldn't go the other way, with cmake-rn defining hermes-engine::node-api for its stub: a prebuild's CMake shouldn't name an engine, since the same addon could be built for another engine or for an Android app that embeds Node-API without React Native.

Who ships it

The plan is that Hermes ships it, since the headers and the symbols it forwards to are both in Hermes. This was proposed to the Hermes team in meta/hermes#1074 (comment). The alternative is React Native shipping it from ReactAndroid and its pods. The RFC presents both:

Hermes React Native
Owns the stable name Hermes, which renamed libhermes.so to libhermesvm.so in 0.82 React Native, whatever the engine is
Headers and export list Built from the same source as the symbols, so they can't drift apart Re-packaged from Hermes, and checked on every bump
Who benefits Any Hermes embedder React Native apps
Changes One Hermes PR and one React Native PR One React Native PR

Moving from one to the other only moves the facade's build. Addons don't change, as long as the target they link keeps its name (see "The Android target name").

Neither the stub nor the facade is specific to Hermes. They're still built with Hermes, because each one is tied to a particular hermesvm: the facade names its soname or install name, and the stub has to declare exactly what it exports, for the same ABIs and slices. What can be shared is their generator, which could be maintained outside both projects, for example next to the .def files and symbol lists nodejs/node-api-headers already ships for Windows import libraries. node-api-stub (#501) is a starting point for that, and Hermes, React Native and cmake-rn would all depend on it.

Hermes branch

  1. The node-api targets in Hermes' CMake build: the facade, and on Android the link-time stub, installed next to hermesvm with include/hermes/napi. In hermes-android, the prefab module points at the stub and jni/<abi>/ gets the facade. It's built on top of static_h (51ae2a0 when proposed). If that doesn't work with React Native's JSI, the fallback is the host's HERMES_GIT_SHA (5a795c9), which has napi: give hermes_napi.h public API C linkage meta/hermes#2106 and JSI_UNSTABLE off.

React Native branch

The branch is napi-host on kraenhansen/react-native. It's based on react/react-native#59729 and react/react-native#59861, so fatal errors are reported without the host's napi_fatal_error override (#492). Those commits are dropped once they land.

  1. Pin the Hermes branch's commit in sdks/.hermesv1version, which the Android source build already downloads as meta/hermes/tarball/<ref>. On iOS, .hermesv1version is treated as a tag, so a commit needs HERMES_COMMIT. GitHub probably serves the fork's commit through meta/hermes, but that's unverified; if not, use REACT_NATIVE_OVERRIDE_HERMES_DIR. This commit is temporary until a Hermes release has Node-API (Remove Hermes vendoring once React Native ships a Hermes with Node-API #412).
  2. Packaging for node-api, wrapping the library Hermes builds:
    • Android: the Gradle plugin extracts the stub and headers from hermes-android, writes node-api-config.cmake, and passes node-api_DIR to every native build (see "The Android target name").
    • CocoaPods: a separate node-api pod. Its headers aren't namespaced under the pod name, so they're added to HEADER_SEARCH_PATHS. Apps get them through user_target_xcconfig, as React-Core does. Library pods don't inherit that, so they call a depend_on_node_api(spec) helper, like depend_on_js_engine, which adds the dependency and the search path.
    • SwiftPM: a headers-only, library-type binary target like ReactNativeHeaders serves its Headers/ without a prefix to whatever depends on the node-api product. The facade itself is a framework-type xcframework, which can't serve headers without a prefix, so the product has both.
  3. The napi host. HermesNapiHost (async work, thread-safe functions, CallInvoker dispatch) ported from the host, without RuntimeNodeApi. hermes_napi.h stays a private include and isn't exported in prefab or pod headers.
  4. dlopen. import { dlopen } from "react-native/node-api", with dlopen(module, filename) working like Node's process.dlopen, including setting module.exports, with no cache (Build addon paths in TypeScript and expose dlopen(module, filename) #498). It's a TurboModule spec in src/private, a react-native/node-api subpath export, and the regenerated type snapshots. Unflagged, since it's opt-in and its surface follows a de facto standard.
  5. An RNTester example. An addon built in RNTester that links node-api, covering a sync call, async work, a thread-safe function and a reload. Its JS builds filename itself, which shows path construction stays out of core. To check teardown, the addon counts environments it created and ones whose cleanup hook ran, in process-global counters that survive DevSettings.reload().
  6. __docs__ for the subsystem.

Changes needed here

Left out

Open questions for reviewers

  • Should Hermes or React Native ship node-api? (See above.)
  • On Android, should node-api::node-api come from a separate node-api AAR, or from a config file the Gradle plugin writes and passes as node-api_DIR? (See above.)
  • Should the React Native PR include Fantom coverage, given it needs a built addon?
  • App Store validation of a re-exporting node-api framework can't be tested in RNTester (see Prebuilt node-api facade for published addons #496).
  • Do cleanup hooks run when the runtime is torn down? The reload test answers this.

What this should settle

  • What has to move. Something must load the addon as well as host it. Today that path is require("./addon.node") → Babel transform → requireNodeAddon(). Core needs one primitive for this, while the Babel plugin, link and the naming scheme may be able to stay here.
  • Whether hermes_napi.h can stay internal. It declares embedder API such as hermes_napi_create_env and hermes_run_script, so it should be a private dependency of core's napi host, not a public header. Addons get the Node-API headers through node-api in an app, and from node-api-headers when prebuilding.

What the RFC asks for

  1. Hermes builds used by React Native export Node-API and ship the node-api library and the headers in include/hermes/napi (Remove Hermes vendoring once React Native ships a Hermes with Node-API #412). static_h already has API/napi, but it isn't on main or in any release. This spike builds on a pinned commit in the meantime.
  2. The library exporting it keeps a stable name, or React Native declares a supported one. The node-api library has a fixed name, so a Hermes rename doesn't break published addons.

The headers define the interface, so a mismatch with them is a Hermes bug and needs no separate ask.

The RNTester example links node-api directly, so this no longer depends on #494.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Host 🏡Our `react-native-node-api-modules` packageLinking 🔗Discovering and copying prebuilds from packages into the hostenhancementNew feature or request

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions