UNPKG

nx

Version:

The core Nx plugin contains the core functionality of Nx like the project graph, nx commands and task orchestration.

489 lines (488 loc) 24.4 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.currentSourceMaps = exports.currentProjectGraph = exports.currentProjectFileMapCache = exports.fileMapWithFiles = void 0; exports.getCachedSerializedProjectGraphPromise = getCachedSerializedProjectGraphPromise; exports.scheduleProjectGraphRecomputation = scheduleProjectGraphRecomputation; exports.registerProjectGraphRecomputationListener = registerProjectGraphRecomputationListener; exports.invalidateGraphCache = invalidateGraphCache; const perf_hooks_1 = require("perf_hooks"); const nx_json_1 = require("../../config/nx-json"); const file_hasher_1 = require("../../hasher/file-hasher"); const build_project_graph_1 = require("../../project-graph/build-project-graph"); const error_types_1 = require("../../project-graph/error-types"); const file_map_utils_1 = require("../../project-graph/file-map-utils"); const nx_deps_cache_1 = require("../../project-graph/nx-deps-cache"); const get_plugins_1 = require("../../project-graph/plugins/get-plugins"); const retrieve_workspace_files_1 = require("../../project-graph/utils/retrieve-workspace-files"); const fileutils_1 = require("../../utils/fileutils"); const workspace_context_1 = require("../../utils/workspace-context"); const workspace_root_1 = require("../../utils/workspace-root"); const progress_topics_1 = require("../../utils/progress-topics"); const client_socket_context_1 = require("./client-socket-context"); const file_change_events_1 = require("./file-watching/file-change-events"); const file_watcher_sockets_1 = require("./file-watching/file-watcher-sockets"); const project_graph_listener_sockets_1 = require("./project-graph-listener-sockets"); const watcher_1 = require("./watcher"); const logger_1 = require("../logger"); let cachedSerializedProjectGraphPromise; // Maps file path to a version counter that increments on each modification. // This lets us detect mid-flight re-modifications when clearing processed files. const collectedUpdatedFiles = new Map(); const collectedDeletedFiles = new Map(); const projectGraphRecomputationListeners = new Set(); let storedWorkspaceConfigHash; let knownExternalNodes = {}; let fileChangeCounter = 0; let recomputationGeneration = 0; // True after the first successful persistProjectGraphToDisk call. Until // that happens, "project-graph.json missing on disk" is the expected // state (we just haven't written it yet) and must not trigger a reset. let cacheHasBeenPersisted = false; /** * Freshness-gated recompute. Each IIFE snapshots the nx.json `plugins` * hash at kickoff and re-reads at commit; if it changed mid-flight, bail * and kick a successor instead of clobbering the winner. Without this, * `cachedSerializedProjectGraphPromise` is last-kickoff-wins and can * return a graph built against a stale plugin set * (see spread.test.ts "middle plugin" flake). */ function kickOffRecompute() { let myPromise; myPromise = (async () => { // Must resolve, never reject: kickOffRecompute() runs fire-and-forget, so // a rejected myPromise crashes the daemon (unhandled rejection). A throwing // prologue (e.g. plugin load fails) becomes an errorResult the next requester surfaces. try { // Single read shared with getPluginsSeparated below. This collapses // what would otherwise be two independent nx.json reads (our snap + // the plugin loader's) into one, so the snap hash and the plugin // set the compute uses always reflect the same disk state. const nxJson = (0, nx_json_1.readNxJson)(workspace_root_1.workspaceRoot); const myPluginsHash = (0, file_hasher_1.hashObject)(nxJson.plugins ?? []); const plugins = await (0, get_plugins_1.getPluginsSeparated)(nxJson, workspace_root_1.workspaceRoot); // Plugin set we just loaded may already be stale vs disk. if (isStale(myPluginsHash)) return chainToSuccessor(myPromise); const result = await processFilesAndCreateAndSerializeProjectGraph(plugins); // Compute may have run against plugins that are now stale. if (isStale(myPluginsHash)) return chainToSuccessor(myPromise); if (cachedSerializedProjectGraphPromise === myPromise && result.projectGraph) { notifyProjectGraphRecomputationListeners(result.projectGraph, result.sourceMaps, result.error); persistProjectGraphToDisk(result); } return result; } catch (e) { return errorResult(e); } })(); cachedSerializedProjectGraphPromise = myPromise; } function isStale(expectedHash) { return readNxJsonPluginsHash() !== expectedHash; } /** * Starts a successor recompute only when this IIFE is still the cached one. * If a newer recompute already replaced the cached pointer, that newer * recompute will produce the fresh result and we just need to return the * pointer so awaiters chain onto it. */ function chainToSuccessor(myPromise) { logger_1.serverLogger.log('Discarding stale recompute result (nx.json plugins changed mid-compute).'); if (cachedSerializedProjectGraphPromise === myPromise) kickOffRecompute(); return cachedSerializedProjectGraphPromise; } function readNxJsonPluginsHash() { return (0, file_hasher_1.hashObject)((0, nx_json_1.readNxJson)(workspace_root_1.workspaceRoot).plugins ?? []); } async function getCachedSerializedProjectGraphPromise(socket) { // Subscribe the requesting client to the graph-construction topic // for the duration of the await, so in-flight progress/log messages // — including those produced by a recomputation that was already // started before this caller arrived — are broadcast to them. if (socket) { (0, client_socket_context_1.subscribeClientToTopic)(socket, progress_topics_1.ProgressTopics.GraphConstruction); } try { // Drain anything the native watcher has buffered before deciding // whether the cached graph is fresh. Without this, a file change // that already arrived in the watcher's accumulator but hasn't // flushed past IDLE_WINDOW yet would be invisible to the staleness // check below — the daemon would return a stale graph. await (0, watcher_1.flushPendingWorkspaceChanges)(); await resetInternalStateIfNxDepsMissing(); // Yield one macrotask boundary so any TSFN-queued watcher callbacks // run before we read collected*. Without this, an event that left // the native side but is still queued in libuv's I/O queue (behind // our request handler) would be invisible here and we'd serve a // stale graph. Placed right before the read so no microtask gap // separates them. await new Promise(setImmediate); // If no compute exists or events are still in collected*, kick one off. // Otherwise reuse whatever is already in flight or cached. const needsRecompute = !cachedSerializedProjectGraphPromise || collectedUpdatedFiles.size > 0 || collectedDeletedFiles.size > 0; if (needsRecompute) { logger_1.serverLogger.log(cachedSerializedProjectGraphPromise ? `Recomputing project graph because of ${collectedUpdatedFiles.size} updated and ${collectedDeletedFiles.size} deleted files.` : 'No in-memory cached project graph found. Recomputing it...'); kickOffRecompute(); } else { logger_1.serverLogger.log('Reusing in-memory cached project graph because no files changed.'); } // A stale compute returns cachedSerializedProjectGraphPromise (the // newer compute that replaced it); promise unwrapping flattens the // chain so we always end up with the latest real result. const result = await cachedSerializedProjectGraphPromise; // Even when the loop didn't recompute, write the cache if it's stale on // disk relative to the in-memory result. This protects against // non-daemon processes overwriting the daemon's valid graph with a // stale/errored one. if (!needsRecompute && result.projectGraph && result.projectFileMapCache && result.sourceMaps) { (0, nx_deps_cache_1.writeCacheIfStale)(result.projectFileMapCache, result.projectGraph, result.sourceMaps, extractErrors(result.error)); } const errors = extractErrors(result.error); if (errors?.length) { cachedSerializedProjectGraphPromise = null; } return result; } catch (e) { // We return the project graph, but we don't want to persist the cache to // serve the same state, as it could cause issues if the error is caused by something // transient cachedSerializedProjectGraphPromise = null; return { error: e, serializedProjectGraph: null, serializedSourceMaps: null, sourceMaps: null, projectGraph: null, projectFileMapCache: null, rustReferences: null, }; } finally { if (socket) { (0, client_socket_context_1.unsubscribeClientFromTopic)(socket, progress_topics_1.ProgressTopics.GraphConstruction); } } } function scheduleProjectGraphRecomputation(createdFiles, updatedFiles, deletedFiles) { ++fileChangeCounter; for (let f of [...createdFiles, ...updatedFiles]) { collectedDeletedFiles.delete(f); collectedUpdatedFiles.set(f, fileChangeCounter); } for (let f of deletedFiles) { collectedUpdatedFiles.delete(f); collectedDeletedFiles.set(f, fileChangeCounter); } // The native watcher already coalesces a burst of events into one batch, // so socket + listener notifications dispatch immediately. if (createdFiles.length > 0 || updatedFiles.length > 0 || deletedFiles.length > 0) { (0, file_change_events_1.notifyFileChangeListeners)({ createdFiles, updatedFiles, deletedFiles }); (0, file_watcher_sockets_1.notifyFileWatcherSockets)(createdFiles, updatedFiles, deletedFiles); // Bump generation synchronously so any in-flight compute fails its // next isStale() check and chains to the newer one. kickOffRecompute // would also bump on first resume, but only after its first await — // a window during which the old compute could falsely pass. ++recomputationGeneration; kickOffRecompute(); } else { // First call (initial startup) — no events but we still need a graph. if (!cachedSerializedProjectGraphPromise) { kickOffRecompute(); } } } function registerProjectGraphRecomputationListener(listener) { projectGraphRecomputationListeners.add(listener); } function computeWorkspaceConfigHash(projectsConfigurations) { const projectConfigurationStrings = Object.entries(projectsConfigurations) .sort(([projectNameA], [projectNameB]) => projectNameA.localeCompare(projectNameB)) .map(([projectName, projectConfig]) => `${projectName}:${JSON.stringify(projectConfig)}`); return (0, file_hasher_1.hashArray)(projectConfigurationStrings); } async function processCollectedUpdatedAndDeletedFiles({ projects, externalNodes, projectRootMap }, updatedFileHashes, deletedFiles) { try { const configHash = computeWorkspaceConfigHash(projects); // Config changed → can't incrementally update; refetch the file map // from disk. Returning instead of mutating module state lets the caller // gate the commit on its staleness check, so a slower stale compute // can't clobber a faster newer one's already-committed state. if (configHash !== storedWorkspaceConfigHash) { const fresh = await (0, retrieve_workspace_files_1.retrieveWorkspaceFiles)(workspace_root_1.workspaceRoot, projectRootMap); return { fileMap: fresh, configHash, knownExternalNodes: externalNodes }; } // Config unchanged → patch the existing file map in place. if (exports.fileMapWithFiles) { return { fileMap: (0, file_map_utils_1.updateFileMap)(projects, exports.fileMapWithFiles.rustReferences, updatedFileHashes, deletedFiles), configHash, }; } // No prior map (first compute on this daemon). const fresh = await (0, retrieve_workspace_files_1.retrieveWorkspaceFiles)(workspace_root_1.workspaceRoot, projectRootMap); return { fileMap: fresh, configHash }; } catch (e) { // this is expected // for instance, project.json can be incorrect or a file we are trying to has // has been deleted // we are resetting internal state to start from scratch next time a file changes // given the user the opportunity to fix the error // if Nx requests the project graph prior to the error being fixed, // the error will be propagated logger_1.serverLogger.log(`Error detected when recomputing project file map: ${e.message}`); resetInternalState(); throw e; } } function invalidateGraphCache() { // Clear the cached promise so the next request triggers a fresh computation. // We intentionally do NOT call getCachedSerializedProjectGraphPromise() here // because assigning its return Promise to the module-level variable causes a // deadlock: the async function resumes, sees the variable is non-null (pointing // at its own Promise), takes the "reuse" branch, and awaits itself forever. cachedSerializedProjectGraphPromise = null; } async function processFilesAndCreateAndSerializeProjectGraph(separatedPlugins) { const plugins = [ ...separatedPlugins.specifiedPlugins, ...separatedPlugins.defaultPlugins, ]; const myGeneration = ++recomputationGeneration; // A newer kickOffRecompute has already replaced // cachedSerializedProjectGraphPromise. Returning it lets the async // unwrap chain our caller onto the newer compute's result; pass // notifyAbort=true when the graph-phase counters still need balancing. const chainToLatest = (notifyAbort) => { if (myGeneration === recomputationGeneration) return null; if (notifyAbort) notifyPluginsGraphAborted(plugins); // Defensive: if the cache was cleared (e.g. resetInternalState ran) // there is nothing to chain to. Returning undefined lets `if (stale) // return stale` fall through and the compute commits stale data. // Kick off a successor so we always have a real promise to chain to. if (!cachedSerializedProjectGraphPromise) { kickOffRecompute(); } return cachedSerializedProjectGraphPromise; }; try { perf_hooks_1.performance.mark('hash-watched-changes-start'); const updatedFilesSnapshot = new Map(collectedUpdatedFiles); const deletedFilesSnapshot = new Map(collectedDeletedFiles); const updatedFiles = [...updatedFilesSnapshot.keys()]; const deletedFiles = [...deletedFilesSnapshot.keys()]; let updatedFileHashes = (0, workspace_context_1.updateFilesInContext)(workspace_root_1.workspaceRoot, updatedFiles, deletedFiles); perf_hooks_1.performance.mark('hash-watched-changes-end'); perf_hooks_1.performance.measure('hash changed files from watcher', 'hash-watched-changes-start', 'hash-watched-changes-end'); logger_1.serverLogger.requestLog(`Updated workspace context based on watched changes, recomputing project graph...`); logger_1.serverLogger.requestLog(updatedFiles); logger_1.serverLogger.requestLog(deletedFiles); const nxJson = (0, nx_json_1.readNxJson)(workspace_root_1.workspaceRoot); global.NX_GRAPH_CREATION = true; let projectConfigurationsResult; let projectConfigurationsError; try { projectConfigurationsResult = await (0, retrieve_workspace_files_1.retrieveProjectConfigurations)(separatedPlugins, workspace_root_1.workspaceRoot, nxJson); } catch (e) { if (e instanceof error_types_1.ProjectConfigurationsError) { projectConfigurationsResult = e.partialProjectConfigurationsResult; projectConfigurationsError = e; } else { throw e; } } const stalePostCreateNodes = chainToLatest(true); if (stalePostCreateNodes) return stalePostCreateNodes; const fileMapUpdate = await processCollectedUpdatedAndDeletedFiles(projectConfigurationsResult, updatedFileHashes, deletedFiles); const stalePreCreateDependencies = chainToLatest(true); if (stalePreCreateDependencies) return stalePreCreateDependencies; // Latest writer commits to module state. Stale computes returned via // chainToLatest above without touching `fileMapWithFiles`, so they // can't clobber a newer compute's write. exports.fileMapWithFiles = fileMapUpdate.fileMap; storedWorkspaceConfigHash = fileMapUpdate.configHash; if (fileMapUpdate.knownExternalNodes) { knownExternalNodes = fileMapUpdate.knownExternalNodes; } // Drain only after committing — a stale compute that returns at the // staleness check above must leave its snapshot in `collected*` so the // newer compute still sees those files. Removing them earlier would // make the next compute snapshot empty and the file changes vanish // from the daemon's view (project graph misses recently added files). // Match version-stamps so a file modified mid-flight (higher version) // stays in the queue for reprocessing. for (const [f, version] of updatedFilesSnapshot) { if (collectedUpdatedFiles.get(f) === version) { collectedUpdatedFiles.delete(f); } } for (const [f, version] of deletedFilesSnapshot) { if (collectedDeletedFiles.get(f) === version) { collectedDeletedFiles.delete(f); } } const g = await createAndSerializeProjectGraph(projectConfigurationsResult); delete global.NX_GRAPH_CREATION; // createDependencies/createMetadata already ran via wrapped hooks, so // graph-phase counters are balanced — no notifyAbort needed. const stalePostBuild = chainToLatest(false); if (stalePostBuild) return stalePostBuild; const errors = [...(projectConfigurationsError?.errors ?? [])]; const aggregate = g.error && (0, error_types_1.isAggregateProjectGraphError)(g.error) && g.error.errors?.length ? g.error : null; if (g.error && !aggregate) return errorResult(g.error); if (aggregate) errors.push(...aggregate.errors); if (errors.length === 0) return g; return errorResult(new error_types_1.DaemonProjectGraphError(errors, g.projectGraph, projectConfigurationsResult.sourceMaps)); } catch (err) { return errorResult(err); } } function errorResult(error) { return { error, projectGraph: null, projectFileMapCache: null, rustReferences: null, serializedProjectGraph: null, serializedSourceMaps: null, sourceMaps: null, }; } function extractErrors(error) { if (!error) return []; return error instanceof error_types_1.DaemonProjectGraphError ? error.errors : [error]; } function persistProjectGraphToDisk(result) { if (!result.projectGraph || !result.projectFileMapCache || !result.sourceMaps) { return; } (0, nx_deps_cache_1.writeCache)(result.projectFileMapCache, result.projectGraph, result.sourceMaps, extractErrors(result.error)); cacheHasBeenPersisted = true; } function copyFileData(d) { return d.map((t) => ({ ...t })); } function copyFileMap(m) { const c = { nonProjectFiles: copyFileData(m.nonProjectFiles), projectFileMap: {}, }; for (let p of Object.keys(m.projectFileMap)) { c.projectFileMap[p] = copyFileData(m.projectFileMap[p]); } return c; } async function createAndSerializeProjectGraph({ projects, sourceMaps, }) { try { perf_hooks_1.performance.mark('create-project-graph-start'); const fileMap = copyFileMap(exports.fileMapWithFiles.fileMap); const rustReferences = exports.fileMapWithFiles.rustReferences; const { projectGraph, projectFileMapCache } = await (0, build_project_graph_1.buildProjectGraphUsingProjectFileMap)(projects, knownExternalNodes, fileMap, rustReferences, exports.currentProjectFileMapCache || (0, nx_deps_cache_1.readFileMapCache)(), await (0, get_plugins_1.getPlugins)((0, nx_json_1.readNxJson)(workspace_root_1.workspaceRoot)), sourceMaps); exports.currentProjectFileMapCache = projectFileMapCache; exports.currentProjectGraph = projectGraph; exports.currentSourceMaps = sourceMaps; perf_hooks_1.performance.mark('create-project-graph-end'); perf_hooks_1.performance.measure('total execution time for createProjectGraph()', 'create-project-graph-start', 'create-project-graph-end'); perf_hooks_1.performance.mark('json-stringify-start'); const serializedProjectGraph = JSON.stringify(projectGraph); const serializedSourceMaps = JSON.stringify(sourceMaps); perf_hooks_1.performance.mark('json-stringify-end'); perf_hooks_1.performance.measure('serialize graph', 'json-stringify-start', 'json-stringify-end'); return { error: null, projectGraph, projectFileMapCache, serializedProjectGraph, serializedSourceMaps, sourceMaps, rustReferences, }; } catch (e) { logger_1.serverLogger.log(`Error detected when creating a project graph: ${e.message}`); return { error: e, projectGraph: null, projectFileMapCache: null, serializedProjectGraph: null, serializedSourceMaps: null, sourceMaps: null, rustReferences: null, }; } } async function resetInternalState() { cachedSerializedProjectGraphPromise = undefined; exports.fileMapWithFiles = undefined; exports.currentProjectFileMapCache = undefined; exports.currentProjectGraph = undefined; exports.currentSourceMaps = undefined; collectedUpdatedFiles.clear(); collectedDeletedFiles.clear(); cacheHasBeenPersisted = false; (0, workspace_context_1.resetWorkspaceContext)(); } async function resetInternalStateIfNxDepsMissing() { // Only meaningful AFTER we've persisted the cache at least once. // Before then, "file missing" is the expected state — an in-flight // first compute hasn't written yet, and resetting would tear down its // promise mid-await and force a redundant recompute. if (!cacheHasBeenPersisted) { return; } try { if (!(0, fileutils_1.fileExists)(nx_deps_cache_1.nxProjectGraph) && cachedSerializedProjectGraphPromise) { await resetInternalState(); } } catch { // A transient stat error shouldn't nuke state — the next request // will retry. } } function notifyPluginsGraphAborted(plugins) { // At both abort sites, only createNodes has been called. // createDependencies and createMetadata are called later in // createAndSerializeProjectGraph, which hasn't run yet. for (const plugin of plugins) { plugin.notifyPhaseAborted?.('graph', 'createNodes'); } } function notifyProjectGraphRecomputationListeners(projectGraph, sourceMaps, error) { for (const listener of projectGraphRecomputationListeners) { listener(projectGraph, sourceMaps, error); } (0, project_graph_listener_sockets_1.notifyProjectGraphListenerSockets)(projectGraph, sourceMaps, error); }