Skip to content

Get Node-API from the app's Hermes, through a host-built facade or direct linking, as an alternative to weak-node-api #490

Open

Description

@kraenhansen

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

  1. 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.
  2. 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-api out completely. The shim then becomes this project's distribution tooling, on our side of the seam alongside cmake-rn and ferric.

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.so to libhermesvm.so, the prefab module to hermes-engine::hermesvm, and hermes.xcframework to hermesvm.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_error itself 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 hermesvm can't load under other engines. JSC has moved to community support and react-native-v8 looks inactive, but weak-node-api is 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_NEEDED both 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_env and napi_value are opaque, and public structs are covered by Node-API's stability promise. That promise doesn't extend to node_api_* experimental functions or hermes_napi_*.
  • Layouts and NAPI_VERSION come from the node_api.h the addon compiles against, not from the linked library.
  • The tooling must reject a static Hermes. Linking a .a copies 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-api can 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, leaving weak-node-api as 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_list does that with no code: in a test the library had an empty __text section, the addon received the same function address as Hermes, and a symbol outside the list failed to link. The list comes from getNodeApiFunctions(), so only Node-API is exposed. An empty library at that name fails with Symbol not found, and a missing one with Library 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_NEEDED on 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 the DT_NEEDED makes 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-rn and ferric, built on getNodeApiFunctions(), 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-linkage with a linkage choice and a way to name the React Native package. --react-native-package and resolveReactNativePath in the host's vendor-hermes are a precedent.
    • Update src/weak-node-api.ts, where CMAKE_JS_LIB points at the shim.
    • Update src/init.ts (WEAK_NODE_API_MODES, inspectCmakeProject) and templates/node-api-link.cmake.
    • Key design point: addon CMakeLists.txt files name the weak-node-api target directly, so a CLI flag alone can't switch them. They need a neutral target, with the mode deciding what it points at.
  • ferric
    • src/cargo.ts hard-codes the shim on both platforms and has no opt-out, so it needs the same choice.
    • src/features.ts rejects the napi-sys dyn-symbols feature, and its message assumes the shim. Revisit it for modes 3 and 4.
  • gyp-to-cmake: --weak-node-api is a boolean, and src/transformer.ts emits the target name.
  • host: the shim is a hard dependency in the podspec, android/build.gradle and android/CMakeLists.txt, and injectIntoWeakNodeApi aborts if the shim fails to load. It has to become optional before an app can drop the shim entirely. The host's napi_fatal_error override 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-example and the test app need at least one addon on mode 5, and one on mode 2.
  • Docs: READMEs and docs/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-api as its only backend, and no behavior change.
  • Add the linkage choice to cmake-rn, then ferric and gyp-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_names from 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-api here 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-api be built under the node-api name, 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

  1. added
    enhancementNew feature or request
    CMake RNOur `cmake` wrapping CLI
    Host 🏡Our `react-native-node-api-modules` package
    Linking 🔗Discovering and copying prebuilds from packages into the host
    on Oct 31, 2026
  2. self-assigned this
    on Oct 31, 2026
  3. changed the title [-]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[/+] on Oct 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

CMake RNOur `cmake` wrapping CLIFerric 🦀Host 🏡Our `react-native-node-api-modules` packageLinking 🔗Discovering and copying prebuilds from packages into the hostenhancementNew feature or requestgyp-to-cmake

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions