Как мы собрали корпоративный мессенджер на Matrix: инженерный дневник

Задача звучала просто: создать ВКС с 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 ~/project
git init
npm create vite@latest . -- --template react-ts
git 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
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.