1e4925007d
SPA — долгоживущий клиент: вкладку держат часами, сервер за это время передеплоивают. Старый index.html ссылается на content-hashed чанки, которых в новом образе нет → ленивый import() → catch-all отдаёт index.html как text/html → WebKit «not a valid JavaScript MIME type» (наблюдали в проде). Реактивный предохранитель (chunk-error-boundary) ловит УЖЕ случившуюся ошибку. Делаем сигнал ПРОАКТИВНЫМ: сервер сообщает версию по существующему WS, клиент сравнивает и делает guarded reload ДО битого чанка. closes #481 - Единый источник версии: vite.config.ts вычисляет resolveAppVersion РОВНО раз → и в define.APP_VERSION, и в плагине, пишущем dist/version.json. Бандл и файл тождественны by construction. Сервер читает client/dist/version.json при старте (client-version.ts: resolveClientDistPath вынесен из static.module — единый источник пути; readClientBuildVersion → версия или '' на любой ошибке, fail-safe). НИКАКИХ Docker/ENV-изменений. - WS: отдельное событие app-version {version} (НЕ в message/WebSocketEvent — член без operation сломал бы union). ws.gateway читает версию в onModuleInit + boot-лог ACTIVE/DISABLED; emit в handleConnection (success-ветка, после join). ТОЛЬКО per-connect анонс, БЕЗ broadcast (broadcast в кластере → thundering-herd reload флота; естественный реконнект покрывает и single-container, и кластер). - Клиент: listener навешан СИНХРОННО в том же useEffect, что создаёт сокет (до connect — иначе гонка с немедленным emit сервера). Чистая decideVersionAction (reload|banner|noop; пустая версия → noop fail-safe). Грязная оболочка triggerGuardedReload: модульный latch, hidden→немедленный reload, visible→баннер + reload-on-hidden {once}, фикс-id закрываемый баннер с кнопкой «Обновить». - Общий one-shot флаг: reload-guard.ts (hasAutoReloaded/markAutoReloaded над существующим chunk-reload-attempted); chunk-load-error-boundary переведён на него. Проактивный + реактивный reload делят ОДИН счётчик → максимум одна авто-перезагрузка за сессию суммарно; permanent skew / oscillation / disabled storage → после первого reload только баннер, петли нет (проверено трассировкой). Проверка: server jest client-version 7/7; client vitest version-coherence+ reload-guard+guarded-reload 16/16 (coverage 97.9%). Build-proof единого источника: APP_VERSION=test-A → dist/version.json {"version":"test-A"} + вшито в бандл. Полный staging-приём (docker build-arg, WS-кадры в DevTools, мульти-таб) — вне автостенда, шаги приёмки #481 (крит.1-4) на ручную проверку. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
53 lines
1.6 KiB
TypeScript
53 lines
1.6 KiB
TypeScript
import { join } from 'path';
|
|
import * as fs from 'node:fs';
|
|
import * as os from 'node:os';
|
|
import { readClientBuildVersion } from './client-version';
|
|
|
|
describe('readClientBuildVersion', () => {
|
|
let dir: string;
|
|
|
|
beforeEach(() => {
|
|
dir = fs.mkdtempSync(join(os.tmpdir(), 'client-version-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
const writeVersionJson = (content: string) =>
|
|
fs.writeFileSync(join(dir, 'version.json'), content);
|
|
|
|
it('returns the version from a valid version.json', () => {
|
|
writeVersionJson(JSON.stringify({ version: 'test-A' }));
|
|
expect(readClientBuildVersion(dir)).toBe('test-A');
|
|
});
|
|
|
|
it('trims surrounding whitespace in the version', () => {
|
|
writeVersionJson(JSON.stringify({ version: ' v1.2.3 ' }));
|
|
expect(readClientBuildVersion(dir)).toBe('v1.2.3');
|
|
});
|
|
|
|
it('returns "" when version.json is missing', () => {
|
|
expect(readClientBuildVersion(dir)).toBe('');
|
|
});
|
|
|
|
it('returns "" on malformed JSON', () => {
|
|
writeVersionJson('{ not json');
|
|
expect(readClientBuildVersion(dir)).toBe('');
|
|
});
|
|
|
|
it('returns "" when the version field is absent', () => {
|
|
writeVersionJson(JSON.stringify({ notVersion: 'x' }));
|
|
expect(readClientBuildVersion(dir)).toBe('');
|
|
});
|
|
|
|
it('returns "" when the version field is not a string', () => {
|
|
writeVersionJson(JSON.stringify({ version: 123 }));
|
|
expect(readClientBuildVersion(dir)).toBe('');
|
|
});
|
|
|
|
it('returns "" when the path does not exist at all', () => {
|
|
expect(readClientBuildVersion(join(dir, 'nope'))).toBe('');
|
|
});
|
|
});
|