diff --git a/libs/native-federation-core/src/build.ts b/libs/native-federation-core/src/build.ts index 7dd03abe..090846ef 100644 --- a/libs/native-federation-core/src/build.ts +++ b/libs/native-federation-core/src/build.ts @@ -21,7 +21,7 @@ export { loadFederationConfig } from './lib/core/load-federation-config'; export { writeFederationInfo } from './lib/core/write-federation-info'; export { writeImportMap } from './lib/core/write-import-map'; export { MappedPath } from './lib/utils/mapped-paths'; - +export { RebuildQueue } from './lib/utils/rebuild-queue'; export { findRootTsConfigJson, share, @@ -33,4 +33,5 @@ export { } from './lib/core/federation-builder'; export * from './lib/utils/build-result-map'; export { hashFile } from './lib/utils/hash-file'; +export * from './lib/utils/errors'; export { logger, setLogLevel } from './lib/utils/logger'; diff --git a/libs/native-federation-core/src/lib/core/build-adapter.ts b/libs/native-federation-core/src/lib/core/build-adapter.ts index 44fc7fbf..b423f8af 100644 --- a/libs/native-federation-core/src/lib/core/build-adapter.ts +++ b/libs/native-federation-core/src/lib/core/build-adapter.ts @@ -32,6 +32,7 @@ export interface BuildAdapterOptions { hash: boolean; platform?: 'browser' | 'node'; optimizedMappings?: boolean; + signal?: AbortSignal; } export interface BuildResult { diff --git a/libs/native-federation-core/src/lib/core/build-for-federation.ts b/libs/native-federation-core/src/lib/core/build-for-federation.ts index b624c638..31558067 100644 --- a/libs/native-federation-core/src/lib/core/build-for-federation.ts +++ b/libs/native-federation-core/src/lib/core/build-for-federation.ts @@ -14,10 +14,12 @@ import { FederationOptions } from './federation-options'; import { writeFederationInfo } from './write-federation-info'; import { writeImportMap } from './write-import-map'; import { logger } from '../utils/logger'; +import { AbortedError } from '../utils/errors'; export interface BuildParams { skipMappingsAndExposed: boolean; skipShared: boolean; + signal?: AbortSignal; } export const defaultBuildParams: BuildParams = { @@ -35,6 +37,8 @@ export async function buildForFederation( externals: string[], buildParams = defaultBuildParams ): Promise { + const signal = buildParams.signal; + let artefactInfo: ArtefactInfo | undefined; if (!buildParams.skipMappingsAndExposed) { @@ -42,12 +46,18 @@ export async function buildForFederation( artefactInfo = await bundleExposedAndMappings( config, fedOptions, - externals + externals, + signal ); logger.measure( start, '[build artifacts] - To bundle all mappings and exposed.' ); + + if (signal?.aborted) + throw new AbortedError( + '[buildForFederation] After exposed-and-mappings bundle' + ); } const exposedInfo = !artefactInfo @@ -77,6 +87,10 @@ export async function buildForFederation( Object.keys(sharedBrowser).forEach((packageName) => cachedSharedPackages.add(packageName) ); + if (signal?.aborted) + throw new AbortedError( + '[buildForFederation] After shared-browser bundle' + ); } if (Object.keys(sharedServer).length > 0) { @@ -96,6 +110,8 @@ export async function buildForFederation( Object.keys(sharedServer).forEach((packageName) => cachedSharedPackages.add(packageName) ); + if (signal?.aborted) + throw new AbortedError('[buildForFederation] After shared-node bundle'); } if (Object.keys(separateBrowser).length > 0) { @@ -115,6 +131,10 @@ export async function buildForFederation( Object.keys(separateBrowser).forEach((packageName) => cachedSharedPackages.add(packageName) ); + if (signal?.aborted) + throw new AbortedError( + '[buildForFederation] After separate-browser bundle' + ); } if (Object.keys(separateServer).length > 0) { @@ -135,6 +155,9 @@ export async function buildForFederation( cachedSharedPackages.add(packageName) ); } + + if (signal?.aborted) + throw new AbortedError('[buildForFederation] After separate-node bundle'); } const sharedMappingInfo = !artefactInfo diff --git a/libs/native-federation-core/src/lib/core/bundle-exposed-and-mappings.ts b/libs/native-federation-core/src/lib/core/bundle-exposed-and-mappings.ts index 2087acae..bca837a4 100644 --- a/libs/native-federation-core/src/lib/core/bundle-exposed-and-mappings.ts +++ b/libs/native-federation-core/src/lib/core/bundle-exposed-and-mappings.ts @@ -11,6 +11,7 @@ import { bundle } from '../utils/build-utils'; import { logger } from '../utils/logger'; import { normalize } from '../utils/normalize'; import { FederationOptions } from './federation-options'; +import { AbortedError } from '../utils/errors'; export interface ArtefactInfo { mappings: SharedInfo[]; @@ -20,8 +21,15 @@ export interface ArtefactInfo { export async function bundleExposedAndMappings( config: NormalizedFederationConfig, fedOptions: FederationOptions, - externals: string[] + externals: string[], + signal?: AbortSignal ): Promise { + if (signal?.aborted) { + throw new AbortedError( + '[bundle-exposed-and-mappings] Aborted before bundling' + ); + } + const shared = config.sharedMappings.map((sm) => { const entryPoint = sm.path; const tmp = sm.key.replace(/[^A-Za-z0-9]/g, '_'); @@ -53,9 +61,17 @@ export async function bundleExposedAndMappings( kind: 'mapping-or-exposed', hash, optimizedMappings: config.features.ignoreUnusedDeps, + signal, }); + if (signal?.aborted) { + throw new AbortedError( + '[bundle-exposed-and-mappings] Aborted after bundle' + ); + } } catch (error) { - logger.error('Error building federation artefacts'); + if (!(error instanceof AbortedError)) { + logger.error('Error building federation artefacts'); + } throw error; } diff --git a/libs/native-federation-core/src/lib/utils/errors.ts b/libs/native-federation-core/src/lib/utils/errors.ts new file mode 100644 index 00000000..1f843ec4 --- /dev/null +++ b/libs/native-federation-core/src/lib/utils/errors.ts @@ -0,0 +1,7 @@ +export class AbortedError extends Error { + constructor(message: string) { + super(message); + this.name = 'AbortedError'; + Object.setPrototypeOf(this, AbortedError.prototype); + } +} diff --git a/libs/native-federation-core/src/lib/utils/rebuild-queue.ts b/libs/native-federation-core/src/lib/utils/rebuild-queue.ts new file mode 100644 index 00000000..3587487e --- /dev/null +++ b/libs/native-federation-core/src/lib/utils/rebuild-queue.ts @@ -0,0 +1,46 @@ +import { logger } from './logger'; + +export class RebuildQueue { + private activeBuilds: Map = new Map(); + private buildCounter = 0; + + async enqueue(rebuildFn: () => Promise): Promise { + const buildId = ++this.buildCounter; + + for (const [id, controller] of this.activeBuilds) { + controller.abort(); + } + if (this.activeBuildCount > 0) + logger.debug( + `Aborted ${this.activeBuildCount} previous bundling task(s)` + ); + this.activeBuilds.clear(); + + const controller = new AbortController(); + this.activeBuilds.set(buildId, controller); + + try { + await rebuildFn(); + } finally { + this.activeBuilds.delete(buildId); + } + } + + abort(): void { + for (const [_, controller] of this.activeBuilds) { + controller.abort(); + } + this.activeBuilds.clear(); + } + + get signal(): AbortSignal | undefined { + if (this.activeBuilds.size === 0) return undefined; + + const latestBuildId = Math.max(...this.activeBuilds.keys()); + return this.activeBuilds.get(latestBuildId)?.signal; + } + + get activeBuildCount(): number { + return this.activeBuilds.size; + } +} diff --git a/libs/native-federation/src/builders/build/builder.ts b/libs/native-federation/src/builders/build/builder.ts index 95abc0fa..5d7cfacf 100644 --- a/libs/native-federation/src/builders/build/builder.ts +++ b/libs/native-federation/src/builders/build/builder.ts @@ -25,6 +25,8 @@ import { logger, setBuildAdapter, setLogLevel, + RebuildQueue, + AbortedError, } from '@softarc/native-federation/build'; import { createAngularBuildAdapter, @@ -373,8 +375,9 @@ export async function* runBuilder( indexHtmlTransformer: transformIndexHtml(nfOptions), }); + const rebuildQueue = new RebuildQueue(); + try { - // builderRun.output.subscribe(async (output) => { for await (const output of builderRun) { lastResult = output; @@ -399,8 +402,29 @@ export async function* runBuilder( // } if (!first && (nfOptions.dev || watch)) { - setTimeout(async () => { - try { + rebuildQueue + .enqueue(async () => { + const signal = rebuildQueue.signal; + if (signal?.aborted) { + throw new AbortedError('Build canceled before starting'); + } + + await new Promise((resolve, reject) => { + const timeout = setTimeout(resolve, nfOptions.rebuildDelay); + + if (signal) { + const abortHandler = () => { + clearTimeout(timeout); + reject(new AbortedError('[builder] During delay.')); + }; + signal.addEventListener('abort', abortHandler, { once: true }); + } + }); + + if (signal?.aborted) { + throw new AbortedError('[builder] Before federation build.'); + } + const start = process.hrtime(); federationResult = await buildForFederation( config, @@ -409,9 +433,14 @@ export async function* runBuilder( { skipMappingsAndExposed: false, skipShared: true, + signal, } ); + if (signal?.aborted) { + throw new AbortedError('[builder] After federation build.'); + } + if (hasLocales && localeFilter) { translateFederationArtefacts( i18n, @@ -421,27 +450,39 @@ export async function* runBuilder( ); } + if (signal?.aborted) { + throw new AbortedError( + '[builder] After federation translations.' + ); + } + logger.info('Done!'); - // Notifies about build completion if (isLocalDevelopment) { federationBuildNotifier.broadcastBuildCompletion(); } - logger.measure(start, 'To rebuild nf.'); - } catch (error) { - logger.error('Federation rebuild failed!'); - - // Notifies about build failure - if (isLocalDevelopment) { - federationBuildNotifier.broadcastBuildError(error); + logger.measure(start, 'To rebuild the federation artifacts.'); + }) + .catch((error) => { + if (error instanceof AbortedError) { + logger.warn('Rebuild was canceled.'); + if (options.verbose) + logger.warn('Cancellation point: ' + error?.message); + } else { + logger.error('Federation rebuild failed!'); + if (options.verbose) console.error(error); + if (isLocalDevelopment) { + federationBuildNotifier.broadcastBuildError(error); + } } - } - }, nfOptions.rebuildDelay); + }); } first = false; } } finally { + rebuildQueue.abort(); + if (isLocalDevelopment) { federationBuildNotifier.stopEventServer(); } diff --git a/libs/native-federation/src/utils/angular-esbuild-adapter.ts b/libs/native-federation/src/utils/angular-esbuild-adapter.ts index 42efcb0a..917b2671 100644 --- a/libs/native-federation/src/utils/angular-esbuild-adapter.ts +++ b/libs/native-federation/src/utils/angular-esbuild-adapter.ts @@ -1,4 +1,5 @@ import { + AbortedError, BuildAdapter, logger, MappedPath, @@ -71,6 +72,7 @@ export function createAngularBuildAdapter( hash, platform, optimizedMappings, + signal, } = options; setNgServerMode(); @@ -92,7 +94,8 @@ export function createAngularBuildAdapter( undefined, undefined, platform, - optimizedMappings + optimizedMappings, + signal ); if (kind === 'shared-package') { @@ -191,8 +194,13 @@ async function runEsbuild( absWorkingDir: string | undefined = undefined, logLevel: esbuild.LogLevel = 'warning', platform?: 'browser' | 'node', - optimizedMappings?: boolean + optimizedMappings?: boolean, + signal?: AbortSignal ) { + if (signal?.aborted) { + throw new AbortedError('[angular-esbuild-adapter] Before building'); + } + const projectRoot = path.dirname(tsConfigPath); const browsers = getSupportedBrowsers(projectRoot, context.logger as any); const target = transformSupportedBrowsersToTargets(browsers); @@ -304,27 +312,49 @@ async function runEsbuild( }; const ctx = await esbuild.context(config); - const result = await ctx.rebuild(); - const memOnly = dev && kind === 'mapping-or-exposed' && !!_memResultHandler; + const abortHandler = () => { + ctx.cancel(); + ctx.dispose(); + }; - const writtenFiles = writeResult(result, outdir, memOnly); + if (signal) { + signal.addEventListener('abort', abortHandler, { once: true }); + } - if (watch) { - registerForRebuilds( - kind, - rebuildRequested, - ctx, - entryPoints, - outdir, - hash, - memOnly - ); - } else { + try { + const result = await ctx.rebuild(); + + if (signal?.aborted) { + throw new AbortedError('[angular-esbuild-adapter] After building.'); + } + + const memOnly = dev && kind === 'mapping-or-exposed' && !!_memResultHandler; + + const writtenFiles = writeResult(result, outdir, memOnly); + + if (watch) { + registerForRebuilds( + kind, + rebuildRequested, + ctx, + entryPoints, + outdir, + hash, + memOnly + ); + } else { + ctx.dispose(); + if (signal) signal.removeEventListener('abort', abortHandler); + } + return writtenFiles; + } catch (error) { ctx.dispose(); + if (signal?.aborted && error?.message?.includes('canceled')) { + throw new AbortedError('[runEsbuild] ESBuild was canceled.'); + } + throw error; } - - return writtenFiles; } async function getTailwindConfig(