Skip to content

Add a Node-API host for Hermes - #59894

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

kraenhansen wants to merge 13 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>.
  • Building without it: Node-API stays in the prebuilt artifacts, but apps that build React Native and Hermes from source can leave it out, to save binary size or to build against a Hermes compiled with HERMES_ENABLE_NAPI=OFF. Set disableNodeApi=true (Gradle) or RCT_DISABLE_NODE_API=1 (CocoaPods, SwiftPM). This defines RCT_DISABLE_NODE_API, which drops the module and its registration, and builds Hermes with HERMES_ENABLE_NAPI=OFF. 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 that runs on Android and iOS.

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

Binary size: Node-API in Hermes plus the module add 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.

Temporary

Open questions

  • Should this include Fantom coverage? Reload and teardown are covered end to end by the Maestro flow, which CI's RNTester E2E jobs run, but 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

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 isNodeApiDisabled ones

  • 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_DISABLE_NODE_API=1. With it, the module's target and React-Runtime's dependency on it are dropped and the define is added. Not built

  • RNTester on Android, built with ./gradlew :packages:rn-tester:android:app:installDebug -PreactNativeArchitectures=arm64-v8a (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 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. 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 without Node-API on Android (assembleDebug -PdisableNodeApi=true) and iOS (RCT_DISABLE_NODE_API=1 RCT_USE_PREBUILT_RNCORE=0 HERMES_COMMIT=… pod install, Debug, simulator). hermesvm exports no napi_* functions, 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: the JavaScript engine does not implement it, or React Native was built without it. Built again with the defaults, the Maestro flow passes on Android and iOS (CocoaPods)

  • The Maestro flow passes in all three: maestro test -e APP_ID=<app id> .maestro/node-api.yml (Maestro 2.6.1, as in CI)

  • 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?

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