// libdatachannel-min — minimal N-API binding to libdatachannel. // Exposes ONLY what GMW GoLive needs: // PeerConnection (offer/answer, ICE, SDP), DataChannel (signaling), // Track send (added in media phase). // Built against libdatachannel 0.24.0 (built from source in /tmp/ldc-build). #include #include #include #include #include #include using namespace Napi; namespace { std::string stateToString(rtc::PeerConnection::State s) { switch (s) { case rtc::PeerConnection::State::New: return "new"; case rtc::PeerConnection::State::Connecting: return "connecting"; case rtc::PeerConnection::State::Connected: return "connected"; case rtc::PeerConnection::State::Disconnected: return "disconnected"; case rtc::PeerConnection::State::Failed: return "failed"; case rtc::PeerConnection::State::Closed: return "closed"; default: return "unknown"; } } std::string binaryToString(const rtc::binary& data) { // rtc::binary is std::vector in libdatachannel >= 0.21 std::string msg(data.size(), '\0'); for (size_t i = 0; i < data.size(); i++) { msg[i] = static_cast(data[i]); } return msg; } // Holds a Napi::Promise::Deferred so it can be moved into TSFN lambdas // without invalid copies (node-addon-api 8.x Deferred is not movable). struct DeferredHolder { Promise::Deferred deferred; explicit DeferredHolder(Promise::Deferred d) : deferred(d) {} }; class DataChannelWrap : public Napi::ObjectWrap { public: static Function Init(Napi::Env env) { Function func = DefineClass(env, "DataChannel", { InstanceMethod("send", &DataChannelWrap::Send), InstanceMethod("isOpen", &DataChannelWrap::IsOpen), InstanceMethod("close", &DataChannelWrap::Close), InstanceMethod("onMessage", &DataChannelWrap::OnMessage), InstanceMethod("onOpen", &DataChannelWrap::OnOpen), }); dcConstructor = Napi::Persistent(func); return func; } // Create a JS wrapper (calls the JS constructor, returns instance). static Object NewInstance(Napi::Env env) { return dcConstructor.New({}); } DataChannelWrap(const Napi::CallbackInfo& info) : Napi::ObjectWrap(info) {} void Init(std::shared_ptr dc) { dc_ = dc; dc_->onMessage([this](rtc::message_variant data) { std::string msg; if (std::holds_alternative(data)) { msg = binaryToString(std::get(data)); } else { msg = std::get(data); } if (msgCb_) { msgCb_->BlockingCall([msg](Napi::Env env, Function cb) { cb.Call({String::New(env, msg)}); }); } }); dc_->onOpen([this]() { if (openCb_) { openCb_->BlockingCall([](Napi::Env env, Function cb) { cb.Call({}); }); } }); } private: static FunctionReference dcConstructor; std::shared_ptr dc_; std::shared_ptr msgCb_; std::shared_ptr openCb_; void Send(const Napi::CallbackInfo& info) { std::string msg = info[0].As().Utf8Value(); if (dc_) dc_->send(msg); } Napi::Value IsOpen(const Napi::CallbackInfo& info) { bool open = dc_ && dc_->isOpen(); return Boolean::New(info.Env(), open); } void Close(const Napi::CallbackInfo& info) { if (dc_) dc_->close(); } void OnMessage(const Napi::CallbackInfo& info) { Function cb = info[0].As(); msgCb_ = std::make_shared( ThreadSafeFunction::New(info.Env(), cb, "dc-message", 0, 1)); } void OnOpen(const Napi::CallbackInfo& info) { Function cb = info[0].As(); openCb_ = std::make_shared( ThreadSafeFunction::New(info.Env(), cb, "dc-open", 0, 1)); } }; class PeerConnectionWrap : public Napi::ObjectWrap { public: static Function Init(Napi::Env env) { Function func = DefineClass(env, "PeerConnection", { InstanceMethod("state", &PeerConnectionWrap::State), InstanceMethod("createOffer", &PeerConnectionWrap::CreateOffer), InstanceMethod("createAnswer", &PeerConnectionWrap::CreateAnswer), InstanceMethod("setRemoteDescription", &PeerConnectionWrap::SetRemoteDescription), InstanceMethod("close", &PeerConnectionWrap::Close), InstanceMethod("onStateChange", &PeerConnectionWrap::OnStateChange), InstanceMethod("createDataChannel", &PeerConnectionWrap::CreateDataChannel), InstanceMethod("onDataChannel", &PeerConnectionWrap::OnDataChannel), }); return func; } PeerConnectionWrap(const Napi::CallbackInfo& info) : Napi::ObjectWrap(info) { Napi::Env env = info.Env(); if (!info[0].IsObject()) { throw TypeError::New(env, "config object required"); } Object config = info[0].As(); rtc::Configuration rtcConfig; if (config.Has("iceServers")) { Array servers = config.Get("iceServers").As(); for (uint32_t i = 0; i < servers.Length(); i++) { std::string url = servers.Get(i).As().Utf8Value(); rtcConfig.iceServers.emplace_back(url); } } pc_ = std::make_shared(rtcConfig); // IMPORTANT: register description/gathering callbacks HERE (constructor), // BEFORE any createDataChannel call. libdatachannel only fires // onLocalDescription for negotiations that start AFTER the callback is // registered — if createDataChannel runs first, the offer callback never // fires (verified in C++ spike: test3 vs test2). pc_->onLocalDescription([this](rtc::Description desc) { latestLocalDesc_ = std::string(desc); fprintf(stderr, "[binding] trickle desc, %zu bytes\n", latestLocalDesc_.size()); }); pc_->onGatheringStateChange([this](rtc::PeerConnection::GatheringState gs) { fprintf(stderr, "[binding] gathering state: %d\n", (int)gs); if (gs == rtc::PeerConnection::GatheringState::Complete) { // Use the getter — it returns the FULL SDP including candidates after // gathering (trickle callbacks only carry the initial fragment). auto ld = pc_->localDescription(); if (ld) { latestLocalDesc_ = std::string(*ld); fprintf(stderr, "[binding] final desc, %zu bytes\n", latestLocalDesc_.size()); } resolvePendingLocalDesc_(); } }); } private: std::shared_ptr pc_; std::shared_ptr stateCb_; std::shared_ptr dcCb_; std::string latestLocalDesc_; std::shared_ptr pendingDescDeferred_; std::shared_ptr pendingDescTsfn_; void resolvePendingLocalDesc_() { if (!pendingDescDeferred_ || !pendingDescTsfn_) return; auto holder = pendingDescDeferred_; auto tsfn = pendingDescTsfn_; pendingDescDeferred_.reset(); pendingDescTsfn_.reset(); std::string sdp = latestLocalDesc_; tsfn->BlockingCall([sdp, holder](Napi::Env e, Function) { holder->deferred.Resolve(String::New(e, sdp)); }); } Napi::Value State(const Napi::CallbackInfo& info) { return String::New(info.Env(), pc_ ? stateToString(pc_->state()) : "closed"); } // createOffer() -> Promise — sets local description, waits for // ICE gathering to complete (so candidates are in the SDP), resolves SDP. Napi::Value CreateOffer(const Napi::CallbackInfo& info) { Napi::Env env = info.Env(); auto holder = std::make_shared(Promise::Deferred::New(env)); if (!pc_) { holder->deferred.Reject(Error::New(env, "peer closed").Value()); return holder->deferred.Promise(); } // createDataChannel already triggers negotiation in libdatachannel 0.24 — // if gathering already completed, resolve immediately from the cached SDP. if (!latestLocalDesc_.empty()) { auto tsfn = std::make_shared(ThreadSafeFunction::New( env, Function::New(env, [](const CallbackInfo&) {}), "desc", 0, 1)); std::string sdp = latestLocalDesc_; tsfn->BlockingCall([sdp, holder](Napi::Env e, Function) { holder->deferred.Resolve(String::New(e, sdp)); }); return holder->deferred.Promise(); } if (pendingDescDeferred_) { pendingDescDeferred_->deferred.Reject( Error::New(env, "previous negotiation still pending").Value()); } pendingDescDeferred_ = holder; pendingDescTsfn_ = std::make_shared( ThreadSafeFunction::New(env, Function::New(env, [](const CallbackInfo&) {}), "desc", 0, 1)); fprintf(stderr, "[binding] calling setLocalDescription(Offer)\n"); try { pc_->setLocalDescription(rtc::Description::Type::Offer); fprintf(stderr, "[binding] setLocalDescription returned OK\n"); } catch (const std::exception& e) { pendingDescDeferred_.reset(); fprintf(stderr, "[binding] setLocalDescription THREW: %s\n", e.what()); throw Error::New(env, e.what()); } return holder->deferred.Promise(); } // createAnswer(offerSdp: string) -> Promise Napi::Value CreateAnswer(const Napi::CallbackInfo& info) { Napi::Env env = info.Env(); std::string offer = info[0].As().Utf8Value(); auto holder = std::make_shared(Promise::Deferred::New(env)); if (!pc_) { holder->deferred.Reject(Error::New(env, "peer closed").Value()); return holder->deferred.Promise(); } if (pendingDescDeferred_) { pendingDescDeferred_->deferred.Reject( Error::New(env, "previous negotiation still pending").Value()); } pendingDescDeferred_ = holder; pendingDescTsfn_ = std::make_shared( ThreadSafeFunction::New(env, Function::New(env, [](const CallbackInfo&) {}), "desc", 0, 1)); try { pc_->setRemoteDescription( rtc::Description(offer, rtc::Description::Type::Offer)); fprintf(stderr, "[binding] answer: setRemoteDescription OK\n"); // libdatachannel 0.24 AUTO-GENERATES the answer when a remote offer is // applied (verified in C++ spike test8/9: B desc type=Answer fires // immediately with a=setup:active). Calling setLocalDescription() again // would OVERWRITE it with a role=actpass SDP, which A rejects with // "Illegal role actpass in remote answer description". So we do NOT call // setLocalDescription here — we just wait for gathering complete and // resolve with the auto-generated answer. This also matches @dank074's // Discord voice flow. } catch (const std::exception& e) { pendingDescDeferred_.reset(); fprintf(stderr, "[binding] answer THREW: %s\n", e.what()); holder->deferred.Reject(Error::New(env, e.what()).Value()); } return holder->deferred.Promise(); } void SetRemoteDescription(const Napi::CallbackInfo& info) { std::string sdp = info[0].As().Utf8Value(); std::string type = info[1].As().Utf8Value(); rtc::Description::Type t = (type == "answer") ? rtc::Description::Type::Answer : rtc::Description::Type::Offer; if (pc_) pc_->setRemoteDescription(rtc::Description(sdp, t)); } void Close(const Napi::CallbackInfo& info) { if (pc_) pc_->close(); } void OnStateChange(const Napi::CallbackInfo& info) { Function cb = info[0].As(); stateCb_ = std::make_shared( ThreadSafeFunction::New(info.Env(), cb, "pc-state", 0, 1)); std::shared_ptr pc = pc_; pc->onStateChange([this](rtc::PeerConnection::State state) { if (stateCb_) { std::string s = stateToString(state); stateCb_->BlockingCall([s](Napi::Env env, Function cb) { cb.Call({String::New(env, s)}); }); } }); } Napi::Value CreateDataChannel(const Napi::CallbackInfo& info) { Napi::Env env = info.Env(); std::string label = info[0].As().Utf8Value(); fprintf(stderr, "[binding] createDataChannel(%s)\n", label.c_str()); auto dc = pc_->createDataChannel(label); Object obj = DataChannelWrap::NewInstance(env); DataChannelWrap::Unwrap(obj)->Init(dc); return obj; } void OnDataChannel(const Napi::CallbackInfo& info) { Function cb = info[0].As(); dcCb_ = std::make_shared( ThreadSafeFunction::New(info.Env(), cb, "dc", 0, 1)); std::shared_ptr pc = pc_; pc->onDataChannel([this](std::shared_ptr dc) { if (dcCb_) { auto dcPtr = dc; dcCb_->BlockingCall([dcPtr](Napi::Env env, Function cb) { Object obj = DataChannelWrap::NewInstance(env); DataChannelWrap::Unwrap(obj)->Init(dcPtr); cb.Call({obj}); }); } }); } }; Object InitAll(Napi::Env env, Object exports) { exports.Set("PeerConnection", PeerConnectionWrap::Init(env)); exports.Set("DataChannel", DataChannelWrap::Init(env)); return exports; } NODE_API_MODULE(libdatachannel_min, InitAll) // Definition for the static constructor reference. FunctionReference DataChannelWrap::dcConstructor; } // namespace