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
7 changes: 7 additions & 0 deletions .changeset/expose-dlopen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"react-native-node-api": minor
---

Export `dlopen(module, filename)`, loading a Node-API addon with the same semantics as Node's `process.dlopen`: `filename` is passed as-is to the system's `dlopen` (e.g. `lib<name>.so` on Android, `@rpath/<name>.framework/<name>` on Apple platforms), the addon's exports are assigned to `module.exports` and nothing is cached.

`requireNodeAddon` is now built on top of it, with the platform specific path and the per-runtime exports cache moving from native code into TypeScript. Its behavior is unchanged.
4 changes: 2 additions & 2 deletions docs/HOW-IT-WORKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,13 +93,13 @@ module.exports = require("react-native-node-api").requireNodeAddon(

## `react-native-node-api` creates a `napi_env` for the addon

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.
`requireNodeAddon` returns the exports cached from an earlier load of the same library. Otherwise it turns the library name into a platform specific path (`@rpath/<name>.framework/<name>` on Apple platforms, `lib<name>.so` on Android) and calls `dlopen(module, filename)`, which has the same semantics as Node's `process.dlopen`. The native implementation of `dlopen` 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 load gets its own environment.

## Hermes loads the platform specific dynamic library and initializes the addon

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.

If the library cannot be opened, or exports no initialization function, `requireNodeAddon` throws.
If the library cannot be opened, or exports no initialization function, `dlopen` throws (and so does `requireNodeAddon`, without caching the failure). Otherwise the resulting exports object is assigned to `module.exports`.

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

Expand Down
172 changes: 69 additions & 103 deletions packages/host/cpp/CxxNodeApiHostModule.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -13,40 +13,60 @@ namespace callstack::react_native_node_api {

namespace {

/// Renders the exception Hermes left pending on `env` as a message, clearing
/// it so the env is usable again. Returns an empty string if the exception
/// cannot be read, in which case the caller reports the status alone.
std::string takePendingExceptionMessage(napi_env env) {
/// Hands `value` over to JSI. Hermes has no public bridge between `napi_value`
/// and `jsi::Value`, so it crosses through a global property named after the
/// env (unique per runtime), which is removed again straight after.
jsi::Value toJsiValue(jsi::Runtime &rt, napi_env env, napi_value value) {
char name[32];
snprintf(name, sizeof(name), "RN$NodeAddon_%p", static_cast<void *>(env));

napi_value global = nullptr;
napi_status status = napi_get_global(env, &global);
assert(status == napi_ok);
status = napi_set_named_property(env, global, name, value);
assert(status == napi_ok);

jsi::Value result = rt.global().getProperty(rt, name);

napi_value key = nullptr;
status = napi_create_string_utf8(env, name, NAPI_AUTO_LENGTH, &key);
assert(status == napi_ok);
bool deleted = false;
status = napi_delete_property(env, global, key, &deleted);
assert(status == napi_ok && deleted);
(void)status;
(void)deleted;
return result;
}

/// Takes the exception Hermes left pending on `env`, clearing it so the env is
/// usable again, or creates an error from `status` if there is none.
napi_value takeLoadError(napi_env env, napi_status status) {
napi_value error = nullptr;
if (napi_get_and_clear_last_exception(env, &error) != napi_ok ||
error == nullptr) {
return {};
}
napi_value asString = nullptr;
if (napi_coerce_to_string(env, error, &asString) != napi_ok) {
return {};
}
size_t length = 0;
if (napi_get_value_string_utf8(env, asString, nullptr, 0, &length) !=
napi_ok) {
return {};
}
std::string message(length, '\0');
if (napi_get_value_string_utf8(env, asString, message.data(), length + 1,
&length) != napi_ok) {
return {};
if (napi_get_and_clear_last_exception(env, &error) == napi_ok &&
error != nullptr) {
napi_valuetype type = napi_undefined;
if (napi_typeof(env, error, &type) == napi_ok && type != napi_undefined) {
return error;
}
}
message.resize(length);
return message;
const std::string message = "Node-API status " + std::to_string(status);
napi_value messageValue = nullptr;
napi_status createStatus = napi_create_string_utf8(
env, message.c_str(), message.size(), &messageValue);
assert(createStatus == napi_ok);
createStatus = napi_create_error(env, nullptr, messageValue, &error);
assert(createStatus == napi_ok);
(void)createStatus;
return error;
}

} // namespace

CxxNodeApiHostModule::CxxNodeApiHostModule(
std::shared_ptr<react::CallInvoker> jsInvoker)
: TurboModule(CxxNodeApiHostModule::kModuleName, jsInvoker) {
methodMap_["requireNodeAddon"] =
MethodMetadata{1, &CxxNodeApiHostModule::requireNodeAddon};
methodMap_["dlopen"] = MethodMetadata{2, &CxxNodeApiHostModule::dlopen};

callInvoker_ = std::move(jsInvoker);

Expand Down Expand Up @@ -85,60 +105,26 @@ CxxNodeApiHostModule::CxxNodeApiHostModule(
HostContext::retainForProcessLifetime(hostContext_);
}

jsi::Value
CxxNodeApiHostModule::requireNodeAddon(jsi::Runtime &rt,
react::TurboModule &turboModule,
const jsi::Value args[], size_t count) {
jsi::Value CxxNodeApiHostModule::dlopen(jsi::Runtime &rt,
react::TurboModule &turboModule,
const jsi::Value args[], size_t count) {
auto &thisModule = static_cast<CxxNodeApiHostModule &>(turboModule);
if (1 == count && args[0].isString()) {
return thisModule.requireNodeAddon(rt, args[0].asString(rt));
if (count >= 2 && args[0].isObject() && args[1].isString()) {
thisModule.dlopen(rt, args[0].asObject(rt), args[1].asString(rt).utf8(rt));
return jsi::Value::undefined();
}
throw jsi::JSError(rt, "Expected requireNodeAddon to be called with a single "
"library name string");
throw jsi::JSError(
rt, "Expected dlopen to be called with a module object and a filename");
}

jsi::Value
CxxNodeApiHostModule::requireNodeAddon(jsi::Runtime &rt,
const jsi::String libraryName) {
const std::string libraryNameStr = libraryName.utf8(rt);

auto [it, inserted] = nodeAddons_.emplace(libraryNameStr, NodeAddon());
NodeAddon &addon = it->second;

// Check if this module has been loaded already, if not then load it...
if (inserted) {
try {
loadNodeAddon(rt, addon, libraryNameStr);
} catch (...) {
// Leave no half-initialized entry behind, so a later require of the same
// addon retries the load instead of reading a missing global.
nodeAddons_.erase(it);
throw;
}
}

// Look the exports up (using JSI) and return it...
return rt.global().getProperty(rt, addon.generatedName.c_str());
}

void CxxNodeApiHostModule::loadNodeAddon(jsi::Runtime &rt, NodeAddon &addon,
const std::string &libraryName) {
#if defined(__APPLE__)
const std::string libraryPath =
"@rpath/" + libraryName + ".framework/" + libraryName;
#elif defined(__ANDROID__)
const std::string libraryPath = "lib" + libraryName + ".so";
#else
#error "Loading Node-API addons is unsupported on this platform"
#endif

log_debug("[%s] Loading addon by '%s'", libraryName.c_str(),
libraryPath.c_str());
void CxxNodeApiHostModule::dlopen(jsi::Runtime &rt, const jsi::Object &module,
const std::string &filename) {
log_debug("Loading addon from '%s'", filename.c_str());

// Create this addon's Node-API environment. Hermes binds an env to its
// low-level VM runtime, which we reach through the (unstable) IHermes JSI
// interface, and takes ownership: the env is torn down with the runtime, so
// there is nothing to free here. Each addon gets its own env, as in Node.
// there is nothing to free here. Each load gets its own env, as in Node.
//
// Fully qualified: `using namespace facebook` makes a bare `hermes`
// ambiguous with the top-level `::hermes` (VM) namespace pulled in via
Expand All @@ -149,52 +135,32 @@ void CxxNodeApiHostModule::loadNodeAddon(jsi::Runtime &rt, NodeAddon &addon,
"create a Node-API environment");
abort();
}
addon.env = hermes_napi_create_env(hermes->getVMRuntimeUnsafe(),
hostContext_->host());
assert(addon.env != nullptr);
napi_env env = addon.env;

// A name to reference the exports object by from JSI. Instead of using
// random numbers to avoid name clashes, we use the address of the env, which
// is unique per addon per runtime.
char generatedName[32];
snprintf(generatedName, sizeof(generatedName), "RN$NodeAddon_%p",
static_cast<void *>(env));
addon.generatedName = generatedName;
napi_env env = hermes_napi_create_env(hermes->getVMRuntimeUnsafe(),
hostContext_->host());
assert(env != nullptr);

// Every napi_value below is created in this scope, and only reachable from
// the JavaScript global (or dropped) once it closes.
// JSI (or dropped) once it closes.
napi_handle_scope scope = nullptr;
napi_status status = napi_open_handle_scope(env, &scope);
assert(status == napi_ok);

// Hermes creates the exports object itself rather than taking
// `module.exports`, so its result replaces `module.exports` below.
napi_value exports = nullptr;
status = hermes_napi_load_module(env, libraryPath.c_str(), &exports);
if (status == napi_ok) {
napi_value global = nullptr;
status = napi_get_global(env, &global);
assert(status == napi_ok);
status = napi_set_named_property(env, global, addon.generatedName.c_str(),
exports);
assert(status == napi_ok);
}

status = hermes_napi_load_module(env, filename.c_str(), &exports);
const bool failed = status != napi_ok;
std::string message;
if (failed) {
message = takePendingExceptionMessage(env);
if (message.empty()) {
message = "Node-API status " + std::to_string(status);
}
}
jsi::Value result =
toJsiValue(rt, env, failed ? takeLoadError(env, status) : exports);

const napi_status closeStatus = napi_close_handle_scope(env, scope);
assert(closeStatus == napi_ok);
(void)closeStatus;

if (failed) {
throw jsi::JSError(rt, "Failed to load '" + libraryName + "' addon from '" +
libraryPath + "': " + message);
throw jsi::JSError(rt, std::move(result));
}
module.setProperty(rt, "exports", std::move(result));
}

} // namespace callstack::react_native_node_api
30 changes: 6 additions & 24 deletions packages/host/cpp/CxxNodeApiHostModule.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@

#include <memory>
#include <string>
#include <unordered_map>

namespace callstack::react_native_node_api {

Expand All @@ -18,35 +17,18 @@ class JSI_EXPORT CxxNodeApiHostModule : public facebook::react::TurboModule {

CxxNodeApiHostModule(std::shared_ptr<facebook::react::CallInvoker> jsInvoker);

static facebook::jsi::Value
requireNodeAddon(facebook::jsi::Runtime &rt,
facebook::react::TurboModule &turboModule,
const facebook::jsi::Value args[], size_t count);
facebook::jsi::Value requireNodeAddon(facebook::jsi::Runtime &rt,
const facebook::jsi::String path);
static facebook::jsi::Value dlopen(facebook::jsi::Runtime &rt,
facebook::react::TurboModule &turboModule,
const facebook::jsi::Value args[],
size_t count);
void dlopen(facebook::jsi::Runtime &rt, const facebook::jsi::Object &module,
const std::string &filename);

protected:
struct NodeAddon {
// The name the addon's exports object is stored under on the JavaScript
// global, which is how the napi_value crosses over to JSI.
std::string generatedName;

// The Node-API environment for this addon, created when the addon is
// initialized. Node creates one env per addon (see
// napi_module_register_by_symbol in Node's src/node_api.cc) and the env
// carries addon-scoped state — instance data, last error info, the addon's
// Node-API version — so addons must not share one. Owned by the Hermes
// runtime, which tears it down when the runtime is destroyed.
napi_env env = nullptr;
};
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_;

void loadNodeAddon(facebook::jsi::Runtime &rt, NodeAddon &addon,
const std::string &libraryName);
};

} // namespace callstack::react_native_node_api
59 changes: 54 additions & 5 deletions packages/host/src/react-native/index.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,63 @@
import { type TurboModule, TurboModuleRegistry } from "react-native";
import { Platform, type TurboModule, TurboModuleRegistry } from "react-native";

export interface Module {
exports: unknown;
}

export interface Spec extends TurboModule {
requireNodeAddon<T = unknown>(libraryName: string): T;
dlopen(module: Module, filename: string): void;
}

const native = TurboModuleRegistry.getEnforcing<Spec>("NodeApiHost");

/**
* Loads a native Node-API addon by filename.
* Loads the Node-API addon at `filename` and assigns its exports to `module.exports`,
* with the same semantics as Node's `process.dlopen`.
*
* `filename` is passed as-is to the system's `dlopen`: e.g. `lib<name>.so` on Android
* and `@rpath/<name>.framework/<name>` on Apple platforms.
* Nothing is cached: calling it again runs the addon's initialization function again.
*/
export function dlopen(module: Module, filename: string): void {
native.dlopen(module, filename);
}

const APPLE_PLATFORMS: readonly string[] = ["ios", "macos", "visionos"];

function getLibraryFilename(libraryName: string): string {
if (Platform.OS === "android") {
return `lib${libraryName}.so`;
} else if (APPLE_PLATFORMS.includes(Platform.OS)) {
return `@rpath/${libraryName}.framework/${libraryName}`;
} else {
throw new Error(
`Loading Node-API addons is unsupported on '${Platform.OS}'`,
);
}
}

// Module scope is per JS runtime, so is this cache.
const addons = new Map<string, Module>();

/**
* Loads a native Node-API addon by library name, returning the exports of a previous load of the same library.
*/
export function requireNodeAddon<T = unknown>(libraryName: string): T {
return native.requireNodeAddon<T>(libraryName);
export function requireNodeAddon<T = unknown>(libraryName: string): T;
export function requireNodeAddon(libraryName: string): unknown {
const cached = addons.get(libraryName);
if (cached) {
return cached.exports;
}
const filename = getLibraryFilename(libraryName);
const module: Module = { exports: {} };
try {
dlopen(module, filename);
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
throw new Error(
`Failed to load '${libraryName}' addon from '${filename}': ${reason}`,
);
}
addons.set(libraryName, module);
return module.exports;
}
Loading