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. 参考资料
- WebRTC samples - perfect negotiation: https://webrtc.github.io/samples/src/content/peerconnection/negotiation/
- coturn wiki: https://github.com/coturn/coturn/wiki
- 上一级 实战项目总览