From 1523398e37daf19fa67d7b3bfc3161f8dbd71191 Mon Sep 17 00:00:00 2001 From: Maruthan G Date: Thu, 30 Apr 2026 18:56:22 +0530 Subject: [PATCH] feat(build): support copying library assets in monorepo builds add a new opt-in flag (compileroptions.includelibraryassets and --include-library-assets cli flag) that, when enabled, copies assets from sibling library projects in a nest monorepo to the app's outdir. default behavior is unchanged. closes #681 --- actions/build.action.ts | 36 ++- commands/build.command.ts | 9 + lib/compiler/helpers/get-value-or-default.ts | 3 +- lib/configuration/configuration.ts | 6 + test/actions/build.action.spec.ts | 318 +++++++++++++++++++ test/lib/compiler/assets-manager.spec.ts | 152 +++++++++ 6 files changed, 520 insertions(+), 4 deletions(-) create mode 100644 test/actions/build.action.spec.ts create mode 100644 test/lib/compiler/assets-manager.spec.ts diff --git a/actions/build.action.ts b/actions/build.action.ts index ab14235ce..daa13b322 100644 --- a/actions/build.action.ts +++ b/actions/build.action.ts @@ -128,6 +128,37 @@ export class BuildAction extends AbstractAction { watchAssetsMode, ); + const includeLibraryAssets = getValueOrDefault( + configuration, + 'compilerOptions.includeLibraryAssets', + appName, + 'includeLibraryAssets', + commandOptions, + ); + if (includeLibraryAssets && configuration.projects) { + for (const [projectName, project] of Object.entries( + configuration.projects, + )) { + if (projectName === appName) { + continue; + } + if ( + project && + project.type === 'library' && + project.compilerOptions && + Array.isArray(project.compilerOptions.assets) && + project.compilerOptions.assets.length > 0 + ) { + this.assetsManager.copyAssets( + configuration, + projectName, + outDir, + watchAssetsMode, + ); + } + } + } + const typeCheck = getValueOrDefault( configuration, 'compilerOptions.typeCheck', @@ -220,9 +251,8 @@ export class BuildAction extends AbstractAction { watchMode: boolean, onSuccess: (() => void) | undefined, ) { - const { WebpackCompiler } = await import( - '../lib/compiler/webpack-compiler' - ); + const { WebpackCompiler } = + await import('../lib/compiler/webpack-compiler'); const webpackCompiler = new WebpackCompiler(this.pluginsLoader); const webpackPath = diff --git a/commands/build.command.ts b/commands/build.command.ts index 65de4053b..f27661bb1 100644 --- a/commands/build.command.ts +++ b/commands/build.command.ts @@ -24,6 +24,10 @@ export class BuildCommand extends AbstractCommand { 'Use "preserveWatchOutput" option when using tsc watch mode.', ) .option('--all', 'Build all projects in a monorepo.') + .option( + '--include-library-assets', + 'Also copy assets from library projects when building an app in a monorepo.', + ) .description('Build Nest application.') .action(async (apps: string[], command: Command) => { const options: Input[] = []; @@ -76,6 +80,11 @@ export class BuildCommand extends AbstractCommand { options.push({ name: 'all', value: !!command.all }); + options.push({ + name: 'includeLibraryAssets', + value: command.includeLibraryAssets, + }); + const inputs: Input[] = apps.map((app) => ({ name: 'app', value: app, diff --git a/lib/compiler/helpers/get-value-or-default.ts b/lib/compiler/helpers/get-value-or-default.ts index 9b0b71c14..c6d6aa9bd 100644 --- a/lib/compiler/helpers/get-value-or-default.ts +++ b/lib/compiler/helpers/get-value-or-default.ts @@ -13,7 +13,8 @@ export function getValueOrDefault( | 'sourceRoot' | 'exec' | 'builder' - | 'typeCheck', + | 'typeCheck' + | 'includeLibraryAssets', options: Input[] = [], defaultValue?: T, ): T { diff --git a/lib/configuration/configuration.ts b/lib/configuration/configuration.ts index 8508a4357..24c44ffd5 100644 --- a/lib/configuration/configuration.ts +++ b/lib/configuration/configuration.ts @@ -66,6 +66,12 @@ export interface CompilerOptions { deleteOutDir?: boolean; manualRestart?: boolean; builder?: Builder; + /** + * When building an application in a monorepo, also copy assets configured + * for sibling library projects into the application's output directory. + * Disabled by default to preserve backward compatibility. + */ + includeLibraryAssets?: boolean; } export interface PluginOptions { diff --git a/test/actions/build.action.spec.ts b/test/actions/build.action.spec.ts new file mode 100644 index 000000000..71486f407 --- /dev/null +++ b/test/actions/build.action.spec.ts @@ -0,0 +1,318 @@ +import { BuildAction } from '../../actions/build.action'; +import { Input } from '../../commands'; +import { AssetsManager } from '../../lib/compiler/assets-manager'; +import { Configuration } from '../../lib/configuration'; + +jest.mock('../../lib/compiler/helpers/get-tsc-config.path', () => ({ + getTscConfigPath: jest.fn(() => 'tsconfig.build.json'), +})); + +jest.mock('../../lib/compiler/helpers/get-builder', () => ({ + getBuilder: jest.fn(() => ({ type: 'tsc' })), +})); + +jest.mock('../../lib/compiler/helpers/delete-out-dir', () => ({ + deleteOutDirIfEnabled: jest.fn(async () => undefined), +})); + +jest.mock('../../lib/compiler/helpers/tsconfig-provider', () => ({ + TsConfigProvider: jest.fn().mockImplementation(() => ({ + getByConfigFilename: jest.fn(() => ({ + options: { outDir: 'dist' }, + })), + })), +})); + +const mockCompilerRun = jest.fn(); +jest.mock('../../lib/compiler/compiler', () => ({ + Compiler: jest.fn().mockImplementation(() => ({ + run: mockCompilerRun, + })), +})); + +class TestableBuildAction extends BuildAction { + public configurationOverride!: Required; + public copyAssetsCalls: Array<{ + appName: string | undefined; + outDir: string; + }> = []; + + constructor() { + super(); + // Override the loader to return our injected configuration. + (this as any).loader = { + load: async () => this.configurationOverride, + }; + // Replace the assets manager with a spy. + const fakeAssetsManager = { + copyAssets: ( + _config: Required, + appName: string | undefined, + outDir: string, + ) => { + this.copyAssetsCalls.push({ appName, outDir }); + }, + closeWatchers: jest.fn(), + } as unknown as AssetsManager; + (this as any).assetsManager = fakeAssetsManager; + } +} + +const baseConfig = ( + overrides: Partial = {}, +): Required => ({ + language: 'ts', + sourceRoot: 'apps/api/src', + collection: '@nestjs/schematics', + entryFile: 'main', + exec: 'node', + monorepo: true, + projects: {}, + compilerOptions: {}, + generateOptions: {}, + ...overrides, +}); + +const buildOptions = ( + overrides: Record = {}, +): Input[] => { + const defaults: Record = { + config: 'nest-cli.json', + webpack: false, + watch: false, + watchAssets: false, + path: undefined, + webpackPath: undefined, + builder: undefined, + typeCheck: undefined, + preserveWatchOutput: false, + all: false, + includeLibraryAssets: undefined, + }; + const merged = { ...defaults, ...overrides }; + return Object.entries(merged).map(([name, value]) => ({ + name, + value: value as boolean | string | string[], + })); +}; + +describe('BuildAction - includeLibraryAssets', () => { + let action: TestableBuildAction; + + beforeEach(() => { + jest.clearAllMocks(); + mockCompilerRun.mockImplementation(() => undefined); + action = new TestableBuildAction(); + }); + + it('should only copy the app assets when includeLibraryAssets is not set (default)', async () => { + action.configurationOverride = baseConfig({ + projects: { + api: { + type: 'application', + sourceRoot: 'apps/api/src', + compilerOptions: { assets: ['app-assets/*'] }, + }, + 'my-lib': { + type: 'library', + sourceRoot: 'libs/my-lib/src', + compilerOptions: { assets: ['library-assets/*'] }, + }, + }, + }); + + await action.runBuild( + [{ name: 'app', value: 'api' }], + buildOptions(), + false, + false, + ); + + expect(action.copyAssetsCalls).toHaveLength(1); + expect(action.copyAssetsCalls[0].appName).toEqual('api'); + }); + + it('should copy library assets when includeLibraryAssets flag is true via CLI', async () => { + action.configurationOverride = baseConfig({ + projects: { + api: { + type: 'application', + sourceRoot: 'apps/api/src', + compilerOptions: { assets: ['app-assets/*'] }, + }, + 'my-lib': { + type: 'library', + sourceRoot: 'libs/my-lib/src', + compilerOptions: { assets: ['library-assets/*'] }, + }, + }, + }); + + await action.runBuild( + [{ name: 'app', value: 'api' }], + buildOptions({ includeLibraryAssets: true }), + false, + false, + ); + + expect(action.copyAssetsCalls).toHaveLength(2); + expect(action.copyAssetsCalls[0].appName).toEqual('api'); + expect(action.copyAssetsCalls[1].appName).toEqual('my-lib'); + // Library assets should be copied into the app's outDir. + expect(action.copyAssetsCalls[1].outDir).toEqual('dist'); + }); + + it('should copy library assets when includeLibraryAssets is true in compilerOptions', async () => { + action.configurationOverride = baseConfig({ + compilerOptions: { includeLibraryAssets: true }, + projects: { + api: { + type: 'application', + sourceRoot: 'apps/api/src', + compilerOptions: { assets: ['app-assets/*'] }, + }, + 'my-lib': { + type: 'library', + sourceRoot: 'libs/my-lib/src', + compilerOptions: { assets: ['library-assets/*'] }, + }, + }, + }); + + await action.runBuild( + [{ name: 'app', value: 'api' }], + buildOptions(), + false, + false, + ); + + expect(action.copyAssetsCalls).toHaveLength(2); + expect(action.copyAssetsCalls.map((c) => c.appName)).toEqual([ + 'api', + 'my-lib', + ]); + }); + + it('should copy assets from multiple libraries when configured', async () => { + action.configurationOverride = baseConfig({ + projects: { + api: { + type: 'application', + sourceRoot: 'apps/api/src', + compilerOptions: { assets: ['app-assets/*'] }, + }, + 'lib-one': { + type: 'library', + sourceRoot: 'libs/lib-one/src', + compilerOptions: { assets: ['one/*.html'] }, + }, + 'lib-two': { + type: 'library', + sourceRoot: 'libs/lib-two/src', + compilerOptions: { assets: ['two/*.proto'] }, + }, + }, + }); + + await action.runBuild( + [{ name: 'app', value: 'api' }], + buildOptions({ includeLibraryAssets: true }), + false, + false, + ); + + const names = action.copyAssetsCalls.map((c) => c.appName); + expect(names).toContain('api'); + expect(names).toContain('lib-one'); + expect(names).toContain('lib-two'); + expect(action.copyAssetsCalls).toHaveLength(3); + }); + + it('should skip libraries without assets configured', async () => { + action.configurationOverride = baseConfig({ + projects: { + api: { + type: 'application', + sourceRoot: 'apps/api/src', + compilerOptions: { assets: ['app-assets/*'] }, + }, + 'lib-no-assets': { + type: 'library', + sourceRoot: 'libs/lib-no-assets/src', + compilerOptions: {}, + }, + 'lib-with-assets': { + type: 'library', + sourceRoot: 'libs/lib-with-assets/src', + compilerOptions: { assets: ['files/*'] }, + }, + }, + }); + + await action.runBuild( + [{ name: 'app', value: 'api' }], + buildOptions({ includeLibraryAssets: true }), + false, + false, + ); + + const names = action.copyAssetsCalls.map((c) => c.appName); + expect(names).toEqual(['api', 'lib-with-assets']); + }); + + it('should not crash for a single-project (no monorepo) configuration when flag is enabled', async () => { + action.configurationOverride = baseConfig({ + monorepo: false, + projects: {}, + compilerOptions: { + assets: ['app-assets/*'], + includeLibraryAssets: true, + }, + }); + + await action.runBuild( + [{ name: 'app', value: undefined as unknown as string }], + buildOptions({ includeLibraryAssets: true }), + false, + false, + ); + + // Only the app's own assets call should occur. + expect(action.copyAssetsCalls).toHaveLength(1); + expect(action.copyAssetsCalls[0].appName).toBeUndefined(); + }); + + it('should skip application-type sibling projects', async () => { + action.configurationOverride = baseConfig({ + projects: { + api: { + type: 'application', + sourceRoot: 'apps/api/src', + compilerOptions: { assets: ['api-assets/*'] }, + }, + 'second-app': { + type: 'application', + sourceRoot: 'apps/second-app/src', + compilerOptions: { assets: ['second-assets/*'] }, + }, + 'shared-lib': { + type: 'library', + sourceRoot: 'libs/shared-lib/src', + compilerOptions: { assets: ['shared/*'] }, + }, + }, + }); + + await action.runBuild( + [{ name: 'app', value: 'api' }], + buildOptions({ includeLibraryAssets: true }), + false, + false, + ); + + const names = action.copyAssetsCalls.map((c) => c.appName); + // Only the current app + library projects should be copied. + expect(names).toEqual(['api', 'shared-lib']); + expect(names).not.toContain('second-app'); + }); +}); diff --git a/test/lib/compiler/assets-manager.spec.ts b/test/lib/compiler/assets-manager.spec.ts new file mode 100644 index 000000000..8033107fd --- /dev/null +++ b/test/lib/compiler/assets-manager.spec.ts @@ -0,0 +1,152 @@ +jest.mock('chokidar', () => ({ + watch: jest.fn(() => ({ + on: jest.fn().mockReturnThis(), + close: jest.fn(), + })), +})); + +const mockCopyFileSync = jest.fn(); +const mockMkdirSync = jest.fn(); +const mockStatSync = jest.fn< + { isFile: () => boolean; isDirectory: () => boolean }, + [string] +>(() => ({ + isFile: () => true, + isDirectory: () => false, +})); +const mockRmSync = jest.fn(); + +jest.mock('fs', () => ({ + copyFileSync: (src: string, dest: string) => mockCopyFileSync(src, dest), + mkdirSync: (path: string, opts?: unknown) => mockMkdirSync(path, opts), + statSync: (path: string) => mockStatSync(path), + rmSync: (path: string, opts?: unknown) => mockRmSync(path, opts), +})); + +const mockGlobSync = jest.fn(); +jest.mock('glob', () => ({ + sync: (pattern: string, opts?: unknown) => mockGlobSync(pattern, opts), +})); + +import { join } from 'path'; +import { AssetsManager } from '../../../lib/compiler/assets-manager'; +import { Configuration } from '../../../lib/configuration'; + +const cwd = process.cwd(); + +describe('AssetsManager', () => { + let assetsManager: AssetsManager; + + beforeEach(() => { + jest.clearAllMocks(); + assetsManager = new AssetsManager(); + }); + + const buildConfig = ( + overrides: Partial = {}, + ): Required => ({ + language: 'ts', + sourceRoot: 'src', + collection: '@nestjs/schematics', + entryFile: 'main', + exec: 'node', + monorepo: false, + projects: {}, + compilerOptions: {}, + generateOptions: {}, + ...overrides, + }); + + describe('copyAssets', () => { + it('should not call copy/mkdir when there are no assets configured', () => { + const configuration = buildConfig(); + + assetsManager.copyAssets(configuration, undefined, 'dist', false); + + expect(mockGlobSync).not.toHaveBeenCalled(); + expect(mockCopyFileSync).not.toHaveBeenCalled(); + }); + + it('should resolve assets relative to the named project sourceRoot when called with a library project name', () => { + const matchedFile = join( + cwd, + 'libs', + 'my-lib', + 'src', + 'library-assets', + 'file.html', + ); + mockGlobSync.mockReturnValue([matchedFile]); + + const configuration = buildConfig({ + monorepo: true, + projects: { + api: { + type: 'application', + sourceRoot: 'apps/api/src', + compilerOptions: { + assets: ['app-assets/*.html'], + }, + }, + 'my-lib': { + type: 'library', + sourceRoot: 'libs/my-lib/src', + compilerOptions: { + assets: ['library-assets/*.html'], + }, + }, + }, + }); + + assetsManager.copyAssets(configuration, 'my-lib', 'dist', false); + + // The first call to glob.sync should be for the matched glob pattern. + expect(mockGlobSync).toHaveBeenCalled(); + const firstCallArg = mockGlobSync.mock.calls[0][0] as string; + // Path is normalized to forward slashes inside copyAssets. + expect(firstCallArg).toContain('libs/my-lib/src/library-assets/*.html'); + + // copyFileSync should be called with the destination under the app's outDir. + expect(mockCopyFileSync).toHaveBeenCalled(); + }); + + it('should respect a per-asset outDir for library assets', () => { + const matchedFile = join( + cwd, + 'libs', + 'my-lib', + 'src', + 'proto', + 'foo.proto', + ); + mockGlobSync.mockReturnValue([matchedFile]); + + const configuration = buildConfig({ + monorepo: true, + projects: { + 'my-lib': { + type: 'library', + sourceRoot: 'libs/my-lib/src', + compilerOptions: { + assets: [ + { + glob: '', + include: 'proto/*.proto', + outDir: 'dist/libs/my-lib', + }, + ], + }, + }, + }, + }); + + assetsManager.copyAssets(configuration, 'my-lib', 'dist', false); + + // copyFileSync's destination (second argument) should reflect the + // per-asset outDir, not the app outDir. + expect(mockCopyFileSync).toHaveBeenCalled(); + const dest = mockCopyFileSync.mock.calls[0][1] as string; + expect(dest.replace(/\\/g, '/')).toContain('dist/libs/my-lib'); + }); + }); +});