Групповые видеозвонки прямо в браузере: без установки, без регистрации, без аккаунтов. Создайте комнату, отправьте ссылку — и говорите.
Бэкенд — собственный SFU (Selective Forwarding Unit) на Go и pion/webrtc: сервер принимает медиапотоки участников и пересылает их остальным, не микшируя и не перекодируя. Фронтенд — React + Vite, раздаётся тем же Go-сервером.
Учебно-портфолийный проект. Он рабочий, но пока не рассчитан на публичный продакшн — см. раздел Ограничения.
- Возможности
- Как это устроено
- Быстрый старт
- Деплой
- Разработка
- HTTP API
- Протокол сигналинга
- Структура проекта
- Ограничения
- Дорожная карта
- Как внести вклад
- Лицензия
- 🎥 Групповые звонки «многие ко многим» через SFU (одна восходящая отправка на участника вместо N)
- 🔗 Комнаты по UUID или по человекочитаемому имени (
/ws/team-syncвместо UUID) - 🚪 Вход без регистрации: создать комнату или войти по ID/имени
- 🎙 Управление микрофоном и камерой
- 🗣 Индикатор говорящего (Web Audio API, реальный уровень звука)
- ⏱ Таймер звонка, копирование ID комнаты в один клик
- 🧹 Автоматическая уборка: пустая комната удаляется из хаба вместе со своим именем
- 📦 Один Docker-образ: собранный фронтенд + Go-бинарник
flowchart LR
subgraph Browser["Браузер (React + Vite)"]
UI[Landing / Room]
PC[RTCPeerConnection]
end
subgraph Server["Go-сервер"]
HTTP[Gin: REST + статика]
WS["ws.Service<br/>WebSocket-сигналинг"]
HUB["hub.Hub<br/>rooms + имена"]
ROOM["room.Room<br/>peers + trackLocals"]
CLIENT["client.Client<br/>PeerConnection + WS"]
end
UI -->|POST /api/rooms| HTTP
UI -->|GET /api/rooms/:id| HTTP
UI -->|WS /ws/:id| WS
WS --> HUB --> ROOM --> CLIENT
PC <-->|SRTP: media| ROOM
SFU вместо mesh. В mesh-схеме каждый участник шлёт свой поток каждому — при N участниках это
N−1 исходящих потоков с каждого клиента. Здесь клиент отправляет поток один раз на сервер,
а сервер раздаёт его остальным. Сервер не декодирует медиа: RTP-пакеты входящего трека
переписываются в TrackLocalStaticRTP и разлетаются подписчикам как есть.
Сервер инициирует переговоры. Клиент никогда не создаёт offer — только отвечает.
Каждый раз, когда состав комнаты или набор треков меняется, Room.SignalPeerConnections()
сверяет для каждого пира его RTPSender'ы с текущим набором треков комнаты, добавляет
недостающие, снимает лишние и при необходимости шлёт новый offer.
Сложные места, на которые стоит посмотреть в коде:
| Проблема | Решение |
|---|---|
| Два параллельных прохода реконсиляции шлют клиенту офферы вразнобой; ответ на устаревший offer ломает signaling state | Каждому офферу присваивается монотонный seq (Room.nextOfferSeq под room.mu); клиент возвращает его в answer, сервер отбрасывает ответы с неактуальным seq — internal/domain/client/message.go, internal/transport/ws/handler.go |
Медленный клиент блокирует всю комнату: запись в его WebSocket держит room.mu |
Офферы создаются под локом, а отправляются после его освобождения — internal/domain/room/signal_peer_connections.go |
Пир с неотвеченным оффером не может получить новый (SetLocalDescription требует stable), и его ошибка обрывала проход для всей комнаты |
Такой пир пропускается в текущем проходе; после применения его answer сигналинг перезапускается и он догоняет пропущенное |
| Клиент, зашедший ровно в момент, когда комната опустела, попадал в комнату, которую уже удаляют | JoinRoom и LeaveRoom (удаление + проверка на пустоту + prune) работают под одним локом хаба — internal/domain/hub/hub.go |
Параллельная запись в один websocket.Conn (запись из горутин — гонка) |
wsutil.ThreadSafeWriter — мьютекс поверх соединения |
docker compose -f docker-compose.local.yml up --buildОткройте http://127.0.0.1:8080.
docker-compose.local.yml использует network_mode: host — это важно для WebRTC: медиатрафик
идёт по эфемерным UDP-портам, которые неудобно пробрасывать поштучно. Корневой
docker-compose.yml — манифест для деплоя, см. Деплой.
Нужны Go 1.25+ и Node.js 20+.
# 1. Собрать фронтенд (сервер раздаёт статику из frontend/dist)
cd frontend
npm ci
npm run build
cd ..
# 2. Запустить сервер
go run ./cmd/serverОткройте http://127.0.0.1:8080.
⚠️ Браузеры дают доступ к камере и микрофону только наlocalhostили по HTTPS. Для проверки с другого устройства поставьте перед сервером reverse-proxy с TLS.
Корневой docker-compose.yml рассчитан на хостинг, который сам выдаёт домен с TLS и
проксирует его на первый сервис манифеста (например, TimeWeb Cloud), — поэтому в нём нет
ни reverse-proxy, ни сертификатов.
Режим сети там bridge, а не host, и это требует настроить две вещи:
| Переменная | Зачем |
|---|---|
HTTP_ADDR |
Адрес прослушивания. По умолчанию 127.0.0.1:8080 — внутри контейнера это значит «недоступен снаружи», нужен 0.0.0.0:8080. |
WEBRTC_PUBLIC_IP |
Публичный IP сервера. Подставляется в ICE-кандидаты вместо адреса контейнера в подсети docker, который для браузера бессмыслен. Задаётся в панели хостинга. |
WEBRTC_UDP_PORT_MIN, WEBRTC_UDP_PORT_MAX |
Диапазон UDP-портов для медиа: по порту на участника. Должен совпадать с диапазоном в ports: — номер порта уже уехал браузеру в ICE-кандидате, снаружи его не подменить. |
Без WEBRTC_PUBLIC_IP сервер стартует, но звонки не соединятся — при старте он пишет об
этом предупреждение в лог.
⚠️ UDP-порты диапазона должны быть открыты наружу. Если хостинг выпускает только HTTP на домен и не даёт публиковать UDP, прямое соединение с SFU невозможно и нужен TURN-релей — его в проекте пока нет (см. Ограничения).
make test # go test ./...
make lint # golangci-lint run ./...
make lint-fix # автофиксы линтера
make fmt # форматированиеКонфигурация линтера — .golangci.yml.
npm run dev поднимает Vite на своём порту, а запросы к /api и /ws проксируются
на Go-сервер — это уже настроено в frontend/vite.config.js, отдельных действий не нужно.
Достаточно запустить рядом бэкенд:
go run ./cmd/server # в одном терминале
cd frontend && npm run dev # в другомЕсли бэкенд слушает не на 127.0.0.1:8080, поправьте server.proxy в vite.config.js.
| Метод | Путь | Описание |
|---|---|---|
POST |
/api/rooms |
Создать комнату |
GET |
/api/rooms/:roomID |
Проверить, существует ли комната (200 / 404) |
GET |
/ws/:roomID |
WebSocket-сигналинг (upgrade) |
GET |
/ping |
Health-заглушка |
GET |
/, /assets/* |
Собранный фронтенд |
Создание комнаты. Тело можно не отправлять вовсе — тогда комната анонимная и доступна только по UUID.
POST /api/rooms
Content-Type: application/json
{ "name": "team-sync" }201 Created
{ "id": "8f14e45f-ceea-467a-9b0f-8e0f7e4e2b1c", "name": "team-sync" }Ошибки: 409 — имя занято, 400 — имя выглядит как UUID (иначе его нельзя было бы
отличить от ID комнаты при резолве).
:roomID везде принимает и UUID, и имя — Hub.Resolve разбирает оба варианта.
Имена сравниваются без учёта регистра: Team Sync и team sync — одна комната.
JSON поверх WebSocket. Все сообщения имеют вид:
{ "event": "offer", "data": "<вложенный JSON в виде строки>" }Поле data — именно строка с JSON внутри, а не вложенный объект.
Сервер → клиент
| event | data |
|---|---|
offer |
{"seq": 7, "sdp": {"type":"offer","sdp":"..."}} |
candidate |
RTCIceCandidateInit |
Клиент → сервер
| event | data |
|---|---|
answer |
{"seq": 7, "sdp": {"type":"answer","sdp":"..."}} — seq копируется из offer'а |
candidate |
RTCIceCandidateInit |
Offer всегда создаёт сервер. Клиент обязан вернуть в answer тот же seq, что пришёл в
offer, — иначе ответ будет отброшен как устаревший.
cmd/server/ точка входа: роутинг, логгер, статика
internal/
domain/
hub/ реестр комнат: создание, резолв ID/имени, join/leave, prune
room/ комната: пиры, треки, реконсиляция и переговоры
client/ один пир: PeerConnection + сигнальный WebSocket
transport/ws/ HTTP-хендлеры и цикл обработки сигнальных сообщений
wsutil/ потокобезопасная обёртка над websocket.Conn
log/ пакетные хелперы поверх slog
frontend/
src/components/ Landing, Room, VideoTile, иконки
src/hooks/ useRoomConnection (медиа + сигналинг), useSpeaking
src/lib/ signaling.js (URL, REST-клиент), colors.js
Слои разделены строго: transport знает про domain, domain про transport — нет.
Логика звонка на фронтенде целиком живёт в useRoomConnection; компоненты о протоколе
не знают ничего.
Честный список того, что ещё не сделано:
- Нет STUN/TURN.
webrtc.Configuration{}пустая, поэтому собираются только host-кандидаты — связь работает в пределах одной локальной сети, но не через NAT. - CORS и
CheckOriginоткрыты всем (AllowOrigins: ["*"],CheckOrigin→true). Для продакшна нужен белый список origin'ов. - Нет реконнекта. Обрыв WebSocket или ICE-failure означает выход из звонка.
- Нет graceful shutdown, ping/pong-heartbeat и
SetReadLimitна WebSocket. - Нет аутентификации и приватных комнат — кто знает ID или имя, тот входит.
- Нет
/health,/readyи метрик. - Всё состояние в памяти одного процесса — горизонтально не масштабируется.
- Конфигурации через env почти нет: только адрес прослушивания и настройки ICE
(
HTTP_ADDR,WEBRTC_PUBLIC_IP,WEBRTC_UDP_PORT_MIN/MAX) — см. Деплой.
Подробный план с приоритетами — в PLANS.md. Крупными мазками:
- Безопасность и надёжность WebSocket (origin-проверка, лимиты, heartbeat, graceful shutdown)
- Конфигурация через переменные окружения
- STUN/TURN и работа через NAT
- Переподключение WebSocket и WebRTC, восстановление медиа
- Типизированные события сигналинга и коды ошибок
- Демонстрация экрана, выбор устройств, индикатор качества связи
- CI (GitHub Actions), метрики и health-эндпоинты