WebRTCを利用した1対1のビデオ通話サンプル

# はじめに
WebブラウザのWebRTC機能を利用して、1対1でビデオ通話をするサンプルです。

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

複数人(Mesh)のビデオ通話サンプルはこちら。
「[WebRTCを利用した複数人でのビデオ通話サンプル](https://qiita.com/nakka_/items/2d1fd3a941f7c0c1108f)」

# 環境
- サーバー
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_server.js 
  + package.json
  + index.html 
  + webrtc.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_server.js
```
サーバーの起動が成功したらサーバー側に以下のメッセージが出ます。
```
Server running.
```

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

Local IDとRemote IDの入力メッセージが出るのでそれぞれ異なる適当な値を入れます。
対向となるWebブラウザでも同じURLを開いて上記のLocal IDとRemote IDの値を逆に入れます。

例）
Webブラウザ1ではLocal IDを「aaa」、Remote IDを「bbb」に設定
Webブラウザ2ではLocal IDを「bbb」、Remote IDを「aaa」に設定

そうするとお互いのカメラとマイクがつながってビデオ通話ができます。

# ソースコード

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

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

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

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

リスト(connections)を使って複数コネクションを管理していますが、シグナリングは1対1の処理になっています。
接続開始時に自分のセッションIDと接続先のセッションIDをconnectionsに保存して、メッセージが来るとconnectionsから対向のコネクションを検索してメッセージを転送しています。
見やすくするためエラー処理や排他処理は入れていません。

```javascript:webrtc_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(function() {
		if (ws.readyState === WebSocket.OPEN) {
			// 接続確認
			ws.send(JSON.stringify({ping: 1}));
		}
	}, 180000);

	ws.on('message', function(message) {
		const json = JSON.parse(message);
		if (json.open) {
			console.log('open: ' + ws._socket.remoteAddress + ': local=' + json.open.local + ', remote=' + json.open.remote);
			// 同一IDが存在するときは古い方を削除
			connections = connections.filter(data => !(data.local === json.open.local && data.remote === json.open.remote));
			// 接続情報を保存
			connections.push({local: json.open.local, remote: json.open.remote, ws: ws});
			connections.some(data => {
				if (data.local === json.open.remote && data.ws.readyState === WebSocket.OPEN) {
					// 両方が接続済の場合にstartを通知
					data.ws.send(JSON.stringify({start: 'answer'}));
					ws.send(JSON.stringify({start: 'offer'}));
					return true;
				}
			});
			return;
		}
		if (json.pong) {
			return;
		}
		if (json.ping) {
			if (ws.readyState === WebSocket.OPEN) {
				ws.send(JSON.stringify({pong: 1}));
			}
			return;
		}
		// 対向の接続を検索
		connections.some(data => {
			if (data.local === json.remote && data.ws.readyState === WebSocket.OPEN) {
				// シグナリングメッセージの転送
				data.ws.send(JSON.stringify(json));
				return true;
			}
		});
	});

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

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

	function closeConnection(conn) {
		connections = connections.filter(data => {
			if (data.ws !== conn) {
				return true;
			}
			connections.some(remoteData => {
				if (remoteData.local === data.remote && remoteData.ws.readyState === WebSocket.OPEN) {
					// 対向に切断を通知
					remoteData.ws.send(JSON.stringify({close: 1}));
					return true;
				}
			});
			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:index.html
<html>
<head>
	<title>WebRTC(Peer) Sample</title>
	<script type="text/javascript" src="https://webrtc.github.io/adapter/adapter-latest.js"></script>
	<script type="text/javascript" src="webrtc.js"></script>
	<style type="text/css">
	video {
		width: 100%;
	}
	</style>
</head>
<body>
	Local
	<video id="localVideo" playsinline autoplay muted></video>
	Remote
	<video id="remoteVideo" playsinline autoplay></video>
</body>
</html>
```

### JavaScript
カメラの開始やWebRTCのシグナリングを処理しています。
ICEメッセージはこちらが期待するより前(SDPメッセージ処理前)に来ることがあり、その場合にICEメッセージが中途半端になりエラーとなります。
それを回避するためにQueueでICEメッセージを管理しています。

```javascript:webrtc.js
let localVideo, remoteVideo;
let localId, remoteId;
let sc, pc, queue;

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');
	remoteVideo = document.getElementById('remoteVideo');

	// Local IDとRemote IDは別々の値を入力する
	// Remote IDと対向のLocal IDが一致するとビデオ通話を開始する
	while (!localId) {
		localId = window.prompt('Local ID', '');
	}
	while (!remoteId) {
		remoteId = window.prompt('Remote ID', '');
	}
	startVideo(localId, remoteId);
}

function startVideo(localId, remoteId) {
	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(localId, remoteId);
		}).catch(e => {
			alert('Camera start error.\n\n' + e.name + ': ' + e.message);
		});
	} else {
		alert('Your browser does not support getUserMedia API');
	}
}

function stopVideo() {
	if (remoteVideo.srcObject) {
		try {
			remoteVideo.srcObject.getTracks().forEach(track => {
				track.stop();
			});
		} catch(error) {
			console.error(error);
		}
		remoteVideo.srcObject = null;
	}
}

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

function startPeerConnection(sdpType) {
	stopPeerConnection();
	queue = new Array();
	pc = new RTCPeerConnection(peerConnectionConfig);
	pc.onicecandidate = function(event) {
		if (event.candidate) {
			// ICE送信
			sc.send(JSON.stringify({ice: event.candidate, remote: remoteId}));
		}
	};
	if (window.stream) {
		// Local側のストリームを設定
		window.stream.getTracks().forEach(track => pc.addTrack(track, window.stream));
	}
	pc.ontrack = function(event) {
		// Remote側のストリームを設定
		if (event.streams && event.streams[0]) {
			remoteVideo.srcObject = event.streams[0];
		} else {
			remoteVideo.srcObject = new MediaStream(event.track);
		}
	};
	if (sdpType === 'offer') {
		// Offerの作成
		pc.createOffer().then(setDescription).catch(errorHandler);
	}
}

function stopPeerConnection() {
	if (pc) {
		pc.close();
		pc = null;
	}
}

function gotMessageFromServer(message) {
	const signal = JSON.parse(message.data);
	if (signal.start) {
		// サーバーからの「start」を受けてPeer接続を開始する
		startPeerConnection(signal.start);
		return;
	}
	if (signal.close) {
		// 接続先の終了通知
		stopVideo();
		stopPeerConnection();
		return;
	}
	if (signal.ping) {
		sc.send(JSON.stringify({pong: 1}));
		return;
	}
	if (!pc) {
		return;
	}
	// 以降はWebRTCのシグナリング処理
	if (signal.sdp) {
		// SDP受信
		if (signal.sdp.type === 'offer') {
			pc.setRemoteDescription(signal.sdp).then(() => {
				// Answerの作成
				pc.createAnswer().then(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が未処理のためキューに貯める
			queue.push(message);
			return;
		}
	}
	if (queue.length > 0 && pc.remoteDescription) {
		// キューのメッセージを再処理
		gotMessageFromServer(queue.shift());
	}
}

function setDescription(description) {
	pc.setLocalDescription(description).then(() => {
		// SDP送信
		sc.send(JSON.stringify({sdp: pc.localDescription, remote: remoteId}));
	}).catch(errorHandler);
}

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