WebRTC 訊號伺服器:Node.js 完整指南

WebRTC 讓瀏覽器可以直接互相傳送影片、音訊和資料。沒有外掛程式、沒有 Flash、沒有中介伺服器接觸你的媒體串流。但這裡有個讓每個開發者第一次都會卡住的問題:兩個瀏覽器無法魔法般地在網際網路上找到彼此。它們需要一個媒人。那個媒人就是訊號伺服器。

我為視訊諮詢平台、多人瀏覽器遊戲和協作編輯工具建立過訊號伺服器。每一次,訊號層都是混亂之處——不是因為它很難,而是因為 WebRTC 有意不規定它。規範告訴你需要交換什麼資訊,但對於如何交換一無所知。這既是禮物也是詛咒。

本指南涵蓋所有內容:訊號在幕後實際做什麼、offer/answer 協議與 SDP、ICE 候選人交換和 NAT 遍歷,然後兩個生產就緒的實現——一個使用 Node.js + Socket.io,另一個使用 Supabase Realtime。我們將查看真實程式碼,而不是虛擬程式碼。

目錄

WebRTC 訊號伺服器:Node.js 範例完整指南

訊號伺服器實際上做什麼?

讓我們撇除術語。訊號伺服器就是一個訊息中繼。就這樣。兩個對等點需要交換幾 KB 的文字才能直接連接。訊號伺服器來回傳達這些訊息。

具體來說,訊號伺服器處理:

  1. 對等點發現 ——用戶 A 需要告訴用戶 B「我想和你連接」。必須有人路由該訊息。
  2. SDP 交換 ——兩個對等點生成會話描述協議 blob,描述其媒體功能(編碼、加密等)。這些需要從 A 到 B,再返回。
  3. ICE 候選人中繼 ——每個對等點發現潛在的網路路徑(候選人)並透過訊號伺服器將它們發送給另一個對等點。
  4. 會話生命週期 ——啟動、重新協商和拆除連接。

以下是訊號伺服器做的事:它永遠不會接觸你的音訊或影片資料。一旦兩個對等點建立直接連接,訊號伺服器的工作本質上就完成了。媒體以對等點對點的方式流動。

把它想像成在派對上介紹兩個人。你走過去,說「嘿莎拉,這是麥克,你們都喜歡攀岩。」然後你走開。莎拉和麥克自己進行。你就是那個場景中的訊號伺服器。

為什麼 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 本地網路介面 僅在同一 LAN 上有效 最低
伺服器反身 (srflx) 透過 STUN 伺服器發現 ~80-85% 的連接
中繼 通過 TURN 伺服器配置 ~99%+ (備用) 更高(中繼)

ICE 候選人滴漏

以下是一個關鍵優化:trickle 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 建立的團隊,這可消除你的基礎設施中的整個伺服器。我們在需要即時協作的無頭 CMS 專案中使用過此模式。

生產強化提示

在開發中使訊號工作很簡單。在生產中保持其可靠性才是真正的工作。

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 候選人滴漏可以在快速連續中生成數十條訊息。在繁忙的伺服器上,這會加起來。實現每個通訊端的速率限制:

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 受歡迎的獨立專案
Self-hosted 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 連接,它就不再依賴訊號伺服器了。如果訊號伺服器崩潰,現有通話將繼續工作。但是,無法建立新連接,如果現有連接需要重新協商(例如,新增螢幕共用),該將失敗。你應該使用叢集或受管服務為高可用性設計訊號伺服器。