From 306e3e946745542c580b108d6a1ceda229a0e634 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 15 Oct 2026 20:56:06 +0400 Subject: [PATCH] build: reach Navara only through import() and ship its worker wasm The import-direction check now tells dynamic imports apart (analyzeModule(source, { dynamic: true })) and rejects any static import or re-export of src/globe/navara/** from outside that directory, so the Navara engine can only be a lazily loaded chunk of the app. Navara's prebuilt workers load their wasm by file name next to themselves, which the bundler cannot see; a build-only Rollup plugin copies the missing ones (draco decoder, font worker) into assets/ when the bundle includes Navara, and adds nothing to a build without it. --- build/navaraAssets.js | 45 +++++++++++++++++++++ build/vite.js | 8 +++- docs/CODE-BOUNDARIES.md | 4 +- scripts/check-import-directions.mjs | 16 +++++++- scripts/module-analysis.mjs | 16 ++++++-- scripts/package-boundaries.json | 3 +- src/tooling/importDirections.test.mjs | 38 ++++++++++++++++++ src/tooling/navaraAssets.test.mjs | 56 +++++++++++++++++++++++++++ 8 files changed, 179 insertions(+), 7 deletions(-) create mode 100644 build/navaraAssets.js create mode 100644 src/tooling/navaraAssets.test.mjs diff --git a/build/navaraAssets.js b/build/navaraAssets.js new file mode 100644 index 000000000..9e81cd2b0 --- /dev/null +++ b/build/navaraAssets.js @@ -0,0 +1,45 @@ +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** Where @navaramap/three keeps its prebuilt worker assets, or null. */ +function defaultAssetsDir() { + try { + const entry = fileURLToPath(import.meta.resolve('@navaramap/three')); + return path.join(path.dirname(entry), 'assets'); + } catch { + return null; + } +} + +/** + * Navara's prebuilt workers (@navaramap/three/dist/assets) load their wasm by + * file name next to themselves (`new URL('x-hash.wasm', self.location)`), a + * reference the bundler cannot see. When a build includes Navara (the + * ?engine=navara chunk), copy every such wasm the bundle lacks into + * `assets/` under its own name. A build without Navara gets none. + * @param {{ assetsDir?: string | null }} [options] + */ +export function navaraWorkerAssets({ assetsDir } = {}) { + return { + name: 'navara-worker-assets', + generateBundle(_options, bundle) { + const usesNavara = Object.values(bundle).some( + (file) => file.type === 'chunk' && file.code.includes('navara_wasm'), + ); + if (!usesNavara) return; + const dir = assetsDir === undefined ? defaultAssetsDir() : assetsDir; + if (!dir || !existsSync(dir)) return; + for (const name of readdirSync(dir).sort()) { + if (!name.endsWith('.wasm')) continue; + const fileName = `assets/${name}`; + if (bundle[fileName]) continue; + this.emitFile({ + type: 'asset', + fileName, + source: readFileSync(path.join(dir, name)), + }); + } + }, + }; +} diff --git a/build/vite.js b/build/vite.js index c4899f8a7..07536abf4 100644 --- a/build/vite.js +++ b/build/vite.js @@ -3,6 +3,7 @@ import cesium from 'vite-plugin-cesium'; import { DEFAULT_ALLOWED_HOSTS, hostCheckPlugin } from './allowedHosts.js'; import { embedFramingPlugin } from './embed-framing.js'; import { panelBuildPlugin } from './panel.js'; +import { navaraWorkerAssets } from './navaraAssets.js'; /** * Content-Security-Policy for every document the dev/preview server serves. @@ -125,6 +126,11 @@ export function createBrowserViteConfig({ mapillaryToken ?? '', ), }, - build: { chunkSizeWarningLimit: 1500 }, + build: { + chunkSizeWarningLimit: 1500, + // Build only: the wasm Navara's workers load by name (the Navara + // engine is a separate chunk loaded on ?engine=navara). + rollupOptions: { plugins: [navaraWorkerAssets()] }, + }, }; } diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index 5fd2e172f..fdc9fd7a4 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -70,7 +70,9 @@ architectural check, not a JavaScript sandbox. The globe contract helpers, nor import an engine implementation; the `src/globe/cesium/**` graph reaches no Navara/three package or `src/globe/navara/**` module, and the `src/globe/navara/**` graph no Cesium package or `src/globe/cesium/**` module, -through helpers too. Common voice controls cannot depend on a +through helpers too. Outside its own directory, `src/globe/navara/**` is +reached only through a literal `import()` (the app's `?engine=navara` mode), so +Navara stays out of the Cesium-mode bundle. Common voice controls cannot depend on a Realtime protocol implementation. Negative fixtures cover indirect helpers, self-package imports, symlinks and unreachable files. diff --git a/scripts/check-import-directions.mjs b/scripts/check-import-directions.mjs index 8f19215a7..888d81f48 100644 --- a/scripts/check-import-directions.mjs +++ b/scripts/check-import-directions.mjs @@ -97,7 +97,9 @@ export function checkImportDirections(root) { } let analysis; try { - analysis = analyzeModule(readFileSync(resolved, 'utf8')); + analysis = analyzeModule(readFileSync(resolved, 'utf8'), { + dynamic: true, + }); } catch (error) { report(file, error.message); return null; @@ -141,6 +143,18 @@ export function checkImportDirections(root) { continue; } record.edges.push(to); + // Navara stays out of the Cesium-mode bundle: outside its own directory + // it is reached only through import() (src/app/scene.js), which the + // bundler splits into a chunk loaded on ?engine=navara. + if ( + to.startsWith('src/globe/navara/') && + !file.startsWith('src/globe/navara/') && + !analysis.dynamicImports.includes(specifier) + ) + report( + file, + `The Navara engine loads only through a dynamic import(): ${to}`, + ); if (file.startsWith('src/') && to.startsWith('server/')) report(file, `Browser imports server: ${to}`); if ( diff --git a/scripts/module-analysis.mjs b/scripts/module-analysis.mjs index 0976159f7..9871e5f5b 100644 --- a/scripts/module-analysis.mjs +++ b/scripts/module-analysis.mjs @@ -15,9 +15,13 @@ const browserGlobals = new Set([ 'getComputedStyle', ]); -/** Inspect literal module dependencies and browser-platform references without executing code. */ -export function analyzeModule(source) { +/** + * Inspect literal module dependencies and browser-platform references without + * executing code. With `dynamic`, also list the import() specifiers apart. + */ +export function analyzeModule(source, { dynamic = false } = {}) { const imports = []; + const dynamicImports = []; const browser = new Set(); function walk(node, parent, field) { if (!node || typeof node !== 'object') return; @@ -35,6 +39,8 @@ export function analyzeModule(source) { 'Computed module imports are not allowed in runtime code', ); imports.push(node.source.value); + if (node.type === 'ImportExpression') + dynamicImports.push(node.source.value); } if (node.type === 'CallExpression' && node.callee?.name === 'require') throw new Error('Runtime modules must use ES imports'); @@ -71,7 +77,11 @@ export function analyzeModule(source) { } } walk(parsers.babel.parse(source)); - return { imports, browser: [...browser] }; + return { + imports, + browser: [...browser], + ...(dynamic && { dynamicImports }), + }; } /** Return static imports, literal dynamic imports and re-exports. */ diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index b64029896..d65fbc4da 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -33,7 +33,8 @@ "build/application-html.js", "build/allowedHosts.js", "build/embed-framing.js", - "build/panel.js" + "build/panel.js", + "build/navaraAssets.js" ], "external": ["vite-plugin-cesium"] }, diff --git a/src/tooling/importDirections.test.mjs b/src/tooling/importDirections.test.mjs index 049aa9adf..1b5fd1695 100644 --- a/src/tooling/importDirections.test.mjs +++ b/src/tooling/importDirections.test.mjs @@ -241,6 +241,44 @@ for (const [name, files, pattern] of [ }); } +test('module analysis can tell dynamic imports from static ones', () => { + assert.deepEqual( + analyzeModule( + "import a from 'one'; export * from 'two'; import('three'); const x = () => import('four');", + { dynamic: true }, + ), + { + imports: ['one', 'two', 'three', 'four'], + browser: [], + dynamicImports: ['three', 'four'], + }, + ); +}); + +test('the app reaches the Navara engine only through a dynamic import', (t) => { + const { root, write } = fixture(t); + write('src/globe/navara/index.js', "import './engine.js';"); + write('src/globe/navara/engine.js', ''); + write( + 'src/app/scene.js', + "export const load = () => import('../globe/navara/index.js');", + ); + assert.doesNotThrow(() => checkImportDirections(root)); + write('src/app/scene.js', "import '../globe/navara/index.js';"); + assert.throws( + () => checkImportDirections(root), + /src\/app\/scene\.js: The Navara engine loads only through a dynamic import\(\): src\/globe\/navara\/index\.js/, + ); + write( + 'src/app/scene.js', + "export { createNavaraGlobe } from '../globe/navara/index.js';", + ); + assert.throws( + () => checkImportDirections(root), + /only through a dynamic import/, + ); +}); + test('geo modules may import each other, and its tests may import cesium', (t) => { const { root, write } = fixture(t); write('src/geo/math.js', 'export const half = (value) => value / 2;'); diff --git a/src/tooling/navaraAssets.test.mjs b/src/tooling/navaraAssets.test.mjs new file mode 100644 index 000000000..977042bf3 --- /dev/null +++ b/src/tooling/navaraAssets.test.mjs @@ -0,0 +1,56 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { navaraWorkerAssets } from '../../build/navaraAssets.js'; +import { createBrowserViteConfig } from '../../build/vite.js'; + +test('the build copies the wasm files Navara workers load by name next to them', (t) => { + const dir = mkdtempSync(path.join(os.tmpdir(), 'gev-navara-assets-')); + t.after(() => rmSync(dir, { recursive: true, force: true })); + writeFileSync(path.join(dir, 'draco_decoder-AAAA.wasm'), 'draco'); + writeFileSync(path.join(dir, 'navara_wasm_worker_bg-BBBB.wasm'), 'worker'); + writeFileSync(path.join(dir, 'fontWorker-CCCC.js'), 'not wasm'); + const plugin = navaraWorkerAssets({ assetsDir: dir }); + const emitted = []; + const bundle = { + // Rollup already emitted this one under the same name. + 'assets/navara_wasm_worker_bg-BBBB.wasm': { type: 'asset' }, + // A Navara worker chunk shows the build includes Navara. + 'assets/index-DDDD.js': { type: 'chunk', code: 'navara_wasm_worker_bg' }, + }; + plugin.generateBundle.call( + { emitFile: (file) => emitted.push(file) }, + {}, + bundle, + ); + assert.deepEqual( + emitted.map(({ fileName, type }) => [fileName, type]), + [['assets/draco_decoder-AAAA.wasm', 'asset']], + ); + assert.equal(String(emitted[0].source), 'draco'); +}); + +test('a build without Navara gets no Navara wasm', (t) => { + const dir = mkdtempSync(path.join(os.tmpdir(), 'gev-navara-assets-')); + t.after(() => rmSync(dir, { recursive: true, force: true })); + writeFileSync(path.join(dir, 'draco_decoder-AAAA.wasm'), 'draco'); + const plugin = navaraWorkerAssets({ assetsDir: dir }); + const emitted = []; + plugin.generateBundle.call( + { emitFile: (file) => emitted.push(file) }, + {}, + { 'assets/index-EEEE.js': { type: 'chunk', code: 'cesium only' } }, + ); + assert.deepEqual(emitted, []); +}); + +test('the browser build runs the Navara asset copy as a Rollup plugin', () => { + const { build } = createBrowserViteConfig({}); + assert.deepEqual( + build.rollupOptions.plugins.map(({ name }) => name), + ['navara-worker-assets'], + ); + assert.equal(build.chunkSizeWarningLimit, 1500); +});