259 lines
9.7 KiB
JavaScript
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)`);
|