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>
136 lines
5.4 KiB
TypeScript
136 lines
5.4 KiB
TypeScript
import { Module, OnModuleInit } from '@nestjs/common';
|
|
import { HttpAdapterHost } from '@nestjs/core';
|
|
import { join } from 'path';
|
|
import * as fs from 'node:fs';
|
|
import fastifyStatic from '@fastify/static';
|
|
import { EnvironmentService } from '../environment/environment.service';
|
|
import { resolveClientDistPath } from '../../common/helpers/client-version';
|
|
|
|
/**
|
|
* Resolve the response headers for a statically served client asset.
|
|
*
|
|
* Extracted from the @fastify/static `setHeaders` callback so the cache
|
|
* classification stays a pure, unit-testable function (see
|
|
* static.module.spec.ts).
|
|
*
|
|
* `Vary: Accept-Encoding` is emitted for every static response because
|
|
* @fastify/static negotiates a precompressed .br/.gz neighbour by the client's
|
|
* Accept-Encoding but does NOT set Vary itself. Without it a shared/proxy cache
|
|
* keyed on the URL alone could store the brotli variant and later serve it to a
|
|
* client that only sent `Accept-Encoding: identity`/gzip → an undecodable body.
|
|
* This matters most for the immutable /assets/ files, which proxies may keep
|
|
* for a year.
|
|
*/
|
|
export function resolveStaticAssetHeaders(
|
|
filePath: string,
|
|
): Record<string, string> {
|
|
const headers: Record<string, string> = { vary: 'Accept-Encoding' };
|
|
|
|
// Content-hashed files under /assets/ never change for a given URL, so they
|
|
// can be cached forever and skip revalidation entirely.
|
|
if (filePath.includes('/assets/')) {
|
|
headers['cache-control'] = 'public, max-age=31536000, immutable';
|
|
return headers;
|
|
}
|
|
|
|
// index.html is rewritten at boot (window.CONFIG injection) and on every
|
|
// deploy — it must be revalidated on every load.
|
|
if (filePath.endsWith('index.html')) {
|
|
headers['cache-control'] = 'no-cache, no-store, must-revalidate';
|
|
return headers;
|
|
}
|
|
|
|
// Everything else (locales, vad, icons, manifest) is NOT content-hashed and
|
|
// changes between deploys, so it keeps @fastify/static's default
|
|
// etag/last-modified revalidation — do NOT mark it immutable.
|
|
return headers;
|
|
}
|
|
|
|
@Module({})
|
|
export class StaticModule implements OnModuleInit {
|
|
constructor(
|
|
private readonly httpAdapterHost: HttpAdapterHost,
|
|
private readonly environmentService: EnvironmentService,
|
|
) {}
|
|
|
|
public async onModuleInit() {
|
|
const httpAdapter = this.httpAdapterHost.httpAdapter;
|
|
const app = httpAdapter.getInstance();
|
|
|
|
const clientDistPath = resolveClientDistPath();
|
|
|
|
const indexFilePath = join(clientDistPath, 'index.html');
|
|
|
|
if (fs.existsSync(clientDistPath) && fs.existsSync(indexFilePath)) {
|
|
const indexTemplateFilePath = join(clientDistPath, 'index-template.html');
|
|
const windowVar = '<!--window-config-->';
|
|
|
|
const configString = {
|
|
ENV: this.environmentService.getNodeEnv(),
|
|
APP_URL: this.environmentService.getAppUrl(),
|
|
CLOUD: this.environmentService.isCloud(),
|
|
COMPACT_PAGE_TREE: this.environmentService.isCompactPageTreeEnabled(),
|
|
FILE_UPLOAD_SIZE_LIMIT:
|
|
this.environmentService.getFileUploadSizeLimit(),
|
|
FILE_IMPORT_SIZE_LIMIT:
|
|
this.environmentService.getFileImportSizeLimit(),
|
|
DRAWIO_URL: this.environmentService.getDrawioUrl(),
|
|
SUBDOMAIN_HOST: this.environmentService.isCloud()
|
|
? this.environmentService.getSubdomainHost()
|
|
: undefined,
|
|
COLLAB_URL: this.environmentService.getCollabUrl(),
|
|
BILLING_TRIAL_DAYS: this.environmentService.isCloud()
|
|
? this.environmentService.getBillingTrialDays()
|
|
: undefined,
|
|
POSTHOG_HOST: this.environmentService.getPostHogHost(),
|
|
POSTHOG_KEY: this.environmentService.getPostHogKey(),
|
|
// #355 — mirrors the server-side CLIENT_TELEMETRY_ENABLED gate so the
|
|
// client only collects/sends vitals when the operator opts in.
|
|
CLIENT_TELEMETRY_ENABLED:
|
|
this.environmentService.isClientTelemetryEnabled(),
|
|
};
|
|
|
|
const windowScriptContent = `<script>window.CONFIG=${JSON.stringify(configString)};</script>`;
|
|
|
|
if (!fs.existsSync(indexTemplateFilePath)) {
|
|
fs.copyFileSync(indexFilePath, indexTemplateFilePath);
|
|
}
|
|
|
|
const html = fs.readFileSync(indexTemplateFilePath, 'utf8');
|
|
const transformedHtml = html.replace(windowVar, windowScriptContent);
|
|
|
|
fs.writeFileSync(indexFilePath, transformedHtml);
|
|
|
|
const RENDER_PATH = '*';
|
|
|
|
await app.register(fastifyStatic, {
|
|
root: clientDistPath,
|
|
wildcard: false,
|
|
// Serve the build-time .br/.gz neighbour when the client accepts it
|
|
// (see vite-plugin-compression2 in apps/client/vite.config.ts).
|
|
preCompressed: true,
|
|
// @fastify/static's default cacheControl:true writes its own
|
|
// Cache-Control (from maxAge, default 0) AFTER the setHeaders callback,
|
|
// silently overwriting the immutable header that resolveStaticAssetHeaders
|
|
// sets — disable it so setHeaders/resolveStaticAssetHeaders own the header.
|
|
cacheControl: false,
|
|
setHeaders: (res, filePath) => {
|
|
for (const [name, value] of Object.entries(
|
|
resolveStaticAssetHeaders(filePath),
|
|
)) {
|
|
res.setHeader(name, value);
|
|
}
|
|
},
|
|
});
|
|
|
|
app.get(RENDER_PATH, (req: any, res: any) => {
|
|
const stream = fs.createReadStream(indexFilePath);
|
|
res
|
|
.header('Cache-Control', 'no-cache, no-store, must-revalidate')
|
|
.type('text/html')
|
|
.send(stream);
|
|
});
|
|
}
|
|
}
|
|
}
|