WebRTC シグナリングサーバーの完全ガイド

WebRTC を使用すると、ブラウザはプラグイン、Flash、メディアストリームに触れる仲介サーバーなしで、ビデオ、オーディオ、データを相互に直接送信できます。しかし、すべての開発者が最初にはまる問題があります。2 つのブラウザがインターネット上で魔法のように互いを見つけることはできません。仲立ち人が必要です。その仲立ち人がシグナリングサーバーです。

ビデオコンサルティングプラットフォーム、マルチプレイヤーブラウザゲーム、コラボレーティブ編集ツール向けのシグナリングサーバーを構築してきました。毎回、シグナリングレイヤーが混乱の場所です。難しいからではなく、WebRTC が意図的に仕様を指定していないからです。仕様では交換する必要のあるどの情報かについては述べていますが、どのように交換するかについては何も述べていません。これは恩恵でもあり、呪いでもあります。

このガイドではすべてを扱います。シグナリングが実際に何をするのか、SDP を使用した Offer/Answer プロトコル、ICE 候補交換と NAT トラバーサル、そして本番環境対応の 2 つの実装 -- 1 つは Node.js + Socket.io、もう 1 つは Supabase Realtime を使用します。疑似コードではなく、実際のコードを見ていきます。

目次

WebRTC Signaling Server: Complete Guide with Node.js Examples

シグナリングサーバーは実際に何をするのか?

専門用語を取り除きましょう。シグナリングサーバーはメッセージリレーです。それだけです。2 つのピアが直接接続する前に数キロバイトのテキストを交換する必要があります。シグナリングサーバーがそれらのメッセージを前後に運びます。

具体的には、シグナリングサーバーが処理するのは:

  1. ピア検出 -- ユーザー A はユーザー B に「あなたと接続したい」と伝える必要があります。誰かがそのメッセージをルーティングする必要があります。
  2. SDP 交換 -- 両方のピアがメディア機能(コーデック、暗号化など)を説明するセッション記述プロトコルブロブを生成します。これらは A から B へ、そして戻る必要があります。
  3. ICE 候補リレー -- 各ピアは潜在的なネットワークパス(候補)を発見し、シグナリングサーバーを通じて他のピアに送信します。
  4. セッションライフサイクル -- 接続の開始、再ネゴシエーション、および廃棄。

シグナリングサーバーがしないこと:オーディオまたはビデオデータに触れることはありません。2 つのピアが直接接続を確立すると、シグナリングサーバーの仕事は本質的に完了です。メディアはピアツーピアで流れます。

パーティーで 2 人を紹介するようなものだと考えてください。あなたは近づいて、「ねえサラ、これはマイク、あなたたちは両方とも登山が好き」と言います。その後、あなたは立ち去ります。サラとマイクはそれから先のことを処理します。あのシナリオではあなたがシグナリングサーバーです。

WebRTC がシグナリングを標準化しない理由

これは見落としではなく、意図的な設計選択です。WebRTC 仕様の作成者は、ほとんどのアプリケーションがユーザー間に既に通信チャネルを持っていることに気づきました -- チャットシステム、マッチングサービス、コラボレーションプラットフォーム。特定のシグナリングプロトコルを強制すると、すべてのアプリケーションが既に存在するものに便乗する代わりに、2 つの通信レイヤーを実装する必要があります。

出会い系アプリには既にメッセージングインフラストラクチャがあります。遠隔医療プラットフォームには既に予約システムがあります。ゲーミングプラットフォームには既にロビーサーバーがあります。WebRTC を使用すると、既に持っているものを使用して、SDP と ICE メッセージを周囲に渡すことができます。

レイテンシーが重要でなければ、USB スティックを運ぶ伝書鳩を使用することができます。(重要ですが、ポイントは成り立ちます。)

Offer/Answer プロトコルと SDP

コアシグナリング交換は、SIP(Session Initiation Protocol)から借用した Offer/Answer モデルと呼ばれるパターンに従います。流れは以下の通りです:

  1. ピア ARTCPeerConnection を作成し、createOffer() を呼び出します
  2. ピア A は setLocalDescription() 経由でオファーをローカル記述として設定します
  3. ピア A はオファー(SDP ブロブ)をシグナリングサーバーを通じてピア B に送信します
  4. ピア B はオファーを受け取り、setRemoteDescription() 経由でリモート記述として設定します
  5. ピア B は createAnswer() を呼び出します
  6. ピア B はアンサーをローカル記述として設定します
  7. ピア B はアンサーをシグナリングサーバーを通じて送信します
  8. ピア A はアンサーを受け取り、リモート記述として設定します

この交換後、両方のピアは互いのメディア機能を知っています。しかし、まだ接続できません -- ICE から得られるネットワークパス情報が必要です。

SDP ブロブの内部は?

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 認証情報、メディアタイプを一覧表示します。シグナリングサーバーはこれのいずれも解析する必要がありません。ブロブをただ不透明な文字列として渡すだけです。

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));

// オファーを作成して送信
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(Network Address Translation)を実行するルーターの後ろにあります。あなたのノートパソコンのローカル IP は 192.168.1.42 かもしれませんが、外の世界はあなたのルーターのパブリック IP を見ます。両方のピアが NAT の後ろにある場合、どちらも他方に直接到達する方法を知りません。

ICE(Interactive Connectivity Establishment)は複数の候補パスを集め、すべてを試すことでこれを解決します:

候補タイプ ソース 成功率 レイテンシ
ホスト ローカルネットワークインターフェース 同じ LAN でのみ動作 最低
Server-reflexive (srflx) STUN サーバー経由で検出 接続の約 80-85%
リレー 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 Signaling Server: Complete Guide with Node.js Examples - architecture

シグナリングトランスポートオプションの比較

基本的には、シグナリング用に双方向通信チャネルを使用できます。一般的なオプションがどのように積み上がるかは以下の通りです:

トランスポート 利点 欠点 最適な用途
WebSocket(生) 低レイテンシ、全二重、軽量 手動再接続ロジック、ルームなし シンプルな 1:1 アプリ
Socket.io 自動再接続、ルーム、ポーリングへのフォールバック 大きなライブラリサイズ(約 45KB)、標準ではない ほとんどの web アプリ
Supabase Realtime マネージドインフラストラクチャ、組み込み認証、Postgres 統合 ベンダーロック、メッセージサイズ制限 既に Supabase 上のアプリ
Firebase Realtime DB マネージド、良好なドキュメント、オフラインサポート ベンダーロック、スケール時の価格 Firebase ベースのアプリ
HTTP ポーリング どこでも動作、シンプルに実装 高レイテンシ、サーバー負荷 レガシー環境
SIP over WebSocket 電話システムとの相互運用性 複雑、ほとんどの web アプリには過剰 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');

// 新しいピアが参加したら、オファーを作成
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 の Broadcast 機能を使用します -- データベース書き込みは不要です。

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 }
      });
    }
  });

// オファーを送信
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 でフィルタリングしているため、broadcast メッセージはすべてのチャネル購読者に表示されます。これはシグナリングに問題ありません(機密データではなく、実際のメディアは暗号化されます)。

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 候補トリッキングは数秒で数十のメッセージを生成する可能性があります。忙しいサーバーでは、これが蓄積します。ソケットごとにレート制限を実装します:

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 プロセスは数千のシグナリング接続を処理できます(シグナリングは軽量で、テキストメッセージだけです)。1 つのサーバーを超えてスケーリングする必要がある場合は、@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/月から グローバル PoP

ICE 構成に常に少なくとも 1 つの TURN サーバーを含めます。「ほとんどの人に有効」とユーザーに伝えることは、VP がホテルの WiFi であなたの製品をデモしようとしている場合、許容できません。

エンタープライズネットワーク間で確実に機能する必要のあるものを構築している場合は、難しい edge cases を処理するための WebRTC ソリューションアーキテクチャについて、お問い合わせください。

FAQ

WebRTC シグナリングサーバーとは何ですか?

シグナリングサーバーは、2 つの WebRTC ピアが直接接続を確立するために必要な情報を交換するのを助けるメッセージリレーです。メディア機能を説明する SDP オファーとアンサー(コーデックを含む)と、ネットワークパスを説明する ICE 候補を運びます。シグナリングサーバーはオーディオまたはビデオデータに触れることはありません -- ピアが接続するとメディアは直接流れます。

WebRTC が標準シグナリングプロトコルを含まない理由は?

WebRTC 仕様は意図的にシグナリングを未指定のままにしています。なぜなら、ほとんどのアプリケーションはユーザー間に既に通信チャネルを持っているからです。チャットアプリ、マッチングサービス、またはコラボレーションツールは、シグナリング用に既存のインフラストラクチャを再利用できます。この柔軟性は、WebSocket、HTTP、SIP、XMPP、またはテキストメッセージを運ぶことができる他の任意のトランスポートを使用できることを意味します。

SDP オファーと SDP アンサーの違いは?

SDP オファーは接続を開始するピアによって作成されます。そのピアのメディア機能(サポートされているコーデック、暗号化方法、メディアタイプ)を説明します。SDP アンサーは受信ピアによって作成され、オファーからどの機能がサポートされているかを確認します。一緒に、両方のピアがメディアセッションに使用する共有パラメータをネゴシエートします。

ICE 候補とは何か、なぜ交換される必要があるのか?

ICE 候補は、ピアに到達できる潜在的なネットワークパスです。各ピアはローカルネットワークインターフェースをチェック、STUN サーバーに問い合わせてパブリックアドレスを取得、TURN サーバーにリレーアドレスを割り当てることによって、候補を検出します。これらの候補をシグナリングサーバーを通じて他のピアに送信して、両側が複数のパスを試し、NAT とファイアウォール経由で機能するものを見つけることができるようにする必要があります。

Supabase Realtime を WebRTC シグナリングサーバーとして使用できますか?

はい。Supabase Realtime の Broadcast 機能は、データベース書き込みを必要としないため、シグナリングに適しています。各ルーム用にチャネルを作成し、offer/answer/ICE メッセージをブロードキャストし、クライアント側で対象ピア ID でフィルタリングします。既に Supabase を使用しており、個別のシグナリングサーバーを実行したくないプロジェクトにとって堅実な選択です。

シグナリングサーバーはいくつのコンカレント接続を処理できますか?

単一の Node.js + Socket.io プロセスは、通常、サーバーリソースと メッセージ頻度に応じて、10,000~50,000 のコンカレントシグナリング接続を処理できます。シグナリングトラフィックは軽量です -- 小さな JSON メッセージだけです。大規模な場合は、Socket.io で Redis アダプタを使用して、複数のサーバーインスタンス間で接続を分散します。WebRTC のボトルネックはシグナリングサーバーではなく、通常は TURN リレー帯域幅です。

本番環境で TURN サーバーが必要ですか?

はい。STUN は約 80-85% の接続を処理しますが、対称 NAT または制限の厳しいファイアウォールの後ろにいる約 15-20% のユーザーは直接接続を防ぐことができません。TURN サーバーがなければ、それらのユーザーは単に接続できません。エンタープライズ環境では、TURN なしの失敗率はさらに高くなる可能性があります。Cloudflare Calls、Twilio、Metered.ca などのサービスは無料層付き TURN を提供します。

アクティブな通話中にシグナリングサーバーがダウンした場合はどうなりますか?

WebRTC 接続が確立されると、シグナリングサーバーに依存しなくなります。シグナリングサーバーがクラッシュした場合、既存の通話は継続して動作します。ただし、新しい接続は確立できず、既存の接続を再ネゴシエートする必要がある場合(例えば、画面共有を追加)、それは失敗します。クラスタリングまたはマネージドサービスを使用して、シグナリングサーバーの高可用性を設計する必要があります。