UNPKG

@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.

274 lines (242 loc) • 10.8 kB
// @ts-check import { exec, execSync } from 'child_process'; import { existsSync, readFileSync, rmSync, statSync, writeFileSync } from 'fs'; import path from 'path'; import { needleLog } from './logging.js'; /** @typedef {{dependencies?: Record<string, string>, devDependencies?: Record<string, string>}} PackageJson */ /** @param {...string} msg */ function log(...msg) { needleLog("needle-dependency-watcher", msg.join(" ")); } /** * @param {"build" | "serve"} command * @param {unknown} _config * @param {import('../types').userSettings} userSettings * @returns {import('vite').Plugin | null} */ export function needleDependencyWatcher(command, _config, userSettings) { if (command === "build") return null; if (userSettings?.noDependencyWatcher === true) return null; const dir = process.cwd(); const packageJsonPath = path.join(dir, "package.json"); const viteCacheDir = path.join(dir, "node_modules", ".vite"); return /** @type {import('vite').Plugin} */ ({ name: 'needle-dependency-watcher', /** @param {import('vite').ViteDevServer} server */ configureServer(server) { manageClients(server); const startWatching = () => watchPackageJson(server, dir, packageJsonPath, viteCacheDir); if (server.httpServer?.listening) { startWatching(); } else { server.httpServer?.once("listening", startWatching); } } }); } /** @type {Set<{send: (data: string) => void, on: (event: string, cb: () => void) => void}>} */ const currentClients = new Set(); /** @param {import('vite').ViteDevServer} server */ function manageClients(server) { server.ws.on("connection", (socket) => { currentClients.add(socket); socket.on("close", () => { currentClients.delete(socket); }); }); } async function triggerReloadOnClients() { log(`Triggering reload on ${currentClients.size} clients...`) for (const client of currentClients) { client.send(JSON.stringify({ type: "full-reload" })); } return new Promise((resolve) => { setTimeout(resolve, 100); }); } /** @type {import('fs').Stats | undefined} */ let packageJsonStat; /** @type {Date | undefined} */ let lastEditTime; /** @type {number | undefined} */ let packageJsonSize; /** @type {PackageJson | undefined} */ let packageJson; let requireInstall = false; /** * @param {import('vite').ViteDevServer} server * @param {string} projectDir * @param {string} packageJsonPath * @param {string} cachePath */ function watchPackageJson(server, projectDir, packageJsonPath, cachePath) { if (!existsSync(packageJsonPath)) { return; } log("Watching project", packageJsonPath) lastRestartTime = 0; packageJsonStat = statSync(packageJsonPath); lastEditTime = packageJsonStat.mtime; packageJsonSize = packageJsonStat.size; /** @type {PackageJson} */ packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")); setTimeout(() => { requireInstall = testIfInstallIsRequired(projectDir, packageJson); }, 1000); setInterval(() => { if (!packageJson || lastEditTime === undefined) return; packageJsonStat = statSync(packageJsonPath); let modified = false; if (packageJsonStat.mtime > lastEditTime) { modified = true; } if (packageJsonStat.size !== packageJsonSize) { modified = true; } if (modified || requireInstall) { let requireReload = false; if (!requireInstall) { requireInstall = testIfInstallIsRequired(projectDir, packageJson); } // test if dependencies changed let newPackageJson = /** @type {PackageJson} */ (JSON.parse(readFileSync(packageJsonPath, "utf8"))); if (newPackageJson.dependencies) { for (const key in newPackageJson.dependencies) { if (packageJson.dependencies?.[key] !== newPackageJson.dependencies[key] && newPackageJson.dependencies[key] !== undefined) { log("Detected new dependency: " + key) requireReload = true; requireInstall = true; } } } if (packageJson.devDependencies && newPackageJson.devDependencies) { for (const key in packageJson.devDependencies) { if (packageJson.devDependencies[key] !== newPackageJson.devDependencies[key] && newPackageJson.devDependencies[key] !== undefined) { log("Detected new devDependency: " + key) requireReload = true; requireInstall = true; } } } if (modified) { log("package.json has changed. Require install: " + (requireInstall ? "yes" : "no")) } packageJsonSize = packageJsonStat.size; lastEditTime = packageJsonStat.mtime; if (requireReload || requireInstall) { restart(server, projectDir, cachePath); } } }, 2000); } /** @param {string} projectDir @param {PackageJson | undefined} packageJson @returns {boolean} */ function testIfInstallIsRequired(projectDir, packageJson) { if (!packageJson) return false; if (packageJson.dependencies) { for (const key in packageJson.dependencies) { // make sure the dependency is installed const depPath = path.join(projectDir, "node_modules", key); if (!existsSync(depPath)) { const val = packageJson.dependencies[key]; if (val.startsWith("file:")) { // check if the local path exists: const dirPath = val.substr(5); // check first the value in case it's a absolute path if (!existsSync(dirPath)) { // then check concatenated with the project dir in case it's a relative path const relPath = path.join(projectDir, val.substr(5)); if (!existsSync(relPath)) { // if neither directories exist then the local path is invalid and we ignore it continue; } } } log("Dependency not installed: " + key) return true; } else { let expectedVersion = ""; const value = packageJson.dependencies[key]; if (value.startsWith("file:")) { const packageJsonPath = path.join(projectDir, value.substr(5), "package.json"); if (existsSync(packageJsonPath)) { expectedVersion = JSON.parse(readFileSync(packageJsonPath, "utf8")).version; } } else { expectedVersion = value; } if (expectedVersion?.length > 0) { const isRange = expectedVersion.includes("x") || expectedVersion.includes("^") || expectedVersion.includes(">") || expectedVersion.includes("<") || expectedVersion.includes("~"); const isTagName = expectedVersion === "stable" || expectedVersion === "latest" || expectedVersion === "next" || expectedVersion === "beta" || expectedVersion === "alpha" || expectedVersion === "canary" || expectedVersion === "experimental"; // We can not automatically install dependencies with "4" or "4.6" as a version // const isFullSemver = expectedVersion.match(/^\d+\.\d+\.\d+(-\w+)?(\.\d+)?$/); if (!isRange && !isTagName) { const packageJsonPath = path.join(depPath, "package.json"); /** @type {String} */ const installedVersion = JSON.parse(readFileSync(packageJsonPath, "utf8")).version; let versionToCompare = expectedVersion.split("@").pop(); while (versionToCompare?.startsWith("^")) versionToCompare = versionToCompare.substring(1); // fix check for cases where the version contains a alias e.g. npm:three@0.160.3 const actualVersion = installedVersion.split("@").pop(); if (actualVersion && versionToCompare && versionToCompare.trimEnd().startsWith(actualVersion.trim()) == false) { log(`Dependency ${key} is installed but version is not the right one. Expected \"${expectedVersion}/" but got \"${installedVersion}\"`) return true; } } } } } } return false; } let isRunningRestart = false; let restartId = 0; let lastRestartTime = 0; /** @param {import('vite').ViteDevServer} server @param {string} projectDir @param {string} cachePath */ async function restart(server, projectDir, cachePath) { if (isRunningRestart) return; isRunningRestart = true; try { const id = ++restartId; if (requireInstall) { requireInstall = false; log("Installing dependencies...") execSync("npm install", { cwd: projectDir, stdio: "inherit" }); requireInstall = false; } if (id !== restartId) return; if (Date.now() - lastRestartTime < 3000) return; log("Restarting server...") lastRestartTime = Date.now(); requireInstall = false; if (existsSync(cachePath)) rmSync(cachePath, { recursive: true, force: true }); await triggerReloadOnClients(); // touch vite config to trigger reload // const viteConfigPath = path.join(projectDir, "vite.config.js"); // if (existsSync(viteConfigPath)) { // const content = readFileSync(viteConfigPath, "utf8"); // writeFileSync(viteConfigPath, content, "utf8"); // isRunningRestart = false; // return; // } // check if server is running if (server.httpServer?.listening) { server.restart(); } isRunningRestart = false; needleLog("needle-dependency-watcher", "-----------------------------------------------"); } catch (err) { log("Error restarting server", String(err)); isRunningRestart = false; } }