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:
Pete Bacon Darwin
2019-05-21 15:23:24 +01:00
committed by Kara Erickson
parent 4004d15ba5
commit 7c4c676413
10 changed files with 655 additions and 73 deletions

View File

@ -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) {

View File

@ -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));
}

View 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;
}
}

View File

@ -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};
}
}
}

View File

@ -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;
}