Skip to content

How about add C++20 coroutine support to Napi::Value? #1456

Closed
@toyobayashi

Description

@toyobayashi

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions