Skip to content

Repository files navigation

Портал — видеозвонки в браузере (WebRTC SFU)

Групповые видеозвонки прямо в браузере: без установки, без регистрации, без аккаунтов. Создайте комнату, отправьте ссылку — и говорите.

Бэкенд — собственный SFU (Selective Forwarding Unit) на Go и pion/webrtc: сервер принимает медиапотоки участников и пересылает их остальным, не микшируя и не перекодируя. Фронтенд — React + Vite, раздаётся тем же Go-сервером.

Учебно-портфолийный проект. Он рабочий, но пока не рассчитан на публичный продакшн — см. раздел Ограничения.


Содержание


Возможности

  • 🎥 Групповые звонки «многие ко многим» через 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
Loading

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 (рекомендуется)

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 — манифест для деплоя, см. Деплой.

Без Docker

Нужны 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.

Фронтенд в режиме dev

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.

HTTP API

Метод Путь Описание
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: ["*"], CheckOrigintrue). Для продакшна нужен белый список 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. Крупными мазками:

  1. Безопасность и надёжность WebSocket (origin-проверка, лимиты, heartbeat, graceful shutdown)
  2. Конфигурация через переменные окружения
  3. STUN/TURN и работа через NAT
  4. Переподключение WebSocket и WebRTC, восстановление медиа
  5. Типизированные события сигналинга и коды ошибок
  6. Демонстрация экрана, выбор устройств, индикатор качества связи
  7. CI (GitHub Actions), метрики и health-эндпоинты

About

App for anonymous calls in browser

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages