1
0
mirror of https://github.com/angular/angular-cli.git synced 2025-05-23 07:19:58 +08:00

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 
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

@ -163,21 +163,49 @@ export async function executeBuild(
// Analyze external imports if external options are enabled
if (options.externalPackages || bundlingResult.externalConfiguration) {
const {
externalConfiguration,
externalImports: { browser, server },
externalConfiguration = [],
externalImports: { browser = [], server = [] },
} = bundlingResult;
const implicitBrowser = browser ? [...browser] : [];
const implicitServer = server ? [...server] : [];
// TODO: Implement wildcard externalConfiguration filtering
executionResult.setExternalMetadata(
externalConfiguration
? implicitBrowser.filter((value) => !externalConfiguration.includes(value))
: implicitBrowser,
externalConfiguration
? implicitServer.filter((value) => !externalConfiguration.includes(value))
: implicitServer,
externalConfiguration,
);
// 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 = externalConfiguration.map((exclusion) => exclusion + '/');
const exclusions = new Set(externalConfiguration);
const explicitExternal = new Set<string>();
const isExplicitExternal = (dep: string): boolean => {
if (exclusions.has(dep)) {
return true;
}
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;

@ -433,7 +433,14 @@ export async function normalizeOptions(
baseHref,
cacheOptions,
crossOrigin,
externalDependencies,
externalDependencies: normalizeExternals(externalDependencies),
externalPackages:
typeof externalPackages === 'object'
? {
...externalPackages,
exclude: normalizeExternals(externalPackages.exclude),
}
: externalPackages,
extractLicenses,
inlineStyleLanguage,
jit: !aot,
@ -441,7 +448,6 @@ export async function normalizeOptions(
polyfills: polyfills === undefined || Array.isArray(polyfills) ? polyfills : [polyfills],
poll,
progress,
externalPackages,
preserveSymlinks,
stylePreprocessorOptions,
subresourceIntegrity,
@ -677,3 +683,23 @@ export function getLocaleBaseHref(
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)))];
}

@ -196,7 +196,7 @@
"additionalProperties": false
},
"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",
"items": {
"type": "string"

@ -115,7 +115,7 @@
"type": "object",
"properties": {
"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",
"items": { "type": "string" }
}

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

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

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

@ -19,7 +19,14 @@ const EXTERNAL_PACKAGE_RESOLUTION = Symbol('EXTERNAL_PACKAGE_RESOLUTION');
* @returns An esbuild 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 {
name: 'angular-external-packages',
@ -33,7 +40,7 @@ export function createExternalPackagesPlugin(options?: { exclude?: string[] }):
.map(([key]) => key);
// 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';
return;
@ -47,10 +54,26 @@ export function createExternalPackagesPlugin(options?: { exclude?: string[] }):
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;
}
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;
pluginData[EXTERNAL_PACKAGE_RESOLUTION] = true;
@ -62,11 +85,18 @@ export function createExternalPackagesPlugin(options?: { exclude?: string[] }):
resolveDir,
});
// Return result if unable to resolve or explicitly marked external (externalDependencies option)
if (!result.path || result.external) {
// Return result if unable to resolve
if (!result.path) {
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
if (loaderFileExtensions.has(extname(result.path))) {
return result;
@ -74,10 +104,9 @@ export function createExternalPackagesPlugin(options?: { exclude?: string[] }):
// Mark paths from a node modules directory as external
if (/[\\/]node_modules[\\/]/.test(result.path)) {
return {
path: args.path,
external: true,
};
seenExternals.add(args.path);
return { external: true };
}
// Otherwise return original result

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