You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

基于TURN数据信道的ICE Trickle信令方案弊端探讨

受限信令下的WebRTC连接优化方案与疑问

我需要使用WebRTC连接两个对等端,但存在一个限制:每个对等端仅能通过初始信令通道向对方传递一条消息。

常规解决方案是等待ICE收集完成后再发送应答,但有时ICE收集耗时可达一分钟左右;而如果对等端能尽早交换正确的ICE候选,连接可更快建立(这正是ICE Trickle的作用)。我拥有一台TURN服务器,可基本保证WebRTC连接能成功建立。

因此我提出以下方案:

  • 等待收集到一个TURN("relay")候选
  • 通过初始(单次)信令通道向远端对等端发送本地描述
  • 在icecandidate事件触发时,通过WebRTC数据信道发送ICE候选

理论上,该方案可确保连接在ICE收集完成前建立,且对等端仍能交换收集到的全部ICE候选。

原型代码

export class CallsManager {
  private peerConnection: RTCPeerConnection;

  private iceTricklingDataChannel: RTCDataChannel;
  /**
   * Stores local ICE candidates to be sent to the remote peer
   * when the data channel opens.
   */
  private iceTricklingBuffer: Array<RTCIceCandidate | null>;

  constructor() {
    this.peerConnection = new RTCPeerConnection({
      iceServers: [
        {
          urls: "turn:<your_turn_ip>",
          username: "username",
          credential: "password123",
        },
      ],
    });

    this.iceTricklingBuffer = [];
    this.iceTricklingDataChannel = this.peerConnection.createDataChannel(
      "iceTrickling",
      {
        negotiated: true,
        id: 1,
      },
    );
    this.iceTricklingDataChannel.onmessage = (e) => {
      console.log("received ICE candidate from remote peer", e.data);
      this.peerConnection.addIceCandidate(JSON.parse(e.data));
    };
    this.iceTricklingDataChannel.onopen = () => {
      console.log(
        "iceTricklingDataChannel open: sending buffered ICE candidates",
        this.iceTricklingBuffer,
      );
      for (const candidate of this.iceTricklingBuffer) {
        sendIceCandidateToDataChannel(this.iceTricklingDataChannel, candidate);
      }
      this.iceTricklingBuffer = [];
    };
  }

  async init(
    outStream: MediaStream,
    onIncomingStream: (stream: MediaStream) => void,
  ) {
    this.peerConnection.ontrack = (e: RTCTrackEvent) => {
      const stream = e.streams[0];
      onIncomingStream(stream);
    };
    outStream
      .getTracks()
      .forEach((track) => this.peerConnection.addTrack(track, outStream));

    const onIncomingCall = async (payload: string) => {
      const gatheredEnoughIceP = gatheredEnoughIce(this.peerConnection);

      const offerObject = {
        type: "offer",
        sdp: payload,
      } as RTCSessionDescriptionInit;
      const offerDescription = new RTCSessionDescription(offerObject);
      this.peerConnection.setRemoteDescription(offerDescription);
      this.peerConnection.setLocalDescription(
        await this.peerConnection.createAnswer(),
      );

      await gatheredEnoughIceP;
      const answer = this.peerConnection.localDescription!.sdp;
      this.peerConnection.onicecandidate =
        this.trickleIceOverDataChannel.bind(this);
      window.signaling.acceptCall(answer);
    };
    const onAcceptedCall = (payload: string) => {
      const answerObject = {
        type: "answer",
        sdp: payload,
      } as RTCSessionDescriptionInit;
      const answerDescription = new RTCSessionDescription(answerObject);

      this.peerConnection.setRemoteDescription(answerDescription);
    };
  }

  async startCall(): Promise<void> {
    const gatheredEnoughIceP = gatheredEnoughIce(this.peerConnection);
    this.peerConnection.setLocalDescription(
      await this.peerConnection.createOffer(),
    );
    await gatheredEnoughIceP;
    const offer = this.peerConnection.localDescription!.sdp;
    this.peerConnection.onicecandidate =
      this.trickleIceOverDataChannel.bind(this);
    window.signaling.startCall(offer);
  }

  private trickleIceOverDataChannel(e: RTCPeerConnectionIceEvent) {
    if (this.iceTricklingDataChannel.readyState !== "open") {
      console.log(
        "Gathered new ICE candidate, but iceTricklingDataChannel is not yet open, will buffer it",
        e.candidate,
      );
      this.iceTricklingBuffer.push(e.candidate);
      return;
    }
    sendIceCandidateToDataChannel(this.iceTricklingDataChannel, e.candidate);
  }
}

function gatheredEnoughIce(pc: RTCPeerConnection): Promise<void> {
  if (pc.localDescription != null || pc.remoteDescription != null) {
    console.warn(
      "gatheredEnoughIce called after setLocalDescription " +
        "or setRemoteDescription: " +
        "it might not have captured all ICE candidate events",
    );
  }

  const gotTurnCandidate = new Promise<void>((r) => {
    const listener = (e: RTCPeerConnectionIceEvent) => {
      if (e.candidate != null && e.candidate.type === "relay") {
        // TODO is it enought to gather just one "relay" candidate
        // to guarantee that the data channel will establish?
        r();

        pc.removeEventListener("icecandidate", listener);
      }
    };
    pc.addEventListener("icecandidate", listener);
  });

  const iceGatheringComplete = new Promise<void>((r) => {
    const listener = () => {
      if (pc.iceGatheringState === "complete") {
        r();
        pc.removeEventListener("icegatheringstatechange", listener);
      }
    };
    pc.addEventListener("icegatheringstatechange", listener);
  });

  return Promise.race([gotTurnCandidate, iceGatheringComplete]);
}

function sendIceCandidateToDataChannel(
  dataChannel: RTCDataChannel,
  candidate: null | RTCIceCandidate,
) {
  console.log("sending ICE candidate to remote peer", candidate);
  dataChannel.send(
    JSON.stringify(candidate === null ? candidate : candidate.toJSON()),
  );
}

具体疑问

  1. 如何判断已收集足够候选以基本保证连接建立(无数据包过滤、TURN服务器正常等前提下)?即仅收集一个"relay"候选是否足够?实际测试中这似乎并不总是有效,对等端会收集3个"relay"候选,分别对应视频、音频、数据信道三个媒体流,且sdpMid各不相同。
  2. 后续新增媒体流时,是否会触发"negotiationneeded"事件?若用户设备从Wi-Fi切换到蜂窝网络导致IP变更、连接中断,能否继续通过WebRTC连接的数据信道(经TURN服务器)进行信令?或者是否可能因对等端切换至真正的P2P连接导致TURN服务器连接超时,进而无法通过RTCPeerConnection通信?

适用场景

我有两个相关使用场景:

  • 通过邮件进行信令,希望将消息数量降至最低,同时避免将用户IP通过邮件发送(可能被邮件服务器永久存储)。
  • Tor项目的Snowflake仅支持对等端间传递一条消息,相关议题为"Signaling through TURN"。

此外还有其他适用场景,例如初始信令通道不可信时,对等端不希望将IP地址或私有网络信息发送至初始信令通道(即先使用iceTransportPolicy: 'relay',在通过TURN建立加密P2P连接后,再调用restartIce()或创建新的RTCPeerConnection),比如通过Tor网络进行信令的场景。


内容的提问来源于stack exchange,提问作者WofWca

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.12 10:48:09