
Привет, друзья!
В этой статье я покажу вам, как разработать приложение для совершения аудио/видео звонков с помощью WebRTC.
Функционал нашего приложения будет следующим:
- при запуске приложения пользователь А получает уникальный идентификатор;
- он передает этот идентификатор пользователю Б;
- пользователь Б использует идентификатор пользователя А для совершения аудио или видео звонка;
- пользователь А получает уведомление о звонке пользователя Б и может ответить на него с видео или без либо отклонить звонок;
- в процессе соединения пользователи имеют возможность включать/выключать аудио и видео;
- после завершения звонка выполняется перезагрузка
WebRTCдля обеспечения возможности совершения нового звонка.
Демо приложения.
Репозиторий с исходным кодом.
Основной источник вдохновения.
Если вам это интересно, прошу под кат.
Для установки зависимостей мы будем использовать Yarn.
Для создания шаблона приложения — Vite.
Для стилизации — Sass.
Для инициализации медиасессии (signalling — сигнализации) между браузерами — веб-сокеты в лице Socket.io.
Основные спецификации, на которые мы будем опираться при разработке приложения:
- WebRTC 1.0: Real-Time Communication Between Browsers — набор
APIдля обмена медиа и другими данными между браузерами, в которых реализован соответствующий набор протоколов, в режиме реального времени; - An Offer/Answer Model with the Session Description Protocol (SDP) — механизм, позволяющий браузерам устанавливать соединение с помощью протокола описания сессии (Session Description Protocol, SDP);
- Media Capture and Streams — набор
API, позволяющих браузеру получать доступ к медиапотоку с устройств пользователя.
Ссылки на соответствующие разделы MDN будут приводиться по мере необходимости.
Фактически наше приложение будет продвинутой реализацией примеров, описанных здесь и здесь.
Подготовка и настройка проекта
Создаем директорию, переходим в нее и создаем шаблон React-приложения:
mkdir react-webrtc
cd react-webrtc
yarn create vite client --template reactПока создается шаблон, поговорим о процессе установки P2P-соединения (peer-to-peer — равный к равному, одноранговая сеть), об интерфейсах и методах, задействованных в этом процессе.
Вот что происходит на самом высоком уровне:
- браузер пользователя А (далее —
А) захватывает (capture) медиапоток с устройств пользователя (видеокамера и микрофон). Для этого используется методgetUserMedia:
// ограничения или требования к потоку
// https://w3c.github.io/mediacapture-main/#constrainable-interface
const config = {
audio: true,
video: true
}
// https://w3c.github.io/mediacapture-main/#dom-mediadevices-getusermedia
const localStream = await navigator.mediaDevices.getUserMedia(config)
// медиапоток состоит из 1 или более медиатреков
// https://w3c.github.io/mediacapture-main/#dom-mediastream-gettracks
const localTracks = localStream.getTracks()Асоздает экземплярRTCPeerConnectionс помощью одноименного конструктора:
// https://w3c.github.io/webrtc-pc/#dom-rtcconfiguration
const config = { iceServers: [{ urls: ['stun:stun.l.google.com:19302'] }] }
// https://w3c.github.io/webrtc-pc/#interface-definition
const pc = new RTCPeerConnection(config)Адобавляет захваченные треки и поток в экземплярRTCPeerConnectionс помощью методаaddTrack:
stream.getTracks().forEach((track) => {
// https://w3c.github.io/webrtc-pc/#dom-rtcpeerconnection-addtrack
pc.addTrack(track, stream)
})Агенерирует предложение (offer) об установке соединения с помощью методаcreateOffer. Данный метод возвращаетRTCLocalSessionDescriptionInit:
// https://w3c.github.io/webrtc-pc/#dom-rtcpeerconnection-createoffer
const offer = await pc.createOffer()Авызывает методsetLocalDescription, передавая ему предложение:
// https://w3c.github.io/webrtc-pc/#dom-peerconnection-setlocaldescription
pc.setLocalDescription(offer)- предложение передается браузеру пользователя Б (далее —
Б) любым доступным способом, например, через веб-сокеты (сигнализация):
socket.emit('call', {
// идентификатор Б
to: remoteId,
// предложение
sdp: offer
})Бсоздает экземплярRTCPeerConnectionи добавляет в него предложение в видеRTCSessionDescription, с помощью методаsetRemoteDescription.RTCSessionDescription(описание сессии) создается с помощью одноименного конструктора:
// https://w3c.github.io/webrtc-pc/#rtcsessiondescription-class
const sdp = new RTCSessionDescription(desc)
// https://w3c.github.io/webrtc-pc/#dom-peerconnection-setremotedescription
pc.setRemoteDescription(sdp)- при добавлении
Атреков и потока в экземплярRTCPeerConnectionна сторонеБвозникает событиеtrack, которое обрабатывается с помощьюontrack:
// событие содержит медиапотоки, полученные от другой стороны, в виде массива
// в нашем случае в массиве будет только один такой поток
// https://w3c.github.io/webrtc-pc/#rtctrackevent
// https://w3c.github.io/webrtc-pc/#dom-rtcpeerconnection-ontrack
pc.ontrack = ({ streams }) => {
remoteStream = streams[0]
}Бзахватывает медиапоток с устройств пользователя, добавляет треки и поток в экземплярRTCPeerConnection, генерирует ответ на предложение об установке соединения (answer) с помощью методаcreateAnswer, вызываетsetLocalDescriptionс ответом и передает ответА:
// https://w3c.github.io/webrtc-pc/#dom-rtcpeerconnection-createanswer
const answer = await pc.createAnswer()
pc.setLocalDescription(answer)
// сигнализация
socket.emit('call', {
// идентификатор А
to: remoteId,
sdp: answer
})А, в свою очередь, также вызываетsetRemoteDescriptionи регистрируетontrack;
в это же время (после вызова
setLocalDescription) происходит подбор кандидатов для установки интерактивного соединения (ICE gathering). Возникает событиеicecandidate, которое обрабатывается с помощьюonicecandidate:
// https://w3c.github.io/webrtc-pc/#dom-rtcpeerconnection-onicecandidate
// https://w3c.github.io/webrtc-pc/#dom-rtcpeerconnectioniceevent
pc.onicecandidate = ({ candidate }) => {
// событие содержит `RTCIceCandidateInit`
// https://w3c.github.io/webrtc-pc/#dom-rtcicecandidateinit
// передаем "кандидата" другой стороне
socket.emit('call', {
to: remoteId,
candidate
})
}- при получении "кандидата" другой стороной, она создает экземпляр
RTCIceCandidateс помощью одноименного конструктора и вызывает методaddIceCandidate:
// https://w3c.github.io/webrtc-pc/#rtcicecandidate-interface
const _candidate = new RTCIceCandidate(candidate)
// https://w3c.github.io/webrtc-pc/#dom-peerconnection-addicecandidate
pc.addIceCandidate(_candidate)- после этого стороны могут напрямую обмениваться медиаданными.
Более подробную информацию о том, как все это происходит на более низком уровне, в доступной форме можно получить здесь.
Сервер
Пожалуй, имеет смысл начать разработку нашего приложения с подготовки сервера, который будет использоваться для инициализации медиасессии между браузерами (сигнализации).
Создаем директорию, переходим в нее, инициализируем Node.js-проект и устанавливаем зависимости:
mkdir server
cd server
yarn init -yp
yarn add express socket.io dotenv nanoid
yarn add -D nodemonЗависимости:
express—Node.js-фреймворкдля разработки веб-серверов;socket.io— библиотека, облегчающая работу с веб-сокетами;dotenv— утилита для работы с переменными среды окружения;nanoid— утилита для генерации идентификаторов;nodemon— утилита для запуска сервера для разработки.
Определяем тип кода сервера и команды для запуска серверов в package.json:
"type": "module",
"scripts": {
"dev": "nodemon",
"start": "node index.js"
}Создаем файл index.js:
touch index.jsИмпортируем библиотеки и утилиты:
import express from 'express'
import { createServer } from 'http'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'
import { Server } from 'socket.io'
import { config } from 'dotenv'
import initSocket from './utils/initSocket.js'Получаем доступ к переменным среды окружения и формируем путь к текущей директории:
config()
const __dirname = dirname(fileURLToPath(import.meta.url))Создаем экземпляры express и сервера:
const app = express()
const server = createServer(app)Добавляем обслуживание статических файлов из сборки клиента:
app.use(express.static(join(__dirname, '../client/dist')))Создаем экземпляр socket.io и добавляем обработку подключения:
const io = new Server(server, {
cors: process.env.ALLOWED_ORIGIN,
serveClient: false
})
io.on('connection', initSocket)Определяем порт и запускаем сервер:
const port = process.env.PORT || 4000
server.listen(port, () => {
console.log(`Server ready on port ${port} 