Skip to content

How should addons get Node-API from the app's Hermes? #490

Closed

Description

@kraenhansen

How should an addon get Node-API from the Hermes an app actually uses, instead of through weak-node-api? Background for the RFC in #447, where this extends the "link the Hermes/React Native framework directly" option.

The work is tracked in:

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, through a generated stub (#494), and the basis for the RFC
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 small prebuilt library the host embeds in the app Deferred (#496). A later option for published prebuilds

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 Possible with a stub, tied to the library name. Mode 5 removes that tie

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 The basis for the RFC
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, prebuilt facade Neutral name, no rebuilds No Yes, with no code Deferred
-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).

Prebuilt facade. The addon links a stub named node-api, and never mentions Hermes. A facade with that name is an ordinary CMake project built by cmake-rn --no-weak-node-api-linkage, and link embeds it like any other module, so CocoaPods and Gradle only drive the existing step. The facade links a stub of Hermes rather than Hermes itself, so it can be compiled once and shipped, with no Hermes at build time. The spike did this on both platforms:

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 host picks which prebuilt facade to embed, inferred from the app or set by configuration. A Hermes rename or a new engine means adding a facade, 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. Removing the DT_NEEDED makes the load fail.

The spike ran this in the test app on the iOS simulator and an Android emulator. See its findings in the comments.

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. Shipping Node-API is one of the RFC's asks.

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.

Addons compile against node-api-headers, which defines the interface. hermes_napi.h declares embedder API and isn't meant for addons, so it stays internal to whatever provides the napi host.

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.

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.

Related

#447, #494, #495, #496, #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
  4. changed the title [-]Get Node-API from the app's Hermes, through a host-built facade or direct linking, as an alternative to weak-node-api[/-] [+]Get Node-API from the app's Hermes, through a prebuilt facade or direct linking, as an alternative to weak-node-api[/+] on Nov 1, 2026
  5. kraenhansen commented on Nov 1, 2026

    @kraenhansen
    CollaboratorAuthor

    Spike results

    An addon that only knows the name node-api loads and runs in the real test app on Hermes, with no weak-node-api involved in its calls, on both platforms. tests/async was relinked by hand, with the vendored Hermes at the pinned commit. Throwaway code, not for merging.

    iOS simulator (26.2, Debug) Android emulator (arm64, API 37, 16 KB pages, Release)
    nodeAddonExamples suite 18 of 18, as baseline 18 of 18, as baseline
    async records @rpath/node-api.framework/node-api libnode-api.so
    Where a probe in the addon found napi_create_function hermesvm.framework/hermesvm libhermesvm.so
    Node-API exports in Hermes vs getNodeApiFunctions() 155 of 155, no differences 155 exported
    Addons in the same run 1 on the facade, 20 on the shim 1 on the facade, 20 on the shim
    Facade code Empty __text, 155 re-exports 0 functions, 3.7 KB in the APK
    Typo or non-Node-API symbol Fails at link time Fails at link time

    What changed from the plan

    • No CocoaPods or Gradle changes are needed to embed the facade. It's a small CMake project built by cmake-rn --no-weak-node-api-linkage, and link embeds and signs it like any other module.
    • The facade doesn't need Hermes to build. It links a stub (.tbd, or an empty .so with the right SONAME), so it can be prebuilt. The issue now says this instead of "built during the app build".

    Hacks a real version must replace

    • getLibraryName has a one-line exception, because link would otherwise rename the facade to package--name.
    • The stub, the addon relink and the symbol list were made by hand or with throwaway scripts.

    Not tested

    • Device builds, device signing and App Store validation. There was no physical iPhone, and whether a re-export-only framework passes validation is the main remaining risk.
    • iOS Release, armeabi-v7a, x86 and x86_64, and other Apple platforms.
    • Any addon other than async, ferric/napi-rs, and addons using node-addon-api.
    • Swapping in another facade or backend.

    Other things found

    • Android: after the first Gradle run, an empty react-native/node_modules/@react-native/codegen directory is left behind, and it makes the next run fail with Cannot find module .../combine-js-to-schema-cli.js. Seen in this repo's pnpm workspace, not yet tracked to a cause.
    • Android: a rebuilt addon reached auto-linked, but Gradle treated the native libs as up to date and packaged the previous binary, so a re-run can test stale code without any error. A green test run isn't proof here. Clearing the merged native lib outputs fixed it. Possibly a bug in how link and jniLibs interact, also not tracked down.

    🤖 Generated with Claude Code

  6. locked and limited conversation to collaborators on Nov 1, 2026
  7. converted this issue into a discussion #493 on Nov 1, 2026
  8. changed the title [-]Get Node-API from the app's Hermes, through a prebuilt facade or direct linking, as an alternative to weak-node-api[/-] [+]How should addons get Node-API from the app's Hermes?[/+] on Nov 1, 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