diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index f3a3908b0..7ed321382 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -128,12 +128,15 @@ export enum InjectPosition { MIDDLE, AFTER, } + export type ToInjectItem = { type: 'file' | 'code'; value: InjectedValue; - position?: InjectPosition; fallback?: ToInjectItem; -}; +} & ( + | { position?: InjectPosition.BEFORE | InjectPosition.AFTER; injectIntoAllChunks?: boolean } + | { position: InjectPosition.MIDDLE } +); export type TimeLogger = { timer: Timer; diff --git a/packages/plugins/build-report/src/index.test.ts b/packages/plugins/build-report/src/index.test.ts index 85cb08c89..253bbd0f2 100644 --- a/packages/plugins/build-report/src/index.test.ts +++ b/packages/plugins/build-report/src/index.test.ts @@ -426,6 +426,7 @@ describe('Build Report Plugin', () => { 'escape-string-regexp/index.js', 'hard_project/main1.js', 'hard_project/main2.js', + 'hard_project/src/dynamicChunk.js', 'hard_project/src/srcFile0.js', 'hard_project/src/srcFile1.js', 'hard_project/workspaces/app/workspaceFile0.js', @@ -475,6 +476,7 @@ describe('Build Report Plugin', () => { filename: 'hard_project/main1.js', dependencies: [ 'chalk/index.js', + 'hard_project/src/dynamicChunk.js', 'hard_project/src/srcFile0.js', 'hard_project/workspaces/app/workspaceFile1.js', ], @@ -643,7 +645,7 @@ describe('Build Report Plugin', () => { }); const entriesList = [ - { entryName: 'app1', dependenciesLength: 9, mainFilesLength: 4 }, + { entryName: 'app1', dependenciesLength: 9, mainFilesLength: 5 }, { entryName: 'app2', dependenciesLength: 0, mainFilesLength: 5 }, ]; diff --git a/packages/plugins/build-report/src/xpack.ts b/packages/plugins/build-report/src/xpack.ts index c9d1bc32e..06eca82b6 100644 --- a/packages/plugins/build-report/src/xpack.ts +++ b/packages/plugins/build-report/src/xpack.ts @@ -430,11 +430,35 @@ export const getXpackPlugin = // Build entries const timeEntries = log.time('building entries'); + + // Helper to recursively get all chunks from a chunk group, including async chunks. + const getAllChunksFromGroup = ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + chunkGroup: any, + visited: Set = new Set(), + ): Chunk[] => { + if (visited.has(chunkGroup)) { + return []; + } + visited.add(chunkGroup); + + const allChunks: Chunk[] = [...chunkGroup.chunks]; + + // Recursively get chunks from child chunk groups (async chunks). + for (const childGroup of chunkGroup.childrenIterable || []) { + allChunks.push(...getAllChunksFromGroup(childGroup, visited)); + } + + return allChunks; + }; + for (const [name, entrypoint] of result.entrypoints) { const entryOutputs: Map = new Map(); const entryInputs: Map = new Map(); let size = 0; - const entryFiles = entrypoint.chunks.flatMap(getChunkFiles); + // Get all chunks including async chunks from child chunk groups. + const allChunks = getAllChunksFromGroup(entrypoint); + const entryFiles = allChunks.flatMap(getChunkFiles); // FIXME This is not a 100% reliable way to get the entry filename. const entryFilename = entrypoint.chunks @@ -482,7 +506,6 @@ export const getXpackPlugin = outputs: Array.from(entryOutputs.values()), type: entryFilename ? getType(entryFilename) : 'unknown', }; - entries.push(file); } timeEntries.end(); diff --git a/packages/plugins/injection/src/esbuild.ts b/packages/plugins/injection/src/esbuild.ts index 3182ac8bc..8c59b38a9 100644 --- a/packages/plugins/injection/src/esbuild.ts +++ b/packages/plugins/injection/src/esbuild.ts @@ -77,7 +77,9 @@ export const getEsbuildPlugin = ( namespace: PLUGIN_NAME, }, async () => { - const content = getContentToInject(contentsToInject[InjectPosition.MIDDLE]); + const content = getContentToInject(contentsToInject, { + position: InjectPosition.MIDDLE, + }); return { // We can't use an empty string otherwise esbuild will crash. @@ -96,63 +98,82 @@ export const getEsbuildPlugin = ( return; } - const banner = getContentToInject(contentsToInject[InjectPosition.BEFORE]); - const footer = getContentToInject(contentsToInject[InjectPosition.AFTER]); - - if (!banner && !footer) { + const bannerForEntries = getContentToInject(contentsToInject, { + position: InjectPosition.BEFORE, + }); + const footerForEntries = getContentToInject(contentsToInject, { + position: InjectPosition.AFTER, + }); + const bannerForAllChunks = getContentToInject(contentsToInject, { + position: InjectPosition.BEFORE, + onAllChunks: true, + }); + const footerForAllChunks = getContentToInject(contentsToInject, { + position: InjectPosition.AFTER, + onAllChunks: true, + }); + + if ( + !bannerForEntries && + !footerForEntries && + !bannerForAllChunks && + !footerForAllChunks + ) { // Nothing to inject. return; } - // Rewrite outputs with the injected content. - // Only keep the entry files. - const outputs: string[] = Object.entries(result.metafile.outputs) - .map(([p, o]) => { - const entryPoint = o.entryPoint; - if (!entryPoint) { - return; - } - - const entry = entries.find((e) => e.resolved.endsWith(entryPoint)); - if (!entry) { - return; - } - - return getAbsolutePath(context.buildRoot, p); - }) - .filter(Boolean) as string[]; - - // Write the content. - const proms = outputs - .filter((output) => { - const { base, ext } = path.parse(output); - const isOutputSupported = isFileSupported(ext); - if (!isOutputSupported) { - warnUnsupportedFile(log, ext, base); - } - return isOutputSupported; - }) - .map(async (output) => { - try { - const source = await fsp.readFile(output, 'utf-8'); - const data = await esbuild.transform(source, { - loader: 'default', - banner, - footer, - }); - - // FIXME: Handle sourcemaps. - await fsp.writeFile(output, data.code); - } catch (e) { - if (isNodeSystemError(e) && e.code === 'ENOENT') { - // When we are using sub-builds, the entry file of sub-builds may not exist - // Hence we should skip the file injection in this case. - log.warn(`Could not inject content in ${output}: ${e}`); - } else { - throw e; + const proms: Promise[] = []; + + // Process all output files + for (const [p, o] of Object.entries(result.metafile.outputs)) { + // Determine if this is an entry point + const isEntry = + o.entryPoint && entries.some((e) => e.resolved.endsWith(o.entryPoint!)); + + // Get the appropriate banner and footer + const banner = isEntry ? bannerForEntries : bannerForAllChunks; + const footer = isEntry ? footerForEntries : footerForAllChunks; + + // Skip if nothing to inject for this chunk type + if (!banner && !footer) { + continue; + } + + const absolutePath = getAbsolutePath(context.buildRoot, p); + const { base, ext } = path.parse(absolutePath); + + // Check if file type is supported + if (!isFileSupported(ext)) { + warnUnsupportedFile(log, ext, base); + continue; + } + + // Inject content + proms.push( + (async () => { + try { + const source = await fsp.readFile(absolutePath, 'utf-8'); + const data = await esbuild.transform(source, { + loader: 'default', + banner, + footer, + }); + + // FIXME: Handle sourcemaps. + await fsp.writeFile(absolutePath, data.code); + } catch (e) { + if (isNodeSystemError(e) && e.code === 'ENOENT') { + // When we are using sub-builds, the entry file of sub-builds may not exist + // Hence we should skip the file injection in this case. + log.warn(`Could not inject content in ${absolutePath}: ${e}`); + } else { + throw e; + } } - } - }); + })(), + ); + } await Promise.all(proms); }); diff --git a/packages/plugins/injection/src/helpers.test.ts b/packages/plugins/injection/src/helpers.test.ts index c2d7f9dbd..7d96ca53f 100644 --- a/packages/plugins/injection/src/helpers.test.ts +++ b/packages/plugins/injection/src/helpers.test.ts @@ -70,11 +70,29 @@ describe('Injection Plugin Helpers', () => { const results = await processInjections(items, mockLogger); expect(Array.from(results.entries())).toEqual([ - ['code', { position: InjectPosition.BEFORE, value: codeContent }], - ['existingFile', { position: InjectPosition.BEFORE, value: localFileContent }], + [ + 'code', + { + position: InjectPosition.BEFORE, + value: codeContent, + injectIntoAllChunks: false, + }, + ], + [ + 'existingFile', + { + position: InjectPosition.BEFORE, + value: localFileContent, + injectIntoAllChunks: false, + }, + ], [ 'existingDistantFile', - { position: InjectPosition.BEFORE, value: distantFileContent }, + { + position: InjectPosition.BEFORE, + value: distantFileContent, + injectIntoAllChunks: false, + }, ], ]); diff --git a/packages/plugins/injection/src/helpers.ts b/packages/plugins/injection/src/helpers.ts index faf0e6a7e..a86b4579c 100644 --- a/packages/plugins/injection/src/helpers.ts +++ b/packages/plugins/injection/src/helpers.ts @@ -16,7 +16,7 @@ import { DISTANT_FILE_RX, SUPPORTED_EXTENSIONS, } from './constants'; -import type { ContentsToInject } from './types'; +import type { ContentsToInject, ContentToInject } from './types'; const yellow = chalk.bold.yellow; @@ -101,29 +101,50 @@ export const processInjections = async ( toInject: Map, log: Logger, cwd: string = process.cwd(), -): Promise> => { - const toReturn: Map = new Map(); +): Promise< + Map +> => { + const toReturn = new Map(); // Processing sequentially all the items. for (const [id, item] of toInject.entries()) { // eslint-disable-next-line no-await-in-loop const value = await processItem(item, log, cwd); if (value) { - toReturn.set(id, { value, position: item.position || InjectPosition.BEFORE }); + const position = item.position || InjectPosition.BEFORE; + toReturn.set(id, { + value, + injectIntoAllChunks: + 'injectIntoAllChunks' in item ? item.injectIntoAllChunks : false, + position, + }); } } return toReturn; }; -export const getContentToInject = (contentToInject: Map) => { - if (contentToInject.size === 0) { +export const getContentToInject = ( + contentToInject: ContentToInject[], + options: { + position: InjectPosition; + onAllChunks?: boolean; + }, +) => { + const filtered = contentToInject.filter((content) => { + return ( + content.position === options.position && + (!options.onAllChunks || content.injectIntoAllChunks) + ); + }); + + if (filtered.length === 0) { return ''; } - const stringToInject = Array.from(contentToInject.values()) + const stringToInject = filtered // Wrapping it in order to avoid variable name collisions. - .map((content) => `(() => {${content}})();`) + .map((content) => `(() => {${content.value}})();`) .join('\n\n'); return `${BEFORE_INJECTION}\n${stringToInject}\n${AFTER_INJECTION}`; }; @@ -136,9 +157,9 @@ export const addInjections = async ( cwd: string = process.cwd(), ) => { const results = await processInjections(toInject, log, cwd); - // Redistribute the content to inject in the right place. - for (const [id, value] of results.entries()) { - contentsToInject[value.position].set(id, value.value); + // Add processed content to the array + for (const value of results.values()) { + contentsToInject.push(value); } }; diff --git a/packages/plugins/injection/src/index.test.ts b/packages/plugins/injection/src/index.test.ts index 2eb3f7e67..eac197070 100644 --- a/packages/plugins/injection/src/index.test.ts +++ b/packages/plugins/injection/src/index.test.ts @@ -23,27 +23,40 @@ import path from 'path'; const FAKE_FILE_PREFIX = 'fake-file-to-inject-'; // Files that we will execute part of the test. -const FILES = ['main.js', 'app1.js', 'app2.js'] as const; +const ENTRY_FILES = ['main.js', 'app1.js', 'app2.js'] as const; const DOMAIN = 'https://example.com'; type ExpectedValues = [string | RegExp, number | [number, number]]; type BaseExpectation = { name: string; logs?: Record; + allChunks?: boolean; content: ExpectedValues; }; type EasyExpectation = Assign; type HardExpectation = Assign< BaseExpectation, - { logs?: { 'app1.js': ExpectedValues; 'app2.js': ExpectedValues } } + { + logs?: { + 'app1.js': ExpectedValues; + 'app2.js': ExpectedValues; + 'chunk-*.js'?: ExpectedValues; + }; + } >; type BuildState = { outdir?: string; - content?: string; + // Content of all files + fullContent?: string; + // Content of entry files only (for chunk testing) + entryContent?: string; + // Content of non-entry chunk files (for chunk testing) + chunkContent?: string; + chunkNumber?: number; // Separate logs based on executed file. logs?: Partial>; }; -type File = (typeof FILES)[number]; +type File = (typeof ENTRY_FILES)[number]; enum ContentType { CODE = 'code', LOCAL = 'local file', @@ -223,6 +236,11 @@ describe('Injection Plugin', () => { }, ]; + // Injections + expectations for injectIntoAllChunks (hard build only). + // This is generated by extending the existing combinator loop below, so it stays consistent. + const allChunksInjections: ToInjectItem[] = []; + const hardWithAllChunksInjections: HardExpectation[] = []; + const toInjectItems: ToInjectItem[] = [ // Add a special case of import to confirm this is working as expected in the middle of the code. { @@ -275,6 +293,30 @@ describe('Injection Plugin', () => { injection.value = `.${getFileUrl(position)}`; } + // Fill in the expectations + injections for all-chunks injection (hard build only). + // MIDDLE position doesn't support injectIntoAllChunks. + if (position !== Position.MIDDLE) { + hardWithAllChunksInjections.push({ + name: `[${position}] ${type} all-chunks injection in hard build`, + // numbers of expected logs depends on the chunks number the bundler generates + // webpack tryes to minimize while rollup does not that why the expected logs range is wider + logs: { + 'app1.js': [injectionLog, [2, 5]], + 'app2.js': [injectionLog, [1, 5]], + }, + // Higher count because it appears in entries AND chunks. + // NOTE: chunk count varies per bundler (runtime/vendor chunks, etc.). + content: [injectionContent, [1, 5]], + }); + + allChunksInjections.push({ + type: injectType, + value: injection.value, + position: positionType as InjectPosition.BEFORE | InjectPosition.AFTER, + injectIntoAllChunks: true, + }); + } + toInjectItems.push(injection); } } @@ -310,6 +352,7 @@ describe('Injection Plugin', () => { positions: Position[]; injections: [ToInjectItem[], number]; expectations: (EasyExpectation | HardExpectation)[]; + allChunks?: boolean; }[] = [ { name: 'Easy build without injections', @@ -339,6 +382,15 @@ describe('Injection Plugin', () => { injections: [toInjectItems, 10], expectations: hardWithInjections, }, + { + name: 'Hard build with injections in all chunks', + entry: hardProjectEntries, + positions: [Position.BEFORE, Position.AFTER], + // 3 content types × 2 positions (BEFORE/AFTER only) = 6 + injections: [allChunksInjections, 6], + expectations: hardWithAllChunksInjections, + allChunks: true, + }, ]; type BuildStates = Partial>; @@ -366,7 +418,11 @@ describe('Injection Plugin', () => { const { errors } = await runBundlers( { output: {}, customPlugins: getPlugins(injections[0], buildStates) }, - { node: true, entry }, + { + node: true, + entry, + splitting: true, + }, ); localState.errors.push(...errors); localState.nockDone = nockScope.isDone(); @@ -383,25 +439,35 @@ describe('Injection Plugin', () => { } const builtFiles = glob.sync(path.resolve(outdir, '*.{js,mjs}')); - - // Only execute files we identified as entries. - const filesToRun: File[] = builtFiles - .map((file) => path.basename(file) as File) - .filter((basename) => FILES.includes(basename)); + // Identify entry files vs chunk files using the entry keys (e.g. ['app1', 'app2']). + const isEntryFile = (filepath: string): filepath is File => { + const basename = path.basename(filepath) as File; + return ENTRY_FILES.includes(basename); + }; + const entryFiles = builtFiles.filter(isEntryFile); + const chunkFiles = builtFiles.filter((f) => !isEntryFile(f)); // Run the files through node to confirm they don't crash and assert their logs. proms.push( - ...filesToRun.map(async (file) => { - const result = await execute('node', [path.resolve(outdir, file)]); + ...entryFiles.map(async (file) => { + const basename = path.basename(file) as File; + const result = await execute('node', [path.resolve(outdir, basename)]); buildState.logs = buildState.logs || {}; - buildState.logs[file] = result.stdout; + buildState.logs[basename] = result.stdout; }), ); - // Store the content of the built files to assert the injections. - buildState.content = builtFiles + // Store separated content for chunk assertions. + buildState.fullContent = builtFiles + .map((file) => readFileSync(file, 'utf8')) + .join('\n'); + buildState.entryContent = entryFiles + .map((file) => readFileSync(file, 'utf8')) + .join('\n'); + buildState.chunkContent = chunkFiles .map((file) => readFileSync(file, 'utf8')) .join('\n'); + buildState.chunkNumber = chunkFiles.length; } await Promise.all(proms); @@ -429,7 +495,7 @@ describe('Injection Plugin', () => { // Webpack can be slow to build... }, 100000); - describe.each(tests)('$name', ({ name: testName, injections, expectations }) => { + describe.each(tests)('$name', ({ name: testName, injections, expectations, allChunks }) => { test('Should have the correct test environment.', () => { const localState = states[testName]; expect(injections[0]).toHaveLength(injections[1]); @@ -446,7 +512,7 @@ describe('Injection Plugin', () => { expect(buildState).toBeDefined(); expect(buildState.outdir).toEqual(expect.any(String)); expect(buildState.logs).toEqual(expect.any(Object)); - expect(buildState.content).toEqual(expect.any(String)); + expect(buildState.fullContent).toEqual(expect.any(String)); }); describe.each(expectations)( @@ -456,16 +522,38 @@ describe('Injection Plugin', () => { content: [expectedContent, contentOccurencies], logs, }) => { - test('Should have the expected content in the bundles.', () => { - const buildState = states[testName].builds[name]; - const content = buildState?.content; - const expectation = - expectedContent instanceof RegExp - ? expectedContent - : new RegExp(escapeStringForRegExp(expectedContent)); - expect(content).toBeDefined(); - expect(content).toRepeatStringTimes(expectation, contentOccurencies); - }); + if (allChunks) { + test('Should appear in the entry files and chunks.', () => { + const buildState = states[testName].builds[name]; + const expectation = + expectedContent instanceof RegExp + ? expectedContent + : new RegExp(escapeStringForRegExp(expectedContent)); + + expect(buildState?.entryContent).toRepeatStringTimes( + expectation, + 2, + ); + expect(buildState?.chunkContent).toRepeatStringTimes( + expectation, + buildState?.chunkNumber ?? 0, + ); + }); + } else { + test('Should have the expected content in the bundles.', () => { + const buildState = states[testName].builds[name]; + const content = buildState?.fullContent; + const expectation = + expectedContent instanceof RegExp + ? expectedContent + : new RegExp(escapeStringForRegExp(expectedContent)); + expect(content).toBeDefined(); + expect(content).toRepeatStringTimes( + expectation, + contentOccurencies, + ); + }); + } if (!logs) { return; diff --git a/packages/plugins/injection/src/index.ts b/packages/plugins/injection/src/index.ts index 6a94b2cbb..c286c0d42 100644 --- a/packages/plugins/injection/src/index.ts +++ b/packages/plugins/injection/src/index.ts @@ -29,11 +29,7 @@ export const getInjectionPlugins: GetInternalPlugins = (arg: GetPluginsArg) => { const injections: Map = new Map(); // Storage for all the positional contents we want to inject. - const contentsToInject: ContentsToInject = { - [InjectPosition.BEFORE]: new Map(), - [InjectPosition.MIDDLE]: new Map(), - [InjectPosition.AFTER]: new Map(), - }; + const contentsToInject: ContentsToInject = []; context.inject = (item: ToInjectItem) => { injections.set(getUniqueId(), item); @@ -64,7 +60,9 @@ export const getInjectionPlugins: GetInternalPlugins = (arg: GetPluginsArg) => { }, handler() { return { - code: getContentToInject(contentsToInject[InjectPosition.MIDDLE]), + code: getContentToInject(contentsToInject, { + position: InjectPosition.MIDDLE, + }), }; }, }; diff --git a/packages/plugins/injection/src/rollup.ts b/packages/plugins/injection/src/rollup.ts index 86d971b2a..a81313562 100644 --- a/packages/plugins/injection/src/rollup.ts +++ b/packages/plugins/injection/src/rollup.ts @@ -21,17 +21,23 @@ export const getRollupPlugin = ( ): PluginOptions['rollup'] => { return { banner(chunk) { - if (chunk.isEntry && chunk.fileName) { - const { base, ext } = path.parse(chunk.fileName); - const isOutputSupported = isFileSupported(ext); - if (!isOutputSupported) { - warnUnsupportedFile(log, ext, base); - return ''; - } - // Can be empty. - return getContentToInject(contentsToInject[InjectPosition.BEFORE]); + const banner = getContentToInject(contentsToInject, { + position: InjectPosition.BEFORE, + onAllChunks: !chunk.isEntry, + }); + + if (banner === '' || !chunk.fileName) { + return ''; } - return ''; + + const { base, ext } = path.parse(chunk.fileName); + const isOutputSupported = isFileSupported(ext); + if (!isOutputSupported) { + warnUnsupportedFile(log, ext, base); + return ''; + } + + return banner; }, async resolveId(source, importer, options) { if (isInjectionFile(source)) { @@ -39,7 +45,12 @@ export const getRollupPlugin = ( // "treeshake.moduleSideEffects: false" may prevent the injection from being included. return { id: source, moduleSideEffects: true }; } - if (options.isEntry && getContentToInject(contentsToInject[InjectPosition.MIDDLE])) { + if ( + options.isEntry && + getContentToInject(contentsToInject, { + position: InjectPosition.MIDDLE, + }) + ) { // Determine what the actual entry would have been. const resolution = await this.resolve(source, importer, options); // If it cannot be resolved or is external, just return it so that Rollup can display an error @@ -70,7 +81,9 @@ export const getRollupPlugin = ( load(id) { if (isInjectionFile(id)) { // Replace with injection content. - return getContentToInject(contentsToInject[InjectPosition.MIDDLE]); + return getContentToInject(contentsToInject, { + position: InjectPosition.MIDDLE, + }); } if (id.endsWith(TO_INJECT_SUFFIX)) { const entryId = id.slice(0, -TO_INJECT_SUFFIX.length); @@ -86,17 +99,23 @@ export const getRollupPlugin = ( return null; }, footer(chunk) { - if (chunk.isEntry && chunk.fileName) { - const { base, ext } = path.parse(chunk.fileName); - const isOutputSupported = isFileSupported(ext); - if (!isOutputSupported) { - warnUnsupportedFile(log, ext, base); - return ''; - } - // Can be empty. - return getContentToInject(contentsToInject[InjectPosition.AFTER]); + const footer = getContentToInject(contentsToInject, { + position: InjectPosition.AFTER, + onAllChunks: !chunk.isEntry, + }); + + if (footer === '' || !chunk.fileName) { + return ''; } - return ''; + + const { base, ext } = path.parse(chunk.fileName); + const isOutputSupported = isFileSupported(ext); + if (!isOutputSupported) { + warnUnsupportedFile(log, ext, base); + return ''; + } + + return footer; }, }; }; diff --git a/packages/plugins/injection/src/types.ts b/packages/plugins/injection/src/types.ts index 5b989b975..84d0d835f 100644 --- a/packages/plugins/injection/src/types.ts +++ b/packages/plugins/injection/src/types.ts @@ -4,7 +4,12 @@ import type { InjectPosition } from '@dd/core/types'; -export type ContentsToInject = Record>; +export type ContentToInject = { + injectIntoAllChunks: boolean; + position: InjectPosition; + value: string; +}; +export type ContentsToInject = Array; export type FileToInject = { absolutePath: string; diff --git a/packages/plugins/injection/src/xpack.ts b/packages/plugins/injection/src/xpack.ts index a97d7a24e..714ad116d 100644 --- a/packages/plugins/injection/src/xpack.ts +++ b/packages/plugins/injection/src/xpack.ts @@ -91,10 +91,16 @@ export const getXpackPlugin = // We need to prepare the injections before the build starts. // Otherwise they'll be empty once resolved. - compiler.hooks.beforeRun.tapPromise(PLUGIN_NAME, async () => { + const setupInjections = async () => { // Prepare the injections. await addInjections(log, toInject, contentsToInject, context.buildRoot); - }); + }; + + // For one-time builds (production mode) + compiler.hooks.beforeRun.tapPromise(PLUGIN_NAME, setupInjections); + + // For watch mode / dev server (webpack dev mode) + compiler.hooks.watchRun.tapPromise(PLUGIN_NAME, setupInjections); // Handle the InjectPosition.START and InjectPosition.END. // This is a re-implementation of the BannerPlugin, @@ -102,11 +108,31 @@ export const getXpackPlugin = // with both banner and footer. compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation) => { const hookCb = () => { - const banner = getContentToInject(contentsToInject[InjectPosition.BEFORE]); - const footer = getContentToInject(contentsToInject[InjectPosition.AFTER]); + const bannerForEntries = getContentToInject(contentsToInject, { + position: InjectPosition.BEFORE, + }); + const footerForEntries = getContentToInject(contentsToInject, { + position: InjectPosition.AFTER, + }); + const bannerForAllChunks = getContentToInject(contentsToInject, { + position: InjectPosition.BEFORE, + onAllChunks: true, + }); + const footerForAllChunks = getContentToInject(contentsToInject, { + position: InjectPosition.AFTER, + onAllChunks: true, + }); for (const chunk of compilation.chunks) { + let banner = bannerForEntries; + let footer = footerForEntries; + if (!chunk.canBeInitial()) { + banner = bannerForAllChunks; + footer = footerForAllChunks; + } + + if (banner === '' && footer === '') { continue; } diff --git a/packages/plugins/metrics/src/index.test.ts b/packages/plugins/metrics/src/index.test.ts index 19e5e699f..ce32b8619 100644 --- a/packages/plugins/metrics/src/index.test.ts +++ b/packages/plugins/metrics/src/index.test.ts @@ -200,7 +200,7 @@ describe('Metrics Universal Plugin', () => { metric: string; args: [GetMetricParams[1]?, GetMetricParams[2]?]; }[] = [ - { metric: 'modules.count', args: [[], 15] }, + { metric: 'modules.count', args: [[], 16] }, { metric: 'entries.count', args: [[], 2] }, // Each bundler may have its own way of bundling. { metric: 'assets.count', args: [] }, @@ -354,7 +354,7 @@ describe('Metrics Universal Plugin', () => { ['chalk/templates.js', ['app1'], 3133, 0, 1], // Somehow rollup and vite are not reporting the same size. ['chalk/index.js', ['app1'], expect.toBeWithinRange(6437, 6439), 4, 1], - ['hard_project/main1.js', ['app1'], 462, 3, 0], + ['hard_project/main1.js', ['app1'], 553, 4, 0], ['hard_project/main2.js', ['app2'], 337, 2, 0], ]; diff --git a/packages/tests/src/_jest/fixtures/hard_project/main1.js b/packages/tests/src/_jest/fixtures/hard_project/main1.js index 05793a717..597311603 100644 --- a/packages/tests/src/_jest/fixtures/hard_project/main1.js +++ b/packages/tests/src/_jest/fixtures/hard_project/main1.js @@ -14,3 +14,7 @@ console.log(chalk.cyan('Hello World!')); fn(); fn2(); + +import('./src/dynamicChunk.js').then((module) => { + module.dynamicChunkFunction(); +}); diff --git a/packages/tests/src/_jest/fixtures/hard_project/src/dynamicChunk.js b/packages/tests/src/_jest/fixtures/hard_project/src/dynamicChunk.js new file mode 100644 index 000000000..c3fc2b96d --- /dev/null +++ b/packages/tests/src/_jest/fixtures/hard_project/src/dynamicChunk.js @@ -0,0 +1,14 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +// This file is dynamically imported to ensure bundlers produce a separate chunk. +// Used for testing injectIntoAllChunks functionality. + +export const dynamicChunkFunction = () => { + return 'DYNAMIC_CHUNK_LOADED'; +}; + +export default dynamicChunkFunction; + + diff --git a/packages/tools/src/bundlers.ts b/packages/tools/src/bundlers.ts index 0796f2f49..af8db4f8c 100644 --- a/packages/tools/src/bundlers.ts +++ b/packages/tools/src/bundlers.ts @@ -30,6 +30,7 @@ export type BundlerConfig = { externals?: string[]; node?: boolean; plugins?: any[]; + splitting?: boolean; // Enable code splitting for dynamic imports }; export type BundlerConfigFunction = (config: BundlerConfig) => BundlerOptions; export type BundlerRunFn = (bundlerConfig: any) => Promise<{ errors: string[]; result?: any }>; @@ -184,6 +185,11 @@ export const configXpack = (config: BundlerConfig): Configuration & RspackOption config.entry, ); + // Determine splitChunks behavior: + // - If splitting is explicitly enabled, allow it + // - Otherwise, disable for node builds (default behavior) + const splitChunks = config.splitting ? undefined : config.node ? false : undefined; + const baseConfig: Configuration & RspackOptions = { context: config.workingDir, entry, @@ -194,11 +200,12 @@ export const configXpack = (config: BundlerConfig): Configuration & RspackOption output: { path: config.outDir, filename: `[name].js`, + chunkFilename: 'chunk.[contenthash].js', }, devtool: 'source-map', optimization: { minimize: false, - splitChunks: config.node ? false : undefined, + splitChunks, }, plugins: config.plugins, target: config.node ? 'node' : undefined, @@ -272,6 +279,8 @@ export const configWebpack = (config: BundlerConfig): Configuration => { }; export const configEsbuild = (config: BundlerConfig): BuildOptions => { + // Code splitting in esbuild requires ESM format + const enableSplitting = config.splitting ?? false; return { absWorkingDir: config.workingDir, bundle: true, @@ -279,10 +288,10 @@ export const configEsbuild = (config: BundlerConfig): BuildOptions => { entryPoints: config.entry, entryNames: '[name]', external: config.externals || [], - format: 'cjs', + format: enableSplitting ? 'esm' : 'cjs', outdir: config.outDir, sourcemap: true, - splitting: false, + splitting: enableSplitting, plugins: config.plugins, }; };