Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/hermes-napi-host-integration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"react-native-node-api": minor
---

Provide a `hermes_napi_host` implementation to the Hermes Node-API environments. This enables thread-safe functions (`napi_create_threadsafe_function` and friends) and moves `napi_async_work` execution onto a worker pool — previously the `execute` callback ran on the JavaScript thread, blocking it for the duration of the work. The host is also in place before an addon's module init runs, so async work and thread-safe functions can now be created during initialization.
36 changes: 36 additions & 0 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -143,12 +143,48 @@
cmake --build build
ctest --test-dir build --output-on-failure
working-directory: packages/weak-node-api
host-cpp-tests:
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/next' || contains(github.event.pull_request.labels.*.name, 'host')
strategy:
fail-fast: false
matrix:
runner:
- ubuntu-latest
- windows-latest
- macos-latest
runs-on: ${{ matrix.runner }}
name: Host C++ tests (${{ matrix.runner }})
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v6
with:
node-version: lts/krypton
cache: pnpm
- name: Setup cpp tools
uses: aminya/setup-cpp@v1
with:
clang-format: true
- name: ccache
uses: hendrikmuhs/ccache-action@v1.2
with:
key: ${{ github.job }}-${{ runner.os }}
- run: pnpm install
- run: pnpm run build
- name: Prepare weak-node-api
run: pnpm --filter weak-node-api run prebuild:prepare
- name: Build and run react-native-node-api host C++ tests
run: |
cmake -S tests -B tests/build
cmake --build tests/build
ctest --test-dir tests/build --output-on-failure
working-directory: packages/host
test-ios:
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/next' || contains(github.event.pull_request.labels.*.name, 'Apple 🍎')
name: Test app (iOS)
runs-on: macos-latest
steps:
- uses: actions/checkout@v4

Check warning

Code scanning / CodeQL

Workflow does not contain permissions Medium

Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {contents: read}
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v6
with:
Expand Down
8 changes: 7 additions & 1 deletion apps/test-app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,13 @@ function loadTests({
)) {
describe(suiteName, () => {
for (const [exampleName, requireExample] of Object.entries(examples)) {
it(exampleName, async () => {
it(exampleName, async function () {
if (exampleName === "threadsafe-function") {
// The ported Node.js suite marshals thousands of values across
// threads; every other example keeps the default timeout so a
// genuine deadlock still fails fast.
this.timeout(30_000);
}
const test = requireExample();
if (test instanceof Function) {
const result = test();
Expand Down
2 changes: 1 addition & 1 deletion docs/HOW-IT-WORKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ Hermes implements both halves of Node-API: the engine-specific functions (see [j
- `ref_loop` / `unref_loop` — keep the event loop alive while a thread-safe function is referenced, modelling libuv's "ref" semantics.
- `fatal_exception` and, for embedders that have one, a libuv loop pointer for `napi_get_uv_event_loop`.

`react-native-node-api` provides that struct, backed by React Native's `CallInvoker` for anything that has to land on the JavaScript thread and a worker pool for the rest.
`react-native-node-api` provides that struct (see `packages/host/cpp/HermesNapiHost.cpp`), backed by React Native's `CallInvoker` for anything that has to land on the JavaScript thread and a process-global worker pool (four threads, like libuv's default) for the rest. `ref_loop` / `unref_loop` and the libuv loop pointer are deliberately left null: React Native's JavaScript thread has no ref-counted event-loop lifetime to model, so thread-safe function ref/unref are tracked but inert, and `napi_get_uv_event_loop` returns `napi_generic_failure` as upstream documents for hosts without libuv.

## `my-app` regain control and call `add`

Expand Down
3 changes: 3 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ export default tseslint.config(
},
globals: {
...globals.commonjs,
// Timers provided by React Native's runtime, where these files run.
setTimeout: "readonly",
setImmediate: "readonly",
},
},
rules: {
Expand Down
3 changes: 3 additions & 0 deletions packages/host/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,6 @@ android/build/

# Generated via `npm run generate-weak-node-api-injector`
/cpp/WeakNodeApiInjector.cpp

# C++ test build artifacts (see `npm run test:configure`)
/tests/build/
4 changes: 2 additions & 2 deletions packages/host/android/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ add_library(node-api-host SHARED
../cpp/WeakNodeApiInjector.cpp
../cpp/RuntimeNodeApi.cpp
../cpp/RuntimeNodeApi.hpp
../cpp/RuntimeNodeApiAsync.cpp
../cpp/RuntimeNodeApiAsync.hpp
../cpp/HermesNapiHost.cpp
../cpp/HermesNapiHost.hpp
)

target_include_directories(node-api-host PRIVATE
Expand Down
55 changes: 36 additions & 19 deletions packages/host/cpp/CxxNodeApiHostModule.cpp
Original file line number Diff line number Diff line change
@@ -1,27 +1,10 @@
#include "CxxNodeApiHostModule.hpp"
#include "Logger.hpp"
#include "RuntimeNodeApiAsync.hpp"

#include <jsi/hermes-interfaces.h>

using namespace facebook;

// Declared by the vendored Hermes in API/napi/hermes_napi.h. We forward declare
// it here (rather than including that header) to avoid pulling in Hermes' own
// node_api.h alongside the weak-node-api copy already included transitively.
//
// The declaration must be `extern "C"`: since facebook/hermes#2106 (included in
// the pinned Hermes commit) the public hermes_napi.h wraps these entry points
// in `extern "C"`, so Hermes exports the unmangled C symbol. Without matching C
// linkage here the reference would be to the C++-mangled name and the app fails
// to link ("Undefined symbol: hermes_napi_create_env"). Passing host as nullptr
// is enough — async work / thread-safe functions will return failure until a
// host integration is wired up (Phase 3).
extern "C" {
struct hermes_napi_host;
napi_env hermes_napi_create_env(void *hermes_runtime, hermes_napi_host *host);
}

namespace callstack::react_native_node_api {

CxxNodeApiHostModule::CxxNodeApiHostModule(
Expand All @@ -31,6 +14,40 @@ CxxNodeApiHostModule::CxxNodeApiHostModule(
MethodMetadata{1, &CxxNodeApiHostModule::requireNodeAddon};

callInvoker_ = std::move(jsInvoker);

// The JS-thread dispatcher behind the hermes_napi_host integration:
// CallInvoker::invokeAsync is callable from any thread, never runs the
// function inline and delivers in order on the JS thread.
//
// Teardown is the load-bearing case. What the host integration needs is
// that a function handed to this dispatcher either runs on the JS thread
// while the runtime is alive, or is dropped — never invoked against a
// destroyed runtime. In bridgeless React Native the CallInvoker received
// here is a RuntimeSchedulerCallInvoker holding a std::weak_ptr to the
// RuntimeScheduler; the ReactInstance owns scheduler and runtime together
// and invokeAsync no-ops once the scheduler is gone, so work cannot outlive
// the runtime it targets. The weak capture below covers the remaining
// window where this module (and its CallInvoker reference) is released
// during instance teardown.
//
// Dropping is safe precisely because a drop implies that teardown: every
// env this host serves is owned by that same runtime and destroyed with it,
// so the completion or tsfn dispatch being dropped has no live observer.
// The one caller that could still see the difference —
// napi_cancel_async_work — receives the verdict through this dispatcher's
// return value (see HostContext::cancelWork).
hostContext_ = HostContext::create(
[weakInvoker = std::weak_ptr(callInvoker_)](std::function<void()> &&fn) {
auto invoker = weakInvoker.lock();
if (!invoker) {
log_warning(
"NapiHost: dropping a task posted after runtime teardown");
return false;
}
Comment on lines +40 to +46

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This is the load-bearing assumption of the whole change, and it's the one thing the C++ tests can't reach (they use FakeJsQueue), so it's worth writing down why it holds.

The direction this guards is the safe one: if the shared_ptr<CallInvoker> is already gone, dropping is fine — the env died with the runtime that owned it. The risky direction is the opposite, a CallInvoker that is still alive while the jsi::Runtime is not, or a RuntimeScheduler that accepts invokeAsync during shutdown and then drops the task:

  • For async work, completeasyncWorkCompleteTrampolinenapi_open_handle_scope(env, …) against a napi_env that Hermes destroyed with its Runtime.
  • For thread-safe functions, a silently dropped dispatch is unrecoverable: napi_threadsafe_function__::dispatch_pending stays true, so no producer ever posts again (hermes_napi_tsfn.cpp:451-455), the tsfn is never finalized and finalize_cb never runs. postTask's own comment in HermesNapiHost.cpp names this failure mode but the guarantee it relies on is implemented here.

Is CallInvoker release ordered before ~Runtime in bridgeless RN? If yes, a one-line note pointing at where that ordering comes from would settle it. If it isn't guaranteed, the drop probably needs to hang off something with the runtime's lifetime rather than the invoker's.


Generated by Claude Code

invoker->invokeAsync(std::move(fn));
return true;
});
HostContext::retainForProcessLifetime(hostContext_);
}

jsi::Value
Expand Down Expand Up @@ -141,7 +158,8 @@ bool CxxNodeApiHostModule::initializeNodeModule(jsi::Runtime &rt,
"create a Node-API environment");
abort();
}
addon.env = hermes_napi_create_env(hermes->getVMRuntimeUnsafe(), nullptr);
addon.env =
hermes_napi_create_env(hermes->getVMRuntimeUnsafe(), hostContext_->host());
assert(addon.env != nullptr);
}
napi_env env = addon.env;
Expand All @@ -163,7 +181,6 @@ bool CxxNodeApiHostModule::initializeNodeModule(jsi::Runtime &rt,
napi_set_named_property(env, global, addon.generatedName.data(), exports);
assert(status == napi_ok);

callstack::react_native_node_api::setCallInvoker(env, callInvoker_);
return true;
}

Expand Down
4 changes: 4 additions & 0 deletions packages/host/cpp/CxxNodeApiHostModule.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
#include <node_api.h>

#include "AddonLoaders.hpp"
#include "HermesNapiHost.hpp"

namespace callstack::react_native_node_api {

Expand Down Expand Up @@ -37,6 +38,9 @@ class JSI_EXPORT CxxNodeApiHostModule : public facebook::react::TurboModule {
};
std::unordered_map<std::string, NodeAddon> nodeAddons_;
std::shared_ptr<facebook::react::CallInvoker> callInvoker_;
// The hermes_napi_host integration passed to every env this module creates.
// Also retained process-wide, as the envs outlive this module on teardown.
std::shared_ptr<HostContext> hostContext_;

using LoaderPolicy = PosixLoader; // FIXME: HACK: This is temporary workaround
// for my lazyness (work on iOS and Android)
Expand Down
Loading
Loading