Задача звучала просто: создать ВКС с jitsi и чатом, нарисовать красивый интерфейс в корп стиле.
Решение - написать собственный тонкий веб-клиент поверх matrix-js-sdk, который разговаривает с уже существующим Synapse-сервером и Jitsi-сервером, но выглядит и ведёт себя как часть внутреннего портала компании.
Ниже - подробный, местами болезненный дневник того, как это строилось: от каркаса на Vite до продакшена с реальными пользователями и очень поучительных багов.
Система состоит из трёх независимых сервисов за одним обратным прокси:
Браузер сотрудника │ ▼ 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. Полностью статическая сборка, раздаётся простым 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. У этого решения есть побочный эффект: такой токен не привязан ни к какому реальному устройству на сервере. Это всплывёт в разделе про шифрование.
| Компонент | Версия/детали |
|---|---|
| Язык | 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.
Рантайм - статический бинарник Node.js 22.14 (доставлен архивом, сервер без интернета).
| Компонент | Версия/детали |
|---|---|
| Homeserver | Synapse 1.159.0 (Docker, matrixdotorg/synapse:latest) |
| БД | PostgreSQL 16 (alpine) |
| Федерация | выключена (federation_domain_whitelist: []) |
| Шифрование | выключено осознанно |
docker-jitsi-meet (unstable-образы): web, jicofo, prosody (XMPP), JVB. Jibri/Jigasi не развёрнуты.
Identity provider - Keycloak (OIDC, confidential-клиент), шлюз перед бэкендами - oauth2-proxy v7.15.4.
| Компонент | Версия/детали |
|---|---|
| ОС | Debian 12 (bookworm) |
| Реверс-прокси | nginx 1.22.1 |
| Контейнеризация | Docker + Docker Compose (для Matrix и Jitsi; собственные Node-сервисы - как systemd-юниты, без Docker) |
| Process supervisor | systemd (4 юнита) |
| WAF | Wallarm-агент (режим monitoring) |
| Развёртывание | сборка в песочнице с интернетом (прод-сервер без исходящего интернета) -> архив по scp/rsync |
Первая версия клиента существовала только в виде собранного 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"
base: '/app/' (приложение живёт под путём /app/, рядом с остальными сервисами портала).Разработка велась пошагово, с остановкой и проверкой после каждого этапа - не пытаясь сразу написать всё приложение целиком.
Этап 0 - каркас. Инициализация Matrix-клиента из ответа GET /app/api/session (браузер никогда не обращается к identity provider напрямую - всю OIDC-логику делает бэкенд), пустой каркас с боковой навигацией и таблица маршрутов. Отдельно, вне общего каркаса - маршрут для экрана видеозвонка: ему нужна высота на весь экран (100vh), а не flex: 1 внутри общего лейаута, и это стоило закладывать сразу, а не чинить потом.
Этап 1 - личные чаты. Список чатов, окно переписки, текстовые сообщения. Сразу правильно, а не патчем потом:
Этап 2 - группы, вложения, редактирование. Важное архитектурное решение: любое медиа на основе mxc://-ссылок получает URL только через один-единственный авторизованный helper, который скачивает файл с Bearer-токеном и превращает в blob-URL. Обычный <img src="mxc://..."> не работает вообще (сервер отдаёт медиа только по авторизованному эндпоинту - тихий отказ без видимой ошибки), а если решить эту проблему один раз для аватарок и не закрепить как правило, она надёжно возвращается позже - во вложениях, в фоне чата, где угодно.
Редактирование сообщений - через relation m.replace, удаление - через redactEvent.
Этап 3 - реакции, ответы, упоминания, опросы, голосовые, фон чата. Несколько намеренно нестандартных решений:
<canvas> (без дополнительной библиотеки, около 30 строк), ссылка на него кладётся в состояние комнаты отдельным кастомным типом события.Этап 4 - звонки. Кнопка создания видеоконференции транслитерирует название в URL-слаг, экран звонка встроен через JitsiMeetExternalAPI, который грузится скриптом прямо с сервера конференций, а не из 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 страницы) в старых чатах видно только последние несколько сообщений. Причина оказалась двойной:
timelineSupport: true.paginateEventTimeline({ backwards: true }), с компенсацией позиции скролла, чтобы подгрузка не «подбрасывала» пользователя.Cannot enable encryption on MatrixClient with unknown deviceId и следом поток POST /keys/upload 400. Корень - архитектурное решение из вводной части: мост авторизации логинит пользователя через административный API сервера, который не создаёт устройство на сервере. Любой deviceId, который придумает клиент, серверу неизвестен - попытка инициализировать крипто-стек и опубликовать ключи устройства закономерно проваливается. Поскольку шифрование в этой инсталляции выключено на уровне конфигурации сервера - решение было просто не инициализировать крипто-модуль SDK вообще. Обычные, нешифрованные комнаты работают штатно.
У одного конкретного пользователя иногда своё же сообщение в ленте выглядело отправленным другим человеком. Причина: мост авторизации сам вычисляет Matrix ID пользователя из заголовков, которые присылает SSO-прокси, не сверяя результат с сервером. Если формат этих заголовков чуть отличается между заходами (регистр, порядок источника 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 такого административного метода попросту не существует (проверено прямым запросом к серверу, не связано с правами токена). Решение - не изобретать обходной путь, а принять, что фон чата живёт как любой обычный файл и может быть вычищен политикой хранения через несколько дней.
Функцию попросили в формате «выбрать дату/время и отправить сообщение позже, не проваливаясь в сам чат». Первая реализация была честно предупреждена как временная и заменена на настоящую серверную задачу:
На этом шаге всплыл поучительный, почти комичный баг: systemd-юнит бэкенд-сервиса запущен в песочнице (ProtectSystem=strict), и каталогу, куда исходно писался JSON-файл с отложенными сообщениями, разрешена только чтение - юнит специально разрешает запись лишь в один конкретный каталог данных. Новая фича по умолчанию писала не туда, куда нужно, и падала с EROFS: read-only file system. Исправлено сменой пути по умолчанию на разрешённый каталог, без единой правки самого systemd-юнита или файла с секретами.
Самая многократно переписываемая логика за весь проект - хороший пример того, что «очевидное» поведение чата на самом деле состоит из нескольких конфликтующих требований:
После этого удаления выяснилось: без него ломается самое первое открытие только что созданного чата (в момент переключения комнаты лента ещё пустая, scrollHeight равен нулю - прокручивать пока некуда, а как только сообщения подгружаются мгновением позже, повторно это уже никто не проверяет). Решение - эффект, пересматривающий условие при каждом изменении числа сообщений, пока не встанет успешно один раз, и затем - никогда больше принудительно для этой открытой комнаты.
Отдельно обнаружилось и было исправлено: прокрутка к «разделителю непрочитанных» иногда визуально ощущалась как «чат открылся в старых сообщениях» - если этот разделитель указывал на сообщение, уже подгруженное из более ранней истории, заметно выше самого низа. Финальное решение: разделитель остаётся только визуальной меткой, но не управляет позицией скролла - чат при открытии всегда идёт в самый низ, к новым сообщениям.
Финальный виток - логами в браузере доказано, что после отключения пункта 5 чат переставал докручиваться и при новых сообщениях, пока пользователь и так стоял внизу и просто смотрел на живую переписку - то есть требование «не выдёргивать при чтении истории» по ошибке реализовали как «не докручивать вообще никогда». Итоговая логика: если пользователь сам проскроллил вверх (читает историю) - новое сообщение ленту не трогает; если он и так был у низа - новое сообщение подтягивает вниз. Отличать эти два случая пришлось через постоянно поддерживаемый флаг «у низа ли пользователь прямо сейчас», обновляемый и по событию скролла, и по факту изменения числа сообщений.
Пользователи стабильно жаловались: бейдж с числом непрочитанных сообщений либо не появляется вовсе, либо появляется и тут же гаснет, причём именно в момент системного уведомления о новом сообщении.
Разбирательство заняло несколько итераций, каждая опровергала предыдущую гипотезу:
После того как пользователи закрыли лишние вкладки и параллельные сессии - счётчик заработал корректно.
Отдельная деталь для будущих себя: диагностический console.debug() в Chrome DevTools относится к уровню «Verbose», который скрыт фильтром консоли по умолчанию - первая попытка логирования «ничего не показала» именно поэтому, а не потому что код не сработал. Для временной отладки в продакшене надёжнее console.warn().
Комментарии и обсуждение — на интерактивной версии статьи.
Перейти к обсуждению →