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 daf8c067..b29cf30a 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 253ab8c9..5e367413 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) { @@ -43,11 +47,17 @@ export async function buildForFederation( config, fedOptions, 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 77620434..3906d1fe 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[]; @@ -21,7 +22,14 @@ export async function bundleExposedAndMappings( config: NormalizedFederationConfig, fedOptions: FederationOptions, 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..a2023cf9 --- /dev/null +++ b/libs/native-federation-core/src/lib/utils/rebuild-queue.ts @@ -0,0 +1,60 @@ +import { logger } from './logger'; + +interface BuildControl { + controller: AbortController; + buildFinished: { resolve: () => void; promise: Promise }; +} + +export class RebuildQueue { + private activeBuilds: Map = new Map(); + private buildCounter = 0; + + async enqueue( + rebuildFn: (signal: AbortSignal) => Promise, + ): Promise { + const buildId = ++this.buildCounter; + + const pendingCancellations = Array.from(this.activeBuilds.values()).map( + (buildInfo) => { + buildInfo.controller.abort(); + return buildInfo.buildFinished.promise; + }, + ); + + if (pendingCancellations.length > 0) { + logger.info(`Aborting ${pendingCancellations.length} bundling task(s)..`); + } + + if (pendingCancellations.length > 0) { + await Promise.all(pendingCancellations); + } + + let buildFinished: () => void; + const completionPromise = new Promise((resolve) => { + buildFinished = resolve; + }); + + const control: BuildControl = { + controller: new AbortController(), + buildFinished: { + resolve: buildFinished!, + promise: completionPromise, + }, + }; + this.activeBuilds.set(buildId, control); + + try { + await rebuildFn(control.controller.signal); + } finally { + control.buildFinished.resolve(); + this.activeBuilds.delete(buildId); + } + } + + dispose(): void { + for (const [_, buildInfo] of this.activeBuilds) { + buildInfo.controller.abort(); + } + this.activeBuilds.clear(); + } +} diff --git a/libs/native-federation-node/src/lib/utils/fstart.mjs b/libs/native-federation-node/src/lib/utils/fstart.mjs index a007a647..eed7a01f 100644 --- a/libs/native-federation-node/src/lib/utils/fstart.mjs +++ b/libs/native-federation-node/src/lib/utils/fstart.mjs @@ -198,7 +198,9 @@ function resolveAndComposeImportMap(parsed) { ); if (invalidKeys.length > 0) { console.warn( - `Invalid top-level key${invalidKeys.length > 0 ? 's' : ''} in import map - ${invalidKeys.join(', ')}`, + `Invalid top-level key${ + invalidKeys.length > 0 ? 's' : '' + } in import map - ${invalidKeys.join(', ')}`, ); } return { diff --git a/libs/native-federation-runtime/src/lib/model/build-notifications-options.ts b/libs/native-federation-runtime/src/lib/model/build-notifications-options.ts index 0da24f2e..abbac525 100644 --- a/libs/native-federation-runtime/src/lib/model/build-notifications-options.ts +++ b/libs/native-federation-runtime/src/lib/model/build-notifications-options.ts @@ -9,4 +9,5 @@ export const BUILD_NOTIFICATIONS_ENDPOINT = export enum BuildNotificationType { COMPLETED = 'federation-rebuild-complete', ERROR = 'federation-rebuild-error', + CANCELLED = 'federation-rebuild-cancelled', } diff --git a/libs/native-federation/src/builders/build/builder.ts b/libs/native-federation/src/builders/build/builder.ts index 0e4a6ef5..41964495 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,31 @@ export async function* runBuilder( // } if (!first && (nfOptions.dev || watch)) { - setTimeout(async () => { - try { + rebuildQueue + .enqueue(async (signal: AbortSignal) => { + if (signal?.aborted) { + throw new AbortedError('Build canceled before starting'); + } + + await new Promise((resolve, reject) => { + const timeout = setTimeout( + resolve, + Math.max(10, 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 +435,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 +452,40 @@ 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.verbose( + 'Rebuild was canceled. Cancellation point: ' + error?.message, + ); + federationBuildNotifier.broadcastBuildCancellation(); + } else { + logger.error('Federation rebuild failed!'); + if (options.verbose) console.error(error); + if (isLocalDevelopment) { + federationBuildNotifier.broadcastBuildError(error); + } } - } - }, nfOptions.rebuildDelay); + }); } first = false; } } finally { + rebuildQueue.dispose(); + if (isLocalDevelopment) { federationBuildNotifier.stopEventServer(); } diff --git a/libs/native-federation/src/builders/build/federation-build-notifier.ts b/libs/native-federation/src/builders/build/federation-build-notifier.ts index 75ead08a..c2426f9f 100644 --- a/libs/native-federation/src/builders/build/federation-build-notifier.ts +++ b/libs/native-federation/src/builders/build/federation-build-notifier.ts @@ -192,6 +192,16 @@ export class FederationBuildNotifier { }); } + /** + * Notifies about cancellation of a federation rebuild + */ + public broadcastBuildCancellation(): void { + this._broadcastEvent({ + type: BuildNotificationType.CANCELLED, + timestamp: Date.now(), + }); + } + /** * Notifies about failed federation rebuild */ diff --git a/libs/native-federation/src/schematics/init/schematic.ts b/libs/native-federation/src/schematics/init/schematic.ts index 7eceb8a2..12f4d96f 100644 --- a/libs/native-federation/src/schematics/init/schematic.ts +++ b/libs/native-federation/src/schematics/init/schematic.ts @@ -300,7 +300,7 @@ function updateWorkspaceConfig( builder: '@angular-architects/native-federation:build', options: { target: `${projectName}:serve-original:development`, - rebuildDelay: 0, + rebuildDelay: 500, dev: true, port: 0, }, diff --git a/libs/native-federation/src/utils/angular-esbuild-adapter.ts b/libs/native-federation/src/utils/angular-esbuild-adapter.ts index f0fcc307..eae4bc89 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(); @@ -93,6 +95,7 @@ export function createAngularBuildAdapter( undefined, platform, optimizedMappings, + signal, ); if (kind === 'shared-package') { @@ -192,7 +195,12 @@ async function runEsbuild( logLevel: esbuild.LogLevel = 'warning', platform?: 'browser' | 'node', 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,46 @@ async function runEsbuild( }; const ctx = await esbuild.context(config); - const result = await ctx.rebuild(); - const memOnly = dev && kind === 'mapping-or-exposed' && !!_memResultHandler; + try { + const abortHandler = async () => { + await ctx.cancel(); + await 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 { - ctx.dispose(); - } + const result = await ctx.rebuild(); - return writtenFiles; + 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 { + if (signal) signal.removeEventListener('abort', abortHandler); + await ctx.dispose(); + } + return writtenFiles; + } catch (error) { + // ESBuild throws an error if the request is cancelled. + // if it is, it's changed to an 'AbortedError' + if (signal?.aborted && error?.message?.includes('canceled')) { + throw new AbortedError('[runEsbuild] ESBuild was canceled.'); + } + throw error; + } } async function getTailwindConfig( @@ -406,14 +433,6 @@ function doesFileExistAndJsonEqual(path: string, content: string) { } } -function doesFileExist(path: string, content: string): boolean { - if (!fs.existsSync(path)) { - return false; - } - const currentContent = fs.readFileSync(path, 'utf-8'); - return currentContent === content; -} - function writeResult( result: esbuild.BuildResult, outdir: string,