From d483e5897e1be8636ba26f6d6ab5dacdedb26a5b Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 16 Oct 2026 19:30:23 +0400 Subject: [PATCH] build: the initial-chunk guard covers Navara's whole dependency closure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of Task 0: - build/navaraDependencies.js reads the dependency closure of @navaramap/three and @navaramap/three-default-plugin from node_modules (dependencies and peers) and drops what the app's other bundled dependencies reach too (Cesium is served as a script, so draco3d, which only Cesium shares, stays engine-only), plus three and postprocessing: 88 packages today (the @navaramap/* packages, @takram/*, @react-three/ fiber, @sparkjsdev/spark, astronomy-engine, d3*, draco3d, n8ao, three-stdlib, …). The browser build's initialChunkGuard fails on a module from any of them in an initial chunk. Tested on a fixture tree. - check-main-chunk's budget measures the entry the page loads (the same chunk the composition check reads), falling back to phase 4's largest index-*.js only for a dist without a page. Build clean: no Navara-only module in the initial chunks; main chunk 2,869,565 bytes (gzip 889,041). --- build/initialChunkGuard.js | 17 ++++-- build/navaraDependencies.js | 73 +++++++++++++++++++++++ build/vite.js | 11 +++- scripts/check-main-chunk.mjs | 26 +++++++- scripts/format-scope.json | 5 +- src/tooling/initialChunkGuard.test.mjs | 2 +- src/tooling/mainChunkComposition.test.mjs | 17 ++++++ src/tooling/navaraDependencies.test.mjs | 69 +++++++++++++++++++++ 8 files changed, 209 insertions(+), 11 deletions(-) create mode 100644 build/navaraDependencies.js create mode 100644 src/tooling/navaraDependencies.test.mjs diff --git a/build/initialChunkGuard.js b/build/initialChunkGuard.js index 263362ba8..c189a70c5 100644 --- a/build/initialChunkGuard.js +++ b/build/initialChunkGuard.js @@ -5,7 +5,11 @@ // (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. */ +/** + * Module ids that only the Navara engine may load, when no package list is + * given (the browser build passes the full dependency closure, + * navaraDependencies.js). + */ export const ENGINE_ONLY_MODULES = /[\\/]node_modules[\\/](?:three|@navaramap[\\/][^\\/]+|postprocessing)[\\/]/; @@ -32,21 +36,24 @@ export function initialChunkViolations(bundle, pattern = ENGINE_ONLY_MODULES) { } /** The Rollup plugin: fails generateBundle when an initial chunk holds three or Navara. */ -export function initialChunkGuard({ log = console.log } = {}) { +export function initialChunkGuard({ + log = console.log, + pattern = ENGINE_ONLY_MODULES, +} = {}) { return { name: 'gev-initial-chunk-guard', generateBundle(_options, bundle) { - const violations = initialChunkViolations(bundle); + const violations = initialChunkViolations(bundle, pattern); if (violations.length) { this.error( - 'three/Navara in the Cesium-mode initial chunks:\n' + + 'Navara-only modules 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'); + log('[initial-chunks] no Navara-only modules in the initial chunks'); }, }; } diff --git a/build/navaraDependencies.js b/build/navaraDependencies.js new file mode 100644 index 000000000..d143b0e96 --- /dev/null +++ b/build/navaraDependencies.js @@ -0,0 +1,73 @@ +// The packages only the Navara engine brings: the dependency closure of +// @navaramap/three and @navaramap/three-default-plugin (dependencies and +// peer dependencies, read from node_modules), without the packages the +// app's other bundled dependencies reach too (Cesium is external), plus +// three and postprocessing, which +// the Cesium-mode page never loads statically either. The initial-chunk +// guard (initialChunkGuard.js) fails the build when one of them is in an +// initial chunk. + +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; + +export const NAVARA_ROOTS = Object.freeze([ + '@navaramap/three', + '@navaramap/three-default-plugin', +]); +const ALWAYS_ENGINE_ONLY = Object.freeze(['three', 'postprocessing']); +// Not bundled: vite-plugin-cesium serves Cesium as a script, so what only +// Cesium reaches (draco3d, …) is not in the app's bundled graph. +const EXTERNAL = Object.freeze(['cesium']); + +function readManifest(root, name) { + const file = path.join(root, 'node_modules', name, 'package.json'); + return existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : null; +} + +/** Every package reachable from `names` through installed manifests. */ +export function dependencyClosure(root, names) { + const seen = new Set(); + const queue = [...names]; + while (queue.length) { + const name = queue.pop(); + if (seen.has(name)) continue; + const manifest = readManifest(root, name); + if (!manifest) continue; + seen.add(name); + for (const next of Object.keys({ + ...manifest.dependencies, + ...manifest.peerDependencies, + })) + queue.push(next); + } + return seen; +} + +/** The engine-only package names, sorted. */ +export function navaraOnlyPackages(root) { + const app = JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8')); + const navara = dependencyClosure(root, NAVARA_ROOTS); + const appRoots = Object.keys(app.dependencies ?? {}).filter( + (name) => + !navara.has(name) && + !ALWAYS_ENGINE_ONLY.includes(name) && + !EXTERNAL.includes(name), + ); + const shared = dependencyClosure(root, appRoots); + return [ + ...new Set([ + ...[...navara].filter((name) => !shared.has(name)), + ...ALWAYS_ENGINE_ONLY, + ]), + ].sort(); +} + +/** A module-id pattern matching node_modules// for the names. */ +export function packagePattern(names) { + const escaped = names.map((name) => + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace('/', '[\\\\/]'), + ); + return new RegExp( + `[\\\\/]node_modules[\\\\/](?:${escaped.join('|')})[\\\\/]`, + ); +} diff --git a/build/vite.js b/build/vite.js index 50bc960e7..911c68aff 100644 --- a/build/vite.js +++ b/build/vite.js @@ -1,3 +1,4 @@ +import { fileURLToPath } from 'node:url'; import { applicationHtmlPlugin } from './application-html.js'; import cesium from 'vite-plugin-cesium'; import { DEFAULT_ALLOWED_HOSTS, hostCheckPlugin } from './allowedHosts.js'; @@ -5,6 +6,7 @@ import { embedFramingPlugin } from './embed-framing.js'; import { panelBuildPlugin } from './panel.js'; import { navaraChunkFileNames, navaraWorkerAssets } from './navaraAssets.js'; import { initialChunkGuard } from './initialChunkGuard.js'; +import { navaraOnlyPackages, packagePattern } from './navaraDependencies.js'; /** * Content-Security-Policy for every document the dev/preview server serves. @@ -134,7 +136,14 @@ export function createBrowserViteConfig({ // The lazy chunk with the Navara engine is named navara-engine. rollupOptions: { // initialChunkGuard: no three/Navara module in the initial chunks. - plugins: [navaraWorkerAssets(), initialChunkGuard()], + plugins: [ + navaraWorkerAssets(), + initialChunkGuard({ + pattern: packagePattern( + navaraOnlyPackages(fileURLToPath(new URL('..', import.meta.url))), + ), + }), + ], output: { chunkFileNames: navaraChunkFileNames }, }, }, diff --git a/scripts/check-main-chunk.mjs b/scripts/check-main-chunk.mjs index 64eca396a..87ae12dd1 100644 --- a/scripts/check-main-chunk.mjs +++ b/scripts/check-main-chunk.mjs @@ -42,8 +42,28 @@ export function checkMainChunk( }; } -/** gzip-9 size of the largest dist/assets/index-*.js, with its name. */ -export function measureMainChunk(distDir) { +/** + * gzip-9 size of the main chunk, with its name: the entry the built page + * loads (`entry`, or found from dist/index.html or the Vite manifest), else + * (phase 4's rule, for a dist without a page) the largest + * dist/assets/index-*.js. + */ +export function measureMainChunk(distDir, { entry = null } = {}) { + let file = entry; + if (!file) { + try { + file = findEntry(distDir).entry; + } catch { + file = null; + } + } + if (file) { + const bytes = fs.readFileSync(path.join(distDir, file)); + return { + name: path.basename(file), + gzipBytes: gzipSync(bytes, { level: 9 }).length, + }; + } const assets = path.join(distDir, 'assets'); const candidates = fs .readdirSync(assets) @@ -113,7 +133,7 @@ function main() { const baselinePath = path.join(ROOT, 'scripts/main-chunk-baseline.json'); let budget = null; if (fs.existsSync(baselinePath)) { - const measured = measureMainChunk(dist); + const measured = measureMainChunk(dist, { entry: composition.entry }); budget = { ...measured, ...checkMainChunk( diff --git a/scripts/format-scope.json b/scripts/format-scope.json index b2e62e82a..8012bc4b3 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1082,5 +1082,8 @@ "src/globe/navara/picking.test.mjs", "src/data/selectionDispatcher.test.mjs", "src/globe/navara/cascadedLightCopy.test.mjs", - "src/tooling/w1Feeds.test.mjs" + "src/tooling/w1Feeds.test.mjs", + "src/tooling/navaraDependencies.test.mjs", + "build/navaraDependencies.js", + "build/initialChunkGuard.js" ] diff --git a/src/tooling/initialChunkGuard.test.mjs b/src/tooling/initialChunkGuard.test.mjs index 329ec4783..ced398531 100644 --- a/src/tooling/initialChunkGuard.test.mjs +++ b/src/tooling/initialChunkGuard.test.mjs @@ -74,5 +74,5 @@ test('the plugin fails the build on a violation and passes a clean 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); + assert.match(errors[0], /Navara-only.*index-A\.js.*three/s); }); diff --git a/src/tooling/mainChunkComposition.test.mjs b/src/tooling/mainChunkComposition.test.mjs index 2db02cf05..edd0b2beb 100644 --- a/src/tooling/mainChunkComposition.test.mjs +++ b/src/tooling/mainChunkComposition.test.mjs @@ -102,3 +102,20 @@ test('the script exports the gzip budget check (phase 4 shape) next to the compo assert.equal(measured.name, 'index-main.js'); assert.ok(measured.gzipBytes > 0 && measured.gzipBytes < 5000); }); + +test('the budget measures the entry the page loads, not the largest index chunk', async () => { + const { mkdirSync, mkdtempSync, writeFileSync } = await import('node:fs'); + const { tmpdir } = await import('node:os'); + const path = await import('node:path'); + const { measureMainChunk } = + await import('../../scripts/check-main-chunk.mjs'); + const dist = mkdtempSync(path.join(tmpdir(), 'gev-main-entry-')); + mkdirSync(path.join(dist, 'assets')); + writeFileSync(path.join(dist, 'assets/index-entry.js'), 'x'.repeat(100)); + writeFileSync(path.join(dist, 'assets/index-lazy.js'), 'y'.repeat(50000)); + writeFileSync( + path.join(dist, 'index.html'), + '', + ); + assert.equal(measureMainChunk(dist).name, 'index-entry.js'); +}); diff --git a/src/tooling/navaraDependencies.test.mjs b/src/tooling/navaraDependencies.test.mjs new file mode 100644 index 000000000..0fda84079 --- /dev/null +++ b/src/tooling/navaraDependencies.test.mjs @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { + navaraOnlyPackages, + packagePattern, +} from '../../build/navaraDependencies.js'; + +function tree(packages, app) { + const root = mkdtempSync(path.join(tmpdir(), 'gev-navara-deps-')); + writeFileSync(path.join(root, 'package.json'), JSON.stringify(app)); + for (const [name, dependencies] of Object.entries(packages)) { + const dir = path.join(root, 'node_modules', name); + mkdirSync(dir, { recursive: true }); + writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ name, dependencies }), + ); + } + return root; +} + +test('engine-only packages: the Navara closure minus what the bundled app reaches', () => { + const root = tree( + { + '@navaramap/three': { 'd3-geo': '1', shared: '1', draco3d: '1' }, + '@navaramap/three-default-plugin': { 'astronomy-engine': '1' }, + 'd3-geo': { 'd3-array': '1' }, + 'd3-array': {}, + 'astronomy-engine': {}, + shared: {}, + draco3d: {}, + 'mapillary-js': { shared: '1', three: '1' }, + cesium: { draco3d: '1' }, + three: {}, + }, + { + dependencies: { + '@navaramap/three': '1', + '@navaramap/three-default-plugin': '1', + 'mapillary-js': '1', + cesium: '1', + three: '1', + }, + }, + ); + assert.deepEqual(navaraOnlyPackages(root), [ + '@navaramap/three', + '@navaramap/three-default-plugin', + 'astronomy-engine', + 'd3-array', + 'd3-geo', + 'draco3d', // only Cesium (external, not bundled) reaches it too + 'postprocessing', + 'three', + ]); +}); + +test('the pattern matches node_modules// and nothing longer', () => { + const pattern = packagePattern(['three', '@navaramap/three', 'd3-geo']); + assert.ok(pattern.test('/r/node_modules/three/build/three.module.js')); + assert.ok(pattern.test('C:\\r\\node_modules\\@navaramap\\three\\dist\\i.js')); + assert.ok(pattern.test('/r/node_modules/d3-geo/src/index.js')); + assert.ok(!pattern.test('/r/node_modules/three-geojson/index.js')); + assert.ok(!pattern.test('/r/node_modules/d3-geo-projection/index.js')); + assert.ok(!pattern.test('/r/src/layers/threeD.js')); +});