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

Conversation

mani3xis
Copy link
Contributor

@mani3xis mani3xis commented May 21, 2025

This branch attempts to rewrite the C++ addon host. The main goals are:

  1. generalize Node-API addon loading process, making it more compatible with how Node.js loads modules
    a. add support for addons that register themselves using the deprecated napi_module_register() [62eafd0]
    b. add support for custom prefixes used in calls to require(), without making node: a special case [3e5f57d]

  2. Introduce a (global) AddonRegistry class to handle loading and managing Node-API modules, replacing the old module loader implementation [a703626]
    a. global (and jsi::Runtime independent) registry is required to properly handle calls to napi_module_register() [62eafd0]

  3. Implements proper Node-API version detection from modules, following Node.js's own implementation patterns [0aaf8d7]

Moreover, this PR also generalizes a few concepts, providing the host mechanisms to fully control and override the resolution behavior per-package [ae6ba19] and per-prefix [3e5f57d] (which also handles URLs).

This branch introduces a breaking-change, where I drop support for requireNodeAddons() taking only one argument. The required babel-plugin is currently extracted to a separate branch: mario/wip-babel-plugin
image

Caution

It is important to note, that the deprecated method of loading modules (via napi_module_register()) is implemented, but after introducing weak node api injection it does not work on iOS - it's not spec-compliant. The issue is that iOS' dynamic linker preloads all libraries and frameworks when the process is being created. This means that the such deprecated addons will be loaded before even React Native gets initialized (yet alone before requireNodeAddon()) is called. The old workaround of adding such frameworks to "Link Binary With Libraries" and marking it "Optional" does not work anymore!
image

Copy link

changeset-bot bot commented May 21, 2025

⚠️ No Changeset found

Latest commit: 04295cf

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

Comment on lines 59 to 61
sanitizeLibraryNameInplace(packageName);
sanitizeLibraryNameInplace(subpath);
const std::string libraryName = packageName + subpath;
Copy link
Collaborator

@kraenhansen kraenhansen May 21, 2025

Choose a reason for hiding this comment

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

The only issue I see with this now being codified in C++ is that the TypeScript code preparing prebuilds would have to have this logic duplicated to produce dynamic libraries with the right "install name" / "SONAME" - not a huge deal, but it is something which could get out of sync. Have you thought about ways to mitigate that risk?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I believe that every platform has different requirements (and workarounds) on how to translate a "required path" to a shared library on disk/bundle. Some platforms might need a lib prefix, and different OSes have different extensions (if .node doesn't work, then we might try .dll or .so), and then there's Apple with Xcframeworks. I don't think that we should push that to the JS code. If all platforms will use the same JS code (just as any other typical Node-API addon), we would be able to have a single bundle driving it. If new platforms will be added later, then - in theory - there would be no need to rebundle JS just so Babel can output different "mangled paths". WDYT?

PS: I'd also would like to expose via our host a way to influence the search paths/patterns that are being scanned.

#if USING_PATCHED_BABEL_PLUGIN
// Strip the extension (if present)
// NOTE: This is needed when working with updated Babel plugin
if (auto pos = name.find(".node"); std::string::npos != pos) {
Copy link
Collaborator

@kraenhansen kraenhansen May 21, 2025

Choose a reason for hiding this comment

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

This will strip ".node", not just from the end but from anywhere in the string (and to the end), right?

I mean, what would the result be on something like:

"my-lib/some-file-with.node-in-the-middle/libsomething.node"

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good catch! It's still temporary code but I'll rewrite it to use a stripSuffix function

Copy link

Choose a reason for hiding this comment

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

If targeting c++20 you can use .ends_with()

exports = addon.initFun_(env, exports);

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

@kraenhansen kraenhansen May 21, 2025

Choose a reason for hiding this comment

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

Nit: 🤔 I feel this reads too much like "fully qualified absolute path"

Copy link
Collaborator

Choose a reason for hiding this comment

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

☝️ probably not a big deal, but perhaps we could use "qualified_path" instead of "fqap" here?

@kraenhansen
Copy link
Collaborator

You're including two empty files into node_modules/react-native-node-api-modules is that on purpose? That doesn't seem right to me.

if (!pathPrefix.empty()) {
// URL protocol or prefix detected, dispatch via custom resolver
std::string pathPrefixCopy(pathPrefix); // HACK: Need explicit cast to `std::string`
if (auto handler = prefixResolvers_.find(pathPrefixCopy); prefixResolvers_.end() != handler) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

This feels driven by a requirement outside of my knowledge.
I love if we could include tests for this use-case 🙏


namespace callstack::nodeapihost {

AddonRegistry::NodeAddon& AddonRegistry::loadAddon(std::string packageName,
Copy link

@robik robik May 29, 2025

Choose a reason for hiding this comment

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

Is there any reason why the parameters are passed by value here? 🤔

@mani3xis mani3xis force-pushed the mario/wip-refactor-cpp branch from 62eafd0 to 881cebb Compare June 12, 2025 10:46
Mariusz Pasinski added 20 commits June 17, 2025 19:30
…ypoints

This big squashed change makes our Babel plugin to replace `require()` calls with `requireNodeAddons()` with 3 parameters, as initially described in #91

While squashing, I also removed my attempt to reimplement Node's resolution algorithm (https://nodejs.org/api/modules.html#all-together) and replaced it with a call to `require.resolve()`, which basically seems to do the same, just better with less code. This code has been tested with package that uses "main" entry point and has no JavaScript files.

Finally, it commit will resolve the TODO regarding scanning the filesystem for addons when using `bindings`. Instead of globbing over the directories, I'm scanning the most popular directories where addons will be placed. Such list of directories was heavily influenced by `node-bindings` itself (https://github.com/TooTallNate/node-bindings/blob/v1.3.0/bindings.js#L21).
If the path points to a file with a `.node` extension, then it must be a Node addon
This small change relaxes the condition for taking the shortcut, as CommonJS modules (in contrast to ESM) do not require developers to explicitly include the file extensions. The Node.js module resolution algorithm (https://nodejs.org/api/modules.html#all-together) in step 4 of LOAD_AS_FILE(X) would try appending the `.node` extension.

In theory, we should make sure that other extensions checked in previous steps are not present, but given that we are implementing it for `requireNodeAddon()`, it should be safe to skip those.
In this particular case we don't want to get packageName="sub-package" with path "../addon-2.node"
Mariusz Pasinski added 13 commits June 17, 2025 19:31
This is basically the generated CMakeList file (from binding.gyp), but with fixed NAPI_VERSION to 4 (as the conversion tool does not respect this setting)
…tion

Early implementation just to get the app running. Without this change, the application crashes immediately after process was created.

This is triggered by dynamic linker load addons at start up, and the "twisted addon" has a function decorated with `__attribute__ ((constructor))` which calls `napi_module_register()` before even Weak NodeAPI has change to inject the proper function pointers...
Currently the addon cannot be resolved using the bare specifier, therefore this workaround makes it use an intermediate file "index.js" which would import the native addon.
@mani3xis mani3xis force-pushed the mario/wip-refactor-cpp branch from 881cebb to 7d82be1 Compare June 20, 2025 12:26
@mani3xis mani3xis marked this pull request as ready for review June 20, 2025 12:26
@mani3xis mani3xis changed the title [DRAFT] Refactor C++ loader: extract cache and addon registry, resolve TODOs Refactor C++ loader: extract cache and addon registry, resolve TODOs Jun 20, 2025
@@ -0,0 +1,19 @@
{
"name": "@callstackincubator/example-3",
Copy link
Collaborator

Choose a reason for hiding this comment

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

I didn't intend for these ./examples to be committed into the repo 🤔 The idea was they they would be copied 1-to-1 from the repo owned by Node.js. I only recently changed the copy script to not delete the directory before copying.

Perhaps we could store this in a separate sub-directory? Of the node-addon-examples package?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I can remove the example from here. Kept it for testing purposes.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I suppose it would be good to keep around to avoid regressions? 🙂 Perhaps just move it into ./extras instead of ./examples?

Mariusz Pasinski added 3 commits June 20, 2025 19:23
I wanted to keep those a bit longer because they will be used in a different source file, but I can revive those functions from Git history, so farewell 👋
The plugin has been patched in this branch (rebased)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
3 participants