Skip to content

Commit f41deb0

Browse files
kraenhansenclaude
andauthored
Load addons through Hermes' hermes_napi_load_module (#445)
* Load addons through Hermes' hermes_napi_load_module The vendored Hermes ships a first-party addon loader that does what CxxNodeApiHostModule did by hand — dlopen, resolve the init function, create the exports object and call it — plus the deprecated napi_module_register fallback the host never implemented. Hand the platform specific path to it instead, and drop AddonLoaders.hpp along with the host's own loading and initialization code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013ugFE6vmMUVMTuoupvhMhX * Trigger the label-gated CI jobs The check workflow only re-evaluates its label conditions on opened, synchronize and reopened events, so the labels added after opening this PR need a push to take effect. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013ugFE6vmMUVMTuoupvhMhX --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 56ae5f8 commit f41deb0

6 files changed

Lines changed: 143 additions & 208 deletions

File tree

.changeset/hermes-loads-addons.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"react-native-node-api": patch
3+
---
4+
5+
Load addons through Hermes' `hermes_napi_load_module` instead of the host's own
6+
`dlopen` + `dlsym` implementation:
7+
8+
- Addons that register themselves by calling the deprecated
9+
`napi_module_register` are now supported. Previously only addons exporting a
10+
`napi_register_module_v1` symbol could be loaded, and the rest resolved to
11+
`undefined`.
12+
- A failing `requireNodeAddon` now throws an error naming the addon, the path
13+
that was tried and the underlying reason (e.g. the `dlopen` error), instead
14+
of silently resolving to `undefined`.
15+
- `node_api_get_module_file_name` now reports the path the addon was loaded
16+
from, instead of an empty string.
17+
18+
This also opens a Node-API handle scope around loading and initializing an
19+
addon. Without one, the `exports` object handed to the addon's initialization
20+
function was not reachable by the garbage collector, so a collection triggered
21+
during initialization could free it while the addon was still populating it.

docs/HOW-IT-WORKS.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -91,15 +91,15 @@ module.exports = require("react-native-node-api").requireNodeAddon(
9191
> In the time of writing, this code only supports iOS as passes the path to the library with its .framework.
9292
> We plan on generalizing this soon 🤞
9393
94-
## Transformed code calls into `react-native-node-api`, loading the platform specific dynamic library
94+
## `react-native-node-api` creates a `napi_env` for the addon
9595

96-
The native implementation of `requireNodeAddon` is responsible for loading the dynamic library and allow the Node-API module to register its initialization function, either by exporting a `napi_register_module_v1` function or by calling the (deprecated) `napi_module_register` function.
96+
The native implementation of `requireNodeAddon` turns the library name into a platform specific path (`@rpath/<name>.framework/<name>` on Apple platforms, `lib<name>.so` on Android) and creates a `napi_env` for the addon by calling `hermes_napi_create_env` with the low-level Hermes VM runtime behind the `jsi::Runtime`. As in Node.js, each addon gets its own environment.
9797

98-
In any case the native code stores the initialization function in a data-structure.
98+
## Hermes loads the platform specific dynamic library and initializes the addon
9999

100-
## `react-native-node-api` creates a `napi_env` and initialize the Node-API module
100+
The host hands the path and the environment to `hermes_napi_load_module`, which opens the dynamic library and finds the addon's initialization function, either by looking up an exported `napi_register_module_v1` symbol or by falling back to the `napi_module` passed to a (deprecated) `napi_module_register` call made while the library was loading. It then calls that function with the environment and a fresh `exports` object.
101101

102-
The initialization function of a Node-API module expects a `napi_env`, which we create by calling `hermes_napi_create_env` with the low-level Hermes VM runtime behind the `jsi::Runtime`. As in Node.js, each addon gets its own environment.
102+
If the library cannot be opened, or exports no initialization function, `requireNodeAddon` throws.
103103

104104
## The library's C++ code initialize the `exports` object
105105

packages/host/cpp/AddonLoaders.hpp

Lines changed: 0 additions & 109 deletions
This file was deleted.

packages/host/cpp/CxxNodeApiHostModule.cpp

Lines changed: 99 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,45 @@
33

44
#include <jsi/hermes-interfaces.h>
55

6+
#include <cassert>
7+
#include <cstdio>
8+
#include <string>
9+
610
using namespace facebook;
711

812
namespace callstack::react_native_node_api {
913

14+
namespace {
15+
16+
/// Renders the exception Hermes left pending on `env` as a message, clearing
17+
/// it so the env is usable again. Returns an empty string if the exception
18+
/// cannot be read, in which case the caller reports the status alone.
19+
std::string takePendingExceptionMessage(napi_env env) {
20+
napi_value error = nullptr;
21+
if (napi_get_and_clear_last_exception(env, &error) != napi_ok ||
22+
error == nullptr) {
23+
return {};
24+
}
25+
napi_value asString = nullptr;
26+
if (napi_coerce_to_string(env, error, &asString) != napi_ok) {
27+
return {};
28+
}
29+
size_t length = 0;
30+
if (napi_get_value_string_utf8(env, asString, nullptr, 0, &length) !=
31+
napi_ok) {
32+
return {};
33+
}
34+
std::string message(length, '\0');
35+
if (napi_get_value_string_utf8(env, asString, message.data(), length + 1,
36+
&length) != napi_ok) {
37+
return {};
38+
}
39+
message.resize(length);
40+
return message;
41+
}
42+
43+
} // namespace
44+
1045
CxxNodeApiHostModule::CxxNodeApiHostModule(
1146
std::shared_ptr<react::CallInvoker> jsInvoker)
1247
: TurboModule(CxxNodeApiHostModule::kModuleName, jsInvoker) {
@@ -58,8 +93,8 @@ CxxNodeApiHostModule::requireNodeAddon(jsi::Runtime &rt,
5893
if (1 == count && args[0].isString()) {
5994
return thisModule.requireNodeAddon(rt, args[0].asString(rt));
6095
}
61-
// TODO: Throw a meaningful error
62-
return jsi::Value::undefined();
96+
throw jsi::JSError(rt, "Expected requireNodeAddon to be called with a single "
97+
"library name string");
6398
}
6499

65100
jsi::Value
@@ -72,116 +107,94 @@ CxxNodeApiHostModule::requireNodeAddon(jsi::Runtime &rt,
72107

73108
// Check if this module has been loaded already, if not then load it...
74109
if (inserted) {
75-
if (!loadNodeAddon(addon, libraryNameStr)) {
76-
return jsi::Value::undefined();
110+
try {
111+
loadNodeAddon(rt, addon, libraryNameStr);
112+
} catch (...) {
113+
// Leave no half-initialized entry behind, so a later require of the same
114+
// addon retries the load instead of reading a missing global.
115+
nodeAddons_.erase(it);
116+
throw;
77117
}
78118
}
79119

80-
// Initialize the addon if it has not already been initialized
81-
if (!rt.global().hasProperty(rt, addon.generatedName.data())) {
82-
initializeNodeModule(rt, addon);
83-
}
84-
85120
// Look the exports up (using JSI) and return it...
86-
return rt.global().getProperty(rt, addon.generatedName.data());
121+
return rt.global().getProperty(rt, addon.generatedName.c_str());
87122
}
88123

89-
bool CxxNodeApiHostModule::loadNodeAddon(NodeAddon &addon,
90-
const std::string &libraryName) const {
124+
void CxxNodeApiHostModule::loadNodeAddon(jsi::Runtime &rt, NodeAddon &addon,
125+
const std::string &libraryName) {
91126
#if defined(__APPLE__)
92-
std::string libraryPath =
127+
const std::string libraryPath =
93128
"@rpath/" + libraryName + ".framework/" + libraryName;
94129
#elif defined(__ANDROID__)
95-
std::string libraryPath = "lib" + libraryName + ".so";
130+
const std::string libraryPath = "lib" + libraryName + ".so";
96131
#else
97-
abort()
132+
#error "Loading Node-API addons is unsupported on this platform"
98133
#endif
99134

100135
log_debug("[%s] Loading addon by '%s'", libraryName.c_str(),
101136
libraryPath.c_str());
102137

103-
typename LoaderPolicy::Symbol initFn = NULL;
104-
typename LoaderPolicy::Module library =
105-
LoaderPolicy::loadLibrary(libraryPath.c_str());
106-
if (NULL != library) {
107-
log_debug("[%s] Loaded addon", libraryName.c_str());
108-
addon.moduleHandle = library;
109-
110-
// Generate a name allowing us to reference the exports object from JSI
111-
// later Instead of using random numbers to avoid name clashes, we just use
112-
// the pointer address of the loaded module
113-
addon.generatedName.resize(32, '\0');
114-
snprintf(addon.generatedName.data(), addon.generatedName.size(),
115-
"RN$NodeAddon_%p", addon.moduleHandle);
116-
117-
initFn = LoaderPolicy::getSymbol(library, "napi_register_module_v1");
118-
if (NULL != initFn) {
119-
log_debug("[%s] Found napi_register_module_v1 (%p)", libraryName.c_str(),
120-
initFn);
121-
addon.init = (napi_addon_register_func)initFn;
122-
} else {
123-
log_debug("[%s] Failed to find napi_register_module_v1. Expecting the "
124-
"addon to call napi_module_register to register itself.",
125-
libraryName.c_str());
126-
}
127-
// TODO: Read "node_api_module_get_api_version_v1" to support the addon
128-
// declaring its Node-API version
129-
// @see
130-
// https://github.com/callstackincubator/react-native-node-api/issues/4
131-
} else {
132-
log_debug("[%s] Failed to load library", libraryName.c_str());
133-
}
134-
return NULL != initFn;
135-
}
136-
137-
bool CxxNodeApiHostModule::initializeNodeModule(jsi::Runtime &rt,
138-
NodeAddon &addon) {
139-
// We should check if the module has already been initialized
140-
assert(NULL != addon.moduleHandle);
141-
assert(NULL != addon.init);
142-
napi_status status = napi_ok;
143-
// TODO: Read the version from the addon
144-
// @see
145-
// https://github.com/callstackincubator/react-native-node-api/issues/4
146-
147138
// Create this addon's Node-API environment. Hermes binds an env to its
148139
// low-level VM runtime, which we reach through the (unstable) IHermes JSI
149140
// interface, and takes ownership: the env is torn down with the runtime, so
150141
// there is nothing to free here. Each addon gets its own env, as in Node.
151-
if (addon.env == nullptr) {
152-
// Fully qualified: `using namespace facebook` makes a bare `hermes`
153-
// ambiguous with the top-level `::hermes` (VM) namespace pulled in via
154-
// <jsi/hermes-interfaces.h>.
155-
auto *hermes = facebook::jsi::castInterface<facebook::hermes::IHermes>(&rt);
156-
if (hermes == nullptr) {
157-
log_debug("NapiHost: JSI runtime is not castable to IHermes; cannot "
158-
"create a Node-API environment");
159-
abort();
160-
}
161-
addon.env =
162-
hermes_napi_create_env(hermes->getVMRuntimeUnsafe(), hostContext_->host());
163-
assert(addon.env != nullptr);
142+
//
143+
// Fully qualified: `using namespace facebook` makes a bare `hermes`
144+
// ambiguous with the top-level `::hermes` (VM) namespace pulled in via
145+
// <jsi/hermes-interfaces.h>.
146+
auto *hermes = facebook::jsi::castInterface<facebook::hermes::IHermes>(&rt);
147+
if (hermes == nullptr) {
148+
log_debug("NapiHost: JSI runtime is not castable to IHermes; cannot "
149+
"create a Node-API environment");
150+
abort();
164151
}
152+
addon.env = hermes_napi_create_env(hermes->getVMRuntimeUnsafe(),
153+
hostContext_->host());
154+
assert(addon.env != nullptr);
165155
napi_env env = addon.env;
166156

167-
// Create the "exports" object
168-
napi_value exports;
169-
status = napi_create_object(env, &exports);
157+
// A name to reference the exports object by from JSI. Instead of using
158+
// random numbers to avoid name clashes, we use the address of the env, which
159+
// is unique per addon per runtime.
160+
char generatedName[32];
161+
snprintf(generatedName, sizeof(generatedName), "RN$NodeAddon_%p",
162+
static_cast<void *>(env));
163+
addon.generatedName = generatedName;
164+
165+
// Every napi_value below is created in this scope, and only reachable from
166+
// the JavaScript global (or dropped) once it closes.
167+
napi_handle_scope scope = nullptr;
168+
napi_status status = napi_open_handle_scope(env, &scope);
170169
assert(status == napi_ok);
171170

172-
// Call the addon init function to populate the "exports" object
173-
// Allowing it to replace the value entirely by its return value
174-
exports = addon.init(env, exports);
175-
176-
napi_value global;
177-
napi_get_global(env, &global);
178-
assert(status == napi_ok);
171+
napi_value exports = nullptr;
172+
status = hermes_napi_load_module(env, libraryPath.c_str(), &exports);
173+
if (status == napi_ok) {
174+
napi_value global = nullptr;
175+
status = napi_get_global(env, &global);
176+
assert(status == napi_ok);
177+
status = napi_set_named_property(env, global, addon.generatedName.c_str(),
178+
exports);
179+
assert(status == napi_ok);
180+
}
179181

180-
status =
181-
napi_set_named_property(env, global, addon.generatedName.data(), exports);
182-
assert(status == napi_ok);
182+
const bool failed = status != napi_ok;
183+
std::string message;
184+
if (failed) {
185+
message = takePendingExceptionMessage(env);
186+
if (message.empty()) {
187+
message = "Node-API status " + std::to_string(status);
188+
}
189+
}
190+
const napi_status closeStatus = napi_close_handle_scope(env, scope);
191+
assert(closeStatus == napi_ok);
192+
(void)closeStatus;
183193

184-
return true;
194+
if (failed) {
195+
throw jsi::JSError(rt, "Failed to load '" + libraryName + "' addon from '" +
196+
libraryPath + "': " + message);
197+
}
185198
}
186199

187200
} // namespace callstack::react_native_node_api

0 commit comments

Comments
 (0)