diff --git a/services/discord-gateway/native/libdatachannel-min/.gitignore b/services/discord-gateway/native/libdatachannel-min/.gitignore new file mode 100644 index 0000000..beea512 --- /dev/null +++ b/services/discord-gateway/native/libdatachannel-min/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +build/ +package-lock.json diff --git a/services/discord-gateway/native/libdatachannel-min/binding.cpp b/services/discord-gateway/native/libdatachannel-min/binding.cpp new file mode 100644 index 0000000..a7b9dbc --- /dev/null +++ b/services/discord-gateway/native/libdatachannel-min/binding.cpp @@ -0,0 +1,355 @@ +// 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 diff --git a/services/discord-gateway/native/libdatachannel-min/binding.gyp b/services/discord-gateway/native/libdatachannel-min/binding.gyp new file mode 100644 index 0000000..f352b50 --- /dev/null +++ b/services/discord-gateway/native/libdatachannel-min/binding.gyp @@ -0,0 +1,21 @@ +{ + "targets": [ + { + "target_name": "libdatachannel_min", + "sources": ["binding.cpp"], + "include_dirs": [ + " { + stateLog.push(`A:${s}`); + log("A state:", s); + }); + pcB.onStateChange((s) => { + stateLog.push(`B:${s}`); + log("B state:", s); + }); + + // B waits for incoming DataChannel + const received = new Promise((resolve) => { + pcB.onDataChannel((dc) => { + log("B got incoming DataChannel"); + dc.onOpen(() => log("B DataChannel open")); + dc.onMessage((msg) => { + log("B received message:", msg); + dc.send("pong from B"); + resolve(msg); + }); + }); + }); + + // A creates an outgoing DataChannel + const dcA = pcA.createDataChannel("test"); + dcA.onOpen(() => { + log("A DataChannel open — sending hello"); + dcA.send("hello from A"); + }); + dcA.onMessage((msg) => { + log("A received reply:", msg); + }); + + // Offer/answer dance + log("A createOffer..."); + const offer = await pcA.createOffer(); + log("Offer SDP bytes:", offer.length); + log("B createAnswer..."); + const answer = await pcB.createAnswer(offer); + log("Answer SDP bytes:", answer.length); + const setupMatch = answer.match(/a=setup:(\S+)/); + log("Answer setup role:", setupMatch ? setupMatch[1] : "NONE"); + pcA.setRemoteDescription(answer, "answer"); + + // Wait for message roundtrip + const msg = await Promise.race([ + received, + new Promise((_, rej) => setTimeout(() => rej(new Error("TIMEOUT waiting for datachannel message")), 15000)), + ]); + + log("ROUNDTRIP OK — B got:", msg); + log("States:", stateLog.join(" | ")); + + const aState = pcA.state(); + const bState = pcB.state(); + log("Final states — A:", aState, "B:", bState); + + pcA.close(); + pcB.close(); + + if (msg !== "hello from A") throw new Error("wrong message"); + if (aState !== "connected" && aState !== "disconnected") throw new Error("A not connected: " + aState); + log("SPIKE PASSED ✅"); +} + +main().catch((e) => { + console.error("SPIKE FAILED:", e.message); + process.exit(1); +});