diff --git a/.changeset/expose-dlopen.md b/.changeset/expose-dlopen.md new file mode 100644 index 00000000..b4011139 --- /dev/null +++ b/.changeset/expose-dlopen.md @@ -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.so` on Android, `@rpath/.framework/` 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. diff --git a/docs/HOW-IT-WORKS.md b/docs/HOW-IT-WORKS.md index 5ebd0951..85b7ec6e 100644 --- a/docs/HOW-IT-WORKS.md +++ b/docs/HOW-IT-WORKS.md @@ -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/.framework/` on Apple platforms, `lib.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/.framework/` on Apple platforms, `lib.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 diff --git a/packages/host/cpp/CxxNodeApiHostModule.cpp b/packages/host/cpp/CxxNodeApiHostModule.cpp index e9e04f51..6a4e6551 100644 --- a/packages/host/cpp/CxxNodeApiHostModule.cpp +++ b/packages/host/cpp/CxxNodeApiHostModule.cpp @@ -13,31 +13,52 @@ 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(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 @@ -45,8 +66,7 @@ std::string takePendingExceptionMessage(napi_env env) { CxxNodeApiHostModule::CxxNodeApiHostModule( std::shared_ptr jsInvoker) : TurboModule(CxxNodeApiHostModule::kModuleName, jsInvoker) { - methodMap_["requireNodeAddon"] = - MethodMetadata{1, &CxxNodeApiHostModule::requireNodeAddon}; + methodMap_["dlopen"] = MethodMetadata{2, &CxxNodeApiHostModule::dlopen}; callInvoker_ = std::move(jsInvoker); @@ -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(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 @@ -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(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 diff --git a/packages/host/cpp/CxxNodeApiHostModule.hpp b/packages/host/cpp/CxxNodeApiHostModule.hpp index 92f07c95..39aea4d2 100644 --- a/packages/host/cpp/CxxNodeApiHostModule.hpp +++ b/packages/host/cpp/CxxNodeApiHostModule.hpp @@ -8,7 +8,6 @@ #include #include -#include namespace callstack::react_native_node_api { @@ -18,35 +17,18 @@ class JSI_EXPORT CxxNodeApiHostModule : public facebook::react::TurboModule { CxxNodeApiHostModule(std::shared_ptr 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 nodeAddons_; std::shared_ptr 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_; - - void loadNodeAddon(facebook::jsi::Runtime &rt, NodeAddon &addon, - const std::string &libraryName); }; } // namespace callstack::react_native_node_api diff --git a/packages/host/src/react-native/index.ts b/packages/host/src/react-native/index.ts index 0a077c75..0a7bded8 100644 --- a/packages/host/src/react-native/index.ts +++ b/packages/host/src/react-native/index.ts @@ -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(libraryName: string): T; + dlopen(module: Module, filename: string): void; } const native = TurboModuleRegistry.getEnforcing("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.so` on Android + * and `@rpath/.framework/` 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(); + +/** + * Loads a native Node-API addon by library name, returning the exports of a previous load of the same library. */ -export function requireNodeAddon(libraryName: string): T { - return native.requireNodeAddon(libraryName); +export function requireNodeAddon(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; }