Как создать свой сервис видеозвонков и чата: архитектура, стек и готовый промпт
Коротко
Свой сервис видеозвонков и чата — аналог Slack по текстовой части и Telemost/Google Meet по звонкам — собирается из двух независимых транспортов: текст и сигналинг идут через Socket.IO и REST в PostgreSQL, а медиа — напрямую между браузерами и медиасервером LiveKit по WebRTC. Ключевое решение — SFU вместо P2P-mesh: mesh честно работает до 3–4 участников, дальше нужен селективный форвардинг, а self-hosted LiveKit закрывает его одним контейнером. В статье — карта из 10 сервисов чата (треды, реакции, присутствие, полнотекстовый поиск с русской морфологией), три сценария входа в звонок, режимы качества с адаптивной деградацией, комнаты по ссылке с гостевым доступом, схема из 14 таблиц чата и звонков, слой безопасности и инфраструктура из 5 контейнеров. В конце — разбор готового промпта из 11 секций, которым весь сервис ставится одной задачей агенту: промпт лежит в арсенале целиком, с кнопкой копирования, и написан по работающей реализации.
Из чего состоит сервис видеозвонков и чата?
Вся система держится на одном архитектурном решении: два независимых транспорта. Текст, состояние и сигналинг идут привычным путём — Socket.IO и REST к Node-бэкенду, хранение в PostgreSQL. А медиа (аудио, видео, демонстрация экрана) через бэкенд не проходит вообще: браузеры соединяются напрямую с медиасервером LiveKit по WebRTC, а бэкенд лишь выдаёт подписанный токен доступа и управляет комнатой через серверный SDK.
Это решение определяет всё остальное. Бэкенд не упирается в трафик и масштабируется по числу сообщений, а не по числу мегабит; медиасервер масштабируется отдельно — и умирает отдельно, не роняя чат. Отсюда же главное правило безопасности: ключи LiveKit живут только на сервере, клиент их никогда не видит. Вместо ключей браузер получает JWT с конкретными правами на конкретную комнату и ограниченным сроком жизни.
Роли распределены так:
- SPA-клиент — React + Vite: один сокет на вкладку, React Query для REST-кэша,
@livekit/components-reactдля медиа-слоя. Виджеты чата и активного звонка живут в корне приложения и переживают навигацию. - API-бэкенд — Express + Socket.IO на одном HTTP-сервере: REST для истории, файлов и CRUD, сокет для всего, что должно прилетать мгновенно.
- SFU-медиасервер — LiveKit: селективный форвардинг, simulcast, dynacast, TCP-fallback.
- Запись — отдельный контейнер LiveKit Egress: подключается к комнате как невидимый участник, композитит сетку в MP4 и пишет на том.
Почему не «просто WebRTC»?
Наивная схема «браузеры соединяются друг с другом напрямую» называется P2P-mesh — и она честно работает до 3–4 человек. Каждый участник шлёт свой поток каждому, и уже на восьмерых собеседниках ноутбук захлёбывается кодированием.
Ответ индустрии — SFU (Selective Forwarding Unit): каждый участник шлёт один поток вверх, а сервер селективно раздаёт нужные потоки вниз. Вместе с simulcast и dynacast нагрузка на клиента перестаёт расти с числом участников.
Писать свой SFU — это месяцы работы. Self-hosted LiveKit закрывает вопрос одним контейнером и оставляет полный контроль над данными: медиа не покидает ваш сервер, в отличие от облачных API видеосвязи.
Какой стек выбрать?
Стек ниже — не список модных технологий, а комплект с работающей реализации: каждый слой решает свою задачу и не лезет в чужую.
| Слой | Технология | Зачем |
|---|---|---|
| Фронтенд | React 18 + Vite | SPA с виджетами чата и звонка, переживающими навигацию |
| Стили | TailwindCSS | Дизайн-токены, светлая и тёмная темы |
| API и реалтайм | Express + Socket.IO | REST для истории и CRUD, сокет для мгновенных событий |
| Хранилище | PostgreSQL 16 | Данные чата, комнат, звонков плюс полнотекстовый поиск на tsvector |
| Кэш и шина | Redis | Presence, rate-limit и адаптер Socket.IO для нескольких инстансов |
| Медиа | LiveKit SFU | WebRTC-звонки: форвардинг, simulcast, TCP-fallback |
| Запись | LiveKit Egress | Композитная запись комнаты в MP4 на том |
Собирать такой проект руками — долго; агентным кодингом он ставится одной детальной задачей. Если подход вам в новинку — начните с разбора, что такое агентный кодинг.
Как устроен чат внутри?
Бэкенд разложен по сервисам, каждый со своей зоной ответственности; роуты и обработчики сокета — тонкие, вся логика внутри сервисов. Десять модулей закрывают текстовую часть целиком:
| Модуль | Отвечает за |
|---|---|
| channelService | Каналы: публичные и приватные, участники и роли, архивация, тема, настройки уведомлений на участника |
| messageService | Отправка, редактирование, мягкое удаление, треды, закрепление, счётчик ответов, полнотекстовый поиск |
| reactionService | Эмодзи-реакции: одна реакция от пользователя на сообщение, агрегация по эмодзи |
| directMessageService | Личные переписки: канал типа direct создаётся на пару участников идемпотентно |
| chatAttachmentService | Вложения до 50 МБ, превью изображений, отдача файла с проверкой доступа |
| bookmarkService | Сохранённые сообщения с личной заметкой |
| presenceService | Статусы online / away / dnd / offline, кастомный статус с эмодзи, heartbeat и авто-away |
| chatNotificationService | Уведомления об упоминаниях, личных сообщениях и ответах в треде |
| guestService | Приглашения по ссылке с набором прав, гостевые сессии, лимиты |
| chatAuditService | Журнал значимых действий: удаления, кики, изменения прав |
Пользователь при этом видит привычный Slack-подобный интерфейс: список каналов с непрочитанными и присутствием, ленту с «печатает…», тред панелью справа (у родительского сообщения виден счётчик ответов), панели «Файлы / Ссылки / Закреплённые / Участники» и упоминания через @, создающие уведомление.
Отдельно стоит отметить поиск: он построен на колонке tsvector PostgreSQL с русской морфологией и триггером обновления при вставке и правке текста. Сообщение находится по слову в любой словоформе — и отдельный поисковый движок для чата рабочей команды не нужен.
Как работают звонки?
Звонок — это всегда запись в таблице комнат звонков плюс живая комната в LiveKit; различаются только точки входа. Сценариев три, плюс один служебный:
- Звонок в канале — модель Slack-хаддла: участник жмёт «Позвонить», в канал падает плашка «Идёт звонок, присоединиться». Никто никого не будит, но все видят активность.
- Личный звонок 1:1 — классический вызов: модалка входящего с рингтоном, исходящий с отменой, таймаут, отказ и сценарий «уже в другом звонке» с предложением переключиться.
- Комната по ссылке — сценарий Telemost: создал комнату, скинул ссылку, собеседник зашёл — с аккаунтом или гостем по имени.
- Возврат в звонок — свёрнутый звонок живёт в плавающем виджете поверх приложения: можно уйти в другой раздел, продолжая говорить.
У комнаты есть режим качества, выбираемый при старте — и он меняет права в выдаваемом токене, а не галочки в интерфейсе:
| Режим | Что публикуется | Когда выбирать |
|---|---|---|
| economy | Микрофон и экран; камера запрещена на уровне токена | Слабый канал, мобильный интернет, большие планёрки |
| standard | Видео до 6 участников, дальше — аватары с индикацией речи | По умолчанию: рабочие встречи команды |
| conference | Видео всех участников без ограничений | Демо, вебинары, презентации при хорошем канале |
Поверх режима работает адаптивная деградация: фоновая задача опрашивает нагрузку медиасервера и рассылает клиентам уровень — none, moderate (снизить битрейт и частоту кадров), high (выключить камеры, оставить экран и звук). Пользователь видит честный баннер, а не самопроизвольно рассыпающуюся картинку.
Модерация внутри звонка — серверная: ведущий может замьютить участника, выключить ему видео, удалить из звонка или завершить звонок для всех, и каждое действие идёт адресной командой через сервер, а не через локальное состояние клиента. Запись включается одной кнопкой: бэкенд запускает композитную запись через Egress (сетка, HD 30 fps; для аудиозвонка — только звук), по остановке кладёт файл на том и сохраняет ссылку. Индикатор «идёт запись» видят все — это не опция, а требование и к приличию, и к закону.
Как сделать комнаты по ссылке и гостевой доступ?
Комната — самостоятельная сущность, не привязанная к каналу: имя, описание, уникальный токен ссылки и две оси настроек. Тип: permanent (переиспользуется бесконечно) или one-time (удаляется после завершения звонка). Доступ: org-only (только свои по логину) или public (любой, у кого есть ссылка, включая гостей без аккаунта).
У публичной комнаты своя страница по токену: превью, поле «Как вас зовут», выбор камеры и микрофона до входа. Внутри — тот же звонок плюс текстовый чат комнаты, где пишут и пользователи, и гости.
Гостевая ссылка — это не «дырка в периметре», а токен с явным набором прав: canChat, canCall, canViewHistory, лимит числа использований и срок годности. Гость получает свою сессию, урезанные лимиты действий и не видит ничего за пределами выданного канала или комнаты. Отозвать ссылку можно в один клик.
Как выглядит схема данных?
Всю функциональность закрывают 14 таблиц чата и звонков (плюс базовые users и organizations), и у каждой сущности организации есть organization_id — база изоляции арендаторов: любой запрос фильтруется по нему прямо в SQL. Ключевые таблицы:
chat_channelsиchat_channel_members— каналы (public/private/direct), членство, роли и отметки прочитанности;chat_messages— сообщения и треды:parent_message_id, упоминания,edited_at/deleted_at, закрепление,search_vector;chat_message_reactions,chat_attachments,chat_bookmarks— реакции, файлы с превью, сохранённые;chat_user_presenceиchat_notifications— присутствие и уведомления;chat_roomsиchat_room_messages— комнаты по ссылке и их чат (автор — пользователь или гостевая сессия);chat_call_roomsиchat_call_participants— сессии звонков (имя комнаты LiveKit, режим, ссылка на запись) и история участия;chat_guest_invitesиchat_guest_sessions— приглашения с правами и активные гости.
Полный DDL с полями, индексами и триггером поиска целиком лежит в промпте, о котором ниже.
Что с безопасностью?
Слой безопасности — часть архитектуры, а не приложение к ней; принцип один: запрещено, пока явно не разрешено.
- Изоляция арендаторов. Каждый запрос к базе фильтруется по организации пользователя. Не «фронт не покажет», а условие в SQL.
- Токены LiveKit выдаются сервером на конкретную комнату, личность и права публикации, с ограниченным сроком. В режиме economy право публиковать камеру просто не выдаётся — запрет невозможно обойти из консоли браузера.
- Проверка прав на входе в звонок: членство в канале, доступ к комнате, валидный гостевой токен.
- Валидация пейлоадов сокета схемами: сокет — такая же граница доверия, как HTTP.
- Лимиты частоты и на HTTP, и на события сокета — свои для пользователя, гостя и анонима.
- Файлы отдаются только через проверку доступа; тип и размер проверяются на сервере.
- Запись видна всем участникам и пишется в журнал аудита.
Какая инфраструктура нужна?
Весь сервис — это пять сервисных контейнеров — приложение, PostgreSQL, Redis, LiveKit и LiveKit Egress — плюс nginx на входе, шестой сервис compose. Порты медиасервера: 7880 — сигналинг по WebSocket, 7881 — TCP-фолбэк для строгих корпоративных сетей, и диапазон UDP 50000–52000 для медиа: примерно две тысячи портов дают запас на сотни одновременных участников.
Перед приложением стоит nginx с тремя обязанностями, о которых чаще всего забывают: проксирование WebSocket (заголовки Upgrade и Connection), поднятый client_max_body_size под вложения — и терминация TLS. Последнее не опция: WebRTC требует защищённого контекста, без HTTPS камера и микрофон просто не включатся.
На каких граблях теряют недели?
- ICE-кандидаты с адресом Docker-моста. Медиасервер в контейнере рекламирует внутренний адрес — соединение не устанавливается ни у кого извне. Лечится явным указанием публичного адреса узла и исключением мостов из списка интерфейсов.
- Забытый лимит тела в nginx. Загрузка файла больше мегабайта возвращает 413 и никогда не доходит до бэкенда, где лимит выставлен честно.
- Сокет на компонент вместо приложения. Пересоздание соединения при каждом ререндере даёт лавину подключений и потерю событий.
- Балансировщик без Redis-адаптера (или без липких сессий). События уходят в инстанс, где нет получателя.
- Гость без ограничений. Ссылка без срока, без лимита использований и без набора прав — это не приглашение, а бэкдор.
- Нет сценария «уже в звонке». Пользователь оказывается в двух комнатах сразу, а на сервере остаются висящие участники.
- Права публикации только на клиенте. Всё, что не запрещено в токене, будет включено — рано или поздно.
Готовый промпт: сервис одной задачей агенту
Всё описанное выше собирается агентом (Claude Code, Cursor, Codex — любым, кто умеет писать файлы и запускать команды) из одного детального промпта. В статью мы его не вставляем — в нём 18 КБ текста, он живёт в карточке арсенала: промпт «Сервис видеозвонков под ключ» — целиком, с кнопкой копирования.
Устройство промпта важнее объёма. В нём 11 секций: точный стек с версиями → схема базы с индексами и триггером поиска → карта REST-эндпоинтов → события сокета в обе стороны → интеграция LiveKit с логикой токенов → экраны и инвентарь кнопок → дизайн-токены → инфраструктура (docker-compose, livekit.yaml, nginx) → безопасность → порядок работы по этапам → критерии приёмки — 12 проверяемых пунктов, от «два браузера переписываются в реальном времени» до «чужая организация не получает ни одного объекта ни по API, ни по сокету».
Именно критерии приёмки превращают промпт из пожелания в задачу: агент работает этапами, после каждого запускает сборку и тесты и проверяет себя по списку. На этом принципе стоит весь вайб-кодинг: человек описывает результат и способ проверки, агент доводит до готовности. Начать удобнее всего с гайда по Claude Code.
Частые вопросы
Можно ли обойтись без LiveKit?
Для звонков 1:1 и на 3–4 человека хватит и чистого WebRTC в режиме mesh. Но дальше без SFU не обойтись, а писать свой — месяцы работы. Альтернатива LiveKit — облачные API видеосвязи, но тогда медиа уходит на чужие серверы; self-hosted LiveKit оставляет данные у вас и ставится одним контейнером.
Сколько нужно контейнеров и потянет ли один сервер?
Шесть в одном docker-compose: приложение, PostgreSQL, Redis, LiveKit, Egress и nginx на входе. Всё поднимается на одном сервере. Диапазон UDP 50000–52000 даёт запас на сотни одновременных участников, а Redis-адаптер Socket.IO позволяет позже разнести приложение на несколько инстансов за балансировщиком.
Почему нельзя отдать ключи LiveKit фронтенду?
Ключи подписывают токены доступа — владея ими, любой посетитель выпишет себе токен в любую комнату с любыми правами. Поэтому ключи живут только на сервере, а клиент получает JWT на конкретную комнату с конкретными правами публикации и сроком жизни. Даже запрет камеры в режиме economy зашит в токен — его не обойти из консоли браузера.
Законно ли записывать звонки?
Запись должна быть явной для всех участников: в этой архитектуре индикатор «идёт запись» видят все, он не скрывается, а старт записи пишется в журнал аудита. Молчаливая запись недопустима ни этически, ни юридически.
Подойдёт ли такая архитектура для тысячи участников?
Описанная конфигурация рассчитана на командный сервис: сотни одновременных участников на одном сервере. SFU-архитектура масштабируется и дальше — медиасервер растёт отдельно от бэкенда, — но вебинары на тысячи зрителей потребуют других решений поверх той же основы. Что ещё реально собрать по такой же схеме — в подборке что можно создать вайб-кодингом.