How about add C++20 coroutine support to `Napi::Value`?
toyobayashi opened this issue · 9 comments
embind already has coroutine implementation
I just now tried to write a toy version, that makes it possible to co_await
a JavaScript Promise in C++.
class CoPromise : public Napi::Promise
#include <coroutine>
#include <exception>
#include <napi.h>
class CoPromise : public Napi::Promise {
public:
CoPromise(napi_env env, napi_value value): Napi::Promise(env, value) {};
class promise_type {
private:
Napi::Env env_;
Napi::Promise::Deferred deferred_;
public:
promise_type(const Napi::CallbackInfo& info):
env_(info.Env()), deferred_(Napi::Promise::Deferred::New(info.Env())) {}
CoPromise get_return_object() const {
return deferred_.Promise().As<CoPromise>();
}
std::suspend_never initial_suspend () const noexcept { return {}; }
std::suspend_never final_suspend () const noexcept { return {}; }
void unhandled_exception() const {
std::exception_ptr exception = std::current_exception();
try {
std::rethrow_exception(exception);
} catch (const Napi::Error& e) {
deferred_.Reject(e.Value());
} catch (const std::exception &e) {
deferred_.Reject(Napi::Error::New(env_, e.what()).Value());
} catch (const std::string& e) {
deferred_.Reject(Napi::Error::New(env_, e).Value());
} catch (const char* e) {
deferred_.Reject(Napi::Error::New(env_, e).Value());
} catch (...) {
deferred_.Reject(Napi::Error::New(env_, "Unknown Error").Value());
}
}
void return_value(Value value) const {
Resolve(value);
}
void Resolve(Value value) const {
deferred_.Resolve(value);
}
void Reject(Value value) const {
deferred_.Reject(value);
}
};
class Awaiter {
private:
Napi::Promise promise_;
std::coroutine_handle<promise_type> handle_;
Napi::Value fulfilled_result_;
public:
Awaiter(Napi::Promise promise): promise_(promise), handle_(), fulfilled_result_() {}
constexpr bool await_ready() const noexcept { return false; }
void await_suspend(std::coroutine_handle<promise_type> handle) {
handle_ = handle;
promise_.Get("then").As<Napi::Function>().Call(promise_, {
Napi::Function::New(promise_.Env(), [this](const Napi::CallbackInfo& info) -> Value {
fulfilled_result_ = info[0];
handle_.resume();
return info.Env().Undefined();
}),
Napi::Function::New(promise_.Env(), [this](const Napi::CallbackInfo& info) -> Value {
handle_.promise().Reject(info[0]);
handle_.destroy();
return info.Env().Undefined();
})
});
}
Value await_resume() const {
return fulfilled_result_;
}
};
Awaiter operator co_await() const {
return Awaiter(*this);
}
};
binding.gyp
{
"target_defaults": {
"cflags_cc": [ "-std=c++20" ],
"xcode_settings": {
"CLANG_CXX_LANGUAGE_STANDARD":"c++20"
},
# https://github.com/nodejs/node-gyp/issues/1662#issuecomment-754332545
"msbuild_settings": {
"ClCompile": {
"LanguageStandard": "stdcpp20"
}
},
},
"targets": [
{
"target_name": "binding",
"include_dirs": [
"<!@(node -p \"require('node-addon-api').include\")"
],
"dependencies": [
"<!(node -p \"require('node-addon-api').targets\"):node_addon_api_except"
],
"sources": [
"src/binding.cpp"
]
}
]
}
binding.cpp
CoPromise NestedCoroutine(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
Napi::Value async_function = info[0];
if (!async_function.IsFunction()) {
throw Napi::Error::New(env, "not function");
}
Napi::Value result = co_await async_function.As<Napi::Function>()({}).As<CoPromise>();
co_return Napi::Number::New(env, result.As<Napi::Number>().DoubleValue() * 2);
}
CoPromise Coroutine(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
Napi::Value number = co_await NestedCoroutine(info);
co_return Napi::Number::New(env, number.As<Napi::Number>().DoubleValue() * 2);
}
CoPromise CoroutineThrow(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
Napi::Value number = co_await NestedCoroutine(info);
throw Napi::Error::New(env, "test error");
co_return Napi::Value();
}
Napi::Object Init(Napi::Env env, Napi::Object exports) {
exports.Set("coroutine", Napi::Function::New(env, Coroutine));
exports.Set("coroutineThrow", Napi::Function::New(env, CoroutineThrow));
return exports;
}
NODE_API_MODULE(addon, Init)
index.js
const binding = require('./build/Release/binding.node')
async function main () {
await binding.coroutine(function () {
return new Promise((resolve, _) => {
setTimeout(() => {
resolve(42)
}, 1000)
})
}).then(value => {
console.log(value)
}).catch(err => {
console.error('JS caught error', err)
})
await binding.coroutine(function () {
return new Promise((_, reject) => {
setTimeout(() => {
reject(42)
}, 1000)
})
}).then(value => {
console.log(value)
}).catch(err => {
console.error('JS caught error', err)
})
await binding.coroutineThrow(function () {
return new Promise((resolve, _) => {
setTimeout(() => {
resolve(42)
}, 1000)
})
}).then(value => {
console.log(value)
}).catch(err => {
console.error('JS caught error', err)
})
}
main()
node index.js
(1000ms after)
168
(1000ms after)
JS caught error 42
(1000ms after)
JS caught error [Error: test error]
output
FWIW, node-addon-api is restricted to the same build restrictions as node, which is c++17.
@KevinEady you are right, but we have two choices:
optin
the feature in case C++ 20 is enabled- Create this new api as an external module
Whaty do you think about?
I think in instances where functionality is added that is not specifically a wrapper for Node-API functionality, we defer to placing the functionality in a separate module/package owned by the original code writer (and therefore not maintained by us), eg. #1163
@KevinEady Is it a better choice to add promise_type
and operator co_await
to Napi::Value
instead of Napi::Promise
? It's similar to JavaScript that can await any type of JavaScript values and the coroutine suspends when await a Thenable
. If go this way, since the Napi::Value
is the base class of all values, I think place changes of Napi::Value
in node-addon-api repo is reasonable. Also adding #if __cplusplus >= 202002L
guard to allow optin.
{
"cflags_cc": [ "-std=c++20" ],
"xcode_settings": {
"CLANG_CXX_LANGUAGE_STANDARD":"c++20",
"OTHER_CPLUSPLUSFLAGS": [ "-std=c++20" ]
},
# https://github.com/nodejs/node-gyp/issues/1662#issuecomment-754332545
"msbuild_settings": {
"ClCompile": {
"LanguageStandard": "stdcpp20"
}
},
}
for example, I changed my toy implementation and placed it in node_modules/node-addon-api/napi.h
diff --git a/node_modules/node-addon-api/napi.h b/node_modules/node-addon-api/napi.h
diff --git a/node_modules/node-addon-api/napi.h b/node_modules/node-addon-api/napi.h
index 9f20cb8..8edc558 100644
--- a/node_modules/node-addon-api/napi.h
+++ b/node_modules/node-addon-api/napi.h
@@ -20,6 +20,11 @@
#include <string>
#include <vector>
+#if __cplusplus >= 202002L
+#include <coroutine>
+#include <variant>
+#endif
+
// VS2015 RTM has bugs with constexpr, so require min of VS2015 Update 3 (known
// good version)
#if !defined(_MSC_VER) || _MSC_FULL_VER >= 190024210
@@ -169,6 +174,10 @@ namespace NAPI_CPP_CUSTOM_NAMESPACE {
// Forward declarations
class Env;
class Value;
+#if __cplusplus >= 202002L
+class ValuePromiseType;
+class ValueAwaiter;
+#endif
class Boolean;
class Number;
#if NAPI_VERSION > 5
@@ -482,6 +491,12 @@ class Value {
MaybeOrValue<Object> ToObject()
const; ///< Coerces a value to a JavaScript object.
+#if __cplusplus >= 202002L
+ using promise_type = ValuePromiseType;
+
+ ValueAwaiter operator co_await() const;
+#endif
+
protected:
/// !cond INTERNAL
napi_env _env;
@@ -3189,6 +3204,117 @@ class Addon : public InstanceWrap<T> {
};
#endif // NAPI_VERSION > 5
+#if __cplusplus >= 202002L
+
+class ValuePromiseType {
+ private:
+ Env env_;
+ Promise::Deferred deferred_;
+
+ public:
+ ValuePromiseType(const CallbackInfo& info):
+ env_(info.Env()), deferred_(Promise::Deferred::New(info.Env())) {}
+
+ Value get_return_object() const {
+ return deferred_.Promise();
+ }
+ std::suspend_never initial_suspend () const NAPI_NOEXCEPT { return {}; }
+ std::suspend_never final_suspend () const NAPI_NOEXCEPT { return {}; }
+
+ void unhandled_exception() const {
+ std::exception_ptr exception = std::current_exception();
+#ifdef NAPI_CPP_EXCEPTIONS
+ try {
+ std::rethrow_exception(exception);
+ } catch (const Error& e) {
+ deferred_.Reject(e.Value());
+ } catch (const std::exception &e) {
+ deferred_.Reject(Error::New(env_, e.what()).Value());
+ } catch (const Value& e) {
+ deferred_.Reject(e);
+ } catch (const std::string& e) {
+ deferred_.Reject(Error::New(env_, e).Value());
+ } catch (const char* e) {
+ deferred_.Reject(Error::New(env_, e).Value());
+ } catch (...) {
+ deferred_.Reject(Error::New(env_, "Unknown Error").Value());
+ }
+#else
+ std::rethrow_exception(exception);
+#endif
+ }
+
+ void return_value(Value value) const {
+ if (env_.IsExceptionPending()) {
+ Reject(env_.GetAndClearPendingException().Value());
+ } else {
+ Resolve(value);
+ }
+ }
+
+ void Resolve(Value value) const {
+ deferred_.Resolve(value);
+ }
+
+ void Reject(Value value) const {
+ deferred_.Reject(value);
+ }
+};
+
+class ValueAwaiter {
+ private:
+ std::variant<Value, Value, Value> state_;
+
+ public:
+ ValueAwaiter(Value value): state_(std::in_place_index<0>, value) {}
+
+ bool await_ready() {
+ const Value* value = std::get_if<0>(&state_);
+ if (value->IsPromise() || (value->IsObject() && value->As<Object>().Get("then").IsFunction())) {
+ return false;
+ }
+ state_.emplace<1>(*value);
+ return true;
+ }
+
+ void await_suspend(std::coroutine_handle<ValuePromiseType> handle) {
+ Object thenable = std::get_if<0>(&state_)->As<Object>();
+ Env env = thenable.Env();
+ thenable.Get("then").As<Function>().Call(thenable, {
+ Function::New(env, [this, handle](const CallbackInfo& info) -> Value {
+ state_.emplace<1>(info[0]);
+ handle.resume();
+ return info.Env().Undefined();
+ }),
+ Function::New(env, [this, handle](const CallbackInfo& info) -> Value {
+ state_.emplace<2>(info[0]);
+#ifdef NAPI_CPP_EXCEPTIONS
+ handle.resume();
+#else
+ handle.promise().Reject(info[0]);
+ handle.destroy();
+#endif
+ return info.Env().Undefined();
+ })
+ });
+ }
+
+ Value await_resume() const {
+ const Value* ok = std::get_if<1>(&state_);
+ if (ok) {
+ return *ok;
+ }
+ const Value* err = std::get_if<2>(&state_);
+ NAPI_THROW(Error(err->Env(), *err), Value());
+ }
+};
+
+inline ValueAwaiter Value::operator co_await() const {
+ return { *this };
+}
+
+#endif // __cplusplus >= 202002L
+
#ifdef NAPI_CPP_CUSTOM_NAMESPACE
} // namespace NAPI_CPP_CUSTOM_NAMESPACE
#endif
Then the usage becomes more nature
#ifdef NAPI_CPP_EXCEPTIONS
#define NAPI_THROW_CO_RETURN(e, ...) throw e
#else
#define NAPI_THROW_CO_RETURN(e, ...) \
do { \
(e).ThrowAsJavaScriptException(); \
co_return __VA_ARGS__; \
} while (0)
#endif
Napi::Value NestedCoroutine(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
Napi::Value async_function = info[0];
if (!async_function.IsFunction()) {
NAPI_THROW_CO_RETURN(Napi::Error::New(env, "not function"), Napi::Value());
}
Napi::Value result = co_await async_function.As<Napi::Function>()({});
result = co_await result; // ok
co_return Napi::Number::New(env, result.As<Napi::Number>().DoubleValue() * 2);
}
Napi::Value Coroutine(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
Napi::Value number = co_await NestedCoroutine(info);
co_return Napi::Number::New(env, number.As<Napi::Number>().DoubleValue() * 2);
}
Napi::Value CoroutineThrow(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
co_await NestedCoroutine(info);
NAPI_THROW_CO_RETURN(Napi::Error::New(env, "test error"), Napi::Value());
co_return Napi::Value();
}
Napi::Object Init(Napi::Env env, Napi::Object exports) {
exports.Set("coroutine", Napi::Function::New(env, Coroutine));
exports.Set("coroutineThrow", Napi::Function::New(env, CoroutineThrow));
return exports;
}
It would be cool if node-addon-api can get this feature.
main...toyobayashi:node-addon-api:coroutine
I added changes and test in my fork. This is a very simple implementation and have not tested complex use case.
Following up on @KevinEady's earlier comment about node-addon-api being a thin wrapper, this is documented in https://github.com/nodejs/node-addon-api/blob/main/CONTRIBUTING.md#source-changes.
We discussed in the node-api team meeting today and based on our documented approach we believe this functionality is best covered in a separated module outside of node-addon-api unless that is impossible.
Some team members are going to take a deeper look and we'll talk about it again next time.
This issue is stale because it has been open many days with no activity. It will be closed soon unless the stale label is removed or a comment is made.
We discussed again today, from the discussion in the meeting today, the feeling of the team continuies to be that it can be implemented on top of node-addon-api without needing to be integrated. Can you confirm that?
That along with the agreed approach of keeping node-api and node-addon-api lean as documented in https://github.com/nodejs/node-addon-api/blob/main/CONTRIBUTING.md#source-changes means that we believe it should be implemented in an external libray versus integrated into node-addon-api itself.
Our suggestion is that you create a separate repo/npm package to provide the functionality. If you do that please let us know and we will consider linking to it from the node-addon-api README.md for those who would be interested in co-routine support.
Let us know if that makes sense to you so we can close the issue?
Respect team's opinion, I create this issue just inspired by embind. Thanks for your suggestion.