WebRTCを利用した複数人でのビデオ通話サンプル(Mesh)

# はじめに
WebブラウザのWebRTC機能を利用して、Mesh接続で複数人とビデオ通話をするサンプルです。

:::note info
[WebRTC](https://developer.mozilla.org/ja/docs/Web/API/WebRTC_API)についての説明は巷に沢山あるため省きます。
:::

1対1のビデオ通話サンプルはこちら。
「[WebRTCを利用した1対1のビデオ通話サンプル](https://qiita.com/nakka_/items/555f0da0289b75edd420)」

# 環境
- サーバー
CentOS Linux release 7.6.1810 (Core)
（Windowsでも動作可能）
node.js v16.18.0

- クライアント(動作確認環境)
Chrome バージョン: 106.0.5249.119
iOS 16.0.2
Android 12
:::note warn
カメラとマイクが必要です。
:::

# インストール
サーバーはnode.jsを利用します。
node.jsで静的ファイルとWebSocketを処理します。WebSocketではクライアント同士でメッセージをやり取り(WebRTCのシグナリング)をします。

ファイルの配置は以下の通りです。(ファイルの内容は後述)
```
WebRoot
  + webrtc_mesh_server.js 
  + package.json
  + mesh.html 
  + webrtc_mesh.js
```

Chromeでカメラとマイクを使うためにはHTTPSで通信をする必要があります。外向けのSSL証明書は[Let's Encrypt](https://letsencrypt.org/ja/)で取得できます。
開発環境では自己署名証明書を利用すると動作できます。
以下は自己署名証明書を作成するコマンドの例です。

```
openssl req -x509 -newkey rsa:2048 -keyout privkey.pem -out cert.pem -nodes -days 365
```

:::note info
[node.js](https://nodejs.org/)のインストールについては省略します。
:::

サーバーにファイルを配置したら、配置したディレクトリで以下のコマンドを使ってパッケージをインストールします。
```
npm install
```

サーバーの起動は以下のコマンドになります。
```
sudo node webrtc_mesh_server.js
```
サーバーの起動が成功したらサーバー側に以下のメッセージが出ます。
```
Server running.
```

クライアント側のWebブラウザで以下のURLを開きます。
```
https://[サーバーのホスト名]:8443/mesh.html
```

Room IDの入力メッセージが出るので適当な値を入れます。
同じRoom IDを指定した全員とビデオ通話ができます。

# ソースコード

ソースコードはGitHubにもあります。
[https://github.com/nakkag/webrtc_mesh](https://github.com/nakkag/webrtc_mesh)

## サーバー
### パッケージ設定
以下はパッケージの設定ファイルです。

```json:package.json
{
  "dependencies": {
    "ws": "^8.5.0"
  }
}
```

### サーバープログラム
サーバープログラムはnode.js用のJavaScriptです。
静的ファイル処理とシグナリング用のWebSocket処理が入っています。

リスト(connections)を使って部屋と接続者を管理しています。
接続時に部屋を指定することで、同じ部屋にいる人全員とビデオ通話できるようにしています。
見やすくするためエラー処理や排他処理は入れていません。

```javascript:webrtc_mesh_server.js
const path = require('path');
const fs = require('fs');
const https = require('https');
const WebSocket = require('ws');

const sslPort = 8443;
const serverConfig = {
	// SSL証明書、環境に合わせてパスを変更する
	key: fs.readFileSync('privkey.pem'),
	cert: fs.readFileSync('cert.pem')
};

// 接続リスト
let connections = [];

// WebSocket処理
const socketProc = function(ws, req) {
	ws._pingTimer = setInterval(() => {
		if (ws.readyState === WebSocket.OPEN) {
			// 接続確認
			ws.send(JSON.stringify({ping: 1}));
		}
	}, 180000);

	ws.on('message', function(message) {
		const json = JSON.parse(message);
		if (json.join) {
			console.log(ws._socket.remoteAddress + ': join room=' + json.join.room + ', id=' + json.join.id);
			// 同一IDが存在するときは古い方を削除
			connections = connections.filter(data => !(data.room === json.join.room && data.id === json.join.id));
			// 接続情報を保存
			connections.push({room: json.join.room, id: json.join.id, ws: ws});
			connections.forEach(data => {
				if (data.room === json.join.room && data.id !== json.join.id && data.ws.readyState === WebSocket.OPEN) {
					// 新規参加者を通知
					data.ws.send(JSON.stringify({join: json.join.id}));
				}
			});
			return;
		}
		if (json.pong) {
			return;
		}
		if (json.ping) {
			if (ws.readyState === WebSocket.OPEN) {
				ws.send(JSON.stringify({pong: 1}));
			}
			return;
		}
		// Peerを検索
		connections.some(data => {
			if (data.room === json.room && data.id === json.dest && data.ws.readyState === WebSocket.OPEN) {
				// メッセージの転送
				data.ws.send(JSON.stringify(json));
				return true;
			}
		});
	});

	ws.on('close', function () {
		closeConnection(ws);
	});

	ws.on('error', function(error) {
		closeConnection(ws);
		console.error(ws._socket.remoteAddress + ': error=' + error);
	});

	function closeConnection(conn) {
		connections = connections.filter(data => {
			if (data.ws !== conn) {
				return true;
			}
			console.log(ws._socket.remoteAddress + ': part room=' + data.room + ', id=' + data.id);
			connections.forEach(roomData => {
				if (data.room === roomData.room && data.id !== roomData.id && roomData.ws.readyState === WebSocket.OPEN) {
					// 退出を通知
					roomData.ws.send(JSON.stringify({part: 1, src: data.id}));
				}
			});
			data.ws = null;
			return false;
		});
		if (conn._pingTimer) {
			clearInterval(conn._pingTimer);
			conn._pingTimer = null;
		}
	}
};

// 静的ファイル処理
const service = function(req, res) {
	const url = req.url.replace(/\?.+$/, '');
	const file = path.join(process.cwd(), url);
	fs.stat(file, (err, stat) => {
		if (err) {
			res.writeHead(404);
			res.end();
			return;
		}
		if (stat.isDirectory()) {
			service({url: url.replace(/\/$/, '') + '/index.html'}, res);
		} else if (stat.isFile()) {
			const stream = fs.createReadStream(file);
			stream.pipe(res);
		} else {
			res.writeHead(404);
			res.end();
		}
	});
};

// HTTPSサーバの開始
const httpsServer = https.createServer(serverConfig, service);
httpsServer.listen(sslPort, '0.0.0.0');
// WebSocketの開始
const wss = new WebSocket.Server({server: httpsServer});
wss.on('connection', socketProc);
console.log('Server running.');
```
## クライアント
### HTML
HTMLにはローカルビデオとリモートビデオの追加先を配置しています。
「adapter-latest.js」を使ってWebRTCに関するブラウザの互換性を解決しています。

```html:mesh.html
<html>
<head>
	<title>WebRTC(Mesh) Sample</title>
	<script type="text/javascript" src="https://webrtc.github.io/adapter/adapter-latest.js"></script>
	<script type="text/javascript" src="webrtc_mesh.js"></script>
	<style type="text/css">
	video {
		width: 100%;
	}
	</style>
</head>
<body>
	Local
	<video id="localVideo" playsinline autoplay muted></video>
	Remote
	<div id="remote"></div>
</body>
</html>
```

### JavaScript
カメラの開始やWebRTCのシグナリングを処理しています。
Mapで同じ部屋のPeerを管理しています。
新しい参加者が来るとPeerを作成して、VIDEOタグの追加などを行っています。

```javascript:webrtc_mesh.js
let localVideo;
let localId, roomId;
let sc;
let peers = new Map();

const sslPort = 8443;
const peerConnectionConfig = {
	iceServers: [
		// GoogleのパブリックSTUNサーバーを指定しているが自前のSTUNサーバーがあれば変更する
		{urls: 'stun:stun.l.google.com:19302'},
		{urls: 'stun:stun1.l.google.com:19302'},
		{urls: 'stun:stun2.l.google.com:19302'},
		// TURNサーバーがあれば指定する
		//{urls: 'turn:turn_server', username:'', credential:''}
	]
};

window.onload = function() {
	localVideo = document.getElementById('localVideo');
	localId = Math.random().toString(36).slice(-4) + '_' + new Date().getTime();
	while (!roomId) {
		roomId = window.prompt('Room ID', '');
	}
	startVideo(roomId, localId);
}

function startVideo(roomId, localId) {
	if (navigator.mediaDevices.getUserMedia) {
		if (window.stream) {
			// 既存のストリームを破棄
			try {
				window.stream.getTracks().forEach(track => {
					track.stop();
				});
			} catch(error) {
				console.error(error);
			}
			window.stream = null;
		}
		// カメラとマイクの開始
		const constraints = {
			audio: true,
			video: true
		};
		navigator.mediaDevices.getUserMedia(constraints).then(stream => {
			window.stream = stream;
			localVideo.srcObject = stream;
			startServerConnection(roomId, localId);
		}).catch(e => {
			alert('Camera start error.\n\n' + e.name + ': ' + e.message);
		});
	} else {
		alert('Your browser does not support getUserMedia API');
	}
}

function startServerConnection(roomId, localId) {
	if (sc) {
		sc.close();
	}
	// サーバー接続の開始
	sc = new WebSocket('wss://' + location.hostname + ':' + sslPort + '/');
	sc.onmessage = gotMessageFromServer;
	sc.onopen = function(event) {
		// サーバーに接続情報を通知
		this.send(JSON.stringify({join: {room: roomId, id: localId}}));
	};
	sc.onclose = function(event) {
		clearInterval(this._pingTimer);
		setTimeout(conn => {
			if (sc === conn) {
				// 一定時間経過後にサーバーへ再接続
				startServerConnection(roomId, localId);
			}
		}, 5000, this);
	}
	sc._pingTimer = setInterval(() => {
		// 接続確認
		sc.send(JSON.stringify({ping: 1}));
	}, 30000);
}

function startPeerConnection(id, sdpType) {
	if (peers.has(id)) {
		peers.get(id)._stopPeerConnection();
	}
	let pc = new RTCPeerConnection(peerConnectionConfig);
	// VIDEOタグの追加
	document.getElementById('remote').insertAdjacentHTML('beforeend', '<video id="' + id + '" playsinline autoplay></video>');
	pc._remoteVideo = document.getElementById(id);
	pc._queue = new Array();
	pc._setDescription = function(description) {
		if (pc) {
			pc.setLocalDescription(description).then(() => {
				// SDP送信
				sc.send(JSON.stringify({sdp: pc.localDescription, room: roomId, src: localId, dest: id}));
			}).catch(errorHandler);
		}
	}
	pc.onicecandidate = function(event) {
		if (event.candidate) {
			// ICE送信
			sc.send(JSON.stringify({ice: event.candidate, room: roomId, src: localId, dest: id}));
		}
	};
	if (window.stream) {
		// Local側のストリームを設定
		window.stream.getTracks().forEach(track => pc.addTrack(track, window.stream));
	}
	pc.ontrack = function(event) {
		if (pc) {
			// Remote側のストリームを設定
			if (event.streams && event.streams[0]) {
				pc._remoteVideo.srcObject = event.streams[0];
			} else {
				pc._remoteVideo.srcObject = new MediaStream(event.track);
			}
		}
	};
	pc._stopPeerConnection = function() {
		if (!pc) {
			return;
		}
		if (pc._remoteVideo && pc._remoteVideo.srcObject) {
			try {
				pc._remoteVideo.srcObject.getTracks().forEach(track => {
					track.stop();
				});
			} catch(error) {
				console.error(error);
			}
			pc._remoteVideo.srcObject = null;
		}
		if (pc._remoteVideo) {
			// VIDEOタグの削除
			pc._remoteVideo.remove();
		}
		pc.close();
		pc = null;
		peers.delete(id);
	};
	peers.set(id, pc);
	if (sdpType === 'offer') {
		// Offerの作成
		pc.createOffer().then(pc._setDescription).catch(errorHandler);
	}
	return pc;
}

function gotMessageFromServer(message) {
	const signal = JSON.parse(message.data);
	if (signal.join) {
		// 新規参加者にofferを送る
		startPeerConnection(signal.join, 'offer');
		return;
	}
	if (signal.ping) {
		sc.send(JSON.stringify({pong: 1}));
		return;
	}
	let pc = peers.get(signal.src);
	if (!pc && (signal.sdp || signal.ice)) {
		// answer側のPeerConnectionを追加
		pc = startPeerConnection(signal.src, 'answer');
	}
	if (!pc) {
		return;
	}
	if (signal.part) {
		// 退出通知
		pc._stopPeerConnection();
		return;
	}
	// 以降はWebRTCのシグナリング処理
	if (signal.sdp) {
		// SDP受信
		if (signal.sdp.type === 'offer') {
			pc.setRemoteDescription(signal.sdp).then(() => {
				// Answerの作成
				pc.createAnswer().then(pc._setDescription).catch(errorHandler);
			}).catch(errorHandler);
		} else if (signal.sdp.type === 'answer') {
			pc.setRemoteDescription(signal.sdp).catch(errorHandler);
		}
	}
	if (signal.ice) {
		// ICE受信
		if (pc.remoteDescription) {
			pc.addIceCandidate(new RTCIceCandidate(signal.ice)).catch(errorHandler);
		} else {
			// SDPが未処理のためキューに貯める
			pc._queue.push(message);
			return;
		}
	}
	if (pc._queue.length > 0 && pc.remoteDescription) {
		// キューのメッセージを再処理
		gotMessageFromServer(pc._queue.shift());
	}
}

function errorHandler(error) {
	alert('Signaling error.\n\n' + error.name + ': ' + error.message);
}
```