T-CREATOR

WebRTC でビデオチャットアプリを作る手順【初心者向け】

WebRTC でビデオチャットアプリを作る手順【初心者向け】

現代の Web アプリケーション開発において、リアルタイム通信の需要は急速に高まっています。オンライン会議、遠隔教育、オンライン診療など、様々な場面でビデオチャット機能が求められており、多くの開発者がこの技術を習得したいと考えているのではないでしょうか。

WebRTC(Web Real-Time Communication)は、ブラウザ同士で直接音声・映像通信を実現できる革新的な技術です。複雑なサーバー構成や高額なライセンス料が不要で、比較的簡単にビデオチャット機能を実装できることから、個人開発者からエンタープライズまで幅広く採用されています。

本記事では、WebRTC を使ったビデオチャットアプリの開発手順を、初心者にもわかりやすく段階的に解説いたします。TypeScript と Next.js を用いた実践的なサンプルコードを交えながら、環境構築から実際の通信機能実装まで、一歩ずつ着実に進めていきましょう。

背景

WebRTC とは何か

WebRTC は、Web ブラウザ間でリアルタイムにメディア(音声・映像)やデータをやり取りできるオープンソース技術です。従来のビデオ通話システムでは、専用のソフトウェアや複雑なサーバー構成が必要でしたが、WebRTC ではブラウザの標準機能として搭載されており、JavaScript から簡単にアクセスできます。

WebRTC の最大の特徴は「P2P(Peer-to-Peer)通信」です。一般的な Web アプリケーションでは、クライアント同士の通信にサーバーを経由しますが、WebRTC では接続確立後はブラウザ同士が直接通信します。これにより、低遅延でのリアルタイム通信が実現できるのです。

以下の図は、WebRTC の基本的な通信フローを示しています。

mermaidsequenceDiagram
    participant A as ブラウザA
    participant S as シグナリングサーバー
    participant B as ブラウザB

    A->>S: 通話開始リクエスト
    S->>B: 通話着信通知
    B->>S: 通話応答
    A->>S: Offer (SDP)
    S->>B: Offer 転送
    B->>S: Answer (SDP)
    S->>A: Answer 転送
    A->>B: ICE候補交換
    B->>A: ICE候補交換
    Note over A,B: P2P通信確立
    A->>B: 音声・映像データ
    B->>A: 音声・映像データ

補足:シグナリングサーバーは接続確立のための情報交換のみを担い、実際のメディアデータは直接やり取りされます。

WebRTC が選ばれる理由

現在多くの企業が WebRTC を採用する理由は、その技術的優位性にあります。

  1. 標準技術としての安定性 - W3C の標準仕様として策定されており、主要ブラウザで安定した動作が保証されています

  2. 開発コストの削減 - 専用プラグインやソフトウェアが不要で、Web ブラウザのみで動作するため、開発・保守コストを大幅に削減できます

  3. セキュリティの確保 - 通信内容は自動的に暗号化され、DTLS(Datagram Transport Layer Security)により高いセキュリティを保持します

  4. スケーラビリティ - P2P 通信により、サーバーの負荷を軽減でき、同時接続数の制限を受けにくい設計です

比較項目WebRTC従来のビデオ通話システム
導入コスト低(標準技術)高(専用ソフト・ライセンス)
開発期間短(数週間)長(数ヶ月以上)
ブラウザ対応標準対応プラグイン必要
サーバー負荷低(P2P 通信)高(中継通信)
セキュリティ自動暗号化別途設定必要

これらの特徴により、スタートアップから大企業まで、幅広い組織で WebRTC が採用されているのです。

課題

WebRTC 開発における技術的ハードル

WebRTC は強力な技術ですが、初心者が開発を始める際にはいくつかの技術的課題に直面します。これらの課題を事前に理解しておくことで、効率的な開発が可能になります。

複雑な接続プロセス

WebRTC の最大の難しさは、P2P 接続を確立するまでの複雑なプロセスです。単純な HTTP 通信とは異なり、以下のような多段階の処理が必要になります。

  1. メディアデバイスアクセス - ユーザーのカメラ・マイクへの許可取得
  2. シグナリング - 接続相手との情報交換
  3. ICE 候補収集 - ネットワーク経路の探索
  4. セキュアな接続確立 - 暗号化通信の開始

以下の図は、WebRTC 接続確立の複雑さを表現しています。

mermaidstateDiagram-v2
    [*] --> MediaAccess: getUserMedia()
    MediaAccess --> PeerConnection: RTCPeerConnection作成
    PeerConnection --> CreateOffer: createOffer()
    CreateOffer --> SetLocalDesc: setLocalDescription()
    SetLocalDesc --> SignalOffer: シグナリングサーバーへ送信
    SignalOffer --> WaitAnswer: 相手からの応答待ち
    WaitAnswer --> SetRemoteDesc: setRemoteDescription()
    SetRemoteDesc --> ICEGathering: ICE候補収集
    ICEGathering --> Connected: P2P接続確立
    Connected --> [*]: 通信開始

    MediaAccess --> [*]: アクセス拒否
    ICEGathering --> [*]: 接続失敗

補足:各ステップでエラーハンドリングが必要で、ネットワーク環境によっては接続に失敗する場合もあります。

ネットワークとファイアウォールの問題

企業環境や一般家庭のネットワークでは、ファイアウォールや NAT(Network Address Translation)により、直接的な P2P 通信が制限される場合があります。

主なネットワーク課題:

  • NAT トラバーサル問題
  • 対称型 NAT での接続困難
  • 企業ファイアウォールによるポート制限
  • IPv6 対応の複雑さ

これらの問題を解決するために、STUN(Session Traversal Utilities for NAT)サーバーや TURN(Traversal Using Relays around NAT)サーバーの設定が必要になる場合があります。

ブラウザ間の互換性問題

WebRTC は標準仕様ですが、ブラウザごとに微妙な実装差があり、完全な互換性を保つのは容易ではありません。

主要な互換性課題:

  • API の呼び出し方法の違い
  • サポートするコーデックの差異
  • モバイルブラウザでの制限
  • 古いブラウザでのフォールバック対応

リアルタイム通信特有の課題

リアルタイム通信では、従来の Web 開発とは異なる課題に対処する必要があります。

パフォーマンス関連の課題:

  • 音声・映像の同期問題
  • ネットワーク帯域の動的調整
  • CPU 使用率の最適化
  • メモリリークの防止

これらの課題を解決しながら、安定したビデオチャットアプリを開発するための具体的な手法を、次の章で詳しく解説いたします。

解決策

WebRTC を用いた段階的アプローチ

前章で挙げた課題を解決するために、本記事では段階的実装アプローチを採用します。複雑な WebRTC 開発を 5 つのステップに分割し、各段階で確実に動作確認を行いながら進めることで、エラーの原因を特定しやすく、学習効果も高められます。

解決策の全体像

WebRTC 開発における課題解決のために、以下の戦略を採用します。

  1. モジュラー設計 - 機能ごとに独立したモジュールに分割
  2. エラーハンドリングの徹底 - 各段階でのエラー処理を明確化
  3. プログレッシブエンハンスメント - 基本機能から高度な機能へ段階的に拡張
  4. クロスブラウザ対応 - 主要ブラウザでの動作保証

以下の図は、段階的実装アプローチの全体構成を示しています。

mermaidflowchart TD
    Start[開発開始] --> Step1[Step1: 環境構築]
    Step1 --> Step2[Step2: 基本WebRTC接続]
    Step2 --> Step3[Step3: シグナリングサーバー]
    Step3 --> Step4[Step4: P2P接続確立]
    Step4 --> Step5[Step5: UI実装]
    Step5 --> Complete[完成]

    Step1 --> Test1[動作確認: プロジェクト起動]
    Step2 --> Test2[動作確認: カメラアクセス]
    Step3 --> Test3[動作確認: サーバー通信]
    Step4 --> Test4[動作確認: P2P通信]
    Step5 --> Test5[動作確認: 完全機能]

    style Start fill:#e1f5fe
    style Complete fill:#c8e6c9
    style Test1,Test2,Test3,Test4,Test5 fill:#fff3e0

補足:各ステップで動作確認を行うことで、問題の早期発見と解決が可能になります。

技術スタックの選定

開発効率と保守性を重視し、以下の技術スタックを採用します。

フロントエンド技術:

  • Next.js 14 - React 基盤のフルスタックフレームワーク
  • TypeScript - 型安全性によるバグ削減
  • Socket.io Client - リアルタイム通信ライブラリ

バックエンド技術:

  • Node.js - JavaScript ランタイム環境
  • Express.js - Web アプリケーションフレームワーク
  • Socket.io - WebSocket ベースの双方向通信

開発支援ツール:

  • Yarn - パッケージマネージャー
  • ESLint - コード品質管理
  • Prettier - コードフォーマッター
技術要素選定理由代替技術
Next.jsSSR 対応、開発体験が良好Create React App、Vite
TypeScript型安全性、保守性向上JavaScript
Socket.io実装が簡単、フォールバック対応WebSocket API、SockJS
Yarn依存関係管理が高速npm、pnpm

エラーハンドリング戦略

WebRTC 開発で頻発するエラーに対し、予防的なエラーハンドリング戦略を実装します。

段階別エラー対応:

  1. メディアアクセスエラー - カメラ・マイクの権限取得失敗
  2. 接続エラー - ネットワーク問題、シグナリング失敗
  3. 通信エラー - P2P 接続の中断、品質劣化
  4. ブラウザ互換性エラー - API 未対応、機能制限

これらの戦略を実装することで、堅牢で拡張性の高いビデオチャットアプリケーションを構築できます。次章では、具体的な実装手順を詳しく解説いたします。

具体例

環境構築とプロジェクト初期設定

まずは開発環境を整備し、プロジェクトの土台を作成します。この段階では、必要なツールのインストールとプロジェクト構造の構築を行います。

Node.js と Yarn のインストール

WebRTC アプリ開発には、Node.js 環境が必要です。公式サイトから最新の LTS 版をダウンロードしてインストールしましょう。

bash# Node.jsバージョン確認
node --version
# v20.10.0 以上であることを確認

# Yarnのインストール(npmを使用)
npm install -g yarn

# Yarnバージョン確認
yarn --version
# 1.22.0 以上であることを確認

Node.js がインストールできたら、Yarn パッケージマネージャーをグローバルに追加します。Yarn は npm よりも高速で、依存関係の管理が優れています。

プロジェクトの初期化と基本構造作成

Next.js プロジェクトを作成し、WebRTC アプリに必要な基本構造を構築します。

bash# Next.jsプロジェクト作成(TypeScript対応)
yarn create next-app webrtc-video-chat --typescript --tailwind --eslint --app

# プロジェクトディレクトリに移動
cd webrtc-video-chat

作成されたプロジェクトには、Next.js 14 の App Router が採用され、TypeScript と Tailwind CSS が事前設定されています。

次に、WebRTC 開発に必要な追加パッケージをインストールします。

bash# WebRTC関連パッケージのインストール
yarn add socket.io-client @types/socket.io-client

# バックエンド用パッケージのインストール
yarn add express socket.io cors
yarn add -D @types/express @types/cors nodemon

Socket.io は双方向通信を簡単に実装できるライブラリで、WebRTC のシグナリングサーバーとして使用します。

プロジェクトフォルダ構造の整備

効率的な開発のために、以下のフォルダ構造を作成しましょう。

pythonwebrtc-video-chat/
├── src/
│   ├── app/                 # Next.js App Router
│   │   ├── page.tsx        # メインページ
│   │   └── layout.tsx      # レイアウトコンポーネント
│   ├── components/         # Reactコンポーネント
│   │   ├── VideoCall.tsx   # ビデオ通話コンポーネント
│   │   └── Controls.tsx    # 通話制御コンポーネント
│   ├── hooks/              # カスタムフック
│   │   └── useWebRTC.ts    # WebRTC操作フック
│   ├── lib/                # ユーティリティ
│   │   └── webrtc.ts       # WebRTC設定
│   └── types/              # TypeScript型定義
│       └── webrtc.d.ts     # WebRTC関連型
├── server/                 # Express.jsサーバー
│   └── index.js           # シグナリングサーバー
├── package.json
└── next.config.js

この構造により、フロントエンドとバックエンドのコードが明確に分離され、保守性が向上します。

package.json の設定

開発効率を向上させるために、package.json にカスタムスクリプトを追加します。

json{
  "name": "webrtc-video-chat",
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "server": "nodemon server/index.js",
    "dev:all": "concurrently \"yarn server\" \"yarn dev\""
  }
}

concurrentlyパッケージを追加して、フロントエンドとバックエンドを同時に起動できるようにします。

bash# 開発用パッケージの追加
yarn add -D concurrently

これで基本的な環境構築が完了しました。次のステップでは、実際に WebRTC の基本機能を実装していきます。

基本的な WebRTC 接続の実装

WebRTC の核となるメディアアクセスとピア接続の基本機能を実装します。この段階では、ユーザーのカメラとマイクにアクセスし、基本的な RTCPeerConnection を設定します。

getUserMedia でメディアデバイスアクセス

最初に、ユーザーのカメラとマイクにアクセスするためのコンポーネントを作成します。

typescript// src/components/VideoCall.tsx
'use client';

import { useEffect, useRef, useState } from 'react';

interface VideoCallProps {
  roomId: string;
}

export default function VideoCall({
  roomId,
}: VideoCallProps) {
  const localVideoRef = useRef<HTMLVideoElement>(null);
  const remoteVideoRef = useRef<HTMLVideoElement>(null);
  const [isConnected, setIsConnected] = useState(false);
  const [mediaStream, setMediaStream] =
    useState<MediaStream | null>(null);

  useEffect(() => {
    initializeMedia();
  }, []);

  const initializeMedia = async () => {
    try {
      // カメラとマイクへのアクセス要求
      const stream =
        await navigator.mediaDevices.getUserMedia({
          video: {
            width: { ideal: 1280 },
            height: { ideal: 720 },
            frameRate: { ideal: 30 },
          },
          audio: {
            echoCancellation: true,
            noiseSuppression: true,
            autoGainControl: true,
          },
        });

      setMediaStream(stream);

      // ローカル動画要素に映像を表示
      if (localVideoRef.current) {
        localVideoRef.current.srcObject = stream;
      }

      console.log(
        'メディアアクセス成功:',
        stream.getTracks()
      );
    } catch (error) {
      console.error('メディアアクセスエラー:', error);
      handleMediaError(error);
    }
  };

  return (
    <div className='flex flex-col items-center p-4'>
      <h2 className='text-2xl font-bold mb-4'>
        ビデオチャット - Room: {roomId}
      </h2>

      <div className='grid grid-cols-1 md:grid-cols-2 gap-4 w-full max-w-4xl'>
        {/* ローカル映像表示 */}
        <div className='relative'>
          <video
            ref={localVideoRef}
            autoPlay
            muted
            playsInline
            className='w-full h-64 bg-gray-900 rounded-lg'
          />
          <span className='absolute bottom-2 left-2 bg-blue-600 text-white px-2 py-1 rounded text-sm'>
            あなた
          </span>
        </div>

        {/* リモート映像表示 */}
        <div className='relative'>
          <video
            ref={remoteVideoRef}
            autoPlay
            playsInline
            className='w-full h-64 bg-gray-900 rounded-lg'
          />
          <span className='absolute bottom-2 left-2 bg-green-600 text-white px-2 py-1 rounded text-sm'>
            相手
          </span>
        </div>
      </div>

      <div className='mt-4 text-center'>
        <p className='text-sm text-gray-600'>
          接続状態: {isConnected ? '接続中' : '待機中'}
        </p>
      </div>
    </div>
  );
}

このコンポーネントは、ページ読み込み時に自動的にカメラとマイクへのアクセスを要求します。getUserMedia のオプションで、映像品質や音声処理の設定も行っています。

メディアアクセスエラーのハンドリング

ユーザーがカメラアクセスを拒否した場合や、デバイスが利用できない場合のエラー処理を実装します。

typescript// src/components/VideoCall.tsx に追加

const handleMediaError = (error: unknown) => {
  if (error instanceof Error) {
    switch (error.name) {
      case 'NotAllowedError':
        alert('カメラとマイクのアクセスが拒否されました。ブラウザの設定を確認してください。');
        break;
      case 'NotFoundError':
        alert('カメラまたはマイクが見つかりません。デバイスを確認してください。');
        break;
      case 'NotReadableError':
        alert('カメラまたはマイクが他のアプリケーションで使用されている可能性があります。');
        break;
      case 'OverconstrainedError':
        alert('指定された制約を満たすカメラが見つかりません。');
        break;
      default:
        alert(`メディアアクセスエラー: ${error.message}`);
    }
  }
};
```

WebRTCでよく発生するエラーパターンに対して、ユーザーにわかりやすいメッセージを表示します。

### RTCPeerConnectionの基本設定

次に、WebRTC通信の核となるRTCPeerConnectionを設定します。

````typescript
// src/lib/webrtc.ts
export class WebRTCManager {
  private peerConnection: RTCPeerConnection | null = null;
  private localStream: MediaStream | null = null;

  // ICEサーバー設定(STUN/TURNサーバー)
  private configuration: RTCConfiguration = {
    iceServers: [
      {
        urls: [
          'stun:stun.l.google.com:19302',
          'stun:stun1.l.google.com:19302',
        ]
      }
    ],
    iceCandidatePoolSize: 10,
  };

  constructor() {
    this.initialize();
  }

  private initialize() {
    try {
      this.peerConnection = new RTCPeerConnection(this.configuration);
      this.setupPeerConnectionListeners();
      console.log('RTCPeerConnection作成成功');
    } catch (error) {
      console.error('RTCPeerConnection作成エラー:', error);
    }
  }

  private setupPeerConnectionListeners() {
    if (!this.peerConnection) return;

    // ICE候補が見つかった時の処理
    this.peerConnection.onicecandidate = (event) => {
      if (event.candidate) {
        console.log('ICE候補発見:', event.candidate);
        // シグナリングサーバーに送信(次ステップで実装)
      }
    };

    // リモートストリームを受信した時の処理
    this.peerConnection.ontrack = (event) => {
      console.log('リモートストリーム受信:', event.streams);
      // リモート映像の表示処理(次ステップで実装)
    };

    // 接続状態変更の監視
    this.peerConnection.onconnectionstatechange = () => {
      console.log('接続状態:', this.peerConnection?.connectionState);
    };
  }

  // ローカルストリームの追加
  addLocalStream(stream: MediaStream) {
    this.localStream = stream;
    if (this.peerConnection) {
      stream.getTracks().forEach(track => {
        this.peerConnection!.addTrack(track, stream);
      });
      console.log('ローカルストリーム追加完了');
    }
  }
}

RTCPeerConnection は、WebRTC 通信の中核となるオブジェクトです。ICE サーバーの設定により、NAT 越えの接続が可能になります。

この段階で、カメラアクセスと基本的な PeerConnection 設定が完了しました。次のステップでは、シグナリングサーバーを構築して、実際の通信開始に必要な情報交換を実装します。

シグナリングサーバーの構築

WebRTC で P2P 接続を確立するには、事前にブラウザ間で接続情報を交換する必要があります。この情報交換プロセスを「シグナリング」と呼び、専用のサーバーを構築して実現します。

Express.js と Socket.io によるサーバー構築

まず、リアルタイム通信を処理するシグナリングサーバーを作成します。

javascript// server/index.js
const express = require('express');
const { Server } = require('socket.io');
const http = require('http');
const cors = require('cors');

const app = express();
const server = http.createServer(app);

// CORS設定でクロスオリジン通信を許可
app.use(
  cors({
    origin: 'http://localhost:3000',
    credentials: true,
  })
);

// Socket.ioサーバーの初期化
const io = new Server(server, {
  cors: {
    origin: 'http://localhost:3000',
    methods: ['GET', 'POST'],
    credentials: true,
  },
});

// 接続中のユーザー管理
const connectedUsers = new Map();
const rooms = new Map();

console.log('シグナリングサーバー開始中...');

io.on('connection', (socket) => {
  console.log(`ユーザー接続: ${socket.id}`);

  // ルーム参加処理
  socket.on('join-room', (roomId) => {
    console.log(
      `ユーザー ${socket.id} がルーム ${roomId} に参加`
    );

    socket.join(roomId);
    connectedUsers.set(socket.id, { roomId, socket });

    // ルーム内の他のユーザーに新規参加を通知
    socket.to(roomId).emit('user-joined', socket.id);

    // 現在のルーム参加者数を送信
    const roomUsers = getRoomUsers(roomId);
    io.to(roomId).emit('room-users', roomUsers);
  });
});

const PORT = process.env.PORT || 3001;
server.listen(PORT, () => {
  console.log(
    `シグナリングサーバー起動: http://localhost:${PORT}`
  );
});

このサーバーは、WebRTC のシグナリングプロセスを管理し、クライアント間の情報交換を仲介します。

オファー・アンサー交換の実装

WebRTC 接続確立に必要な SDP(Session Description Protocol)の交換処理を実装します。

javascript// server/index.js に追加

// オファー(接続開始)の処理
socket.on('offer', (data) => {
  console.log(
    `オファー受信: ${socket.id} -> ${data.target}`
  );

  // 指定された相手にオファーを転送
  socket.to(data.target).emit('offer', {
    sdp: data.sdp,
    sender: socket.id,
  });
});

// アンサー(接続応答)の処理
socket.on('answer', (data) => {
  console.log(
    `アンサー受信: ${socket.id} -> ${data.target}`
  );

  // 指定された相手にアンサーを転送
  socket.to(data.target).emit('answer', {
    sdp: data.sdp,
    sender: socket.id,
  });
});

// ICE候補の交換処理
socket.on('ice-candidate', (data) => {
  console.log(
    `ICE候補受信: ${socket.id} -> ${data.target}`
  );

  // 指定された相手にICE候補を転送
  socket.to(data.target).emit('ice-candidate', {
    candidate: data.candidate,
    sender: socket.id,
  });
});

// 切断処理
socket.on('disconnect', () => {
  console.log(`ユーザー切断: ${socket.id}`);

  const user = connectedUsers.get(socket.id);
  if (user) {
    // 同じルームの他のユーザーに切断を通知
    socket
      .to(user.roomId)
      .emit('user-disconnected', socket.id);
    connectedUsers.delete(socket.id);
  }
});

// ルーム内のユーザー一覧取得
function getRoomUsers(roomId) {
  const users = [];
  connectedUsers.forEach((user, socketId) => {
    if (user.roomId === roomId) {
      users.push(socketId);
    }
  });
  return users;
}

シグナリングサーバーは、WebRTC の接続確立に必要な 3 つの重要な情報を交換します:オファー、アンサー、ICE 候補です。

フロントエンドでの Socket.io 接続

次に、フロントエンド側でシグナリングサーバーに接続する処理を実装します。

typescript// src/hooks/useWebRTC.ts
'use client';

import { useEffect, useRef, useState } from 'react';
import { io, Socket } from 'socket.io-client';

export const useWebRTC = (roomId: string) => {
  const [isConnected, setIsConnected] = useState(false);
  const [remoteStream, setRemoteStream] =
    useState<MediaStream | null>(null);
  const socketRef = useRef<Socket | null>(null);
  const peerConnectionRef =
    useRef<RTCPeerConnection | null>(null);
  const localStreamRef = useRef<MediaStream | null>(null);

  useEffect(() => {
    initializeSocket();
    initializePeerConnection();

    return () => {
      cleanup();
    };
  }, [roomId]);

  const initializeSocket = () => {
    // シグナリングサーバーへの接続
    socketRef.current = io('http://localhost:3001');

    socketRef.current.on('connect', () => {
      console.log('シグナリングサーバーに接続');
      socketRef.current?.emit('join-room', roomId);
    });

    // 新しいユーザー参加の通知を受信
    socketRef.current.on(
      'user-joined',
      async (userId: string) => {
        console.log('新しいユーザーが参加:', userId);
        await createOffer(userId);
      }
    );

    // オファー受信時の処理
    socketRef.current.on('offer', async (data) => {
      console.log('オファー受信:', data.sender);
      await handleOffer(data.sdp, data.sender);
    });

    // アンサー受信時の処理
    socketRef.current.on('answer', async (data) => {
      console.log('アンサー受信:', data.sender);
      await handleAnswer(data.sdp);
    });

    // ICE候補受信時の処理
    socketRef.current.on('ice-candidate', async (data) => {
      console.log('ICE候補受信:', data.sender);
      await handleIceCandidate(data.candidate);
    });
  };

  const cleanup = () => {
    socketRef.current?.disconnect();
    peerConnectionRef.current?.close();
  };

  return {
    isConnected,
    remoteStream,
    // 他の必要な関数もexport
  };
};

このカスタムフックにより、WebRTC の複雑な処理を React コンポーネントから分離し、再利用可能な形で管理できます。

シグナリングサーバーの構築により、ブラウザ間での情報交換が可能になりました。次のステップでは、実際の P2P 接続確立処理を実装します。

P2P 接続の確立

前ステップで構築したシグナリングサーバーを使用して、実際にブラウザ間の P2P 接続を確立します。この段階では、オファー・アンサーの作成、ICE 候補の交換、メディアストリームの送受信を実装します。

オファーとアンサーの作成処理

WebRTC 接続の開始者(オファー側)と受信者(アンサー側)の処理を実装します。

typescript// src/hooks/useWebRTC.ts に追加

const initializePeerConnection = () => {
  const configuration: RTCConfiguration = {
    iceServers: [
      { urls: 'stun:stun.l.google.com:19302' },
      { urls: 'stun:stun1.l.google.com:19302' },
    ],
  };

  peerConnectionRef.current = new RTCPeerConnection(
    configuration
  );

  // ICE候補が見つかった時の処理
  peerConnectionRef.current.onicecandidate = (event) => {
    if (event.candidate && socketRef.current) {
      console.log('ICE候補送信:', event.candidate);
      socketRef.current.emit('ice-candidate', {
        candidate: event.candidate,
        target: remoteUserIdRef.current,
      });
    }
  };

  // リモートストリーム受信時の処理
  peerConnectionRef.current.ontrack = (event) => {
    console.log('リモートストリーム受信');
    setRemoteStream(event.streams[0]);
    setIsConnected(true);
  };

  // 接続状態の監視
  peerConnectionRef.current.onconnectionstatechange =
    () => {
      const state =
        peerConnectionRef.current?.connectionState;
      console.log('接続状態変更:', state);

      if (state === 'connected') {
        setIsConnected(true);
      } else if (
        state === 'disconnected' ||
        state === 'failed'
      ) {
        setIsConnected(false);
      }
    };
};

// オファーの作成と送信
const createOffer = async (targetUserId: string) => {
  try {
    if (
      !peerConnectionRef.current ||
      !localStreamRef.current
    ) {
      throw new Error(
        'PeerConnection または LocalStream が初期化されていません'
      );
    }

    remoteUserIdRef.current = targetUserId;

    // ローカルストリームをPeerConnectionに追加
    localStreamRef.current.getTracks().forEach((track) => {
      peerConnectionRef.current!.addTrack(
        track,
        localStreamRef.current!
      );
    });

    // オファーの作成
    const offer =
      await peerConnectionRef.current.createOffer({
        offerToReceiveAudio: true,
        offerToReceiveVideo: true,
      });

    // ローカル記述の設定
    await peerConnectionRef.current.setLocalDescription(
      offer
    );

    console.log('オファー作成完了:', offer);

    // シグナリングサーバー経由でオファーを送信
    socketRef.current?.emit('offer', {
      sdp: offer,
      target: targetUserId,
    });
  } catch (error) {
    console.error('オファー作成エラー:', error);
  }
};

オファーは、WebRTC 接続を開始する側が作成し、相手に送信する接続情報です。メディアの種類や通信パラメータが含まれています。

オファー受信とアンサー作成

オファーを受信した側のアンサー作成処理を実装します。

typescript// src/hooks/useWebRTC.ts に追加

// オファー受信時の処理
const handleOffer = async (
  offer: RTCSessionDescriptionInit,
  senderId: string
) => {
  try {
    if (
      !peerConnectionRef.current ||
      !localStreamRef.current
    ) {
      throw new Error(
        'PeerConnection または LocalStream が初期化されていません'
      );
    }

    remoteUserIdRef.current = senderId;

    // ローカルストリームをPeerConnectionに追加
    localStreamRef.current.getTracks().forEach((track) => {
      peerConnectionRef.current!.addTrack(
        track,
        localStreamRef.current!
      );
    });

    // リモート記述の設定
    await peerConnectionRef.current.setRemoteDescription(
      offer
    );

    console.log('オファー受信完了:', offer);

    // アンサーの作成
    const answer =
      await peerConnectionRef.current.createAnswer();

    // ローカル記述の設定
    await peerConnectionRef.current.setLocalDescription(
      answer
    );

    console.log('アンサー作成完了:', answer);

    // シグナリングサーバー経由でアンサーを送信
    socketRef.current?.emit('answer', {
      sdp: answer,
      target: senderId,
    });
  } catch (error) {
    console.error('オファー処理エラー:', error);
  }
};

// アンサー受信時の処理
const handleAnswer = async (
  answer: RTCSessionDescriptionInit
) => {
  try {
    if (!peerConnectionRef.current) {
      throw new Error(
        'PeerConnection が初期化されていません'
      );
    }

    // リモート記述の設定
    await peerConnectionRef.current.setRemoteDescription(
      answer
    );

    console.log('アンサー受信完了:', answer);
  } catch (error) {
    console.error('アンサー処理エラー:', error);
  }
};

アンサーは、オファーを受信した側が作成し、送信者に返す応答情報です。これにより、双方の通信パラメータが決定されます。

ICE 候補の交換とネットワーク最適化

NAT 越えのために必要な ICE 候補の交換処理を実装します。

typescript// src/hooks/useWebRTC.ts に追加

// ICE候補受信時の処理
const handleIceCandidate = async (
  candidate: RTCIceCandidateInit
) => {
  try {
    if (!peerConnectionRef.current) {
      throw new Error(
        'PeerConnection が初期化されていません'
      );
    }

    await peerConnectionRef.current.addIceCandidate(
      candidate
    );
    console.log('ICE候補追加完了:', candidate);
  } catch (error) {
    console.error('ICE候補処理エラー:', error);
  }
};

// ローカルストリームの設定
const setLocalStream = async (stream: MediaStream) => {
  localStreamRef.current = stream;

  if (peerConnectionRef.current) {
    // 既存のトラックを削除
    peerConnectionRef.current
      .getSenders()
      .forEach((sender) => {
        peerConnectionRef.current!.removeTrack(sender);
      });

    // 新しいトラックを追加
    stream.getTracks().forEach((track) => {
      peerConnectionRef.current!.addTrack(track, stream);
    });

    console.log('ローカルストリーム設定完了');
  }
};

ICE 候補は、実際のメディアデータを送受信するためのネットワークパスです。複数の候補の中から最適なものが自動選択されます。

以下の図は、P2P 接続確立プロセスの流れを示しています。

mermaidsequenceDiagram
    participant A as ブラウザA
    participant S as シグナリングサーバー
    participant B as ブラウザB

    A->>A: getUserMedia()
    B->>B: getUserMedia()

    A->>A: RTCPeerConnection作成
    B->>B: RTCPeerConnection作成

    A->>S: join-room
    B->>S: join-room

    S->>A: user-joined (BのID)

    A->>A: createOffer()
    A->>S: offer送信
    S->>B: offer転送

    B->>B: setRemoteDescription(offer)
    B->>B: createAnswer()
    B->>S: answer送信
    S->>A: answer転送

    A->>A: setRemoteDescription(answer)

    A->>S: ICE候補送信
    S->>B: ICE候補転送
    B->>S: ICE候補送信
    S->>A: ICE候補転送

    Note over A,B: P2P接続確立
    A->>B: メディアストリーム送信
    B->>A: メディアストリーム送信

補足:ICE 候補の交換は複数回行われ、最適なネットワークパスが選択されます。

これで P2P 接続の確立処理が完了しました。次のステップでは、ユーザーが操作しやすい UI を実装し、通話制御機能を追加します。

UI の実装とユーザー体験の向上

最後のステップとして、ユーザーが直感的に操作できる UI と、ミュート・カメラ ON/OFF などの通話制御機能を実装します。React/Next.js と Tailwind CSS を使用して、モダンで使いやすいインターフェースを構築します。

メインページコンポーネントの実装

ビデオチャットの全体画面を管理するメインコンポーネントを作成します。

typescript// src/app/page.tsx
'use client';

import { useState } from 'react';
import VideoCall from '@/components/VideoCall';

export default function Home() {
  const [roomId, setRoomId] = useState('');
  const [isJoined, setIsJoined] = useState(false);

  const handleJoinRoom = (e: React.FormEvent) => {
    e.preventDefault();
    if (roomId.trim()) {
      setIsJoined(true);
    }
  };

  if (isJoined) {
    return <VideoCall roomId={roomId} />;
  }

  return (
    <div className='min-h-screen bg-gradient-to-br from-blue-900 to-purple-900 flex items-center justify-center p-4'>
      <div className='bg-white rounded-xl shadow-2xl p-8 max-w-md w-full'>
        <h1 className='text-3xl font-bold text-center mb-8 text-gray-800'>
          WebRTC ビデオチャット
        </h1>

        <form
          onSubmit={handleJoinRoom}
          className='space-y-6'
        >
          <div>
            <label className='block text-sm font-medium text-gray-700 mb-2'>
              ルームID
            </label>
            <input
              type='text'
              value={roomId}
              onChange={(e) => setRoomId(e.target.value)}
              placeholder='ルームIDを入力してください'
              className='w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent'
              required
            />
          </div>

          <button
            type='submit'
            className='w-full bg-blue-600 text-white py-3 px-6 rounded-lg hover:bg-blue-700 transition duration-200 font-medium'
          >
            ルームに参加
          </button>
        </form>

        <div className='mt-8 text-sm text-gray-600 text-center'>
          <p>同じルームIDを入力して参加すると、</p>
          <p>ビデオチャットが開始されます。</p>
        </div>
      </div>
    </div>
  );
}

このコンポーネントは、ユーザーがルーム ID を入力してビデオチャットに参加するためのランディングページを提供します。

通話制御コンポーネントの実装

ミュート、カメラ ON/OFF、通話終了などの制御機能を実装します。

typescript// src/components/Controls.tsx
'use client';

import { useState } from 'react';
import {
  MicrophoneIcon,
  VideoCameraIcon,
  PhoneXMarkIcon,
  SpeakerWaveIcon,
} from '@heroicons/react/24/outline';
import {
  MicrophoneIcon as MicrophoneIconSolid,
  VideoCameraIcon as VideoCameraIconSolid,
  SpeakerXMarkIcon,
} from '@heroicons/react/24/solid';

interface ControlsProps {
  localStream: MediaStream | null;
  isAudioEnabled: boolean;
  isVideoEnabled: boolean;
  onAudioToggle: () => void;
  onVideoToggle: () => void;
  onEndCall: () => void;
}

export default function Controls({
  localStream,
  isAudioEnabled,
  isVideoEnabled,
  onAudioToggle,
  onVideoToggle,
  onEndCall,
}: ControlsProps) {
  const [isSpeakerOn, setIsSpeakerOn] = useState(true);

  const toggleSpeaker = () => {
    setIsSpeakerOn(!isSpeakerOn);
    // スピーカーのON/OFF制御は実装環境により異なる
    console.log('スピーカー切り替え:', !isSpeakerOn);
  };

  return (
    <div className='flex items-center justify-center space-x-4 p-4 bg-gray-800 rounded-lg'>
      {/* マイクコントロール */}
      <button
        onClick={onAudioToggle}
        className={`p-3 rounded-full transition duration-200 ${
          isAudioEnabled
            ? 'bg-gray-600 hover:bg-gray-700 text-white'
            : 'bg-red-600 hover:bg-red-700 text-white'
        }`}
        title={
          isAudioEnabled
            ? 'マイクをミュート'
            : 'マイクをオン'
        }
      >
        {isAudioEnabled ? (
          <MicrophoneIcon className='w-6 h-6' />
        ) : (
          <MicrophoneIconSolid className='w-6 h-6' />
        )}
      </button>

      {/* カメラコントロール */}
      <button
        onClick={onVideoToggle}
        className={`p-3 rounded-full transition duration-200 ${
          isVideoEnabled
            ? 'bg-gray-600 hover:bg-gray-700 text-white'
            : 'bg-red-600 hover:bg-red-700 text-white'
        }`}
        title={
          isVideoEnabled ? 'カメラをオフ' : 'カメラをオン'
        }
      >
        {isVideoEnabled ? (
          <VideoCameraIcon className='w-6 h-6' />
        ) : (
          <VideoCameraIconSolid className='w-6 h-6' />
        )}
      </button>

      {/* スピーカーコントロール */}
      <button
        onClick={toggleSpeaker}
        className={`p-3 rounded-full transition duration-200 ${
          isSpeakerOn
            ? 'bg-gray-600 hover:bg-gray-700 text-white'
            : 'bg-red-600 hover:bg-red-700 text-white'
        }`}
        title={
          isSpeakerOn
            ? 'スピーカーをミュート'
            : 'スピーカーをオン'
        }
      >
        {isSpeakerOn ? (
          <SpeakerWaveIcon className='w-6 h-6' />
        ) : (
          <SpeakerXMarkIcon className='w-6 h-6' />
        )}
      </button>

      {/* 通話終了 */}
      <button
        onClick={onEndCall}
        className='p-3 bg-red-600 hover:bg-red-700 text-white rounded-full transition duration-200'
        title='通話を終了'
      >
        <PhoneXMarkIcon className='w-6 h-6' />
      </button>
    </div>
  );
}

通話制御では、直感的に操作できるアイコンベースのボタンを使用し、状態に応じて色が変化するデザインを採用しています。

改良された VideoCall コンポーネント

UI コンポーネントを統合し、通話制御機能を実装した完全なビデオ通話画面を作成します。

typescript// src/components/VideoCall.tsx(改良版)
'use client';

import { useEffect, useRef, useState } from 'react';
import { useWebRTC } from '@/hooks/useWebRTC';
import Controls from './Controls';

interface VideoCallProps {
  roomId: string;
}

export default function VideoCall({
  roomId,
}: VideoCallProps) {
  const localVideoRef = useRef<HTMLVideoElement>(null);
  const remoteVideoRef = useRef<HTMLVideoElement>(null);

  const [localStream, setLocalStream] =
    useState<MediaStream | null>(null);
  const [isAudioEnabled, setIsAudioEnabled] =
    useState(true);
  const [isVideoEnabled, setIsVideoEnabled] =
    useState(true);
  const [connectionStatus, setConnectionStatus] =
    useState<string>('接続中...');

  // カスタムフックからWebRTC機能を取得
  const {
    isConnected,
    remoteStream,
    setLocalStreamToHook,
  } = useWebRTC(roomId);

  useEffect(() => {
    initializeMedia();
  }, []);

  useEffect(() => {
    // リモートストリームが取得できたら表示
    if (remoteStream && remoteVideoRef.current) {
      remoteVideoRef.current.srcObject = remoteStream;
    }
  }, [remoteStream]);

  useEffect(() => {
    // 接続状態の更新
    setConnectionStatus(isConnected ? '接続中' : '待機中');
  }, [isConnected]);

  const initializeMedia = async () => {
    try {
      const stream =
        await navigator.mediaDevices.getUserMedia({
          video: {
            width: { ideal: 1280 },
            height: { ideal: 720 },
            frameRate: { ideal: 30 },
          },
          audio: {
            echoCancellation: true,
            noiseSuppression: true,
            autoGainControl: true,
          },
        });

      setLocalStream(stream);
      setLocalStreamToHook(stream);

      if (localVideoRef.current) {
        localVideoRef.current.srcObject = stream;
      }
    } catch (error) {
      console.error('メディア初期化エラー:', error);
      setConnectionStatus('メディアアクセスエラー');
    }
  };

  // 音声のON/OFF制御
  const toggleAudio = () => {
    if (localStream) {
      localStream.getAudioTracks().forEach((track) => {
        track.enabled = !isAudioEnabled;
      });
      setIsAudioEnabled(!isAudioEnabled);
    }
  };

  // 映像のON/OFF制御
  const toggleVideo = () => {
    if (localStream) {
      localStream.getVideoTracks().forEach((track) => {
        track.enabled = !isVideoEnabled;
      });
      setIsVideoEnabled(!isVideoEnabled);
    }
  };

  // 通話終了
  const endCall = () => {
    if (localStream) {
      localStream
        .getTracks()
        .forEach((track) => track.stop());
    }
    window.location.reload(); // 簡単な実装として画面をリロード
  };

  return (
    <div className='min-h-screen bg-gray-900 flex flex-col'>
      {/* ヘッダー */}
      <div className='bg-gray-800 text-white p-4'>
        <div className='flex justify-between items-center max-w-6xl mx-auto'>
          <h1 className='text-xl font-semibold'>
            ビデオチャット
          </h1>
          <div className='flex items-center space-x-4'>
            <span className='text-sm'>
              ルーム: {roomId}
            </span>
            <span
              className={`text-sm px-3 py-1 rounded-full ${
                isConnected
                  ? 'bg-green-600'
                  : 'bg-yellow-600'
              }`}
            >
              {connectionStatus}
            </span>
          </div>
        </div>
      </div>

      {/* メイン映像エリア */}
      <div className='flex-1 p-4'>
        <div className='max-w-6xl mx-auto'>
          <div className='grid grid-cols-1 lg:grid-cols-2 gap-4 h-[calc(100vh-200px)]'>
            {/* リモート映像(メイン) */}
            <div className='relative bg-gray-800 rounded-lg overflow-hidden'>
              <video
                ref={remoteVideoRef}
                autoPlay
                playsInline
                className='w-full h-full object-cover'
              />
              {!remoteStream && (
                <div className='absolute inset-0 flex items-center justify-center text-white text-lg'>
                  相手の接続を待っています...
                </div>
              )}
              <div className='absolute bottom-4 left-4 bg-green-600 text-white px-3 py-1 rounded text-sm'>
                相手
              </div>
            </div>

            {/* ローカル映像 */}
            <div className='relative bg-gray-800 rounded-lg overflow-hidden lg:order-last'>
              <video
                ref={localVideoRef}
                autoPlay
                muted
                playsInline
                className='w-full h-full object-cover'
              />
              <div className='absolute bottom-4 left-4 bg-blue-600 text-white px-3 py-1 rounded text-sm'>
                あなた
              </div>
              {!isVideoEnabled && (
                <div className='absolute inset-0 bg-gray-900 flex items-center justify-center text-white'>
                  カメラがオフです
                </div>
              )}
            </div>
          </div>
        </div>
      </div>

      {/* コントロールパネル */}
      <div className='p-4'>
        <div className='max-w-6xl mx-auto flex justify-center'>
          <Controls
            localStream={localStream}
            isAudioEnabled={isAudioEnabled}
            isVideoEnabled={isVideoEnabled}
            onAudioToggle={toggleAudio}
            onVideoToggle={toggleVideo}
            onEndCall={endCall}
          />
        </div>
      </div>
    </div>
  );
}

レスポンシブデザインとアクセシビリティ

モバイルデバイスでも使いやすいレスポンシブデザインを実装し、アクセシビリティにも配慮します。

typescript// tailwind.config.js
module.exports = {
  content: [
    './src/pages/**/*.{js,ts,jsx,tsx,mdx}',
    './src/components/**/*.{js,ts,jsx,tsx,mdx}',
    './src/app/**/*.{js,ts,jsx,tsx,mdx}',
  ],
  theme: {
    extend: {
      screens: {
        xs: '475px',
      },
      animation: {
        'pulse-slow': 'pulse 3s infinite',
      },
    },
  },
  plugins: [],
};

レスポンシブ対応により、スマートフォンやタブレットでも快適にビデオチャットを利用できます。

完成した WebRTC ビデオチャットアプリの主な特徴:

機能実装内容
映像通話HD 品質での双方向ビデオ通信
音声通話エコーキャンセレーション対応
通話制御ミュート、カメラ ON/OFF、通話終了
UI/UXレスポンシブデザイン、直感的操作
接続管理自動再接続、エラーハンドリング

これで、WebRTC を使った本格的なビデオチャットアプリケーションが完成しました。

まとめ

本記事では、WebRTC を用いたビデオチャットアプリの開発手順を段階的に解説いたしました。複雑に見える WebRTC 技術も、適切な手順で実装することで、初心者でも理解しやすい形で習得できることをお示ししました。

実装で学んだ重要なポイント

  1. 段階的アプローチの効果 - 複雑な機能を 5 つのステップに分割することで、エラーの原因特定が容易になり、学習効果も向上しました

  2. シグナリングサーバーの重要性 - P2P 通信の前段階として、ブラウザ間の情報交換を管理するサーバーが不可欠であることを理解しました

  3. エラーハンドリングの徹底 - メディアアクセス、ネットワーク接続、ブラウザ互換性など、各段階でのエラー処理が安定したアプリには必須です

  4. ユーザー体験への配慮 - 技術的な実装だけでなく、直感的な UI 設計とレスポンシブ対応が実用的なアプリには重要です

WebRTC 開発における課題と解決

開発過程で直面した主な課題と、その解決策をまとめます:

  • 複雑な接続プロセス → モジュラー設計による段階的実装
  • ネットワーク制限問題 → STUN/TURN サーバーの適切な設定
  • ブラウザ互換性 → 標準 API の使用と適切なエラーハンドリング
  • リアルタイム通信特有の課題 → Socket.io による安定した双方向通信

今後の拡張可能性

構築したアプリは基本的なビデオチャット機能を提供しますが、以下のような拡張も可能です:

  • 多人数通話対応 - Mesh 型や SFU 型アーキテクチャの導入
  • 画面共有機能 - getDisplayMedia() API の活用
  • チャット機能 - DataChannel を使用したテキスト通信
  • 録画機能 - MediaRecorder API による通話記録
  • 認証機能 - セキュアなユーザー管理システム

学習の成果

TypeScript と Next.js を使用した現代的な Web アプリケーション開発手法を習得し、WebRTC という先進的な技術を実践的に活用できるようになりました。特に、リアルタイム通信の仕組みを理解することで、今後の Web 開発における幅広い応用が期待できます。

WebRTC 技術は今後もオンラインコミュニケーションの中核技術として発展していくでしょう。本記事で学んだ基礎知識と実装経験を活かし、より高度なリアルタイム Web アプリケーション開発に挑戦していただければ幸いです。

関連リンク

公式ドキュメント

技術仕様・標準

開発支援ツール

サンプルコード・リポジトリ

STUN/TURN サーバー

関連記事・チュートリアル