Skip to content

Conversation

@kraenhansen
Copy link
Collaborator

@kraenhansen kraenhansen commented Nov 10, 2025

This is my suggestion for adding "multi-host" support to weak-node-api, enabling multiple engines implementing Node-API to co-exist and share the Node-API function namespace. While not needed specifically for bringing Node-API to React Native adding this could make weak-node-api more applicable in other scenarios where multiple engines implementing Node-API share a single process.

I'm proposing adding mechanisms to "wrap" the opaque pointers of specific Node-API implementors with references to the NodeApiHost object to enable deferring implementation of Node-API functions based on the napi_env, node_api_basic_env, napi_threadsafe_function or napi_async_cleanup_hook_handle passed.

classDiagram
    class NodeApiHost {
        <<interface>>
    }

    class NodeApiMultiHost {
        -vector~unique_ptr~WrappedEnv~~ envs
        +wrap(napi_env, weak_ptr~NodeApiHost~) napi_env
        +static napi_* methods...
    }

    class WrappedEnv {
        +napi_env value
        +weak_ptr~NodeApiHost~ host
        -vector~unique_ptr~WrappedThreadsafeFunction~~ threadsafe_functions
        -vector~unique_ptr~WrappedAsyncCleanupHookHandle~~ async_cleanup_hook_handles
        +wrap(napi_threadsafe_function, WrappedEnv*, weak_ptr~NodeApiHost~) napi_threadsafe_function
        +wrap(napi_async_cleanup_hook_handle, WrappedEnv*, weak_ptr~NodeApiHost~) napi_async_cleanup_hook_handle
    }

    class WrappedThreadsafeFunction {
        +napi_threadsafe_function value
        +WrappedEnv* env
        +weak_ptr~NodeApiHost~ host
    }

    class WrappedAsyncCleanupHookHandle {
        +napi_async_cleanup_hook_handle value
        +WrappedEnv* env
        +weak_ptr~NodeApiHost~ host
    }

    NodeApiMultiHost --|> NodeApiHost : inherits
    NodeApiMultiHost *-- "0..*" WrappedEnv : owns
    WrappedEnv *-- "0..*" WrappedThreadsafeFunction : owns
    WrappedEnv *-- "0..*" WrappedAsyncCleanupHookHandle : owns
    WrappedThreadsafeFunction --> WrappedEnv : references
    WrappedAsyncCleanupHookHandle --> WrappedEnv : references
    WrappedEnv --> NodeApiHost : weak reference
    WrappedThreadsafeFunction --> NodeApiHost : weak reference
    WrappedAsyncCleanupHookHandle --> NodeApiHost : weak reference
    WrappedEnv ..|> enable_shared_from_this : implements

    note for NodeApiMultiHost "Manages multiple Node-API host implementations"
    note for WrappedEnv "Wraps napi_env with ownership tracking for threadsafe functions and async cleanup hook handles"
Loading

WrappedEnv, WrappedThreadsafeFunction and WrappedAsyncCleanupHookHandle objects can then be passed around like their respective opaque pointers and are "unwrapped" in the internal implementation of the "multi host" implementation of Node-API functions.

Wrapped objects are created calling one of the wrap instance methods on NodeApiMultiHost or WrappedEnv, which are called internally in napi_create_threadsafe_function and napi_add_async_cleanup_hook too.

Usage

  • Create a NodeApiMultiHost (providing functions to register modules and handling a fatal error)
  • Inject it as the global host
  • Get an env from the actual Node-API implementatin
  • Wrap the env with using the NodeApiMultiHost (calling multi_host.wrap(original_env, host);)
  • Pass the wrapped env to Node-API functions to delegate as needed

static size_t foo_calls = 0;
auto host_foo = std::shared_ptr<WeakNodeApiHost>(new WeakNodeApiHost{
.napi_create_object = [](napi_env env,
napi_value *result) -> napi_status {
foo_calls++;
return napi_status::napi_ok;
}});
static size_t bar_calls = 0;
auto host_bar = std::shared_ptr<WeakNodeApiHost>(new WeakNodeApiHost{
.napi_create_object = [](napi_env env,
napi_value *result) -> napi_status {
bar_calls++;
return napi_status::napi_ok;
}});
// Create and inject a multi host and wrap two envs
WeakNodeApiMultiHost multi_host{nullptr, nullptr};
inject_weak_node_api_host(multi_host);
auto foo_env = multi_host.wrap(napi_env{}, host_foo);
auto bar_env = multi_host.wrap(napi_env{}, host_bar);
napi_value result;
REQUIRE(foo_calls == 0);
REQUIRE(bar_calls == 0);
REQUIRE(napi_create_object(foo_env, &result) == napi_ok);
REQUIRE(foo_calls == 1);
REQUIRE(bar_calls == 0);
REQUIRE(napi_create_object(bar_env, &result) == napi_ok);
REQUIRE(foo_calls == 1);
REQUIRE(bar_calls == 1);

TODO

  • Provide a way to "delete" a WrappedEnv (besides deleting the entire NodeApiMultiHost)
  • Delete WrappedThreadsafeFunction (in some napi_*_threadsafe_function function?) and WrappedAsyncCleanupHookHandle (in napi_remove_async_cleanup_hook).

Open questions

  1. Should we use std::function instead of raw function pointers for all (or some) of the WeakNodeApiHost members? This would allow capturing lambdas, making it much easier to provide a meaningful implementation of for example napi_module_register.

Generated code

Below are samples from the generated code:

napi_create_object implementation

napi_status NodeApiMultiHost::napi_create_object(napi_env arg0,
                                                 napi_value *arg1) {
  auto wrapped = reinterpret_cast<WrappedEnv *>(arg0);
  if (auto host = wrapped->host.lock()) {
    if (host->napi_create_object == nullptr) {
      fprintf(stderr, "Node-API function 'napi_create_object' called on a host "
                      "which doesn't provide an implementation\n");
      return napi_status::napi_generic_failure;
    }

    return host->napi_create_object(wrapped->value, arg1);

  } else {
    fprintf(stderr, "Node-API function 'napi_create_object' called after host "
                    "was destroyed.\n");
    return napi_status::napi_generic_failure;
  }
};

napi_create_threadsafe_function and napi_add_async_cleanup_hook implementations

Notice the calls to wrap, wrapping their opaque "out" pointers.

napi_status NodeApiMultiHost::napi_create_threadsafe_function(
    napi_env env, napi_value func, napi_value async_resource,
    napi_value async_resource_name, size_t max_queue_size,
    size_t initial_thread_count, void *thread_finalize_data,
    napi_finalize thread_finalize_cb, void *context,
    napi_threadsafe_function_call_js call_js_cb,
    napi_threadsafe_function *result) {
  auto wrapped = reinterpret_cast<WrappedEnv *>(env);
  if (auto host = wrapped->host.lock()) {
    if (host->napi_create_threadsafe_function == nullptr) {
      fprintf(stderr,
              "Node-API function 'napi_create_threadsafe_function' called on a "
              "host which doesn't provide an implementation\n");
      return napi_status::napi_generic_failure;
    }

    auto status = host->napi_create_threadsafe_function(
        wrapped->value, func, async_resource, async_resource_name,
        max_queue_size, initial_thread_count, thread_finalize_data,
        thread_finalize_cb, context, call_js_cb, result);
    if (status == napi_status::napi_ok) {
      *result = wrapped->wrap(*result, wrapped, wrapped->host);
    }
    return status;

  } else {
    fprintf(stderr, "Node-API function 'napi_create_threadsafe_function' "
                    "called after host was destroyed.\n");
    return napi_status::napi_generic_failure;
  }
};

napi_status NodeApiMultiHost::napi_add_async_cleanup_hook(
    node_api_basic_env env, napi_async_cleanup_hook hook, void *arg,
    napi_async_cleanup_hook_handle *remove_handle) {
  auto wrapped = reinterpret_cast<WrappedEnv *>(env);
  if (auto host = wrapped->host.lock()) {
    if (host->napi_add_async_cleanup_hook == nullptr) {
      fprintf(stderr, "Node-API function 'napi_add_async_cleanup_hook' called "
                      "on a host which doesn't provide an implementation\n");
      return napi_status::napi_generic_failure;
    }

    auto status = host->napi_add_async_cleanup_hook(wrapped->value, hook, arg,
                                                    remove_handle);
    if (status == napi_status::napi_ok) {
      *remove_handle = wrapped->wrap(*remove_handle, wrapped, wrapped->host);
    }
    return status;

  } else {
    fprintf(stderr, "Node-API function 'napi_add_async_cleanup_hook' called "
                    "after host was destroyed.\n");
    return napi_status::napi_generic_failure;
  }
};

@kraenhansen kraenhansen self-assigned this Nov 10, 2025
@kraenhansen kraenhansen force-pushed the kh/weak-node-api-multi-host branch from 6f9356d to f79a30b Compare November 10, 2025 21:53
@RobinWuu
Copy link

Wow, I really need this capability. And I like the "wrap" mechanisms; they're very elegant for this scenario.

  1. Should we use std::function instead of raw function pointers for all (or some) of the WeakNodeApiHost members? This would allow capturing lambdas, making it much easier to provide a meaningful implementation of for example napi_module_register.

From my perspective, using std::function for napi_module_register indeed provides greater flexibility. If napi_module_register is a std::function, it can enable module isolation between multiple Node-API hosting JS engines/runtimes within the same process. Would this be meaningful in certain scenarios?

I suggest the Wrapped objects be owned by the WeakNodeApiMultiHost object (at least for now), but could we make memory management more efficient to release the Wrapped before the WeakNodeApiMultiHost deletion: In napi_remove_async_cleanup_hook or some of the napi_*_threadsafe_function functions?

I agree that releasing the corresponding Wrapped<T> in napi_remove_async_cleanup_hook or napi_release_threadsafe_function would be appropriate and feasible.

@kraenhansen kraenhansen marked this pull request as ready for review November 11, 2025 15:18
@kraenhansen kraenhansen requested a review from Copilot November 11, 2025 15:18
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull Request Overview

This PR adds multi-host support to weak-node-api, enabling multiple Node-API implementations to coexist in a single process. The implementation wraps opaque Node-API pointers (napi_env, napi_threadsafe_function, napi_async_cleanup_hook_handle) with metadata tracking which host owns them, allowing proper delegation of API calls to the correct implementation.

Key changes:

  • Introduces WeakNodeApiMultiHost class with wrapper mechanism for opaque Node-API types
  • Generates multi-host header and source files alongside existing weak-node-api files
  • Adds comprehensive test coverage for multi-host scenarios including host lifecycle and wrapped pointer handling

Reviewed Changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
packages/weak-node-api/tests/test_multi_host.cpp Comprehensive test suite validating multi-host injection, call routing, host lifecycle, and wrapped opaque pointer handling
packages/weak-node-api/tests/CMakeLists.txt Adds new multi-host test file to the build configuration
packages/weak-node-api/scripts/generators/multi-host.ts Code generator for multi-host header and implementation, including wrapper creation and call delegation logic
packages/weak-node-api/scripts/generate-weak-node-api.ts Updates generator to produce multi-host files with documentation headers
packages/weak-node-api/CMakeLists.txt Includes generated multi-host source and header files in the build
packages/weak-node-api/.gitignore Simplifies ignore pattern to cover all generated files

@kraenhansen kraenhansen force-pushed the kh/weak-node-api-multi-host branch from f79a30b to 4d78b71 Compare November 13, 2025 10:32
@kraenhansen kraenhansen changed the base branch from main to kh/weak-node-api-refactored-generator-3 November 13, 2025 10:32
@kraenhansen kraenhansen force-pushed the kh/weak-node-api-multi-host branch 2 times, most recently from 5ff0a5e to daf6686 Compare November 13, 2025 11:24
@kraenhansen kraenhansen force-pushed the kh/weak-node-api-refactored-generator-3 branch from cf5e056 to bd952a6 Compare November 13, 2025 13:58
@kraenhansen kraenhansen added Apple 🍎 Anything related to the Apple platform (iOS, macOS, Cocoapods, Xcode, XCFrameworks, etc.) Android 🤖 Anything related to the Android platform (Gradle, NDK, Android SDK) labels Nov 13, 2025
@kraenhansen kraenhansen force-pushed the kh/weak-node-api-refactored-generator-3 branch 2 times, most recently from 4ab6985 to 0e545a5 Compare November 15, 2025 06:26
Base automatically changed from kh/weak-node-api-refactored-generator-3 to main November 18, 2025 05:54

struct WrappedEnv;

struct WrappedThreadsafeFunction {

Choose a reason for hiding this comment

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

I noticed that we're calling the constructors of struct WrappedThreadsafeFunction and struct WrappedAsyncCleanupHookHandle with three arguments:

auto ptr = std::make_unique<WrappedAsyncCleanupHookHandle>(original, env, weak_host);
auto ptr = std::make_unique<WrappedThreadsafeFunction>(original, env, weak_host);

When compiling locally, the compiler throws an error. I'm wondering if these structs should explicitly define a constructor that accepts these three parameters?😊

/Applications/Xcode_15.2.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX14.2.sdk/usr/include/c++/v1/__memory/unique_ptr.h:686:30: error: no matching constructor for initialization of 'NodeApiMultiHost::WrappedAsyncCleanupHookHandle'
  return unique_ptr<_Tp>(new _Tp(_VSTD::forward<_Args>(__args)...));
                             ^   ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
weak-node-api/generated/NodeApiMultiHost.cpp:240:12: note: in instantiation of function template specialization 'std::make_unique<NodeApiMultiHost::WrappedAsyncCleanupHookHandle, napi_async_cleanup_hook_handle__ *&, NodeApiMultiHost::WrappedEnv *&, std::weak_ptr<NodeApiHost> &>' requested here
      std::make_unique<WrappedAsyncCleanupHookHandle>(original, env, weak_host);

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

It's supposed to use the default constructor of those structs 🤔 Do you have this in your generated/NodeApiMultiHost.hpp?

  struct WrappedThreadsafeFunction {
    napi_threadsafe_function value;
    WrappedEnv *env;
    std::weak_ptr<NodeApiHost> host;
  };

  struct WrappedAsyncCleanupHookHandle {
    napi_async_cleanup_hook_handle value;
    WrappedEnv *env;
    std::weak_ptr<NodeApiHost> host;
  };

Copy link
Collaborator Author

@kraenhansen kraenhansen Nov 19, 2025

Choose a reason for hiding this comment

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

I might need to add explicit constructors? I don't know why it builds for me and on CI though 🤔
I've just rebased on latest main after #330 merged - pulling the latest might change something for you after a clean build?

Choose a reason for hiding this comment

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

I might need to add explicit constructors? I don't know why it builds for me and on CI though 🤔 I've just rebased on latest main after #330 merged - pulling the latest might change something for you after a clean build?

I see now. My local compiler is configured with a lower C++ standard that doesn't support this parenthesis initialization syntax. That makes sense now. Thanks!😊

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Great 👍 Perhaps I could add something to the CMake config to either configure the language version or fail faster?

@kraenhansen kraenhansen force-pushed the kh/weak-node-api-multi-host branch from daf6686 to 4cbf27b Compare November 19, 2025 14:47
@RobinWuu
Copy link

Hi, @kraenhansen ,
I've run into an issue: in the callback function of napi_create_function, the obtained napi_env is not a WrappedEnv but the original napi_env. This prevents direct use of the Weak Node API within the callback.
Perhaps we should consider adding a mapping from napi_env to WeakNodeApiHost via a host getter? I can't think of a better approach for now.
Thanks for your work on this! And I look forward to hearing your thoughts on this issue.

auto host_foo = std::shared_ptr<WeakNodeApiHost>(new WeakNodeApiHost{
    .napi_create_function =
        [](napi_env env, const char* utf8name, size_t length, napi_callback cb,
           void* callback_data, napi_value* result) -> napi_status {
      // Omitted: actual implementation of napi_create_function across different JS engines
      return napi_ok;
    }});

// Create and inject a multi-host, then wrap two envs
WeakNodeApiMultiHost multi_host{nullptr, nullptr};
inject_weak_node_api_host(multi_host);

// Original napi_env
napi_env raw_env{};
// foo_env is a WrappedEnv
auto foo_env = multi_host.wrap(napi_env{}, host_foo);

napi_callback foo_cb = [](napi_env env, napi_callback_info info) -> napi_value {
  // This `env` is not the WrappedEnv foo_env, but the original napi_env raw_env.
  // Cannot directly call weak-node-api functions here.
  return nullptr;
};

napi_value foo_fn = nullptr;
napi_create_function(foo_env, "foo", 3, foo_cb, nullptr, &foo_fn);

@kraenhansen kraenhansen force-pushed the kh/weak-node-api-multi-host branch from 23802a8 to e82efc2 Compare November 21, 2025 08:14
@kraenhansen
Copy link
Collaborator Author

Cannot directly call weak-node-api functions here.

Ideally, you'd be able to capture the host_foo as a weak_ptr when creating foo_cb, but this isn't possible as napi_create_function expects raw function pointers. I've added two tests showing how I imagine this could be done:

SECTION("via global function") {
napi_value result;
napi_callback cb = [](napi_env env, napi_callback_info info) -> napi_value {
napi_value obj;
napi_status status = napi_create_object(env, &obj);
return obj;
};
napi_create_function(raw_env, "foo", 3, cb, nullptr, &result);
}
SECTION("via callback info") {
napi_value result;
napi_callback cb = [](napi_env env, napi_callback_info info) -> napi_value {
// Get host via callback info
void *data;
napi_get_cb_info(env, info, nullptr, nullptr, nullptr, &data);
auto *host_ptr = static_cast<decltype(&host)>(data);
napi_value obj;
napi_status status = host_ptr->napi_create_object(env, &obj);
return obj;
};
napi_create_function(raw_env, "foo", 3, cb, &host, &result);
}

Perhaps you could adjust these or add a new test as an example of the issues you're seeing?

@RobinWuu
Copy link

Cannot directly call weak-node-api functions here.

Ideally, you'd be able to capture the host_foo as a weak_ptr when creating foo_cb, but this isn't possible as napi_create_function expects raw function pointers. I've added two tests showing how I imagine this could be done:

SECTION("via global function") {
napi_value result;
napi_callback cb = [](napi_env env, napi_callback_info info) -> napi_value {
napi_value obj;
napi_status status = napi_create_object(env, &obj);
return obj;
};
napi_create_function(raw_env, "foo", 3, cb, nullptr, &result);
}
SECTION("via callback info") {
napi_value result;
napi_callback cb = [](napi_env env, napi_callback_info info) -> napi_value {
// Get host via callback info
void *data;
napi_get_cb_info(env, info, nullptr, nullptr, nullptr, &data);
auto *host_ptr = static_cast<decltype(&host)>(data);
napi_value obj;
napi_status status = host_ptr->napi_create_object(env, &obj);
return obj;
};
napi_create_function(raw_env, "foo", 3, cb, &host, &result);
}

Perhaps you could adjust these or add a new test as an example of the issues you're seeing?

I've added a test case try to illustrate my issues. 🙏
https://github.com/RobinWuu/react-native-node-api/blob/5b4eb3f91fcc8e0e9856b8006952e67cb7def7bb/packages/weak-node-api/tests/test_multi_host.cpp#L174-L206

By the way, I've experimented with an alternative approach locally: injecting a NodeApiHostGetter to handle the mapping from napi_env (which can be nullptr, e.g., in napi_call_threadsafe_function) to NodeApiHost.

This approach solves the issues I'm facing because it avoids having two napi_env. However, it intrudes upon the weak-node-api in normal mode, meaning that even in non-multi-host scenarios, one must indirectly obtain the NodeApiHost through the NodeApiHostGetter.

This is the commit for NodeApiHostGetter injection approach, in my fork repo: RobinWuu@f233545

And here's the related test case: RobinWuu@8c68ff4

@kraenhansen
Copy link
Collaborator Author

kraenhansen commented Nov 21, 2025

Okay, reading the comments in depth, I believe I understand your need and problem 👍 Thanks for bearing with me 🙂

This line in my test wont work in a real scenario, since the global functions expect a wrapped env and I'm calling it with a raw unwrapped env:

napi_status status = napi_create_object(env, &obj);

And passing the host through callback info won't work because the weak-node-api concept is supposed to be opaque from the addon's POW

napi_create_function(raw_env, "foo", 3, cb, &host, &result);

It's actually a pretty fundamental flaw in the design 🤔 Hmmm ...

Possible solution?

A solution which come to mind would be to add wrapping of the napi_env before passing it to callbacks, in the implementation of to every Node-API function taking a napi_callback, napi_finalize, napi_async_execute_callback or napi_async_complete_callback argument (all function types taking napi_env as their first argument):

  • napi_define_class
  • napi_create_function
  • napi_set_instance_data
  • napi_create_async_work

This might however be tricky with these callback arguments being raw function pointers 🤔

The main issue here is that we'd need to store the wrapped env behind the data pointer, which is read via the second argument to the callback (of type napi_callback_info).

Here's how far I got in my experiments with an attempt to create a wrap function intended to be called with a callback and data from the "user", which will "wrapped" by a lambda reading the wrapped env off the data from the callback_info and passing the wrapped env to the original callback:

(Notice the TODO , which I don't know how to get around - yet).

std::tuple<napi_callback, void *>
NodeApiMultiHost::WrappedEnv::wrap(napi_callback original_calback,
                                  void *original_data, WrappedEnv *env,
                                  std::weak_ptr<NodeApiHost> weak_host) {
  auto ptr = std::make_unique<WrappedCallback>(original_calback, original_data,
                                              env, weak_host);
  auto raw_ptr = ptr.get();
  env->callbacks.push_back(std::move(ptr));
  napi_callback cb = [](napi_env env, napi_callback_info info) -> napi_value {
    // This function is called by the "real" host with an unwrapped env, which
    // we'd want to wrap before calling the actual callback
    void *data;
    napi_get_cb_info(env, info, nullptr, nullptr, nullptr, &data);
    auto *callback = static_cast<decltype(raw_ptr)>(data);
    assert(callback != nullptr);
    if (auto host = callback->host.lock()) {
      // Call the original callback with the wrapped env
      // TODO: We cannot construct a new napi_callback_info which points to th original data 😬
      callback->value(reinterpret_cast<napi_env>(callback->env), nullptr);
    } else {
      fprintf(
          stderr,
          "Wrapped Node-API callback was called after host was destroyed\n");
      abort();
    }
  };
  return {cb, raw_ptr};
}

I had a look at your getter approach - to be clear, does that solve this issue in your view?

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

Labels

Android 🤖 Anything related to the Android platform (Gradle, NDK, Android SDK) Apple 🍎 Anything related to the Apple platform (iOS, macOS, Cocoapods, Xcode, XCFrameworks, etc.) weak-node-api

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants