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>
156 lines
5.1 KiB
TypeScript
156 lines
5.1 KiB
TypeScript
import { defineConfig, loadEnv, type Plugin } from "vite";
|
|
import react from "@vitejs/plugin-react";
|
|
import { compression } from "vite-plugin-compression2";
|
|
import * as path from "path";
|
|
import * as fs from "node:fs";
|
|
import { execSync } from "node:child_process";
|
|
|
|
const envPath = path.resolve(process.cwd(), "..", "..");
|
|
|
|
// Resolve the version string shown in the UI.
|
|
// Priority: explicit APP_VERSION env (injected by Docker/CI, where .git is absent),
|
|
// then `git describe` for local builds, then the package.json version as a fallback.
|
|
function resolveAppVersion(cwd: string): string {
|
|
const fromEnv = process.env.APP_VERSION?.trim();
|
|
if (fromEnv) return fromEnv;
|
|
try {
|
|
return execSync("git describe --tags --always", {
|
|
cwd,
|
|
stdio: ["ignore", "pipe", "ignore"],
|
|
})
|
|
.toString()
|
|
.trim();
|
|
} catch {
|
|
return `v${process.env.npm_package_version ?? "0.0.0"}`;
|
|
}
|
|
}
|
|
|
|
// Emit <outDir>/version.json = { "version": appVersion } so the server can read
|
|
// the exact same build id the bundle was compiled with. The value is the SAME
|
|
// `appVersion` fed into `define.APP_VERSION`, so version.json and the baked-in
|
|
// global are identical by construction — the single source of truth (no
|
|
// runtime-env second copy that could drift and cause a false version mismatch).
|
|
function versionJsonPlugin(version: string): Plugin {
|
|
let outDir = "dist";
|
|
return {
|
|
name: "emit-version-json",
|
|
apply: "build",
|
|
configResolved(config) {
|
|
outDir = config.build.outDir;
|
|
},
|
|
writeBundle() {
|
|
const root = path.resolve(process.cwd(), outDir);
|
|
fs.mkdirSync(root, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(root, "version.json"),
|
|
JSON.stringify({ version }),
|
|
);
|
|
},
|
|
};
|
|
}
|
|
|
|
export default defineConfig(({ mode }) => {
|
|
const appVersion = resolveAppVersion(envPath);
|
|
const {
|
|
APP_URL,
|
|
FILE_UPLOAD_SIZE_LIMIT,
|
|
FILE_IMPORT_SIZE_LIMIT,
|
|
DRAWIO_URL,
|
|
CLOUD,
|
|
SUBDOMAIN_HOST,
|
|
COLLAB_URL,
|
|
BILLING_TRIAL_DAYS,
|
|
POSTHOG_HOST,
|
|
POSTHOG_KEY,
|
|
} = loadEnv(mode, envPath, "");
|
|
|
|
return {
|
|
define: {
|
|
"process.env": {
|
|
APP_URL,
|
|
FILE_UPLOAD_SIZE_LIMIT,
|
|
FILE_IMPORT_SIZE_LIMIT,
|
|
DRAWIO_URL,
|
|
CLOUD,
|
|
SUBDOMAIN_HOST,
|
|
COLLAB_URL,
|
|
BILLING_TRIAL_DAYS,
|
|
POSTHOG_HOST,
|
|
POSTHOG_KEY,
|
|
},
|
|
APP_VERSION: JSON.stringify(appVersion),
|
|
},
|
|
plugins: [
|
|
react(),
|
|
versionJsonPlugin(appVersion),
|
|
// Emit .br and .gz next to every built asset so the server can serve the
|
|
// precompressed copy (see @fastify/static preCompressed in static.module.ts).
|
|
compression({
|
|
algorithms: ["brotliCompress", "gzip"],
|
|
// vite-plugin-compression2's default `include` only covers text-ish
|
|
// bundle output (js/mjs/json/css/html/svg/…). Extend it with the large
|
|
// VAD binaries copied from public/vad (.wasm ~26MB, .onnx ~2.3MB) so
|
|
// they are brotli/gzip'd once at build time and served via
|
|
// @fastify/static preCompressed — otherwise @fastify/compress would
|
|
// re-brotli them on EVERY request. The default types are repeated here
|
|
// because setting `include` replaces (does not extend) the default.
|
|
include: /\.(html|xml|css|json|js|mjs|svg|yaml|yml|toml|wasm|onnx)$/,
|
|
// index.html is rewritten at server boot (window.CONFIG injection); a
|
|
// precompressed copy would go stale — NEVER precompress it.
|
|
exclude: [/index\.html$/],
|
|
}),
|
|
],
|
|
build: {
|
|
rolldownOptions: {
|
|
output: {
|
|
advancedChunks: {
|
|
groups: [
|
|
{
|
|
name: "vendor-mantine",
|
|
test: /[\\/]node_modules[\\/]@mantine[\\/]/,
|
|
},
|
|
// NOTE: TipTap/ProseMirror/Yjs are intentionally NOT force-grouped
|
|
// into a single vendor chunk. Doing so backfires: rolldown co-locates
|
|
// a small module shared with the (eager) react-i18next runtime into
|
|
// that group chunk, which then drags the whole ~590KB editor engine
|
|
// into the eager modulepreload graph. Left to the default splitting,
|
|
// the editor engine stays in lazily-loaded chunks pulled only by the
|
|
// route-split editor/share pages. KaTeX is safe to group (nothing
|
|
// eager references it).
|
|
// KaTeX in its own stable chunk; loaded on demand by the lazy math
|
|
// node views (never in the startup path).
|
|
{
|
|
name: "vendor-katex",
|
|
test: /[\\/]node_modules[\\/]katex[\\/]/,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
resolve: {
|
|
alias: {
|
|
"@": "/src",
|
|
},
|
|
},
|
|
server: {
|
|
proxy: {
|
|
"/api": {
|
|
target: APP_URL,
|
|
changeOrigin: false,
|
|
},
|
|
"/socket.io": {
|
|
target: APP_URL,
|
|
ws: true,
|
|
rewriteWsOrigin: true,
|
|
},
|
|
"/collab": {
|
|
target: APP_URL,
|
|
ws: true,
|
|
rewriteWsOrigin: true,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
});
|