Skip to content

Add a Node-API host for Hermes - #59894

Draft
kraenhansen wants to merge 15 commits into
react:mainfrom
kraenhansen:napi-host
Draft

kraenhansen wants to merge 15 commits into
react:mainfrom
kraenhansen:napi-host

Conversation

@kraenhansen

@kraenhansen kraenhansen commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

Summary:

Lets a React Native app load Node-API addons, using the Node-API implementation Hermes already has. This is the working diff for the RFC in react-native-community/discussions-and-proposals#1030, and isn't meant to merge as is: it depends on a Hermes that exports Node-API, which no Hermes release does yet.

  • dlopen: import {dlopen} from 'react-native/node-api' loads an addon into the calling runtime. It behaves like Node's process.dlopen(module, filename): it sets module.exports and caches nothing. filename is passed to the system's dlopen, so core doesn't decide how addons are named, laid out or cached.
  • The NativeNodeApiCxx module: creates a napi_env per dlopen call and hosts async work and thread-safe functions, dispatched through the runtime's CallInvoker. Creating the env and loading the addon go through a small NodeApiEngine interface, which Hermes implements with its embedder API (hermes_napi.h). HermesInstance registers the module, so it's only available with Hermes.
  • Packaging: Hermes' Node-API headers are exposed in the Android prefab, the hermes-engine pod (built from source) and the SwiftPM build, so addons can #include <node_api.h>.
  • Off by default: apps enable Node-API with nodeApiEnabled=true (Gradle) or RCT_NODE_API_ENABLED=1 (CocoaPods, SwiftPM), and build React Native from source, since the prebuilt artifacts don't include it. This defines RCT_NODE_API_ENABLED, which compiles in the module and its registration, and a Hermes built from source gets HERMES_ENABLE_NAPI to match. When it's missing, dlopen's error says how to enable it, and the Gradle plugin and pod install warn when it's set with prebuilt React Native. The RFC covers the naming.
  • An RNTester example: an addon covering a sync call, async work, a thread-safe function and env cleanup across a reload, with a Maestro flow for Android and iOS.

Fatal Node-API errors are reported through #59729 and #59861, which this branch is based on.

Binary size: enabling Node-API in Hermes and the module adds 131,072 bytes (+0.62%) to a release arm64 RNTester APK and 113,304 bytes (+0.80%) to the iOS app. The RFC has the breakdown and how it was measured.

Not in this PR yet

Addons should link against a node-api library rather than the engine, so that they don't record Hermes' library name. The plan is for Hermes to ship it next to hermesvm, proposed in meta/hermes#1074 (comment). Addons would then use the same CMake in an app as in a prebuild:

find_package(node-api REQUIRED CONFIG)
target_link_libraries(my_addon PRIVATE node-api::node-api)

Until then, the RNTester example links hermesvm directly, marked with TODOs. The module keeps linking hermesvm: it's the embedder, and uses hermes_napi.h, which node-api won't export.

Not tested in CI

CI builds React Native and RNTester with the defaults, so without Node-API. The Maestro flow is in packages/rn-tester/NodeApiExample/.maestro/, outside the folder CI's E2E jobs run, and is only run locally with Node-API enabled, as described in NodeApiExample/README.md. RCT_REMOVE_LEGACY_MODULE_INTEROP and RCT_REMOVE_LEGACY_COMPONENT_INTEROP, the other opt-ins that compile React Native's core differently, aren't tested in CI either.

Alternatively, CI could enable Node-API in its PR (dry-run) builds only, and the flow could move back into .maestro/. That means ORG_GRADLE_PROJECT_nodeApiEnabled=true in build-android's dry-run branch, and RCT_NODE_API_ENABLED=1 in prebuild-ios-core and test-ios-rntester for non-release runs, with the switch added to the iOS core prebuild's cache keys so nightly and release builds never reuse a cache built with Node-API. Nightly and release artifacts would stay without it, but PR E2E jobs would test a build that differs from the default, and no other opt-in is enabled this way today.

Temporary

Open questions

  • Should this include Fantom coverage? Reload and teardown are covered end to end by the Maestro flow, but only locally (see Not tested in CI), and Fantom tests would need the example addon built for the host.
  • App Store validation of a re-exporting node-api framework hasn't been tested.
  • hermes_napi.h, Hermes' embedder API, is public in the Hermes artifacts: the Android prefab and the hermes-engine pod's NodeApiEmbedder subspec. Only React Native's host needs it, so Hermes might want to ship it separately from the Node-API headers addons use.
  • Unlike Node, an addon's init gets a new, empty exports object rather than the caller's module.exports, because hermes_napi_load_module doesn't take one: NAPI: hermes_napi_load_module can't take the caller's exports object, unlike Node's process.dlopen meta/hermes#2307.

Changelog:

[GENERAL] [ADDED] - Load Node-API addons with dlopen from react-native/node-api, opt-in with nodeApiEnabled (Android) or RCT_NODE_API_ENABLED (iOS)

Test Plan:

  • yarn flow-check

  • yarn lint

  • yarn build-types and yarn test-generated-typescript, including a type test for react-native/node-api

  • yarn test: no Jest tests were added, and the suite wasn't run

  • Gradle plugin: ./gradlew :react-native-gradle-plugin:test --tests '*ProjectUtilsTest*', 39 tests passed, including the new isNodeApiEnabled ones. The whole plugin suite: 250 of 251 pass. PreparePrefabHeadersTaskTest.preparePrefabHeadersTask_declaresTheHeadersItCopiesAsInputs fails comparing /var/… with /private/var/… temp paths on macOS, in a task this PR doesn't touch

  • CocoaPods: utils-test.rb (run on its own), 81 tests. The new set_node_api_flags tests pass. test_SetGCCPreprocessorDefinitionForHermes_itSetsThePreprocessorForDebug fails, and fails on the base commit too

  • SwiftPM: swift package dump-package with and without RCT_NODE_API_ENABLED=1. Without it, the module's target and React-Runtime's dependency on it are left out; with it, they're included and the define is added

  • RNTester on Android, built with ./gradlew :packages:rn-tester:android:app:assembleDebug -PreactNativeArchitectures=arm64-v8a -PnodeApiEnabled=true (Hermes from source at the pinned commit) and run on an arm64 API 37 emulator:

    Check Result
    Sync call hello() → "Hello from Node-API", add(2, 3) → 5
    Async work squareAsync(7) → 49
    Thread-safe function from a pthread tick 1, 2 and 3 from thread
    Before reload envStats() → {"created":1,"cleanedUp":0}
    After DevSettings.reload() envStats() → {"created":2,"cleanedUp":1}, and async work still completes
  • RNTester on iOS (CocoaPods): same results. Installed with RCT_NODE_API_ENABLED=1 HERMES_COMMIT=$(cat ../react-native/sdks/.hermesv1version) pod install, built in Debug, and run on an iPhone 17 Pro simulator (iOS 26.2).

  • RNTester on iOS (SwiftPM): same results, on the same simulator, before Node-API became opt-in. Not run again since: it now needs RCT_NODE_API_ENABLED=1 when prebuilding React Native. Hermes was built for the simulator at the pinned commit, with the Node-API headers added to its destroot. React Native was prebuilt against it with HERMES_ENGINE_TARBALL_PATH=… RN_DEP_VERSION=nightly node scripts/ios-prebuild, and RNTester was converted with npx react-native spm add --deintegrate for the run only, as the SwiftPM CI job does.

  • Built with the defaults on Android (assembleDebug) and iOS (HERMES_COMMIT=… pod install, Debug, simulator): Hermes is built with HERMES_ENABLE_NAPI off, hermesvm exports no napi_* functions (155 with Node-API enabled), neither libhermestooling.so nor the iOS app has NodeApi symbols, the example addon isn't built, and the example shows 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

  • Gradle plugin warning: with -PnodeApiEnabled=true, helloworld (prebuilt AAR) prints WARNING: 'nodeApiEnabled' has no effect on the prebuilt React Native AAR…, and RNTester (React Native built from source) doesn't

  • With Node-API enabled, the Maestro flow passes on Android and iOS (CocoaPods): maestro test -e APP_ID=<app id> NodeApiExample/.maestro/node-api.yml (Maestro 2.6.1)

  • Release build on a physical iPhone

Android iOS (CocoaPods) iOS (SwiftPM)
node-api-rntester-android.mp4
node-api-rntester-ios.mp4
node-api-rntester-ios-spm.mp4

🤖 Generated with Claude Code

kraenhansen and others added 12 commits October 31, 2026 22:01
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Moves the handler from the Android-only JHermesInstance to the
cross-platform HermesInstance, and logs through react_native_log_fatal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
reactnativejni, which initializes glog, is only loaded lazily on the
New Architecture, so glog output during startup never reached logcat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Temporary, for the Node-API spike: pins Hermes to a static_h commit that
has API/napi, builds it from source on Android, and ships the vendored
Node-API headers (include/hermes/napi) in the Hermes prefab headers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`dlopen(module, filename)` from `react-native/node-api` loads a Node-API
addon like Node's `process.dlopen`. It's backed by the NativeNodeApiCxx
TurboModule, which hosts async work and thread-safe functions for the
engine and leaves env creation and addon loading to a NodeApiEngine.
Hermes provides one and registers the module from createJSRuntime, so
it's only available when Hermes is the engine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A small C addon, built as its own shared library and loaded with
`dlopen` from `react-native/node-api`. The example covers a sync call,
async work, a thread-safe function, and env cleanup across a reload.
Android only for now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds a React-nodeapinativemodule pod, installed and depended on by
React-RuntimeHermes. When Hermes is built from source, its podspec now
exposes hermes_napi.h and the Node-API headers in include/hermes/napi.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A build phase on the RNTester target builds the example addon into
nodeapiexample.framework in the app's Frameworks folder, linking
hermesvm, so it can be loaded with dlopen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Covers the sync call, async work, the thread-safe function, and env
cleanup across a reload. The example's buttons get plain testIDs,
since Maestro matches ids as regular expressions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A hand-written declaration, like react-native/unstable-internals-do-not-use,
with a type test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds a React-nodeapinativemodule target, depended on by React-Runtime,
and lets the RNTester example find the Node-API headers in the Hermes
artifacts when it isn't built with CocoaPods.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`disableNodeApi` (Gradle) and `RCT_DISABLE_NODE_API=1` (CocoaPods, SwiftPM) leave the Node-API module out of React Native and build Hermes with `HERMES_ENABLE_NAPI=OFF`, when building from source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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{

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we fix this in Hermes? Leaking this memory will lead to lot of false signals.

Comment on lines +75 to +81
std::call_once(fatalHandlerFlag, []() {
auto* fatalHandlerInterface =
jsi::castInterface<ISetFatalHandler>(makeHermesRootAPI());
if (fatalHandlerInterface) {
fatalHandlerInterface->setFatalHandler(hermesFatalHandler);
}
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is already in another PR, right?

Comment on lines +84 to +91
std::call_once(nodeApiFlag, []() {
registerCxxModuleToGlobalModuleMap(
std::string(NativeNodeApi::kModuleName),
[](std::shared_ptr<CallInvoker> jsInvoker) {
return std::make_shared<NativeNodeApi>(
std::make_shared<HermesNodeApiEngine>(), std::move(jsInvoker));
});
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this go in DefaultTurboModules?

kraenhansen and others added 2 commits November 13, 2026 22:14
Node-API is now off by default. `nodeApiEnabled` (Gradle) and `RCT_NODE_API_ENABLED=1` (CocoaPods, SwiftPM) compile in the module and build Hermes from source with `HERMES_ENABLE_NAPI` on. `dlopen` says how to enable it when it's missing, and the Gradle plugin warns when it's set with the prebuilt AAR, as `pod install` does with the prebuilt `React.xcframework`.

RNTester builds without it, so its Maestro flow moves out of the folder CI runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

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

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants