feat(deploy): version-coherence — WS-анонс версии сервера + guarded reload устаревших вкладок после редеплоя #481

Open
opened 2026-07-11 01:50:15 +03:00 by agent_vscode · 0 comments
Collaborator

Проблема / Цель

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 (наблюдали в проде при сохранении записи);
  • плюс контрактный рассинхрон старого JS-клиента с новым API (тихий, без явной ошибки чанка).

Сейчас есть только реактивный предохранитель — 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).

Опоры (всё уже есть в коде):

  • Клиентский build id уже вшивается. vite.config.ts через define кладёт глобаль APP_VERSION (функция resolveAppVersion: env APP_VERSIONgit describe --tags --alwaysv${package.json version}). Объявлена в vite-env.d.ts, показывается в шапке app-header.tsx:94. Это версия текущего бандла вкладки — отдельную вводить не нужно.
  • Единый WS-канал приложения. socket.io gateway ws.gateway.ts (transports:['websocket']) поверх Redis-адаптера ws-redis.adapter.ts. Все прикладные события идут ОДНИМ событием 'message' с дискриминатором operation (use-query-subscription.ts, types.ts).
  • Единая точка создания сокета. user-provider.tsx: io(SOCKET_URL,…) (SOCKET_URL===undefined → тот же origin), кладёт в socketAtom, тут же навешан newSocket.on('connect', …). Готовый образец чистой-логики-с-инъекцией: connect-resync.ts + connect-resync.test.ts.
  • Существующий guard-паттерн. chunk-load-error-boundary.tsx: one-shot флаг sessionStorage["chunk-reload-attempted"], обёрнут в try/catch (при недоступности storage — НЕ авто-reload). Конвенция проекта — не более одного авто-reload за сессию (см. docs/stabilization-plan.md T4.6/S51: вариант с «оконным счётчиком» был заменён ревью на этот простой one-shot).
  • @mantine/notifications уже в зависимостях и смонтирован в main.tsx → баннер штатно.
  • EnvironmentService глобальный (@Global, environment.module.ts).
  • CI уже передаёт build-arg 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=… без пересборки клиента.

Решение — один артефакт, из которого читают обе стороны:

  • Клиент (vite.config.ts): вычислить 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')); использовать и там, и в новом ридере (единый источник пути — глубина ../ зависит от расположения модуля, не дублировать!).
  • Чистая-ish функция readClientBuildVersion(clientDistPath: string): string рядом с helpers: fs.readFileSync(join(clientDistPath,'version.json'))JSON.parse().version trimmed; любая ошибка (нет файла / битый JSON) → ''. Юнит-тестируема через временный путь.

2. Контракт WS — отдельное событие app-version

Отдельное socket.io-событие app-version (НЕ подмешивать в 'message'/WebSocketEvent: это не room-scoped и не про react-query; член без поля operation сломал бы дискриминацию union в use-query-subscription):

  • payload: { 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 }).
  • Один info-лог при старте: 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

Слушатель регистрируется синхронно в том же useEffect user-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 не определена):

// Pure decision. All inputs injected; no globals, no side effects.
// autoReloadUsed = "a session-wide auto-reload has already happened"
export function decideVersionAction(args: {
  serverVersion: string;
  clientVersion: string;
  autoReloadUsed: boolean;
}): "reload" | "banner" | "noop" {
  const { serverVersion, clientVersion, autoReloadUsed } = args;
  if (!serverVersion || !clientVersion) return "noop"; // fail-safe: unknown version → never act
  if (serverVersion === clientVersion) return "noop";  // in sync
  if (autoReloadUsed) return "banner";                 // one auto-reload per session already spent
  return "reload";                                     // real mismatch, first time this session
}

Грязная оболочка triggerGuardedReload(rawServerVersion) в отдельном модуле (импортирует @mantine/notifications, читает APP_VERSION и sessionStorage), держит модульный флаг идемпотентности:

  1. serverV = (rawServerVersion ?? '').trim(), clientV = (APP_VERSION ?? '').trim().
  2. autoReloadUsed = hasAutoReloaded() — читает общий флаг (см. ниже) через try/catch; ошибка чтения → трактовать как true (fail toward «не перезагружать»).
  3. action = decideVersionAction({serverVersion: serverV, clientVersion: clientV, autoReloadUsed}).
  4. if (action === 'noop') return;
  5. Идемпотентность: если в этой загрузке вкладки уже обработали (handled) — return (не плодить слушатели/баннеры). Иначе handled = true.
  6. action === 'banner' → показать баннер (ниже), console.warn для диагностики, БЕЗ авто-reload. Выход.
  7. 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 (возвращает успех записи).
  • chunk-load-error-boundary.tsx переключить на этот модуль (поведение не меняется).
  • Эта фича использует те же hasAutoReloaded/markAutoReloaded. Итог: максимум одна авто-перезагрузка за сессию суммарно по обоим путям; всё сверх — ручной баннер/UI.

Это одним простым правилом закрывает все петлевые сценарии: перманентный скос (застрявший прокси/CDN отдаёт старый бандл), осцилляцию при rolling-деплое мульти-реплики (сокет прыгает между живыми нодами разных версий) и недоступность storage — после первой авто-перезагрузки флаг стоит, дальше только баннер, петли нет.

Модель состояний вкладки (R1)

Состояние Вход Выход Владелец
in-sync старт / версии равны / пустая версия app-version с непустым несовпадением клиент
pending-reload несовпадение, авто-reload ещё не потрачен, вкладка visible клик по баннеру ИЛИ вкладка→hidden клиент
reloading performAutoReload() (сразу если hidden, иначе по выходу из pending-reload) навигация клиент
banner-only несовпадение, но авто-reload уже потрачен (флаг стоит) / ошибка storage — (только баннер с ручной кнопкой) клиент

После 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).

Критерии приёмки (наблюдаемые на стенде)

  1. Единый источник. Образ с --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". Все три совпадают.
  2. Сигнал доходит. Вкладка версии A. Пересобрать APP_VERSION=test-B, docker compose up -d. В течение секунд после авто-реконнекта: видимая вкладка показывает баннер «Доступна новая версия»; фоновая (hidden) — перезагружается сама. DevTools: новый WS-connect + app-version version:"test-B".
  3. Guarded reload. Клик по «Обновить» → reload → APP_VERSION==test-B, баннер исчез. Отдельно: видимая вкладка, уведённая в фон (visibilitychange→hidden), так же перезагружается.
  4. One-shot / петле-безопасность. Смоделировать перманентный скос (сервер test-B, бандл вкладки остаётся test-A — напр. прокси отдаёт старый index.html). Наблюдаемо: РОВНО ОДНА авто-перезагрузка (в sessionStorage выставлен chunk-reload-attempted), далее только баннер; бесконечного reload-цикла нет. То же при осцилляции нод (мульти-реплика) — не более одной авто-перезагрузки.
  5. Fail-safe (пустая версия). Образ, где version.json отсутствует/пуст: сервер логирует DISABLED, шлёт app-version version:""; баннер НЕ показывается, location не меняется.
  6. Storage недоступен. С отключённым sessionStorage (private mode): авто-reload не происходит (только баннер); петли нет (как в существующем boundary).
  7. Гармонизация. Существующий chunk-load-error-boundary.tsx продолжает работать; оба пути делят один флаг chunk-reload-attempted; суммарно не более одной авто-перезагрузки за сессию.
  8. On-connect и на reconnect. app-version приходит и на первый connect, и на каждый reconnect (разорвать сеть в DevTools → восстановить → снова пришло).
  9. Юнит-тесты. decideVersionAction: пустое с любой стороны → noop; равны → noop; несовпадение + autoReloadUsed=truebanner; несовпадение + falsereload. readClientBuildVersion: валидный файл → версия; нет файла/битый JSON → ''.

Отказы и наблюдаемость (R4)

  • Версия сервера пустая (нет version.json) → клиент no-op; сервер логирует DISABLED при старте. Существующие деплои без фичи работают без изменений.
  • Перманентный скос / осцилляция / недоступный storage → one-shot флаг: одна авто-перезагрузка, дальше баннер; петли нет. На клиенте console.warn при входе в banner-only.
  • Redis-адаптер недоступен → основной путь (per-connect client.emit) локален инстансу, не затронут.
  • Известное ограничение — auth-disconnect. Если access-JWT протух за время жизни вкладки (JWT_TOKEN_EXPIRES_IN, дефолт 90d), handleConnection при реконнекте делает server-initiated disconnect() (Unauthorized), после которого socket.io-client НЕ реконнектит автоматически (reason io server disconnect) и у клиента нет обработчика этого — сигнал версии не дойдёт, пока не будет ручного F5. Обычный редеплой при живом токене (transport close) реконнектится штатно и сигнал доходит. Вероятность растёт при уменьшении JWT_TOKEN_EXPIRES_IN. Чинить auth-реконнект — вне этой минимальной фичи (возможный follow-up).
  • Известное ограничение — анонимные/публичные share-вкладки. handleConnection дисконнектит неаутентифицированные сокеты до анонса → долгоживущие share-вкладки сигнал не получат. Вне scope.

Совместимость и миграции (R8)

  • БД-миграций нет. Дефолтное поведение существующих потоков не меняется.
  • Никаких новых обязательных env: версия берётся из version.json, который уже едет в образе с client/dist. Если файла нет — фича инертна (fail-safe).
  • Первый деплой фичи: вкладки со старым (до-фичевым) бандлом не имеют обработчика app-version → на этом первом редеплое не перезагрузятся (их подхватит существующий chunk-load-error-boundary при навигации). Механизм работает со ВТОРОГО деплоя. Ожидаемо, действий не требует.
  • Rollback на до-фичевую версию сервера: сервер не эмитит app-version → клиенты no-op. Безопасно.
  • Service worker (#120, не влит): если приземлится SW с кэшем app-shell — location.reload() может отдать старый shell; тогда reload должен будет дёргать skipWaiting/сброс SW-кэша. Вне scope; ревизия при мерже PR #120.

Решённые развилки

  • Насколько агрессивно перезагружать → баннер + reload в фоне (видимую вкладку не трогаем; авто-reload на visibility→hidden или по клику; уже-hidden вкладку перезагружаем сразу). Решил владелец. Детекцию «грязного» Yjs-редактора не делаем — нет единого флага, visibility — безопасная точка.
  • Источник build id → client/dist/version.json (Vite пишет, сервер читает), а не runtime-env. Дизайн: истинно единый источник (R3), убирает ложный mismatch из-за runtime-override/source-деплоя.
  • Механизм доставки → только per-connect анонс (broadcast на bootstrap отвергнут: thundering herd + ложные reload в кластере; reconnect покрывает и single-container, и кластер).
  • Событие → отдельное app-version, не подмешивать в 'message'.
  • Loop-guard → переиспользовать one-shot chunk-reload-attempted (конвенция S51), общий с chunk-load-error-boundary; без оконного таймера/версионного ключа.

Связи

## Проблема / Цель SPA — долгоживущий клиент: вкладку держат открытой часами/днями, а сервер за это время передеплоивают (docker `up -d` заменяет контейнер целиком). После редеплоя открытая вкладка остаётся в некогерентном состоянии: - старый `index.html` (уже в памяти вкладки) ссылается на content-hashed чанки, которых в новом образе нет. Ленивый `import()` не находит файл → catch-all `*` в [static.module.ts](apps/server/src/integrations/static/static.module.ts) отдаёт `index.html` с `Content-Type: text/html` → WebKit: `'text/html' is not a valid JavaScript MIME type` (наблюдали в проде при сохранении записи); - плюс контрактный рассинхрон старого JS-клиента с новым API (тихий, без явной ошибки чанка). Сейчас есть только **реактивный** предохранитель — [chunk-load-error-boundary.tsx](apps/client/src/components/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). Опоры (всё уже есть в коде): - **Клиентский build id уже вшивается.** [vite.config.ts](apps/client/vite.config.ts) через `define` кладёт глобаль `APP_VERSION` (функция `resolveAppVersion`: env `APP_VERSION` → `git describe --tags --always` → `v${package.json version}`). Объявлена в [vite-env.d.ts](apps/client/src/vite-env.d.ts), показывается в шапке [app-header.tsx:94](apps/client/src/components/layouts/global/app-header.tsx#L94). **Это версия текущего бандла вкладки** — отдельную вводить не нужно. - **Единый WS-канал приложения.** socket.io gateway [ws.gateway.ts](apps/server/src/ws/ws.gateway.ts) (`transports:['websocket']`) поверх Redis-адаптера [ws-redis.adapter.ts](apps/server/src/ws/adapter/ws-redis.adapter.ts). Все прикладные события идут ОДНИМ событием `'message'` с дискриминатором `operation` ([use-query-subscription.ts](apps/client/src/features/websocket/use-query-subscription.ts), [types.ts](apps/client/src/features/websocket/types/types.ts)). - **Единая точка создания сокета.** [user-provider.tsx](apps/client/src/features/user/user-provider.tsx): `io(SOCKET_URL,…)` (`SOCKET_URL===undefined` → тот же origin), кладёт в `socketAtom`, тут же навешан `newSocket.on('connect', …)`. Готовый образец чистой-логики-с-инъекцией: [connect-resync.ts](apps/client/src/features/user/connect-resync.ts) + [connect-resync.test.ts](apps/client/src/features/user/connect-resync.test.ts). - **Существующий guard-паттерн.** [chunk-load-error-boundary.tsx](apps/client/src/components/chunk-load-error-boundary.tsx): one-shot флаг `sessionStorage["chunk-reload-attempted"]`, обёрнут в try/catch (при недоступности storage — НЕ авто-reload). Конвенция проекта — **не более одного авто-reload за сессию** (см. `docs/stabilization-plan.md` T4.6/S51: вариант с «оконным счётчиком» был заменён ревью на этот простой one-shot). - **`@mantine/notifications`** уже в зависимостях и смонтирован в [main.tsx](apps/client/src/main.tsx) → баннер штатно. - **`EnvironmentService`** глобальный (`@Global`, [environment.module.ts](apps/server/src/integrations/environment/environment.service.ts)). - **CI уже передаёт build-arg** `APP_VERSION` (`.github/workflows/develop.yml`, `release.yml`). **Ключевой пробел:** сервер в рантайме свою версию НЕ знает. В [Dockerfile](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=…` без пересборки клиента. Решение — **один артефакт**, из которого читают обе стороны: - **Клиент** ([vite.config.ts](apps/client/vite.config.ts)): вычислить `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](apps/server/src/common/helpers) из существующей логики [static.module.ts](apps/server/src/integrations/static/static.module.ts) (там уже `join(__dirname,'..','..','..','..','client/dist')`); использовать и там, и в новом ридере (единый источник пути — глубина `../` зависит от расположения модуля, не дублировать!). - Чистая-ish функция `readClientBuildVersion(clientDistPath: string): string` рядом с helpers: `fs.readFileSync(join(clientDistPath,'version.json'))` → `JSON.parse().version` trimmed; любая ошибка (нет файла / битый JSON) → `''`. Юнит-тестируема через временный путь. ### 2. Контракт WS — отдельное событие `app-version` Отдельное socket.io-событие `app-version` (НЕ подмешивать в `'message'`/`WebSocketEvent`: это не room-scoped и не про react-query; член без поля `operation` сломал бы дискриминацию union в use-query-subscription): - payload: `{ version: string }`. - Тип `AppVersionSocketPayload = { version: string }` объявить в Mantine-free клиентском модуле рядом с чистой логикой; **не расширять `WebSocketEvent`**. **Механизм доставки — только per-connect анонс** ([ws.gateway.ts](apps/server/src/ws/ws.gateway.ts)): - В `onModuleInit` прочитать версию один раз в `private readonly`-подобное поле: `this.appVersion = readClientBuildVersion(resolveClientDistPath())`. - В `handleConnection`, ПОСЛЕ успешной auth и `client.join(...)` (success-ветка, не catch): `client.emit('app-version', { version: this.appVersion })`. - Один info-лог при старте: `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 Слушатель регистрируется **синхронно** в том же `useEffect` [user-provider.tsx](apps/client/src/features/user/user-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 не определена): ```ts // Pure decision. All inputs injected; no globals, no side effects. // autoReloadUsed = "a session-wide auto-reload has already happened" export function decideVersionAction(args: { serverVersion: string; clientVersion: string; autoReloadUsed: boolean; }): "reload" | "banner" | "noop" { const { serverVersion, clientVersion, autoReloadUsed } = args; if (!serverVersion || !clientVersion) return "noop"; // fail-safe: unknown version → never act if (serverVersion === clientVersion) return "noop"; // in sync if (autoReloadUsed) return "banner"; // one auto-reload per session already spent return "reload"; // real mismatch, first time this session } ``` **Грязная оболочка** `triggerGuardedReload(rawServerVersion)` в отдельном модуле (импортирует `@mantine/notifications`, читает `APP_VERSION` и sessionStorage), держит модульный флаг идемпотентности: 1. `serverV = (rawServerVersion ?? '').trim()`, `clientV = (APP_VERSION ?? '').trim()`. 2. `autoReloadUsed = hasAutoReloaded()` — читает общий флаг (см. ниже) через try/catch; **ошибка чтения → трактовать как `true`** (fail toward «не перезагружать»). 3. `action = decideVersionAction({serverVersion: serverV, clientVersion: clientV, autoReloadUsed})`. 4. `if (action === 'noop') return;` 5. Идемпотентность: если в этой загрузке вкладки уже обработали (`handled`) — `return` (не плодить слушатели/баннеры). Иначе `handled = true`. 6. `action === 'banner'` → показать баннер (ниже), `console.warn` для диагностики, БЕЗ авто-reload. Выход. 7. `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](apps/client/src/components/chunk-load-error-boundary.tsx)) reload должны делить ОДИН счётчик, чтобы гарантия «не более одного авто-reload за сессию» держалась через оба и не было двойных/зацикленных перезагрузок. - Вынести существующий ключ `"chunk-reload-attempted"` и его try/catch-доступ в общий модуль (напр. `apps/client/src/lib/reload-guard.ts`): `hasAutoReloaded(): boolean` и `markAutoReloaded(): boolean` (возвращает успех записи). - [chunk-load-error-boundary.tsx](apps/client/src/components/chunk-load-error-boundary.tsx) переключить на этот модуль (поведение не меняется). - Эта фича использует те же `hasAutoReloaded`/`markAutoReloaded`. Итог: **максимум одна авто-перезагрузка за сессию суммарно** по обоим путям; всё сверх — ручной баннер/UI. Это **одним простым правилом** закрывает все петлевые сценарии: перманентный скос (застрявший прокси/CDN отдаёт старый бандл), осцилляцию при rolling-деплое мульти-реплики (сокет прыгает между живыми нодами разных версий) и недоступность storage — после первой авто-перезагрузки флаг стоит, дальше только баннер, петли нет. ### Модель состояний вкладки (R1) | Состояние | Вход | Выход | Владелец | |---|---|---|---| | `in-sync` | старт / версии равны / пустая версия | `app-version` с непустым несовпадением | клиент | | `pending-reload` | несовпадение, авто-reload ещё не потрачен, вкладка `visible` | клик по баннеру ИЛИ вкладка→`hidden` | клиент | | `reloading` | `performAutoReload()` (сразу если `hidden`, иначе по выходу из `pending-reload`) | навигация | клиент | | `banner-only` | несовпадение, но авто-reload уже потрачен (флаг стоит) / ошибка storage | — (только баннер с ручной кнопкой) | клиент | После 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). ## Критерии приёмки (наблюдаемые на стенде) 1. **Единый источник.** Образ с `--build-arg APP_VERSION=test-A`: `docker exec … cat /app/apps/client/dist/version.json` → `{"version":"test-A"}`; UI-шапка ([app-header.tsx](apps/client/src/components/layouts/global/app-header.tsx)) показывает `test-A`; на WS-коннекте DevTools показывает кадр `app-version` с `version:"test-A"`. Все три совпадают. 2. **Сигнал доходит.** Вкладка версии A. Пересобрать `APP_VERSION=test-B`, `docker compose up -d`. В течение секунд после авто-реконнекта: видимая вкладка показывает баннер «Доступна новая версия»; фоновая (hidden) — перезагружается сама. DevTools: новый WS-connect + `app-version` `version:"test-B"`. 3. **Guarded reload.** Клик по «Обновить» → reload → `APP_VERSION==test-B`, баннер исчез. Отдельно: видимая вкладка, уведённая в фон (`visibilitychange→hidden`), так же перезагружается. 4. **One-shot / петле-безопасность.** Смоделировать перманентный скос (сервер `test-B`, бандл вкладки остаётся `test-A` — напр. прокси отдаёт старый `index.html`). Наблюдаемо: РОВНО ОДНА авто-перезагрузка (в `sessionStorage` выставлен `chunk-reload-attempted`), далее только баннер; бесконечного reload-цикла нет. То же при осцилляции нод (мульти-реплика) — не более одной авто-перезагрузки. 5. **Fail-safe (пустая версия).** Образ, где `version.json` отсутствует/пуст: сервер логирует `DISABLED`, шлёт `app-version` `version:""`; баннер НЕ показывается, `location` не меняется. 6. **Storage недоступен.** С отключённым `sessionStorage` (private mode): авто-reload не происходит (только баннер); петли нет (как в существующем boundary). 7. **Гармонизация.** Существующий [chunk-load-error-boundary.tsx](apps/client/src/components/chunk-load-error-boundary.tsx) продолжает работать; оба пути делят один флаг `chunk-reload-attempted`; суммарно не более одной авто-перезагрузки за сессию. 8. **On-connect и на reconnect.** `app-version` приходит и на первый connect, и на каждый reconnect (разорвать сеть в DevTools → восстановить → снова пришло). 9. **Юнит-тесты.** `decideVersionAction`: пустое с любой стороны → `noop`; равны → `noop`; несовпадение + `autoReloadUsed=true` → `banner`; несовпадение + `false` → `reload`. `readClientBuildVersion`: валидный файл → версия; нет файла/битый JSON → `''`. ## Отказы и наблюдаемость (R4) - **Версия сервера пустая** (нет `version.json`) → клиент no-op; сервер логирует `DISABLED` при старте. Существующие деплои без фичи работают без изменений. - **Перманентный скос / осцилляция / недоступный storage** → one-shot флаг: одна авто-перезагрузка, дальше баннер; петли нет. На клиенте `console.warn` при входе в `banner-only`. - **Redis-адаптер недоступен** → основной путь (per-connect `client.emit`) локален инстансу, не затронут. - **Известное ограничение — auth-disconnect.** Если access-JWT протух за время жизни вкладки (`JWT_TOKEN_EXPIRES_IN`, дефолт `90d`), `handleConnection` при реконнекте делает server-initiated `disconnect()` (`Unauthorized`), после которого socket.io-client НЕ реконнектит автоматически (reason `io server disconnect`) и у клиента нет обработчика этого — сигнал версии не дойдёт, пока не будет ручного F5. Обычный редеплой при живом токене (`transport close`) реконнектится штатно и сигнал доходит. Вероятность растёт при уменьшении `JWT_TOKEN_EXPIRES_IN`. Чинить auth-реконнект — вне этой минимальной фичи (возможный follow-up). - **Известное ограничение — анонимные/публичные share-вкладки.** `handleConnection` дисконнектит неаутентифицированные сокеты до анонса → долгоживущие share-вкладки сигнал не получат. Вне scope. ## Совместимость и миграции (R8) - БД-миграций нет. Дефолтное поведение существующих потоков не меняется. - Никаких новых обязательных env: версия берётся из `version.json`, который уже едет в образе с `client/dist`. Если файла нет — фича инертна (fail-safe). - **Первый деплой фичи:** вкладки со старым (до-фичевым) бандлом не имеют обработчика `app-version` → на этом первом редеплое не перезагрузятся (их подхватит существующий chunk-load-error-boundary при навигации). Механизм работает со ВТОРОГО деплоя. Ожидаемо, действий не требует. - **Rollback на до-фичевую версию сервера:** сервер не эмитит `app-version` → клиенты no-op. Безопасно. - **Service worker (#120, не влит):** если приземлится SW с кэшем app-shell — `location.reload()` может отдать старый shell; тогда reload должен будет дёргать `skipWaiting`/сброс SW-кэша. Вне scope; ревизия при мерже PR #120. ## Решённые развилки - Насколько агрессивно перезагружать → **баннер + reload в фоне** (видимую вкладку не трогаем; авто-reload на `visibility→hidden` или по клику; уже-hidden вкладку перезагружаем сразу). Решил владелец. Детекцию «грязного» Yjs-редактора не делаем — нет единого флага, visibility — безопасная точка. - Источник build id → **`client/dist/version.json`** (Vite пишет, сервер читает), а не runtime-env. Дизайн: истинно единый источник (R3), убирает ложный mismatch из-за runtime-override/source-деплоя. - Механизм доставки → **только per-connect анонс** (broadcast на bootstrap отвергнут: thundering herd + ложные reload в кластере; reconnect покрывает и single-container, и кластер). - Событие → **отдельное `app-version`**, не подмешивать в `'message'`. - Loop-guard → **переиспользовать one-shot `chunk-reload-attempted`** (конвенция S51), общий с chunk-load-error-boundary; без оконного таймера/версионного ключа. ## Связи - Опоры: [static.module.ts](apps/server/src/integrations/static/static.module.ts), [ws.gateway.ts](apps/server/src/ws/ws.gateway.ts), [user-provider.tsx](apps/client/src/features/user/user-provider.tsx), [connect-resync.ts](apps/client/src/features/user/connect-resync.ts), [chunk-load-error-boundary.tsx](apps/client/src/components/chunk-load-error-boundary.tsx), [vite.config.ts](apps/client/vite.config.ts), [Dockerfile](Dockerfile). - Смежное: epic #195 (mobile/offline/PWA), PR #120 (unified service worker) — будущая ревизия reload+SW. - Прецедент guard-политики: `docs/stabilization-plan.md` T4.6/S51.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#481