Files
magistr/frontend/scripts/build-react.mjs
dipatrik10 97bf3cc50a feat: завершить миграцию фронтенда на React (этапы 1-5)
Админка, настройки, логин, кабинеты преподавателя и студента
полностью переведены на React 19 (модель strangler, docs/REACT_MIGRATION.md).

- 15 вкладок админки: lazy-загрузка, общий error boundary, сервисный слой
- Удалены legacy-views и переходный слой LegacyTabView (45 файлов)
- Сборка esbuild (frontend/scripts/build-react.mjs) с бюджетами gzip на чанки
- CSP-хэши, кэширование: chunk'и immutable, CSS/HTML no-cache (react-cache.conf)
- Фиксы: lazy-импорт именованных экспортов (краш после авторизации),
  растяжение #admin-root/#settings-root на всю ширину,
  возврат subjects/lessonTypes из справочников (краш просмотра расписаний),
  отказ от заведомо широкого поиска расписания без выбранной цели,
  центрирование карточки логина
- Тесты: 237/237 (npm run check), регрессионные на lazy-вкладки и ширину
- Документация: REACT_MIGRATION.md, REACT_MIGRATION_RESULT.md, FRONTEND.md
2026-09-28 23:15:04 +03:00

174 lines
7.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { build } from 'esbuild';
import { readFile, rm } from 'node:fs/promises';
import { gzipSync } from 'node:zlib';
const LOGIN_ENTRY_GZIP_LIMIT = 10_000;
const STUDENT_ENTRY_GZIP_LIMIT = 20_000;
const TEACHER_ENTRY_GZIP_LIMIT = 20_000;
const SETTINGS_ENTRY_GZIP_LIMIT = 20_000;
const ADMIN_ENTRY_GZIP_LIMIT = 20_000;
// Лимит общих chunk'ов поднят с 75 000 до 85 000 на этапе 5 (волна 1):
// компоненты дизайн-системы (ConfirmDialog, AsyncCombobox) стали общими
// chunk'ами — их теперь используют и React-вкладки админ-панели, поэтому
// они переехали из entrypoint'ов студента/настроек в общий кэшируемый пул.
const SHARED_CHUNKS_GZIP_LIMIT = 85_000;
// Лимит холодной загрузки входа поднят с 80 000 до 85 000 на этапе 5 (волна 1):
// общие компоненты дизайн-системы (ConfirmDialog, AsyncCombobox) стали общими
// chunk'ами — их теперь используют и React-вкладки админ-панели. Сам вход
// login не изменился, вырос только объём общих кэшируемых chunk'ов.
const COLD_LOAD_GZIP_LIMIT = 85_000;
const STUDENT_COLD_LOAD_GZIP_LIMIT = 90_000;
const TEACHER_COLD_LOAD_GZIP_LIMIT = 90_000;
const SETTINGS_COLD_LOAD_GZIP_LIMIT = 90_000;
const ADMIN_COLD_LOAD_GZIP_LIMIT = 90_000;
// Lazy-chunkи вкладок админ-панели: каждая вкладка загружается
// отдельным chunk'ом при открытии, поэтому бюджет отдельный от
// холодной загрузки оболочки.
const ADMIN_TAB_CHUNKS_GZIP_LIMIT = 150_000;
await rm('dist/react', { force: true, recursive: true });
const result = await build({
entryPoints: {
login: 'react/login/main.jsx',
student: 'react/student/main.jsx',
teacher: 'react/teacher/main.jsx',
settings: 'react/settings/main.jsx',
admin: 'react/admin/main.jsx',
'shared/runtime': 'react/shared/runtime-entry.jsx'
},
bundle: true,
chunkNames: 'shared/chunks/[name]-[hash]',
define: {
'process.env.NODE_ENV': '"production"'
},
format: 'esm',
jsx: 'automatic',
legalComments: 'none',
metafile: true,
minify: true,
outdir: 'dist/react',
platform: 'browser',
splitting: true,
target: ['es2022']
});
const outputs = result.metafile.outputs;
function outputBytes(path) {
return outputs[path]?.bytes ?? 0;
}
async function gzipSize(path) {
return gzipSync(await readFile(path)).byteLength;
}
const sharedChunkPaths = Object.keys(outputs)
.filter(path => path.startsWith('dist/react/shared/chunks/') && path.endsWith('.js'));
// Chunkи, достижимые только из entrypoint'а админ-панели (lazy-загрузчики
// React-вкладок react/admin/**), — в общий runtime-бюджет и холодную загрузку
// других страниц они не входят. Классифицируем по достижимости от entrypoint'ов,
// потому что модуль react/shared, используемый только одной вкладкой, попадает
// в её chunk и не является общим.
const ENTRY_OUTPUT_PATHS = Object.freeze({
login: 'dist/react/login.js',
student: 'dist/react/student.js',
teacher: 'dist/react/teacher.js',
settings: 'dist/react/settings.js',
admin: 'dist/react/admin.js',
runtime: 'dist/react/shared/runtime.js'
});
function chunkReachability() {
const reach = new Map();
for (const [entryName, entryPath] of Object.entries(ENTRY_OUTPUT_PATHS)) {
const stack = [entryPath];
const seen = new Set();
while (stack.length > 0) {
const path = stack.pop();
if (seen.has(path) || !outputs[path]) continue;
seen.add(path);
for (const imported of outputs[path].imports || []) {
stack.push(imported.path);
}
}
for (const path of seen) {
if (!reach.has(path)) reach.set(path, new Set());
reach.get(path).add(entryName);
}
}
return reach;
}
const reach = chunkReachability();
const adminLazyChunkPaths = sharedChunkPaths.filter(path => {
const entries = reach.get(path);
return entries !== undefined && entries.size > 0 && [...entries].every(name => name === 'admin');
});
const runtimeChunkPaths = sharedChunkPaths.filter(path => !adminLazyChunkPaths.includes(path));
const sharedChunkBytes = runtimeChunkPaths.reduce((total, path) => total + outputBytes(path), 0);
const sharedChunkGzipBytes = (
await Promise.all(runtimeChunkPaths.map(gzipSize))
).reduce((total, bytes) => total + bytes, 0);
const adminLazyChunkBytes = adminLazyChunkPaths.reduce((total, path) => total + outputBytes(path), 0);
const adminLazyChunkGzipBytes = (
await Promise.all(adminLazyChunkPaths.map(gzipSize))
).reduce((total, bytes) => total + bytes, 0);
const entryReports = [];
for (const [name, path, limit, coldLimit] of [
['входа', 'dist/react/login.js', LOGIN_ENTRY_GZIP_LIMIT, COLD_LOAD_GZIP_LIMIT],
['студента', 'dist/react/student.js', STUDENT_ENTRY_GZIP_LIMIT, STUDENT_COLD_LOAD_GZIP_LIMIT],
['преподавателя', 'dist/react/teacher.js', TEACHER_ENTRY_GZIP_LIMIT, TEACHER_COLD_LOAD_GZIP_LIMIT],
['настроек', 'dist/react/settings.js', SETTINGS_ENTRY_GZIP_LIMIT, SETTINGS_COLD_LOAD_GZIP_LIMIT],
['админ-панели', 'dist/react/admin.js', ADMIN_ENTRY_GZIP_LIMIT, ADMIN_COLD_LOAD_GZIP_LIMIT]
]) {
const rawBytes = outputBytes(path);
const gzipBytes = await gzipSize(path);
const coldLoadGzipBytes = gzipBytes + sharedChunkGzipBytes;
entryReports.push({ name, path, rawBytes, gzipBytes, coldLoadGzipBytes, limit, coldLimit });
if (gzipBytes > limit) {
throw new Error(
`React entrypoint ${name} превышает бюджет: ${gzipBytes} байт gzip при лимите ${limit}`
);
}
if (coldLoadGzipBytes > coldLimit) {
throw new Error(
`Холодная загрузка ${name} превышает бюджет: ${coldLoadGzipBytes} байт gzip при лимите ${coldLimit}`
);
}
}
if (sharedChunkGzipBytes > SHARED_CHUNKS_GZIP_LIMIT) {
throw new Error(
`Общие React-chunk превышают бюджет: ${sharedChunkGzipBytes} байт gzip при лимите ${SHARED_CHUNKS_GZIP_LIMIT}`
);
}
if (adminLazyChunkGzipBytes > ADMIN_TAB_CHUNKS_GZIP_LIMIT) {
throw new Error(
`Lazy-chunkи вкладок админки превышают бюджет: ${adminLazyChunkGzipBytes} байт gzip`
+ ` при лимите ${ADMIN_TAB_CHUNKS_GZIP_LIMIT}`
);
}
if (runtimeChunkPaths.length === 0) {
throw new Error('Сборка не создала общий кэшируемый React-chunk');
}
for (const report of entryReports) {
console.log(
`React entrypoint ${report.name}: ${report.rawBytes} байт, ${report.gzipBytes} байт gzip, `
+ `холодная загрузка ${report.coldLoadGzipBytes} байт gzip`
);
}
console.log(`Общие React-chunk: ${sharedChunkBytes} байт, ${sharedChunkGzipBytes} байт gzip`);
console.log(
`Lazy-chunkи вкладок админки: ${adminLazyChunkPaths.length} chunk, `
+ `${adminLazyChunkBytes} байт, ${adminLazyChunkGzipBytes} байт gzip`
);