feat(ivy): customize ngcc via configuration files (#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 #30591
This commit is contained in:

committed by
Kara Erickson

parent
4004d15ba5
commit
7c4c676413
@ -5,7 +5,7 @@
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.io/license
|
||||
*/
|
||||
import {AbsoluteFsPath, FileSystem, absoluteFrom, getFileSystem, resolve} from '../../src/ngtsc/file_system';
|
||||
import {AbsoluteFsPath, FileSystem, absoluteFrom, dirname, getFileSystem, resolve} from '../../src/ngtsc/file_system';
|
||||
import {CommonJsDependencyHost} from './dependencies/commonjs_dependency_host';
|
||||
import {DependencyResolver} from './dependencies/dependency_resolver';
|
||||
import {EsmDependencyHost} from './dependencies/esm_dependency_host';
|
||||
@ -14,6 +14,7 @@ import {UmdDependencyHost} from './dependencies/umd_dependency_host';
|
||||
import {ConsoleLogger, LogLevel} from './logging/console_logger';
|
||||
import {Logger} from './logging/logger';
|
||||
import {hasBeenProcessed, markAsProcessed} from './packages/build_marker';
|
||||
import {NgccConfiguration} from './packages/configuration';
|
||||
import {EntryPointFormat, EntryPointJsonProperty, SUPPORTED_FORMAT_PROPERTIES, getEntryPointFormat} from './packages/entry_point';
|
||||
import {makeEntryPointBundle} from './packages/entry_point_bundle';
|
||||
import {EntryPointFinder} from './packages/entry_point_finder';
|
||||
@ -92,7 +93,8 @@ export function mainNgcc(
|
||||
umd: umdDependencyHost,
|
||||
commonjs: commonJsDependencyHost
|
||||
});
|
||||
const finder = new EntryPointFinder(fileSystem, logger, resolver);
|
||||
const config = new NgccConfiguration(fileSystem, dirname(absoluteFrom(basePath)));
|
||||
const finder = new EntryPointFinder(fileSystem, config, logger, resolver);
|
||||
const fileWriter = getFileWriter(fileSystem, createNewEntryPointFormats);
|
||||
|
||||
const absoluteTargetEntryPointPath =
|
||||
@ -192,6 +194,10 @@ function hasProcessedTargetEntryPoint(
|
||||
fs: FileSystem, targetPath: AbsoluteFsPath, propertiesToConsider: string[],
|
||||
compileAllFormats: boolean) {
|
||||
const packageJsonPath = resolve(targetPath, 'package.json');
|
||||
// It might be that this target is configured in which case its package.json might not exist.
|
||||
if (!fs.exists(packageJsonPath)) {
|
||||
return false;
|
||||
}
|
||||
const packageJson = JSON.parse(fs.readFile(packageJsonPath));
|
||||
|
||||
for (const property of propertiesToConsider) {
|
||||
|
@ -5,7 +5,7 @@
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.io/license
|
||||
*/
|
||||
import {AbsoluteFsPath, FileSystem} from '../../../src/ngtsc/file_system';
|
||||
import {AbsoluteFsPath, FileSystem, dirname} from '../../../src/ngtsc/file_system';
|
||||
import {EntryPointJsonProperty, EntryPointPackageJson} from './entry_point';
|
||||
|
||||
export const NGCC_VERSION = '0.0.0-PLACEHOLDER';
|
||||
@ -49,5 +49,8 @@ export function markAsProcessed(
|
||||
format: EntryPointJsonProperty) {
|
||||
if (!packageJson.__processed_by_ivy_ngcc__) packageJson.__processed_by_ivy_ngcc__ = {};
|
||||
packageJson.__processed_by_ivy_ngcc__[format] = NGCC_VERSION;
|
||||
// Just in case this package.json was synthesized due to a custom configuration
|
||||
// we will ensure that the path to the containing folder exists before we write the file.
|
||||
fs.ensureDir(dirname(packageJsonPath));
|
||||
fs.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2));
|
||||
}
|
||||
|
124
packages/compiler-cli/ngcc/src/packages/configuration.ts
Normal file
124
packages/compiler-cli/ngcc/src/packages/configuration.ts
Normal file
@ -0,0 +1,124 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google Inc. All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.io/license
|
||||
*/
|
||||
import * as vm from 'vm';
|
||||
import {AbsoluteFsPath, FileSystem, dirname, join, resolve} from '../../../src/ngtsc/file_system';
|
||||
import {PackageJsonFormatProperties} from './entry_point';
|
||||
|
||||
/**
|
||||
* The format of a project level configuration file.
|
||||
*/
|
||||
export interface NgccProjectConfig { packages: {[packagePath: string]: NgccPackageConfig}; }
|
||||
|
||||
/**
|
||||
* The format of a package level configuration file.
|
||||
*/
|
||||
export interface NgccPackageConfig {
|
||||
/**
|
||||
* The entry-points to configure for this package.
|
||||
*
|
||||
* In the config file the keys can be paths relative to the package path;
|
||||
* but when being read back from the `NgccConfiguration` service, these paths
|
||||
* will be absolute.
|
||||
*/
|
||||
entryPoints: {[entryPointPath: string]: NgccEntryPointConfig;};
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration options for an entry-point.
|
||||
*
|
||||
* The existence of a configuration for a path tells ngcc that this should be considered for
|
||||
* processing as an entry-point.
|
||||
*/
|
||||
export interface NgccEntryPointConfig {
|
||||
/** Do not process (or even acknowledge the existence of) this entry-point, if true. */
|
||||
ignore?: boolean;
|
||||
/**
|
||||
* This property, if provided, holds values that will override equivalent properties in an
|
||||
* entry-point's package.json file.
|
||||
*/
|
||||
override?: PackageJsonFormatProperties;
|
||||
}
|
||||
|
||||
const NGCC_CONFIG_FILENAME = 'ngcc.config.js';
|
||||
|
||||
export class NgccConfiguration {
|
||||
// TODO: change string => ModuleSpecifier when we tighten the path types in #30556
|
||||
private cache = new Map<string, NgccPackageConfig>();
|
||||
|
||||
constructor(private fs: FileSystem, baseDir: AbsoluteFsPath) {
|
||||
const projectConfig = this.loadProjectConfig(baseDir);
|
||||
for (const packagePath in projectConfig.packages) {
|
||||
const absPackagePath = resolve(baseDir, 'node_modules', packagePath);
|
||||
const packageConfig = projectConfig.packages[packagePath];
|
||||
packageConfig.entryPoints =
|
||||
this.processEntryPoints(absPackagePath, packageConfig.entryPoints);
|
||||
this.cache.set(absPackagePath, packageConfig);
|
||||
}
|
||||
}
|
||||
|
||||
getConfig(packagePath: AbsoluteFsPath): NgccPackageConfig {
|
||||
if (this.cache.has(packagePath)) {
|
||||
return this.cache.get(packagePath) !;
|
||||
}
|
||||
|
||||
const packageConfig = this.loadPackageConfig(packagePath);
|
||||
packageConfig.entryPoints = this.processEntryPoints(packagePath, packageConfig.entryPoints);
|
||||
this.cache.set(packagePath, packageConfig);
|
||||
return packageConfig;
|
||||
}
|
||||
|
||||
private loadProjectConfig(baseDir: AbsoluteFsPath): NgccProjectConfig {
|
||||
const configFilePath = join(baseDir, NGCC_CONFIG_FILENAME);
|
||||
if (this.fs.exists(configFilePath)) {
|
||||
try {
|
||||
return this.evalSrcFile(configFilePath);
|
||||
} catch (e) {
|
||||
throw new Error(`Invalid project configuration file at "${configFilePath}": ` + e.message);
|
||||
}
|
||||
} else {
|
||||
return {packages: {}};
|
||||
}
|
||||
}
|
||||
|
||||
private loadPackageConfig(packagePath: AbsoluteFsPath): NgccPackageConfig {
|
||||
const configFilePath = join(packagePath, NGCC_CONFIG_FILENAME);
|
||||
if (this.fs.exists(configFilePath)) {
|
||||
try {
|
||||
return this.evalSrcFile(configFilePath);
|
||||
} catch (e) {
|
||||
throw new Error(`Invalid package configuration file at "${configFilePath}": ` + e.message);
|
||||
}
|
||||
} else {
|
||||
return {entryPoints: {}};
|
||||
}
|
||||
}
|
||||
|
||||
private evalSrcFile(srcPath: AbsoluteFsPath): any {
|
||||
const src = this.fs.readFile(srcPath);
|
||||
const theExports = {};
|
||||
const sandbox = {
|
||||
module: {exports: theExports},
|
||||
exports: theExports, require,
|
||||
__dirname: dirname(srcPath),
|
||||
__filename: srcPath
|
||||
};
|
||||
vm.runInNewContext(src, sandbox, {filename: srcPath});
|
||||
return sandbox.module.exports;
|
||||
}
|
||||
|
||||
private processEntryPoints(
|
||||
packagePath: AbsoluteFsPath, entryPoints: {[entryPointPath: string]: NgccEntryPointConfig;}):
|
||||
{[entryPointPath: string]: NgccEntryPointConfig;} {
|
||||
const processedEntryPoints: {[entryPointPath: string]: NgccEntryPointConfig;} = {};
|
||||
for (const entryPointPath in entryPoints) {
|
||||
// Change the keys to be absolute paths
|
||||
processedEntryPoints[resolve(packagePath, entryPointPath)] = entryPoints[entryPointPath];
|
||||
}
|
||||
return processedEntryPoints;
|
||||
}
|
||||
}
|
@ -5,10 +5,13 @@
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.io/license
|
||||
*/
|
||||
import {relative} from 'canonical-path';
|
||||
import {basename} from 'path';
|
||||
import * as ts from 'typescript';
|
||||
import {AbsoluteFsPath, FileSystem, join, resolve} from '../../../src/ngtsc/file_system';
|
||||
import {parseStatementForUmdModule} from '../host/umd_host';
|
||||
import {Logger} from '../logging/logger';
|
||||
import {NgccConfiguration, NgccEntryPointConfig} from './configuration';
|
||||
|
||||
/**
|
||||
* The possible values for the format of an entry-point.
|
||||
@ -34,7 +37,7 @@ export interface EntryPoint {
|
||||
compiledByAngular: boolean;
|
||||
}
|
||||
|
||||
interface PackageJsonFormatProperties {
|
||||
export interface PackageJsonFormatProperties {
|
||||
fesm2015?: string;
|
||||
fesm5?: string;
|
||||
es2015?: string; // if exists then it is actually FESM2015
|
||||
@ -67,18 +70,25 @@ export const SUPPORTED_FORMAT_PROPERTIES: EntryPointJsonProperty[] =
|
||||
* @returns An entry-point if it is valid, `null` otherwise.
|
||||
*/
|
||||
export function getEntryPointInfo(
|
||||
fs: FileSystem, logger: Logger, packagePath: AbsoluteFsPath,
|
||||
fs: FileSystem, config: NgccConfiguration, logger: Logger, packagePath: AbsoluteFsPath,
|
||||
entryPointPath: AbsoluteFsPath): EntryPoint|null {
|
||||
const packageJsonPath = resolve(entryPointPath, 'package.json');
|
||||
if (!fs.exists(packageJsonPath)) {
|
||||
const entryPointConfig = config.getConfig(packagePath).entryPoints[entryPointPath];
|
||||
if (entryPointConfig === undefined && !fs.exists(packageJsonPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entryPointPackageJson = loadEntryPointPackage(fs, logger, packageJsonPath);
|
||||
if (!entryPointPackageJson) {
|
||||
if (entryPointConfig !== undefined && entryPointConfig.ignore === true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const loadedEntryPointPackageJson =
|
||||
loadEntryPointPackage(fs, logger, packageJsonPath, entryPointConfig !== undefined);
|
||||
const entryPointPackageJson = mergeConfigAndPackageJson(
|
||||
loadedEntryPointPackageJson, entryPointConfig, packagePath, entryPointPath);
|
||||
if (entryPointPackageJson === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// We must have a typings property
|
||||
const typings = entryPointPackageJson.typings || entryPointPackageJson.types;
|
||||
@ -86,16 +96,18 @@ export function getEntryPointInfo(
|
||||
return null;
|
||||
}
|
||||
|
||||
// Also there must exist a `metadata.json` file next to the typings entry-point.
|
||||
// An entry-point is assumed to be compiled by Angular if there is either:
|
||||
// * a `metadata.json` file next to the typings entry-point
|
||||
// * a custom config for this entry-point
|
||||
const metadataPath = resolve(entryPointPath, typings.replace(/\.d\.ts$/, '') + '.metadata.json');
|
||||
const compiledByAngular = entryPointConfig !== undefined || fs.exists(metadataPath);
|
||||
|
||||
const entryPointInfo: EntryPoint = {
|
||||
name: entryPointPackageJson.name,
|
||||
packageJson: entryPointPackageJson,
|
||||
package: packagePath,
|
||||
path: entryPointPath,
|
||||
typings: resolve(entryPointPath, typings),
|
||||
compiledByAngular: fs.exists(metadataPath),
|
||||
typings: resolve(entryPointPath, typings), compiledByAngular,
|
||||
};
|
||||
|
||||
return entryPointInfo;
|
||||
@ -140,12 +152,15 @@ export function getEntryPointFormat(
|
||||
* @returns JSON from the package.json file if it is valid, `null` otherwise.
|
||||
*/
|
||||
function loadEntryPointPackage(
|
||||
fs: FileSystem, logger: Logger, packageJsonPath: AbsoluteFsPath): EntryPointPackageJson|null {
|
||||
fs: FileSystem, logger: Logger, packageJsonPath: AbsoluteFsPath,
|
||||
hasConfig: boolean): EntryPointPackageJson|null {
|
||||
try {
|
||||
return JSON.parse(fs.readFile(packageJsonPath));
|
||||
} catch (e) {
|
||||
// We may have run into a package.json with unexpected symbols
|
||||
logger.warn(`Failed to read entry point info from ${packageJsonPath} with error ${e}.`);
|
||||
if (!hasConfig) {
|
||||
// We may have run into a package.json with unexpected symbols
|
||||
logger.warn(`Failed to read entry point info from ${packageJsonPath} with error ${e}.`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -156,3 +171,23 @@ function isUmdModule(fs: FileSystem, sourceFilePath: AbsoluteFsPath): boolean {
|
||||
return sourceFile.statements.length > 0 &&
|
||||
parseStatementForUmdModule(sourceFile.statements[0]) !== null;
|
||||
}
|
||||
|
||||
function mergeConfigAndPackageJson(
|
||||
entryPointPackageJson: EntryPointPackageJson | null,
|
||||
entryPointConfig: NgccEntryPointConfig | undefined, packagePath: AbsoluteFsPath,
|
||||
entryPointPath: AbsoluteFsPath): EntryPointPackageJson|null {
|
||||
if (entryPointPackageJson !== null) {
|
||||
if (entryPointConfig === undefined) {
|
||||
return entryPointPackageJson;
|
||||
} else {
|
||||
return {...entryPointPackageJson, ...entryPointConfig.override};
|
||||
}
|
||||
} else {
|
||||
if (entryPointConfig === undefined) {
|
||||
return null;
|
||||
} else {
|
||||
const name = `${basename(packagePath)}/${relative(packagePath, entryPointPath)}`;
|
||||
return {name, ...entryPointConfig.override};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -9,12 +9,13 @@ import {AbsoluteFsPath, FileSystem, join, resolve} from '../../../src/ngtsc/file
|
||||
import {DependencyResolver, SortedEntryPointsInfo} from '../dependencies/dependency_resolver';
|
||||
import {Logger} from '../logging/logger';
|
||||
import {PathMappings} from '../utils';
|
||||
|
||||
import {NgccConfiguration} from './configuration';
|
||||
import {EntryPoint, getEntryPointInfo} from './entry_point';
|
||||
|
||||
export class EntryPointFinder {
|
||||
constructor(
|
||||
private fs: FileSystem, private logger: Logger, private resolver: DependencyResolver) {}
|
||||
private fs: FileSystem, private config: NgccConfiguration, private logger: Logger,
|
||||
private resolver: DependencyResolver) {}
|
||||
/**
|
||||
* Search the given directory, and sub-directories, for Angular package entry points.
|
||||
* @param sourceDirectory An absolute path to the directory to search for entry points.
|
||||
@ -111,7 +112,8 @@ export class EntryPointFinder {
|
||||
const entryPoints: EntryPoint[] = [];
|
||||
|
||||
// Try to get an entry point from the top level package directory
|
||||
const topLevelEntryPoint = getEntryPointInfo(this.fs, this.logger, packagePath, packagePath);
|
||||
const topLevelEntryPoint =
|
||||
getEntryPointInfo(this.fs, this.config, this.logger, packagePath, packagePath);
|
||||
|
||||
// If there is no primary entry-point then exit
|
||||
if (topLevelEntryPoint === null) {
|
||||
@ -120,8 +122,11 @@ export class EntryPointFinder {
|
||||
|
||||
// Otherwise store it and search for secondary entry-points
|
||||
entryPoints.push(topLevelEntryPoint);
|
||||
this.walkDirectory(packagePath, subdir => {
|
||||
const subEntryPoint = getEntryPointInfo(this.fs, this.logger, packagePath, subdir);
|
||||
this.walkDirectory(packagePath, packagePath, (path, isDirectory) => {
|
||||
// If the path is a JS file then strip its extension and see if we can match an entry-point.
|
||||
const possibleEntryPointPath = isDirectory ? path : stripJsExtension(path);
|
||||
const subEntryPoint =
|
||||
getEntryPointInfo(this.fs, this.config, this.logger, packagePath, possibleEntryPointPath);
|
||||
if (subEntryPoint !== null) {
|
||||
entryPoints.push(subEntryPoint);
|
||||
}
|
||||
@ -136,22 +141,29 @@ export class EntryPointFinder {
|
||||
* @param dir the directory to recursively walk.
|
||||
* @param fn the function to apply to each directory.
|
||||
*/
|
||||
private walkDirectory(dir: AbsoluteFsPath, fn: (dir: AbsoluteFsPath) => void) {
|
||||
private walkDirectory(
|
||||
packagePath: AbsoluteFsPath, dir: AbsoluteFsPath,
|
||||
fn: (path: AbsoluteFsPath, isDirectory: boolean) => void) {
|
||||
return this.fs
|
||||
.readdir(dir)
|
||||
// Not interested in hidden files
|
||||
.filter(p => !p.startsWith('.'))
|
||||
.filter(path => !path.startsWith('.'))
|
||||
// Ignore node_modules
|
||||
.filter(p => p !== 'node_modules')
|
||||
// Only interested in directories (and only those that are not symlinks)
|
||||
.filter(p => {
|
||||
const stat = this.fs.lstat(resolve(dir, p));
|
||||
return stat.isDirectory() && !stat.isSymbolicLink();
|
||||
})
|
||||
.forEach(subDir => {
|
||||
const resolvedSubDir = resolve(dir, subDir);
|
||||
fn(resolvedSubDir);
|
||||
this.walkDirectory(resolvedSubDir, fn);
|
||||
.filter(path => path !== 'node_modules')
|
||||
.map(path => resolve(dir, path))
|
||||
.forEach(path => {
|
||||
const stat = this.fs.lstat(path);
|
||||
|
||||
if (stat.isSymbolicLink()) {
|
||||
// We are not interested in symbolic links
|
||||
return;
|
||||
}
|
||||
|
||||
fn(path, stat.isDirectory());
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
this.walkDirectory(packagePath, path, fn);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -187,3 +199,7 @@ function removeDeeperPaths(value: AbsoluteFsPath, index: number, array: Absolute
|
||||
function values<T>(obj: {[key: string]: T}): T[] {
|
||||
return Object.keys(obj).map(key => obj[key]);
|
||||
}
|
||||
|
||||
function stripJsExtension<T extends string>(filePath: T): T {
|
||||
return filePath.replace(/\.js$/, '') as T;
|
||||
}
|
||||
|
Reference in New Issue
Block a user