Соло-проект: PWA-чат с гео-каналами, «Мафией» и звонками. Что получилось и какие ошибки я совершил

Зачем ещё один чат

Все началось с того, что я решил создать веб - приложение в стиле рации для общения с друзьями - заходишь и говоришь, а остальные слушают. Далее добавились видеорация, сервис личных сообщений и звонков.

Так появился Push-to-talk Chat (pttchat.ru). Это чат-приложение, а не мессенджер: основа здесь комнаты, а личные сообщения и звонки идут дополнением. Проект я делаю один.

Что есть сейчас

  • Тематические комнаты: «Общая», «Новичкам», «Геймеры», «Музыка», «Кино и сериалы». Можно создавать свои, в том числе с паролем.
  • Гео-каналы: чат твоего города появляется автоматически. Приложение определяет город по геолокации, и канал создаётся, если его ещё нет.
  • Игры в комнатах: «Мафия» (от 4 игроков) и «Кто я?», где персонажа придумывает AI. Если играешь один, он же отвечает на твои вопросы.
  • Звонки: один на один с друзьями и групповые, голосовые и видеосообщения.
  • Личные чаты с E2EE: сообщения шифруются на устройстве.
  • AI-бот в комнатах.
  • PWA: ставится на телефон одной кнопкой, без App Store и Google Play, с push-уведомлениями.
  • Вход: через Telegram, VK ID, email или гостевой вход.

Ключевые решения

Сайт вместо приложения. Публикация в магазинах для соло-проекта долгая, а обновления через веб доходят до всех сразу. Цена вопроса: аккуратная работа service worker и push-уведомлений.

Один сервер вместо микросервисов. Node.js, Socket.io и SQLite. Для проекта моего масштаба этого хватает с запасом, а отлаживать один процесс проще, чем десять. Задел на рост есть: Socket.io умеет работать через Redis, если понадобится несколько инстансов, хотя хранилище тогда придётся выносить.

Два режима звонков. Для маленьких групп WebRTC работает напрямую между участниками (mesh), а для больших можно включить SFU на mediasoup. Режим выбирается при запуске сервера, динамического переключения по числу участников нет. Это компромисс в пользу простоты.

Игры в комнатах

Комнату можно сделать игровой. Только в таких комнатах появляются игровые панели.

«Кто я?». Один угадывает персонажа, задавая вопросы да/нет. Персонажа генерирует нейросеть, а если запрос к ней упал, подставляется запасной из списка. Остальные видят, кто загадан, и отвечают. Если угадывающий один, отвечает AI.

«Мафия». Лобби с хостом, минимум 4 игрока, роли раздаются приватно, ночью и днём чередуются действия и голосование.

Самая неочевидная часть здесь не правила, а разрывы связи. Что делать, если отключился хост? А тот, кто угадывает? А если ответ AI пришёл, когда раунд уже закончился? Все эти случаи приходится разбирать отдельно, иначе игра зависает.

Самое сложное: звонки между друзьями

Групповые комнаты оказались проще, чем «позвонить другу». В комнате все уже в одном месте, а у звонка есть состояния, гонки и вопрос «что, если человека нет онлайн?».

  • Занят или нет. Клиент сам сообщает серверу, что он в звонке, и звонящий получает статус «занят». Если вкладка упала посреди разговора, флаг может остаться, пока не сработает отключение сокета.
  • Три исхода вызова: идёт вызов, занят, недоступен. Если друг онлайн, ему приходит уведомление по сокету, если оффлайн, то push. Промежуточный случай самый интересный: друг онлайн, но не отвечает. Тогда через несколько секунд уходит push, чтобы разбудить устройство.
  • Рукопожатие. Нельзя отправлять WebRTC-предложение сразу после нажатия «Ответить»: у принимающего ещё не готов медиапоток. Пришлось выстроить цепочку: «ответил» → «готов» → только потом начало соединения. Без этого звонок то соединялся, то нет.
  • Из звонка один на один в групповой. Когда к разговору добавляют третьего, интерфейс не должен закрыть звонок, который на самом деле переходит в группу.

E2EE: что реально защищено

Личные чаты шифруются на устройстве: ECDH на кривой P-256 и AES-GCM. Приватный ключ хранится в браузере так, что прочитать его из JavaScript нельзя.

Но ограничения есть, и лучше сказать:

  • Ключи статические. Пара создаётся один раз на устройство. У схемы нет «ratchet» (регулярной смены ключей), а значит нет прямой секретности: если ключ когда-нибудь утечёт, прошлую переписку можно будет прочитать.
  • Отпечатки ключей не сверяются. Публичные ключи лежат на сервере, и защиты от подмены на уровне клиента нет.
  • Публичные комнаты не шифруются. Сервер видит их целиком, а метаданные видны везде.

Так что это защита от чтения переписки в хранилище, а не от скомпрометированного сервера. Дальше в планах сверка отпечатков и ротация ключей.

Ошибки, которые обошлись дорого

Главная страница превращалась в ошибку браузера. Из-за одной строчки в service worker при отсутствии сети и пустом кэше пользователь вместо приложения видел системную страницу ошибки. Мораль для любого PWA: каждая ветка запасного варианта обязана возвращать ответ, а не пустое значение. На моей машине с прогретым кэшем это почти не воспроизводилось.

Проверка есть в коде, но не работает. Ревью показало, что функция проверки подписи авторизации написана, но нигде не вызывается. Всё выглядело нормально, пока кто-нибудь целенаправленно не отправит поддельный запрос. Урок универсальный: функция в коде не равна проверке в роуте, а негативные сценарии нужно проверять с первого дня.

Спящие баги в неиспользуемом коде. В обработчике, который клиент никогда не вызывает, нашлись ссылки на необъявленные переменные. Пока никто не дошёл до этого пути, всё работало. Такие вещи ловит статический анализатор за секунды.

Общий вывод один: баги прячутся в негативных сценариях, а не на счастливом пути.

Что дальше

  • Динамическое переключение mesh/SFU по числу участников вместо одного режима на весь сервер.
  • Улучшение E2EE: сверка отпечатков ключей, ротация ключей, поддержка нескольких устройств.
  • Масштабирование за пределы одного инстанса: вынос хранилища с SQLite и Redis-адаптер для сокетов.

Мне нужны первые пользователи и живой фидбек: что непонятно, чего не хватает, где ломается. Заходите на pttchat.ru, а в комментариях расскажите, какая комната вам была бы нужна и какие функции вы бы добавили.