Skip to content

Commit

Permalink
fix(ngcc): cope with packages following APF v14+
Browse files Browse the repository at this point in the history
In PR #45405, the Angular Package Format (APF) was updated so that
secondary entry-points (such as `@angular/common/http`) do not have
their own `package.json` file, as they used to. Instead, the paths to
their various formats and types are exposed via the primary
`package.json` file's `exports` property. As an example, see the v13
[@angular/common/http/package.json][1] and compare it with the v14
[@angular/common/package.json > exports][2].

Previously, `ngcc` was not able to analyze such v14+ entry-points and
would instead error as it considered such entry-points missing.

This commit addresses the issue by detecting this situation and
synthesizing a `package.json` file for the secondary entry-points based
on the `exports` property of the primary `package.json` file. This data
is only used by `ngcc` in order to determine that the entry-point does
not need further processing, since it is already in Ivy format.

[1]: https://unpkg.com/browse/@angular/common@13.3.5/http/package.json
[2]: https://unpkg.com/browse/@angular/common@14.0.0-next.15/package.json
  • Loading branch information
gkalpak committed Apr 30, 2022
1 parent a88bf20 commit 330569e
Show file tree
Hide file tree
Showing 7 changed files with 129 additions and 12 deletions.
26 changes: 23 additions & 3 deletions packages/compiler-cli/ngcc/src/dependencies/module_resolver.ts
Original file line number Diff line number Diff line change
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,29 @@ 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 each 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 packageJsonDir = this.findPackagePath(modulePath);
const packageJsonPath = packageJsonDir && this.fs.join(packageJsonDir, 'package.json');
// TODO: Fix types and handle errors, potentially by moving `entry_point.ts > loadPackageJson()`
// (and associated types?) to `utits.ts`.
const packageJson =
packageJsonPath && JSON.parse(this.fs.readFile(packageJsonPath)) as Record<string, any>;

if (packageJson?.exports === undefined) {
return false;
}

const exportsKey = `./${this.fs.relative(packageJsonDir!, modulePath)}`;

return packageJson.exports[exportsKey] !== undefined;
}

/**
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
85 changes: 79 additions & 6 deletions packages/compiler-cli/ngcc/src/packages/entry_point.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,8 @@ export function getEntryPointInfo(
const loadedPackagePackageJson = loadPackageJson(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 +149,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 @@ -267,6 +268,78 @@ function loadPackageJson(
}
}

/**
* 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 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 = loadPackageJson(fs, secondaryPackageJsonPath);
if (loadedPackageJson !== null) {
return loadedPackageJson;
}

// If a primary `package.json` exists and has an `exports` property, try to synthesize the
// secondary `package.json` from that.
//
// 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`.
if (primaryPackageJson?.exports !== undefined) {
// 1. Find the `exports` key for the entry-point.
const relativeEntryPointPath = fs.relative(packagePath, entryPointPath);
const exportsKey = `./${relativeEntryPointPath}`;

// 2. Read the data (if it exists).
const exportsData =
(primaryPackageJson.exports as JsonObject)[exportsKey] as JsonObject | undefined;

if (exportsData === undefined) {
return null;
}

// 3. Create a synthesized `package.json`.
const synthesizedPackageJson: EntryPointPackageJson = {
synthesized: true,
name: `${primaryPackageJson.name}/${relativeEntryPointPath}`,
};

// 4. 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 = exportsData[prop];

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

// 5. Return the synthesized JSON.
return synthesizedPackageJson;
}

// If unable to load or synthesize a secondary `package.json`, return `null`.
return null;
}

function sniffModuleFormat(
fs: ReadonlyFileSystem, sourceFilePath: AbsoluteFsPath): EntryPointFormat|undefined {
const resolvedPath = resolveFileWithPostfixes(fs, sourceFilePath, ['', '.js', '/index.js']);
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
10 changes: 10 additions & 0 deletions packages/compiler-cli/ngcc/src/writing/package_json_updater.ts
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,14 @@ runInEachFileSystem(() => {
expect(() => update.writeChanges(_('/bar/package.json')))
.toThrowError('Trying to apply a `PackageJsonUpdate` that has already been applied.');
});

it('should throw, if trying to update a synthesized `package.json` file', () => {
const update = updater.createUpdate().addChange(['foo'], 'updated');

expect(() => update.writeChanges(_('/foo/package.json'), {
synthesized: true
})).toThrowError('Trying to update a non-existent (synthesized) `package.json` file.');
});
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,14 @@ runInEachFileSystem(() => {
.toThrowError('Trying to apply a `PackageJsonUpdate` that has already been applied.');
});

it('should throw, if trying to update a synthesized `package.json` file', () => {
const update = updater.createUpdate().addChange(['foo'], 'updated');

expect(() => update.writeChanges(_('/foo/package.json'), {
synthesized: true
})).toThrowError('Trying to update a non-existent (synthesized) `package.json` file.');
});

describe('(property positioning)', () => {
// Helpers
const createJsonFile = (jsonObj: JsonObject) => {
Expand Down

0 comments on commit 330569e

Please sign in to comment.