WebRTC – это API, предоставляемое браузером и позволяющее организовать P2P соединение и передачу данных напрямую между браузерами. В Интернете довольно много руководств по написанию собственного видео-чата при помощи WebRTC. Например, вот статья на Хабре. Однако, все они ограничиваются соединением двух клиентов. В этой статье я постараюсь рассказать о том, как при помощи WebRTC организовать подключение и обмен сообщениями между тремя и более пользователями.
Интерфейс RTCPeerConnection представляет собой peer-to-peer подключение между двумя браузерами. Чтобы соединить трех и более пользователей, нам придется организовать mesh-сеть (сеть, в которой каждый узел подключен ко всем остальным узлам).
Будем использовать следующую схему:
- При открытии страницы проверяем наличие ID комнаты в location.hash
- Если ID комнаты не указано, генерируем новый
- Отправляем signalling server'у сообщение о том, что мы хотим присоединиться к указанной комнате
- Signalling server разсылает остальным клиентам в этой комнате оповещение о новом пользователе
- Клиенты, уже находящиеся к комнате, отправляют новичку SDP offer
- Новичок отвечает на offer'ы
0. Signalling server
Как известно, хоть WebRTC и предоставляет возможность P2P соединения между браузерами, для его работы всё равно требуется дополнительный транспорт для обмена сервисными сообщениями. В этом примере в качестве такого транспорта выступает WebSocket сервер, написанный на Node.JS с использованием socket.io:
var socket_io = require("socket.io");
module.exports = function (server) {
var users = {};
var io = socket_io(server);
io.on("connection", function(socket) {
// Желание нового пользователя присоединиться к комнате
socket.on("room", function(message) {
var json = JSON.parse(message);
// Добавляем сокет в список пользователей
users[json.id] = socket;
if (socket.room !== undefined) {
// Если сокет уже находится в какой-то комнате, выходим из нее
socket.leave(socket.room);
}
// Входим в запрошенную комнату
socket.room = json.room;
socket.join(socket.room);
socket.user_id = json.id;
// Отправялем остальным клиентам в этой комнате сообщение о присоединении нового участника
socket.broadcast.to(socket.room).emit("new", json.id);
});
// Сообщение, связанное с WebRTC (SDP offer, SDP answer или ICE candidate)
socket.on("webrtc", function(message) {
var json = JSON.parse(message);
if (json.to !== undefined && users[json.to] !== undefined) {
// Если в сообщении указан получатель и этот получатель известен серверу, отправляем сообщение только ему...
users[json.to].emit("webrtc", message);
} else {
// ...иначе считаем сообщение широковещательным
socket.broadcast.to(socket.room).emit("webrtc", message);
}
});
// Кто-то отсоединился
socket.on("disconnect", function() {
// При отсоединении клиента, оповещаем об этом остальных
socket.broadcast.to(socket.room).emit("leave", socket.user_id);
delete users[socket.user_id];
});
});
};
1. index.html
Исходный код самой страницы довольно простой. Я сознательно не стал уделять внимание верстке и прочим красивостям, так как это статья не об этом. Если кому-то захочется, сделать ее красивой, особого труда не составит.
<html>
<head>
<title>WebRTC Chat Demo</title>
<script src="/socket.io/socket.io.js"></script>
</head>
<body>
<div>Connected to <span id="connection_num">0</span> peers</div>
<div><textarea id="message"></textarea><br/><button onclick="sendMessage();">Send</button></div>
<div id="room_link"></div>
<div id="chatlog"></div>
<script type="text/javascript" src="/javascripts/main.js"></script>
</body>
</html>
2. main.js
2.0. Получение ссылок на элементы страницы и интерфейсы WebRTC
var chatlog = document.getElementById("chatlog");
var message = document.getElementById("message");
var connection_num = document.getElementById("connection_num");
var room_link = document.getElementById("room_link");
Нам по прежнему приходится использовать браузерные префиксы для обращения к интерфейсам WebRTC.
var PeerConnection = window.mozRTCPeerConnection || window.webkitRTCPeerConnection;
var SessionDescription = window.mozRTCSessionDescription || window.RTCSessionDescription;
var IceCandidate = window.mozRTCIceCandidate || window.RTCIceCandidate;
2.1. Определение ID комнаты
Тут нам понадобится функция, для генерации уникального идентификатора комнаты и пользователя. Будем использовать для этих целей UUID.
function uuid () {
var s4 = function() {
return Math.floor(Math.random() * 0x10000).toString(16);
};
return s4() + s4() + "-" + s4() + "-" + s4() + "-" + s4() + "-" + s4() + s4() + s4();
}
Теперь попробуем вытащить идентификатор комнаты из адреса. Если такового не задано, сгенерируем новый. Выведем на страницу ссылку на текущую комнату, и, за одно, сгенерируем идентификатор текущего пользователя.
var ROOM = location.hash.substr(1);
if (!ROOM) {
ROOM = uuid();
}
room_link.innerHTML = "<a href='#"+ROOM+"'>Link to the room</a>";
var ME = uuid();
2.2. WebSocket
Сразу при открытии страницы подключимся к нашему signalling server'у, отправим запрос на вход в комнату и укажем обработчики сообщений.
// Указываем, что при закрытии сообщения нужно отправить серверу оповещение об этом
var socket = io.connect("", {"sync disconnect on unload": true});
socket.on("webrtc", socketReceived);
socket.on("new", socketNewPeer);
// Сразу отправляем запрос на вход в комнату
socket.emit("room", JSON.stringify({id: ME, room: ROOM}));
// Вспомогательная функция для отправки адресных сообщений, связанных с WebRTC
function sendViaSocket(type, message, to) {
socket.emit("webrtc", JSON.stringify({id: ME, to: to, type: type, data: message}));
}
2.3. Настройки PeerConnection
Большинство провайдеров предоставляем подключение к Интернету через NAT. Из-за этого прямое подключение становится не таким уж тривиальным делом. При создании соединения нам нужно указать список STUN и TURN серверов, которые браузер будет пытаться использовать для обхода NAT. Так же укажем пару дополнительных опций для подключения.
var server = {
iceServers: [
{url: "stun:23.21.150.121"},
{url: "stun:stun.l.google.com:19302"},
{url: "turn:numb.viagenie.ca", credential: "your password goes here", username: "example@example.com"}
]
};
var options = {
optional: [
{DtlsSrtpKeyAgreement: true}, // требуется для соединения между Chrome и Firefox
{RtpDataChannels: true} // требуется в Firefox для использования DataChannels API
]
}
2.4. Подключение нового пользователя
Когда в комнату добавляется новый пир, сервер отправляет нам сообщение new. Согласно обработчикам сообщений, указанным выше, вызовется функция socketNewPeer.
var peers = {};
function socketNewPeer(data) {
peers[data] = {
candidateCache: []
};
// Создаем новое подключение
var pc = new PeerConnection(server, options);
// Инициализирууем его
initConnection(pc, data, "offer");
// Сохраняем пира в списке пиров
peers[data].connection = pc;
// Создаем DataChannel по которому и будет происходить обмен сообщениями
var channel = pc.createDataChannel("mychannel", {});
channel.owner = data;
peers[data].channel = channel;
// Устанавливаем обработчики событий канала
bindEvents(channel);
// Создаем SDP offer
pc.createOffer(function(offer) {
pc.setLocalDescription(offer);
});
}
function initConnection(pc, id, sdpType) {
pc.onicecandidate = function (event) {
if (event.candidate) {
// При обнаружении нового ICE кандидата добавляем его в список для дальнейшей отправки
peers[id].candidateCache.push(event.candidate);
} else {
// Когда обнаружение кандидатов завершено, обработчик будет вызван еще раз, но без кандидата
// В этом случае мы отправялем пиру сначала SDP offer или SDP answer (в зависимости от параметра функции)...
sendViaSocket(sdpType, pc.localDescription, id);
// ...а затем все найденные ранее ICE кандидаты
for (var i = 0; i < peers[id].candidateCache.length; i++) {
sendViaSocket("candidate", peers[id].candidateCache[i], id);
}
}
}
pc.oniceconnectionstatechange = function (event) {
if (pc.iceConnectionState == "disconnected") {
connection_num.innerText = parseInt(connection_num.innerText) - 1;
delete peers[id];
}
}
}
function bindEvents (channel) {
channel.onopen = function () {
connection_num.innerText = parseInt(connection_num.innerText) + 1;
};
channel.onmessage = function (e) {
chatlog.innerHTML += "<div>Peer says: " + e.data + "</div>";
};
}
2.5. SDP offer, SDP answer, ICE candidate
При получении одного из этих сообщений вызываем обработчик соответствующего сообщения.
function socketReceived(data) {
var json = JSON.parse(data);
switch (json.type) {
case "candidate":
remoteCandidateReceived(json.id, json.data);
break;
case "offer":
remoteOfferReceived(json.id, json.data);
break;
case "answer":
remoteAnswerReceived(json.id, json.data);
break;
}
}
2.5.0 SDP offer
function remoteOfferReceived(id, data) {
createConnection(id);
var pc = peers[id].connection;
pc.setRemoteDescription(new SessionDescription(data));
pc.createAnswer(function(answer) {
pc.setLocalDescription(answer);
});
}
function createConnection(id) {
if (peers[id] === undefined) {
peers[id] = {
candidateCache: []
};
var pc = new PeerConnection(server, options);
initConnection(pc, id, "answer");
peers[id].connection = pc;
pc.ondatachannel = function(e) {
peers[id].channel = e.channel;
peers[id].channel.owner = id;
bindEvents(peers[id].channel);
}
}
}
2.5.1 SDP answer
function remoteAnswerReceived(id, data) {
var pc = peers[id].connection;
pc.setRemoteDescription(new SessionDescription(data));
}
2.5.2 ICE candidate
function remoteCandidateReceived(id, data) {
createConnection(id);
var pc = peers[id].connection;
pc.addIceCandidate(new IceCandidate(data));
}
2.6. Отправка сообщения
При нажатии на кнопку Send вызывается функция sendMessage. Всё, что она делает, это проходится по списку пиров, и пытается отправить всем указанное сообщение.
function sendMessage () {
var msg = message.value;
for (var peer in peers) {
if (peers.hasOwnProperty(peer)) {
if (peers[peer].channel !== undefined) {
try {
peers[peer].channel.send(msg);
} catch (e) {}
}
}
}
chatlog.innerHTML += "<div>Peer says: " + msg + "</div>";
message.value = "";
}
2.7. Отключение
Ну и в завершении, при закрытии страницы, хорошо бы закрыть все открытые подключения.
window.addEventListener("beforeunload", onBeforeUnload);
function onBeforeUnload(e) {
for (var peer in peers) {
if (peers.hasOwnProperty(peer)) {
if (peers[peer].channel !== undefined) {
try {
peers[peer].channel.close();
} catch (e) {}
}
}
}
}