fix(@angular/build): exclude all entrypoints of a library from prebundling

The configuration now ensures that when a package is listed for exclusion, all paths within that package including sub-paths like `@foo/bar/baz`  are marked as external and not prebundled by the development server.

For example, specifying `@foo/bar` in the exclude list will prevent the development server from bundling any files from the `@foo/bar` package, including its sub-paths such as `@foo/bar/baz`.

This aligns with esbuild external option behaviour https://esbuild.github.io/api/#external

Closes #29170
This commit is contained in:
Alan Agius 2025-03-06 17:53:46 +00:00
parent 03180fe035
commit f0dd60be1e
9 changed files with 121 additions and 39 deletions

View File

@ -163,21 +163,49 @@ export async function executeBuild(
// Analyze external imports if external options are enabled // Analyze external imports if external options are enabled
if (options.externalPackages || bundlingResult.externalConfiguration) { if (options.externalPackages || bundlingResult.externalConfiguration) {
const { const {
externalConfiguration, externalConfiguration = [],
externalImports: { browser, server }, externalImports: { browser = [], server = [] },
} = bundlingResult; } = bundlingResult;
const implicitBrowser = browser ? [...browser] : []; // Similar to esbuild, --external:@foo/bar automatically implies --external:@foo/bar/*,
const implicitServer = server ? [...server] : []; // which matches import paths like @foo/bar/baz.
// TODO: Implement wildcard externalConfiguration filtering // This means all paths within the @foo/bar package are also marked as external.
executionResult.setExternalMetadata( const exclusionsPrefixes = externalConfiguration.map((exclusion) => exclusion + '/');
externalConfiguration const exclusions = new Set(externalConfiguration);
? implicitBrowser.filter((value) => !externalConfiguration.includes(value)) const explicitExternal = new Set<string>();
: implicitBrowser,
externalConfiguration const isExplicitExternal = (dep: string): boolean => {
? implicitServer.filter((value) => !externalConfiguration.includes(value)) if (exclusions.has(dep)) {
: implicitServer, return true;
externalConfiguration, }
);
for (const prefix of exclusionsPrefixes) {
if (dep.startsWith(prefix)) {
return true;
}
}
return false;
};
const implicitBrowser: string[] = [];
for (const dep of browser) {
if (isExplicitExternal(dep)) {
explicitExternal.add(dep);
} else {
implicitBrowser.push(dep);
}
}
const implicitServer: string[] = [];
for (const dep of server) {
if (isExplicitExternal(dep)) {
explicitExternal.add(dep);
} else {
implicitServer.push(dep);
}
}
executionResult.setExternalMetadata(implicitBrowser, implicitServer, [...explicitExternal]);
} }
const { metafile, initialFiles, outputFiles } = bundlingResult; const { metafile, initialFiles, outputFiles } = bundlingResult;

View File

@ -433,7 +433,14 @@ export async function normalizeOptions(
baseHref, baseHref,
cacheOptions, cacheOptions,
crossOrigin, crossOrigin,
externalDependencies, externalDependencies: normalizeExternals(externalDependencies),
externalPackages:
typeof externalPackages === 'object'
? {
...externalPackages,
exclude: normalizeExternals(externalPackages.exclude),
}
: externalPackages,
extractLicenses, extractLicenses,
inlineStyleLanguage, inlineStyleLanguage,
jit: !aot, jit: !aot,
@ -441,7 +448,6 @@ export async function normalizeOptions(
polyfills: polyfills === undefined || Array.isArray(polyfills) ? polyfills : [polyfills], polyfills: polyfills === undefined || Array.isArray(polyfills) ? polyfills : [polyfills],
poll, poll,
progress, progress,
externalPackages,
preserveSymlinks, preserveSymlinks,
stylePreprocessorOptions, stylePreprocessorOptions,
subresourceIntegrity, subresourceIntegrity,
@ -677,3 +683,23 @@ export function getLocaleBaseHref(
return baseHrefSuffix !== '' ? urlJoin(baseHref, baseHrefSuffix) : undefined; return baseHrefSuffix !== '' ? urlJoin(baseHref, baseHrefSuffix) : undefined;
} }
/**
* Normalizes an array of external dependency paths by ensuring that
* wildcard patterns (`/*`) are removed from package names.
*
* This avoids the need to handle this normalization repeatedly in our plugins,
* as esbuild already treats `--external:@foo/bar` as implicitly including
* `--external:@foo/bar/*`. By standardizing the input, we ensure consistency
* and reduce redundant checks across our plugins.
*
* @param value - An optional array of dependency paths to normalize.
* @returns A new array with wildcard patterns removed from package names, or `undefined` if input is `undefined`.
*/
function normalizeExternals(value: string[] | undefined): string[] | undefined {
if (!value) {
return undefined;
}
return [...new Set(value.map((d) => (d.endsWith('/*') ? d.slice(0, -2) : d)))];
}

View File

@ -196,7 +196,7 @@
"additionalProperties": false "additionalProperties": false
}, },
"externalDependencies": { "externalDependencies": {
"description": "Exclude the listed external dependencies from being bundled into the bundle. Instead, the created bundle relies on these dependencies to be available during runtime.", "description": "Exclude the listed external dependencies from being bundled into the bundle. Instead, the created bundle relies on these dependencies to be available during runtime. Note: `@foo/bar` marks all paths within the `@foo/bar` package as external, including sub-paths like `@foo/bar/baz`.",
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"

View File

@ -115,7 +115,7 @@
"type": "object", "type": "object",
"properties": { "properties": {
"exclude": { "exclude": {
"description": "List of package imports that should not be prebundled by the development server. The packages will be bundled into the application code itself.", "description": "List of package imports that should not be prebundled by the development server. The packages will be bundled into the application code itself. Note: specifying `@foo/bar` marks all paths within the `@foo/bar` package as excluded, including sub-paths like `@foo/bar/baz`.",
"type": "array", "type": "array",
"items": { "type": "string" } "items": { "type": "string" }
} }

View File

@ -48,7 +48,7 @@ describeServeBuilder(executeDevServer, DEV_SERVER_BUILDER_INFO, (harness, setupT
it('respects import specifiers when using baseHref with trailing slash', async () => { it('respects import specifiers when using baseHref with trailing slash', async () => {
setupTarget(harness, { setupTarget(harness, {
externalDependencies: ['rxjs', 'rxjs/operators'], externalDependencies: ['rxjs'],
baseHref: '/test/', baseHref: '/test/',
}); });
@ -67,7 +67,7 @@ describeServeBuilder(executeDevServer, DEV_SERVER_BUILDER_INFO, (harness, setupT
it('respects import specifiers when using baseHref without trailing slash', async () => { it('respects import specifiers when using baseHref without trailing slash', async () => {
setupTarget(harness, { setupTarget(harness, {
externalDependencies: ['rxjs', 'rxjs/operators'], externalDependencies: ['rxjs/*'],
baseHref: '/test', baseHref: '/test',
}); });

View File

@ -359,17 +359,16 @@ export class BundlerContext {
// Collect all external package names // Collect all external package names
const externalImports = new Set<string>(); const externalImports = new Set<string>();
for (const { imports } of Object.values(result.metafile.outputs)) { for (const { imports } of Object.values(result.metafile.outputs)) {
for (const importData of imports) { for (const { external, kind, path } of imports) {
if ( if (
!importData.external || !external ||
SERVER_GENERATED_EXTERNALS.has(importData.path) || SERVER_GENERATED_EXTERNALS.has(path) ||
(importData.kind !== 'import-statement' && (kind !== 'import-statement' && kind !== 'dynamic-import' && kind !== 'require-call')
importData.kind !== 'dynamic-import' &&
importData.kind !== 'require-call')
) { ) {
continue; continue;
} }
externalImports.add(importData.path);
externalImports.add(path);
} }
} }

View File

@ -127,9 +127,9 @@ export class ExecutionResult {
setExternalMetadata( setExternalMetadata(
implicitBrowser: string[], implicitBrowser: string[],
implicitServer: string[], implicitServer: string[],
explicit: string[] | undefined, explicit: string[],
): void { ): void {
this.externalMetadata = { implicitBrowser, implicitServer, explicit: explicit ?? [] }; this.externalMetadata = { implicitBrowser, implicitServer, explicit };
} }
get output() { get output() {

View File

@ -19,7 +19,14 @@ const EXTERNAL_PACKAGE_RESOLUTION = Symbol('EXTERNAL_PACKAGE_RESOLUTION');
* @returns An esbuild plugin. * @returns An esbuild plugin.
*/ */
export function createExternalPackagesPlugin(options?: { exclude?: string[] }): Plugin { export function createExternalPackagesPlugin(options?: { exclude?: string[] }): Plugin {
const exclusions = options?.exclude?.length ? new Set(options.exclude) : undefined; const exclusions = new Set<string>(options?.exclude);
// Similar to esbuild, --external:@foo/bar automatically implies --external:@foo/bar/*,
// which matches import paths like @foo/bar/baz.
// This means all paths within the @foo/bar package are also marked as external.
const exclusionsPrefixes = options?.exclude?.map((exclusion) => exclusion + '/') ?? [];
const seenExclusions: Set<string> = new Set();
const seenExternals = new Set<string>();
const seenNonExclusions: Set<string> = new Set();
return { return {
name: 'angular-external-packages', name: 'angular-external-packages',
@ -33,7 +40,7 @@ export function createExternalPackagesPlugin(options?: { exclude?: string[] }):
.map(([key]) => key); .map(([key]) => key);
// Safe to use native packages external option if no loader options or exclusions present // Safe to use native packages external option if no loader options or exclusions present
if (!exclusions && !loaderOptionKeys?.length) { if (!exclusions.size && !loaderOptionKeys?.length) {
build.initialOptions.packages = 'external'; build.initialOptions.packages = 'external';
return; return;
@ -47,10 +54,26 @@ export function createExternalPackagesPlugin(options?: { exclude?: string[] }):
return null; return null;
} }
if (exclusions?.has(args.path)) { if (seenExternals.has(args.path)) {
return { external: true };
}
if (exclusions.has(args.path) || seenExclusions.has(args.path)) {
return null; return null;
} }
if (!seenNonExclusions.has(args.path)) {
for (const exclusion of exclusionsPrefixes) {
if (args.path.startsWith(exclusion)) {
seenExclusions.add(args.path);
return null;
}
}
seenNonExclusions.add(args.path);
}
const { importer, kind, resolveDir, namespace, pluginData = {} } = args; const { importer, kind, resolveDir, namespace, pluginData = {} } = args;
pluginData[EXTERNAL_PACKAGE_RESOLUTION] = true; pluginData[EXTERNAL_PACKAGE_RESOLUTION] = true;
@ -62,11 +85,18 @@ export function createExternalPackagesPlugin(options?: { exclude?: string[] }):
resolveDir, resolveDir,
}); });
// Return result if unable to resolve or explicitly marked external (externalDependencies option) // Return result if unable to resolve
if (!result.path || result.external) { if (!result.path) {
return result; return result;
} }
// Return if explicitly marked external (externalDependencies option)
if (result.external) {
seenExternals.add(args.path);
return { external: true };
}
// Allow customized loaders to run against configured paths regardless of location // Allow customized loaders to run against configured paths regardless of location
if (loaderFileExtensions.has(extname(result.path))) { if (loaderFileExtensions.has(extname(result.path))) {
return result; return result;
@ -74,10 +104,9 @@ export function createExternalPackagesPlugin(options?: { exclude?: string[] }):
// Mark paths from a node modules directory as external // Mark paths from a node modules directory as external
if (/[\\/]node_modules[\\/]/.test(result.path)) { if (/[\\/]node_modules[\\/]/.test(result.path)) {
return { seenExternals.add(args.path);
path: args.path,
external: true, return { external: true };
};
} }
// Otherwise return original result // Otherwise return original result

View File

@ -27,7 +27,7 @@ export function createRemoveIdPrefixPlugin(externals: string[]): Plugin {
return; return;
} }
const escapedExternals = externals.map(escapeRegexSpecialChars); const escapedExternals = externals.map((e) => escapeRegexSpecialChars(e) + '(?:/.+)?');
const prefixedExternalRegex = new RegExp( const prefixedExternalRegex = new RegExp(
`${resolvedConfig.base}${VITE_ID_PREFIX}(${escapedExternals.join('|')})`, `${resolvedConfig.base}${VITE_ID_PREFIX}(${escapedExternals.join('|')})`,
'g', 'g',