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(/
+
+
+ `;
+ 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);
+});