From 65ab23bd92bc02d703238f5983fb665cd04d7f45 Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Wed, 5 Aug 2026 18:59:23 +0200 Subject: [PATCH 1/2] perf(workspace-plugin): reuse a single api-extractor compiler state in generate-api --- .../executors/generate-api/executor.spec.ts | 38 ++++++ .../src/executors/generate-api/executor.ts | 108 +++++++++++------- 2 files changed, 106 insertions(+), 40 deletions(-) diff --git a/tools/workspace-plugin/src/executors/generate-api/executor.spec.ts b/tools/workspace-plugin/src/executors/generate-api/executor.spec.ts index 64c701c30ea576..66b4c2e355affc 100644 --- a/tools/workspace-plugin/src/executors/generate-api/executor.spec.ts +++ b/tools/workspace-plugin/src/executors/generate-api/executor.spec.ts @@ -1,6 +1,8 @@ import { type ExecutorContext, serializeJson } from '@nx/devkit'; import { + CompilerState, Extractor, + type ICompilerStateCreateOptions, type IExtractorInvokeOptions, type ExtractorConfig, type ExtractorResult, @@ -40,6 +42,14 @@ const _context: ExecutorContext = { const execSyncMock = execSync as jest.Mock; +// The real thing would build a TS program over the fixtures, which the mocked `Extractor.invoke` never reads. +const compilerStateStub = { program: {} } as unknown as CompilerState; +let compilerStateCreateSpy: jest.SpyInstance; + +beforeEach(() => { + compilerStateCreateSpy = jest.spyOn(CompilerState, 'create').mockReturnValue(compilerStateStub); +}); + function cleanup() { // Remove all contents of the fixtures directory but keep the directory itself const entries = readdirSync(fixturesRootDir, { withFileTypes: true }); @@ -192,6 +202,7 @@ describe('GenerateApi Executor', () => { const actualLocalBuildValue = isCI() ? false : true; expect(extractorArgs).toEqual({ + compilerState: compilerStateStub, localBuild: actualLocalBuildValue, showDiagnostics: false, showVerboseMessages: true, @@ -224,6 +235,7 @@ describe('GenerateApi Executor', () => { expect(extractorConfig).toEqual(expect.any(Object)); expect(extractorArgs).toEqual({ + compilerState: compilerStateStub, localBuild: false, showDiagnostics: true, showVerboseMessages: true, @@ -492,4 +504,30 @@ describe('GenerateApi Executor – export subpath resolution', () => { expect(ExtractorInvokeSpy).toHaveBeenCalledTimes(1 + 1 + subDirs.length); expect(output.success).toBe(true); }); + + it('creates one compiler state for all entry points and reuses it for every invocation', async () => { + const subDirs = ['alpha', 'beta']; + const { context } = prepareExportFixture({ wildcardSubDirs: subDirs, namedExports: ['utils'] }); + + const capturedConfigs: ExtractorConfig[] = []; + const capturedStates: (CompilerState | undefined)[] = []; + jest.spyOn(Extractor, 'invoke').mockImplementation((cfg, invokeOptions) => { + capturedConfigs.push(cfg); + capturedStates.push(invokeOptions?.compilerState); + return { succeeded: true } as ExtractorResult; + }); + + const output = await executor({ ...options, exportSubpaths: true }, context); + + expect(compilerStateCreateSpy).toHaveBeenCalledTimes(1); + + const [primaryConfig, createOptions] = compilerStateCreateSpy.mock.calls[0]; + expect(primaryConfig).toBe(capturedConfigs[0]); + expect(createOptions?.additionalEntryPoints).toEqual( + capturedConfigs.slice(1).map(cfg => cfg.mainEntryPointFilePath), + ); + + expect(capturedStates).toEqual(new Array(1 + 1 + subDirs.length).fill(compilerStateStub)); + expect(output.success).toBe(true); + }); }); diff --git a/tools/workspace-plugin/src/executors/generate-api/executor.ts b/tools/workspace-plugin/src/executors/generate-api/executor.ts index bf0961e8f62cd4..b0b19b36218361 100644 --- a/tools/workspace-plugin/src/executors/generate-api/executor.ts +++ b/tools/workspace-plugin/src/executors/generate-api/executor.ts @@ -2,7 +2,7 @@ import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { execSync } from 'node:child_process'; import { type ExecutorContext, type PromiseExecutor, logger, parseJson } from '@nx/devkit'; -import { Extractor, ExtractorConfig, type IConfigFile } from '@microsoft/api-extractor'; +import { CompilerState, Extractor, ExtractorConfig, type IConfigFile } from '@microsoft/api-extractor'; import type { GenerateApiExecutorSchema } from './schema'; import type { PackageJson, TsConfig } from '../../types'; @@ -28,30 +28,49 @@ export default runExecutor; export interface NormalizedOptions extends ReturnType {} +type ConfigSource = { configPath: string } | { configObject: IConfigFile }; + async function runGenerateApi(options: NormalizedOptions, context: ExecutorContext): Promise { if (!generateTypeDeclarations(options)) { return false; } - // Run primary api-extractor config - if (!apiExtractor({ configPath: options.config }, options, context)) { - return false; - } + const configSources: ConfigSource[] = [{ configPath: options.config }]; - // Expand export subpaths and run api-extractor for each resolved entry + // Expand export subpaths into one api-extractor config per resolved entry if (options.exportSubpaths.enabled) { - const subpathConfigs = getExportSubpathConfigs(options); - for (const configObject of subpathConfigs) { - verboseLog(`Running api-extractor for export subpath entry: ${configObject.mainEntryPointFilePath}`); - if (!apiExtractor({ configObject }, options, context)) { - return false; - } + for (const configObject of getExportSubpathConfigs(options)) { + verboseLog(`Resolved api-extractor config for export subpath entry: ${configObject.mainEntryPointFilePath}`); + configSources.push({ configObject }); + } + } + + const extractorConfigs = configSources.map(configSource => prepareExtractorConfig(configSource, options)); + const compilerState = createCompilerState(extractorConfigs); + + for (const extractorConfig of extractorConfigs) { + if (!invokeExtractor(extractorConfig, compilerState, options, context)) { + return false; } } return true; } +/** + * Every config compiles with the same tsconfig, so one TS program can serve all entry points + * instead of api-extractor creating a new one per invocation. + */ +function createCompilerState(extractorConfigs: ExtractorConfig[]): CompilerState { + const [primaryConfig, ...subpathConfigs] = extractorConfigs; + + verboseLog(`Creating shared api-extractor compiler state for ${extractorConfigs.length} entry point(s)`); + + return CompilerState.create(primaryConfig, { + additionalEntryPoints: subpathConfigs.map(config => config.mainEntryPointFilePath), + }); +} + function normalizeOptions(schema: GenerateApiExecutorSchema, context: ExecutorContext) { const defaults = { config: '{projectRoot}/config/api-extractor.json', @@ -116,42 +135,20 @@ function generateTypeDeclarations(options: NormalizedOptions) { } } -function apiExtractor( - configSource: { configPath: string } | { configObject: IConfigFile }, - options: NormalizedOptions, - context: ExecutorContext, -) { +/** + * Loads, parses, customizes and prepares the api-extractor config for the API Extractor API. + */ +function prepareExtractorConfig(configSource: ConfigSource, options: NormalizedOptions): ExtractorConfig { const { rawConfig, fullPath } = resolveConfigSource(); - // Load,parse,customize and prepare the api-extractor.json file for API Extractor API customizeExtractorConfig(rawConfig); - const extractorConfig = ExtractorConfig.prepare({ + + return ExtractorConfig.prepare({ configObject: rawConfig, configObjectFullPath: fullPath, packageJsonFullPath: options.packageJsonPath, }); - // Invoke API Extractor - const extractorResult = Extractor.invoke(extractorConfig, { - // Equivalent to the "--local" command-line parameter - localBuild: options.local, - - // Equivalent to the "--verbose" command-line parameter - showVerboseMessages: context.isVerbose, - showDiagnostics: options.diagnostics, - }); - - if (extractorResult.succeeded) { - verboseLog(`API Extractor completed successfully`); - return true; - } - - logger.error( - `API Extractor completed with ${extractorResult.errorCount} errors` + - ` and ${extractorResult.warningCount} warnings`, - ); - return false; - /** * Resolves the config source into a raw IConfigFile and the full path used for token resolution. * File-based sources are loaded from disk; programmatic configs reuse the primary config path. @@ -182,6 +179,37 @@ function apiExtractor( } } +function invokeExtractor( + extractorConfig: ExtractorConfig, + compilerState: CompilerState, + options: NormalizedOptions, + context: ExecutorContext, +) { + verboseLog(`Running api-extractor for entry point: ${extractorConfig.mainEntryPointFilePath}`); + + const extractorResult = Extractor.invoke(extractorConfig, { + compilerState, + + // Equivalent to the "--local" command-line parameter + localBuild: options.local, + + // Equivalent to the "--verbose" command-line parameter + showVerboseMessages: context.isVerbose, + showDiagnostics: options.diagnostics, + }); + + if (extractorResult.succeeded) { + verboseLog(`API Extractor completed successfully`); + return true; + } + + logger.error( + `API Extractor completed with ${extractorResult.errorCount} errors` + + ` and ${extractorResult.warningCount} warnings`, + ); + return false; +} + function getTsConfigForApiExtractor(options: { tsConfig: TsConfig; packageJson: PackageJson; From 6a5e66f531c00d997d759a9a5097198db368afaf Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Wed, 5 Aug 2026 21:19:27 +0200 Subject: [PATCH 2/2] chore(workspace-plugin): dedupe api-extractor console preamble and log per-entry progress --- .../executors/generate-api/executor.spec.ts | 34 +++++++++ .../src/executors/generate-api/executor.ts | 75 +++++++++++++++++-- 2 files changed, 101 insertions(+), 8 deletions(-) diff --git a/tools/workspace-plugin/src/executors/generate-api/executor.spec.ts b/tools/workspace-plugin/src/executors/generate-api/executor.spec.ts index 66b4c2e355affc..493f70ba1f7758 100644 --- a/tools/workspace-plugin/src/executors/generate-api/executor.spec.ts +++ b/tools/workspace-plugin/src/executors/generate-api/executor.spec.ts @@ -1,10 +1,12 @@ import { type ExecutorContext, serializeJson } from '@nx/devkit'; import { CompilerState, + ConsoleMessageId, Extractor, type ICompilerStateCreateOptions, type IExtractorInvokeOptions, type ExtractorConfig, + type ExtractorMessage, type ExtractorResult, } from '@microsoft/api-extractor'; import { basename, join } from 'node:path'; @@ -203,6 +205,7 @@ describe('GenerateApi Executor', () => { expect(extractorArgs).toEqual({ compilerState: compilerStateStub, + messageCallback: expect.any(Function), localBuild: actualLocalBuildValue, showDiagnostics: false, showVerboseMessages: true, @@ -236,6 +239,7 @@ describe('GenerateApi Executor', () => { expect(extractorConfig).toEqual(expect.any(Object)); expect(extractorArgs).toEqual({ compilerState: compilerStateStub, + messageCallback: expect.any(Function), localBuild: false, showDiagnostics: true, showVerboseMessages: true, @@ -530,4 +534,34 @@ describe('GenerateApi Executor – export subpath resolution', () => { expect(capturedStates).toEqual(new Array(1 + 1 + subDirs.length).fill(compilerStateStub)); expect(output.success).toBe(true); }); + + it('reports repeated api-extractor console notices only once across invocations', async () => { + const { context } = prepareExportFixture({ namedExports: ['utils'] }); + + const capturedCallbacks: NonNullable[] = []; + jest.spyOn(Extractor, 'invoke').mockImplementation((_config, invokeOptions) => { + capturedCallbacks.push(invokeOptions!.messageCallback!); + return { succeeded: true } as ExtractorResult; + }); + + await executor({ ...options, exportSubpaths: true }, context); + + expect(capturedCallbacks).toHaveLength(2); + expect(capturedCallbacks[0]).toBe(capturedCallbacks[1]); + + const messages = { + firstPreamble: { messageId: ConsoleMessageId.Preamble, handled: false } as ExtractorMessage, + secondPreamble: { messageId: ConsoleMessageId.Preamble, handled: false } as ExtractorMessage, + apiReport: { messageId: ConsoleMessageId.ApiReportCopied, handled: false } as ExtractorMessage, + }; + + capturedCallbacks[0](messages.firstPreamble); + capturedCallbacks[1](messages.secondPreamble); + capturedCallbacks[1](messages.apiReport); + + expect(messages.firstPreamble.handled).toBe(false); + expect(messages.secondPreamble.handled).toBe(true); + // unrelated console messages keep api-extractor's default handling + expect(messages.apiReport.handled).toBe(false); + }); }); diff --git a/tools/workspace-plugin/src/executors/generate-api/executor.ts b/tools/workspace-plugin/src/executors/generate-api/executor.ts index b0b19b36218361..e90bc374a3979b 100644 --- a/tools/workspace-plugin/src/executors/generate-api/executor.ts +++ b/tools/workspace-plugin/src/executors/generate-api/executor.ts @@ -1,8 +1,15 @@ import { existsSync, readFileSync } from 'node:fs'; -import { join } from 'node:path'; +import { join, relative } from 'node:path'; import { execSync } from 'node:child_process'; import { type ExecutorContext, type PromiseExecutor, logger, parseJson } from '@nx/devkit'; -import { CompilerState, Extractor, ExtractorConfig, type IConfigFile } from '@microsoft/api-extractor'; +import { + CompilerState, + ConsoleMessageId, + Extractor, + ExtractorConfig, + type ExtractorMessage, + type IConfigFile, +} from '@microsoft/api-extractor'; import type { GenerateApiExecutorSchema } from './schema'; import type { PackageJson, TsConfig } from '../../types'; @@ -47,9 +54,21 @@ async function runGenerateApi(options: NormalizedOptions, context: ExecutorConte const extractorConfigs = configSources.map(configSource => prepareExtractorConfig(configSource, options)); const compilerState = createCompilerState(extractorConfigs); - - for (const extractorConfig of extractorConfigs) { - if (!invokeExtractor(extractorConfig, compilerState, options, context)) { + const messageCallback = createConsoleMessageDeduper(); + + for (const [index, extractorConfig] of extractorConfigs.entries()) { + const invoked = invokeExtractor( + { + extractorConfig, + compilerState, + messageCallback, + progress: { current: index + 1, total: extractorConfigs.length }, + }, + options, + context, + ); + + if (!invoked) { return false; } } @@ -57,6 +76,27 @@ async function runGenerateApi(options: NormalizedOptions, context: ExecutorConte return true; } +/** + * api-extractor repeats its compiler version notices on every invocation, so keep only the first of each. + */ +function createConsoleMessageDeduper() { + const dedupedMessageIds: string[] = [ConsoleMessageId.Preamble, ConsoleMessageId.CompilerVersionNotice]; + const alreadyReported = new Set(); + + return (message: ExtractorMessage) => { + if (!dedupedMessageIds.includes(message.messageId)) { + return; + } + + if (alreadyReported.has(message.messageId)) { + message.handled = true; + return; + } + + alreadyReported.add(message.messageId); + }; +} + /** * Every config compiles with the same tsconfig, so one TS program can serve all entry points * instead of api-extractor creating a new one per invocation. @@ -180,15 +220,22 @@ function prepareExtractorConfig(configSource: ConfigSource, options: NormalizedO } function invokeExtractor( - extractorConfig: ExtractorConfig, - compilerState: CompilerState, + params: { + extractorConfig: ExtractorConfig; + compilerState: CompilerState; + messageCallback: (message: ExtractorMessage) => void; + progress: { current: number; total: number }; + }, options: NormalizedOptions, context: ExecutorContext, ) { - verboseLog(`Running api-extractor for entry point: ${extractorConfig.mainEntryPointFilePath}`); + const { extractorConfig, compilerState, messageCallback, progress } = params; + + logEntryPoint(); const extractorResult = Extractor.invoke(extractorConfig, { compilerState, + messageCallback, // Equivalent to the "--local" command-line parameter localBuild: options.local, @@ -208,6 +255,18 @@ function invokeExtractor( ` and ${extractorResult.warningCount} warnings`, ); return false; + + function logEntryPoint() { + const outputPath = extractorConfig.untrimmedFilePath || extractorConfig.mainEntryPointFilePath; + const label = relative(options.projectAbsolutePath, outputPath); + + if (progress.total === 1) { + verboseLog(`Generating API for ${label}`); + return; + } + + logger.info(`[${progress.current}/${progress.total}] Generating API for ${label}`); + } } function getTsConfigForApiExtractor(options: {