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

跨网也可通

相关推荐
Moment11 小时前
Vibe Coding 时代,到底该选什么样的工具来提升效率❓❓❓
前端·后端·github
IT_陈寒12 小时前
SpringBoot性能飙升200%?这5个隐藏配置你必须知道!
前端·人工智能·后端
小时前端13 小时前
React性能优化的完整方法论,附赠大厂面试通关技巧
前端·react.js
Nicko13 小时前
Jetpack Compose BOM 2026.02.01 解读与升级指南
前端
小蜜蜂dry13 小时前
nestjs学习 - 控制器、提供者、模块
前端·node.js·nestjs
优秀稳妥的JiaJi13 小时前
基于腾讯地图实现电子围栏绘制与校验
前端·vue.js·前端框架
前端开发呀14 小时前
从 qiankun(乾坤) 迁移到 Module Federation(模块联邦),对MF只能说相见恨晚!
前端
没想好d14 小时前
通用管理后台组件库-10-表单组件
前端
恋猫de小郭14 小时前
你用的 Claude 可能是虚假 Claude ,论文数据告诉你,Shadow API 中的欺骗性模型声明
前端·人工智能·ai编程
_Eleven15 小时前
Pinia vs Vuex 深度解析与完整实战指南
前端·javascript·vue.js