build: guard the initial chunks by module id; main-chunk check in CI

Review of Task 0:
- build/initialChunkGuard.js, a Rollup plugin in the browser build, fails
  the build when an initial chunk (an entry and what it imports
  statically) holds a module from node_modules/three, @navaramap/* or
  postprocessing. Checked by forcing it on satellite.js, which the main
  chunk does hold: the build fails naming the chunk and module.
  navaraAssets.test now pins both build plugins.
- scripts/check-main-chunk.mjs is one script for both main-chunk checks:
  the composition (text markers in the initial chunks, sizes) and, when
  scripts/main-chunk-baseline.json exists, the phase-4 branch's gzip-9
  budget, with its exports (checkMainChunk, measureMainChunk) so the two
  branches merge into this file. The composition test moved to
  src/tooling/mainChunkComposition.test.mjs to leave phase 4's
  mainChunk.test.mjs its name.
- CI runs npm run check:main-chunk after each npm run build (unit,
  browser-gate and Windows jobs).
This commit is contained in:
German Khokhlov committed 2026-10-16 16:22:49 +04:00
1 parent f7f4e9a45c
commit 76851ee949
8 files changed
+289 -66

No files matched your search

+124 -61
View File
@@ -1,17 +1,25 @@
#!/usr/bin/env node
/**
* The build gate for the Cesium-mode bundle: the main chunk (the module
* script dist/index.html loads, or the Vite manifest's entry when the build
* writes one) and every chunk it imports statically must carry no three or
* Navara code; those load only through navaraMode.js's import(). Prints the
* sizes (raw and gzip) and fails when a marker is found.
* The main-chunk gate of the Cesium-mode bundle (run after `npm run build`):
*
* npm run build && node scripts/check-main-chunk.mjs [--dist=dist] [--json]
* 1. Composition: the main chunk (the Vite manifest's entry, or the module
* script dist/index.html loads) and every chunk it imports statically carry
* no three or Navara code (text markers; the build itself also fails on
* three/@navaramap module ids in initial chunks, build/initialChunkGuard.js).
* 2. Budget, when scripts/main-chunk-baseline.json exists: gzip-9 of the
* largest dist/assets/index-*.js may exceed the recorded baseline by at
* most its allowance (the phase-4 branch's check; same exports,
* checkMainChunk and measureMainChunk, so the two merge into this script).
*
* Prints the sizes. `--json` prints the whole result; `--dist=DIR` reads
* another build.
*
* npm run build && npm run check:main-chunk
*/
import fs from 'node:fs';
import path from 'node:path';
import { gzipSync } from 'node:zlib';
import { fileURLToPath } from 'node:url';
import { fileURLToPath, pathToFileURL } from 'node:url';
import {
entryFromHtml,
entryFromManifest,
@@ -19,13 +27,40 @@ import {
initialChunks,
} from '../src/tooling/mainChunk.js';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const option = (name) =>
process.argv.find((arg) => arg.startsWith(`--${name}=`))?.split('=')[1];
const dist = path.resolve(root, option('dist') ?? 'dist');
const asJson = process.argv.includes('--json');
const ROOT = fileURLToPath(new URL('..', import.meta.url));
function findEntry() {
/** The verdict for a main-chunk size against the baseline. */
export function checkMainChunk(
gzipBytes,
{ gzipBytes: baseline, allowanceBytes },
) {
const limit = baseline + allowanceBytes;
const growth = gzipBytes - baseline;
return {
ok: gzipBytes <= limit,
message: `main chunk ${gzipBytes} bytes gzip-9 (${growth >= 0 ? '+' : ''}${growth} over the ${baseline} baseline; limit ${limit})`,
};
}
/** gzip-9 size of the largest dist/assets/index-*.js, with its name. */
export function measureMainChunk(distDir) {
const assets = path.join(distDir, 'assets');
const candidates = fs
.readdirSync(assets)
.filter((name) => /^index-.*\.js$/.test(name))
.map((name) => ({ name, bytes: fs.readFileSync(path.join(assets, name)) }))
.sort((a, b) => b.bytes.length - a.bytes.length);
if (!candidates.length)
throw new Error('no dist/assets/index-*.js: run npm run build first');
const [main] = candidates;
return {
name: main.name,
gzipBytes: gzipSync(main.bytes, { level: 9 }).length,
};
}
/** The entry chunk (relative to dist) and how it was found. */
function findEntry(dist) {
const manifestPath = path.join(dist, '.vite', 'manifest.json');
if (fs.existsSync(manifestPath)) {
const entry = entryFromManifest(
@@ -41,54 +76,82 @@ function findEntry() {
return { entry, via: 'index.html' };
}
const read = (file) => {
const absolute = path.join(dist, file);
return fs.existsSync(absolute) ? fs.readFileSync(absolute, 'utf8') : null;
};
const { entry, via } = findEntry();
const chunks = initialChunks(entry, read).map((file) => {
const code = read(file);
if (code == null)
return { file, missing: true, bytes: 0, gzipBytes: 0, markers: [] };
/** Composition of the initial chunks: sizes and forbidden markers. */
export function checkComposition(dist) {
const read = (file) => {
const absolute = path.join(dist, file);
return fs.existsSync(absolute) ? fs.readFileSync(absolute, 'utf8') : null;
};
const { entry, via } = findEntry(dist);
const chunks = initialChunks(entry, read).map((file) => {
const code = read(file);
if (code == null)
return { file, missing: true, bytes: 0, gzipBytes: 0, markers: [] };
return {
file,
bytes: Buffer.byteLength(code),
gzipBytes: gzipSync(code).length,
markers: forbiddenMarkersIn(code),
};
});
return {
file,
bytes: Buffer.byteLength(code),
gzipBytes: gzipSync(code).length,
markers: forbiddenMarkersIn(code),
entry,
via,
mainBytes: chunks[0].bytes,
mainGzipBytes: chunks[0].gzipBytes,
initialBytes: chunks.reduce((sum, chunk) => sum + chunk.bytes, 0),
chunks,
ok: chunks.every((chunk) => !chunk.missing && !chunk.markers.length),
};
});
const main = chunks[0];
const failures = chunks.filter(
(chunk) => chunk.missing || chunk.markers.length,
);
const result = {
entry,
via,
mainBytes: main.bytes,
mainGzipBytes: main.gzipBytes,
initialBytes: chunks.reduce((sum, chunk) => sum + chunk.bytes, 0),
chunks,
ok: failures.length === 0,
};
if (asJson) console.log(JSON.stringify(result, null, 2));
else {
const kb = (bytes) => `${(bytes / 1000).toFixed(2)} kB`;
console.log(`[main-chunk] entry ${entry} (via ${via})`);
for (const chunk of chunks)
console.log(
`[main-chunk] ${chunk.file}: ${kb(chunk.bytes)} (gzip ${kb(chunk.gzipBytes)})` +
(chunk.missing ? ' MISSING' : '') +
(chunk.markers.length ? ` FORBIDDEN: ${chunk.markers.join(', ')}` : ''),
);
console.log(
`[main-chunk] main ${main.bytes} bytes (gzip ${main.gzipBytes}); initial chunks ${result.initialBytes} bytes`,
);
console.log(
result.ok
? '[main-chunk] ok: no three or Navara in the main chunk'
: '[main-chunk] FAIL',
);
}
process.exitCode = result.ok ? 0 : 1;
function main() {
const option = (name) =>
process.argv.find((arg) => arg.startsWith(`--${name}=`))?.split('=')[1];
const dist = path.resolve(ROOT, option('dist') ?? 'dist');
const composition = checkComposition(dist);
const baselinePath = path.join(ROOT, 'scripts/main-chunk-baseline.json');
let budget = null;
if (fs.existsSync(baselinePath)) {
const measured = measureMainChunk(dist);
budget = {
...measured,
...checkMainChunk(
measured.gzipBytes,
JSON.parse(fs.readFileSync(baselinePath, 'utf8')),
),
};
}
const ok = composition.ok && (budget?.ok ?? true);
if (process.argv.includes('--json')) {
console.log(JSON.stringify({ composition, budget, ok }, null, 2));
} else {
const kb = (bytes) => `${(bytes / 1000).toFixed(2)} kB`;
console.log(
`[check:main-chunk] entry ${composition.entry} (via ${composition.via})`,
);
for (const chunk of composition.chunks)
console.log(
`[check:main-chunk] ${chunk.file}: ${kb(chunk.bytes)} (gzip ${kb(chunk.gzipBytes)})` +
(chunk.missing ? ' MISSING' : '') +
(chunk.markers.length
? ` FORBIDDEN: ${chunk.markers.join(', ')}`
: ''),
);
console.log(
`[check:main-chunk] main ${composition.mainBytes} bytes (gzip ${composition.mainGzipBytes}); initial chunks ${composition.initialBytes} bytes; ` +
(composition.ok
? 'no three or Navara'
: 'three or Navara in the initial chunks'),
);
console.log(
budget
? `[check:main-chunk] ${budget.name}: ${budget.message}`
: '[check:main-chunk] no scripts/main-chunk-baseline.json: size budget not checked',
);
}
if (!ok) process.exitCode = 1;
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
main();
+3 -2
View File
@@ -1064,6 +1064,7 @@
"src/tooling/navaraAssets.test.mjs",
"src/tooling/tileCache.test.mjs",
"scripts/check-main-chunk.mjs",
"src/tooling/mainChunk.test.mjs",
"scripts/qa-w1-baseline.mjs"
"src/tooling/mainChunkComposition.test.mjs",
"scripts/qa-w1-baseline.mjs",
"src/tooling/initialChunkGuard.test.mjs"
]