WebRTC信令服务器:Node.js示例完整指南
WebRTC 信令服务器:使用 Node.js 的完整指南
WebRTC 让浏览器可以直接相互发送视频、音频和数据。无需插件,无需 Flash,无需中介服务器接触你的媒体流。但这里有个坑:两个浏览器无法在互联网上神奇地找到彼此。它们需要一个牵线人。那个牵线人就是信令服务器。
我已经为视频咨询平台、多人浏览器游戏和协作编辑工具构建过信令服务器。每一次,混淆都出现在信令层——不是因为它很难,而是因为 WebRTC 故意将其留为未指定状态。规范告诉你需要交换哪些信息,但对如何交换没有任何说明。这既是礼物也是诅咒。
本指南涵盖了所有内容:信令在幕后实际做什么、使用 SDP 的 offer/answer 协议、ICE 候选交换和 NAT 穿透,以及两个生产就绪的实现——一个使用 Node.js + Socket.io,另一个使用 Supabase Realtime。我们将查看真实代码,而不是伪代码。
目录
- 信令服务器实际做什么?
- Offer/Answer 协议和 SDP
- ICE 候选和 NAT 穿透
- 信令传输选项比较
- 实现 1:Node.js + Socket.io
- 实现 2:Supabase Realtime
- 生产硬化提示
- TURN 服务器:直接连接失败时
- 常见问题

信令服务器实际做什么?
让我们剥离行话。信令服务器是一个消息中继。仅此而已。两个对等方需要交换几千字节的文本,然后才能直接连接。信令服务器将这些消息来回传递。
具体来说,信令服务器处理:
- 对等方发现 ——用户 A 需要告诉用户 B"我想与你连接"。必须有人路由这条消息。
- SDP 交换 ——两个对等方都生成会话描述协议 (Session Description Protocol) blob,描述其媒体功能(编解码器、加密等)。这些需要从 A 传到 B 再返回。
- ICE 候选中继 ——每个对等方发现潜在的网络路径(候选)并通过信令服务器发送给另一个对等方。
- 会话生命周期 ——启动、重新协商和关闭连接。
信令服务器不做的事情:它永远不接触你的音频或视频数据。一旦两个对等方建立直接连接,信令服务器的工作基本上就完成了。媒体以对等方式流动。
把它想象成在派对上介绍两个人。你走过去说"嘿 Sarah,这是 Mike,你们都喜欢登山。"然后你走开了。Sarah 和 Mike 从那里开始。你在这个场景中是信令服务器。
为什么 WebRTC 不标准化信令
这是一个故意的设计选择,不是监督。WebRTC 规范作者认识到大多数应用程序已经在用户之间有一个通信通道——聊天系统、匹配服务、协作平台。强制实施特定的信令协议意味着每个应用程序需要实现两个通信层,而不是利用已有的东西。
约会应用已经有消息基础设施。远程医疗平台已经有预约系统。游戏平台已经有大厅服务器。WebRTC 让你使用你已经拥有的任何东西来传递这些 SDP 和 ICE 消息。
你可能会使用携带 USB 的信鸽,只要延迟不重要就行。(延迟确实重要,但观点成立。)
Offer/Answer 协议和 SDP
核心信令交换遵循称为 offer/answer 模型的模式,借自 SIP(会话启动协议)。流程如下:
- 对等方 A 创建
RTCPeerConnection并调用createOffer() - 对等方 A 通过
setLocalDescription()将 offer 设置为其本地描述 - 对等方 A 通过信令服务器将 offer(一个 SDP blob)发送给对等方 B
- 对等方 B 接收 offer 并通过
setRemoteDescription()将其设置为远程描述 - 对等方 B 调用
createAnswer() - 对等方 B 将答案设置为其本地描述
- 对等方 B 通过信令服务器将答案发送回来
- 对等方 A 接收答案并将其设置为远程描述
在此交换后,两个对等方都知道彼此的媒体功能。但他们仍然无法连接——他们需要网络路径信息,这来自 ICE。
SDP Blob 内部有什么?
SDP 是一种文本格式,看起来像是在 1996 年设计的。因为它确实是。以下是一个精简的示例:
v=0
o=- 4625943584070133116 2 IN IP4 127.0.0.1
s=-
t=0 0
m=video 9 UDP/TLS/RTP/SAVPF 96 97 98
c=IN IP4 0.0.0.0
a=rtcp:9 IN IP4 0.0.0.0
a=ice-ufrag:aR7B
a=ice-pwd:hN3Y2mDoS68sGLprHvBGNbKp
a=fingerprint:sha-256 D1:3C:A0:...
a=setup:actpass
a=mid:0
a=rtpmap:96 VP8/90000
a=rtpmap:97 H264/90000
重要的部分:它列出支持的编解码器(VP8、H264)、加密指纹、ICE 凭据和媒体类型。你的信令服务器不需要解析这些中的任何一个。它只需将 blob 作为不透明字符串传递。
代码中的 SDP Offer/Answer
以下是提供对等方的客户端 JavaScript:
const pc = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'turn:your-turn-server.com:3478', username: 'user', credential: 'pass' }
]
});
// 添加本地媒体轨道
const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
stream.getTracks().forEach(track => pc.addTrack(track, stream));
// 创建并发送 offer
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
// 发送到信令服务器
signalingServer.send({
type: 'offer',
sdp: pc.localDescription,
targetPeerId: 'peer-b-id'
});
以及应答对等方:
signalingServer.on('offer', async (data) => {
await pc.setRemoteDescription(new RTCSessionDescription(data.sdp));
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
signalingServer.send({
type: 'answer',
sdp: pc.localDescription,
targetPeerId: data.fromPeerId
});
});
ICE 候选和 NAT 穿透
SDP 协商处理什么媒体要发送。ICE 处理如何到达网络上的另一个对等方。这是事情变得有趣的地方——也是大多数 WebRTC 连接在现实世界中实际失败的地方。
NAT 问题
大多数设备位于运行 NAT(网络地址转换)的路由器后面。你的笔记本电脑可能有 192.168.1.42 的本地 IP,但外部世界看到你的路由器的公共 IP。当两个对等方都位于 NAT 后面时,都不知道如何直接到达另一个。
ICE(交互式连接建立)通过收集多个候选路径并尝试所有路径来解决这个问题:
| 候选类型 | 源 | 成功率 | 延迟 |
|---|---|---|---|
| Host | 本地网络接口 | 仅在同一局域网上有效 | 最低 |
| Server-reflexive (srflx) | 通过 STUN 服务器发现 | ~80-85% 的连接 | 低 |
| Relay | 通过 TURN 服务器分配 | ~99%+(备用) | 更高(中继) |
ICE 候选分流
这是一个关键的优化:分流 ICE。与其等待所有候选被收集后再发送它们,不如在发现每个候选时立即将其发送到远程对等方。这可以从连接建立中节省数秒。
// 调用方端:随着候选的到达而发送
pc.onicecandidate = (event) => {
if (event.candidate) {
signalingServer.send({
type: 'ice-candidate',
candidate: event.candidate,
targetPeerId: remotePeerId
});
}
};
// 接收方端:随着候选的到达而添加
signalingServer.on('ice-candidate', async (data) => {
try {
await pc.addIceCandidate(new RTCIceCandidate(data.candidate));
} catch (err) {
// 候选可能在设置远程描述之前到达
// 缓冲它们并稍后添加
console.error('Failed to add ICE candidate:', err);
}
});
那个 catch 块暗示了一个真实的问题:ICE 候选可能在你调用 setRemoteDescription() 之前到达。你需要缓冲它们。我们将在下面的生产实现中正确处理这个问题。
ICE 候选缓冲
这是几乎每个教程都跳过的东西,它会在生产中引起错误。以下是模式:
let pendingCandidates = [];
let remoteDescriptionSet = false;
signalingServer.on('ice-candidate', async (data) => {
if (remoteDescriptionSet) {
await pc.addIceCandidate(new RTCIceCandidate(data.candidate));
} else {
pendingCandidates.push(data.candidate);
}
});
// 设置远程描述后:
await pc.setRemoteDescription(remoteDesc);
remoteDescriptionSet = true;
for (const candidate of pendingCandidates) {
await pc.addIceCandidate(new RTCIceCandidate(candidate));
}
pendingCandidates = [];

信令传输选项比较
你基本上可以为信令使用任何双向通信通道。以下是常见选项的比较:
| 传输 | 优点 | 缺点 | 最适合 |
|---|---|---|---|
| WebSocket(原始) | 低延迟、全双工、轻量 | 手动重连逻辑、无房间 | 简单的 1:1 应用 |
| Socket.io | 自动重连、房间、回退到轮询 | 更大的库大小(~45KB)、不是标准 | 大多数网络应用 |
| Supabase Realtime | 托管基础设施、内置身份验证、Postgres 集成 | 供应商锁定、消息大小限制 | 已在 Supabase 上的应用 |
| Firebase Realtime DB | 托管、良好的文档、离线支持 | 供应商锁定、大规模定价 | 基于 Firebase 的应用 |
| HTTP 轮询 | 到处有效、易于实现 | 高延迟、服务器负载 | 遗留环境 |
| SIP over WebSocket | 与电话系统互操作 | 复杂、对大多数网络应用来说过度设计 | VoIP 集成 |
对于大多数项目,Socket.io 或像 Supabase 这样的托管实时服务是正确的选择。让我们构建两者。
实现 1:Node.js + Socket.io
这是最常见的方法,我建议想要对其信令基础设施有完全控制权的团队使用。我们正在构建一个支持具有多个对等方的房间的信令服务器。
服务器设置
import { createServer } from 'http';
import { Server } from 'socket.io';
const httpServer = createServer();
const io = new Server(httpServer, {
cors: {
origin: process.env.CLIENT_ORIGIN || 'http://localhost:3000',
methods: ['GET', 'POST']
}
});
// 跟踪房间及其参与者
const rooms = new Map();
io.on('connection', (socket) => {
console.log(`Peer connected: ${socket.id}`);
socket.on('join-room', (roomId) => {
socket.join(roomId);
if (!rooms.has(roomId)) {
rooms.set(roomId, new Set());
}
rooms.get(roomId).add(socket.id);
// 通知房间中的现有对等方
socket.to(roomId).emit('peer-joined', { peerId: socket.id });
// 向新对等方发送现有对等方列表
const existingPeers = [...rooms.get(roomId)].filter(id => id !== socket.id);
socket.emit('existing-peers', { peers: existingPeers });
});
socket.on('offer', ({ targetPeerId, sdp }) => {
io.to(targetPeerId).emit('offer', {
sdp,
fromPeerId: socket.id
});
});
socket.on('answer', ({ targetPeerId, sdp }) => {
io.to(targetPeerId).emit('answer', {
sdp,
fromPeerId: socket.id
});
});
socket.on('ice-candidate', ({ targetPeerId, candidate }) => {
io.to(targetPeerId).emit('ice-candidate', {
candidate,
fromPeerId: socket.id
});
});
socket.on('disconnect', () => {
// 清理房间
for (const [roomId, peers] of rooms.entries()) {
if (peers.has(socket.id)) {
peers.delete(socket.id);
socket.to(roomId).emit('peer-left', { peerId: socket.id });
if (peers.size === 0) {
rooms.delete(roomId);
}
}
}
});
});
const PORT = process.env.PORT || 3001;
httpServer.listen(PORT, () => {
console.log(`Signaling server running on port ${PORT}`);
});
客户端集成
import { io } from 'socket.io-client';
const socket = io('wss://your-signaling-server.com');
const peerConnections = new Map();
const ICE_SERVERS = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
// 为生产添加 TURN 服务器
]
};
function createPeerConnection(remotePeerId) {
const pc = new RTCPeerConnection(ICE_SERVERS);
let pendingCandidates = [];
let isRemoteDescSet = false;
pc.onicecandidate = (event) => {
if (event.candidate) {
socket.emit('ice-candidate', {
targetPeerId: remotePeerId,
candidate: event.candidate
});
}
};
pc.ontrack = (event) => {
// 将远程流附加到视频元素
const remoteVideo = document.getElementById(`video-${remotePeerId}`);
if (remoteVideo) {
remoteVideo.srcObject = event.streams[0];
}
};
pc.onconnectionstatechange = () => {
if (pc.connectionState === 'failed' || pc.connectionState === 'disconnected') {
console.warn(`Connection to ${remotePeerId} ${pc.connectionState}`);
// 在这里实现重连逻辑
}
};
// 存储连接及其状态
peerConnections.set(remotePeerId, {
pc,
pendingCandidates,
isRemoteDescSet,
setRemoteDescDone() {
this.isRemoteDescSet = true;
this.pendingCandidates.forEach(c => pc.addIceCandidate(new RTCIceCandidate(c)));
this.pendingCandidates = [];
}
});
return pc;
}
// 加入房间
socket.emit('join-room', 'my-room-id');
// 当新对等方加入时,创建 offer
socket.on('peer-joined', async ({ peerId }) => {
const pc = createPeerConnection(peerId);
localStream.getTracks().forEach(track => pc.addTrack(track, localStream));
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
socket.emit('offer', { targetPeerId: peerId, sdp: pc.localDescription });
});
socket.on('offer', async ({ sdp, fromPeerId }) => {
const pc = createPeerConnection(fromPeerId);
localStream.getTracks().forEach(track => pc.addTrack(track, localStream));
await pc.setRemoteDescription(new RTCSessionDescription(sdp));
peerConnections.get(fromPeerId).setRemoteDescDone();
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
socket.emit('answer', { targetPeerId: fromPeerId, sdp: pc.localDescription });
});
socket.on('answer', async ({ sdp, fromPeerId }) => {
const conn = peerConnections.get(fromPeerId);
await conn.pc.setRemoteDescription(new RTCSessionDescription(sdp));
conn.setRemoteDescDone();
});
socket.on('ice-candidate', ({ candidate, fromPeerId }) => {
const conn = peerConnections.get(fromPeerId);
if (conn.isRemoteDescSet) {
conn.pc.addIceCandidate(new RTCIceCandidate(candidate));
} else {
conn.pendingCandidates.push(candidate);
}
});
这处理房间中的多个对等方,正确缓冲 ICE 候选,并在断开连接时清理。这是我们在构建 Next.js 应用程序实时功能时使用的模式。
实现 2:Supabase Realtime
如果你已经在使用 Supabase(或想避免运行自己的 WebSocket 服务器),Supabase Realtime 频道对信令效果非常好。这种方法使用 Supabase 的广播功能——无需数据库写入。
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
);
const roomId = 'video-call-room-123';
const myPeerId = crypto.randomUUID();
const channel = supabase.channel(roomId, {
config: { broadcast: { self: false } }
});
// 监听信令消息
channel
.on('broadcast', { event: 'offer' }, ({ payload }) => {
if (payload.targetPeerId === myPeerId) {
handleOffer(payload);
}
})
.on('broadcast', { event: 'answer' }, ({ payload }) => {
if (payload.targetPeerId === myPeerId) {
handleAnswer(payload);
}
})
.on('broadcast', { event: 'ice-candidate' }, ({ payload }) => {
if (payload.targetPeerId === myPeerId) {
handleIceCandidate(payload);
}
})
.on('broadcast', { event: 'peer-joined' }, ({ payload }) => {
if (payload.peerId !== myPeerId) {
initiateConnection(payload.peerId);
}
})
.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
// 宣布存在
await channel.send({
type: 'broadcast',
event: 'peer-joined',
payload: { peerId: myPeerId }
});
}
});
// 发送 offer
async function sendOffer(targetPeerId, sdp) {
await channel.send({
type: 'broadcast',
event: 'offer',
payload: { sdp, fromPeerId: myPeerId, targetPeerId }
});
}
// 发送答案
async function sendAnswer(targetPeerId, sdp) {
await channel.send({
type: 'broadcast',
event: 'answer',
payload: { sdp, fromPeerId: myPeerId, targetPeerId }
});
}
// 发送 ICE 候选
async function sendIceCandidate(targetPeerId, candidate) {
await channel.send({
type: 'broadcast',
event: 'ice-candidate',
payload: { candidate, fromPeerId: myPeerId, targetPeerId }
});
}
Supabase 方法有一个显著的优势:你可以使用 Supabase 的 Presence 功能免费获得存在跟踪。你可以看到谁在房间中,而无需额外代码。权衡是广播消息对所有频道订阅者可见——你在客户端按 targetPeerId 过滤,这对信令来说很好(它不是敏感数据;实际媒体是加密的)。
对于在 Supabase 上构建的团队,这消除了你基础设施中的整个服务器。我们已经在需要实时协作的项目中使用过这种模式。
生产硬化提示
让信令在开发中工作很直接。在生产中保持其可靠性是真正的工作所在。
1. 消息顺序和去重
WebSocket 消息在重连期间可能会乱序到达。向你的消息添加序列号并在客户端处理重排:
let messageSeq = 0;
function sendSignalingMessage(type, payload) {
socket.emit(type, { ...payload, seq: ++messageSeq, timestamp: Date.now() });
}
2. 心跳和重连
Socket.io 自动处理重连,但在重连后你需要重新加入房间:
socket.on('connect', () => {
if (currentRoom) {
socket.emit('join-room', currentRoom);
}
});
3. 速率限制
ICE 候选分流可以快速连续生成数十条消息。在繁忙的服务器上,这会增加。实现每个 socket 的速率限制:
import { RateLimiterMemory } from 'rate-limiter-flexible';
const rateLimiter = new RateLimiterMemory({
points: 50, // 消息
duration: 10, // 每 10 秒
});
io.on('connection', (socket) => {
socket.use(async ([event, ...args], next) => {
try {
await rateLimiter.consume(socket.id);
next();
} catch {
next(new Error('Rate limit exceeded'));
}
});
});
4. 身份验证
在生产中永远不要运行没有身份验证的信令服务器。对于 Socket.io,使用中间件:
io.use(async (socket, next) => {
const token = socket.handshake.auth.token;
try {
const user = await verifyJWT(token);
socket.data.userId = user.id;
next();
} catch {
next(new Error('Authentication failed'));
}
});
5. 水平扩展
单个 Node.js 进程可以处理数千个信令连接(信令很轻——只是文本消息)。当你需要扩展超过一个服务器时,使用 @socket.io/redis-adapter:
import { createAdapter } from '@socket.io/redis-adapter';
import { createClient } from 'redis';
const pubClient = createClient({ url: process.env.REDIS_URL });
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
io.adapter(createAdapter(pubClient, subClient));
这确保 socket.to(peerId).emit() 跨多个服务器实例工作。
TURN 服务器:直接连接失败时
STUN 对大约 80-85% 的连接有效。对于剩余的 15-20%(对称 NAT、限制性防火墙、企业网络),你需要一个 TURN 服务器来中继媒体流量。
在 2026 年,你的 TURN 选项包括:
| 提供者 | 定价(大约) | 说明 |
|---|---|---|
| Cloudflare Calls TURN | 免费层可用,按使用量计费 | 对大多数项目最有价值 |
| Twilio TURN | $0.40/GB | 可靠、文档完善 |
| Metered.ca | 免费 500GB/月,之后 $0.40/GB | 独立开发者流行 |
| 自托管 coturn | 仅服务器成本 | 完全控制、操作负担 |
| Xirsys | 从 $24.99/月 | 全球 PoPs |
始终在你的 ICE 配置中包括至少一个 TURN 服务器。告诉用户"对大多数人有效"在副总裁试图在酒店 WiFi 上演示你的产品时是不可接受的。
如果你正在构建需要在企业网络中可靠工作的东西,请联系我们——我们已经帮助团队架构处理复杂边界情况的 WebRTC 解决方案。
常见问题
什么是 WebRTC 信令服务器? 信令服务器是一个消息中继,帮助两个 WebRTC 对等方交换建立直接连接所需的信息。它携带 SDP offer 和 answer(描述媒体功能)和 ICE 候选(描述网络路径)。信令服务器永远不接触音频或视频数据——一旦对等方连接,媒体直接在它们之间流动。
为什么 WebRTC 不包括标准信令协议? WebRTC 规范故意将信令留为未指定状态,因为大多数应用程序已经在用户之间有一个通信通道。聊天应用、匹配服务或协作工具可以重复使用其现有基础设施进行信令。这种灵活性意味着你可以使用 WebSocket、HTTP、SIP、XMPP 或任何其他可以携带文本消息的传输。
SDP offer 和 SDP answer 有什么区别? SDP offer 由启动连接的对等方创建。它描述该对等方的媒体功能——支持的编解码器、加密方法和媒体类型。SDP answer 由接收对等方创建,并确认它支持 offer 中的哪些功能。一起,它们协商两个对等方都将用于媒体会话的共享参数。
什么是 ICE 候选,为什么需要交换它们? ICE 候选是对等方可以被到达的潜在网络路径。每个对等方通过检查其本地网络接口、查询 STUN 服务器获取面向公众的地址,以及在 TURN 服务器上分配中继地址来发现候选。这些候选必须通过信令服务器发送给另一个对等方,以便两方可以尝试多条路径并找到通过 NAT 和防火墙的有效路径。
我可以使用 Supabase Realtime 作为 WebRTC 信令服务器吗? 可以。Supabase Realtime 的广播功能适用于信令,因为它在连接的客户端之间提供低延迟消息传递,无需任何数据库写入。你为每个房间创建一个频道,广播 offer/answer/ICE 消息,并在客户端按目标对等方 ID 过滤。对于已在使用 Supabase 且想避免运行单独信令服务器的项目来说,这是一个坚实的选择。
信令服务器可以处理多少个并发连接? 单个 Node.js + Socket.io 进程通常可以处理 10,000–50,000 个并发信令连接,具体取决于消息频率和服务器资源。信令流量很轻——只是小的 JSON 消息。对于更大的规模,使用带 Socket.io 的 Redis 适配器在多个服务器实例之间分发连接。WebRTC 中的瓶颈很少是信令服务器;通常是 TURN 中继带宽。
我在生产中需要 TURN 服务器吗? 是的。虽然 STUN 处理约 80-85% 的连接,但大约 15-20% 的用户位于对称 NAT 或限制性防火墙后面,这阻止直接连接。没有 TURN 服务器,这些用户根本无法连接。在企业环境中,没有 TURN 的故障率甚至可能更高。Cloudflare Calls、Twilio 和 Metered.ca 等服务提供带免费层的 TURN。
如果信令服务器在活跃呼叫期间宕机会发生什么? 一旦建立 WebRTC 连接,它就不依赖信令服务器了。如果信令服务器崩溃,现有呼叫将继续工作。但是无法建立新连接,如果现有连接需要重新协商(例如,添加屏幕共享),该操作将失败。你应该使用集群或托管服务为你的信令服务器设计高可用性。