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.
This commit is contained in:
German Khokhlov committed 2026-10-15 20:56:06 +04:00
1 parent 72fec292d4
commit 306e3e9467
8 files changed
+179 -7

No files matched your search

+45
View File
@@ -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)),
});
}
},
};
}
+7 -1
View File
@@ -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()] },
},
};
}
+3 -1
View File
@@ -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.
+15 -1
View File
@@ -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 (
+13 -3
View File
@@ -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. */
+2 -1
View File
@@ -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"]
},
+38
View File
@@ -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;');
+56
View File
@@ -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);
});