70 行 Node 脚本跑通 WebRTC 数据透传:信令手动换,数据点对点

组成:一条 DataChannel 加两次手动信令

WebRTC 数据透传只需要三样东西:两端各一个 RTCPeerConnection、一条 DataChannel、一次 ICE 打洞。DataChannel 可以理解为一条点对点的双向字节管道,用法接近 TCP socket;一条连接上还能随时开很多条通道,各自独立,开新通道不需要重新走信令。

信令(交换连接参数的 SDP)不经过服务器,两端各打印一行 token,人工复制粘贴给对方。本文用纯 JS 实现的 werift 库,npm install werift 即装,无原生编译依赖。

完整脚本见附件 peer.min.js,全文 70 行。

信令交换与数据回传时序

WebRTC 手动信令与数据回传时序

人工搬运只有两次:A 的 offer token 交给 B,B 的 answer token 交回 A。连接建立后 DataChannel 全双工,任意一方发送,接收端原样回传一份副本。

分步最小实现

① 两端各建一个对等端

import { RTCPeerConnection } from "werift";
const pc = new RTCPeerConnection({
  iceServers: [{ urls: "stun:stun.l.google.com:19302" }], // 公共 STUN 打洞
});

② A:建通道,生成 offer,打印 token

// "chat" 只是给这条通道起的名字(对端能读到,用来区分通道用途),不做寻址
const dc = pc.createDataChannel("chat");
// 这行必须写在 createOffer 之前:werift 生成 offer 时如果一条通道都没有,
// 就不会启动 ICE gather,SDP 里连候选地址都没有,对端拿到的是废 token
await pc.setLocalDescription(await pc.createOffer());
// 返回时 ICE 候选收集(gathering)已完成,SDP 自带全部候选,之后无需再交换候选
console.log(encode(pc.localDescription)); // 一行 base64url,人工粘给 B

③ B:收 offer,回 answer,打印 token

await pc.setRemoteDescription(decode(await ask("offer token: ")));
await pc.setLocalDescription(await pc.createAnswer());
console.log(encode(pc.localDescription)); // 一行 base64url,人工粘回 A
// 对端通道打开时的接收处理 pc.ondatachannel 见步⑤

④ A:粘贴 answer token,连接自动建立

await pc.setRemoteDescription(decode(await ask("answer token: ")));
// ICE / DTLS / SCTP 自动完成,DataChannel 变为 open

⑤ 收发与 loop back

B 侧从头到尾没有调用过 createDataChannel。A 建的通道会在 B 侧通过 ondatachannel 事件自动送进来,拿到它才能收发:

// B 侧:收到即原样回传。双方都回传会无限乒乓,所以只让一侧回传
function loopBack(channel) {
  channel.onMessage.subscribe((msg) => channel.send(msg));
}
pc.ondatachannel = (event) => {
  loopBack(event.channel);
};

// A 侧:打印对端回传的内容(A 自己建的通道直接用 dc 变量收发)
dc.onMessage.subscribe((msg) => console.log("[echo]", msg));
rl.on("line", (line) => {
  if (dc && dc.readyState === "open") dc.send(line);
});

token 的编解码各只有一行:

const encode = (d) =>
  Buffer.from(JSON.stringify({ type: d.type, sdp: d.sdp }), "utf8").toString("base64url");
const decode = (line) =>
  JSON.parse(Buffer.from(line.trim(), "base64url").toString("utf8"));

运行方式

node peer.min.js answer   # 终端 B:粘 offer token,打出 answer token
node peer.min.js offer    # 终端 A:粘 answer token,然后直接打字

两端打印 连接状态: connected 后,任意一侧输入文本回车,对端收到并原样回传。

werift 有两个专属的坑,脚本注释里有标注:setLocalDescription 的返回值里 sdp 为空,必须用 pc.localDescriptionSessionDescription.type 是原型 getter,序列化 token 时必须显式取 {type, sdp},否则 answer 侧推断类型,DataChannel 建立不可靠。