@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.
192 lines (165 loc) • 7.73 kB
JavaScript
// @ts-check
/**
* Vite plugin: needle:dts-generator
*
* Thin wrapper around plugins/dts-generator/index.js.
* Regenerates `needle-bindings.d.ts` on startup and whenever a .glb / .gltf
* file changes in the assets directory.
*
* The generated file is written to `{codegenDirectory}/needle-bindings.d.ts`
* (falls back to `src/generated/needle-bindings.d.ts`).
*
* Usage — the plugin is already wired into needlePlugins(). To use standalone:
*
* import { needleDtsGenerator } from "@needle-tools/engine/plugins/vite/dts-generator.js";
* // in vite.config.js plugins array:
* needleDtsGenerator(command, needleConfig, userSettings)
*/
import { join, resolve, dirname } from 'path';
import { existsSync, readFileSync, writeFileSync, mkdirSync, realpathSync } from 'fs';
import { tryLoadProjectConfig } from './config.js';
import { generateBindingsDts } from '../dts-generator/index.js';
import { needleLog } from './logging.js';
// Two dirs up from plugins/vite/ → package root.
// Use fileURLToPath so %7E and other URL-encoded characters in the path are decoded correctly.
// realpathSync follows symlinks so the path is stable even in npm link / monorepo setups.
import { fileURLToPath } from 'url';
let _packageRoot = /** @type {string | null} */ (null);
try {
_packageRoot = realpathSync(join(dirname(fileURLToPath(import.meta.url)), '..', '..'));
} catch (_e) {
// If we can't resolve the package root (e.g. unusual install layout), the plugin
// will silently skip generation rather than crashing the dev server.
}
/**
* Ensure `.vscode/settings.json` references the generated `needle-html-data.json`
* so VS Code provides `data-bind-needle` completions in HTML files automatically.
* Only adds the entry if it isn't already present — never overwrites other settings.
*
* @param {string} projectRoot
* @param {string} htmlDataPath Absolute path to the generated needle-html-data.json
*/
function ensureVscodeHtmlCustomData(projectRoot, htmlDataPath) {
const vscodeDir = join(projectRoot, ".vscode");
const settingsPath = join(vscodeDir, "settings.json");
// Relative path from project root for portability
const relPath = htmlDataPath.replace(projectRoot + "/", "").replace(projectRoot + "\\", "");
/** @type {Record<string, unknown>} */
let settings = {};
if (existsSync(settingsPath)) {
try {
settings = JSON.parse(readFileSync(settingsPath, "utf8"));
} catch (_e) { /* malformed JSON — leave settings empty, will add key */ }
}
const key = "html.customData";
const existing = Array.isArray(settings[key]) ? /** @type {string[]} */ (settings[key]) : [];
if (existing.includes(relPath)) return; // already registered
settings[key] = [...existing, relPath];
mkdirSync(vscodeDir, { recursive: true });
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
needleLog("needle:dts-generator", `registered HTML completions in .vscode/settings.json`);
}
/**
* @param {"build" | "serve"} _command Vite command (unused — runs in both modes)
* @param {import('../types/needleConfig').needleMeta | null | undefined} _config
* @param {import('../types').userSettings} [_userSettings]
* @returns {import('vite').Plugin | null}
*/
export function needleDtsGenerator(_command, _config, _userSettings) {
if(_userSettings?.dts?.enabled === false) {
return null;
}
let projectRoot = process.cwd();
function resolveCodegenDir() {
const projectConfig = tryLoadProjectConfig();
return projectConfig?.codegenDirectory
? resolve(projectRoot, projectConfig.codegenDirectory)
: join(projectRoot, "src", "generated");
}
function resolveOutputPath() {
// Write to a .needle dotfolder at the package root.
// Not in the `files` allowlist → never npm-published.
// ../../.needle/ from plugins/types/ resolves correctly for both
// symlinked (js/package~/) and published (node_modules/@needle-tools/engine/) layouts.
return join(/** @type {string} */ (_packageRoot), ".needle", "generated", "needle-bindings.gen.d.ts");
}
function resolveAssetsDir() {
const projectConfig = tryLoadProjectConfig();
return projectConfig?.assetsDirectory
? resolve(projectRoot, projectConfig.assetsDirectory)
: join(projectRoot, "assets");
}
/** @type {import('vite').ViteDevServer | undefined} */
let devServer;
/** @type {Promise<void> | null} */
let _runInFlight = null;
function run() {
if (_runInFlight) return _runInFlight;
_runInFlight = _doRun().finally(() => { _runInFlight = null; });
return _runInFlight;
}
async function _doRun() {
try {
if (!_packageRoot) return;
const assetsDir = resolveAssetsDir();
const outputPath = resolveOutputPath();
const codegenDir = resolveCodegenDir();
const count = await generateBindingsDts({ assetsDir, outputPath, projectRoot, codegenDir });
// HTML custom data sits next to the generated dts in .needle/generated/
const htmlDataPath = join(/** @type {string} */ (_packageRoot), ".needle", "generated", "needle-html-data.json");
ensureVscodeHtmlCustomData(projectRoot, htmlDataPath);
if (count !== false) {
needleLog("needle:dts-generator", `${count} binding(s) → ${outputPath.replace(process.cwd(), ".")}`);
if (devServer) {
const hot = devServer.hot ?? devServer.ws;
hot.send({ type: "full-reload", path: "*" });
}
} else {
needleLog("needle:dts-generator", `up-to-date → ${outputPath.replace(process.cwd(), ".")}`);
}
} catch (err) {
needleLog("needle:dts-generator", "Failed: " + (/** @type {any} */ (err)?.message ?? err));
}
}
return {
name: "needle:dts-generator",
/** @param {import('vite').ResolvedConfig} config */
configResolved(config) {
projectRoot = config.root ?? process.cwd();
},
buildStart() {
// In serve mode, the configureServer post-hook runs instead.
// Only run here for actual builds (no devServer).
if (!devServer) return run();
return undefined;
},
/** @param {import('vite').ViteDevServer} server */
configureServer(server) {
devServer = server;
// Watch assets directory for GLB/glTF changes and regenerate
const assetsDir = resolveAssetsDir();
server.watcher.add(assetsDir);
// Also watch files that determine which GLBs are entrypoints
const indexHtmlPath = join(projectRoot, "index.html");
const genJsPath = join(resolveCodegenDir(), "gen.js");
server.watcher.add(indexHtmlPath);
server.watcher.add(genJsPath);
server.watcher.on("change", (file) => {
if (
(/\.(glb|gltf)$/i.test(file) && file.startsWith(assetsDir)) ||
file === indexHtmlPath ||
file === genJsPath
) {
run();
}
});
server.watcher.on("add", (file) => {
if (/\.(glb|gltf)$/i.test(file) && file.startsWith(assetsDir)) {
run();
}
});
// Return post-hook so Vite awaits the initial run before printing "ready"
return () => run();
},
};
}