Обзор задачи
Создание веб‑приложения для видеоконференций требует сочетания нескольких технологий: WebRTC обеспечивает прямую потоковую передачу аудио‑видео между клиентами, Node.js выступает серверной платформой, а Socket.IO отвечает за мгновенный обмен сигнальными сообщениями. В результате получаем многопользовательскую комнату, в которой каждый участник видит и слышит остальных, может управлять микрофоном и камерой, а сервер контролирует присоединение и отключение участников.
Архитектурный подход
- Клиент: HTML‑страница с JavaScript‑логикой, использующей
navigator.mediaDevices.getUserMediaдля захвата локального медиа иRTCPeerConnectionдля организации p2p‑соединений. - Сервер: Node.js‑приложение на Express, к которому подключён Socket.IO. Сервер хранит сведения о комнатах и их участниках и передаёт сигналы (offer, answer, ICE‑candidate) между клиентами.
- Сигналы: Обмен происходит через WebSocket‑соединение, реализованное Socket.IO. Сигналы не содержат медиа‑данные, а только описание соединения.
Настройка серверной части
Создаём каталог server и инициализируем npm‑проект:
mkdir server && cd server
npm init -y
npm install express socket.io
Файл server.js содержит базовую конфигурацию:
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const app = express();
const server = http.createServer(app);
const io = new Server(server);
app.use(express.static('public'));
const rooms = {}; // { roomId: [userId, ...] }
io.on('connection', socket => {
console.log('Connected:', socket.id);
socket.on('join-room', (roomId, userId) => {
socket.join(roomId);
rooms[roomId] = rooms[roomId] || [];
rooms[roomId].push(userId);
// Уведомляем остальных участников о новом пользователе
socket.to(roomId).emit('user-connected', userId);
// Обработка отключения
socket.on('disconnect', () => {
socket.to(roomId).emit('user-disconnected', userId);
rooms[roomId] = rooms[roomId].filter(id => id !== userId);
});
});
// Перенаправление сигнальных сообщений
socket.on('signal', ({ roomId, to, data }) => {
socket.to(to).emit('signal', { from: socket.id, data });
});
});
const PORT = process.env.PORT || 3000;
server.listen(PORT, () => console.log(`Server listening on ${PORT}`));
roomsхранит массив идентификаторов пользователей в каждой комнате.- Событие
signalпередаёт любой тип сигнального сообщения от одного клиента к другому.
Фронтенд‑интерфейс
В каталоге public размещаем три файла: index.html, style.css и client.js.
index.html
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Видеоконференция</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="videos"></div>
<script src="/socket.io/socket.io.js"></script>
<script src="client.js"></script>
</body>
</html>
Контейнер #videos будет наполняться элементами <video> для каждого участника.
client.js
const socket = io();
const urlParams = new URLSearchParams(window.location.search);
const ROOM_ID = urlParams.get('room') || 'default';
const USER_ID = Math.random().toString(36).substring(2, 9);
const peers = {}; // { userId: RTCPeerConnection }
const videoContainer = document.getElementById('videos');
// Запрашиваем локальный поток
navigator.mediaDevices.getUserMedia({ video: true, audio: true })
.then(stream => {
addVideoStream(USER_ID, stream, true);
socket.emit('join-room', ROOM_ID, USER_ID);
})
.catch(err => console.error('Media error:', err));
// Обработчики сигналов от сервера
socket.on('user-connected', userId => {
const peer = createPeer(userId, true);
peers[userId] = peer;
});
socket.on('user-disconnected', userId => {
if (peers[userId]) {
peers[userId].close();
delete peers[userId];
removeVideo(userId);
}
});
socket.on('signal', ({ from, data }) => {
const peer = peers[from] || createPeer(from, false);
peers[from] = peer;
if (data.type === 'offer') {
peer.setRemoteDescription(new RTCSessionDescription(data))
.then(() => peer.createAnswer())
.then(answer => peer.setLocalDescription(answer))
.then(() => socket.emit('signal', { roomId: ROOM_ID, to: from, data: peer.localDescription }));
} else if (data.type === 'answer') {
peer.setRemoteDescription(new RTCSessionDescription(data));
} else if (data.candidate) {
peer.addIceCandidate(new RTCIceCandidate(data.candidate));
}
});
// Создание нового RTCPeerConnection
function createPeer(remoteId, isInitiator) {
const peer = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
});
// Добавляем локальный поток к соединению
navigator.mediaDevices.getUserMedia({ video: true, audio: true })
.then(stream => stream.getTracks().forEach(t => peer.addTrack(t, stream)));
// При получении удалённого потока отображаем его
peer.ontrack = event => {
const [remoteStream] = event.streams;
addVideoStream(remoteId, remoteStream);
};
// Обмен ICE‑кандидатами
peer.onicecandidate = event => {
if (event.candidate) {
socket.emit('signal', {
roomId: ROOM_ID,
to: remoteId,
data: { candidate: event.candidate }
});
}
};
// Инициировать соединение, если это создатель
if (isInitiator) {
peer.createOffer()
.then(offer => peer.setLocalDescription(offer))
.then(() => socket.emit('signal', {
roomId: ROOM_ID,
to: remoteId,
data: peer.localDescription
}));
}
return peer;
}
// Вспомогательные функции UI
function addVideoStream(id, stream, muted = false) {
const video = document.createElement('video');
video.id = `video-${id}`;
video.srcObject = stream;
video.autoplay = true;
video.playsInline = true;
video.muted = muted;
videoContainer.appendChild(video);
}
function removeVideo(id) {
const video = document.getElementById(`video-${id}`);
if (video) video.remove();
}
Ключевые моменты:
- После захвата локального медиа пользователь сразу отправляет запрос
join-room. - При подключении нового участника клиент создаёт
RTCPeerConnection, добавляет в него локальные треки и генерируетoffer. - Сигналы (offer/answer/ICE) проходят через Socket.IO, где каждый клиент получает только сообщения, адресованные ему.
- При отключении соединения соответствующий
<video>элемент удаляется.
Управление комнатами и участниками
Сервер хранит массив идентификаторов в объекте rooms. При входе нового пользователя в комнату он добавляется в массив и получает список уже подключённых. При выходе пользователь удаляется из массива, а остальные получают событие user-disconnected. Такой подход упрощает масштабирование: достаточно добавить в rooms дополнительный уровень, например, ограничение количества участников или хранение метаданных (имя, аватар).
Развёртывание и безопасность
- HTTPS: WebRTC требует защищённого соединения. При деплое используйте сертификаты (Let's Encrypt) и настройте обратный прокси (nginx) для перенаправления HTTP→HTTPS.
- STUN/TURN: Для работы за строгими NAT‑ами рекомендуется добавить TURN‑сервер (например, coturn). В
RTCPeerConnectionуказывайте списокiceServersс вашими STUN/TURN‑узлами. - Скалируемость: При росте нагрузки серверную часть можно разместить в Docker‑контейнере и масштабировать через кластеризацию Socket.IO (Redis‑adapter) для синхронизации событий между несколькими экземплярами.
- Контроль доступа: Добавьте простую авторизацию (JWT) и проверку прав перед тем, как позволять клиенту присоединяться к комнате.
Итоги реализации
Сочетание WebRTC, Node.js и Socket.IO позволяет построить полностью рабочее видеоконференц‑приложение без сторонних сервисов. Сервер отвечает лишь за обмен сигнальными сообщениями и управление списком участников, тогда как медиапоток передаётся напрямую между клиентами, что минимизирует нагрузку на бекенд. При правильной настройке STUN/TURN‑серверов и HTTPS‑соединения приложение готово к использованию в реальных проектах, от небольших командных встреч до масштабных онлайн‑мероприятий.