diff --git a/packages/nx/src/daemon/client/client.ts b/packages/nx/src/daemon/client/client.ts index 801ad7e1e2c93..a41f82862bf67 100644 --- a/packages/nx/src/daemon/client/client.ts +++ b/packages/nx/src/daemon/client/client.ts @@ -77,6 +77,10 @@ import { FLUSH_SYNC_GENERATOR_CHANGES_TO_DISK, type HandleFlushSyncGeneratorChangesToDiskMessage, } from '../message-types/flush-sync-generator-changes-to-disk'; +import { + DelayedSpinner, + SHOULD_SHOW_SPINNERS, +} from '../../utils/delayed-spinner'; const DAEMON_ENV_SETTINGS = { NX_PROJECT_GLOB_CACHE: 'false', @@ -194,6 +198,17 @@ export class DaemonClient { projectGraph: ProjectGraph; sourceMaps: ConfigurationSourceMaps; }> { + let spinner: DelayedSpinner; + if (SHOULD_SHOW_SPINNERS) { + // If the graph takes a while to load, we want to show a spinner. + spinner = new DelayedSpinner( + 'Calculating the project graph on the Nx Daemon', + 500 + ).scheduleMessageUpdate( + 'Calculating the project graph on the Nx Daemon is taking longer than expected. Re-run with NX_DAEMON=false to see more details.', + 30_000 + ); + } try { const response = await this.sendToDaemonViaQueue({ type: 'REQUEST_PROJECT_GRAPH', @@ -208,6 +223,8 @@ export class DaemonClient { } else { throw e; } + } finally { + spinner?.cleanup(); } } diff --git a/packages/nx/src/project-graph/build-project-graph.ts b/packages/nx/src/project-graph/build-project-graph.ts index be61b9e227c78..a80fc1ed78205 100644 --- a/packages/nx/src/project-graph/build-project-graph.ts +++ b/packages/nx/src/project-graph/build-project-graph.ts @@ -44,6 +44,7 @@ import { ConfigurationSourceMaps, mergeMetadata, } from './utils/project-configuration-utils'; +import { DelayedSpinner, SHOULD_SHOW_SPINNERS } from '../utils/delayed-spinner'; let storedFileMap: FileMap | null = null; let storedAllWorkspaceFiles: FileData[] | null = null; @@ -313,14 +314,47 @@ async function updateProjectGraphWithPlugins( (plugin) => plugin.createDependencies ); performance.mark('createDependencies:start'); + + let spinner: DelayedSpinner; + const inProgressPlugins = new Set(); + + function updateSpinner() { + if (!spinner) { + return; + } + if (inProgressPlugins.size === 1) { + return `Creating project graph dependencies with ${ + inProgressPlugins.keys()[0] + }`; + } else if (process.env.NX_VERBOSE_LOGGING === 'true') { + return [ + `Creating project graph dependencies with ${inProgressPlugins.size} plugins`, + ...Array.from(inProgressPlugins).map((p) => ` - ${p}`), + ].join('\n'); + } else { + return `Creating project graph dependencies with ${inProgressPlugins.size} plugins`; + } + } + + if (SHOULD_SHOW_SPINNERS) { + spinner = new DelayedSpinner( + `Creating project graph dependencies with ${plugins.length} plugins` + ); + } + await Promise.all( createDependencyPlugins.map(async (plugin) => { performance.mark(`${plugin.name}:createDependencies - start`); - + inProgressPlugins.add(plugin.name); try { - const dependencies = await plugin.createDependencies({ - ...context, - }); + const dependencies = await plugin + .createDependencies({ + ...context, + }) + .finally(() => { + inProgressPlugins.delete(plugin.name); + updateSpinner(); + }); for (const dep of dependencies) { builder.addDependency( @@ -352,6 +386,7 @@ async function updateProjectGraphWithPlugins( `createDependencies:start`, `createDependencies:end` ); + spinner?.cleanup(); const graphWithDeps = builder.getUpdatedProjectGraph(); @@ -396,15 +431,43 @@ export async function applyProjectMetadata( const errors: CreateMetadataError[] = []; performance.mark('createMetadata:start'); + let spinner: DelayedSpinner; + const inProgressPlugins = new Set(); + + function updateSpinner() { + if (!spinner) { + return; + } + if (inProgressPlugins.size === 1) { + return `Creating project metadata with ${inProgressPlugins.keys()[0]}`; + } else if (process.env.NX_VERBOSE_LOGGING === 'true') { + return [ + `Creating project metadata with ${inProgressPlugins.size} plugins`, + ...Array.from(inProgressPlugins).map((p) => ` - ${p}`), + ].join('\n'); + } else { + return `Creating project metadata with ${inProgressPlugins.size} plugins`; + } + } + + if (SHOULD_SHOW_SPINNERS) { + spinner = new DelayedSpinner( + `Creating project metadata with ${plugins.length} plugins` + ); + } + const promises = plugins.map(async (plugin) => { if (plugin.createMetadata) { performance.mark(`${plugin.name}:createMetadata - start`); + inProgressPlugins.add(plugin.name); try { const metadata = await plugin.createMetadata(graph, context); results.push({ metadata, pluginName: plugin.name }); } catch (e) { errors.push(new CreateMetadataError(e, plugin.name)); } finally { + inProgressPlugins.delete(plugin.name); + updateSpinner(); performance.mark(`${plugin.name}:createMetadata - end`); performance.measure( `${plugin.name}:createMetadata`, @@ -417,6 +480,8 @@ export async function applyProjectMetadata( await Promise.all(promises); + spinner?.cleanup(); + for (const { metadata: projectsMetadata, pluginName } of results) { for (const project in projectsMetadata) { const projectConfiguration: ProjectConfiguration = diff --git a/packages/nx/src/project-graph/plugins/internal-api.ts b/packages/nx/src/project-graph/plugins/internal-api.ts index 49a6c85d6c565..ac29ab7792d8e 100644 --- a/packages/nx/src/project-graph/plugins/internal-api.ts +++ b/packages/nx/src/project-graph/plugins/internal-api.ts @@ -15,6 +15,7 @@ import { CreateNodesContextV2, CreateNodesResult, NxPluginV2, + ProjectsMetadata, } from './public-api'; import { ProjectGraph } from '../../config/project-graph'; import { loadNxPluginInIsolation } from './isolation'; @@ -25,6 +26,7 @@ import { isAggregateCreateNodesError, } from '../error-types'; import { IS_WASM } from '../../native'; +import { RawProjectGraphDependency } from '../project-graph-builder'; export class LoadedNxPlugin { readonly name: string; @@ -41,11 +43,11 @@ export class LoadedNxPlugin { ]; readonly createDependencies?: ( context: CreateDependenciesContext - ) => ReturnType; + ) => Promise; readonly createMetadata?: ( graph: ProjectGraph, context: CreateMetadataContext - ) => ReturnType; + ) => Promise; readonly options?: unknown; readonly include?: string[]; @@ -110,12 +112,12 @@ export class LoadedNxPlugin { } if (plugin.createDependencies) { - this.createDependencies = (context) => + this.createDependencies = async (context) => plugin.createDependencies(this.options, context); } if (plugin.createMetadata) { - this.createMetadata = (graph, context) => + this.createMetadata = async (graph, context) => plugin.createMetadata(graph, this.options, context); } } diff --git a/packages/nx/src/project-graph/plugins/isolation/messaging.ts b/packages/nx/src/project-graph/plugins/isolation/messaging.ts index f8e6e69a4378f..e0d55f5c8506f 100644 --- a/packages/nx/src/project-graph/plugins/isolation/messaging.ts +++ b/packages/nx/src/project-graph/plugins/isolation/messaging.ts @@ -84,7 +84,7 @@ export interface PluginCreateDependenciesResult { type: 'createDependenciesResult'; payload: | { - dependencies: ReturnType; + dependencies: Awaited>; success: true; tx: string; } @@ -99,7 +99,7 @@ export interface PluginCreateMetadataResult { type: 'createMetadataResult'; payload: | { - metadata: ReturnType; + metadata: Awaited>; success: true; tx: string; } diff --git a/packages/nx/src/project-graph/utils/project-configuration-utils.ts b/packages/nx/src/project-graph/utils/project-configuration-utils.ts index 911199881bfe2..d36d0a31edb98 100644 --- a/packages/nx/src/project-graph/utils/project-configuration-utils.ts +++ b/packages/nx/src/project-graph/utils/project-configuration-utils.ts @@ -13,6 +13,7 @@ import { workspaceRoot } from '../../utils/workspace-root'; import { minimatch } from 'minimatch'; import { join } from 'path'; import { performance } from 'perf_hooks'; + import { LoadedNxPlugin } from '../plugins/internal-api'; import { MergeNodesError, @@ -30,6 +31,11 @@ import { } from '../error-types'; import { CreateNodesResult } from '../plugins/public-api'; import { isGlobPattern } from '../../utils/globs'; +import { isOnDaemon } from '../../daemon/is-on-daemon'; +import { + DelayedSpinner, + SHOULD_SHOW_SPINNERS, +} from '../../utils/delayed-spinner'; export type SourceInformation = [file: string | null, plugin: string]; export type ConfigurationSourceMaps = Record< @@ -324,6 +330,32 @@ export async function createProjectConfigurations( ): Promise { performance.mark('build-project-configs:start'); + let spinner: DelayedSpinner; + const inProgressPlugins = new Set(); + + function updateSpinner() { + if (!spinner) { + return; + } + + if (inProgressPlugins.size === 1) { + return `Creating project graph nodes with ${inProgressPlugins.keys()[0]}`; + } else if (process.env.NX_VERBOSE_LOGGING === 'true') { + return [ + `Creating project graph nodes with ${inProgressPlugins.size} plugins`, + ...Array.from(inProgressPlugins).map((p) => ` - ${p}`), + ].join('\n'); + } else { + return `Creating project graph nodes with ${inProgressPlugins.size} plugins`; + } + } + + if (SHOULD_SHOW_SPINNERS) { + spinner = new DelayedSpinner( + `Creating project graph nodes with ${plugins.length} plugins` + ); + } + const results: Array> = []; const errors: Array< | AggregateCreateNodesError @@ -352,44 +384,55 @@ export async function createProjectConfigurations( exclude ); + inProgressPlugins.add(pluginName); let r = createNodes(matchingConfigFiles, { nxJsonConfiguration: nxJson, workspaceRoot: root, - }).catch((e: Error) => { - const errorBodyLines = [ - `An error occurred while processing files for the ${pluginName} plugin.`, - ]; - const error: AggregateCreateNodesError = isAggregateCreateNodesError(e) - ? // This is an expected error if something goes wrong while processing files. - e - : // This represents a single plugin erroring out with a hard error. - new AggregateCreateNodesError([[null, e]], []); - - const innerErrors = error.errors; - for (const [file, e] of innerErrors) { - if (file) { - errorBodyLines.push(` - ${file}: ${e.message}`); - } else { - errorBodyLines.push(` - ${e.message}`); - } - if (e.stack) { - const innerStackTrace = ' ' + e.stack.split('\n')?.join('\n '); - errorBodyLines.push(innerStackTrace); + }) + .catch((e: Error) => { + const errorBodyLines = [ + `An error occurred while processing files for the ${pluginName} plugin.`, + ]; + const error: AggregateCreateNodesError = isAggregateCreateNodesError(e) + ? // This is an expected error if something goes wrong while processing files. + e + : // This represents a single plugin erroring out with a hard error. + new AggregateCreateNodesError([[null, e]], []); + + const innerErrors = error.errors; + for (const [file, e] of innerErrors) { + if (file) { + errorBodyLines.push(` - ${file}: ${e.message}`); + } else { + errorBodyLines.push(` - ${e.message}`); + } + if (e.stack) { + const innerStackTrace = + ' ' + e.stack.split('\n')?.join('\n '); + errorBodyLines.push(innerStackTrace); + } } - } - error.stack = errorBodyLines.join('\n'); + error.stack = errorBodyLines.join('\n'); - // This represents a single plugin erroring out with a hard error. - errors.push(error); - // The plugin didn't return partial results, so we return an empty array. - return error.partialResults.map((r) => [pluginName, r[0], r[1]] as const); - }); + // This represents a single plugin erroring out with a hard error. + errors.push(error); + // The plugin didn't return partial results, so we return an empty array. + return error.partialResults.map( + (r) => [pluginName, r[0], r[1]] as const + ); + }) + .finally(() => { + inProgressPlugins.delete(pluginName); + updateSpinner(); + }); results.push(r); } return Promise.all(results).then((results) => { + spinner?.cleanup(); + const { projectRootMap, externalNodes, rootMap, configurationSourceMaps } = mergeCreateNodesResults(results, nxJson, errors); diff --git a/packages/nx/src/utils/delayed-spinner.ts b/packages/nx/src/utils/delayed-spinner.ts new file mode 100644 index 0000000000000..78701d7207b95 --- /dev/null +++ b/packages/nx/src/utils/delayed-spinner.ts @@ -0,0 +1,65 @@ +import * as ora from 'ora'; + +/** + * A class that allows to delay the creation of a spinner, as well + * as schedule updates to the message of the spinner. Useful for + * scenarios where one wants to only show a spinner if an operation + * takes longer than a certain amount of time. + */ +export class DelayedSpinner { + spinner: ora.Ora; + timeouts: NodeJS.Timeout[] = []; + initial: number = Date.now(); + + /** + * Constructs a new {@link DelayedSpinner} instance. + * + * @param message The message to display in the spinner + * @param ms The number of milliseconds to wait before creating the spinner + */ + constructor(message: string, ms: number = 500) { + this.timeouts.push( + setTimeout(() => { + this.spinner = ora(message); + }, ms).unref() + ); + } + + /** + * Sets the message to display in the spinner. + * + * @param message The message to display in the spinner + * @returns The {@link DelayedSpinner} instance + */ + setMessage(message: string) { + this.spinner.text = message; + return this; + } + + /** + * Schedules an update to the message of the spinner. Useful for + * changing the message after a certain amount of time has passed. + * + * @param message The message to display in the spinner + * @param delay How long to wait before updating the message + * @returns The {@link DelayedSpinner} instance + */ + scheduleMessageUpdate(message: string, delay: number) { + this.timeouts.push( + setTimeout(() => { + this.spinner.text = message; + }, delay).unref() + ); + return this; + } + + /** + * Stops the spinner and cleans up any scheduled timeouts. + */ + cleanup() { + this.spinner?.stop(); + this.timeouts.forEach((t) => clearTimeout(t)); + } +} + +export const SHOULD_SHOW_SPINNERS = process.stdout.isTTY;