diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 375bc9077..cb9e76017 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,6 +59,9 @@ jobs: - name: Build production bundle run: npm run build + - name: Main chunk (no three or Navara in Cesium mode; size budget when recorded) + run: npm run check:main-chunk + street-level-browser: # Hermetic: every Mapillary request is answered from fixtures, so the # dummy token below never leaves the page. @@ -89,6 +92,9 @@ jobs: MAPILLARY_CLIENT_TOKEN: 'MLY|0|qa-fixture' run: npm run build + - name: Main chunk (no three or Navara in Cesium mode; size budget when recorded) + run: npm run check:main-chunk + - name: Start the preview server env: MAPILLARY_CLIENT_TOKEN: 'MLY|0|qa-fixture' @@ -154,3 +160,6 @@ jobs: - name: Build production bundle run: npm run build + + - name: Main chunk (no three or Navara in Cesium mode; size budget when recorded) + run: npm run check:main-chunk diff --git a/build/initialChunkGuard.js b/build/initialChunkGuard.js new file mode 100644 index 000000000..263362ba8 --- /dev/null +++ b/build/initialChunkGuard.js @@ -0,0 +1,52 @@ +// Build guard: the Cesium-mode page must not load three or Navara. The +// initial chunks are every entry chunk and what it imports statically; +// navaraMode.js reaches Navara only through import(), so three and +// @navaramap/* belong to lazy chunks. This checks the bundle's module ids +// (what Rollup put in each chunk), and fails the build otherwise; +// scripts/check-main-chunk.mjs checks the built files' text as well. + +/** Module ids that only the Navara engine may load. */ +export const ENGINE_ONLY_MODULES = + /[\\/]node_modules[\\/](?:three|@navaramap[\\/][^\\/]+|postprocessing)[\\/]/; + +/** + * Engine-only modules in initial chunks of a Rollup bundle, sorted by chunk. + * @param {Record} bundle + * @returns {{ chunk: string, module: string }[]} + */ +export function initialChunkViolations(bundle, pattern = ENGINE_ONLY_MODULES) { + const initial = new Set(); + const visit = (fileName) => { + const chunk = bundle[fileName]; + if (!chunk || chunk.type !== 'chunk' || initial.has(fileName)) return; + initial.add(fileName); + for (const imported of chunk.imports ?? []) visit(imported); + }; + for (const [fileName, chunk] of Object.entries(bundle)) + if (chunk.type === 'chunk' && chunk.isEntry) visit(fileName); + const violations = []; + for (const fileName of [...initial].sort()) + for (const module of bundle[fileName].moduleIds ?? []) + if (pattern.test(module)) violations.push({ chunk: fileName, module }); + return violations; +} + +/** The Rollup plugin: fails generateBundle when an initial chunk holds three or Navara. */ +export function initialChunkGuard({ log = console.log } = {}) { + return { + name: 'gev-initial-chunk-guard', + generateBundle(_options, bundle) { + const violations = initialChunkViolations(bundle); + if (violations.length) { + this.error( + 'three/Navara in the Cesium-mode initial chunks:\n' + + violations + .map(({ chunk, module }) => ` ${chunk}: ${module}`) + .join('\n'), + ); + return; + } + log('[initial-chunks] no three or Navara modules in the initial chunks'); + }, + }; +} diff --git a/build/vite.js b/build/vite.js index 5929c979d..50bc960e7 100644 --- a/build/vite.js +++ b/build/vite.js @@ -4,6 +4,7 @@ import { DEFAULT_ALLOWED_HOSTS, hostCheckPlugin } from './allowedHosts.js'; import { embedFramingPlugin } from './embed-framing.js'; import { panelBuildPlugin } from './panel.js'; import { navaraChunkFileNames, navaraWorkerAssets } from './navaraAssets.js'; +import { initialChunkGuard } from './initialChunkGuard.js'; /** * Content-Security-Policy for every document the dev/preview server serves. @@ -132,7 +133,8 @@ export function createBrowserViteConfig({ // engine is a separate chunk loaded on ?engine=navara). // The lazy chunk with the Navara engine is named navara-engine. rollupOptions: { - plugins: [navaraWorkerAssets()], + // initialChunkGuard: no three/Navara module in the initial chunks. + plugins: [navaraWorkerAssets(), initialChunkGuard()], output: { chunkFileNames: navaraChunkFileNames }, }, }, diff --git a/scripts/check-main-chunk.mjs b/scripts/check-main-chunk.mjs index 7de3fa55b..64eca396a 100644 --- a/scripts/check-main-chunk.mjs +++ b/scripts/check-main-chunk.mjs @@ -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(); diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 6956983f3..6799cddf8 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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" ] diff --git a/src/tooling/initialChunkGuard.test.mjs b/src/tooling/initialChunkGuard.test.mjs new file mode 100644 index 000000000..329ec4783 --- /dev/null +++ b/src/tooling/initialChunkGuard.test.mjs @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + ENGINE_ONLY_MODULES, + initialChunkGuard, + initialChunkViolations, +} from '../../build/initialChunkGuard.js'; + +const chunk = (fields) => ({ + type: 'chunk', + isEntry: false, + imports: [], + moduleIds: [], + ...fields, +}); + +const bundle = () => ({ + 'assets/index-A.js': chunk({ + isEntry: true, + imports: ['assets/vendor-B.js'], + moduleIds: ['/repo/src/main.js', '/repo/node_modules/cesium/index.js'], + }), + 'assets/vendor-B.js': chunk({ + moduleIds: ['/repo/node_modules/satellite.js/dist/index.js'], + }), + 'assets/navara-engine-C.js': chunk({ + isDynamicEntry: true, + moduleIds: [ + '/repo/node_modules/three/build/three.module.js', + '/repo/node_modules/@navaramap/three/dist/index.js', + ], + }), + 'assets/style.css': { type: 'asset' }, +}); + +test('lazy chunks may hold three and Navara; initial chunks are clean', () => { + assert.deepEqual(initialChunkViolations(bundle()), []); + assert.ok(ENGINE_ONLY_MODULES.test('/x/node_modules/three/src/a.js')); + assert.ok( + ENGINE_ONLY_MODULES.test('C:\\x\\node_modules\\@navaramap\\three\\a.js'), + ); + assert.ok(!ENGINE_ONLY_MODULES.test('/x/node_modules/three-geojson/a.js')); + assert.ok(!ENGINE_ONLY_MODULES.test('/x/src/layers/threeD.js')); +}); + +test('three or Navara reached by a static import from the entry is reported', () => { + const broken = bundle(); + broken['assets/vendor-B.js'].moduleIds.push( + '/repo/node_modules/three/build/three.core.js', + ); + broken['assets/index-A.js'].moduleIds.push( + '/repo/node_modules/@navaramap/three-default-plugin/dist/index.js', + ); + assert.deepEqual(initialChunkViolations(broken), [ + { + chunk: 'assets/index-A.js', + module: + '/repo/node_modules/@navaramap/three-default-plugin/dist/index.js', + }, + { + chunk: 'assets/vendor-B.js', + module: '/repo/node_modules/three/build/three.core.js', + }, + ]); +}); + +test('the plugin fails the build on a violation and passes a clean bundle', () => { + const plugin = initialChunkGuard({ log: () => {} }); + const errors = []; + const context = { error: (message) => errors.push(message) }; + plugin.generateBundle.call(context, {}, bundle()); + assert.deepEqual(errors, []); + const broken = bundle(); + broken['assets/index-A.js'].moduleIds.push('/r/node_modules/three/a.js'); + plugin.generateBundle.call(context, {}, broken); + assert.equal(errors.length, 1); + assert.match(errors[0], /three.*index-A\.js/s); +}); diff --git a/src/tooling/mainChunk.test.mjs b/src/tooling/mainChunkComposition.test.mjs similarity index 73% rename from src/tooling/mainChunk.test.mjs rename to src/tooling/mainChunkComposition.test.mjs index dd1ee2418..2db02cf05 100644 --- a/src/tooling/mainChunk.test.mjs +++ b/src/tooling/mainChunkComposition.test.mjs @@ -84,3 +84,21 @@ test('three and Navara markers are reported, ordinary engine names are not', () ]); assert.ok(FORBIDDEN_MARKERS.length >= 2); }); + +test('the script exports the gzip budget check (phase 4 shape) next to the composition check', async () => { + const { mkdirSync, mkdtempSync, writeFileSync } = await import('node:fs'); + const { tmpdir } = await import('node:os'); + const path = await import('node:path'); + const { checkMainChunk, measureMainChunk } = + await import('../../scripts/check-main-chunk.mjs'); + const baseline = { gzipBytes: 1000, allowanceBytes: 100 }; + assert.equal(checkMainChunk(1100, baseline).ok, true); + assert.equal(checkMainChunk(1101, baseline).ok, false); + const dist = mkdtempSync(path.join(tmpdir(), 'gev-main-chunk-')); + mkdirSync(path.join(dist, 'assets')); + writeFileSync(path.join(dist, 'assets/index-small.js'), 'x'); + writeFileSync(path.join(dist, 'assets/index-main.js'), 'x'.repeat(5000)); + const measured = measureMainChunk(dist); + assert.equal(measured.name, 'index-main.js'); + assert.ok(measured.gzipBytes > 0 && measured.gzipBytes < 5000); +}); diff --git a/src/tooling/navaraAssets.test.mjs b/src/tooling/navaraAssets.test.mjs index 878e0482a..fbab2771c 100644 --- a/src/tooling/navaraAssets.test.mjs +++ b/src/tooling/navaraAssets.test.mjs @@ -46,11 +46,11 @@ test('a build without Navara gets no Navara wasm', (t) => { assert.deepEqual(emitted, []); }); -test('the browser build runs the Navara asset copy as a Rollup plugin', () => { +test('the browser build runs the Navara asset copy and the initial-chunk guard as Rollup plugins', () => { const { build } = createBrowserViteConfig({}); assert.deepEqual( build.rollupOptions.plugins.map(({ name }) => name), - ['navara-worker-assets'], + ['navara-worker-assets', 'gev-initial-chunk-guard'], ); assert.equal(build.chunkSizeWarningLimit, 1500); });