はじめに
この記事では、WebRTC・シグナリングサーバー・WebSocket の概要を整理し、Next.js での最小構成デモを作ってみます。
WebRTC とは?
WebRTC(Web Real-Time Communication)は、ブラウザ同士で音声・映像などのデータを直接(P2P)通信を行う仕組み。
→ ブラウザ同士が直接やり取りするといっても、相手をどう特定するの?
→シグナリングが必要
シグナリングとは?
下記のような接続に必要な情報をやり取りする
-
SDP(offer/answer):どんな映像/音声コーデックが使えるか、などの情報 -
ice-candidate:接続に使える IP・ポート情報
→ これらの交換を仲介するのがシグナリングサーバー
※通信手段は何でもいい
Ex)WebSocket / REST API / Socket.io
シグナリングサーバーの役割まとめ
- 接続前の情報交換を手伝う
- 実データ(映像・音声)は運ばない。
- WebSocket や HTTP など、何で実装しても OK。
- 情報交換が終わったらブラウザ同士が直接やり取り。
[ブラウザA] ⇄ (WebSocket経由でSDP交換) ⇄ [シグナリングサーバー] ⇄ [ブラウザB]
↖───────────── WebRTC (映像/音声P2P) ─────────────↗
WebSocket との違い
| 項目 | WebSocket | WebRTC |
|---|---|---|
| 通信方式 | クライアント ⇄ サーバー | ブラウザ ⇄ ブラウザ(P2P) |
| データの経路 | サーバーを経由 | 直接接続(TURN 経由もあり) |
| 用途 | チャット・通知・リアルタイム同期など | 映像通話・音声通話・データ転送など |
Next.js で最小構成のハンズオン
今回は、PC 一台で試せるように、テキストデータを使った WebRTC の最小構成アプリを作成します。
※概要を理解するハンズオンのためファイル分割等が行われていないこと・コードが汚いことはご了承ください
1. プロジェクトのセットアップ
まずは Next.js プロジェクトを作成し、必要なパッケージをインストールします。
npx create-next-app@latest webrtc-demo
cd webrtc-demo
# シグナリングサーバー用のwsと、サーバー/クライアント同時起動用のconcurrentlyを入れる
npm i ws
npm i -D concurrently
package.json の scripts を修正して、Next.js とシグナリングサーバーをコマンド一発で起動できるようにしておきます。
{
"scripts": {
"dev": "concurrently \"node server/signaling-server.js\" \"next dev\"",
"build": "next build",
"start": "next start"
}
}
2. シグナリングサーバーの実装
シグナリングサーバーは ws ライブラリを使って実装します。
プロジェクトルートに server ディレクトリを作成し、signaling-server.js を配置します。
import { WebSocketServer } from "ws";
//WebSocketサーバーを立ち上げ。rooms は「どの部屋に誰がいるか」を管理するためのリスト
const wss = new WebSocketServer({ port: 3001 });
const rooms = new Map();
// 部屋への入室処理
function joinRoom(ws, roomId) {
if (!rooms.has(roomId)) rooms.set(roomId, new Set());
rooms.get(roomId).add(ws);
ws.roomId = roomId;
}
// シグナリングを行うための処理
function broadcastToRoom(ws, msgObj) {
const set = rooms.get(ws.roomId);
if (!set) return;
for (const client of set) {
// 自分以外に転送
if (client !== ws && client.readyState === client.OPEN) {
client.send(JSON.stringify(msgObj));
}
}
}
wss.on("connection", (ws) => {
ws.on("message", (data) => {
const msg = JSON.parse(data.toString());
const { type, roomId, payload } = msg;
if (type === "join") {
joinRoom(ws, roomId);
return;
}
// offer / answer / ice を相手へ中継
if (type === "offer" || type === "answer" || type === "ice") {
broadcastToRoom(ws, { type, payload });
}
});
});
3.クライアントサイドの実装 (Next.js)
page.tsx を編集して「シグナリングサーバーへの接続」と「WebRTC(P2P)接続の確立」、そして「チャットの送受信」を行っています。
import { useRef, useState } from "react";
// WebSocket 経由でシグナリングサーバーに送受信するメッセージの型
// type でメッセージの種類を切り分ける
type Signal =
| { type: "join"; roomId: string } // ルーム参加
| { type: "offer"; payload: RTCSessionDescriptionInit } // SDP オファー
| { type: "answer"; payload: RTCSessionDescriptionInit } // SDP アンサー
| { type: "ice"; payload: RTCIceCandidateInit }; // ICE 候補
// チャットで扱う 1 メッセージの型
type ChatMessage = {
sender: "me" | "other";
text: string;
};
export default function Home() {
const [roomId, setRoomId] = useState("");
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [inputText, setInputText] = useState("");
// WebSocket が接続済みかどうか
const [connected, setConnected] = useState(false);
// DataChannel(P2P側)の状態
const [channelStatus, setChannelStatus] = useState<"closed" | "open">(
"closed"
);
// WebSocket インスタンスを保持(再レンダリング間で同じものを使うため useRef)
const wsRef = useRef<WebSocket | null>(null);
// RTCPeerConnection のインスタンスを保持
const pcRef = useRef<RTCPeerConnection | null>(null);
// P2P の DataChannel(チャット用)を保持
const dataChannelRef = useRef<RTCDataChannel | null>(null);
// 表示するメッセージ配列に追加するヘルパー
const addMessage = (text: string, sender: "me" | "other") => {
setMessages((prev) => [...prev, { sender, text }]);
};
// DataChannel のイベントを設定する関数
// - メッセージ受信時: onmessage
// - チャネルが開いた時: onopen
const setupDataChannel = (dc: RTCDataChannel) => {
// 相手からメッセージが届いた時の処理
dc.onmessage = (ev) => addMessage(ev.data, "other");
// チャネルが開通した時(Send ボタンなどを有効にする)
dc.onopen = () => setChannelStatus("open");
// 送信時に使うため参照を保持
dataChannelRef.current = dc;
};
// WebRTC の RTCPeerConnection を作成し、イベントを設定する関数
const createPeerConnection = () => {
const pc = new RTCPeerConnection();
// 受信側で、相手が作った DataChannel を受け取るイベント
pc.ondatachannel = (ev) => setupDataChannel(ev.channel);
// ICE 候補が見つかったときに呼ばれる
// 候補をシグナリングサーバー経由で相手に通知する
pc.onicecandidate = (ev) => {
if (ev.candidate) wsSend({ type: "ice", payload: ev.candidate.toJSON() });
};
pcRef.current = pc;
return pc;
};
// WebSocket でシグナリングメッセージを送る共通関数
// roomId も一緒に送って、サーバー側がルーム毎に振り分けられるようにする
const wsSend = (msg: Signal) => {
wsRef.current?.send(JSON.stringify({ ...msg, roomId }));
};
// シグナリングサーバー(WebSocket)に接続する
const connectSignaling = () => {
const ws = new WebSocket("ws://localhost:3001");
wsRef.current = ws;
// WebSocket 接続完了時
ws.onopen = () => {
// ルーム参加メッセージを送信
wsSend({ type: "join", roomId });
setConnected(true);
};
// シグナリングメッセージを受信したときの処理
ws.onmessage = async (ev) => {
const msg: Signal = JSON.parse(ev.data);
const pc = pcRef.current ?? createPeerConnection();
if (msg.type === "offer") {
// 受信側の処理(Offer 受信)
// 相手から送られてきた SDP をリモート記述としてセット
await pc.setRemoteDescription(msg.payload);
// 自分側の Answer を作成
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
// Answer を相手へ返す
wsSend({ type: "answer", payload: answer });
} else if (msg.type === "answer") {
// 発信側の処理(Answer 受信)
await pc.setRemoteDescription(msg.payload);
} else if (msg.type === "ice") {
// 相手から ICE 候補を受け取ったとき
await pc.addIceCandidate(msg.payload);
}
};
};
// チャットを開始する側の処理
// - PeerConnection 作成
// - DataChannel 作成
// - Offer を作成して送信
const startCall = async () => {
const pc = createPeerConnection();
// 発信側が DataChannel を作成する
const dc = pc.createDataChannel("chat");
// DataChannel のイベントなどを設定
setupDataChannel(dc);
// SDP Offer を生成してローカルにセット
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
// シグナリングサーバー経由で相手へ送信
wsSend({ type: "offer", payload: offer });
};
// チャットメッセージを送信する処理
const sendMessage = () => {
// P2P の DataChannel 経由で相手へ送信
dataChannelRef.current?.send(inputText);
// 自分側の画面にもメッセージを追加
addMessage(inputText, "me");
// 入力欄をクリア
setInputText("");
};
return (
<main
style={{
padding: 24,
maxWidth: 600,
margin: "0 auto",
fontFamily: "sans-serif",
}}
>
<h1>WebRTC Data Channel Chat</h1>
<p>テキストチャットでP2P通信を検証します。</p>
<div style={{ display: "flex", gap: 8, marginBottom: 16 }}>
<input
value={roomId}
onChange={(e) => setRoomId(e.target.value)}
placeholder="Room ID"
style={{ padding: 8 }}
/>
<button
onClick={connectSignaling}
disabled={connected}
style={{ padding: "8px 16px" }}
>
{connected ? "WS Connected" : "1. Connect WS"}
</button>
<button
onClick={startCall}
disabled={!connected || channelStatus === "open"}
style={{ padding: "8px 16px" }}
>
1. Start Call (Offer)
</button>
</div>
<div
style={{
border: "1px solid #ccc",
borderRadius: 8,
padding: 16,
height: 300,
overflowY: "auto",
background: "#f9f9f9",
marginBottom: 16,
}}
>
{messages.length === 0 && (
<div style={{ color: "#888" }}>No messages yet.</div>
)}
{messages.map((m, i) => (
<div
key={i}
style={{
textAlign: m.sender === "me" ? "right" : "left",
marginBottom: 8,
}}
>
<span
style={{
background: m.sender === "me" ? "#dcf8c6" : "#fff",
padding: "6px 12px",
borderRadius: 12,
display: "inline-block",
border: "1px solid #ddd",
}}
>
{m.text}
</span>
</div>
))}
</div>
<div style={{ display: "flex", gap: 8 }}>
<input
value={inputText}
onChange={(e) => setInputText(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && sendMessage()}
placeholder="Type a message..."
disabled={channelStatus !== "open"}
style={{ flex: 1, padding: 8 }}
/>
<button
onClick={sendMessage}
disabled={channelStatus !== "open"}
style={{ padding: "8px 16px" }}
>
Send
</button>
</div>
<div
style={{
marginTop: 12,
fontSize: 12,
color: channelStatus === "open" ? "green" : "red",
}}
>
P2P Status: {channelStatus.toUpperCase()}
</div>
</main>
);
}
シグナリングサーバーに送るメッセージの type
| type | 何のため? | いつ送る? |
|---|---|---|
join |
同じ部屋にユーザーを集める | WS 接続直後 |
offer |
P2P 接続を始めたい!(Caller → Callee) | Start Call 時 |
answer |
offer の返答(Callee → Caller) | offer 受信後 |
ice |
P2P 用ネットワーク経路候補(ICE Candidate) | WebRTC のネゴ中に自動送信 |
4.動作確認
- サーバー起動
npm run dev
- ブラウザで 2 つのタブを開く
-
http://localhost:3000を 2 つのタブ(またはウィンドウ)で開きます。
- 接続手順
- タブ A: 「Connect WS」をクリック
- タブ B: 「Connect WS」をクリック
- タブ A: 「Start Call (Offer)」をクリック
- チャット
NATを超えた通信を行う場合
今回のハンズオンではローカル同士での通信だったので不要でしたが、NATを超えて異なるネットワーク間で通信を行いたい場合には STUN / TURN サーバーが必要なるケースもあります
STUN / TURNサーバーとは?
STUN / TURN サーバーには下記のような役割があります。
| 名称 | 役割 |
|---|---|
| STUN(Session Traversal Utilities for NATs) | 外から見た自分のIP / ポートを調べる |
| TURN(Traversal Using Relay around NAT) | 中継サーバーとして通信を代理する |
STUNサーバーのイメージ
┌─────────────────────┐ ┌─────────────────────┐
│ ブラウザA │ │ ブラウザB │
└───────┬─────────────┘ └─────────────────────┘
IPアドレスを教えて ▲
│ IPアドレスはX.X.X.Xですよ
│ │
┌─────────┐
│ NAT │
└─────────┘
│ │
│ │
▼ │
┌───────────┴───────┐
│ STUN サーバー │
└───────────────────┘
TRUNサーバーのイメージ
┌─────────────────────┐ ┌─────────────────────┐
│ ブラウザA │ ───────×──────── │ ブラウザB │
└───────┬─────────────┘ └────────────┬────────┘
│ │
┌─────────┐ ┌─────────┐
│ NAT │ │ NAT │
└─────────┘ └─────────┘
│ │
│ ┌────────────────────┐ │
└───────────── │ TRUNサーバー | ──────────┘
└────────────────────┘
→今回のハンズオンでは使用していませんが、下記のように書くことでSTUNサーバーを指定することも可能です
const pc = new RTCPeerConnection({
iceServers: [
{ urls: "stun:stun.l.google.com:19302" }, // STUN
{
urls: "turn:example.com:3478", // TURN
username: "user",
credential: "password"
}
],
});
参考にさせていただいた記事
https://qiita.com/okyk/items/a405f827e23cb9ef3bde
