跳到主要内容

L1-1v1-call

第一个真实跨网络的项目。在 L0 基础上加上:信令、ICE 候选交换、TURN 部署、Perfect Negotiation、断线重连。

1. 学习目标

  • 实现一个 Node + WebSocket 信令服务。
  • 在公网部署 coturn 并接入。
  • 用 Perfect Negotiation 模式处理 glare。
  • 实现 ICE Restart 应对网络切换。
  • 接入 getStats 上报。

2. 架构

3. 目录结构

L1-1v1-call/
├── client/
│ ├── index.html
│ ├── src/
│ │ ├── signaling.js # WebSocket 客户端 + 重连
│ │ ├── peer.js # PeerConnection 封装 + Perfect Negotiation
│ │ ├── stats.js # getStats 上报
│ │ └── main.js
│ └── vite.config.js
├── server/
│ ├── index.js # ws 信令服务
│ ├── auth.js # JWT 房间鉴权
│ ├── turn-credentials.js # coturn 短期凭据签发
│ └── package.json
├── infra/
│ ├── coturn/turnserver.conf
│ ├── docker-compose.yml
│ └── nginx/ # TLS 反代信令
└── README.md

4. 信令协议(最小集)

// 客户端 → 服务端
type ClientMsg =
| { type: 'join'; room: string; token: string; peerId: string }
| { type: 'leave' }
| { type: 'sdp'; to: string; description: RTCSessionDescriptionInit }
| { type: 'ice'; to: string; candidate: RTCIceCandidateInit | null }
| { type: 'ping' };

// 服务端 → 客户端
type ServerMsg =
| { type: 'joined'; room: string; peers: string[]; turn: RTCIceServer[] }
| { type: 'peer-join'; peerId: string }
| { type: 'peer-leave'; peerId: string }
| { type: 'sdp'; from: string; description: RTCSessionDescriptionInit }
| { type: 'ice'; from: string; candidate: RTCIceCandidateInit | null }
| { type: 'pong' }
| { type: 'error'; code: string; message: string };

turn 字段由服务端用短期凭据现签现下发,客户端不持久化。

5. 关键代码片段

5.1 信令服务(Node + ws)

// server/index.js(节选)
import { WebSocketServer } from 'ws';
import jwt from 'jsonwebtoken';
import { signTurnCredentials } from './turn-credentials.js';

const rooms = new Map(); // roomId -> Map(peerId, ws)
const wss = new WebSocketServer({ port: 8080 });

wss.on('connection', (ws) => {
let roomId, peerId;
ws.on('message', (raw) => {
const msg = JSON.parse(raw);
if (msg.type === 'join') {
const payload = jwt.verify(msg.token, process.env.JWT_SECRET);
if (payload.room !== msg.room) return ws.send(JSON.stringify({type:'error',code:'forbidden'}));
roomId = msg.room; peerId = msg.peerId;
const room = rooms.get(roomId) ?? new Map();
const peers = [...room.keys()];
room.set(peerId, ws); rooms.set(roomId, room);
ws.send(JSON.stringify({
type: 'joined', room: roomId, peers,
turn: signTurnCredentials(peerId),
}));
for (const [id, peer] of room) {
if (id !== peerId) peer.send(JSON.stringify({type:'peer-join', peerId}));
}
}
if (msg.type === 'sdp' || msg.type === 'ice') {
const target = rooms.get(roomId)?.get(msg.to);
if (target) target.send(JSON.stringify({...msg, from: peerId}));
}
});
ws.on('close', () => {
if (!roomId) return;
const room = rooms.get(roomId);
room?.delete(peerId);
for (const peer of room?.values() ?? []) {
peer.send(JSON.stringify({type:'peer-leave', peerId}));
}
});
});

5.2 TURN 短期凭据

// server/turn-credentials.js
import crypto from 'node:crypto';

export function signTurnCredentials(userId, ttlSec = 600) {
const username = `${Math.floor(Date.now()/1000) + ttlSec}:${userId}`;
const credential = crypto
.createHmac('sha1', process.env.TURN_SHARED_SECRET)
.update(username)
.digest('base64');
return [{
urls: [
'turn:turn.example.com:3478?transport=udp',
'turn:turn.example.com:3478?transport=tcp',
'turns:turn.example.com:5349?transport=tcp',
],
username,
credential,
}];
}

5.3 客户端 Perfect Negotiation

// client/src/peer.js(节选)
export function createPeer({polite, signaling, iceServers, onTrack}) {
const pc = new RTCPeerConnection({iceServers, bundlePolicy:'max-bundle', rtcpMuxPolicy:'require'});
let makingOffer = false;
let ignoreOffer = false;

pc.onnegotiationneeded = async () => {
try {
makingOffer = true;
await pc.setLocalDescription();
signaling.send({type:'sdp', description: pc.localDescription});
} finally { makingOffer = false; }
};

pc.onicecandidate = ({candidate}) =>
signaling.send({type:'ice', candidate});

pc.ontrack = onTrack;

pc.oniceconnectionstatechange = () => {
if (pc.iceConnectionState === 'failed') pc.restartIce();
};

signaling.on('sdp', async ({description}) => {
const offerCollision = description.type === 'offer'
&& (makingOffer || pc.signalingState !== 'stable');
ignoreOffer = !polite && offerCollision;
if (ignoreOffer) return;
await pc.setRemoteDescription(description);
if (description.type === 'offer') {
await pc.setLocalDescription();
signaling.send({type:'sdp', description: pc.localDescription});
}
});

signaling.on('ice', async ({candidate}) => {
try { await pc.addIceCandidate(candidate); }
catch (e) { if (!ignoreOffer) throw e; }
});

return pc;
}

6. coturn 配置

# infra/coturn/turnserver.conf
listening-port=3478
tls-listening-port=5349
listening-ip=0.0.0.0

# REST API 短期凭据
use-auth-secret
static-auth-secret=CHANGE_ME_TO_LONG_RANDOM
realm=turn.example.com

# 配额
total-quota=1200
user-quota=12
max-bps=2000000

# TLS
cert=/etc/letsencrypt/live/turn.example.com/fullchain.pem
pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem

# 日志
verbose
log-file=/var/log/coturn/turnserver.log
no-loopback-peers
no-multicast-peers

7. 部署一条命令

docker compose -f infra/docker-compose.yml up -d

docker-compose.yml 起:

  • coturn(暴露 3478/UDP, 3478/TCP, 5349/TCP)
  • 信令服务(暴露 8080)
  • nginx(443 反代信令到 wss)

8. 弱网测试

# 200ms RTT + 2% 丢包
sudo tc qdisc add dev wlan0 root netem delay 200ms loss 2%

# 限上行 800 kbps
sudo tc qdisc change dev wlan0 root tbf rate 800kbit burst 32kbit latency 400ms

合格指标:

  • 通话不中断,画面保持连续。
  • qualityLimitationReason 多数时间是 bandwidth不是 cpu
  • freezeCount 单分钟 ≤ 2。

9. 验收 Checklist

  • npm run dev 在两台设备能跑通通话
  • 关闭一边的 Wi-Fi 切到 4G,5s 内自愈
  • 强制 iceTransportPolicy: 'relay' 仍可通话(验证 TURN 工作)
  • coturn 日志能看到中继流量记录
  • 信令断线后客户端 3s 内重连,不重新协商即可继续
  • 关闭页面后 coturn 端口立即释放
  • getStats 每 5s 上报一次,至少含 RTT / 丢包率 / 帧率 / freezeCount

10. 参考资料