Админка, настройки, логин, кабинеты преподавателя и студента полностью переведены на 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
174 lines
7.6 KiB
JavaScript
174 lines
7.6 KiB
JavaScript
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`
|
||
);
|