node-addon-api
node-addon-api copied to clipboard
How about add C++20 coroutine support to `Napi::Value`?
embind already has coroutine implementation
https://github.com/emscripten-core/emscripten/blob/b5b7fedda835bdf8f172a700726109a4a3899909/system/include/emscripten/val.h#L703-L789
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:
optinthe 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. https://github.com/nodejs/node-addon-api/issues/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.
https://github.com/nodejs/node-addon-api/compare/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.