シンプルなP2P通信をCloudflare Workersで作ってみる 【CFW P2P心理戦ゲーム開発記】 #8

ブラウザ同士がサーバーを介さずに直接データを送り合う「P2P(Peer-to-Peer)通信」。これをWebブラウザで実現する規格がWebRTCです。

WebRTCは、リアルタイムな対戦ゲームやチャットツールを作るのに最高の技術ですが、いざ実装しようとすると「SDP」や「ICE Candidate」「シグナリングサーバー」といった専門用語が一気に押し寄せ、挫折しがちな領域でもあります。

そこで今回は、連載の文脈をいったん脇に置き、「Cloudflare Workers(D1)をシグナリングに使用して、ブラウザの2つのタブ間で文字のキャッチボールができる最小構成のP2P環境(DataChannel)」を構築します。

この記事のコードを参考にして動かすだけで、WebRTCの基本構造が完璧に理解できるようになります!

CloudFlareを用いてWebRTCでリアルタイムチャット実装、P2Pの接続デモンストレーション

0. CloudFlare Workerが使える環境を整える。

P2Pの通信を実装する環境としてCloudFlare Workersがあります、手元で試してみたい場合は以下の記事を参考に環境構築してみてください。

cloud flare workersの始め方、アカウント作成からデプロイまで

Cloudflare Workersの始め方:Wranglerによるローカル環境構築と世界公開の手順

Cloudflare Workersの始め方をアカウント作成からローカル環境構築、デプロイまで徹底解説!Wranglerでのエミュレートや最新のAGENTS.md設定も網羅。リアルタイム対戦を可能にする高速P2P通信環境を手に入れる。

1. WebRTC P2P通信の全体像と「伝言板シグナリング」

WebRTCは最終的にブラウザ間(Peer to Peer)で直接通信を行いますが、「お互いの存在(IPアドレスなど)を知らない状態の2人が、どうやって最初に出会うか」という問題があります。

この最初の出会いを仲介するプロセスのことをシグナリングと呼び、仲介するサーバーをシグナリングサーバーと呼びます。

一般的にはWebSocketなどの双方向通信が使われますが、今回はコード量を極限まで減らし、動作を分かりやすくするため、CloudflareのD1データベースを「デジタル伝言板」として利用する「HTTPポーリング(問い合わせ)方式」を採用します。

1.1 シグナリングのコードを10分の1にする「Vanilla ICE」の術

WebRTCでは、自分の接続情報(SDP)を作ったあとに、通信経路の候補(ICE Candidate)がパラパラと非同期で何個も発生します。通常はこれが発生するたびにサーバーを介して相手に送りつける必要があります(Trickle ICE)。

しかし今回は、「経路候補が全部出揃うまで数秒待ってから、SDPの中に全部ひっくるめて1発で伝言板に書き込む」という『Vanilla ICE』という手法をとります。これにより、サーバー側のリレー処理が「1往復のPOST/GETだけ」になり、システム構築のハードルが劇的に下がります。


2. サーバー側(Cloudflare Workers / D1)の実装

まずは、伝言板となるデータベースのテーブルを定義し、お互いのSDPをリレーするためのエンドポイントを Workers に作成します。

2.1 伝言板テーブルの作成(SQL)

D1データベースに、オファー(部屋を立てた側)とアンサー(部屋に入った側)のSDPを一時保存するテーブルを作成します。

CREATE TABLE IF NOT EXISTS webrtc_rooms (
    room_id TEXT PRIMARY KEY,
    offer_sdp TEXT,
    answer_sdp TEXT,
    created_at TEXT DEFAULT (datetime('now', 'localtime'))
);

2.2 ハンドラーの実装(src/handlers/webrtcHandler.ts)

オファーの登録・取得、アンサーの登録・取得を行うシンプルなAPIです。

// WebRTC簡易シグナリング用ハンドラー

// 1. Offer側のSDPを伝言板に保存する (POST /api/webrtc/offer)
export async function handlePostOffer(request: Request, env: any): Promise<Response> {
  const { roomId, offerSdp } = await request.json() as any;
  await env.DB.prepare("INSERT OR REPLACE INTO webrtc_rooms (room_id, offer_sdp) VALUES (?, ?)")
    .bind(roomId, offerSdp).run();
  return Response.json({ success: true });
}

// 2. Answer側がOfferのSDPを取得する (GET /api/webrtc/offer?roomId=xxx)
export async function handleGetOffer(request: Request, env: any): Promise<Response> {
  const url = new URL(request.url);
  const roomId = url.searchParams.get("roomId");
  const room = await env.DB.prepare("SELECT offer_sdp FROM webrtc_rooms WHERE room_id = ?").bind(roomId).first() as any;
  return Response.json({ offerSdp: room?.offer_sdp || null });
}

// 3. Answer側のSDPを伝言板に保存する (POST /api/webrtc/answer)
export async function handlePostAnswer(request: Request, env: any): Promise<Response> {
  const { roomId, answerSdp } = await request.json() as any;
  await env.DB.prepare("UPDATE webrtc_rooms SET answer_sdp = ? WHERE room_id = ?")
    .bind(answerSdp, roomId).run();
  return Response.json({ success: true });
}

// 4. Offer側がAnswerのSDPが書き込まれるのを待つ (GET /api/webrtc/answer?roomId=xxx)
export async function handleGetAnswer(request: Request, env: any): Promise<Response> {
  const url = new URL(request.url);
  const roomId = url.searchParams.get("roomId");
  const room = await env.DB.prepare("SELECT answer_sdp FROM webrtc_rooms WHERE room_id = ?").bind(roomId).first() as any;
  return Response.json({ answerSdp: room?.answer_sdp || null });
}

2.3 ルーティングの追加(src/index.ts)

メインの fetch ハンドラに、上記のエンドポイントを追加します。ここでwebRTCのチャンネル追加のやり取りをします。

import { handlePostOffer, handleGetOffer, handlePostAnswer, handleGetAnswer } from "./handlers/webrtcHandler";


// fetchハンドラ内の分岐を追加
if (url.pathname === "/api/webrtc/offer" && request.method === "POST") return await handlePostOffer(request, env);
if (url.pathname === "/api/webrtc/offer" && request.method === "GET") return await handleGetOffer(request, env);
if (url.pathname === "/api/webrtc/answer" && request.method === "POST") return await handlePostAnswer(request, env);
if (url.pathname === "/api/webrtc/answer" && request.method === "GET") return await handleGetAnswer(request, env);

3. クライアント側(HTML / JavaScript)の実装

フロントエンドは、HTML1枚の中に「部屋を作る(Offer)側」と「部屋に入る(Answer)側」の両方のロジックを詰め込んだ、完全なスタンドアロンの検証用ファイルを作成しました。

プロジェクト内に public/webrtc-test.html というファイルを作り、接続テストしてみます。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>WebRTC P2P ミニマムテスト</title>
    <style>
        body { font-family: sans-serif; max-width: 600px; margin: 20px auto; padding: 0 10px; line-height: 1.6; }
        .box { border: 1px solid #ccc; padding: 15px; margin-bottom: 15px; border-radius: 5px; background: #fafafa; }
        input, button { padding: 8px; font-size: 14px; margin: 5px 0; }
        #chat { height: 150px; border: 1px solid #aaa; overflow-y: scroll; background: #fff; padding: 10px; }
    </style>
</head>
<body>

    <h2>WebRTC P2P データチャネル疎通テスト</h2>

    <div class="box">
        <label>ルームID: <input type="text" id="roomId" value="test-room-123"></label>
        <br>
        <button id="btnCreate">1. 部屋を作る (Offer側)</button>
        <button id="btnJoin">2. 部屋に入る (Answer側)</button>
    </div>

    <div class="box">
        <h3>ステータス: <span id="status">未接続</span></h3>
        <input type="text" id="msgInput" placeholder="メッセージを入力" disabled>
        <button id="btnSend" disabled>送信</button>
        <h4>通信ログ:</h4>
        <div id="chat"></div>
    </div>

    <script>
        const API_BASE = "/api/webrtc";
        let peerConnection = null;
        let dataChannel = null;

        const roomIdInput = document.getElementById("roomId");
        const statusEl = document.getElementById("status");
        const chatEl = document.getElementById("chat");
        const msgInput = document.getElementById("msgInput");
        const btnSend = document.getElementById("btnSend");

        // 共通:WebRTCの初期化設定
        const rtcConfig = { iceServers: [{ urls: "stun:stun.l.google.com:19302" }] };

        function log(msg) {
            chatEl.innerHTML += `<div>${msg}</div>`;
            chatEl.scrollTop = chatEl.scrollHeight;
        }

        // データチャネルのイベントハンドラを登録する共通関数
        function setupDataChannelEvents(channel) {
            channel.onopen = () => {
                statusEl.innerText = "P2P接続完了!";
                statusEl.style.color = "green";
                msgInput.disabled = false;
                btnSend.disabled = false;
                log("--- DataChannel が開通しました。サーバーを介さない直接通信が可能です ---");
            };
            channel.onmessage = (e) => log(`受け取り相手: ${e.data}`);
            channel.onclose = () => {
                statusEl.innerText = "切断されました";
                statusEl.style.color = "red";
            };
        }

        // ==========================================
        // 1. 部屋を作る側 (Offer) のロジック
        // ==========================================
        document.getElementById("btnCreate").onclick = async () => {
            const roomId = roomIdInput.value;
            statusEl.innerText = "Offer作成中・経路収集中...";
            
            peerConnection = new RTCPeerConnection(rtcConfig);
            
            // Offer側がDataChannelを自ら生成する
            dataChannel = peerConnection.createDataChannel("chat-channel");
            setupDataChannelEvents(dataChannel);

            // Vanilla ICE: 全てのICE Candidate(通信経路)が集まるのを待ってからPOSTする
            peerConnection.onicecandidate = async (event) => {
                if (event.candidate === null) { // null は収集完了のサイン
                    log("ICE経路収集完了。伝言板へOfferを書き込みます...");
                    await fetch(`${API_BASE}/offer`, {
                        method: "POST",
                        headers: { "Content-Type": "application/json" },
                        body: JSON.stringify({ roomId, offerSdp: JSON.stringify(peerConnection.localDescription) })
                    });
                    log("伝言板への書き込み完了。相手のAnswerを待っています(ポーリング中)...");
                    startPollingAnswer(roomId);
                }
            };

            const offer = await peerConnection.createOffer();
            await peerConnection.setLocalDescription(offer);
        };

        // Answerが書き込まれるまで5秒おきにチェックする(HTTPポーリング)
        function startPollingAnswer(roomId) {
            const timer = setInterval(async () => {
                const res = await fetch(`${API_BASE}/answer?roomId=${roomId}`);
                const { answerSdp } = await res.json();
                if (answerSdp) {
                    clearInterval(timer);
                    log("🏁 相手のAnswerを発見! 最終接続シーケンスを実行します。");
                    await peerConnection.setRemoteDescription(new RTCSessionDescription(JSON.parse(answerSdp)));
                }
            }, 3000);
        }

        // ==========================================
        // 2. 部屋に入る側 (Answer) のロジック
        // ==========================================
        document.getElementById("btnJoin").onclick = async () => {
            const roomId = roomIdInput.value;
            statusEl.innerText = "伝言板からOfferを読み込み中...";

            // 伝言板から相手のOfferを拾う
            const res = await fetch(`${API_BASE}/offer?roomId=${roomId}`);
            const { offerSdp } = await res.json();
            if (!offerSdp) {
                alert("指定されたルームにOfferがありません。先に部屋を作ってください。");
                return;
            }

            peerConnection = new RTCPeerConnection(rtcConfig);

            // Answer側は、相手から開かれるDataChannelを「受け取る」イベントをセットする
            peerConnection.ondatachannel = (event) => {
                dataChannel = event.channel;
                setupDataChannelEvents(dataChannel);
            };

            // Vanilla ICE: 経路収集が終わったらAnswerをPOST
            peerConnection.onicecandidate = async (event) => {
                if (event.candidate === null) {
                    log("ICE経路収集完了。伝言板へAnswerを書き込みます...");
                    await fetch(`${API_BASE}/answer`, {
                        method: "POST",
                        headers: { "Content-Type": "application/json" },
                        body: JSON.stringify({ roomId, answerSdp: JSON.stringify(peerConnection.localDescription) })
                    });
                    log("Answerを送信しました。接続が完了するまでお待ちください。");
                }
            };

            await peerConnection.setRemoteDescription(new RTCSessionDescription(JSON.parse(offerSdp)));
            const answer = await peerConnection.createAnswer();
            await peerConnection.setLocalDescription(answer);
        };

        // メッセージ送信処理
        btnSend.onclick = () => {
            const msg = msgInput.value;
            if (msg && dataChannel) {
                dataChannel.send(msg);
                log(`自分: ${msg}`);
                msgInput.value = "";
            }
        };
    </script>
</body>
</html>

4. 運命のP2P開通確認(検証手順)

P2Pテストの通信テストウィンドウ

ブラウザで2つ開いて

実際にブラウザを使ってP2P通信を走らせてみました。

  1. サーバー起動: npm run dev で Workers を立ち上げます。
  2. 画面を開く: ブラウザで、作成したHTMLページを「2つのタブ」(またはシークレットウィンドウなど)で同時に開きます。(ルームIDは両方とも共通のままにします)
  3. 部屋を作る(タブ1): 片方のタブで 「1. 部屋を作る (Offer側)」 ボタンを押します。ログに「相手のAnswerを待っています」と出ればOK。
  4. 部屋に入る(タブ2): もう片方のタブで 「2. 部屋に入る (Answer側)」 ボタンを押します。

ボタンを押して数秒待つと、お互いのステータス表示が同時にガラッと切り替わります。

「P2P接続完了! 」

P2Pで接続完了した例

接続完了後は、入力欄に好きな文字を打って送信してみると、 送信した瞬間に、もう一方のタブへ一瞬で文字が着信します。この文字の往来は、Cloudflareのサーバー(Workers)を一切介さず、ブラウザ同士が直接パケットを投げ合っている本物のP2P通信です!

こんにちは をp2pで送信してみた例

“こんにちは!”というテキストを左側のウィンドウから送信すると、ほぼ遅延なく右側のウィンドウで受け取れます。(同一pc内なので超高速ですが、別端末から接続しても同一Lan内なら同等の速度を出せますし、サーバと通信しないので、サーバを極端なくらい強化しない限り達成できない遅延ほぼ0を達成できます。)

webRTCはそれぞれ接続を確立できさえすればネットワークタブは静かなままになる

デベロッパーツールのnetworkタブを確認しても、いくらテキストを送りあっても最初の接続以降静かなままであることが確認できます。
webRTCの仕組みとして、始めに接続を確立さえしてしまえばwebrtc上で通信を行うという仕組みのためです。
ちなみに、確立されたwebRTCの生データを確認したい場合は、メッセージのやり取りをした状態で、ブラウザの新しいタブを開き、以下のURLを入力してみてください。

  • Edgeの場合: edge://webrtc-internals/
  • Chromeの場合: chrome://webrtc-internals/

これを開くと、現在裏側で動いているWebRTCのコネクション(生データ)がズラリと表示されます。

edgeでwebrtcの通信を確認する方法

edgeで確認した例


5. まとめ

いかがだったでしょうか? 本来なら大掛かりな双方向インフラが必要になるWebRTCのシグナリングですが、Cloudflare WorkersのD1を「伝言板」として活用し、さらに「Vanilla ICE」を組み合わせることで、信じられないほど短いシンプルなコードでP2PのDataChannelを開通させることができました。

個別流入でこの記事に辿り着いた方も、ぜひ手元の環境で「サーバーレス×P2P」の圧倒的な爆速レスポンスを体感してみてください。

関連記事

CloudFlare Workersセットアップ
Cloudflare Workersの始め方:Wranglerによるローカル環境構築と世界公開の手順
【第2回】データベースを作る
D1データベース作成とテスト環境構築の手順 【CFW P2P心理戦ゲーム開発記】 #2
【第3回】データベーススキーマ設計
D1データベーススキーマの設計:リレーショナルデータベースとは? 【CFW P2P心理戦ゲーム開発記】 #3
【第4回】ユーザデータベース実装
D1データベース実装:ユーザデータ管理 【CFW P2P心理戦ゲーム開発記】 #4

PR

もう一つの選択肢:VPSで自由なゲームサーバー構築

本連載ではCloudflare Workersを活用したP2P実装を進めていますが、もし環境の制約がなく、
「もっと使い慣れた言語で自由にゲームサーバーを立てたい」
「WebSocketなどの常駐プロセスをガッツリ回したい」

という場合は、VPS上に独自のシグナリングサーバーを構築するのも強力な正攻法です。

「テキストを読んで知識として知っていること」と、「実際に手元でLinuxサーバー(Ubuntuなど)を叩いて構築した経験」とでは、バックエンドへの理解の深さに大きな差が生まれます。

最近の海外サービスは「最初は無料・格安で普及させ、定着したタイミングで一気に値上げや制限強化に踏み切る」という戦略が多く、個人開発での新規参入や継続運用のハードルが高くなりがちです。

その点、日本の老舗である さくらのインターネット(さくらのVPS) は価格面でも運用の面でも圧倒的な安定感があり、個人的にとても信頼して推しています。

「海外サービスの急な仕様変更に振り回されたくない」「自分のインフラ拠点を国内に1つ持っておきたい」という方は、ぜひ一度触ってみてください!

連載の次なるステップ(開発記へ戻る)

ゲーム開発連載としての次回(第9回)からは、今回開通した「P2Pのピュアな土管」の上に、フェーズ2の「ゲーム画面(UIのモック)とゲーム進行の内部状態(ステート)の設計」を乗せていきます。
今回省略したD1のデータベースのクリーンアップ(使われていない部屋レコードの掃除)なども行っていこうと思います。

ターン制御や、実弾・空砲の管理ロジックなど、いよいよゲームの脳みそを作っていきますのでどうぞお楽しみに!

次回:関連記事は、2026年6月18日に公開予定 (あと21時間)

ここまで読んでいただきありがとうございます。 では、次の記事で。 lumenHero