1V1音视频对话2--Web 双浏览器完整通话测试(强制 relay)

这一步很重要,目标是两个浏览器窗口,通过你的 TURN 服务器,100% relay 建立视频通话,主要是测试跨网段的WebRTC实现。

第一部分:测试结构说明

我们做一个:

bash 复制代码
index.html
server.js (Node 信令)

流程:

浏览器 A ↔ WebSocket ↔ 浏览器 B

媒体流 100% 走 TURN relay

第二部分:先创建信令服务器(最简单)

在服务器执行:

bash 复制代码
apt install nodejs npm -y
mkdir webrtc-test
cd webrtc-test
npm init -y
npm install ws

创建文件:

bash 复制代码
vi server.js

粘贴:

bash 复制代码
const WebSocket = require('ws');

const wss = new WebSocket.Server({ port: 3000 });

let clients = {};

wss.on('connection', (ws) => {
    ws.on('message', (message) => {
        const data = JSON.parse(message);

        if (data.type === 'register') {
            clients[data.id] = ws;
            ws.id = data.id;
            console.log("Registered:", data.id);
        }

        if (data.to && clients[data.to]) {
            clients[data.to].send(JSON.stringify(data));
        }
    });

    ws.on('close', () => {
        if (ws.id) delete clients[ws.id];
    });
});

console.log("WebSocket signaling server running on port 3000");

启动:

bash 复制代码
node server.js

第三部分:创建 WebRTC 页面

创建:

bash 复制代码
vi index.html

粘贴完整代码:

bash 复制代码
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>WebRTC TURN Relay Test</title>
</head>
<body>

<h3>WebRTC TURN Relay Test</h3>

Your ID: <input id="myId"><br>
Peer ID: <input id="peerId"><br>

<button onclick="connect()">Connect</button>
<button onclick="call()">Call</button>
<button onclick="hangup()">Hangup</button>

<br><br>

<video id="localVideo" autoplay playsinline muted width="300"></video>
<video id="remoteVideo" autoplay playsinline width="300"></video>

<script>

let ws;
let pc;
let localStream;
let myId;
let peerId;
let pendingCandidates = [];

const iceConfig = {
  iceTransportPolicy: "relay",  // 强制走 TURN
  iceServers: [
    {
      urls: "turn:123.129.219.235:3478?transport=udp",
      username: "lanz",
      credential: "QAZ123"
    }
  ]
};

function logState() {
  if (!pc) return;
  console.log("ICE:", pc.iceConnectionState);
  console.log("Connection:", pc.connectionState);
}

function connect() {
  myId = document.getElementById("myId").value;

  ws = new WebSocket("wss://tt.toim.cc/ws");  // 修改成你的地址

  ws.onopen = () => {
    console.log("Connected to signaling server");
    ws.send(JSON.stringify({type:"register", id:myId}));
  };

  ws.onmessage = async (msg) => {
    const data = JSON.parse(msg.data);

    if (data.type === "offer") {
      peerId = data.from;
      await createPeer();

      await pc.setRemoteDescription(new RTCSessionDescription(data.offer));

      // 处理缓存的 candidates
      for (let c of pendingCandidates) {
        await pc.addIceCandidate(new RTCIceCandidate(c));
      }
      pendingCandidates = [];

      const answer = await pc.createAnswer();
      await pc.setLocalDescription(answer);

      ws.send(JSON.stringify({
        type:"answer",
        answer,
        to:peerId,
        from:myId
      }));
    }

    if (data.type === "answer") {
      await pc.setRemoteDescription(new RTCSessionDescription(data.answer));

      for (let c of pendingCandidates) {
        await pc.addIceCandidate(new RTCIceCandidate(c));
      }
      pendingCandidates = [];
    }

    if (data.type === "candidate") {
      if (pc && pc.remoteDescription) {
        await pc.addIceCandidate(new RTCIceCandidate(data.candidate));
      } else {
        pendingCandidates.push(data.candidate);
      }
    }

    if (data.type === "hangup") {
      cleanUp();
    }
  };
}

async function createPeer() {

  pc = new RTCPeerConnection(iceConfig);

  pc.onicecandidate = (event) => {
    if (event.candidate) {
      ws.send(JSON.stringify({
        type:"candidate",
        candidate:event.candidate,
        to:peerId,
        from:myId
      }));
    }
  };

  pc.ontrack = (event) => {
    console.log("Remote stream received");
    document.getElementById("remoteVideo").srcObject = event.streams[0];
  };

  pc.oniceconnectionstatechange = logState;
  pc.onconnectionstatechange = logState;

  try {
    localStream = await navigator.mediaDevices.getUserMedia({
      video: true,
      audio: true
    });
  } catch (e) {
    console.warn("video+audio failed, fallback to audio only");
    localStream = await navigator.mediaDevices.getUserMedia({
      video: false,
      audio: true
    });
  }

  document.getElementById("localVideo").srcObject = localStream;

  localStream.getTracks().forEach(track => {
    pc.addTrack(track, localStream);
  });
}

async function call() {

  peerId = document.getElementById("peerId").value;

  await createPeer();

  const offer = await pc.createOffer();
  await pc.setLocalDescription(offer);

  ws.send(JSON.stringify({
    type:"offer",
    offer,
    to:peerId,
    from:myId
  }));
}

function hangup() {
  ws.send(JSON.stringify({
    type:"hangup",
    to:peerId,
    from:myId
  }));
  cleanUp();
}

function cleanUp() {
  if (pc) {
    pc.close();
    pc = null;
  }

  if (localStream) {
    localStream.getTracks().forEach(track => track.stop());
    localStream = null;
  }

  document.getElementById("localVideo").srcObject = null;
  document.getElementById("remoteVideo").srcObject = null;

  pendingCandidates = [];

  console.log("Call ended");
}

</script>

</body>
</html>

⚠ 把:

bash 复制代码
YOUR_SERVER_IP

替换成你的公网 IP,并记得打开3000端口!

第四步:测试方法

注意:这里index.html必须是localhost或可以访问的https地址,否则不够权限使用摄像头!

HTTPS 页面 + WS 信令要用 WSS

你如果把页面切到 https://,你代码里:

bash 复制代码
ws = new WebSocket("ws://YOUR_SERVER_IP:3000");

会被浏览器拦截(混合内容),必须改成:

bash 复制代码
ws = new WebSocket("wss://你的域名/ws");

在站点配置里加:

bash 复制代码
ws = new WebSocket("wss://你的域名/ws");

1️⃣ 浏览器1打开 index.html

2️⃣ 浏览器2打开 index.html

3️⃣ 分别输入 ID:

窗口1:

bash 复制代码
myId: A
peerId: B

窗口2:

bash 复制代码
myId: B
peerId: A
bash 复制代码
myId: B
peerId: A

4️⃣ 两边都点击 Connect

5️⃣ A 点击 Call

预期结果

本地视频显示

远端视频显示

控制台 ICE 显示 relay

跨网也可通

相关推荐
Surmon15 小时前
彻底搞懂大模型 Temperature、Top-p、Top-k 的区别!
前端·人工智能
木斯佳17 小时前
前端八股文面经大全:bilibili生态技术方向二面 (2026-03-25)·面经深度解析
前端·ai·ssd·sse·rag
不会写DN17 小时前
Gin 日志体系详解
前端·javascript·gin
冬夜戏雪18 小时前
实习面经记录(十)
java·前端·javascript
爱学习的程序媛19 小时前
【Web前端】JavaScript设计模式全解析
前端·javascript·设计模式·web
小码哥_常19 小时前
从SharedPreferences到DataStore:Android存储进化之路
前端
老黑19 小时前
开源工具 AIDA:给 AI 辅助开发加一个数据采集层,让 AI 从错误中自动学习(Glama 3A 认证)
前端·react.js·ai·nodejs·cursor·vibe coding·claude code
jessecyj19 小时前
Spring boot整合quartz方法
java·前端·spring boot
苦瓜小生20 小时前
【前端】|【js手撕】经典高频面试题:手写实现function.call、apply、bind
java·前端·javascript
天若有情67320 小时前
前端HTML精讲03:页面性能优化+懒加载,搞定首屏加速
前端·性能优化·html