Repository navigation
Get Node-API from the app's Hermes, through a host-built facade or direct linking, as an alternative to weak-node-api #490
Description
A proposal, not a commitment: let an addon get Node-API from the Hermes an app actually uses, instead of through weak-node-api, either by linking it directly or through a small facade the host builds with the app. Part of #447, where it extends the "link the Hermes/React Native framework directly" option.
Findings were checked against next and upstream on 2026-10-31.
Why
- Addons with a known Hermes don't need the shim. "Addons are compiled independently of any app" doesn't hold for an addon built as part of an app, or a library compiled from source in the consuming app.
- It simplifies a React Native core proposal. Examples and an eventual RFC could show an addon linked against the app's Hermes and loaded by one core primitive, leaving
weak-node-apiout completely. The shim then becomes this project's distribution tooling, on our side of the seam alongsidecmake-rnandferric.
The original reason for the shim has also weakened. Hermes now implements the Node-API surface itself and the host no longer has to complete it (#428), with napi_fatal_error as the one remaining override, which #492 removes.
Linkage modes
The eventual shape is a single, mutually exclusive choice when building an addon:
| # | Mode | Provides symbols through | Status |
|---|---|---|---|
| 1 | weak-node-api |
Shim, injected by the host | Today's default |
| 2 | Hermes | The hermesvm library of a concrete React Native, recorded by its real name |
Proposed for in-app and from-source addons |
| 3 | Allow unresolved symbols | Linker flags, resolved at load time | Included for completeness. It's essentially cmake-js' default and could be useful for Node.js targets (#217), but has limited practical use on iOS and Android |
| 4 | None | Whatever the project sets up itself | Today's --no-weak-node-api-linkage, which the "none" mode could replace |
| 5 | Facade | node-api, a neutral library name provided by a library the host builds with the app |
Proposed for published prebuilds, and the recommended direction |
Modes 2 and 5 both reach Hermes without a shim. The difference is what the addon records: mode 2 names Hermes, while mode 5 only names Node-API and leaves the choice of backend to the app.
Scope
Mode 2 is simplest when the build knows the app's exact Hermes. Published prebuilds are possible too, because an addon is only tied to a library name and not a Hermes version (see below):
| Scenario | Hermes known? | Fit |
|---|---|---|
| Addon built as part of an app | Yes | Good: it ships with the Hermes it linked against |
| Library compiled from source in the consuming app | Yes, if the build runs in the app's context | Good |
| Prebuilt published to npm | No | Use mode 5: link a neutral-name stub, and the host provides that name at app build |
Tradeoffs
The main concern: Hermes as a private detail behind JSI
Two different couplings get mixed up here:
- Binary: the addon gains a dependency on a library name. This is a real coupling. React Native never promised that name, and it already changed once: 0.82 renamed
libhermes.sotolibhermesvm.so, the prefab module tohermes-engine::hermesvm, andhermes.xcframeworktohermesvm.xcframework. - API: the addon only references the Node-API C ABI, not Hermes' C++ internals. That is a smaller and more stable surface than what the host itself uses today (
getVMRuntimeUnsafe, documented as unstable).
Direct linking does work against the abstraction, but only by adding a second engine-level contract next to JSI. An RFC should ask for exactly that: Node-API as a stable public ABI of the engine, and a stable way to link it. This is narrower than making Hermes public.
I found no React Native documentation that either declares Hermes private or supports third-party linking. In practice it happens: react-native-worklets links hermes-engine::hermesvm, as does our host. Searching react/react-native issues and the proposals repo found no policy either way, and no Node-API proposal. That search wasn't exhaustive.
Benefits
- No shim library, no injection step and no extra indirection on each Node-API call.
- Addons no longer need the host to have injected a function table before any Node-API call can work.
- A missing symbol surfaces when the addon loads (tested on macOS and an Android emulator with dummy libraries).
- Removes the core-adoption obstacle described in Upstreaming the Node-API host into React Native core ☂️ #447.
Costs
- No interposition point. Addons call Hermes directly, so the host can't intercept individual Node-API calls.
napi_fatal_erroritself isn't lost, since Hermes implements it. Reporting its reason depends on React Native installing a fatal handler (see Blockers). - Directly linked addons become Hermes-only. A dependency on
hermesvmcan't load under other engines. JSC has moved to community support andreact-native-v8looks inactive, butweak-node-apiis engine-neutral today and that has real value. - Directly linked addons are tied to the library name. Apple's install name and Android's
DT_NEEDEDboth bake it in, and another rename would break such builds. This is the question for the RFC, and an acceptable risk while adoption is small. - More combinations to test and explain. Addons with different linkage should coexist in one app, since both reach the same implementation, but the host must still carry the shim as long as any addon needs it.
What an addon records
An addon doesn't store any code, offsets or hash from the library it was linked against. It only stores a library name and symbol names, which is the same mechanism it uses with weak-node-api today:
| Android (ELF) | Apple (Mach-O) | |
|---|---|---|
| Library name | DT_NEEDED taken from the library's SONAME |
Load command taken from the install name, plus two version numbers |
| Symbols | By name, not tied to a library | By name, tied to that library |
Experiments with dummy libraries, run on macOS and natively on an arm64 API 37 emulator. On Android they ran from a shell process, not inside an app's linker namespace:
- An ELF addon linked against the real library and one linked against an empty-bodied stub of the same name were byte-identical.
- Swapping in a different library with the same name works on both platforms. A missing symbol fails clearly at
dlopen, and so does a renamed library. - A higher recorded Apple compatibility version didn't block loading on macOS. Not tested on iOS.
What this means across Hermes versions:
- Only the library name and the presence of the used symbols tie an addon to a Hermes. Addons built this way won't load on React Native before 0.82, where the library name differs.
- Nothing C++ crosses the boundary, so the addon's STL, C++ standard or use of Rust doesn't affect this link.
napi_envandnapi_valueare opaque, and public structs are covered by Node-API's stability promise. That promise doesn't extend tonode_api_*experimental functions orhermes_napi_*.- Layouts and
NAPI_VERSIONcome from thenode_api.hthe addon compiles against, not from the linked library. - The tooling must reject a static Hermes. Linking a
.acopies the code into the addon, which would give it a second VM.
Getting a library to link against
The name recorded comes from inside the library, not its file name, so a plain symlink or copy of today's shim doesn't work: the addon still records libweak-node-api.so or @rpath/weak-node-api.framework/weak-node-api, with no error. On Android, linking a library with no SONAME by absolute path records the build machine's path, also silently. CMake's IMPORTED_NO_SONAME avoids that.
| Approach | Prebuilds | Modifies binaries | Extra library in app | Verdict |
|---|---|---|---|---|
| Real library of an app build | Poor | No | No | Fine inside an app's own build |
| Donor app | Poor | No | No | Rejected |
| Generated stub, real name | Tied to one name | No | No | Fine for in-app and from-source addons |
| Rewrite at app build | Any host exposing Node-API from one library | Yes | No | Fallback for hosts that can't ship a library |
| Generated stub, host-built facade | Neutral name, no rebuilds | No | Yes, with no code | Recommended |
-undefined dynamic_lookup, -flat_namespace |
No constraint | No | No | Rejected |
Generated stub. Generate a stub from getNodeApiFunctions(), the list weak-node-api already has, with the library name as a parameter. On Android, compile a small C file with the NDK clang and -soname <name> (llvm-ifs is in the NDK and might avoid compiling; not tried). On Apple, write a .tbd, which needs no compiler. It lives in a temp directory, so it can't be bundled into the addon. Both cargo (-L, -l) and CMake (an imported target) can take the name at build time. The react-native package reference then only determines the library name. Every symbol in the stub should be checked against the real Hermes, or a mismatch only appears at load time.
Donor app. Using a library's example app as the source of Hermes works, since the binary's contents don't matter, but it's heavy for what it provides: a list of names and one library name. Dev builds often produce one ABI or simulator slice, the example app depends on the addon that would need its output, the files sit in hashed Gradle caches or Pods/, and the donor's Hermes must never be bundled with the addon.
Rewrite at app build. Publish against a generic stub with no injection support, then stamp in the real name in the host's link step, which already runs install_name_tool and codesign on Apple. On Android, a long reserved name can be replaced in place with no patchelf, which isn't in the NDK. Addons must be prepared at build time, with that reserved name on Android and -headerpad_max_install_names on Apple, since a longer name otherwise fails. Tested on dummy binaries, and a rewritten ELF loads and runs on the emulator. It doesn't help a host with a static engine or a split implementation, and an addon that skips the step fails at load with library not found (confirmed on the emulator).
Host-built facade. The addon links a stub named node-api, and never mentions Hermes. The host builds a library with that name during the app build, against the Hermes it already links, so addons don't need rebuilding:
| Android | Apple | |
|---|---|---|
| Recorded by the addon | libnode-api.so |
@rpath/node-api.framework/node-api |
Every published addon records this name, so it's effectively a stable ABI contract and can't easily change once prebuilds ship. node-api names the interface rather than an engine or this project, which is also what core would need to adopt. It's close to the host's existing libnode-api-host.so, but nothing on the Android or iOS system paths checked uses it, and Node's own shared library is libnode.
- The backend is inferred from the app or set by configuration. A Hermes rename is a one-line host change, and no published addon breaks.
- If
weak-node-apican also be built under the neutral name, one addon binary could work with either backend, and the choice moves to the app. Mode 5 could then replace mode 1 for addons, leavingweak-node-apias one backend the app can choose. - It suits the RFC: core only has to provide a library with a given name, and Hermes stays private.
Unlike weak-node-api, a facade has no forwarding functions, no table and no injection step. The loader resolves each symbol once, and the addon holds the engine's own address:
- Apple: a symbol is bound to the library the addon recorded, so something at that name must provide it.
-reexported_symbols_listdoes that with no code: in a test the library had an empty__textsection, the addon received the same function address as Hermes, and a symbol outside the list failed to link. The list comes fromgetNodeApiFunctions(), so only Node-API is exposed. An empty library at that name fails withSymbol not found, and a missing one withLibrary not loaded. A symlink to Hermes also ran on macOS, but whether it loads as a single image or survives iOS signing and App Store validation is unknown. A copy must not be used, as it would likely mean two VMs. - Android: an empty library with a
DT_NEEDEDon Hermes, because bionic's breadth-first local group makes Hermes reachable. ELF has no per-symbol re-export, so the stub is what limits an addon to Node-API at link time. Tested on the emulator from a shell process, and removing theDT_NEEDEDmakes the load fail.
None of this has run in a real app, or on iOS.
Flat lookup. -undefined dynamic_lookup and -flat_namespace work on macOS with no stub or library, but both are rejected. They don't constrain addons to the Node-API surface, so a typo or a non-Node-API symbol compiles and only fails on a device, which is a worse developer experience. They're also deprecated on iOS. In the case inspected, binding was lazy, so a missing symbol probably fails at call time. That's why they're rejected for iOS and Android. Mode 3 stays as an option for Node.js targets (#217), where the runtime is the host and this is the usual way to build addons.
Blockers
Stock React Native can't do this yet. static_h has API/napi and enables it by default, but it is on neither main nor any release. Hermes 260318099.0.5 (used by React Native 0.88) exports no napi_*, node_api_* or hermes_napi* symbols on arm64 Android or iOS. So until #412 clears, mode 2 only works with our vendored Hermes.
A react-native install doesn't contain Hermes. react-native@0.88.1 on npm has no binaries, only podspecs, build scripts and sdks/hermes-engine/version.properties. The library comes from Maven on Android (com.facebook.hermes:hermes-android) and from a tarball fetched by pod install on Apple, or from our vendored build. Pointing at a package identifies which Hermes, but doesn't supply it. A generated stub removes this blocker for linking, though the app still needs a Hermes with Node-API at runtime.
hermes_napi.h also doesn't ship in any headers yet. That matters less for addons, which take node_api.h from weak-node-api today.
React Native drops the reason for Hermes fatal errors on the New Architecture. We want to delete the host's napi_fatal_error (#492), so that shim and direct-linked addons behave the same, but that's blocked upstream by react/react-native#59728, with a proposed fix in react/react-native#59729. Mode 2 and 5 addons bypass the host's override, so they would hit this too.
Changes needed
- Stub generator: shared by
cmake-rnandferric, built ongetNodeApiFunctions(), with the library name as a parameter. - Facade: built by the host during the app build, with a generated re-export list on Apple.
cmake-rn- Replace
--no-weak-node-api-linkagewith a linkage choice and a way to name the React Native package.--react-native-packageandresolveReactNativePathin the host'svendor-hermesare a precedent. - Update
src/weak-node-api.ts, whereCMAKE_JS_LIBpoints at the shim. - Update
src/init.ts(WEAK_NODE_API_MODES,inspectCmakeProject) andtemplates/node-api-link.cmake. - Key design point: addon
CMakeLists.txtfiles name theweak-node-apitarget directly, so a CLI flag alone can't switch them. They need a neutral target, with the mode deciding what it points at.
- Replace
ferricsrc/cargo.tshard-codes the shim on both platforms and has no opt-out, so it needs the same choice.src/features.tsrejects the napi-sysdyn-symbolsfeature, and its message assumes the shim. Revisit it for modes 3 and 4.
gyp-to-cmake:--weak-node-apiis a boolean, andsrc/transformer.tsemits the target name.host: the shim is a hard dependency in the podspec,android/build.gradleandandroid/CMakeLists.txt, andinjectIntoWeakNodeApiaborts if the shim fails to load. It has to become optional before an app can drop the shim entirely. The host'snapi_fatal_erroroverride should be deleted (refactor(host): drop the shadowing napi_fatal_error #492), once the upstream blocker above is fixed.- Examples and tests:
node-addon-examples,cmake-rn-example,ferric-exampleand the test app need at least one addon on mode 5, and one on mode 2. - Docs:
READMEs anddocs/WEAK-NODE-API.md.
Suggested order
- Spike: load an addon linked against a generated stub through a host-built facade in the test app, on both platforms, Apple first as the riskiest. Confirm the exports at the pinned commit with
nm/readelf. - Add the stub generator and the facade, with a check of the stub against the real Hermes.
- Introduce the neutral CMake target, with
weak-node-apias its only backend, and no behavior change. - Add the linkage choice to
cmake-rn, thenferricandgyp-to-cmake. - Make the shim optional in the host.
- Use the working example as the basis for the RFC in Upstreaming the Node-API host into React Native core ☂️ #447.
Independent of that order, delete the host's napi_fatal_error (#492) once react/react-native#59728 is fixed.
Open questions
- Should a prebuild record its linkage, so the host can fail early with a clear message?
- Should Apple builds turn on
-headerpad_max_install_namesfrom the start, to keep the rewrite option open? - How do published prebuilds fit the RFC? Likely answer: out of scope for core, handled by
weak-node-apihere and possibly by node-api: use v-table to reverse module dependencies nodejs/node#60916 later. That PR is still open and unreleased. - Can
weak-node-apibe built under thenode-apiname, as an alternative backend? - Should in-app and from-source addons use the same facade path, so there's only one to maintain?
Related
#447, #491, #492, react/react-native#59728, #412, #428, #217, #4
Activity
[-]Link addons directly against a React Native's Hermes as an alternative to weak-node-api[/-][+]Get Node-API from the app's Hermes, through a host-built facade or direct linking, as an alternative to weak-node-api[/+]