import path from 'path';
import { existsSync, readFileSync, readdirSync } from 'fs';
import { tryGetNeedleEngineVersion, tryGetPackageVersion } from '../common/version.js';
import { needleLog } from './logging.js';
/**
* @type {string[]}
*/
export const preloadScriptPaths = [];
/**
* Returns true when \`@needle-tools/engine\` is installed as a local package
* (i.e. it has its own nested `node_modules`). Vite's optimiser must skip such
* packages, otherwise it tries to pre-bundle source that was never meant to be
* pre-bundled and fails at dev-server start.
*
* @param {string} [root] – project root; defaults to `process.cwd()`.
* @returns {boolean}
*/
export function isLocalNeedleEngineInstalled(root = process.cwd()) {
const lockPath = path.resolve(root, 'node_modules', '@needle-tools/engine', 'node_modules', '.package-lock.json');
return existsSync(lockPath);
}
/**
* @param {"build" | "serve"} command
* @param {import('../types/needleConfig').needleMeta | null | undefined} config
* @param {import('../types').userSettings} userSettings
* @returns {import('vite').Plugin[]}
*/
export function needleDependencies(command, config, userSettings) {
/**
* @type {import('vite').Plugin}
*/
return [
{
name: 'needle:dependencies',
enforce: 'pre',
/**
* @param {import('vite').UserConfig} config
*/
config: (config, env) => {
handleOptimizeDeps(config);
handleManualChunks(config);
// Ensure all imports of three and needle-engine resolve to the same copy
// This prevents duplicates when packages (e.g. sample scripts) have their own node_modules
if (!config.resolve) config.resolve = {};
if (!config.resolve.dedupe) config.resolve.dedupe = [];
for (const pkg of ['three', '@needle-tools/engine']) {
if (!config.resolve.dedupe.includes(pkg)) {
config.resolve.dedupe.push(pkg);
}
}
// Return server.fs config as a partial so Vite deep-merges it correctly.
// Mutating config.server directly is not reliable for nested objects.
if (isLocalNeedleEngineInstalled()) {
needleLog("needle-dependencies", 'Detected local @needle-tools/engine package → disabling server.fs.strict to allow symlinked worker imports');
return { server: { fs: { strict: false } } };
}
},
},
// Vite 8's optimizer rebases `new URL(specifier, import.meta.url)` paths
// inside pre-bundled dependencies (PR #21434), but only handles truly relative
// paths (./ ../). Bare-specifier paths like `three-mesh-bvh/src/workers/...`
// are treated as relative to the *source file* that contained the `new URL()`
// call, producing a wrong URL such as:
// /node_modules/@needle-tools/engine/.../three-mesh-bvh/src/workers/generateMeshBVH.worker.js
// This middleware rewrites those broken URLs so the dev server finds the file at
// its real location in node_modules.
{
name: 'needle:worker-url-rewrite',
configureServer(server) {
const rewritePackages = ['three-mesh-bvh'];
server.middlewares.use((req, _res, next) => {
if (req.url) {
for (const pkg of rewritePackages) {
const marker = `/${pkg}/`;
if (req.url.includes(marker) && !req.url.startsWith(`/node_modules/${pkg}/`) && !req.url.startsWith('/@fs/')) {
const idx = req.url.indexOf(marker);
const rewritten = '/node_modules' + req.url.slice(idx);
needleLog('needle-dependencies', `Rewriting worker URL → ${rewritten}`);
req.url = rewritten;
break;
}
}
}
next();
});
},
},
]
}
const excludeDependencies = [
]
/**
* Scans the engine source to find all `three/examples/jsm/...` module paths
* that are actually imported. Returns a regex matching those paths so they
* go into the core `three-examples` chunk. Anything else from three/examples
* that the engine doesn't use goes into `three-examples.extras`.
* @returns {RegExp}
*/
function buildThreeExamplesCoreTest() {
/** @type {Set<string>} */
const modulePaths = new Set();
// Find the engine source directory relative to this plugin file
const srcDir = path.resolve(import.meta.dirname, '../../src');
if (!existsSync(srcDir)) {
needleLog('needle-dependencies', `Engine src not found at "${srcDir}" — using fallback regex`);
return /three\/(?:examples\/jsm|addons)\/(?:loaders|libs)\//;
}
scanDir(srcDir);
function scanDir(dir) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (entry.isDirectory()) {
scanDir(path.join(dir, entry.name));
}
else if (entry.name.endsWith('.ts')) {
try {
const content = readFileSync(path.join(dir, entry.name), 'utf8');
// Match: from "three/examples/jsm/..." or "three/addons/..." (skip type-only imports)
const re = /(?<!import\s+type\s[^;]*?)from\s+["']three\/(?:examples\/jsm|addons)\/([^"']+)["']/g;
let m;
while ((m = re.exec(content)) !== null) {
// Extract the directory part, e.g. "loaders/GLTFLoader.js" → "loaders"
const dir = m[1].split('/')[0];
modulePaths.add(dir);
}
}
catch { /* skip unreadable files */ }
}
}
}
if (modulePaths.size === 0) {
return /three\/(?:examples\/jsm|addons)\/(?:loaders|libs)\//;
}
// Build regex: three/(examples/jsm|addons)/(loaders|libs|controls|...)/
const dirs = [...modulePaths].sort();
needleLog('needle-dependencies', `three-examples core dirs: ${dirs.join(', ')}`);
const dirsPattern = dirs.join('|');
const pattern = `three\\/(?:examples\\/jsm|addons)\\/(?:${dirsPattern})\\/`;
return new RegExp(pattern);
}
/**
* @param {import('vite').UserConfig} config
*/
function handleOptimizeDeps(config) {
excludeDependencies.forEach(dep => {
if (config.optimizeDeps?.include?.includes(dep)) {
needleLog("needle-dependencies", `${dep} is included in the optimizeDeps.include array. This may cause issues with the worker import.`, "warn", { dimBody: false });
}
else {
if (!config.optimizeDeps) {
config.optimizeDeps = {};
}
if (!config.optimizeDeps.exclude) {
config.optimizeDeps.exclude = [];
}
// This needs to be excluded from optimization because otherwise the worker import fails
// three-mesh-bvh/src/workers/generateMeshBVH.worker.js?worker
// same for gltf-progressive
needleLog("needle-dependencies", `Adding ${dep} to the optimizeDeps.exclude array to support workers.`);
config.optimizeDeps.exclude.push(dep);
if (!config.server) config.server = {};
if (!config.server.fs) config.server.fs = {};
if (config.server.fs.strict === undefined) {
// we need to disable strictness to allow importing the worker from three-mesh-bvh node_modules in our GenerateMeshBVHWorker.js file
config.server.fs.strict = false;
}
}
});
// is needle engine a local package? exclude it from vite's optimizeDeps
if (isLocalNeedleEngineInstalled()) {
config.optimizeDeps ??= {};
config.optimizeDeps.exclude ??= [];
if (!config.optimizeDeps.include?.includes('@needle-tools/engine') &&
!config.optimizeDeps.exclude.includes('@needle-tools/engine')) {
config.optimizeDeps.exclude.push('@needle-tools/engine');
needleLog("needle-dependencies", 'Detected local @needle-tools/engine package → will exclude it from optimization');
}
}
}
/**
* @param {import('vite').UserConfig} config
*/
/** @type {RegExp} */
let threeExamplesCoreRegex;
function handleManualChunks(config) {
if (!config.build) {
config.build = {};
}
threeExamplesCoreRegex = buildThreeExamplesCoreTest();
// Support both rolldownOptions (Vite 8+) and rollupOptions (Vite 7 and earlier)
const optionsKey = 'rolldownOptions' in (config.build) ? 'rolldownOptions' : 'rollupOptions';
if (!config.build[optionsKey]) {
config.build[optionsKey] = {};
}
if (!config.build[optionsKey].output) {
config.build[optionsKey].output = {};
}
const rollupOutput = config.build[optionsKey].output;
// Detect Vite 8+ (Rolldown-based) which needs codeSplitting.groups instead of manualChunks.
// In Rolldown, manualChunks gets converted to a single codeSplitting group with recursive
// dependency inclusion, causing chunks like gltf-progressive to absorb three.js.
let isVite8 = false;
try {
const vitePkgPath = path.resolve(process.cwd(), 'node_modules/vite/package.json');
if (existsSync(vitePkgPath)) {
isVite8 = parseInt(JSON.parse(readFileSync(vitePkgPath, 'utf8')).version) >= 8;
}
} catch { }
if (isVite8) needleLog("needle-dependencies", `Vite 8+ detected`);
if (Array.isArray(rollupOutput)) {
// append the manualChunks function to the array
if (process.env.DEBUG) needleLog("needle-dependencies", "registering manualChunks");
rollupOutput.push({
manualChunks: needleManualChunks
})
}
else {
// If preserveModules is true we can not modify the chunkFileNames
let allowManualChunks = true;
if ("manualChunks" in rollupOutput || "codeSplitting" in rollupOutput) {
allowManualChunks = false;
// if the user has already defined manualChunks/codeSplitting, we don't want to overwrite it
needleLog("needle-dependencies", "manualChunks/codeSplitting already found in vite config - will not overwrite it");
}
else if (rollupOutput.preserveModules === true) {
allowManualChunks = false;
needleLog("needle-dependencies", "manualChunks can not be registered because preserveModules is true");
}
if (rollupOutput.inlineDynamicImports === true) {
allowManualChunks = false;
needleLog("needle-dependencies", "manualChunks can not be registered because inlineDynamicImports is true");
}
if (allowManualChunks) {
if (process.env.DEBUG) needleLog("needle-dependencies", "registering manualChunks");
if (isVite8) {
// Vite 8+ uses Rolldown which converts manualChunks into a single codeSplitting group
// with recursive dependency inclusion. This causes gltf-progressive to absorb three.js.
// Use codeSplitting.groups directly with priorities so three.js gets its own chunk.
// Priority determines which group claims a module first. With the default
// includeDependenciesRecursively: true, claimed modules pull their deps into
// the same chunk — UNLESS those deps were already claimed by a higher-priority
// group. So three.js core (priority 100) must be higher than three-examples (90)
// to prevent examples from absorbing the core lib via recursive deps.
rollupOutput.codeSplitting = {
groups: [
{ name: 'three', test: /\/three\/(?:build|src)\//, priority: 100 },
// three/examples modules used by the engine go into the core chunk.
// Anything else (user-installed three/examples) goes into extras.
{ name: 'three-examples', test: threeExamplesCoreRegex, priority: 95 },
{ name: 'three-examples.extras', test: /three\/(?:examples|addons)/, priority: 90 },
{ name: 'three-mesh-bvh', test: /three-mesh-bvh/, priority: 60 },
// postprocessing must have higher priority than n8ao so n8ao's
// recursive deps (postprocessing library) don't get merged into the n8ao chunk
{ name: 'postprocessing', test: /node_modules\/postprocessing\//, priority: 55 },
{ name: 'postprocessing.ao', test: /node_modules\/n8ao\//, priority: 50 },
{ name: 'rapier3d', test: /@dimforge\/rapier3d/, priority: 50 },
{ name: 'materialx', test: /@needle-tools\/materialx/, priority: 50 },
{ name: 'three-mesh-ui', test: /node_modules\/three-mesh-ui/, priority: 50 },
{ name: 'three-quarks', test: /node_modules\/three\.quarks/, priority: 50 },
{ name: 'peerjs', test: /node_modules\/peerjs/, priority: 50 },
{ name: 'gltf-progressive', test: /\/gltf-progressive\//, priority: 40 },
// Independently skippable feature groups — only features large enough
// to justify a separate chunk (>50 KB). Smaller features merge into
// the main needle-engine chunk to reduce HTTP requests.
{ name: 'needle-engine-ui', test: /engine-components\/ui\//, priority: 35 },
{ name: 'needle-engine-webxr', test: /engine-components\/(webxr|avatar)\//, priority: 35 },
{ name: 'needle-engine-particles', test: /engine-components\/particlesystem\//, priority: 35 },
// Postprocessing effects — separate so n8ao isn't eagerly loaded
{ name: 'needle-engine.extras', test: /engine-components\/postprocessing\//, priority: 35 },
// Small 3rd-party deps used by the engine — merge into needle-engine
// to avoid tiny unnamed chunks.
{ name: 'needle-engine', test: /node_modules\/websocket-ts\//, priority: 35 },
{ name: 'needle-engine', test: /three-animation-pointer/, priority: 35 },
// register_types (codegen) must stay separate from lazy component code
// to preserve dynamic import() boundaries created by the treeshake plugin.
{ name: 'register_types', test: /codegen\/register_types/, priority: 32 },
// Everything else (export, timeline, splines, web,
// physics, debug, utils, experimental) merges into the main
// needle-engine chunk.
{ name: 'needle-engine', test: /@needle-tools\/engine/, priority: 30 },
]
};
} else {
rollupOutput.manualChunks = needleManualChunks;
}
}
if (rollupOutput.chunkFileNames) {
needleLog("needle-dependencies", "chunkFileNames already defined");
}
else {
rollupOutput.chunkFileNames = handleChunkFileNames;
}
if (rollupOutput.assetFileNames) {
needleLog("needle-dependencies", "assetFileNames already defined");
}
else {
rollupOutput.assetFileNames = assetFileNames;
}
}
// TODO: this was a test if it allows us to remove the sync import of postprocessing due to n8ao's import
// config.build.rollupOptions.external = (source, importer, isResolved) => {
// if (importer?.includes("node_modules/n8ao/") || importer?.includes("node_modules/postprocessing/")) {
// console.log("EXTERNAL", importer);
// return true;
// }
// }
/** https://rollupjs.org/configuration-options/#output-assetfilenames
* @param {import("vite").Rollup.PreRenderedAsset} chunkInfo */
function assetFileNames(chunkInfo) {
// "assets/..." is the default
// this happens if e.g. a glTF or GLB file is link preloaded
if (chunkInfo.name?.toLowerCase().includes(".glb") || chunkInfo.name?.toLowerCase().includes(".gltf")) {
return `assets/[name][extname]`;
}
return `assets/[name].[hash][extname]`;
}
let needleEngineVersionedNameUsed = false;
/** @param {import("vite").Rollup.PreRenderedChunk} chunk */
function handleChunkFileNames(chunk) {
if (chunk.name === 'needle-engine') {
try {
const version = tryGetNeedleEngineVersion();
if (version && !needleEngineVersionedNameUsed) {
// Rolldown codeSplitting can create multiple chunks with the same name.
// Only assign the versioned name once — to the chunk with the most exports
// (the primary chunk). Additional same-named chunks get hash-based names.
const exportCount = chunk.exports?.length ?? 0;
if (exportCount > 10) {
needleEngineVersionedNameUsed = true;
const name = `assets/needle-engine@${version}.js`;
preloadScriptPaths.push(`./${name}`);
return name;
}
}
}
catch (e) {
needleLog("needle-dependencies", "Error reading version " + e, "warn");
}
}
else if (chunk.name === 'three' || chunk.name === 'three-examples' || chunk.name === 'three-examples.extras') {
// 'three' may be aliased to @needle-tools/three — check both package locations
const version = tryGetPackageVersion("three") ?? tryGetPackageVersion("@needle-tools/three");
const name = version
? `assets/${chunk.name}@${version}.js`
: `assets/${chunk.name}.js`;
// Preload three core and three-examples (loaders/libs) — both are always
// needed. three-examples.extras is NOT preloaded (feature-specific).
if (chunk.name !== 'three-examples.extras') {
preloadScriptPaths.push(`./${name}`);
}
return name;
}
// else if(chunk.name === 'index') {
// console.log(chunk);
// debugger
// // this is the main chunk
// // we don't want to add a hash here to be able to easily import the main script
// return `index.js`;
// }
// Rename stray chunks that Rolldown extracted from dynamic import boundaries.
// These get arbitrary names (e.g. "ScrollFollow", "src") but belong to engine deps.
const ids = chunk.moduleIds ?? Object.keys(chunk.modules ?? {});
if (ids.some(id => /engine-components/.test(id)) && !chunk.name.startsWith('needle-engine')) {
return `assets/needle-engine.extras.[hash].js`;
}
// Rename generic "src" or "build" chunks — these are tiny re-export shims
// Rolldown creates at dynamic import() boundaries. Give them a clearer name.
if (chunk.name === 'src' || chunk.name === 'build') {
return `assets/needle-engine.dep.[hash].js`;
}
return `assets/[name].[hash].js`;
}
/**
* @param {string} id
* @param {import('vite').Rollup.ManualChunkMeta | null} meta
*/
function needleManualChunks(id, meta) {
// console.log(id);
if (id.includes("three/examples") || id.includes("three/addons")) {
if (threeExamplesCoreRegex?.test(id)) {
return "three-examples";
}
return "three-examples.extras";
}
if (id.includes('/three/')) {
return "three";
}
if (id.includes("node_modules/n8ao/")) {
detectSyncImports(id, meta);
return "postprocessing.ao";
}
if (id.includes("node_modules/postprocessing/")) {
// postprocessing bundle is preloaded https://github.com/vitejs/vite/pull/9938
detectSyncImports(id, meta);
return "postprocessing";
}
if (id.includes("@dimforge/rapier3d")) {
detectSyncImports(id, meta);
return "rapier3d";
}
if (id.includes("@needle-tools/materialx")) {
detectSyncImports(id, meta);
return "materialx";
}
if (id.includes("node_modules/three-mesh-ui")) {
return "three-mesh-ui";
}
if (id.includes("node_modules/three.quarks")) {
return "three-quarks";
}
// we want to bundle gltf-progressive separately because it also initiates Draco and KTX worker preloading
if (id.includes("/gltf-progressive/")) {
return "gltf-progressive";
}
if (id.includes("node_modules/needle-engine")
// DEV
// || id.includes("/package~/src/")
) {
return "needle-engine";
}
}
}
/**
* @param {string} id
* @param {import('vite').Rollup.ManualChunkMeta | null} meta
*/
function detectSyncImports(id, meta) {
if (meta) {
if (meta.getModuleInfo) {
const info = meta.getModuleInfo(id);
if (info && info.importers.length > 0) {
const isNeedleDev = id.includes("/package~/src/");
if (isNeedleDev) {
console.warn(`WARN: SYNC IMPORTER DETECTED of ${id}`);
console.warn(info.importers)
}
}
}
}
}