Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(ngcc): cope with packages following APF v14+ #45833

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
24 changes: 20 additions & 4 deletions packages/compiler-cli/ngcc/src/dependencies/module_resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*/
import {AbsoluteFsPath, ReadonlyFileSystem} from '../../../src/ngtsc/file_system';
import {PathMappings} from '../path_mappings';
import {isRelativePath, resolveFileWithPostfixes} from '../utils';
import {isRelativePath, loadJson, loadSecondaryEntryPointInfoForApfV14, resolveFileWithPostfixes} from '../utils';

/**
* This is a very cut-down implementation of the TypeScript module resolution strategy.
Expand Down Expand Up @@ -110,8 +110,8 @@ export class ModuleResolver {
* Try to resolve the `moduleName` as an external entry-point by searching the `node_modules`
* folders up the tree for a matching `.../node_modules/${moduleName}`.
*
* If a folder is found but the path does not contain a `package.json` then it is marked as a
* "deep-import".
* If a folder is found but the path is not considered an entry-point (see `isEntryPoint()`) then
* it is marked as a "deep-import".
*/
private resolveAsEntryPoint(moduleName: string, fromPath: AbsoluteFsPath): ResolvedModule|null {
let folder = fromPath;
Expand All @@ -136,9 +136,25 @@ export class ModuleResolver {
* Can we consider the given path as an entry-point to a package?
*
* This is achieved by checking for the existence of `${modulePath}/package.json`.
* If there is no `package.json`, we check whether this is an APF v14+ secondary entry-point,
* which does not have its own `package.json` but has an `exports` entry in the package's primary
* `package.json`.
*/
private isEntryPoint(modulePath: AbsoluteFsPath): boolean {
return this.fs.exists(this.fs.join(modulePath, 'package.json'));
if (this.fs.exists(this.fs.join(modulePath, 'package.json'))) {
return true;
}

const packagePath = this.findPackagePath(modulePath);
if (packagePath === null) {
return false;
}

const packagePackageJson = loadJson(this.fs, this.fs.join(packagePath, 'package.json'));
const entryPointInfoForApfV14 =
loadSecondaryEntryPointInfoForApfV14(this.fs, packagePackageJson, packagePath, modulePath);

return entryPointInfoForApfV14 !== null;
}

/**
Expand Down
2 changes: 1 addition & 1 deletion packages/compiler-cli/ngcc/src/execution/cluster/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*/

import {AbsoluteFsPath} from '../../../../src/ngtsc/file_system';
import {JsonObject} from '../../packages/entry_point';
import {JsonObject} from '../../utils';
import {PackageJsonChange} from '../../writing/package_json_updater';
import {Task, TaskProcessingOutcome} from '../tasks/api';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import cluster from 'cluster';

import {AbsoluteFsPath} from '../../../../src/ngtsc/file_system';
import {JsonObject} from '../../packages/entry_point';
import {JsonObject} from '../../utils';
import {applyChange, PackageJsonChange, PackageJsonUpdate, PackageJsonUpdater} from '../../writing/package_json_updater';

import {sendMessageToMaster} from './utils';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {replaceTsWithNgInErrors} from '../../../src/ngtsc/diagnostics';
import {FileSystem} from '../../../src/ngtsc/file_system';
import {Logger} from '../../../src/ngtsc/logging';
import {ParsedConfiguration} from '../../../src/perform_compile';
import {EntryPointPackageJson, getEntryPointFormat} from '../packages/entry_point';
import {getEntryPointFormat} from '../packages/entry_point';
import {makeEntryPointBundle} from '../packages/entry_point_bundle';
import {createModuleResolutionCache, SharedFileCache} from '../packages/source_file_cache';
import {Transformer} from '../packages/transformer';
Expand Down
4 changes: 2 additions & 2 deletions packages/compiler-cli/ngcc/src/execution/tasks/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
* 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 {EntryPoint, EntryPointJsonProperty, JsonObject} from '../../packages/entry_point';
import {PartiallyOrderedList} from '../../utils';
import {EntryPoint, EntryPointJsonProperty} from '../../packages/entry_point';
import {JsonObject, PartiallyOrderedList} from '../../utils';

/**
* Represents a unit of work to be undertaken by an `Executor`.
Expand Down
89 changes: 66 additions & 23 deletions packages/compiler-cli/ngcc/src/packages/entry_point.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import ts from 'typescript';
import {AbsoluteFsPath, PathManipulation, ReadonlyFileSystem} from '../../../src/ngtsc/file_system';
import {Logger} from '../../../src/ngtsc/logging';
import {parseStatementForUmdModule} from '../host/umd_host';
import {resolveFileWithPostfixes} from '../utils';
import {JsonObject, loadJson, loadSecondaryEntryPointInfoForApfV14, resolveFileWithPostfixes} from '../utils';

import {NgccConfiguration, NgccEntryPointConfig} from './configuration';

Expand Down Expand Up @@ -49,13 +49,6 @@ export interface EntryPoint extends JsonObject {
generateDeepReexports: boolean;
}

export type JsonPrimitive = string|number|boolean|null;
export type JsonValue = JsonPrimitive|JsonArray|JsonObject|undefined;
export interface JsonArray extends Array<JsonValue> {}
export interface JsonObject {
[key: string]: JsonValue;
}

export interface PackageJsonFormatPropertiesMap {
browser?: string;
fesm2015?: string;
Expand Down Expand Up @@ -135,10 +128,11 @@ export function getEntryPointInfo(
entryPointPath: AbsoluteFsPath): GetEntryPointResult {
const packagePackageJsonPath = fs.resolve(packagePath, 'package.json');
const entryPointPackageJsonPath = fs.resolve(entryPointPath, 'package.json');
const loadedPackagePackageJson = loadPackageJson(fs, packagePackageJsonPath);
const loadedPackagePackageJson = loadJson<EntryPointPackageJson>(fs, packagePackageJsonPath);
const loadedEntryPointPackageJson = (packagePackageJsonPath === entryPointPackageJsonPath) ?
loadedPackagePackageJson :
loadPackageJson(fs, entryPointPackageJsonPath);
loadOrSynthesizeSecondaryPackageJson(
fs, packagePath, entryPointPath, entryPointPackageJsonPath, loadedPackagePackageJson);
const {packageName, packageVersion} = getPackageNameAndVersion(
fs, packagePath, loadedPackagePackageJson, loadedEntryPointPackageJson);
const repositoryUrl = getRepositoryUrl(loadedPackagePackageJson);
Expand All @@ -148,17 +142,17 @@ export function getEntryPointInfo(
let entryPointPackageJson: EntryPointPackageJson;

if (entryPointConfig === undefined) {
if (!fs.exists(entryPointPackageJsonPath)) {
// No `package.json` and no config.
if (loadedEntryPointPackageJson !== null) {
entryPointPackageJson = loadedEntryPointPackageJson;
} else if (!fs.exists(entryPointPackageJsonPath)) {
// No entry-point `package.json` or package `package.json` with exports and no config.
return NO_ENTRY_POINT;
} else if (loadedEntryPointPackageJson === null) {
} else {
// `package.json` exists but could not be parsed and there is no redeeming config.
logger.warn(`Failed to read entry point info from invalid 'package.json' file: ${
entryPointPackageJsonPath}`);

return INCOMPATIBLE_ENTRY_POINT;
} else {
entryPointPackageJson = loadedEntryPointPackageJson;
}
} else if (entryPointConfig.ignore === true) {
// Explicitly ignored entry-point.
Expand Down Expand Up @@ -254,17 +248,66 @@ export function getEntryPointFormat(
}

/**
* Parse the JSON from a `package.json` file.
* @param packageJsonPath the absolute path to the `package.json` file.
* @returns JSON from the `package.json` file if it is valid, `null` otherwise.
* Parse the JSON from a secondary `package.json` file. If no such file exists, look for a
* corresponding entry in the primary `package.json` file's `exports` property (if any) and
* synthesize the JSON from that.
*
* @param packagePath The absolute path to the containing npm package.
* @param entryPointPath The absolute path to the secondary entry-point.
* @param secondaryPackageJsonPath The absolute path to the secondary `package.json` file.
* @param primaryPackageJson The parsed JSON of the primary `package.json` (or `null` if it failed
* to be loaded).
* @returns Parsed JSON (either loaded from a secondary `package.json` file or synthesized from a
* primary one) if it is valid, `null` otherwise.
*/
function loadPackageJson(
fs: ReadonlyFileSystem, packageJsonPath: AbsoluteFsPath): EntryPointPackageJson|null {
try {
return JSON.parse(fs.readFile(packageJsonPath)) as EntryPointPackageJson;
} catch {
function loadOrSynthesizeSecondaryPackageJson(
fs: ReadonlyFileSystem, packagePath: AbsoluteFsPath, entryPointPath: AbsoluteFsPath,
secondaryPackageJsonPath: AbsoluteFsPath,
primaryPackageJson: EntryPointPackageJson|null): EntryPointPackageJson|null {
// If a secondary `package.json` exists and is valid, load and return that.
const loadedPackageJson = loadJson<EntryPointPackageJson>(fs, secondaryPackageJsonPath);
if (loadedPackageJson !== null) {
return loadedPackageJson;
}

// Try to load the entry-point info from the primary `package.json` data.
const entryPointInfo =
loadSecondaryEntryPointInfoForApfV14(fs, primaryPackageJson, packagePath, entryPointPath);
if (entryPointInfo === null) {
return null;
}

// Create a synthesized `package.json`.
//
// NOTE:
// We do not care about being able to update the synthesized `package.json` (for example, updating
// its `__processed_by_ivy_ngcc__` property), because these packages are generated with Angular
// v14+ (following the Angular Package Format v14+) and thus are already in Ivy format and do not
// require processing by `ngcc`.
const synthesizedPackageJson: EntryPointPackageJson = {
synthesized: true,
name: `${primaryPackageJson!.name}/${fs.relative(packagePath, entryPointPath)}`,
};

// Update the synthesized `package.json` with any of the supported format and types properties,
// changing paths to make them relative to the entry-point directory. This makes the synthesized
// `package.json` similar to how a `package.json` inside the entry-point directory would look
// like.
for (const prop of [...SUPPORTED_FORMAT_PROPERTIES, 'types', 'typings']) {
const packageRelativePath = entryPointInfo[prop];

if (typeof packageRelativePath === 'string') {
const absolutePath = fs.resolve(packagePath, packageRelativePath);
const entryPointRelativePath = fs.relative(entryPointPath, absolutePath);
synthesizedPackageJson[prop] =
(fs.isRooted(entryPointRelativePath) || entryPointRelativePath.startsWith('.')) ?
entryPointRelativePath :
`./${entryPointRelativePath}`;
}
}

// Return the synthesized JSON.
return synthesizedPackageJson;
}

function sniffModuleFormat(
Expand Down
2 changes: 0 additions & 2 deletions packages/compiler-cli/ngcc/src/packages/source_file_cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@ import ts from 'typescript';

import {AbsoluteFsPath, ReadonlyFileSystem} from '../../../src/ngtsc/file_system';

import {adjustElementAccessExports} from './adjust_cjs_umd_exports';

/**
* A cache that holds on to source files that can be shared for processing all entry-points in a
* single invocation of ngcc. In particular, the following files are shared across all entry-points
Expand Down
76 changes: 76 additions & 0 deletions packages/compiler-cli/ngcc/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ import ts from 'typescript';
import {absoluteFrom, AbsoluteFsPath, isRooted, ReadonlyFileSystem} from '../../src/ngtsc/file_system';
import {DeclarationNode, KnownDeclaration} from '../../src/ngtsc/reflection';

export type JsonPrimitive = string|number|boolean|null;
export type JsonValue = JsonPrimitive|JsonArray|JsonObject|undefined;
export interface JsonArray extends Array<JsonValue> {}
export interface JsonObject {
[key: string]: JsonValue;
}

/**
* A list (`Array`) of partially ordered `T` items.
*
Expand Down Expand Up @@ -164,3 +171,72 @@ export function stripDollarSuffix(value: string): string {
export function stripExtension(fileName: string): string {
return fileName.replace(/\..+$/, '');
}

/**
* Parse the JSON from a `package.json` file.
*
* @param packageJsonPath The absolute path to the `package.json` file.
* @returns JSON from the `package.json` file if it exists and is valid, `null` otherwise.
*/
export function loadJson<T extends JsonObject = JsonObject>(
fs: ReadonlyFileSystem, packageJsonPath: AbsoluteFsPath): T|null {
try {
return JSON.parse(fs.readFile(packageJsonPath)) as T;
} catch {
return null;
}
}

/**
* Given the parsed JSON of a `package.json` file, try to extract info for a secondary entry-point
* from the `exports` property. Such info will only be present for packages following Angular
* Package Format v14+.
*
* @param primaryPackageJson The parsed JSON of the primary `package.json` (or `null` if it failed
* to be loaded).
* @param packagePath The absolute path to the containing npm package.
* @param entryPointPath The absolute path to the secondary entry-point.
* @returns The `exports` info for the specified entry-point if it exists, `null` otherwise.
*/
export function loadSecondaryEntryPointInfoForApfV14(
fs: ReadonlyFileSystem, primaryPackageJson: JsonObject|null, packagePath: AbsoluteFsPath,
entryPointPath: AbsoluteFsPath): JsonObject|null {
// Check if primary `package.json` has been loaded and has an `exports` property that is an
// object.
const exportMap = primaryPackageJson?.exports;
if (!isExportObject(exportMap)) {
return null;
}

// Find the `exports` key for the secondary entry-point.
const relativeEntryPointPath = fs.relative(packagePath, entryPointPath);
const entryPointExportKey = `./${relativeEntryPointPath}`;

// Read the info for the entry-point.
const entryPointInfo = exportMap[entryPointExportKey];

// Check whether the entry-point info exists and is an export map.
return isExportObject(entryPointInfo) ? entryPointInfo : null;
}

/**
* Check whether a value read from a JSON file is a Node.js export map (either the top-level one or
* one for a subpath).
*
* In `package.json` files, the `exports` field can be of type `Object | string | string[]`, but APF
* v14+ uses an object with subpath exports for each entry-point, which in turn are conditional
* exports (see references below). This function verifies that a value read from the top-level
* `exports` field or a subpath is of type `Object` (and not `string` or `string[]`).
*
* References:
* - https://nodejs.org/api/packages.html#exports
* - https://nodejs.org/api/packages.html#subpath-exports
* - https://nodejs.org/api/packages.html#conditional-exports
* - https://v14.angular.io/guide/angular-package-format#exports
*
* @param thing The value read from the JSON file
* @returns True if the value is an `Object` (and not an `Array`).
*/
function isExportObject(thing: JsonValue): thing is JsonObject {
return (typeof thing === 'object') && (thing !== null) && !Array.isArray(thing);
}
12 changes: 11 additions & 1 deletion packages/compiler-cli/ngcc/src/writing/package_json_updater.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*/

import {AbsoluteFsPath, dirname, FileSystem} from '../../../src/ngtsc/file_system';
import {JsonObject, JsonValue} from '../packages/entry_point';
import {JsonObject, JsonValue} from '../utils';


export type PackageJsonChange = [string[], JsonValue, PackageJsonPropertyPositioning];
Expand Down Expand Up @@ -101,6 +101,7 @@ export class PackageJsonUpdate {
*/
writeChanges(packageJsonPath: AbsoluteFsPath, parsedJson?: JsonObject): void {
this.ensureNotApplied();
this.ensureNotSynthesized(parsedJson);
this.writeChangesImpl(this.changes, packageJsonPath, parsedJson);
this.applied = true;
}
Expand All @@ -110,6 +111,15 @@ export class PackageJsonUpdate {
throw new Error('Trying to apply a `PackageJsonUpdate` that has already been applied.');
}
}

private ensureNotSynthesized(parsedJson?: JsonObject) {
if (parsedJson?.synthesized) {
// Theoretically, this should never happen, because synthesized `package.json` files should
// only be created for libraries following the Angular Package Format v14+, which means they
// should already be in Ivy format and not require processing by `ngcc`.
throw new Error('Trying to update a non-existent (synthesized) `package.json` file.');
}
}
}

/** A `PackageJsonUpdater` that writes directly to the file-system. */
Expand Down