Соло-проект: 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, а в комментариях расскажите, какая комната вам была бы нужна и какие функции вы бы добавили.