diff --git a/package.json b/package.json index 1791ad656..289c64a97 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "qa:street-level": "node scripts/qa-street-level.mjs", "qa:street-level:fixtures": "node scripts/qa-street-level.mjs --fixtures", "check:boundaries": "node scripts/check-import-directions.mjs && node scripts/check-package-boundaries.mjs", + "check:main-chunk": "node scripts/check-main-chunk.mjs", "qa:transit": "node scripts/qa-transit.mjs", "mcp": "node server/mcp/stdio.js" }, diff --git a/scripts/check-main-chunk.mjs b/scripts/check-main-chunk.mjs new file mode 100644 index 000000000..7de3fa55b --- /dev/null +++ b/scripts/check-main-chunk.mjs @@ -0,0 +1,94 @@ +#!/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. + * + * npm run build && node scripts/check-main-chunk.mjs [--dist=dist] [--json] + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { gzipSync } from 'node:zlib'; +import { fileURLToPath } from 'node:url'; +import { + entryFromHtml, + entryFromManifest, + forbiddenMarkersIn, + 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'); + +function findEntry() { + const manifestPath = path.join(dist, '.vite', 'manifest.json'); + if (fs.existsSync(manifestPath)) { + const entry = entryFromManifest( + JSON.parse(fs.readFileSync(manifestPath, 'utf8')), + ); + if (entry) return { entry, via: 'manifest' }; + } + const htmlPath = path.join(dist, 'index.html'); + if (!fs.existsSync(htmlPath)) + throw new Error(`No build at ${dist}: run npm run build first`); + const entry = entryFromHtml(fs.readFileSync(htmlPath, 'utf8')); + if (!entry) throw new Error(`${htmlPath} loads no module script`); + 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: [] }; + return { + file, + bytes: Buffer.byteLength(code), + gzipBytes: gzipSync(code).length, + markers: forbiddenMarkersIn(code), + }; +}); +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; diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 8e0aa8922..46d7469e6 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1063,5 +1063,8 @@ "src/services/groundSnap.test.mjs", "src/sharelinkPose.test.mjs", "src/tooling/navaraAssets.test.mjs", - "src/tooling/tileCache.test.mjs" + "src/tooling/tileCache.test.mjs", + "scripts/check-main-chunk.mjs", + "src/tooling/mainChunk.test.mjs", + "scripts/qa-w1-baseline.mjs" ] diff --git a/src/tooling/mainChunk.js b/src/tooling/mainChunk.js new file mode 100644 index 000000000..dd2bfe29b --- /dev/null +++ b/src/tooling/mainChunk.js @@ -0,0 +1,80 @@ +// The main chunk of a Vite build and what it must not carry: the Cesium-mode +// page loads the entry chunk and every chunk it imports statically; Navara and +// three belong in the lazy chunk navaraMode.js loads with import(). Pure +// helpers for scripts/check-main-chunk.mjs. + +/** + * Markers that only three or Navara code leaves in a bundle (minification + * keeps string literals): three's own warnings name their classes, Navara's + * package names, wasm modules and attribution card classes. Plain engine + * names ('navara' as an engine kind) are not markers. + */ +export const FORBIDDEN_MARKERS = Object.freeze([ + Object.freeze({ + name: 'three', + pattern: /THREE\.(?:WebGLRenderer|GLTFLoader|EXRLoader|Vector\d)/, + }), + Object.freeze({ + name: 'navara', + pattern: /@navaramap\/|navara_wasm|navara-attr-/, + }), +]); + +/** The module script a built index.html loads, relative to dist ('assets/…'), or null. */ +export function entryFromHtml(html) { + const scripts = String(html).match(/]*>/g) ?? []; + for (const tag of scripts) { + if (!/\btype\s*=\s*["']module["']/.test(tag)) continue; + const src = tag.match(/\bsrc\s*=\s*["']([^"']+)["']/); + if (src) return src[1].replace(/^\.?\//, ''); + } + return null; +} + +/** The file of the manifest chunk marked isEntry for index.html (or any entry), or null. */ +export function entryFromManifest(manifest) { + const chunks = Object.values(manifest ?? {}); + const entry = + chunks.find((chunk) => chunk.isEntry && chunk.src === 'index.html') ?? + chunks.find((chunk) => chunk.isEntry); + return entry?.file ?? null; +} + +/** Sibling chunks a chunk imports statically (`from"./x.js"`, `import"./x.js"`). */ +export function staticImportsOf(code) { + const found = []; + const pattern = /(?:\bfrom|\bimport)\s*(["'])\.\/([^"']+\.js)\1/g; + for (const match of String(code).matchAll(pattern)) { + if (!found.includes(match[2])) found.push(match[2]); + } + return found; +} + +/** + * The entry and every chunk reachable from it by static imports, in + * discovery order. `read(file)` returns a chunk's code (file relative to + * dist, in the entry's directory). + */ +export function initialChunks(entry, read) { + const directory = entry.includes('/') + ? entry.slice(0, entry.lastIndexOf('/') + 1) + : ''; + const seen = [entry]; + for (let index = 0; index < seen.length; index++) { + const code = read(seen[index]); + if (code == null) continue; + for (const name of staticImportsOf(code)) { + const file = directory + name; + if (!seen.includes(file)) seen.push(file); + } + } + return seen; +} + +/** The names of FORBIDDEN_MARKERS found in code. */ +export function forbiddenMarkersIn(code) { + const text = String(code); + return FORBIDDEN_MARKERS.filter(({ pattern }) => pattern.test(text)).map( + ({ name }) => name, + ); +} diff --git a/src/tooling/mainChunk.test.mjs b/src/tooling/mainChunk.test.mjs new file mode 100644 index 000000000..dd1ee2418 --- /dev/null +++ b/src/tooling/mainChunk.test.mjs @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + FORBIDDEN_MARKERS, + entryFromHtml, + entryFromManifest, + forbiddenMarkersIn, + initialChunks, + staticImportsOf, +} from './mainChunk.js'; + +test('the entry is the module script the built index.html loads', () => { + const html = ` + + + + `; + assert.equal(entryFromHtml(html), 'assets/index--RGa-KXE.js'); +}); + +test('an index.html without a module script has no entry', () => { + assert.equal( + entryFromHtml(''), + null, + ); +}); + +test('the entry of a Vite manifest is the chunk marked isEntry for index.html', () => { + const manifest = { + 'src/app/navaraMode.js': { + file: 'assets/navaraMode-B.js', + isDynamicEntry: true, + }, + 'index.html': { + file: 'assets/index-A.js', + isEntry: true, + src: 'index.html', + imports: ['_vendor-C.js'], + }, + '_vendor-C.js': { file: 'assets/vendor-C.js' }, + }; + assert.equal(entryFromManifest(manifest), 'assets/index-A.js'); +}); + +test('static imports are found, dynamic imports are not', () => { + const code = + 'import{a as b}from"./vendor-C.js";import"./side-D.js";' + + 'const x=()=>import("./navaraMode-B.js");' + + "import { y } from './spaced-E.js';"; + assert.deepEqual(staticImportsOf(code), [ + 'vendor-C.js', + 'side-D.js', + 'spaced-E.js', + ]); +}); + +test('the initial chunks are the entry and everything it imports statically', () => { + const files = { + 'assets/index-A.js': 'import"./vendor-C.js";import("./lazy-L.js")', + 'assets/vendor-C.js': 'import{z}from"./deep-D.js"', + 'assets/deep-D.js': 'import"./vendor-C.js"', + 'assets/lazy-L.js': 'THREE.WebGLRenderer', + }; + assert.deepEqual( + initialChunks('assets/index-A.js', (file) => files[file]), + ['assets/index-A.js', 'assets/vendor-C.js', 'assets/deep-D.js'], + ); +}); + +test('three and Navara markers are reported, ordinary engine names are not', () => { + assert.deepEqual( + forbiddenMarkersIn( + 'B4=Object.freeze(["cesium","navara"]);FP={cesium:49,navara:80}', + ), + [], + ); + assert.deepEqual( + forbiddenMarkersIn('console.warn("THREE.WebGLRenderer: Context Lost.")'), + ['three'], + ); + assert.deepEqual(forbiddenMarkersIn('import "@navaramap/three"'), ['navara']); + assert.deepEqual(forbiddenMarkersIn('new URL("navara_wasm_bg.wasm")'), [ + 'navara', + ]); + assert.ok(FORBIDDEN_MARKERS.length >= 2); +});