Skip to content

Commit 7c4c676

Browse files
petebacondarwinkara
authored andcommitted
feat(ivy): customize ngcc via configuration files (angular#30591)
There are scenarios where it is not possible for ngcc to guess the format or configuration of an entry-point just from the files on disk. Such scenarios include: 1) Unwanted entry-points: A spurious package.json makes ngcc think there is an entry-point when there should not be one. 2) Deep-import entry-points: some packages allow deep-imports but do not provide package.json files to indicate to ngcc that the imported path is actually an entry-point to be processed. 3) Invalid/missing package.json properties: For example, an entry-point that does not provide a valid property to a required format. The configuration is provided by one or more `ngcc.config.js` files: * If placed at the root of the project, this file can provide configuration for named packages (and their entry-points) that have been npm installed into the project. * If published as part of a package, the file can provide configuration for entry-points of the package. The configured of a package at the project level will override any configuration provided by the package itself. PR Close angular#30591
1 parent 4004d15 commit 7c4c676

10 files changed

Lines changed: 655 additions & 73 deletions

File tree

packages/compiler-cli/ngcc/src/main.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* Use of this source code is governed by an MIT-style license that can be
66
* found in the LICENSE file at https://angular.io/license
77
*/
8-
import {AbsoluteFsPath, FileSystem, absoluteFrom, getFileSystem, resolve} from '../../src/ngtsc/file_system';
8+
import {AbsoluteFsPath, FileSystem, absoluteFrom, dirname, getFileSystem, resolve} from '../../src/ngtsc/file_system';
99
import {CommonJsDependencyHost} from './dependencies/commonjs_dependency_host';
1010
import {DependencyResolver} from './dependencies/dependency_resolver';
1111
import {EsmDependencyHost} from './dependencies/esm_dependency_host';
@@ -14,6 +14,7 @@ import {UmdDependencyHost} from './dependencies/umd_dependency_host';
1414
import {ConsoleLogger, LogLevel} from './logging/console_logger';
1515
import {Logger} from './logging/logger';
1616
import {hasBeenProcessed, markAsProcessed} from './packages/build_marker';
17+
import {NgccConfiguration} from './packages/configuration';
1718
import {EntryPointFormat, EntryPointJsonProperty, SUPPORTED_FORMAT_PROPERTIES, getEntryPointFormat} from './packages/entry_point';
1819
import {makeEntryPointBundle} from './packages/entry_point_bundle';
1920
import {EntryPointFinder} from './packages/entry_point_finder';
@@ -92,7 +93,8 @@ export function mainNgcc(
9293
umd: umdDependencyHost,
9394
commonjs: commonJsDependencyHost
9495
});
95-
const finder = new EntryPointFinder(fileSystem, logger, resolver);
96+
const config = new NgccConfiguration(fileSystem, dirname(absoluteFrom(basePath)));
97+
const finder = new EntryPointFinder(fileSystem, config, logger, resolver);
9698
const fileWriter = getFileWriter(fileSystem, createNewEntryPointFormats);
9799

98100
const absoluteTargetEntryPointPath =
@@ -192,6 +194,10 @@ function hasProcessedTargetEntryPoint(
192194
fs: FileSystem, targetPath: AbsoluteFsPath, propertiesToConsider: string[],
193195
compileAllFormats: boolean) {
194196
const packageJsonPath = resolve(targetPath, 'package.json');
197+
// It might be that this target is configured in which case its package.json might not exist.
198+
if (!fs.exists(packageJsonPath)) {
199+
return false;
200+
}
195201
const packageJson = JSON.parse(fs.readFile(packageJsonPath));
196202

197203
for (const property of propertiesToConsider) {

packages/compiler-cli/ngcc/src/packages/build_marker.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* Use of this source code is governed by an MIT-style license that can be
66
* found in the LICENSE file at https://angular.io/license
77
*/
8-
import {AbsoluteFsPath, FileSystem} from '../../../src/ngtsc/file_system';
8+
import {AbsoluteFsPath, FileSystem, dirname} from '../../../src/ngtsc/file_system';
99
import {EntryPointJsonProperty, EntryPointPackageJson} from './entry_point';
1010

1111
export const NGCC_VERSION = '0.0.0-PLACEHOLDER';
@@ -49,5 +49,8 @@ export function markAsProcessed(
4949
format: EntryPointJsonProperty) {
5050
if (!packageJson.__processed_by_ivy_ngcc__) packageJson.__processed_by_ivy_ngcc__ = {};
5151
packageJson.__processed_by_ivy_ngcc__[format] = NGCC_VERSION;
52+
// Just in case this package.json was synthesized due to a custom configuration
53+
// we will ensure that the path to the containing folder exists before we write the file.
54+
fs.ensureDir(dirname(packageJsonPath));
5255
fs.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2));
5356
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
import * as vm from 'vm';
9+
import {AbsoluteFsPath, FileSystem, dirname, join, resolve} from '../../../src/ngtsc/file_system';
10+
import {PackageJsonFormatProperties} from './entry_point';
11+
12+
/**
13+
* The format of a project level configuration file.
14+
*/
15+
export interface NgccProjectConfig { packages: {[packagePath: string]: NgccPackageConfig}; }
16+
17+
/**
18+
* The format of a package level configuration file.
19+
*/
20+
export interface NgccPackageConfig {
21+
/**
22+
* The entry-points to configure for this package.
23+
*
24+
* In the config file the keys can be paths relative to the package path;
25+
* but when being read back from the `NgccConfiguration` service, these paths
26+
* will be absolute.
27+
*/
28+
entryPoints: {[entryPointPath: string]: NgccEntryPointConfig;};
29+
}
30+
31+
/**
32+
* Configuration options for an entry-point.
33+
*
34+
* The existence of a configuration for a path tells ngcc that this should be considered for
35+
* processing as an entry-point.
36+
*/
37+
export interface NgccEntryPointConfig {
38+
/** Do not process (or even acknowledge the existence of) this entry-point, if true. */
39+
ignore?: boolean;
40+
/**
41+
* This property, if provided, holds values that will override equivalent properties in an
42+
* entry-point's package.json file.
43+
*/
44+
override?: PackageJsonFormatProperties;
45+
}
46+
47+
const NGCC_CONFIG_FILENAME = 'ngcc.config.js';
48+
49+
export class NgccConfiguration {
50+
// TODO: change string => ModuleSpecifier when we tighten the path types in #30556
51+
private cache = new Map<string, NgccPackageConfig>();
52+
53+
constructor(private fs: FileSystem, baseDir: AbsoluteFsPath) {
54+
const projectConfig = this.loadProjectConfig(baseDir);
55+
for (const packagePath in projectConfig.packages) {
56+
const absPackagePath = resolve(baseDir, 'node_modules', packagePath);
57+
const packageConfig = projectConfig.packages[packagePath];
58+
packageConfig.entryPoints =
59+
this.processEntryPoints(absPackagePath, packageConfig.entryPoints);
60+
this.cache.set(absPackagePath, packageConfig);
61+
}
62+
}
63+
64+
getConfig(packagePath: AbsoluteFsPath): NgccPackageConfig {
65+
if (this.cache.has(packagePath)) {
66+
return this.cache.get(packagePath) !;
67+
}
68+
69+
const packageConfig = this.loadPackageConfig(packagePath);
70+
packageConfig.entryPoints = this.processEntryPoints(packagePath, packageConfig.entryPoints);
71+
this.cache.set(packagePath, packageConfig);
72+
return packageConfig;
73+
}
74+
75+
private loadProjectConfig(baseDir: AbsoluteFsPath): NgccProjectConfig {
76+
const configFilePath = join(baseDir, NGCC_CONFIG_FILENAME);
77+
if (this.fs.exists(configFilePath)) {
78+
try {
79+
return this.evalSrcFile(configFilePath);
80+
} catch (e) {
81+
throw new Error(`Invalid project configuration file at "${configFilePath}": ` + e.message);
82+
}
83+
} else {
84+
return {packages: {}};
85+
}
86+
}
87+
88+
private loadPackageConfig(packagePath: AbsoluteFsPath): NgccPackageConfig {
89+
const configFilePath = join(packagePath, NGCC_CONFIG_FILENAME);
90+
if (this.fs.exists(configFilePath)) {
91+
try {
92+
return this.evalSrcFile(configFilePath);
93+
} catch (e) {
94+
throw new Error(`Invalid package configuration file at "${configFilePath}": ` + e.message);
95+
}
96+
} else {
97+
return {entryPoints: {}};
98+
}
99+
}
100+
101+
private evalSrcFile(srcPath: AbsoluteFsPath): any {
102+
const src = this.fs.readFile(srcPath);
103+
const theExports = {};
104+
const sandbox = {
105+
module: {exports: theExports},
106+
exports: theExports, require,
107+
__dirname: dirname(srcPath),
108+
__filename: srcPath
109+
};
110+
vm.runInNewContext(src, sandbox, {filename: srcPath});
111+
return sandbox.module.exports;
112+
}
113+
114+
private processEntryPoints(
115+
packagePath: AbsoluteFsPath, entryPoints: {[entryPointPath: string]: NgccEntryPointConfig;}):
116+
{[entryPointPath: string]: NgccEntryPointConfig;} {
117+
const processedEntryPoints: {[entryPointPath: string]: NgccEntryPointConfig;} = {};
118+
for (const entryPointPath in entryPoints) {
119+
// Change the keys to be absolute paths
120+
processedEntryPoints[resolve(packagePath, entryPointPath)] = entryPoints[entryPointPath];
121+
}
122+
return processedEntryPoints;
123+
}
124+
}

packages/compiler-cli/ngcc/src/packages/entry_point.ts

Lines changed: 46 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,13 @@
55
* Use of this source code is governed by an MIT-style license that can be
66
* found in the LICENSE file at https://angular.io/license
77
*/
8+
import {relative} from 'canonical-path';
9+
import {basename} from 'path';
810
import * as ts from 'typescript';
911
import {AbsoluteFsPath, FileSystem, join, resolve} from '../../../src/ngtsc/file_system';
1012
import {parseStatementForUmdModule} from '../host/umd_host';
1113
import {Logger} from '../logging/logger';
14+
import {NgccConfiguration, NgccEntryPointConfig} from './configuration';
1215

1316
/**
1417
* The possible values for the format of an entry-point.
@@ -34,7 +37,7 @@ export interface EntryPoint {
3437
compiledByAngular: boolean;
3538
}
3639

37-
interface PackageJsonFormatProperties {
40+
export interface PackageJsonFormatProperties {
3841
fesm2015?: string;
3942
fesm5?: string;
4043
es2015?: string; // if exists then it is actually FESM2015
@@ -67,35 +70,44 @@ export const SUPPORTED_FORMAT_PROPERTIES: EntryPointJsonProperty[] =
6770
* @returns An entry-point if it is valid, `null` otherwise.
6871
*/
6972
export function getEntryPointInfo(
70-
fs: FileSystem, logger: Logger, packagePath: AbsoluteFsPath,
73+
fs: FileSystem, config: NgccConfiguration, logger: Logger, packagePath: AbsoluteFsPath,
7174
entryPointPath: AbsoluteFsPath): EntryPoint|null {
7275
const packageJsonPath = resolve(entryPointPath, 'package.json');
73-
if (!fs.exists(packageJsonPath)) {
76+
const entryPointConfig = config.getConfig(packagePath).entryPoints[entryPointPath];
77+
if (entryPointConfig === undefined && !fs.exists(packageJsonPath)) {
7478
return null;
7579
}
7680

77-
const entryPointPackageJson = loadEntryPointPackage(fs, logger, packageJsonPath);
78-
if (!entryPointPackageJson) {
81+
if (entryPointConfig !== undefined && entryPointConfig.ignore === true) {
7982
return null;
8083
}
8184

85+
const loadedEntryPointPackageJson =
86+
loadEntryPointPackage(fs, logger, packageJsonPath, entryPointConfig !== undefined);
87+
const entryPointPackageJson = mergeConfigAndPackageJson(
88+
loadedEntryPointPackageJson, entryPointConfig, packagePath, entryPointPath);
89+
if (entryPointPackageJson === null) {
90+
return null;
91+
}
8292

8393
// We must have a typings property
8494
const typings = entryPointPackageJson.typings || entryPointPackageJson.types;
8595
if (!typings) {
8696
return null;
8797
}
8898

89-
// Also there must exist a `metadata.json` file next to the typings entry-point.
99+
// An entry-point is assumed to be compiled by Angular if there is either:
100+
// * a `metadata.json` file next to the typings entry-point
101+
// * a custom config for this entry-point
90102
const metadataPath = resolve(entryPointPath, typings.replace(/\.d\.ts$/, '') + '.metadata.json');
103+
const compiledByAngular = entryPointConfig !== undefined || fs.exists(metadataPath);
91104

92105
const entryPointInfo: EntryPoint = {
93106
name: entryPointPackageJson.name,
94107
packageJson: entryPointPackageJson,
95108
package: packagePath,
96109
path: entryPointPath,
97-
typings: resolve(entryPointPath, typings),
98-
compiledByAngular: fs.exists(metadataPath),
110+
typings: resolve(entryPointPath, typings), compiledByAngular,
99111
};
100112

101113
return entryPointInfo;
@@ -140,12 +152,15 @@ export function getEntryPointFormat(
140152
* @returns JSON from the package.json file if it is valid, `null` otherwise.
141153
*/
142154
function loadEntryPointPackage(
143-
fs: FileSystem, logger: Logger, packageJsonPath: AbsoluteFsPath): EntryPointPackageJson|null {
155+
fs: FileSystem, logger: Logger, packageJsonPath: AbsoluteFsPath,
156+
hasConfig: boolean): EntryPointPackageJson|null {
144157
try {
145158
return JSON.parse(fs.readFile(packageJsonPath));
146159
} catch (e) {
147-
// We may have run into a package.json with unexpected symbols
148-
logger.warn(`Failed to read entry point info from ${packageJsonPath} with error ${e}.`);
160+
if (!hasConfig) {
161+
// We may have run into a package.json with unexpected symbols
162+
logger.warn(`Failed to read entry point info from ${packageJsonPath} with error ${e}.`);
163+
}
149164
return null;
150165
}
151166
}
@@ -156,3 +171,23 @@ function isUmdModule(fs: FileSystem, sourceFilePath: AbsoluteFsPath): boolean {
156171
return sourceFile.statements.length > 0 &&
157172
parseStatementForUmdModule(sourceFile.statements[0]) !== null;
158173
}
174+
175+
function mergeConfigAndPackageJson(
176+
entryPointPackageJson: EntryPointPackageJson | null,
177+
entryPointConfig: NgccEntryPointConfig | undefined, packagePath: AbsoluteFsPath,
178+
entryPointPath: AbsoluteFsPath): EntryPointPackageJson|null {
179+
if (entryPointPackageJson !== null) {
180+
if (entryPointConfig === undefined) {
181+
return entryPointPackageJson;
182+
} else {
183+
return {...entryPointPackageJson, ...entryPointConfig.override};
184+
}
185+
} else {
186+
if (entryPointConfig === undefined) {
187+
return null;
188+
} else {
189+
const name = `${basename(packagePath)}/${relative(packagePath, entryPointPath)}`;
190+
return {name, ...entryPointConfig.override};
191+
}
192+
}
193+
}

packages/compiler-cli/ngcc/src/packages/entry_point_finder.ts

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,13 @@ import {AbsoluteFsPath, FileSystem, join, resolve} from '../../../src/ngtsc/file
99
import {DependencyResolver, SortedEntryPointsInfo} from '../dependencies/dependency_resolver';
1010
import {Logger} from '../logging/logger';
1111
import {PathMappings} from '../utils';
12-
12+
import {NgccConfiguration} from './configuration';
1313
import {EntryPoint, getEntryPointInfo} from './entry_point';
1414

1515
export class EntryPointFinder {
1616
constructor(
17-
private fs: FileSystem, private logger: Logger, private resolver: DependencyResolver) {}
17+
private fs: FileSystem, private config: NgccConfiguration, private logger: Logger,
18+
private resolver: DependencyResolver) {}
1819
/**
1920
* Search the given directory, and sub-directories, for Angular package entry points.
2021
* @param sourceDirectory An absolute path to the directory to search for entry points.
@@ -111,7 +112,8 @@ export class EntryPointFinder {
111112
const entryPoints: EntryPoint[] = [];
112113

113114
// Try to get an entry point from the top level package directory
114-
const topLevelEntryPoint = getEntryPointInfo(this.fs, this.logger, packagePath, packagePath);
115+
const topLevelEntryPoint =
116+
getEntryPointInfo(this.fs, this.config, this.logger, packagePath, packagePath);
115117

116118
// If there is no primary entry-point then exit
117119
if (topLevelEntryPoint === null) {
@@ -120,8 +122,11 @@ export class EntryPointFinder {
120122

121123
// Otherwise store it and search for secondary entry-points
122124
entryPoints.push(topLevelEntryPoint);
123-
this.walkDirectory(packagePath, subdir => {
124-
const subEntryPoint = getEntryPointInfo(this.fs, this.logger, packagePath, subdir);
125+
this.walkDirectory(packagePath, packagePath, (path, isDirectory) => {
126+
// If the path is a JS file then strip its extension and see if we can match an entry-point.
127+
const possibleEntryPointPath = isDirectory ? path : stripJsExtension(path);
128+
const subEntryPoint =
129+
getEntryPointInfo(this.fs, this.config, this.logger, packagePath, possibleEntryPointPath);
125130
if (subEntryPoint !== null) {
126131
entryPoints.push(subEntryPoint);
127132
}
@@ -136,22 +141,29 @@ export class EntryPointFinder {
136141
* @param dir the directory to recursively walk.
137142
* @param fn the function to apply to each directory.
138143
*/
139-
private walkDirectory(dir: AbsoluteFsPath, fn: (dir: AbsoluteFsPath) => void) {
144+
private walkDirectory(
145+
packagePath: AbsoluteFsPath, dir: AbsoluteFsPath,
146+
fn: (path: AbsoluteFsPath, isDirectory: boolean) => void) {
140147
return this.fs
141148
.readdir(dir)
142149
// Not interested in hidden files
143-
.filter(p => !p.startsWith('.'))
150+
.filter(path => !path.startsWith('.'))
144151
// Ignore node_modules
145-
.filter(p => p !== 'node_modules')
146-
// Only interested in directories (and only those that are not symlinks)
147-
.filter(p => {
148-
const stat = this.fs.lstat(resolve(dir, p));
149-
return stat.isDirectory() && !stat.isSymbolicLink();
150-
})
151-
.forEach(subDir => {
152-
const resolvedSubDir = resolve(dir, subDir);
153-
fn(resolvedSubDir);
154-
this.walkDirectory(resolvedSubDir, fn);
152+
.filter(path => path !== 'node_modules')
153+
.map(path => resolve(dir, path))
154+
.forEach(path => {
155+
const stat = this.fs.lstat(path);
156+
157+
if (stat.isSymbolicLink()) {
158+
// We are not interested in symbolic links
159+
return;
160+
}
161+
162+
fn(path, stat.isDirectory());
163+
164+
if (stat.isDirectory()) {
165+
this.walkDirectory(packagePath, path, fn);
166+
}
155167
});
156168
}
157169
}
@@ -187,3 +199,7 @@ function removeDeeperPaths(value: AbsoluteFsPath, index: number, array: Absolute
187199
function values<T>(obj: {[key: string]: T}): T[] {
188200
return Object.keys(obj).map(key => obj[key]);
189201
}
202+
203+
function stripJsExtension<T extends string>(filePath: T): T {
204+
return filePath.replace(/\.js$/, '') as T;
205+
}

0 commit comments

Comments
 (0)