@needle-tools/engine
Version:
Needle Engine is a web-based runtime for 3D apps. It runs on your machine for development with great integrations into editors like Unity or Blender - and can be deployed onto any device! It is flexible, extensible and networking and XR are built-in.
218 lines (197 loc) • 10.8 kB
JavaScript
import { createWriteStream, existsSync, mkdirSync } from 'fs';
import path from 'path';
import { needleLog } from './logging.js';
const projectDir = process.cwd() + "/";
/**
* @typedef {"auto-resolve" | ((res: string, packageName: string, index: number, importPath: string) => string | null | void)} PackageResolveValue
*/
/**
* @type {Record<string, PackageResolveValue>}
*/
const packages_to_resolve = {
// We are currently overriding "three" resolution to ensure that all dependencies resolve to the same three.js version.
// This is hacky, but the alternative is potentially having conflicting three.js versions since some packages are
// stubborn with their peer dependencies or just slow (slower as we) with updating.
// NOT adding this allows node.js to correctly resolve `exports` specified in three.js package.json;
// since we're overriding resolution here we need to manually resolve the subset of exports that we use.
'three/addons': (res, packageName, index, importPath) => {
// Replace "three/addons" prefix with "three/examples/jsm", preserving any subpath
const subpath = importPath.substring("three/addons".length);
return "three/examples/jsm" + subpath;
},
'three/nodes': (res, packageName, index, importPath) => {
return "three/examples/jsm/nodes/Nodes.js";
},
'three': "auto-resolve",
// Handle all previous imports where users did import using @needle-engine/src
'@needle-tools/engine/src': (res, packageName, index, importPath) => {
// resolve old engine/src imports UNLESS it's the asap plugin (the asap plugin currently only exists in the src folder)
if (!importPath.startsWith("@needle-tools/engine/src/asap")) {
return res + "/../lib";
}
},
/*
Removed the previously present @needle-tools/engine entry
because this is automatically done by vite according to whatever we define in our package.json exports
This did previously prevent us from declaring proper exports in package.json
*/
'@needle-tools/engine': (res, packageName, index, importPath) => {
// Check if the import is something like @needle-tools/engine/engine/engine_utils
// in which case we want to resolve into the lib directory
if (importPath.startsWith("@needle-tools/engine/engine")) {
return res + "/lib";
}
// Return null for bare "@needle-tools/engine" imports so the resolveId
// hook skips resolution entirely and lets Vite's optimizer redirect to
// the pre-bundled version. Without this, the alias plugin resolves to
// the raw source path, bypassing pre-bundled CJS interop for transitive
// deps like rtcpeerconnection-shim.
return null;
},
// Auto resolve to 'node_modules/<name>' or '@needle-tools/engine/node_modules/<name>'
'peerjs': "auto-resolve",
'websocket-ts': "auto-resolve",
'md5': "auto-resolve",
'three-mesh-bvh': "auto-resolve",
'postprocessing': "auto-resolve",
'@dimforge/rapier3d-compat': "auto-resolve",
}
/**
* @param {"build" | "serve"} command
* @param {import('../types/needleConfig').needleMeta | null | undefined} config
* @param {import('../types').userSettings} userSettings
* @returns {import('vite').Plugin | import('vite').Plugin[] | undefined}
*/
export function needleViteAlias(command, config, userSettings) {
if (config?.noAlias === true || userSettings?.noAlias === true)
return;
const debug = userSettings.debugAlias;
let outputDebugFile = null;
function log(...msg) {
needleLog("needle-alias", msg.join(" "));
if (debug) logToFile(...msg);
}
function logToFile(...msg) {
outputDebugFile?.write(msg.join(" ") + "\n");
}
if (debug) {
const outputFilePath = path.resolve(projectDir, 'node_modules/.needle/needle.alias.log');
const outputDirectory = path.dirname(outputFilePath);
if (!existsSync(outputDirectory)) mkdirSync(outputDirectory, { recursive: true });
outputDebugFile = createWriteStream(outputFilePath, { flags: "a" });
const timestamp = new Date().toISOString();
outputDebugFile.write("\n\n\n--------------------------\n");
outputDebugFile.write(`[needle-alias] Logging to: ${outputFilePath} (${timestamp})\n`);
log("Logging to:", outputFilePath);
}
// Pre-compute resolved paths for each package
/** @type {Array<{name: string, fullpath: string, pathExists: boolean, isNestedInEngine: boolean, callback: ((res: string, packageName: string, index: number, importPath: string) => string | null | void) | null}>} */
const resolvers = [];
/** @type {Array<{find: string, replacement: string}>} Aliases for nested engine auto-resolve packages */
const nestedAliases = [];
for (const name in packages_to_resolve) {
const value = packages_to_resolve[name];
const callback = typeof value === "function" ? value : null;
const isAutoResolve = value === "auto-resolve";
let fullpath = path.resolve(projectDir, 'node_modules', name);
let isNestedInEngine = false;
{
const pathInEngine = path.resolve(projectDir, 'node_modules', "@needle-tools/engine", "node_modules", name);
if (existsSync(pathInEngine)) {
fullpath = pathInEngine;
isNestedInEngine = true;
}
}
const pathExists = existsSync(fullpath);
resolvers.push({ name, fullpath, pathExists, isNestedInEngine, callback });
// For auto-resolve packages nested in the engine, use resolve.alias so both
// the optimizer and resolveId see the same path. This ensures Vite pre-bundles
// these deps (handling CJS interop) while keeping a single copy.
if (isAutoResolve && isNestedInEngine && pathExists) {
nestedAliases.push({ find: name, replacement: fullpath });
}
}
const workingDirectory = `${process.cwd()}/`;
/** @type {import("vite").Plugin} */
const aliasPlugin = {
name: "needle-alias",
// Register resolve.alias for nested engine packages so Vite's optimizer
// can find and pre-bundle them (with proper CJS interop).
config(config) {
if (nestedAliases.length > 0) {
if (!config.resolve) config.resolve = {};
// resolve.alias can be an array of {find,replacement} or a Record<string,string>.
// Convert to array format if needed (works in Vite 2-8).
if (!config.resolve.alias) {
config.resolve.alias = [];
}
else if (!Array.isArray(config.resolve.alias)) {
// Convert Record<string,string> to array format
const entries = Object.entries(config.resolve.alias);
config.resolve.alias = entries.map(([find, replacement]) => ({ find, replacement }));
}
for (const alias of nestedAliases) {
config.resolve.alias.push(alias);
needleLog("needle-alias", `Adding resolve.alias: ${alias.find} → ${alias.replacement.split('node_modules/').pop()}`);
}
}
},
// Use resolveId instead of resolve.alias to support both Rollup and Rolldown (Vite 8+).
// Rolldown does not support function-based alias replacements.
enforce: 'pre',
async resolveId(id, _importer, _options) {
for (const { name, fullpath, pathExists, isNestedInEngine, callback } of resolvers) {
// Check if import matches this resolver (exact match or starts with name/)
if (id !== name && !id.startsWith(name + '/')) continue;
if (callback !== null) {
const overrideResult = callback(fullpath, name, 0, id);
if (typeof overrideResult === "string") {
if (debug) log(`Redirecting "${id}" → "${overrideResult}"`);
// Let Vite resolve the result further (handles directories, package.json exports, etc.)
return this.resolve(overrideResult, _importer, { skipSelf: true });
}
// If the callback explicitly returned null, skip all resolution
// and let Vite handle it (e.g. optimizer pre-bundled deps).
if (overrideResult === null) break;
}
// For auto-resolve packages in the project's own node_modules,
// let Vite handle resolution naturally (preserves CJS interop / optimizeDeps).
// Only redirect when the package is nested inside @needle-tools/engine/node_modules.
if (pathExists && (callback !== null || isNestedInEngine)) {
if (debug) log(`Resolved "${id}" → "${fullpath.substring(workingDirectory.length).replaceAll("\\", "/")}"`);
// Rewrite the import to resolve from the target directory, letting Vite handle package.json/exports
const subpath = id.substring(name.length); // e.g. "" or "/something"
const rewritten = fullpath + subpath;
return this.resolve(rewritten, _importer, { skipSelf: true });
}
break;
}
},
}
/** @type {string|undefined} */
let lastImporter = "";
/** This plugin logs all imports. This helps to find cases where incorrect folders are found/resolved. */
/** @type {import("vite").Plugin} */
const debuggingPlugin = {
name: "needle:alias-debug",
// needs to run before regular resolver
enforce: 'pre',
resolveId(id, importer, options) {
if (importer) {
// simplify paths for better readability
if (importer.includes("js/package~")) importer = "package~" + importer.split("js/package~")[1];
if (importer.includes("node_modules/@needle-tools")) importer = "node_modules/@needle-tools" + importer.split("node_modules/@needle-tools")[1];
if (importer.includes("node_modules/.vite")) importer = ".vite" + importer.split("node_modules/.vite")[1];
}
// verbose logging for all imports
if (lastImporter !== importer) {
lastImporter = importer;
logToFile(`[needle-alias] Resolving: ${importer} (file${options?.ssr ? ", SSR" : ""})`);
}
logToFile(`[needle-alias] → ${id}`);
return;
},
}
if (debug) return [debuggingPlugin, aliasPlugin];
return [aliasPlugin];
}