feat(deploy): version-coherence — WS-анонс версии сервера + guarded reload устаревших вкладок после редеплоя #481
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Проблема / Цель
SPA — долгоживущий клиент: вкладку держат открытой часами/днями, а сервер за это время передеплоивают (docker
up -dзаменяет контейнер целиком). После редеплоя открытая вкладка остаётся в некогерентном состоянии:index.html(уже в памяти вкладки) ссылается на content-hashed чанки, которых в новом образе нет. Ленивыйimport()не находит файл → catch-all*в static.module.ts отдаётindex.htmlсContent-Type: text/html→ WebKit:'text/html' is not a valid JavaScript MIME type(наблюдали в проде при сохранении записи);Сейчас есть только реактивный предохранитель — chunk-load-error-boundary.tsx: ловит уже случившуюся ошибку чанка и делает
location.reload()(один раз за сессию). То есть пользователь всё равно сначала упирается в белый экран/ошибку.Цель: сделать сигнал проактивным — сервер сообщает подключённым клиентам свою версию по уже существующему WS; клиент сравнивает со своей вшитой и делает guarded reload ДО того, как упрётся в битый чанк. Окно некогерентности схлопывается до секунд после реконнекта. Политика UX (выбрана владельцем): баннер + reload в фоне — видимую активную вкладку не дёргаем.
Осознанно НЕ входит (см. «Границы scope»): retention ассетов, честный 404 на
/assets, CDN, REST-endpoint версии/polling.Контекст и исследование
Стек: монорепо (форк Docmost),
apps/server(NestJS + Fastify),apps/client(React + Vite + socket.io-client).Опоры (всё уже есть в коде):
defineкладёт глобальAPP_VERSION(функцияresolveAppVersion: envAPP_VERSION→git describe --tags --always→v${package.json version}). Объявлена в vite-env.d.ts, показывается в шапке app-header.tsx:94. Это версия текущего бандла вкладки — отдельную вводить не нужно.transports:['websocket']) поверх Redis-адаптера ws-redis.adapter.ts. Все прикладные события идут ОДНИМ событием'message'с дискриминаторомoperation(use-query-subscription.ts, types.ts).io(SOCKET_URL,…)(SOCKET_URL===undefined→ тот же origin), кладёт вsocketAtom, тут же навешанnewSocket.on('connect', …). Готовый образец чистой-логики-с-инъекцией: connect-resync.ts + connect-resync.test.ts.sessionStorage["chunk-reload-attempted"], обёрнут в try/catch (при недоступности storage — НЕ авто-reload). Конвенция проекта — не более одного авто-reload за сессию (см.docs/stabilization-plan.mdT4.6/S51: вариант с «оконным счётчиком» был заменён ревью на этот простой one-shot).@mantine/notificationsуже в зависимостях и смонтирован в main.tsx → баннер штатно.EnvironmentServiceглобальный (@Global, environment.module.ts).APP_VERSION(.github/workflows/develop.yml,release.yml).Ключевой пробел: сервер в рантайме свою версию НЕ знает. В Dockerfile
ARG/ENV APP_VERSIONзаданы только в стадииbuilder(там его читает Vite), в финальную стадиюinstallerон НЕ проброшен.Смежное (не блокеры): epic #195 (mobile/offline/PWA), PR #120 (unified service worker) — SW пока не влит.
Дизайн
1. Источник истины для build id (R3) —
client/dist/version.jsonПроблема наивного подхода «пробросить
ENV APP_VERSIONна сервер»: клиентская версия baked at build, серверная из runtime-env — это две копии, равные лишь по совпадению (CI передал один build-arg в две стадии). Любой runtime-путь их разводит и даёт ложный mismatch → вечный баннер: оператор source-деплоя ставитAPP_VERSION=xyzв.env(сервер видит, клиентский бандл — нет, там фолбэк на git/pkg); либоdocker run -e APP_VERSION=…без пересборки клиента.Решение — один артефакт, из которого читают обе стороны:
const appVersion = resolveAppVersion(envPath)РОВНО ОДИН раз; использовать его И вdefine: { APP_VERSION: JSON.stringify(appVersion) }, И в маленьком плагине, который вwriteBundle/closeBundleпишет<outDir>/version.json={"version": appVersion}. Один и тот жеappVersion→ клиентский бандл иversion.jsonтождественны по построению.client/dist/version.jsonпри старте (файл уже копируется в образ Dockerfile'ом —COPY --from=builder /app/apps/client/dist …). НИКАКОГО runtime-env, НИКАКИХ изменений Dockerfile.Так серверная и клиентская версии — один и тот же выход
resolveAppVersion()из одной сборки, разойтись не могут (истинный единый источник, R3). Отсутствие/непарс файла → пустая строка → фича молча инертна (fail-safe).Реализация на сервере:
resolveClientDistPath()в common/helpers из существующей логики static.module.ts (там ужеjoin(__dirname,'..','..','..','..','client/dist')); использовать и там, и в новом ридере (единый источник пути — глубина../зависит от расположения модуля, не дублировать!).readClientBuildVersion(clientDistPath: string): stringрядом с helpers:fs.readFileSync(join(clientDistPath,'version.json'))→JSON.parse().versiontrimmed; любая ошибка (нет файла / битый JSON) →''. Юнит-тестируема через временный путь.2. Контракт WS — отдельное событие
app-versionОтдельное socket.io-событие
app-version(НЕ подмешивать в'message'/WebSocketEvent: это не room-scoped и не про react-query; член без поляoperationсломал бы дискриминацию union в use-query-subscription):{ version: string }.AppVersionSocketPayload = { version: string }объявить в Mantine-free клиентском модуле рядом с чистой логикой; не расширятьWebSocketEvent.Механизм доставки — только per-connect анонс (ws.gateway.ts):
onModuleInitпрочитать версию один раз вprivate readonly-подобное поле:this.appVersion = readClientBuildVersion(resolveClientDistPath()).handleConnection, ПОСЛЕ успешной auth иclient.join(...)(success-ветка, не catch):client.emit('app-version', { version: this.appVersion }).app-version reload: ACTIVE (v=<X>)либоDISABLED (version.json missing/empty)— наблюдаемость (иначе оператор не узнает, что механизм выключен).Почему именно per-connect, а НЕ broadcast «на старте контейнера»: в single-container docker на момент старта нового контейнера подключённых клиентов НЕТ (старый убит, сокеты порваны). Доставку делает авто-реконнект: клиент переподключается к новому контейнеру → в
handleConnectionполучает новую версию. Broadcast наOnApplicationBootstrapнамеренно НЕ добавляем — в кластере он вызвал бы синхронный веерный reload всего флота (thundering herd) и ложные reload на клиентов, которых LB вернул на старую ноду; естественный реконнект покрывает и кластер (клиент перезагрузится, когда его старая нода задрейнится) со свободным стаггером.3. Клиент — приём + guarded reload
Слушатель регистрируется синхронно в том же
useEffectuser-provider.tsx, где создаётся сокет (там же, гдеnewSocket.on('connect',…)), ДО установления соединения — иначе гонка: сервер шлётapp-versionсразу вhandleConnection, и на быстром localhost-коннекте событие потерялось бы. Тело:newSocket.on('app-version', p => triggerGuardedReload(p?.version)).Чистая логика (Mantine-free модуль, юнит-тест по образцу connect-resync.test.ts; версии ИНЪЕКТИРУЮТСЯ параметрами — глобаль
APP_VERSIONв vitest не определена):Грязная оболочка
triggerGuardedReload(rawServerVersion)в отдельном модуле (импортирует@mantine/notifications, читаетAPP_VERSIONи sessionStorage), держит модульный флаг идемпотентности:serverV = (rawServerVersion ?? '').trim(),clientV = (APP_VERSION ?? '').trim().autoReloadUsed = hasAutoReloaded()— читает общий флаг (см. ниже) через try/catch; ошибка чтения → трактовать какtrue(fail toward «не перезагружать»).action = decideVersionAction({serverVersion: serverV, clientVersion: clientV, autoReloadUsed}).if (action === 'noop') return;handled) —return(не плодить слушатели/баннеры). Иначеhandled = true.action === 'banner'→ показать баннер (ниже),console.warnдля диагностики, БЕЗ авто-reload. Выход.action === 'reload':document.visibilityState === 'hidden'→performAutoReload()немедленно (никто не смотрит — безопасно; важно: покрывает фоновые вкладки, которые уже hidden в момент сигнала);visible) → показать баннер + один разdocument.addEventListener('visibilitychange', onHidden, {once:true}), гдеonHiddenприhiddenзовётperformAutoReload().Баннер:
notifications.show({ id: 'app-version-reload', autoClose: false, withCloseButton: true, … })— фиксированныйid(повторные показы обновляют один баннер, не стопку), закрываемый; кнопка «Обновить» →performAutoReload(). Ветка'banner'показывает ТОТ ЖЕ видимый баннер с кнопкой (не толькоconsole.warn) — отличие от'reload'-visible лишь в том, что не армируется авто-reload по visibility.performAutoReload():markAutoReloaded()(записать общий флаг; если запись бросила — НЕ перезагружать, показать баннер, как делает существующий boundary вcatch → return), затемwindow.location.reload().4. Общий one-shot флаг — гармонизация с существующим boundary (R3)
Проактивный (эта фича) и реактивный (chunk-load-error-boundary.tsx) reload должны делить ОДИН счётчик, чтобы гарантия «не более одного авто-reload за сессию» держалась через оба и не было двойных/зацикленных перезагрузок.
"chunk-reload-attempted"и его try/catch-доступ в общий модуль (напр.apps/client/src/lib/reload-guard.ts):hasAutoReloaded(): booleanиmarkAutoReloaded(): boolean(возвращает успех записи).hasAutoReloaded/markAutoReloaded. Итог: максимум одна авто-перезагрузка за сессию суммарно по обоим путям; всё сверх — ручной баннер/UI.Это одним простым правилом закрывает все петлевые сценарии: перманентный скос (застрявший прокси/CDN отдаёт старый бандл), осцилляцию при rolling-деплое мульти-реплики (сокет прыгает между живыми нодами разных версий) и недоступность storage — после первой авто-перезагрузки флаг стоит, дальше только баннер, петли нет.
Модель состояний вкладки (R1)
in-syncapp-versionс непустым несовпадениемpending-reloadvisiblehiddenreloadingperformAutoReload()(сразу еслиhidden, иначе по выходу изpending-reload)banner-onlyПосле reload: новый бандл → новый
clientVersion; на коннекте сервер анонсирует; равно →in-sync; всё ещё нет (перманентный скос) →banner-only(флаг стоит), петли нет. Сервер владеет только фактом «моя версия = X»; решение о reload — на клиенте.Policy-константы (R7)
Спец-констант не вводим: guard — булев one-shot флаг (переиспользуем
chunk-reload-attempted, конвенция проекта из S51). Оконный таймер/версионный ключ НЕ используем — one-shot проще и строго петле-безопасен.Границы scope
IN:
version.json(Vite пишет, сервер читает черезresolveClientDistPath()+readClientBuildVersion()); per-connect анонсapp-versionв ws.gateway + boot-лог; клиентский guarded reload (чистаяdecideVersionAction+ грязная оболочка + баннер + reload-on-hidden); вынос общегоreload-guardи перевод на него chunk-load-error-boundary; юнит-тесты чистой логики.OUT (осознанно, отдельные задачи): retention ассетов (volume/CDN/sync-без-delete); честный
404на/assets/*; REST/api/version+ polling; cache-busting service worker (вернуться при мерже PR #120); точная детекция «грязного» редактора (вместо неё безопасная точка по visibility); broadcast «на старте контейнера» (намеренно отвергнут, см. Дизайн §2).Критерии приёмки (наблюдаемые на стенде)
--build-arg APP_VERSION=test-A:docker exec … cat /app/apps/client/dist/version.json→{"version":"test-A"}; UI-шапка (app-header.tsx) показываетtest-A; на WS-коннекте DevTools показывает кадрapp-versionсversion:"test-A". Все три совпадают.APP_VERSION=test-B,docker compose up -d. В течение секунд после авто-реконнекта: видимая вкладка показывает баннер «Доступна новая версия»; фоновая (hidden) — перезагружается сама. DevTools: новый WS-connect +app-versionversion:"test-B".APP_VERSION==test-B, баннер исчез. Отдельно: видимая вкладка, уведённая в фон (visibilitychange→hidden), так же перезагружается.test-B, бандл вкладки остаётсяtest-A— напр. прокси отдаёт старыйindex.html). Наблюдаемо: РОВНО ОДНА авто-перезагрузка (вsessionStorageвыставленchunk-reload-attempted), далее только баннер; бесконечного reload-цикла нет. То же при осцилляции нод (мульти-реплика) — не более одной авто-перезагрузки.version.jsonотсутствует/пуст: сервер логируетDISABLED, шлётapp-versionversion:""; баннер НЕ показывается,locationне меняется.sessionStorage(private mode): авто-reload не происходит (только баннер); петли нет (как в существующем boundary).chunk-reload-attempted; суммарно не более одной авто-перезагрузки за сессию.app-versionприходит и на первый connect, и на каждый reconnect (разорвать сеть в DevTools → восстановить → снова пришло).decideVersionAction: пустое с любой стороны →noop; равны →noop; несовпадение +autoReloadUsed=true→banner; несовпадение +false→reload.readClientBuildVersion: валидный файл → версия; нет файла/битый JSON →''.Отказы и наблюдаемость (R4)
version.json) → клиент no-op; сервер логируетDISABLEDпри старте. Существующие деплои без фичи работают без изменений.console.warnпри входе вbanner-only.client.emit) локален инстансу, не затронут.JWT_TOKEN_EXPIRES_IN, дефолт90d),handleConnectionпри реконнекте делает server-initiateddisconnect()(Unauthorized), после которого socket.io-client НЕ реконнектит автоматически (reasonio server disconnect) и у клиента нет обработчика этого — сигнал версии не дойдёт, пока не будет ручного F5. Обычный редеплой при живом токене (transport close) реконнектится штатно и сигнал доходит. Вероятность растёт при уменьшенииJWT_TOKEN_EXPIRES_IN. Чинить auth-реконнект — вне этой минимальной фичи (возможный follow-up).handleConnectionдисконнектит неаутентифицированные сокеты до анонса → долгоживущие share-вкладки сигнал не получат. Вне scope.Совместимость и миграции (R8)
version.json, который уже едет в образе сclient/dist. Если файла нет — фича инертна (fail-safe).app-version→ на этом первом редеплое не перезагрузятся (их подхватит существующий chunk-load-error-boundary при навигации). Механизм работает со ВТОРОГО деплоя. Ожидаемо, действий не требует.app-version→ клиенты no-op. Безопасно.location.reload()может отдать старый shell; тогда reload должен будет дёргатьskipWaiting/сброс SW-кэша. Вне scope; ревизия при мерже PR #120.Решённые развилки
visibility→hiddenили по клику; уже-hidden вкладку перезагружаем сразу). Решил владелец. Детекцию «грязного» Yjs-редактора не делаем — нет единого флага, visibility — безопасная точка.client/dist/version.json(Vite пишет, сервер читает), а не runtime-env. Дизайн: истинно единый источник (R3), убирает ложный mismatch из-за runtime-override/source-деплоя.app-version, не подмешивать в'message'.chunk-reload-attempted(конвенция S51), общий с chunk-load-error-boundary; без оконного таймера/версионного ключа.Связи
docs/stabilization-plan.mdT4.6/S51.