Skip to content

Conversation

@vmoroz
Copy link
Member

@vmoroz vmoroz commented Dec 1, 2025

Summary

This PR reverses how Node-API modules bind to runtime functions. Currently, modules depend on Node-API symbols exported by the runtime (e.g., node.exe). With this change, the runtime instead provides Node-API functions to modules via vtables—structs containing function pointers exposed through napi_env and related handles.

This enables runtime-agnostic modules: the same pre-built .node file can be loaded by any Node-API-compatible runtime without recompilation or platform-specific binding hooks. This includes standalone runtimes, embedded scenarios (e.g., libnode inside an application), and even applications hosting multiple embedded JS runtimes simultaneously—each runtime provides its own vtables to the modules it loads.

The Issue

The current Node-API module binding has platform-specific limitations (see abi-stable-node#471):

  • Windows: Modules use delay-load with win_delay_load_hook.cc to resolve symbols from libnode.dll or the current process. Modules compiled without this hook cannot load in alternative runtimes. Even with the hook, it cannot route to other embedded Node-API runtimes—it only checks libnode.dll then falls back to the host process.
  • Linux/macOS: Modules use weak symbol binding to resolve functions from the current process. This doesn't work on Android, which requires strong binding.
  • Multi-runtime: A process cannot host multiple JS runtimes that load Node-API modules—there's no way to specify which runtime a module should bind to.
  • FFI overhead: Bindings in other languages (C#, Java, Rust) must resolve ~160 functions by name, which is expensive.

The Solution

Reverse the dependency: instead of modules binding to runtime symbols, the runtime exposes its API through vtables in napi_env and related handles.

Design

  • Two vtables: node_api_js_vtable (~120 functions from js_native_api.h) and node_api_module_vtable (~40 functions from node_api.h). Separated because js_native_api.h can be used independently of Node.js-specific APIs.

  • Exposed via structs: Three previously opaque structs now have concrete definitions with vtable pointers:

    struct napi_env__ {
      uint64_t sentinel;                         // NODE_API_VT_SENTINEL  
      const node_api_js_vtable* js_vtable;
      const node_api_module_vtable* module_vtable;
    };

    Similarly for napi_threadsafe_function__ and napi_async_cleanup_hook_handle__ (for functions which don't take napi_env as their first argument).

  • Sentinel detection: The sentinel value has bit 0 = 1, which can never match a C++ vtable pointer (always aligned). This lets modules detect vtable-enabled runtimes vs. legacy runtimes.

  • Opt-in: Define NODE_API_MODULE_USE_VTABLE to enable. Node-API functions become static inline wrappers that dispatch through the vtable, with optional fallback to symbol binding for legacy runtimes.

Macro Effect
NODE_API_MODULE_USE_VTABLE Enable vtable mode (required)
NODE_API_MODULE_NO_VTABLE_FALLBACK Disable fallback to symbol binding
NODE_API_MODULE_NO_VTABLE_IMPL Skip wrapper functions, use vtable directly
  • ABI stability: New functions are always added at the end of vtables. Function positions never change (except experimental APIs).

Compatibility

Runtime Module without vtable Module with vtable
Legacy (no vtable support) Works as today Falls back to symbol binding*
New (vtable support) Works as today Uses vtable

*Fallback can be disabled with NODE_API_MODULE_NO_VTABLE_FALLBACK.

How it works:

  • Modules detect vtable support by checking env->sentinel == NODE_API_VT_SENTINEL
  • If sentinel matches: dispatch through vtable
  • If sentinel doesn't match (legacy runtime): resolve symbols via dlsym/GetProcAddress and cache in a local vtable
  • Legacy modules (without NODE_API_MODULE_USE_VTABLE) are unaffected—they continue using symbol binding as today

Key rule: Multi-runtime environments require vtable-enabled modules. Legacy modules bind to whichever runtime's symbols are visible in the process—they cannot distinguish between runtimes.

Testing

Test Infrastructure Changes

  • Addon list in JS: Tests now declare their addons via // Addons: comment in the JS file (e.g., // Addons: binding, binding_vtable). The test runner reads this list and runs the same test for each addon variant.
  • Helper module: test/common/addon-test.js provides addonPath based on the --addon=<name> argument, allowing one test file to run against multiple addon builds.

Build Variants

Each test addon is compiled in multiple configurations via binding.gyp:

{ "target_name": "binding" },                           // original (no vtable)
{ "target_name": "binding_vtable",
  "defines": [ "NODE_API_MODULE_USE_VTABLE" ] },        // vtable with fallback

The 1_hello_world test also includes additional variants:

{ "target_name": "binding_vtable_nofb",
  "defines": [ "NODE_API_MODULE_USE_VTABLE",
               "NODE_API_MODULE_NO_VTABLE_FALLBACK" ] }, // vtable only, no fallback
{ "target_name": "binding_vtable_noimpl",
  "defines": [ "NODE_API_MODULE_USE_VTABLE",
               "NODE_API_MODULE_NO_VTABLE_IMPL" ] },     // direct vtable access

Backward Compatibility Verification

All vtable-enabled tests (with fallback) were run against Node.js v24. Tests pass except those using APIs added after v24 (e.g., Float16). This confirms the fallback mechanism works correctly with older runtimes.


Prior art: The vtable approach is well-established—Java JNI, COM, and Vulkan all use similar patterns.

@nodejs-github-bot
Copy link
Collaborator

Review requested:

  • @nodejs/gyp
  • @nodejs/node-api

@nodejs-github-bot nodejs-github-bot added c++ Issues and PRs that require attention from people who are familiar with C++. lib / src Issues and PRs related to general changes in the lib or src directory. needs-ci PRs that need a full CI run. labels Dec 1, 2025
@vmoroz vmoroz marked this pull request as draft December 1, 2025 03:58
@kraenhansen
Copy link

kraenhansen commented Dec 1, 2025

From the PR description this seems super valuable and a great built-in alternative to the weak-node-api library we've been working on to add Node-API support to React Native. As such, I'd be happy for us to adopt this approach over the stuff we have now 👍

The limitation around the module being bound to a single runtime, might not be an issue as a multi-runtime host can inject functions that deal with that internally.

I'm left wondering how (if at all) add-ons which are statically linked into the process are affected by this proposal? I guess not at all, as they can still register themselves and call into the global Node-API functions resolved at link time.

@devsnek
Copy link
Member

devsnek commented Dec 1, 2025

nice! node-api symbol visibility has been a pain for us in deno so we'd love to adopt this approach as well.

NULL, \
{0}, \
}; \
NAPI_C_CTOR(_register_##modname) { napi_module_register(&_module); } \
Copy link
Member

Choose a reason for hiding this comment

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

We should still support addons compiled with legacy Node-API headers. Could this new v-table approach be opt-in?

Copy link
Member Author

Choose a reason for hiding this comment

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

I agree, we should make it opt-in to start with and play with it a bit.
If it works well, then we can promote it to be default at some point.
I considered to use the NAPI_EXPERIMENTAL, but it seems it is not the right approach since we use the NAPI_EXPERIMENTAL for new Node-API functions, and the module loading is an orthogonal process. Thus, a special opt-in flag would be better.

Copy link
Member Author

Choose a reason for hiding this comment

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

I have added a new macro NODE_API_MODULE_USE_VTABLE. It is currently used only by two tests.

@legendecas legendecas added the node-api Issues and PRs related to the Node-API. label Dec 1, 2025
#define NAPI_NO_RETURN
#endif

// Used by deprecated registration method napi_module_register.

Choose a reason for hiding this comment

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

For anyone wondering, this was moved into the node_api_types.h.

Copy link
Member Author

Choose a reason for hiding this comment

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

I had to move it because the new vtable struct uses it.

@kraenhansen
Copy link

There might be a potential a potential coordination problem, where the addon tries to initialize itself before the vtable gets injected (set) by the host. I don't see that as an issue when the addon relies on "symbol based" module registration, since the host can ensure to call the initialize function after setting the vtable, but how about an addon trying to call napi_module_register when loaded?

@devsnek
Copy link
Member

devsnek commented Dec 1, 2025

but how about an addon trying to call napi_module_register when loaded?

these should probably be exclusive modes, so napi_module_register wouldn't be called, or could only be called from within the host call to the inject function.

src/node_api.cc Outdated

#endif

node_api_vtable g_vtable = {
Copy link
Member

Choose a reason for hiding this comment

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

Should this be const?

Copy link
Member Author

Choose a reason for hiding this comment

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

Good point! I will fix it.

Copy link
Member Author

Choose a reason for hiding this comment

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

I have added const for the global variables related to the v-tables.

@vmoroz
Copy link
Member Author

vmoroz commented Dec 1, 2025

There might be a potential a potential coordination problem, where the addon tries to initialize itself before the vtable gets injected (set) by the host. I don't see that as an issue when the addon relies on "symbol based" module registration, since the host can ensure to call the initialize function after setting the vtable, but how about an addon trying to call napi_module_register when loaded?

The napi_module_register usage is deprecated. No new code is supposed to use it anymore.
We used to have a deprecated attribute, but we found that some developers use it to register modules explictly. E.g. when the modules are part of the host executable.
I do not have a good answer to that besides that the exisitng public API is still there and user can use as before.

I am going to add a conditional flag and restore the deleted test as @legendecas suggested. This test is using the napi_module_register method and it can be used as a show case how to use the Node-API directly when needed.

@vmoroz
Copy link
Member Author

vmoroz commented Dec 1, 2025

I'm left wondering how (if at all) add-ons which are statically linked into the process are affected by this proposal? I guess not at all, as they can still register themselves and call into the global Node-API functions resolved at link time.

Right, I would expect it too, but we should verify and test this scenario.

@vmoroz
Copy link
Member Author

vmoroz commented Dec 1, 2025

The issues that I am facing on Mac and Linux are due to the use of real "C" compilers where the inline keyword has a different semantic than in "C++". Changing it to static inline generates its own set of issues. Thus, I am still working on it.

@RobinWuu
Copy link

RobinWuu commented Dec 2, 2025

Nice!👍 In Lynx/PrimJS, we are currently using a similar vtable approach to address the needs of multi-runtime injection. However, our previous API was not fully aligned with the Node-API standard, which is a problem I have been working to fix recently.
We’re thrilled to see this solution will potentially be natively integrated into Node.js, and we’re also more than happy to adopt this approach.

@vmoroz vmoroz force-pushed the pr/node_api_vtable branch from cf9b056 to f3d584b Compare December 2, 2025 15:17
@vmoroz
Copy link
Member Author

vmoroz commented Dec 2, 2025

Nice!👍 In Lynx/PrimJS, we are currently using a similar vtable approach to address the needs of multi-runtime injection. However, our previous API was not fully aligned with the Node-API standard, which is a problem I have been working to fix recently. We’re thrilled to see this solution will potentially be natively integrated into Node.js, and we’re also more than happy to adopt this approach.

It is great to hear it! Any suggestions to improve code in this PR to fit your scenario are welcome.

@vmoroz vmoroz marked this pull request as ready for review December 2, 2025 15:35
@codecov
Copy link

codecov bot commented Dec 2, 2025

Codecov Report

❌ Patch coverage is 98.73096% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 88.45%. Comparing base (bcdf2e0) to head (fa88025).
⚠️ Report is 7 commits behind head on main.

Files with missing lines Patch % Lines
src/node_api.cc 97.05% 1 Missing and 2 partials ⚠️
src/js_native_api_v8.cc 99.63% 0 Missing and 1 partial ⚠️
src/js_native_api_v8.h 94.11% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #60916      +/-   ##
==========================================
- Coverage   88.53%   88.45%   -0.08%     
==========================================
  Files         704      704              
  Lines      208759   208899     +140     
  Branches    40281    40393     +112     
==========================================
- Hits       184816   184787      -29     
+ Misses      15947    15929      -18     
- Partials     7996     8183     +187     
Files with missing lines Coverage Δ
src/js_native_api_v8_internals.h 0.00% <ø> (ø)
src/node_api_internals.h 100.00% <ø> (ø)
src/node_binding.cc 82.74% <ø> (ø)
src/js_native_api_v8.cc 69.79% <99.63%> (-6.70%) ⬇️
src/js_native_api_v8.h 89.28% <94.11%> (-0.14%) ⬇️
src/node_api.cc 73.60% <97.05%> (-1.56%) ⬇️

... and 46 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@legendecas legendecas moved this from Need Triage to In Progress in Node-API Team Project Dec 5, 2025
"target_name": "binding",
"sources": [ "binding.c" ]
"sources": [ "binding.c" ],
'defines': [ 'NODE_API_MODULE_USE_VTABLE' ]
Copy link
Member

@legendecas legendecas Dec 5, 2025

Choose a reason for hiding this comment

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

We can add a new target named binding_vtable and verify that the hello_world test can be kept and run in the same process with the new vtable binding.

Copy link
Member Author

Choose a reason for hiding this comment

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

Thank you for the suggestion! I had duplicated all the targets and added there the "_vtable" suffix.

src/node_api.h Outdated

#ifdef NODE_API_MODULE_USE_VTABLE
#define NODE_API_MODULE_SET_VTABLE_DEFINITION \
const node_api_module_vtable* g_node_api_module_vtable = \
Copy link
Member

Choose a reason for hiding this comment

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

Could we add a test that contains two compilation units and this global variable can be properly defined?

Copy link
Member Author

Choose a reason for hiding this comment

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

All tests are changed to build addons with and without optional support for v-table. They show that the tests can run in both modes. In one case node-api/1_hello_world I have added a test that supports v-table, but does not implement a fallback implementation when it is missing. It passes in new version of Node.js, but fails in old version.
All other tests pass in the old versions on Node.sj except for the cases when the tests are using APIs that are not part of the previous Node.js version, e.g. Float16.
IMHO, we should run the Node-API tests against the new and old Node.js versions, but it is a subject for another discussion.

@mani3xis
Copy link

mani3xis commented Dec 10, 2025

👍
Just as a comment, I was working on something very similar, but I must admit that imho this is the most flexible approach, which opens new possibilities, like enabling validation layers and middlewares (the runtime can "replace" or "patch" the original Node-API function pointer with functions that perform extra validation or logging, etc. completely transparently to the "consumer") -- a little bit like optional Validation Layers in Vulkan API. Lastly, V-table approach also decreases the cost of dynamic symbol resolution, etc.

From ABI stability, adding new function pointers at the end of the struct/V-table is ABI-stable. Lastly, there are some runtimes that support multiple JS engines, and they would need to dispatch to the correct V-table (based on the node_env) -- I believe both PrimJS and OpenHarmony's Native Engine are doing this. This is also one reason why JS engines should not provide Node-API symbols, otherwise they will trigger symbol conflicts.

What might be good to consider is adding a function that returns a V-table for a given version. This way, if a Node Addon is using say N-API version 8, and the runtime supports, say, N-API version 10, such node_api_get_vtable(NAPI_VERSION_8, flags) might return a properly initialized V-Table with newer functions replaced with an assertion or something. Just a random idea inspired by an old blog post...

edit: If we can bump NAPI_MODULE_VERSION then maybe its the best time to introduce napi_register_module_v2() which takes a 3rd argument: a versioned struct/v-table with function pointers to query the host/runtime (e.g. ask for V-Tables, require other addons, etc.). WDYT?

@vmoroz vmoroz force-pushed the pr/node_api_vtable branch from 713c148 to 24b85d1 Compare January 5, 2026 19:33
@vmoroz
Copy link
Member Author

vmoroz commented Jan 5, 2026

edit: If we can bump NAPI_MODULE_VERSION then maybe its the best time to introduce napi_register_module_v2() which takes a 3rd argument: a versioned struct/v-table with function pointers to query the host/runtime (e.g. ask for V-Tables, require other addons, etc.). WDYT?

@mani3xis , thank you for the feedback and great ideas! In the last few weeks I was working on improving the code and was able to achieve good results that should now enable multiple runtimes in the same executable since the v-table is now exposed from the napi_env as you suggested.
As for the versioning, I played with the idea to add the napi_register_module_v2, but seemed to be unnecessary.
When we currently load a Node-API module we first look for an optional function with the name node_api_module_get_api_version_v1. This function must return the Node-API version supported by the module. If the function is not present, then we assume the Node-API version 8. The version 8 was there when we added this mechanism. Then, the internal napi_env created with the module version inside. Based on that version Node.js adjusts the behavior of the Node-API.
So, when the napi_register_module_v1 is called with the napi_env, that napi_env is already adjusted for the requested version.
Since the v-table in the latest iteration of this PR is a field of napi_env__, it can have the adjusted v-table if needed.

@vmoroz vmoroz force-pushed the pr/node_api_vtable branch from 9d095bf to d3bd284 Compare January 7, 2026 01:42
@RobinWuu
Copy link

RobinWuu commented Jan 8, 2026

@vmoroz Hi, Could I experimentally try out the solution proposed in this PR in my project first? I’ve been struggling with symbol conflicts between multiple NAPI implementations lately (callstackincubator/react-native-node-api#341), and this solution seems like it could fix my problem—I can’t wait to give it a shot.

@vmoroz
Copy link
Member Author

vmoroz commented Jan 8, 2026

@vmoroz Hi, Could I experimentally try out the solution proposed in this PR in my project first? I’ve been struggling with symbol conflicts between multiple NAPI implementations lately (callstackincubator/react-native-node-api#341), and this solution seems like it could fix my problem—I can’t wait to give it a shot.

Hi @RobinWuu , yes, feel free to try it. I am curious to know if it works for you. At this point I consider the implementation to be quite complete. Though it is still a subject of a code review, addressing feedback, etc. I cannot predict when and if at all it will be merged to Node.js project. I.e. there is some risk in using it at the early stage.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

c++ Issues and PRs that require attention from people who are familiar with C++. lib / src Issues and PRs related to general changes in the lib or src directory. needs-ci PRs that need a full CI run. node-api Issues and PRs related to the Node-API.

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

8 participants