対象読者と動作環境
対象読者は、WebRTC で P2P 通話機能を実装しようとしていて、SDP と ICE candidate の基本概念は知っている方です。RTCPeerConnection を初めて触る方向けの入門解説はしません。
この記事は自作のチーム用ワークスペース Kurari の LAN 内通話機能(Call Mode)を実装した際にハマった点をまとめたものです。動作確認したバージョンは次のとおりです。
- フロントエンド: Vite 8.1.1 / React 19.2.7 / TypeScript 5.9.3 / Zustand 5.0.14
- バックエンド: Spring Boot 3.5.16 / Kotlin 2.1.21 / Java 21
- Chromium(E2E テスト実行時): Playwright 経由で起動
Kurari の通話は「同じ LAN 内にいるチームメンバー同士で音声・カメラ・画面共有をつなぐ」プレビュー機能です。インターネット越しに常時稼働させる通話サービスとしての運用は想定していません。この前提が、後述する iceServers: [] の設計にそのまま効いてきます。
構成: P2P メッシュ + シグナリング専用バックエンド
Kurari の通話は SFU を挟まない P2P メッシュです。参加者が N 人いれば、各クライアントは自分以外の N-1 人と個別に RTCPeerConnection を張ります。バックエンドは SDP と ICE candidate を中継するだけで、メディアには一切触りません。
backend/src/main/kotlin/app/kurari/ws/EventBroadcaster.kt の call.signal ハンドラです(156〜164 行)。
"call.signal" -> {
// WebRTC の offer/answer/ICE を宛先セッションへそのまま中継(宛先不在は黙殺)
val to = payload["to"]?.asText() ?: return
val target = sessions[to] ?: return
val relayed = mutableMapOf<String, Any?>("from" to session.id)
if (payload.has("description")) relayed["description"] = payload["description"]
if (payload.has("candidate")) relayed["candidate"] = payload["candidate"]
sendTo(target, "call.signal", relayed)
}
コメントにある通り、description と candidate の中身はサーバ側で一切検証しません。宛先の sessionId(WebSocket セッション ID)だけを見て転送するだけです。参加者一覧の管理も CallRegistry(backend/src/main/kotlin/app/kurari/ws/CallRegistry.kt)がメモリ上の ConcurrentHashMap で持つだけで、永続化しません。
// CallRegistry.kt 19-22
@Component
class CallRegistry {
private val entries = ConcurrentHashMap<String, CallParticipant>()
EventBroadcaster が処理する通話関連イベントは call.join / call.leave / call.media / call.signal / call.transcript(通話中の文字起こしをサーバ側に蓄積するイベント)の 5 種類です。メディアのエンコード・デコード・帯域制御はすべてブラウザの RTCPeerConnection に任せ、サーバはシグナリングと文字起こしのメッセージパッシングに徹しています。
そしてフロントの RTCConfiguration は iceServers を空配列にしています(frontend/src/stores/call-store.ts 35-36 行)。
// LAN 内の host candidate だけで繋ぐ(サーバレス)。届かない環境が出たらここに STUN を足す
const RTC_CONFIG: RTCConfiguration = { iceServers: [] }
STUN サーバを介さないので、収集される ICE candidate は host candidate(自分の LAN 内 IP)だけになります。NAT 越えが必要なインターネット越しの通話には使えませんが、Kurari は同一 LAN 内での利用を前提にしているため、外部サーバへの依存をなくして完結させています。
perfect negotiation: polite / impolite の決め方
P2P メッシュで両者が同時に onnegotiationneeded を発火させると、双方が offer を送り合う「glare」が起きます。Kurari は MDN の Perfect negotiation パターンをそのまま実装しています。ポイントは、衝突時にどちらが自分の offer を諦めて相手を受け入れるか(polite)を、両者が矛盾なく判定できることです。
Kurari では sessionId の大小比較で決めています(ensurePeer 内、185-186 行)。
// 衝突時に譲る側。sessionId の小さい方が polite(双方で判定が一致する)
polite: selfSessionId < peerId,
sessionId は WebSocket 接続時にサーバが払い出す文字列 ID です。A から見た B との関係と、B から見た A との関係で polite の真偽が必ず逆になるので、両者の合意なしに衝突解決の役割分担が決まります。
実際の衝突処理は handleSignal にあります(256-293 行)。
const handleSignal = async (
entry: PeerEntry,
from: string,
description?: RTCSessionDescriptionInit,
candidate?: RTCIceCandidateInit | null,
) => {
const { pc } = entry
if (description) {
const readyForOffer =
!entry.makingOffer && (pc.signalingState === 'stable' || entry.isSettingRemoteAnswerPending)
const offerCollision = description.type === 'offer' && !readyForOffer
entry.ignoreOffer = !entry.polite && offerCollision
if (entry.ignoreOffer) return
entry.isSettingRemoteAnswerPending = description.type === 'answer'
await pc.setRemoteDescription(description)
entry.isSettingRemoteAnswerPending = false
// remoteDescription 確定を待っていた candidate を流し込む
for (const pending of entry.pendingCandidates.splice(0)) {
await pc.addIceCandidate(pending).catch(() => {})
}
if (description.type === 'offer') {
await pc.setLocalDescription()
send('call.signal', { to: from, description: pc.localDescription })
}
} else if (candidate) {
// (後述)
}
}
衝突時の流れをシーケンス図にすると次のようになります。A が polite、B が impolite です。
setLocalDescription() を引数なしで呼ぶと、ブラウザが現在の状態から適切な offer/answer を自動生成します。polite 側はこの仕組みのおかげで、衝突した自分の offer を明示的に rollback しなくても setRemoteDescription(offer) を呼ぶだけで暗黙に rollback されます。impolite 側は届いた offer を ignoreOffer = true にして黙って捨てるだけで、自分が送った offer への answer を待てば整合性が取れます。
ハマり1: シグナル処理をピアごとに直列化する
WebSocket 経由で届く call.signal イベントは、description と candidate が別々のメッセージとして次々に届きます。ここで安易に受信ハンドラを async のまま呼びっぱなしにすると、setRemoteDescription(offer) の await 中に次の candidate の処理が先に走ってしまい、pc.remoteDescription の状態を前提にした分岐が壊れます。
Kurari では、ピアごとに Promise チェーンを持たせてシグナル処理を直列化しています。enqueueSignal(call-store.ts 296-307 行)です。
/** シグナルをピアごとに直列で処理する(並行実行すると setRemoteDescription と衝突する) */
const enqueueSignal = (
from: string,
description?: RTCSessionDescriptionInit,
candidate?: RTCIceCandidateInit | null,
) => {
const entry = ensurePeer(from)
if (!entry) return
entry.queue = entry.queue
.then(() => handleSignal(entry, from, description, candidate))
// シグナリングの一時的な不整合は次のネゴシエーションで回復する
.catch(() => {})
}
PeerEntry.queue は各ピアが持つ Promise<void> で、新しいシグナルが来るたびに「前のシグナル処理が終わってから実行する」形で数珠つなぎにします(call-store.ts 44 行のコメント通りです)。
/** このピア宛シグナルの直列実行チェーン(description 処理中に candidate が割り込むと落ちるため) */
queue: Promise<void>
直列化を入れる前は、同一ピアからほぼ同時に description と複数の candidate が届いた場合に処理順序が入れ替わり、addIceCandidate が InvalidStateError で失敗することがありました。ピアごとに queue を分けているので、A とのシグナル処理が B とのシグナル処理をブロックすることはありません。あくまで「同じピアに対する処理の順序」だけを保証しています。
ハマり2: remoteDescription 確定前の candidate はキュー必須
ICE candidate は setRemoteDescription の完了を待たずに届くことが普通にあります。pc.addIceCandidate() は remoteDescription が未設定の状態で呼ぶと失敗するため、届いた順にそのまま addIceCandidate するとエラーで candidate を取りこぼします。
直列化していても、description の処理そのものが await pc.setRemoteDescription(description) で時間がかかっている間に次の candidate イベントが直列キューに積まれ、description 処理が終わってから実行されます。つまり順序の問題ではなく、「candidate が先に届いたときにどう待たせるか」が別途必要です。Kurari は pendingCandidates という待機列に一旦積んでおき、remoteDescription 確定後にまとめて流し込みます(handleSignal の該当部分、call-store.ts 281-292 行)。
} else if (candidate) {
if (!pc.remoteDescription) {
entry.pendingCandidates.push(candidate)
return
}
try {
await pc.addIceCandidate(candidate)
} catch (e) {
// 無視した offer に紐づく candidate は捨ててよい
if (!entry.ignoreOffer) throw e
}
}
そして description を処理する側で、確定直後に待機列を空にします(270-275 行)。
await pc.setRemoteDescription(description)
entry.isSettingRemoteAnswerPending = false
// remoteDescription 確定を待っていた candidate を流し込む
// (旧いネゴシエーションの残骸は addIceCandidate が拒否するので握りつぶす)
for (const pending of entry.pendingCandidates.splice(0)) {
await pc.addIceCandidate(pending).catch(() => {})
}
このキューを実装する前は、pc.connectionState が new のまま進まないことがありました。エラー自体は catch で握りつぶせるので例外は見えず、onicecandidate が発火しているのに接続が張られないという分かりにくい形で症状が出ます。原因は setRemoteDescription 完了前に届いた candidate が addIceCandidate の例外で単純に消えていたことで、ICE candidate は 1 件でも欠けると接続経路の候補が減るため、環境によっては到達可能な経路が候補からすべて漏れて接続が成立しません。ログに残る例外を疑う前に、まず「candidate を本当に全部 addIceCandidate できているか」を確認する価値があります。
ハマり3: 画面共有をカメラ映像と見分ける
P2P メッシュでは 1 ピアから複数の MediaStream(カメラ用・画面共有用)が届き得ます。ontrack はストリーム単位でイベントが発火しますが、受信側にはそのストリームが「カメラ」なのか「画面共有」なのかを示すメタデータが乗っていません。
Kurari はここを screenStreamId(call.media で送る画面共有ストリームの MediaStream.id)と、受信済みストリームの id を突き合わせて解決しています。まず受信側の格納(call-store.ts 215-228 行)です。
pc.ontrack = (e) => {
const stream = e.streams[0]
if (!stream) return
set((s) => {
const peerStreams = s.remoteStreams[peerId] ?? {}
if (peerStreams[stream.id] === stream) return s
return {
remoteStreams: {
...s.remoteStreams,
[peerId]: { ...peerStreams, [stream.id]: stream },
},
}
})
}
remoteStreams は sessionId → streamId → MediaStream の 2 段構造です。1 ピアにつき複数ストリームを個別の ID で保持できます。表示側(CallMode.tsx 130-137 行)で、参加者が申告した screenStreamId と一致しないストリームをカメラ映像として選びます。
/** 画面共有用ストリームを除外し、参加者のカメラストリームを解決する */
function findCameraStream(
streams: Record<string, MediaStream> | undefined,
screenStreamId: string | null,
): MediaStream | null {
if (!streams) return null
return Object.values(streams).find((stream) => stream.id !== screenStreamId) ?? null
}
画面共有ストリームそのものは streams?.[p.screenStreamId] で ID 直引きします(CallMode.tsx 116-122 行)。
{p.screenStreamId && (
<ScreenTile
name={name}
color={color}
stream={streams?.[p.screenStreamId] ?? null}
/>
)}
screenStreamId は CallParticipant(CallRegistry.kt 7-12 行)の一部としてサーバ側の参加者一覧にも乗っており、call.media イベントで更新されるたびに call.participants としてブロードキャストされます。つまり「今誰が画面共有中か」はシグナリングサーバが把握していて、実際のストリームの中身(映像そのもの)は P2P で直接流れる、という役割分担です。
送信側で画面共有を開始するときは、既存の全ピアに新しいトラックを addTrack してから call.media を送ります(startScreenShare、387-410 行)。
startScreenShare: async () => {
if (get().screenStream) return
let stream: MediaStream
try {
stream = await navigator.mediaDevices.getDisplayMedia({ video: true })
} catch (e) {
const errorMessage = screenMediaErrorMessage(e)
if (errorMessage) set({ errorMessage })
return
}
const track = stream.getVideoTracks()[0]
if (!track) {
stream.getTracks().forEach((t) => t.stop())
set({ errorMessage: '共有できる画面が見つかりません' })
return
}
set({ screenStream: stream, errorMessage: null })
track.onended = () => get().stopScreenShare()
for (const { pc } of peers.values()) pc.addTrack(track, stream)
const { muted, cameraOff } = get()
send('call.media', { muted, cameraOff, screenStreamId: stream.id })
},
pc.addTrack(track, stream) を呼ぶと onnegotiationneeded が自動発火し、追加されたトラックを乗せた新しい offer が各ピアに送られます。画面共有の開始・停止のたびに、シグナリングとメディア追加が connect 済みのコネクション上で再ネゴシエーションされる形です。
ハマり4: WS 再接続で sessionId が変わる
Kurari の sessionId は Spring の WebSocketSession.id で、接続ごとに新しく発行されます。ネットワークが一瞬切れて WebSocket が再接続すると、同じブラウザタブでも sessionId が変わります。
これは polite/impolite の判定や call.signal の宛先解決の前提が崩れることを意味します。旧 sessionId 宛の PeerConnection を持ち続けても、相手からのシグナルはもう届きません。Kurari は WS の状態変化をトリガーに、全 PeerConnection を破棄して call.join を送り直す設計にしています(handleWsState、444-455 行)。
handleWsState: (state) => {
if (!wantJoined) return
if (state === 'closed') {
// 再接続すると sessionId が変わるため、旧接続はすべて破棄して張り直す
closeAllPeers()
stopTranscription()
} else if (state === 'open') {
const { screenStream, muted, cameraOff } = get()
send('call.join', { muted, cameraOff, screenStreamId: screenStream?.id ?? null })
startTranscription()
}
},
wantJoined はモジュール変数で、join() が呼ばれてから leave() が呼ばれるまで true のままです(78 行、340 行、346 行)。これがないと、通話に一度も参加していないタブで WS が再接続するたびに call.join が送られてしまいます。「参加する意思があるか」と「今 WebSocket がつながっているか」を分けて持つことで、再接続時の再参加をこの意思フラグだけで判定できます。
closeAllPeers は peers Map(モジュール変数)を空にして remoteStreams もクリアします(168-172 行)。
const closeAllPeers = () => {
for (const entry of peers.values()) entry.pc.close()
peers.clear()
set({ remoteStreams: {} })
}
再接続後の call.join に対してサーバは新しい sessionId で call.joined(既存参加者一覧つき)を返し、applyCallEvent がこれを受けて syncPeers を呼び、新しい sessionId を前提に PeerConnection を張り直します。旧 PeerConnection を再利用しようとする設計は考えず、「セッションが変わったら通話も張り直す」と割り切っているのがポイントです。
ハマり5: 画面遷移で通話を切らない
Kurari は Board / Doc / Chat / AI 出力を 1 画面に統合したワークスペースで、通話中でもホワイトボードや資料を操作できることが要件でした。React Router のルート /call(App.tsx 167 行)から別のルートに移動しても、通話は切れてはいけません。
これを実現しているのは、RTCPeerConnection や MediaStream を React の state ではなく Zustand ストアの外、モジュール変数として持っていることです(call-store.ts 74-78 行)。
// RTCPeerConnection や送信関数は再レンダー不要なのでモジュール変数に持つ
let sender: ((msg: object) => void) | null = null
const peers = new Map<string, PeerEntry>()
/** 参加の意思。WS 再接続時に call.join を送り直す判定に使う(leave で解除) */
let wantJoined = false
CallMode コンポーネント(components/call/CallMode.tsx)は /call にルーティングされたときだけマウントされますが、peers Map はコンポーネントのライフサイクルと無関係にモジュールスコープに存在し続けます。CallMode が unmount されても peers.get(sessionId).pc は破棄されず、映像・音声トラックの送受信は継続します。
別ルートにいる間の状態は FloatingCallBar(components/call/FloatingCallBar.tsx)が useCallStore を購読して表示します。
const { pathname } = useLocation()
...
if (status !== 'joined' || pathname.startsWith('/call')) return null
status はストアの state(React 側で購読可能)なので、/call 以外のパスにいて status === 'joined' なら通話中バーが浮きます。「メディアの実体はモジュール変数」「通話中かどうかの表示状態は Zustand の state」と役割を分けたことで、UI 側は素直に useCallStore を購読するだけで済み、CallMode の unmount 時に何かクリーンアップ処理を書く必要がなくなりました。逆に言うと、もし PeerConnection を useEffect の中でコンポーネントのライフサイクルに紐づけて生成・破棄していたら、ルート遷移のたびに通話が切れ直す実装になっていたはずです。
E2E でのカメラ代替と mDNS 匿名化の無効化
通話機能の E2E テスト(frontend/e2e/smoke.mjs)は実カメラなしで getUserMedia を通す必要があります。Playwright の chromium.launch() に渡す起動引数です(100-110 行)。
const browser = await chromium.launch({
// 通話テスト用: 実カメラなしで getUserMedia を通す(緑のテストパターン映像 + ビープ音)。
// mDNS 匿名化はテスト環境では解決できず ICE が繋がらないため無効化する
args: [
'--use-fake-device-for-media-stream',
'--use-fake-ui-for-media-stream',
'--auto-select-desktop-capture-source=Entire screen',
'--autoplay-policy=no-user-gesture-required',
'--disable-features=WebRtcHideLocalIpsWithMdns',
],
})
--use-fake-device-for-media-stream は緑のテストパターン映像とビープ音を返す仮想デバイスを有効にするフラグです。似た名前で --use-fake-device-for-media-capture という表記を見かけることがありますが、Chromium の実際のフラグ名は -capture ではなく -stream です。ここを間違えると getUserMedia がフラグを認識せず、実カメラを要求してヘッドレス環境でテストが止まります。
--use-fake-ui-for-media-stream は権限確認ダイアログを自動許可するフラグ、--auto-select-desktop-capture-source=Entire screen は画面共有の getDisplayMedia が出す選択ダイアログを自動的に「画面全体」で確定させるフラグです。画面共有のテストには両方が要ります。
--disable-features=WebRtcHideLocalIpsWithMdns は Chrome の mDNS 匿名化(ICE candidate に載る IP を .local ホスト名に置き換える機能)を無効にします。コメントの通り、テスト環境ではこの mDNS ホスト名を解決できず、host candidate はあるのに実際には疎通しないという状態になります。iceServers: [] で host candidate 頼みの構成にしている以上、この candidate が名前解決できないと ICE が候補を使い切って接続に失敗します。E2E に限らず、「LAN 内なのに connectionState が failed になる」という事象を見たら、まずブラウザの mDNS 匿名化が有効になっていないかを疑う価値があります。