nx
Version:
489 lines (488 loc) • 24.4 kB
JavaScript
;
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);
}