Initial TrueGrowth source import
This commit is contained in:
293
scripts/validate-startup-bundle.js
Normal file
293
scripts/validate-startup-bundle.js
Normal file
@@ -0,0 +1,293 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const DIST_DIR = path.resolve(__dirname, '../dist/apps/web');
|
||||
const INDEX_HTML = path.join(DIST_DIR, 'index.html');
|
||||
const IDLE_MANIFEST = path.join(DIST_DIR, 'idle-prefetch-manifest.json');
|
||||
const DISALLOWED_PREFIXES = [
|
||||
'diagram-engines-',
|
||||
'tool-windows-',
|
||||
'external-skills-',
|
||||
];
|
||||
const MAX_STARTUP_ASSET_BYTES = 500 * 1024;
|
||||
const STATIC_IMPORT_RE =
|
||||
/(?:\bimport\s*(?:[^"'`]*?\bfrom\s*)?|\bexport\s*[^"'`]*?\bfrom\s*)["']\.\/([^"']+)["']/g;
|
||||
const DYNAMIC_IMPORT_RE = /\bimport\(["']\.\/([^"']+)["']\)/g;
|
||||
|
||||
function fail(message) {
|
||||
console.error(`[startup-validate] ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(INDEX_HTML)) {
|
||||
fail('dist/apps/web/index.html 不存在,请先构建 web 应用');
|
||||
}
|
||||
|
||||
const html = fs.readFileSync(INDEX_HTML, 'utf8');
|
||||
const scriptMatches = Array.from(
|
||||
html.matchAll(/<script[^>]+src="\.\/([^"]+)"[^>]*><\/script>/g)
|
||||
).map((match) => match[1]);
|
||||
const styleMatches = Array.from(
|
||||
html.matchAll(/<link[^>]+rel="stylesheet"[^>]+href="\.\/([^"]+)"[^>]*>/g)
|
||||
).map((match) => match[1]);
|
||||
|
||||
const directAssets = [...scriptMatches, ...styleMatches].filter((asset) =>
|
||||
asset.startsWith('assets/')
|
||||
);
|
||||
|
||||
if (directAssets.length === 0) {
|
||||
fail('入口 HTML 没有直接引用任何 assets 资源');
|
||||
}
|
||||
|
||||
const directScriptAssets = scriptMatches.filter((asset) =>
|
||||
asset.startsWith('assets/')
|
||||
);
|
||||
|
||||
// 运行时分组样式允许直接注入 HTML,避免首次展示时额外请求 CSS。
|
||||
// 这里仅阻止分组 JS 重新回流到首屏入口链。
|
||||
const invalidDirectAssets = directScriptAssets.filter((asset) =>
|
||||
DISALLOWED_PREFIXES.some((prefix) => path.basename(asset).startsWith(prefix))
|
||||
);
|
||||
|
||||
if (invalidDirectAssets.length > 0) {
|
||||
fail(`重模块重新回流到入口 HTML:${invalidDirectAssets.join(', ')}`);
|
||||
}
|
||||
|
||||
let idleManifest = { groups: {} };
|
||||
if (fs.existsSync(IDLE_MANIFEST)) {
|
||||
idleManifest = JSON.parse(fs.readFileSync(IDLE_MANIFEST, 'utf8'));
|
||||
}
|
||||
|
||||
function collectStaticImports(entryAsset, visited = new Set()) {
|
||||
if (visited.has(entryAsset)) {
|
||||
return visited;
|
||||
}
|
||||
visited.add(entryAsset);
|
||||
|
||||
const fullPath = path.join(DIST_DIR, entryAsset);
|
||||
if (!fs.existsSync(fullPath) || !entryAsset.endsWith('.js')) {
|
||||
return visited;
|
||||
}
|
||||
|
||||
const source = fs.readFileSync(fullPath, 'utf8');
|
||||
STATIC_IMPORT_RE.lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = STATIC_IMPORT_RE.exec(source))) {
|
||||
const imported = path.posix.normalize(
|
||||
path.posix.join(path.posix.dirname(entryAsset), match[1])
|
||||
);
|
||||
if (!visited.has(imported)) {
|
||||
collectStaticImports(imported, visited);
|
||||
}
|
||||
}
|
||||
|
||||
return visited;
|
||||
}
|
||||
|
||||
function collectDirectDynamicImports(entryAsset) {
|
||||
const dynamicImports = new Set();
|
||||
const fullPath = path.join(DIST_DIR, entryAsset);
|
||||
if (!fs.existsSync(fullPath) || !entryAsset.endsWith('.js')) {
|
||||
return dynamicImports;
|
||||
}
|
||||
|
||||
const source = fs.readFileSync(fullPath, 'utf8');
|
||||
DYNAMIC_IMPORT_RE.lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = DYNAMIC_IMPORT_RE.exec(source))) {
|
||||
dynamicImports.add(
|
||||
path.posix.normalize(
|
||||
path.posix.join(path.posix.dirname(entryAsset), match[1])
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return dynamicImports;
|
||||
}
|
||||
|
||||
function collectStaticImportAssets(entryAsset) {
|
||||
const imports = new Set();
|
||||
const fullPath = path.join(DIST_DIR, entryAsset);
|
||||
if (!fs.existsSync(fullPath) || !entryAsset.endsWith('.js')) {
|
||||
return imports;
|
||||
}
|
||||
|
||||
const source = fs.readFileSync(fullPath, 'utf8');
|
||||
STATIC_IMPORT_RE.lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = STATIC_IMPORT_RE.exec(source))) {
|
||||
imports.add(
|
||||
path.posix.normalize(
|
||||
path.posix.join(path.posix.dirname(entryAsset), match[1])
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return imports;
|
||||
}
|
||||
|
||||
function collectJsAssets() {
|
||||
const assetsDir = path.join(DIST_DIR, 'assets');
|
||||
if (!fs.existsSync(assetsDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fs
|
||||
.readdirSync(assetsDir)
|
||||
.filter((fileName) => fileName.endsWith('.js'))
|
||||
.map((fileName) => path.posix.join('assets', fileName));
|
||||
}
|
||||
|
||||
function buildStaticChunkGraph() {
|
||||
const jsAssets = collectJsAssets();
|
||||
const jsAssetSet = new Set(jsAssets);
|
||||
const graph = new Map();
|
||||
|
||||
for (const asset of jsAssets) {
|
||||
const imports = Array.from(collectStaticImportAssets(asset)).filter(
|
||||
(importedAsset) => jsAssetSet.has(importedAsset)
|
||||
);
|
||||
graph.set(asset, imports);
|
||||
}
|
||||
|
||||
return graph;
|
||||
}
|
||||
|
||||
function findStronglyConnectedComponents(graph) {
|
||||
let index = 0;
|
||||
const stack = [];
|
||||
const onStack = new Set();
|
||||
const indexes = new Map();
|
||||
const lowlinks = new Map();
|
||||
const components = [];
|
||||
|
||||
function visit(node) {
|
||||
indexes.set(node, index);
|
||||
lowlinks.set(node, index);
|
||||
index += 1;
|
||||
stack.push(node);
|
||||
onStack.add(node);
|
||||
|
||||
for (const next of graph.get(node) || []) {
|
||||
if (!graph.has(next)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!indexes.has(next)) {
|
||||
visit(next);
|
||||
lowlinks.set(node, Math.min(lowlinks.get(node), lowlinks.get(next)));
|
||||
} else if (onStack.has(next)) {
|
||||
lowlinks.set(node, Math.min(lowlinks.get(node), indexes.get(next)));
|
||||
}
|
||||
}
|
||||
|
||||
if (lowlinks.get(node) === indexes.get(node)) {
|
||||
const component = [];
|
||||
let current;
|
||||
|
||||
do {
|
||||
current = stack.pop();
|
||||
onStack.delete(current);
|
||||
component.push(current);
|
||||
} while (current !== node);
|
||||
|
||||
if (component.length > 1) {
|
||||
components.push(component.sort());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of graph.keys()) {
|
||||
if (!indexes.has(node)) {
|
||||
visit(node);
|
||||
}
|
||||
}
|
||||
|
||||
return components.sort((a, b) => b.length - a.length);
|
||||
}
|
||||
|
||||
const entryScripts = scriptMatches.filter(
|
||||
(asset) => asset.startsWith('assets/') && asset.endsWith('.js')
|
||||
);
|
||||
|
||||
const entryDependencyGraph = new Set();
|
||||
entryScripts.forEach((asset) => {
|
||||
collectStaticImports(asset, entryDependencyGraph);
|
||||
for (const dynamicAsset of collectDirectDynamicImports(asset)) {
|
||||
collectStaticImports(dynamicAsset, entryDependencyGraph);
|
||||
}
|
||||
});
|
||||
|
||||
const invalidStaticDeps = Array.from(entryDependencyGraph).filter(
|
||||
(asset) =>
|
||||
asset !== undefined &&
|
||||
DISALLOWED_PREFIXES.some((prefix) => path.basename(asset).startsWith(prefix))
|
||||
);
|
||||
|
||||
if (invalidStaticDeps.length > 0) {
|
||||
fail(`重模块重新回流到入口依赖链:${invalidStaticDeps.join(', ')}`);
|
||||
}
|
||||
|
||||
const chunkCycles = findStronglyConnectedComponents(buildStaticChunkGraph());
|
||||
if (chunkCycles.length > 0) {
|
||||
const formattedCycles = chunkCycles
|
||||
.map((component, index) => `#${index + 1}: ${component.join(' -> ')}`)
|
||||
.join('\n');
|
||||
fail(`构建产物存在静态 chunk 循环依赖:\n${formattedCycles}`);
|
||||
}
|
||||
|
||||
const startupAssetGraph = new Set([
|
||||
...directAssets,
|
||||
...entryDependencyGraph,
|
||||
]);
|
||||
const startupBudgetReport = Array.from(startupAssetGraph)
|
||||
.filter((asset) => asset && (asset.endsWith('.js') || asset.endsWith('.css')))
|
||||
.map((asset) => {
|
||||
const fullPath = path.join(DIST_DIR, asset);
|
||||
const size = fs.existsSync(fullPath) ? fs.statSync(fullPath).size : 0;
|
||||
return {
|
||||
asset,
|
||||
size,
|
||||
limit: MAX_STARTUP_ASSET_BYTES,
|
||||
ok: size <= MAX_STARTUP_ASSET_BYTES,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.size - a.size);
|
||||
|
||||
const oversizedStartupAssets = startupBudgetReport.filter((item) => !item.ok);
|
||||
if (oversizedStartupAssets.length > 0) {
|
||||
fail(
|
||||
`首屏依赖链存在超过 500KB 的资源:\n${oversizedStartupAssets
|
||||
.map(
|
||||
({ asset, size, limit }) =>
|
||||
`- ${asset}: ${size} bytes > ${limit} bytes`
|
||||
)
|
||||
.join('\n')}`
|
||||
);
|
||||
}
|
||||
|
||||
const sizeReport = directAssets.map((asset) => {
|
||||
const fullPath = path.join(DIST_DIR, asset);
|
||||
const size = fs.existsSync(fullPath) ? fs.statSync(fullPath).size : 0;
|
||||
return {
|
||||
asset,
|
||||
size,
|
||||
};
|
||||
});
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
directAssets: sizeReport,
|
||||
entryDependencyGraph: Array.from(entryDependencyGraph).sort(),
|
||||
startupBudget: startupBudgetReport,
|
||||
chunkCycles: [],
|
||||
idlePrefetchGroups: Object.keys(idleManifest.groups || {}),
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
Reference in New Issue
Block a user