Skip to content

Refactor C++ loader: extract cache and addon registry, resolve TODOs #104

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 55 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
240ae83
feat(babel): add 2 params to requireNodeAddon(); respect package entr…
May 20, 2025
8768c93
refactor: extract `findNodeAddonForBindings()`
May 20, 2025
bdc06cf
feat: add shortcut to `isNodeApiModule()`
May 20, 2025
fdaf8b5
feat: relax the condition for CJS modules without file exts
May 21, 2025
925d958
style: address linter issues
May 21, 2025
58746ae
feat: extract path normalization from `determineModuleContext()`
Jun 4, 2025
285b098
feat: update tests to cover `determineNormalizedModuleContext()`
Jun 4, 2025
e87a66e
feat: add test for scoped package names
Jun 4, 2025
798fed7
feat: update tests for 3 arg `requireNodeAddon()`
Jun 4, 2025
be9926a
feat: remove xcframework dir from test code
Jun 4, 2025
d5bf070
feat: change the third param to original path
Jun 4, 2025
2085fdf
feat: add test case for require with "main" entry point
Jun 4, 2025
3660b6f
fix: remove unused `requiredFrom` parameter
Jun 4, 2025
a414cd2
fix: remove unsupported babel option "naming"
Jun 4, 2025
dfeafbb
feat: add tests for `findNodeAddonsForBindings()`
Jun 4, 2025
6cfbc52
fix: ensure that module paths use only forward slash (esp. on Windows)
Jun 4, 2025
8bb6e01
feat: add test case for addon that "escaped" its package
Jun 4, 2025
eaa86a0
feat: make `resolvedPath` optional as suggested by Kraen
Jun 11, 2025
824c932
feat: run each test case as separate call to `it()`
Jun 11, 2025
1cdc092
chore: move `findNodeAddonForBindings()` to path-utils
Jun 11, 2025
f2bfc83
chore: rename `originalPath` parameter to `originalId`
Jun 11, 2025
64b967c
chore: replace `[].forEach()` with for-of
Jun 11, 2025
d7ad8a1
feat: move path slash normalization to determineModuleContext()
Jun 11, 2025
b0071ff
chore: use more descriptive variable names
Jun 11, 2025
e0b5139
feat: make requireNodeAddon() take 3 arguments
May 19, 2025
89612c4
fixup: remove single argument requireNodeAddon()
May 20, 2025
439e7a6
feat: add path utility functions
May 19, 2025
dec9035
feat: ensure that paths use safe ASCII alphanumericals
May 19, 2025
40a8791
feat: declare alias for custom resolver function
May 19, 2025
f9a4aae
feat: add support for custom prefix resolvers
May 19, 2025
9caedb8
feat: add support for custom package-specific resolvers
May 19, 2025
d7a2e3c
refactor: extract existing loading code to resolveRelativePath() method
May 19, 2025
3b7f209
feat: add `startsWith()` helper function
May 19, 2025
c90b8fc
feat: compute merged subpath and verify it before loading
May 19, 2025
d6b254f
fix: explicitly cast `pathPrefix` to `std::string` for lookup
May 19, 2025
063e5b5
fix: ensure `joinPath` is called on `requiredFrom`'s parent directory
May 19, 2025
e7fd0be
feat: add stub methods for interacting with require cache
May 20, 2025
68a4a2e
feat: make the `resolveRelativePath()` interact with (no-op) require …
May 20, 2025
4102043
feat: draft first implementation of AddonRegistry
May 21, 2025
b5b7bf3
refactor: swap old TM addon loader with AddonRegistry
May 21, 2025
2befad7
feat: support modules that register via `napi_module_register()`
May 21, 2025
3b30241
fixup: update params in NativeNodeApiHost spec
May 21, 2025
f4130a1
fix: implement proper extension stripping
Jun 9, 2025
6b441b5
feat: extract the "twisted" node api addon example from "wip-refactor…
Jun 18, 2025
a9f3be0
fix: include the generated (and corrected) CMakeList
Jun 18, 2025
d5bf7b4
chore: rename `requiredFrom` param to `originalId`
Jun 11, 2025
6179a4d
fixup: fix types in `endsWith` (use `string_views`)
Jun 11, 2025
437ba9b
fix: change param types of `startsWith` to `string_view`s
Jun 11, 2025
c224486
Merge branch 'mario/twisted-addon-example' into mario/wip-refactor-cpp
Jun 18, 2025
8cd8791
feat: add `2_function_arguments_twisted/napi` to list of examples
Jun 18, 2025
3481e82
fix: defer calling `napi_module_register()` until weak node api injec…
Jun 18, 2025
7d82be1
hack: apply workaround for "twisted" addon
Jun 20, 2025
e8ff65d
refactor: remove unused path utils
Jun 20, 2025
fc0a1a0
fix: assertion firing when loading deprecated way
Jun 20, 2025
04295cf
chore: remove redundant feature flag
Jun 20, 2025
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
226 changes: 226 additions & 0 deletions packages/host/cpp/AddonRegistry.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
#include "AddonRegistry.hpp"
#include <cctype> // for std::isalnum

#ifndef NODE_API_DEFAULT_MODULE_API_VERSION
#define NODE_API_DEFAULT_MODULE_API_VERSION 8
#endif

using namespace facebook;

namespace {
napi_status napi_emplace_named_property_object(napi_env env,
napi_value object,
const char *utf8Name,
napi_value *outObject) {
bool propertyFound = false;
napi_status status = napi_has_named_property(env, object, utf8Name, &propertyFound);
assert(napi_ok == status);

assert(nullptr != outObject);
if (propertyFound) {
status = napi_get_named_property(env, object, utf8Name, outObject);
} else {
// Need to create it first
status = napi_create_object(env, outObject);
assert(napi_ok == status);

status = napi_set_named_property(env, object, utf8Name, *outObject);
}

return status;
}

bool endsWith(const std::string_view &str, const std::string_view &suffix) {
#if __cplusplus >= 202002L // __cpp_lib_starts_ends_with
return str.ends_with(suffix);
#else
return str.size() >= suffix.size()
&& std::equal(suffix.rbegin(), suffix.rend(), str.rbegin());
#endif
}

std::string_view stripSuffix(const std::string_view &str, const std::string_view &suffix) {
if (endsWith(str, suffix)) {
return str.substr(0, str.size() - suffix.size());
} else {
return str;
}
}

void sanitizeLibraryNameInplace(std::string &name) {
// Strip the extension (if present)
// NOTE: This is needed when working with updated Babel plugin
name = stripSuffix(name, ".node");

for (char &c : name) {
if (!std::isalnum(c)) {
c = '-';
}
}
}
} // namespace

namespace callstack::nodeapihost {

AddonRegistry::NodeAddon& AddonRegistry::loadAddon(std::string packageName,
std::string subpath) {
const std::string fqan = packageName + subpath.substr(1);
auto [it, inserted] =
trackedAddons_.try_emplace(fqan, NodeAddon(packageName, subpath));
NodeAddon &addon = it->second;

sanitizeLibraryNameInplace(packageName);
sanitizeLibraryNameInplace(subpath);
const std::string libraryName = packageName + subpath;

if (inserted || !it->second.isLoaded()) {
#if defined(__APPLE__)
const std::string libraryPath = "@rpath/" + libraryName + ".framework/" + libraryName;
tryLoadAddonAsDynamicLib(addon, libraryPath);
#elif defined(__ANDROID__)
const std::string libraryPath = "lib" + libraryName + ".so";
tryLoadAddonAsDynamicLib(addon, libraryPath);
#else
abort();
#endif
}

return addon;
}

bool AddonRegistry::tryLoadAddonAsDynamicLib(NodeAddon &addon, const std::string &path) {
{
// There can be only a SINGLE pending module (the same limitation
// has Node.js since Jan 28, 2014 commit 76b9846, see link below).
// We MUST clear it before attempting to load next addon.
// https://github.com/nodejs/node/blob/76b98462e589a69d9fd48ccb9fb5f6e96b539715/src/node.cc#L1949)
assert(nullptr == pendingRegistration_);
}

// Load addon as dynamic library
typename LoaderPolicy::Module library = LoaderPolicy::loadLibrary(path.c_str());
if (nullptr != library) {
addon.moduleApiVersion_ = NODE_API_DEFAULT_MODULE_API_VERSION;
if (nullptr != pendingRegistration_) {
// there is a pending addon that used the deprecated `napi_register_module()`
addon.initFun_ = pendingRegistration_;
} else {
// pending addon remains empty, we should look for the symbols...
typename LoaderPolicy::Symbol initFn = LoaderPolicy::getSymbol(library, "napi_register_module_v1");
if (nullptr != initFn) {
addon.initFun_ = (napi_addon_register_func)initFn;
// This solves https://github.com/callstackincubator/react-native-node-api-modules/issues/4
typename LoaderPolicy::Symbol getVersionFn = LoaderPolicy::getSymbol(library, "node_api_module_get_api_version_v1");
if (nullptr != getVersionFn) {
addon.moduleApiVersion_ = ((node_api_addon_get_api_version_func)getVersionFn)();
}
}
}

if (nullptr != addon.initFun_) {
addon.moduleHandle_ = (void *)library;
addon.loadedFilePath_ = path;
}
}

// We MUST clear the `pendingAddon_`, even when the module failed to load!
// See: https://github.com/nodejs/node/commit/a60056df3cad2867d337fc1d7adeebe66f89031a
pendingRegistration_ = nullptr;
return addon.isLoaded();
}

jsi::Value AddonRegistry::instantiateAddonInRuntime(jsi::Runtime &rt, NodeAddon &addon) {
// We should check if the module has already been initialized
assert(true == addon.isLoaded());
assert(addon.moduleApiVersion_ > 0 && addon.moduleApiVersion_ <= 10);

napi_status status = napi_ok;
napi_env env = reinterpret_cast<napi_env>(rt.createNodeApiEnv(addon.moduleApiVersion_));

// Create the "exports" object
napi_value exports;
status = napi_create_object(env, &exports);
assert(napi_ok == status);

// Call the addon init function to populate the "exports" object
// Allowing it to replace the value entirely by its return value
// TODO: Check the return value (see Node.js specs)
exports = addon.initFun_(env, exports);

// "Compute" the Fully Qualified Addon Path
const std::string fqap = addon.packageName_ + addon.subpath_.substr(1);

{
napi_value descriptor;
status = createAddonDescriptor(env, exports, &descriptor);
assert(napi_ok == status);

napi_value global;
napi_get_global(env, &global);
assert(napi_ok == status);

status = storeAddonByFullPath(env, global, fqap, descriptor);
assert(napi_ok == status);
}

return lookupAddonByFullPath(rt, fqap);
}

bool AddonRegistry::handleOldNapiModuleRegister(napi_addon_register_func addonInitFunc) {
assert(nullptr == pendingRegistration_);
pendingRegistration_ = addonInitFunc;
return true;
}

napi_status AddonRegistry::createAddonDescriptor(napi_env env, napi_value exports, napi_value *outDescriptor) {
// Create the descriptor object
assert(nullptr != outDescriptor);
napi_status status = napi_create_object(env, outDescriptor);

// Point the `env` property to the current `napi_env`
if (napi_ok == status) {
napi_value env_value;
status = napi_create_external(env, env, nullptr, nullptr, &env_value);
if (napi_ok == status) {
status = napi_set_named_property(env, *outDescriptor, "env", env_value);
}
}

// Cache the addons exports in descriptor's `exports` property
if (napi_ok == status) {
status = napi_set_named_property(env, *outDescriptor, "exports", exports);
}

return status;
}

napi_status AddonRegistry::storeAddonByFullPath(napi_env env, napi_value global, const std::string &fqap, napi_value descriptor) {
// Get the internal registry object
napi_value registryObject;
napi_status status = napi_emplace_named_property_object(env, global, kInternalRegistryKey, &registryObject);
assert(napi_ok == status);

status = napi_set_named_property(env, registryObject, fqap.c_str(), descriptor);
return status;
}

jsi::Value AddonRegistry::lookupAddonByFullPath(jsi::Runtime &rt, const std::string &fqap) {
// Get the internal registry object
jsi::Object global = rt.global();
if (!global.hasProperty(rt, kInternalRegistryKey)) {
// Create it first
jsi::Object registryObject = jsi::Object(rt);
global.setProperty(rt, kInternalRegistryKey, registryObject);
}
jsi::Value registryValue = global.getProperty(rt, kInternalRegistryKey);
jsi::Object registryObject = registryValue.asObject(rt);

// Lookup by addon path
jsi::Value addonValue(nullptr);
if (registryObject.hasProperty(rt, fqap.c_str())) {
addonValue = registryObject.getProperty(rt, fqap.c_str());
}
return addonValue;
}

} // namespace callstack::nodeapihost
45 changes: 45 additions & 0 deletions packages/host/cpp/AddonRegistry.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
#pragma once

#include <unordered_map>
#include <jsi/jsi.h>
#include <node_api.h>
#include "AddonLoaders.hpp"

namespace callstack::nodeapihost {

class AddonRegistry {
public:
struct NodeAddon {
NodeAddon(std::string packageName, std::string subpath)
: packageName_(packageName)
, subpath_(subpath)
{}

inline bool isLoaded() const { return nullptr != initFun_; }

std::string packageName_;
std::string subpath_;
std::string loadedFilePath_;
void *moduleHandle_ = nullptr;
napi_addon_register_func initFun_ = nullptr;
int32_t moduleApiVersion_;
};

NodeAddon& loadAddon(std::string packageName, std::string subpath);
facebook::jsi::Value instantiateAddonInRuntime(facebook::jsi::Runtime &rt, NodeAddon &addon);
bool handleOldNapiModuleRegister(napi_addon_register_func addonInitFunc);

using LoaderPolicy = PosixLoader; // FIXME: HACK: This is temporary workaround
// for my lazyness (works on iOS and Android)
private:
bool tryLoadAddonAsDynamicLib(NodeAddon &addon, const std::string &path);
napi_status createAddonDescriptor(napi_env env, napi_value exports, napi_value *outDescriptor);
napi_status storeAddonByFullPath(napi_env env, napi_value global, const std::string &fqap, napi_value descriptor);
facebook::jsi::Value lookupAddonByFullPath(facebook::jsi::Runtime &rt, const std::string &fqap);

static constexpr const char *kInternalRegistryKey = "$NodeApiHost";
std::unordered_map<std::string, NodeAddon> trackedAddons_;
napi_addon_register_func pendingRegistration_;
};

} // namespace callstack::nodeapihost
Loading
Loading