Зачем вообще собственный мессенджер
Задача звучала просто: создать ВКС с jitsi и чатом нарисовать красивый интерфейс в корп стиле.
Решение — написать собственный тонкий веб-клиент поверх matrix-js-sdk, который разговаривает с уже существующим Synapse-сервером и Jitsi-сервером, но выглядит и ведёт себя как часть внутреннего портала компании.
Ниже — подробный, местами болезненный дневник того, как это строилось: от каркаса на Vite до продакшена с реальными пользователями и очень поучительных багов.
Все домены, IP-адреса, имена пользователей и название организации в этой статье вымышлены или обобщены. Реальные секреты (токены, пароли, внутренние адреса) в статью не попали ни в каком виде.
Архитектура в двух словах
Система состоит из трёх независимых сервисов за одним обратным прокси:
Браузер сотрудника │ ▼ reverse-proxy (nginx) ──> SSO (OIDC) ──> Keycloak-подобный identity provider │ ├── /app/* -> статика React-приложения (веб-клиент) ├── /app/api/* -> Node.js "мост авторизации" (auth-bridge) ├── /_matrix/* -> Synapse (сервер Matrix) └── /external_api.js, /app/call/* -> Jitsi Meet
-
Веб-клиент — React 19 + Vite + TypeScript +
matrix-js-sdk. Полностью статическая сборка (набор.js/.css/.wasmфайлов), раздаётся простым Node-сервером. -
Мост авторизации — небольшой Node.js-сервис. Его единственная задача: превратить корпоративную SSO-сессию сотрудника в Matrix access-токен, не заставляя сотрудника логиниться ещё раз и не выдавая ему пароль от Matrix-аккаунта.
-
Synapse — обычный self-hosted сервер Matrix, шифрование на нём отключено осознанно (внутренний контур, не требуется).
-
Jitsi Meet — отдельный сервер видеоконференций, клиент грузит его
external_api.jsнапрямую с этого сервера, а не из npm-пакета.
Ключевая архитектурная особенность, которая потом аукнется несколько раз в этой статье: мост авторизации минтит Matrix-токены через административный API Synapse (POST /_synapse/admin/v1/users/<id>/login) — это позволяет войти “от имени” сотрудника, не зная его пароль и не проводя отдельный вход в Matrix. У этого решения — по крайней мере с той версией Synapse, что использовалась — есть побочный эффект: такой токен не привязан ни к какому реальному устройству на сервере. Это всплывёт в разделе про шифрование.
Технологический стек
Фронтенд (portal-web)
|
|
|
|---|---|
|
Язык |
TypeScript ~6.0 |
|
UI-фреймворк |
React 19.2 + react-dom |
|
Роутинг |
react-router 8.3 (не react-router-dom) |
|
Сборщик |
Vite 8.2 (обычный Rollup-бандлер, без Rolldown) |
|
Matrix-клиент |
matrix-js-sdk 42.3 |
|
Крипто-модуль SDK |
@matrix-org/matrix-sdk-crypto-wasm 18.8 (обязательная инициализация SDK; само E2E-шифрование выключено) |
|
Иконки |
lucide-react 1.43 |
|
Эмодзи-пикер |
emoji-picker-react 4.20 |
|
Стили |
CSS Modules + один файл CSS-переменных темы, prefers-color-scheme |
|
Состояние |
без стейт-менеджера — React-хуки поверх событий MatrixClient |
|
Тесты |
Vitest 5.0 |
|
Линтер |
oxlint 1.79 |
|
Раздача статики |
собственный Node.js-сервер (server.mjs), без веб-фреймворка |
Бэкенд-сервисы
Все — Node.js без npm-зависимостей, только встроенные модули: fetch, fs, crypto, http.
-
matrix-auth-bridge — мост Keycloak-сессия -> Matrix access-токен, фоновая доставка отложенных сообщений
-
jwt-minter — выдача модераторского JWT для Jitsi
-
portal-web/server.mjs — раздача собранного фронтенда
Рантайм — статический бинарник Node.js 22.14 (доставлен архивом, сервер без интернета).
Мессенджер-бэкенд (Matrix)
|
|
|
|---|---|
|
Homeserver |
Synapse 1.159.0 (Docker, matrixdotorg/synapse:latest) |
|
БД |
PostgreSQL 16 (alpine), postgres:16-alpine |
|
Федерация |
выключена (federation_domain_whitelist: []) |
|
Шифрование |
выключено осознанно |
Видеоконференции (Jitsi)
docker-jitsi-meet (unstable-образы): web, jicofo, prosody (XMPP), JVB. Jibri/Jigasi не развёрнуты.
SSO / авторизация
|
|
|
|---|---|
|
Identity provider |
Keycloak (OIDC, confidential-клиент) |
|
Шлюз перед бэкендами |
oauth2-proxy v7.15.4 (Go-сборка) |
Инфраструктура
|
|
|
|---|---|
|
ОС |
Debian 12 (bookworm) |
|
Реверс-прокси |
nginx 1.22.1 |
|
Контейнеризация |
Docker + Docker Compose (для Matrix и Jitsi; собственные Node-сервисы — как systemd-юниты, без Docker) |
|
Process supervisor |
systemd (4 юнита: matrix-auth-bridge, jwt-minter, oauth2-proxy-go, portal-web) |
|
WAF |
Wallarm-агент (режим monitoring) |
|
Развёртывание |
сборка в песочнице с интернетом (сам прод-сервер без исходящего интернета) -> архив по scp/rsync |
Протоколы/форматы
Matrix Client-Server API, XMPP (внутри Jitsi/Prosody), OIDC/OAuth2, JWT (для Jitsi-модератора), WebRTC (звонки через JVB).
Стадия 0: катастрофа с исходниками и урок про git
Первая версия клиента существовала только в виде собранного dist/ на проде и во временной рабочей папке на машине разработчика — исходники никогда не коммитились в git. Машина ушла в перезагрузку, временная папка была в /tmp и не пережила рестарт. Исходники потерялись безвозвратно.
К счастью, сам собранный dist/ (то, что реально крутится на проде) уцелел и был отдельно забэкаплен. Пересборка велась по:
-
детально задокументированному поведению (что должно уметь приложение — из более раннего технического задания);
-
сверке с живым собранным бандлом на проде (там нашлась как минимум одна незадокументированная, но реально работающая фича — фон чата, под неё уже существовали серверные эндпоинты).
Урок, который был осознанно заложен в процесс с этого момента: git-репозиторий создаётся ПЕРВЫМ действием, раньше даже npm create vite@latest. Коммит — после каждого логически законченного куска работы, а не “в конце этапа”. Дальше в статье это правило не нарушалось ни разу.
mkdir -p ~/project && cd ~/projectgit initnpm create vite@latest . -- --template react-tsgit add -A && git commit -m "chore: scaffold vite react-ts template"
Репозиторий сразу подключили к двум удалённым: приватному внутреннему Gitlab-серверу.
Технологические решения, зафиксированные с самого начала
-
React 19 и
react-router(актуальный путь импорта, а не устаревающийreact-router-dom). -
matrix-js-sdk, актуальная стабильная версия на момент старта, плюс явная зависимость на WASM-модуль крипто-библиотеки SDK (обязательная часть инициализации клиента в актуальных версиях SDK — это не значит, что сообщения шифруются; на этом сервере шифрование отключено на уровне конфигурации).
-
Обычный Rollup-бандлер Vite, без экспериментальных альтернатив — единственная нестандартная настройка это
base: '/app/'(приложение живёт не в корне домена, а под путём/app/, рядом с остальными сервисами портала). -
Без Redux/Zustand/react-query: всё состояние живёт в объекте
MatrixClientи React-хуках поверх его событий — это идиоматичный подход дляmatrix-js-sdk, второй слой стейт-менеджмента только добавил бы сложности. -
CSS Modules по компонентам + один файл с CSS-переменными темы (
--surface-bg,--bubble-in,--bubble-outи т.п.), поддержка светлой/тёмной темы черезprefers-color-schemeс ручным оверрайдом вlocalStorage.
Этапы разработки
Разработка велась пошагово, с остановкой и проверкой после каждого этапа — не пытаясь сразу написать всё приложение целиком.
Этап 0 — каркас
Скелет приложения: инициализация Matrix-клиента из ответа GET /app/api/session (сам браузер никогда не обращается к identity provider напрямую — всю OIDC-логику делает бэкенд), пустой каркас с боковой навигацией и таблица маршрутов. Отдельно, вне общего каркаса — маршрут для экрана видеозвонка: ему нужна высота на весь экран (100vh), а не flex: 1 внутри общего лейаута, и как показала практика, это стоило закладывать сразу, а не чинить потом.
Проверка: приложение грузится, сессия реально устанавливается, тема переключается.
Этап 1 — личные чаты
Список чатов, окно переписки, обычные текстовые сообщения. Уже на этом этапе — сразу правильно, а не патчем потом:
-
Поиск уже существующего личного чата с собеседником проверяет оба статуса участия — не только “уже состою”, но и “приглашён, но ещё не принял” — иначе поиск пропускал такие комнаты и плодил дубликаты переписки с одним и тем же человеком.
-
Автопринятие приглашений подключается сразу при создании Matrix-клиента, до того как какой-либо код успеет искать существующие чаты — иначе появлялось состояние гонки.
Этап 2 — группы, вложения, редактирование
Групповые чаты, добавление участников, отправка файлов и изображений. Здесь же — важное архитектурное решение: любое медиа на основе mxc://-ссылок получает URL только через один-единственный авторизованный helper, который скачивает файл с Bearer-токеном и превращает в blob-URL. Обычный <img src="mxc://..."> не работает вообще (сервер отдаёт медиа только по авторизованному эндпоинту, без токена — тихий отказ без видимой ошибки), а если решить эту проблему один раз для аватарок и не закрепить как правило, она надёжно возвращается позже — уже во вложениях, в фоне чата, где угодно.
Редактирование сообщений — через relation m.replace, удаление — через redactEvent.
Этап 3 — реакции, ответы, упоминания, опросы, голосовые, фон чата
Стандартные механизмы протокола (реакции, ответ на сообщение) и несколько намеренно нестандартных решений:
-
Упоминания участников и опросы реализованы через собственные, не-стандартные типы событий/полей контента (в духе
custom.namespace.poll), а не через существующие MSC-предложения — сознательный выбор в пользу простоты и предсказуемости над соответствием черновым, ещё не стабилизировавшимся стандартам. -
Фон чата: изображение ресайзится на клиенте через
<canvas>(без дополнительной библиотеки, около 30 строк), загружается, ссылка на него кладётся в состояние комнаты отдельным кастомным типом события. -
Голосовые сообщения — через
MediaRecorder.
Этап 4 — звонки
Кнопка создания видеоконференции транслитерирует название в URL-слаг, экран звонка встроен через JitsiMeetExternalAPI, который грузится скриптом прямо с сервера конференций (<script src="https://meet.example.com/external_api.js">), а не из npm-пакета — так гарантированно используется совместимая с сервером версия.
Здесь же сработало решение из Этапа 0 про отдельный маршрут вне общего лейаута — 100vh на видеозвонке ни разу не пришлось чинить постфактум.
Этап 5 — статус прочтения
Протокол Matrix даёт только “участник X прочитал вплоть до события E”, а не булев флаг “это конкретное сообщение прочитано”. Понадобился отдельный компаратор позиции двух событий в таймлайне:
function isReadByUser(room, userId, messageEventId): boolean { const readUpTo = room.getEventReadUpTo(userId) if (!readUpTo) return false const cmp = compareEventPosition(room, readUpTo, messageEventId) return cmp !== null && cmp >= 0}
В личном чате — одна/две галочки на своём сообщении. В групповом — “прочитано X из Y”, по клику открывается список кто и когда прочитал.
Важная деталь дизайна: это отдельный хук от логики разделителя “непрочитанные сообщения”. Разделитель фиксирует свою позицию прочтения один раз при открытии чата, до отправки своего read receipt. Статус прочтения читает чужие receipt-ы непрерывно, пока чат открыт. Смешивание этих двух хуков в один почти наверняка сломало бы порядок операций одного из них при последующей правке другого — поэтому с самого начала это два независимых куска кода.
Этап 6 — профиль, контакты, уведомления, брендинг
Профиль пользователя, справочник контактов, браузерные уведомления о новых сообщениях, финальный проход по всем текстам через файл конфигурации бренда. Полный smoke-тест перед первым реальным деплоем: загрузка приложения, переключение темы, отсутствие дублей личных чатов, аватарки и медиа, все типы сообщений и реакции, опрос с несколькими голосами, фон чата, три сценария входа в звонок, статус прочтения в обоих видах чатов, финальная сборка даёт ожидаемый набор файлов.
Продакшен: что оказалось не так просто, как в теории
Первая версия прошла все внутренние проверки и уехала на прод. А дальше начался второй, гораздо более длинный этап — реальные пользователи находили то, что не покрывалось ни одним чек-листом.
“Пропадают старые сообщения при каждом деплое”
Симптом: после каждого обновления фронтенда (то есть после hard-refresh страницы) в старых чатах видно только последние несколько сообщений, будто вся история стёрлась. Причина оказалась двойной:
-
matrix-js-sdkпо умолчанию агрессивно обрезает старые события из таймлайна в памяти по мере поступления новых — это не баг синхронизации и не удаление на сервере, просто клиент их больше не хранит и не рисует. Лечится одним флагом клиента:timelineSupport: true. -
Начальная синхронизация (
initialSyncLimit) при каждой свежей загрузке страницы подгружает только “хвост” каждого чата, а никакой явной подгрузки истории назад изначально не было реализовано. Добавили: при прокрутке к верху ленты вызываетсяpaginateEventTimeline({ backwards: true }), с компенсацией позиции скролла, чтобы подгрузка не “подбрасывала” пользователя.
“От шифрования сыплются ошибки в консоли”
Cannot enable encryption on MatrixClient with unknown deviceId и следом поток POST /keys/upload 400. Корень — то самое архитектурное решение из вводной части: мост авторизации логинит пользователя через административный API сервера, который не создаёт устройство на сервере. Любой deviceId, который придумает клиент, серверу неизвестен — попытка инициализировать крипто-стек и опубликовать ключи устройства закономерно проваливается. Поскольку шифрование в этой инсталляции выключено на уровне конфигурации сервера и объективно не требуется — решение было просто не инициализировать крипто-модуль SDK вообще. Обычные, нешифрованные комнаты работают штатно.
“Своё сообщение отображается как чужое”
У одного конкретного пользователя иногда своё же сообщение в ленте выглядело отправленным другим человеком. Причина: мост авторизации сам вычисляет Matrix ID пользователя из заголовков, которые присылает SSO-прокси (условно — из имени пользователя или email в claim’ах токена), не сверяя результат с сервером. Если формат этих заголовков чуть отличается между заходами (регистр, порядок источника claim’а), мост может вычислить немного другой ID, чем тот, под которым отправлялись прошлые сообщения — и client.getUserId() перестаёт совпадать с event.getSender() у собственных же сообщений.
Фикс — при создании клиента дополнительно вызывать client.whoami() и, если сервер называет другой ID, доверять именно ему:
const whoami = await client.whoami()if (whoami.user_id && whoami.user_id !== client.credentials.userId) { client.credentials.userId = whoami.user_id}
“Фон чата не защищён от удаления через три дня”
У функции фона чата была задумана защита от автоматической очистки медиа (у сервера есть политика удаления неиспользуемых файлов через несколько дней). Для этого в бэкенде был реализован вызов административного эндпоинта “защитить медиа от удаления”. На практике этот эндпоинт стабильно возвращал 404 — оказалось, что в установленной версии Synapse такого административного метода попросту не существует (проверено прямым запросом к серверу, не связано с правами токена — тот же токен успешно вызывает другие административные методы). Решение — не изобретать альтернативный обходной путь, а принять, что фон чата живёт как любой обычный файл и может быть вычищен политикой хранения через несколько дней; вызов защиты убран из кода.
“Отложенные сообщения” — от клиентского костыля к серверной реализации
Функцию попросили в формате “выбрать дату/время и отправить сообщение позже, не проваливаясь в сам чат”. Первая реализация была честно предупреждена как временная и заменена на настоящую серверную задачу:
-
Перед любым изменением бэкенда — резервная копия.
-
В хранилище (обычный JSON-файл на диске бэкенда) складываются отложенные сообщения: кто, куда, что, когда отправить.
-
Фоновый таймер каждые N секунд проверяет, не наступило ли время очередного сообщения, и отправляет его от имени автора (тем же приёмом “логин через административный API”, что и у самого моста авторизации).
-
У пользователя, который запланировал сообщение, в интерфейсе появляется значок часов со счётчиком — сколько отложенных сообщений ждут отправки в этом чате, и возможность отменить любое из них.
На этом шаге всплыл поучительный, почти комичный баг: systemd-юнит бэкенд-сервиса запущен в песочнице (ProtectSystem=strict), и каталогу, куда исходно писался JSON-файл с отложенными сообщениями, разрешена только чтение — юнит специально разрешает запись лишь в один конкретный каталог данных. Новая фича по умолчанию писала не туда, куда нужно, и падала с EROFS: read-only file system. Исправлено сменой пути по умолчанию на разрешённый каталог, без единой правки самого systemd-юнита или файла с секретами.
Одиссея с автоскроллом ленты сообщений
Это оказалась самая многократно переписываемая логика за весь проект — хороший пример того, что “очевидное” поведение чата на самом деле состоит из нескольких конфликтующих требований:
-
Сначала — безусловная прокрутка вниз при любом изменении ленты. Просто и сразу выявило проблему: при чтении старой истории новое сообщение выдёргивало пользователя обратно вниз.
-
Попытка сделать “умную” прокрутку — вниз, только если пользователь и так был у низа ленты. Реализация оказалась сырой и была отклонена.
-
Возврат к безусловной прокрутке как временной мере.
-
Прокрутка к “разделителю непрочитанных” при открытии чата (как в большинстве мессенджеров) вместо жёсткого “в самый низ”.
-
Прямое требование убрать автоматическую прокрутку при новом сообщении полностью — чат не должен никуда выдёргивать, пока читаешь историю.
-
После этого удаления выяснилось: без него ломается самое первое открытие только что созданного чата (в момент переключения комнаты лента ещё пустая,
scrollHeightравен нулю — прокручивать пока некуда, а как только сообщения подгружаются мгновением позже, повторно это уже никто не проверяет). Решение — не просто одноразовый эффект по смене комнаты, а эффект, пересматривающий условие при каждом изменении числа сообщений, пока не встанет успешно один раз, и затем — никогда больше принудительно для этой открытой комнаты. -
Отдельно обнаружилось и было исправлено: прокрутка к “разделителю непрочитанных” иногда визуально ощущалась как “чат открылся в старых сообщениях” — если этот разделитель указывал на сообщение, уже подгруженное из более ранней истории (в том числе из более раннего посещения в той же вкладке браузера), заметно выше самого низа. Финальное решение: разделитель остаётся только визуальной меткой (“непрочитанные сообщения” — линия в ленте), но не управляет позицией скролла — чат при открытии всегда идёт в самый низ, к новым сообщениям.
-
Финальный виток — реальными логами в браузере доказано, что после отключения пункта 5 чат переставал докручиваться и при новых сообщениях, пока пользователь и так стоял внизу и просто смотрел на живую переписку — то есть требование “не выдёргивать при чтении истории” по ошибке реализовали как “не докручивать вообще никогда”. Итоговая логика: если пользователь сам проскроллил вверх (читает историю) — новое сообщение ленту не трогает; если он и так был у низа — новое сообщение подтягивает вниз, как в любом обычном мессенджере. Отличать эти два случая друг от друга оказалось необходимо через постоянно поддерживаемый флаг “у низа ли пользователь прямо сейчас”, обновляемый и по событию скролла, и по факту изменения числа сообщений (высота ленты могла вырасти без единого события scroll).
Детективная история со счётчиком непрочитанных
Пользователи стабильно жаловались: бейдж с числом непрочитанных сообщений либо не появляется вовсе, либо появляется и тут же гаснет, причём именно в момент системного уведомления о новом сообщении.
Разбирательство заняло несколько итераций, каждая опровергала предыдущую гипотезу:
-
Первая гипотеза — гонка между событием “пришло новое сообщение” и обновлением серверного счётчика непрочитанных на объекте комнаты. Проверка исходников SDK эту гипотезу опровергла: сервер, наоборот, присылает актуальный счётчик раньше, чем обрабатываются сами сообщения.
-
Вторая гипотеза — окно чата, единожды открытое, слишком охотно отправляет отметку “прочитано” на любое новое сообщение в выбранном чате, даже если вкладка браузера неактивна. Гипотеза оказалась верной лишь частично — реальным багом, но не тем, что описывали пользователи (по их подтверждению, злополучный чат вообще не был открыт в момент, когда пропадал счётчик).
-
Добавлено прямое логирование в консоль браузера: каждый вызов “отметить прочитанным” с полным стеком вызова, и каждое изменение серверного счётчика с меткой времени.
-
Логи показали чистую картину: серверный счётчик действительно скачет 1 -> 0 в течение сотни миллисекунд, без единого вызова “отметить прочитанным” в этой конкретной вкладке. Значит, кто-то ещё, под тем же аккаунтом, отправляет эту отметку — то есть где-то есть вторая забытая открытая сессия того же пользователя (другая вкладка, другое устройство), в которой этот самый чат уже открыт и прокручен вниз — она и отмечает каждое новое сообщение прочитанным почти мгновенно.
-
После того как пользователи закрыли лишние вкладки и параллельные сессии — счётчик заработал корректно.
Здесь стоит отдельно отметить методологический урок: первые две гипотезы были логически безупречны, подкреплены чтением исходного кода библиотеки — и обе оказались либо неверны, либо неполны. Только прямое, подробное логирование прямо в продакшене (временное, снятое сразу после диагностики) дало однозначный ответ. Гадать по описанию бага дальше двух-трёх итераций не имело смысла — быстрее оказалось добавить наблюдаемость и посмотреть на реальные данные.
Отдельная деталь для будущих себя: диагностический console.debug() в Chrome DevTools относится к уровню “Verbose”, который скрыт фильтром консоли по умолчанию — первая попытка логирования “ничего не показала” именно поэтому, а не потому что код не сработал. Для временной отладки в продакшене надёжнее console.warn().
Что осталось в архитектуре как осознанные компромиссы
-
Кастомные, не-MSC типы событий для упоминаний, опросов, фона чата, аватарок групп — вместо экспериментальных, ещё не стабилизировавшихся предложений по расширению протокола. Простое, предсказуемое поведение важнее теоретической совместимости с клиентами, которые всё равно не используются.
-
Нет шифрования — осознанное решение уровня инфраструктуры (замкнутый внутренний контур), а не недостаток реализации.
-
Один системный процесс на бэкенд — Node.js без очередей и брокеров сообщений, с простым файлом на диске под отложенные задачи. Для внутреннего инструмента с некритичным объёмом это оправданная простота, а не технический долг.
-
Единая точка бренд-конфигурации — избавляет от риска “забыли заменить название компании в одном из полусотни файлов” при каждой публикации в открытый репозиторий.
Главные практические выводы
-
Git — с первого коммита, без исключений. Потеря исходников из-за забытой временной папки — единственная причина, по которой этот проект вообще пришлось переписывать с нуля.
-
Закладывать структурные решения сразу, а не патчить постфактум — отдельный маршрут для полноэкранного звонка, единый helper для авторизованной загрузки медиа, разделение хуков “своя” и “чужая” позиция прочтения — каждое из этих решений, принятое на старте, избавило от болезненной правки позже.
-
Бэкап перед любой серверной правкой, диагностика перед любой серверной гипотезой. Административные эндпоинты сервера стоит проверять прямым запросом, а не доверять документации версии, которая может не совпадать с реально установленной.
-
Когда третья подряд гипотеза по логам не подтверждается — прекратить гадать и добавить логирование. Разбирательство со счётчиком непрочитанных заняло бы в разы меньше времени, если бы подробные логи были добавлены сразу после первого же неподтвердившегося предположения.
-
“Очевидное” поведение чата (автоскролл) на самом деле требует явно сформулированных, отдельных правил под разные ситуации (“читаю историю” vs “слежу за живой перепиской”) — попытка описать это одним общим эффектом раз за разом давала регрессию то в одну, то в другую сторону.
Ссылка на репозиторий: https://github.com/skvorezvictor/go-servise-vks-chats
ссылка на оригинал статьи https://habr.com/ru/articles/1082044/