spike: minimal N-API libdatachannel binding — WebRTC handshake proven
Phase 0 of GoLive rewrite (drop @dank074/node-datachannel 771MB): minimal N-API binding exposing PeerConnection/DataChannel/ICE/SDP, built against libdatachannel 0.24.0 (from node-datachannel _deps source). Verified: offer/answer/ICE/DataChannel roundtrip between two local peers (test-handshake.js). Key findings: - callbacks must be registered in ctor BEFORE createDataChannel - SDP with candidates comes from localDescription() at gathering Complete - answer auto-generates on setRemoteDescription(offer); do NOT call setLocalDescription() after or role=actpass breaks the peer
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
build/
|
||||||
|
package-lock.json
|
||||||
@@ -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 <napi.h>
|
||||||
|
#include <rtc/rtc.hpp>
|
||||||
|
|
||||||
|
#include <functional>
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
#include <variant>
|
||||||
|
|
||||||
|
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<std::byte> in libdatachannel >= 0.21
|
||||||
|
std::string msg(data.size(), '\0');
|
||||||
|
for (size_t i = 0; i < data.size(); i++) {
|
||||||
|
msg[i] = static_cast<char>(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<DataChannelWrap> {
|
||||||
|
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<DataChannelWrap>(info) {}
|
||||||
|
|
||||||
|
void Init(std::shared_ptr<rtc::DataChannel> dc) {
|
||||||
|
dc_ = dc;
|
||||||
|
dc_->onMessage([this](rtc::message_variant data) {
|
||||||
|
std::string msg;
|
||||||
|
if (std::holds_alternative<rtc::binary>(data)) {
|
||||||
|
msg = binaryToString(std::get<rtc::binary>(data));
|
||||||
|
} else {
|
||||||
|
msg = std::get<std::string>(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<rtc::DataChannel> dc_;
|
||||||
|
std::shared_ptr<ThreadSafeFunction> msgCb_;
|
||||||
|
std::shared_ptr<ThreadSafeFunction> openCb_;
|
||||||
|
|
||||||
|
void Send(const Napi::CallbackInfo& info) {
|
||||||
|
std::string msg = info[0].As<String>().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<Function>();
|
||||||
|
msgCb_ = std::make_shared<ThreadSafeFunction>(
|
||||||
|
ThreadSafeFunction::New(info.Env(), cb, "dc-message", 0, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
void OnOpen(const Napi::CallbackInfo& info) {
|
||||||
|
Function cb = info[0].As<Function>();
|
||||||
|
openCb_ = std::make_shared<ThreadSafeFunction>(
|
||||||
|
ThreadSafeFunction::New(info.Env(), cb, "dc-open", 0, 1));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
class PeerConnectionWrap : public Napi::ObjectWrap<PeerConnectionWrap> {
|
||||||
|
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<PeerConnectionWrap>(info) {
|
||||||
|
Napi::Env env = info.Env();
|
||||||
|
if (!info[0].IsObject()) {
|
||||||
|
throw TypeError::New(env, "config object required");
|
||||||
|
}
|
||||||
|
Object config = info[0].As<Object>();
|
||||||
|
rtc::Configuration rtcConfig;
|
||||||
|
if (config.Has("iceServers")) {
|
||||||
|
Array servers = config.Get("iceServers").As<Array>();
|
||||||
|
for (uint32_t i = 0; i < servers.Length(); i++) {
|
||||||
|
std::string url = servers.Get(i).As<String>().Utf8Value();
|
||||||
|
rtcConfig.iceServers.emplace_back(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pc_ = std::make_shared<rtc::PeerConnection>(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<rtc::PeerConnection> pc_;
|
||||||
|
std::shared_ptr<ThreadSafeFunction> stateCb_;
|
||||||
|
std::shared_ptr<ThreadSafeFunction> dcCb_;
|
||||||
|
std::string latestLocalDesc_;
|
||||||
|
std::shared_ptr<DeferredHolder> pendingDescDeferred_;
|
||||||
|
std::shared_ptr<ThreadSafeFunction> 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<string> — 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<DeferredHolder>(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>(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>(
|
||||||
|
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<string>
|
||||||
|
Napi::Value CreateAnswer(const Napi::CallbackInfo& info) {
|
||||||
|
Napi::Env env = info.Env();
|
||||||
|
std::string offer = info[0].As<String>().Utf8Value();
|
||||||
|
auto holder = std::make_shared<DeferredHolder>(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>(
|
||||||
|
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<String>().Utf8Value();
|
||||||
|
std::string type = info[1].As<String>().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<Function>();
|
||||||
|
stateCb_ = std::make_shared<ThreadSafeFunction>(
|
||||||
|
ThreadSafeFunction::New(info.Env(), cb, "pc-state", 0, 1));
|
||||||
|
std::shared_ptr<rtc::PeerConnection> 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<String>().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<Function>();
|
||||||
|
dcCb_ = std::make_shared<ThreadSafeFunction>(
|
||||||
|
ThreadSafeFunction::New(info.Env(), cb, "dc", 0, 1));
|
||||||
|
std::shared_ptr<rtc::PeerConnection> pc = pc_;
|
||||||
|
pc->onDataChannel([this](std::shared_ptr<rtc::DataChannel> 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
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"target_name": "libdatachannel_min",
|
||||||
|
"sources": ["binding.cpp"],
|
||||||
|
"include_dirs": [
|
||||||
|
"<!@(node -p \"require('node-addon-api').include\")",
|
||||||
|
"/home/code/GMW/services/discord-gateway/node_modules/.pnpm/@lng2004+node-datachannel@0.32.0-20260202/node_modules/@lng2004/node-datachannel/build/_deps/libdatachannel-src/include"
|
||||||
|
],
|
||||||
|
"libraries": [
|
||||||
|
"/tmp/ldc-build/libdatachannel.so.0.24.0"
|
||||||
|
],
|
||||||
|
"cflags": ["-std=c++17", "-fexceptions"],
|
||||||
|
"cflags_cc": ["-std=c++17", "-fexceptions"],
|
||||||
|
"defines": ["NAPI_CPP_EXCEPTIONS"],
|
||||||
|
"conditions": [
|
||||||
|
["OS=='linux'", { "cflags": ["-fvisibility=hidden"] }]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// libdatachannel-min — JS entry.
|
||||||
|
const native = require("./build/Release/datachannel_min.node");
|
||||||
|
module.exports = native;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "libdatachannel-min",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Minimal N-API binding to libdatachannel — PeerConnection, DataChannel, ICE, SDP (+ media tracks for GoLive)",
|
||||||
|
"main": "index.js",
|
||||||
|
"gypfile": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "node-gyp rebuild",
|
||||||
|
"test": "node test-handshake.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"node-addon-api": "^8.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"node-gyp": "^11.5.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// Phase 0 spike: prove the minimal binding can do a full WebRTC handshake
|
||||||
|
// (offer/answer + ICE + DataChannel) between two local PeerConnections.
|
||||||
|
"use strict";
|
||||||
|
const { PeerConnection } = require("./build/Release/datachannel_min.node");
|
||||||
|
|
||||||
|
function log(...args) {
|
||||||
|
console.log("[spike]", ...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const pcA = new PeerConnection({ iceServers: [] });
|
||||||
|
const pcB = new PeerConnection({ iceServers: [] });
|
||||||
|
|
||||||
|
const stateLog = [];
|
||||||
|
pcA.onStateChange((s) => {
|
||||||
|
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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user