Files
portal/app/scripts/parse-bundle-analyze.mjs
T

259 lines
9.7 KiB
JavaScript

// Parse rollup-plugin-visualizer HTML output → Top-15 chunks + critical-path total.
// Reads storage/bundle-analyze.html, extracts the inline `const data = {...};` JSON,
// computes per-chunk raw + gzip + brotli totals by walking each top-level chunk's tree
// and summing the corresponding nodeParts entries.
import { readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const htmlPath = resolve(__dirname, '..', 'storage', 'bundle-analyze.html');
const html = readFileSync(htmlPath, 'utf8');
// Locate the data assignment.
const marker = 'const data = ';
const start = html.indexOf(marker);
if (start === -1) {
console.error('FATAL: marker "const data = " not found');
process.exit(2);
}
// Find the terminating `};\n` that closes the object literal.
// The line ends with `}};` (closing data) — find first `};` after start.
const afterMarker = start + marker.length;
// Trailing semicolon: search for `};` then back up. Simpler: capture until matching brace.
let depth = 0;
let i = afterMarker;
let inString = false;
let escape = false;
let firstBraceFound = false;
for (; i < html.length; i++) {
const ch = html[i];
if (escape) { escape = false; continue; }
if (ch === '\\') { escape = true; continue; }
if (ch === '"') { inString = !inString; continue; }
if (inString) continue;
if (ch === '{') { depth++; firstBraceFound = true; }
else if (ch === '}') {
depth--;
if (firstBraceFound && depth === 0) { i++; break; }
}
}
const jsonStr = html.slice(afterMarker, i);
const data = JSON.parse(jsonStr);
// Build uid -> nodePart map (raw=renderedLength, gzip=gzipLength, brotli=brotliLength).
// In visualizer schema v2, each leaf uid lives in `nodeParts`.
const nodeParts = data.nodeParts || {};
// Walk a tree node, collect all uids in subtree.
function collectUids(node, out) {
if (!node) return;
if (node.uid) {
out.push(node.uid);
return;
}
if (Array.isArray(node.children)) {
for (const c of node.children) collectUids(c, out);
}
}
// Top-level: data.tree.children = array of chunks (name = "assets/<chunk>.js" or "assets/<chunk>.css").
const chunks = [];
for (const chunkNode of data.tree.children) {
const uids = [];
collectUids(chunkNode, uids);
let raw = 0;
let gzip = 0;
let brotli = 0;
for (const uid of uids) {
const part = nodeParts[uid];
if (!part) continue;
raw += part.renderedLength || 0;
gzip += part.gzipLength || 0;
brotli += part.brotliLength || 0;
}
chunks.push({
name: chunkNode.name,
raw,
gzip,
brotli,
moduleCount: uids.length,
});
}
// Sort by raw size descending.
chunks.sort((a, b) => b.raw - a.raw);
// Format helper.
const fmt = (n) => `${(n / 1024).toFixed(2)} kB`;
console.log('=== Top-15 chunks (sorted by raw size) ===');
console.log('| # | name | raw | gzip | brotli | modules |');
console.log('|---|---|---|---|---|---|');
chunks.slice(0, 15).forEach((c, idx) => {
console.log(`| ${idx + 1} | ${c.name} | ${fmt(c.raw)} | ${fmt(c.gzip)} | ${fmt(c.brotli)} | ${c.moduleCount} |`);
});
console.log('\n=== All chunks (full list, for critical-path identification) ===');
console.log('| # | name | raw | gzip | brotli |');
console.log('|---|---|---|---|---|');
chunks.forEach((c, idx) => {
console.log(`| ${idx + 1} | ${c.name} | ${fmt(c.raw)} | ${fmt(c.gzip)} | ${fmt(c.brotli)} |`);
});
// Critical path: eager chunks loaded on initial page render.
// For an SPA with vite-plugin + dynamic-import lazy routes, the eager set = entry chunks
// (app-*.js + app-*.css) plus any chunks they statically import.
// The entry is `resources/js/app.ts` → produces `app-<hash>.js`.
// Static imports of entry from manifest = critical path; dynamic imports = lazy.
//
// Read the manifest.json (Laravel Vite plugin) to find the entry's static imports.
const manifestPath = resolve(__dirname, '..', 'public', 'build', 'manifest.json');
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
// Find entry (isEntry: true).
const entries = Object.entries(manifest).filter(([, v]) => v.isEntry);
console.log('\n=== Manifest entries (isEntry=true) ===');
for (const [key, val] of entries) {
console.log(` ${key} -> ${val.file}`);
if (val.imports) console.log(` imports: ${JSON.stringify(val.imports)}`);
if (val.css) console.log(` css: ${JSON.stringify(val.css)}`);
if (val.dynamicImports) console.log(` dynamicImports.count: ${val.dynamicImports.length}`);
}
// Resolve critical-path file set: entry.file + recursive entry.imports[].file + entry.css[].
const eagerFiles = new Set();
function addEager(manifestKey) {
const entry = manifest[manifestKey];
if (!entry) return;
if (entry.file && !eagerFiles.has(entry.file)) {
eagerFiles.add(entry.file);
// Recurse into static imports only (NOT dynamicImports).
if (Array.isArray(entry.imports)) {
for (const imp of entry.imports) addEager(imp);
}
if (Array.isArray(entry.css)) {
for (const c of entry.css) eagerFiles.add(c);
}
}
}
for (const [key, val] of entries) {
if (val.isEntry) addEager(key);
}
console.log('\n=== Critical-path eager file set (entry + static imports + css) ===');
for (const f of eagerFiles) console.log(` - ${f}`);
// Sum the corresponding chunks' gzip + raw.
let eagerRaw = 0;
let eagerGzip = 0;
let eagerBrotli = 0;
for (const f of eagerFiles) {
// Chunk name in visualizer is "assets/<file>" stripping "assets/" prefix in manifest path.
// Manifest file path is e.g. "assets/app-XXXX.js" already.
const target = chunks.find((c) => c.name === f);
if (target) {
eagerRaw += target.raw;
eagerGzip += target.gzip;
eagerBrotli += target.brotli;
} else {
// CSS files are not in tree (visualizer only tracks JS bundle internals).
// Read CSS size directly from disk.
try {
const cssPath = resolve(__dirname, '..', 'public', 'build', f);
const css = readFileSync(cssPath);
const zlib = await import('node:zlib');
const gzSize = zlib.gzipSync(css).length;
const brSize = zlib.brotliCompressSync(css).length;
eagerRaw += css.length;
eagerGzip += gzSize;
eagerBrotli += brSize;
console.log(` (CSS direct measure) ${f}: raw=${fmt(css.length)} gzip=${fmt(gzSize)} brotli=${fmt(brSize)}`);
} catch (e) {
console.log(` WARN: could not measure ${f}: ${e.message}`);
}
}
}
console.log('\n=== Critical-path eager total (visualizer-attribution; pre-minify source bytes) ===');
console.log(`raw: ${fmt(eagerRaw)}`);
console.log(`gzip: ${fmt(eagerGzip)}`);
console.log(`brotli: ${fmt(eagerBrotli)}`);
console.log(`(${eagerFiles.size} files)`);
// ==========================================================================
// DISK-TRUTH VIEW: measure emitted files directly. Visualizer's renderedLength
// is pre-minify source-byte attribution; on-disk JS is post-minify. Vite's
// build stdout reports on-disk truth. Re-compute Top-15 + critical-path from
// disk for an apples-to-apples view against the Vite log.
// ==========================================================================
import { readdirSync, statSync } from 'node:fs';
import { gzipSync, brotliCompressSync } from 'node:zlib';
const buildDir = resolve(__dirname, '..', 'public', 'build', 'assets');
const allFiles = readdirSync(buildDir);
const diskJs = [];
const diskCss = [];
for (const f of allFiles) {
const full = resolve(buildDir, f);
const st = statSync(full);
if (!st.isFile()) continue;
const content = readFileSync(full);
const entry = {
name: `assets/${f}`,
raw: content.length,
gzip: gzipSync(content).length,
brotli: brotliCompressSync(content).length,
};
if (f.endsWith('.js')) diskJs.push(entry);
else if (f.endsWith('.css')) diskCss.push(entry);
}
diskJs.sort((a, b) => b.raw - a.raw);
diskCss.sort((a, b) => b.raw - a.raw);
console.log('\n=== Top-15 chunks (DISK-TRUTH, .js only, sorted by raw on-disk size) ===');
console.log('| # | name | raw | gzip | brotli |');
console.log('|---|---|---|---|---|');
diskJs.slice(0, 15).forEach((c, idx) => {
console.log(`| ${idx + 1} | ${c.name} | ${fmt(c.raw)} | ${fmt(c.gzip)} | ${fmt(c.brotli)} |`);
});
console.log('\n=== Top-10 CSS files (DISK-TRUTH) ===');
console.log('| # | name | raw | gzip | brotli |');
console.log('|---|---|---|---|---|');
diskCss.slice(0, 10).forEach((c, idx) => {
console.log(`| ${idx + 1} | ${c.name} | ${fmt(c.raw)} | ${fmt(c.gzip)} | ${fmt(c.brotli)} |`);
});
// Critical-path eager total — DISK TRUTH using same manifest set.
let diskEagerRaw = 0;
let diskEagerGzip = 0;
let diskEagerBrotli = 0;
const diskEagerDetails = [];
for (const f of eagerFiles) {
const target = [...diskJs, ...diskCss].find((c) => c.name === f);
if (target) {
diskEagerRaw += target.raw;
diskEagerGzip += target.gzip;
diskEagerBrotli += target.brotli;
diskEagerDetails.push(target);
} else {
console.log(` WARN: ${f} not found on disk`);
}
}
console.log('\n=== Critical-path eager files (DISK TRUTH details) ===');
console.log('| # | name | raw | gzip | brotli |');
console.log('|---|---|---|---|---|');
diskEagerDetails.forEach((c, idx) => {
console.log(`| ${idx + 1} | ${c.name} | ${fmt(c.raw)} | ${fmt(c.gzip)} | ${fmt(c.brotli)} |`);
});
console.log('\n=== Critical-path eager total (DISK TRUTH = sum of emitted files) ===');
console.log(`raw: ${fmt(diskEagerRaw)}`);
console.log(`gzip: ${fmt(diskEagerGzip)}`);
console.log(`brotli: ${fmt(diskEagerBrotli)}`);
console.log(`(${eagerFiles.size} files)`);