WebRTC 信令服务器:使用 Node.js 的完整指南

WebRTC 让浏览器可以直接相互发送视频、音频和数据。无需插件,无需 Flash,无需中介服务器接触你的媒体流。但这里有个坑:两个浏览器无法在互联网上神奇地找到彼此。它们需要一个牵线人。那个牵线人就是信令服务器。

我已经为视频咨询平台、多人浏览器游戏和协作编辑工具构建过信令服务器。每一次,混淆都出现在信令层——不是因为它很难,而是因为 WebRTC 故意将其留为未指定状态。规范告诉你需要交换哪些信息,但对如何交换没有任何说明。这既是礼物也是诅咒。

本指南涵盖了所有内容:信令在幕后实际做什么、使用 SDP 的 offer/answer 协议、ICE 候选交换和 NAT 穿透,以及两个生产就绪的实现——一个使用 Node.js + Socket.io,另一个使用 Supabase Realtime。我们将查看真实代码,而不是伪代码。

目录

WebRTC 信令服务器:使用 Node.js 示例的完整指南

信令服务器实际做什么?

让我们剥离行话。信令服务器是一个消息中继。仅此而已。两个对等方需要交换几千字节的文本,然后才能直接连接。信令服务器将这些消息来回传递。

具体来说,信令服务器处理:

  1. 对等方发现 ——用户 A 需要告诉用户 B"我想与你连接"。必须有人路由这条消息。
  2. SDP 交换 ——两个对等方都生成会话描述协议 (Session Description Protocol) blob,描述其媒体功能(编解码器、加密等)。这些需要从 A 传到 B 再返回。
  3. ICE 候选中继 ——每个对等方发现潜在的网络路径(候选)并通过信令服务器发送给另一个对等方。
  4. 会话生命周期 ——启动、重新协商和关闭连接。

信令服务器不做的事情:它永远不接触你的音频或视频数据。一旦两个对等方建立直接连接,信令服务器的工作基本上就完成了。媒体以对等方式流动。

把它想象成在派对上介绍两个人。你走过去说"嘿 Sarah,这是 Mike,你们都喜欢登山。"然后你走开了。Sarah 和 Mike 从那里开始。你在这个场景中是信令服务器。

为什么 WebRTC 不标准化信令

这是一个故意的设计选择,不是监督。WebRTC 规范作者认识到大多数应用程序已经在用户之间有一个通信通道——聊天系统、匹配服务、协作平台。强制实施特定的信令协议意味着每个应用程序需要实现两个通信层,而不是利用已有的东西。

约会应用已经有消息基础设施。远程医疗平台已经有预约系统。游戏平台已经有大厅服务器。WebRTC 让你使用你已经拥有的任何东西来传递这些 SDP 和 ICE 消息。

你可能会使用携带 USB 的信鸽,只要延迟不重要就行。(延迟确实重要,但观点成立。)

Offer/Answer 协议和 SDP

核心信令交换遵循称为 offer/answer 模型的模式,借自 SIP(会话启动协议)。流程如下:

  1. 对等方 A 创建 RTCPeerConnection 并调用 createOffer()
  2. 对等方 A 通过 setLocalDescription() 将 offer 设置为其本地描述
  3. 对等方 A 通过信令服务器将 offer(一个 SDP blob)发送给对等方 B
  4. 对等方 B 接收 offer 并通过 setRemoteDescription() 将其设置为远程描述
  5. 对等方 B 调用 createAnswer()
  6. 对等方 B 将答案设置为其本地描述
  7. 对等方 B 通过信令服务器将答案发送回来
  8. 对等方 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 = [];

WebRTC 信令服务器:使用 Node.js 示例的完整指南 - 架构

信令传输选项比较

你基本上可以为信令使用任何双向通信通道。以下是常见选项的比较:

传输 优点 缺点 最适合
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 连接,它就不依赖信令服务器了。如果信令服务器崩溃,现有呼叫将继续工作。但是无法建立新连接,如果现有连接需要重新协商(例如,添加屏幕共享),该操作将失败。你应该使用集群或托管服务为你的信令服务器设计高可用性。