P2Pを全くやったことない人間がgeminiを用いてwebRTCをうっすら理解していこうというお話です。
1. Deep Researchで概要をざっくり把握
deepReserch君に頑張ってもらって、ざっくりRTCを理解していく。
DeepReserch(一部抜粋)
WebRTCの基幹アーキテクチャと主要API
WebRTCの機能は、主に3つの高レベルJavaScript APIによって抽象化されている。これらのAPIは、ブラウザ内部の複雑なメディア処理エンジン(多くはC++やRustで実装されている)と開発者のコードを繋ぐ役割を果たす。
[MediaStream API(getUserMedia)]
メディアキャプチャ機能を提供するgetUserMedia()は、ユーザーのカメラやマイクといったハードウェアデバイスへのアクセスを管理する。このAPIは単にメディアを取得するだけでなく、解像度、フレームレート、エコーキャンセレーション、ノイズ抑制といった制約(Constraints)を動的に適用することが可能である。取得されたデータはMediaStreamTrackとして扱われ、WebRTCはこれらをコンテナレス(Containerless)で転送することで、パケットのオーバーヘッドを最小限に抑えている。
[RTCPeerConnection API]
WebRTCの心臓部と言えるのがRTCPeerConnectionである。このインターフェースは、P2P接続のライフサイクル全般を管理し、シグナル処理、コーデックのネゴシエーション、ピア間の通信、セキュリティ、および帯域幅管理を実行する。接続プロセスは、Session Description Protocol(SDP)を用いたオファー/アンサーモデルに基づいており、JSEP(JavaScript Session Establishment Protocol)によって制御される。注目すべき点は、WebRTC自体はシグナリングの輸送メカニズムを規定していないことである。開発者はWebSockets、HTTP、あるいは伝統的なSIPなど、用途に応じた任意のプロトコルをシグナリングに採用できる。
[RTCDataChannel API]
音声とビデオ以外の任意のバイナリデータやテキスト情報を低遅延で送受信するための機能がRTCDataChannelである。これはSCTP(Stream Control Transmission Protocol)をDTLS(Datagram Transport Layer Security)上で動作させる構造をとっており、WebSocketsと同様のAPIを備えながら、より低いレイテンシと高い柔軟性を提供する。データの信頼性(Reliable)や順序性(Ordered)をアプリケーション側で設定できるため、リアルタイム対戦ゲームの状態同期やファイル共有、IoTデバイスの制御などで重宝されている。
ネットワーク透過性とNATトラバーサルの仕組み
WebRTCが直面する最大のネットワーク的課題は、現代のインターネット構造において不可避なNAT(Network Address Translation)とファイアウォールである。多くのデバイスはプライベートIPアドレスを割り当てられており、外部から直接アクセスすることができない。この問題を解決するために、WebRTCはInteractive Connectivity Establishment(ICE)フレームワークを採用している。
STUNおよびTURNサーバーの役割
ICEは、STUN(Session Traversal Utilities for NAT)とTURN(Traversal Using Relays around NAT)という2種類のサーバーを利用して接続経路を探索する。STUNサーバーは、クライアントが自身のパブリックIPアドレスとポート番号を特定することを支援する。しかし、対称型NAT(Symmetric NAT)や厳格な企業ファイアウォール環境ではSTUNだけでは不十分であり、全トラフィックを中継するTURNサーバーがフォールバックとして必要になる。
TURNサーバーの利用は帯域幅コストが高く、中継による遅延も発生するが、どのようなネットワーク環境下でも通信を保証するためには不可欠なコンポーネントである。2025年現在、大規模なWebRTCアプリケーションでは、遅延を最小化するために世界各地にリージョン別のTURNサーバーを配置し、オートフェイルオーバー機能を備えたインフラを構築することが一般的となっている。
ICE候補の収集と優先順位付け
ICEプロセスでは、「ホスト候補(ローカルIP)」、「サーバー反射候補(STUN経由のパブリックIP)」、「リレー候補(TURN経由)」の3種類のアドレス候補が収集される。これらの候補はシグナリングチャネルを通じて対向ピアと交換され、ICEアルゴリズムが最も効率的な経路(通常は直接のP2P、不可能であればリレー)を選択する。また、IPv6への移行に伴い、NATを介さないグローバルルーティングが可能なケースが増えており、ICEはIPv4とIPv6の両方で最適な経路を探索するHappy Eyeballsメカニズムをサポートしている。
ピア同士がいろんなデータをオーバヘッドなく通信するために、毎回SDPというやつを交換して、そのうえでNATを自身だけだと超えられないからSTUNやTURNの力を借りながら通信するという感じ?... (このフロー全体がIECなのか?)
STUNの方が早かったりするけどTURNの方がどこでも使える的な?...
2. ようわからんけど実装してみて
とりあえずざっくり大筋はわかったつもりになったので、いったんgeminiに実装してもらって、それをリバースエンジニアリングしく所存
作成されたコード (index.html):
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>WebRTC Simple Demo</title>
</head>
<body>
<h1>WebRTC Data Channel Demo</h1>
<button id="startButton">Start Connection</button>
<button id="sendButton" disabled>Send Message</button>
<div id="log"></div>
<script>
const startButton = document.getElementById('startButton');
const sendButton = document.getElementById('sendButton');
const logDiv = document.getElementById('log');
let localConnection;
let remoteConnection;
let sendChannel;
let receiveChannel;
startButton.onclick = createConnection;
sendButton.onclick = sendMessage;
function log(text) {
logDiv.innerHTML += text + "<br>";
console.log(text);
}
async function createConnection() {
log("接続を開始します...");
// PeerConnectionの作成
localConnection = new RTCPeerConnection();
remoteConnection = new RTCPeerConnection();
// ICE Candidateの交換 (ローカル -> リモート)
localConnection.onicecandidate = e => {
if (e.candidate) {
remoteConnection.addIceCandidate(e.candidate);
log("Local ICE candidate added to Remote");
}
};
// ICE Candidateの交換 (リモート -> ローカル)
remoteConnection.onicecandidate = e => {
if (e.candidate) {
localConnection.addIceCandidate(e.candidate);
log("Remote ICE candidate added to Local");
}
};
// データチャンネルの作成 (ローカル側)
sendChannel = localConnection.createDataChannel("sendDataChannel");
sendChannel.onopen = () => {
log("Send channel opened");
sendButton.disabled = false;
};
// データチャンネルの受信 (リモート側)
remoteConnection.ondatachannel = receiveChannelCallback;
// オファーの作成と交換
try {
const offer = await localConnection.createOffer();
await localConnection.setLocalDescription(offer);
log("Offer created and set as Local Description");
await remoteConnection.setRemoteDescription(offer);
log("Offer set as Remote Description");
const answer = await remoteConnection.createAnswer();
await remoteConnection.setLocalDescription(answer);
log("Answer created and set as Local Description");
await localConnection.setRemoteDescription(answer);
log("Answer set as Remote Description");
} catch (e) {
log("Error: " + e);
}
}
function receiveChannelCallback(event) {
receiveChannel = event.channel;
receiveChannel.onmessage = onReceiveMessage;
receiveChannel.onopen = () => log("Receive channel opened");
}
function onReceiveMessage(event) {
log("Received Message: " + event.data);
alert("メッセージ受信: " + event.data);
}
function sendMessage() {
const message = "Hello WebRTC via Gemini!";
sendChannel.send(message);
log("Sent Message: " + message);
}
</script>
</body>
</html>
作成されたコード (main.js):
// ICE server URLs
let peerConnectionConfig = { 'iceServers': [{ "urls": "stun:stun.l.google.com:19302" }] };
// Data channel オプション
let dataChannelOptions = {
ordered: false,
}
// Peer Connection
let peerConnection;
// Data Channel
let dataChannel;
// ページ読み込み時に呼び出す関数
window.onload = function () {
document.getElementById('status').value = 'closed';
}
// 新しい RTCPeerConnection を作成する
function createPeerConnection() {
let pc = new RTCPeerConnection(peerConnectionConfig);
// ICE candidate 取得時のイベントハンドラを登録
pc.onicecandidate = function (evt) {
if (evt.candidate) {
// 一部の ICE candidate を取得
// Trickle ICE では ICE candidate を相手に通知する
console.log(evt.candidate);
document.getElementById('status').value = 'Collecting ICE candidates';
} else {
// 全ての ICE candidate の取得完了(空の ICE candidate イベント)
// Vanilla ICE では,全てのICE candidate を含んだ SDP を相手に通知する
// (SDP は pc.localDescription.sdp で取得できる)
// 今回は手動でシグナリングするため textarea に SDP を表示する
document.getElementById('localSDP').value = pc.localDescription.sdp;
document.getElementById('status').value = 'Vanilla ICE ready';
}
};
pc.onconnectionstatechange = function (evt) {
switch (pc.connectionState) {
case "connected":
document.getElementById('status').value = 'connected';
break;
case "disconnected":
case "failed":
document.getElementById('status').value = 'disconnected';
break;
case "closed":
document.getElementById('status').value = 'closed';
break;
}
};
pc.ondatachannel = function (evt) {
console.log('Data channel created:', evt);
setupDataChannel(evt.channel);
dataChannel = evt.channel;
};
return pc;
}
// ピアの接続を開始する
function startPeerConnection() {
// 新しい RTCPeerConnection を作成する
peerConnection = createPeerConnection();
// Data channel を生成
dataChannel = peerConnection.createDataChannel('test-data-channel', dataChannelOptions);
setupDataChannel(dataChannel);
// Offer を生成する
peerConnection.createOffer().then(function (sessionDescription) {
console.log('createOffer() succeeded.');
return peerConnection.setLocalDescription(sessionDescription);
}).then(function () {
// setLocalDescription() が成功した場合
// Trickle ICE ではここで SDP を相手に通知する
// Vanilla ICE では ICE candidate が揃うのを待つ
console.log('setLocalDescription() succeeded.');
}).catch(function (err) {
console.error('setLocalDescription() failed.', err);
});
document.getElementById('status').value = 'offer created';
}
// Data channel のイベントハンドラを定義する
function setupDataChannel(dc) {
dc.onerror = function (error) {
console.log('Data channel error:', error);
};
dc.onmessage = function (evt) {
console.log('Data channel message:', evt.data);
let msg = evt.data;
document.getElementById('history').value = 'other> ' + msg + '\n' + document.getElementById('history').value;
};
dc.onopen = function (evt) {
console.log('Data channel opened:', evt);
};
dc.onclose = function () {
console.log('Data channel closed.');
};
}
// 相手の SDP 通知を受ける
function setRemoteSdp() {
let sdptext = document.getElementById('remoteSDP').value;
// SDPの内容から Offer か Answer かを簡易判定する
// Offer には通常 a=setup:actpass が含まれる
let isOffer = (sdptext.indexOf('a=setup:actpass') >= 0);
if (peerConnection && !isOffer) {
// Peer Connection が生成済みで、かつ Offer でない(Answerと思われる)場合
let answer = new RTCSessionDescription({
type: 'answer',
sdp: sdptext,
});
peerConnection.setRemoteDescription(answer).then(function () {
console.log('setRemoteDescription() succeeded.');
}).catch(function (err) {
console.error('setRemoteDescription() failed.', err);
document.getElementById('status').value = 'Error (Answer): ' + err.name;
});
} else {
// Peer Connection が未生成、または強制的に新しい Offer として処理する場合
if (peerConnection) {
console.warn('Existing PeerConnection found but receiving new Offer. Resetting...');
peerConnection.close();
peerConnection = null;
}
let offer = new RTCSessionDescription({
type: 'offer',
sdp: sdptext,
});
// Peer Connection を生成
peerConnection = createPeerConnection();
peerConnection.setRemoteDescription(offer).then(function () {
console.log('setRemoteDescription() succeeded.');
return peerConnection.createAnswer();
}).then(function (sessionDescription) {
console.log('createAnswer() succeeded.');
return peerConnection.setLocalDescription(sessionDescription);
}).then(function () {
// setLocalDescription() が成功した場合
// Trickle ICE ではここで SDP を相手に通知する
// Vanilla ICE では ICE candidate が揃うのを待つ
console.log('setLocalDescription() succeeded.');
document.getElementById('status').value = 'answer created (collecting ICE...)';
}).catch(function (err) {
console.error('SDP Error:', err);
document.getElementById('status').value = 'Error (Offer): ' + err.name;
});
}
}
// チャットメッセージの送信
function sendMessage() {
if (!peerConnection || peerConnection.connectionState != 'connected') {
alert('PeerConnection is not established.');
return false;
}
let msg = document.getElementById('message').value;
document.getElementById('message').value = '';
document.getElementById('history').value = 'me> ' + msg + '\n' + document.getElementById('history').value;
dataChannel.send(msg);
return true;
}

サーバ立てずにwebページ間で通信出来てしまった...(同じPCだから全然P2Pじゃないけど)
3.リバースエンジニアリング
とりあえず、コードをざっくり理解するためにgeminiでmarmaidを出してもらう


うーん、この感じだとSTUN/TURNサーバは人間がやってるって認識でいいんかな?もしそうだとしたら、
少なくとも現在の実装でシグナリングまではうまくいってるから別ネットでNAT越えができるかやってみる
STUNはgoogleのサーバつかってるっぽい
let peerConnectionConfig = { 'iceServers': [{ "urls": "stun:stun.l.google.com:19302" }] };
stunの関数はちゃんとうごいてるが、最後までコネクトできない感じ。両方のネットをスマホ2台のテザリングでやってるからもしかしたら対称NATとかセパレータとかが悪さしてるかも。今の環境だとさいげんできないから、また家と大学で家のPCをリモートで動かして試してみようかな...
まとめ
こんな感じで、実際にモノ動かしながら試しながら、やる人にとってはわりと生成AIで軽い実装をやってもらってから色々試すのはおもろいと今更ながら思いました。(小並感)
