Skip to content

Commit d4e949f

Browse files
gkalpakAndrewKushnir
authored andcommitted
fix(ngcc): cope with packages following APF v14+ (#45833)
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 PR Close #45833
1 parent 400ec17 commit d4e949f

File tree

9 files changed

+446
-12
lines changed

9 files changed

+446
-12
lines changed

packages/compiler-cli/ngcc/src/dependencies/module_resolver.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88
import {AbsoluteFsPath, ReadonlyFileSystem} from '../../../src/ngtsc/file_system';
99
import {PathMappings} from '../path_mappings';
10-
import {isRelativePath, resolveFileWithPostfixes} from '../utils';
10+
import {isRelativePath, loadJson, loadSecondaryEntryPointInfoForApfV14, resolveFileWithPostfixes} from '../utils';
1111

1212
/**
1313
* This is a very cut-down implementation of the TypeScript module resolution strategy.
@@ -110,8 +110,8 @@ export class ModuleResolver {
110110
* Try to resolve the `moduleName` as an external entry-point by searching the `node_modules`
111111
* folders up the tree for a matching `.../node_modules/${moduleName}`.
112112
*
113-
* If a folder is found but the path does not contain a `package.json` then it is marked as a
114-
* "deep-import".
113+
* If a folder is found but the path is not considered an entry-point (see `isEntryPoint()`) then
114+
* it is marked as a "deep-import".
115115
*/
116116
private resolveAsEntryPoint(moduleName: string, fromPath: AbsoluteFsPath): ResolvedModule|null {
117117
let folder = fromPath;
@@ -136,9 +136,25 @@ export class ModuleResolver {
136136
* Can we consider the given path as an entry-point to a package?
137137
*
138138
* This is achieved by checking for the existence of `${modulePath}/package.json`.
139+
* If there is no `package.json`, we check whether this is an APF v14+ secondary entry-point,
140+
* which does not have its own `package.json` but has an `exports` entry in the package's primary
141+
* `package.json`.
139142
*/
140143
private isEntryPoint(modulePath: AbsoluteFsPath): boolean {
141-
return this.fs.exists(this.fs.join(modulePath, 'package.json'));
144+
if (this.fs.exists(this.fs.join(modulePath, 'package.json'))) {
145+
return true;
146+
}
147+
148+
const packagePath = this.findPackagePath(modulePath);
149+
if (packagePath === null) {
150+
return false;
151+
}
152+
153+
const packagePackageJson = loadJson(this.fs, this.fs.join(packagePath, 'package.json'));
154+
const entryPointInfoForApfV14 =
155+
loadSecondaryEntryPointInfoForApfV14(this.fs, packagePackageJson, packagePath, modulePath);
156+
157+
return entryPointInfoForApfV14 !== null;
142158
}
143159

144160
/**

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

Lines changed: 71 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import ts from 'typescript';
1010
import {AbsoluteFsPath, PathManipulation, ReadonlyFileSystem} from '../../../src/ngtsc/file_system';
1111
import {Logger} from '../../../src/ngtsc/logging';
1212
import {parseStatementForUmdModule} from '../host/umd_host';
13-
import {JsonObject, loadJson, resolveFileWithPostfixes} from '../utils';
13+
import {JsonObject, loadJson, loadSecondaryEntryPointInfoForApfV14, resolveFileWithPostfixes} from '../utils';
1414

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

@@ -131,7 +131,8 @@ export function getEntryPointInfo(
131131
const loadedPackagePackageJson = loadJson<EntryPointPackageJson>(fs, packagePackageJsonPath);
132132
const loadedEntryPointPackageJson = (packagePackageJsonPath === entryPointPackageJsonPath) ?
133133
loadedPackagePackageJson :
134-
loadJson<EntryPointPackageJson>(fs, entryPointPackageJsonPath);
134+
loadOrSynthesizeSecondaryPackageJson(
135+
fs, packagePath, entryPointPath, entryPointPackageJsonPath, loadedPackagePackageJson);
135136
const {packageName, packageVersion} = getPackageNameAndVersion(
136137
fs, packagePath, loadedPackagePackageJson, loadedEntryPointPackageJson);
137138
const repositoryUrl = getRepositoryUrl(loadedPackagePackageJson);
@@ -141,17 +142,17 @@ export function getEntryPointInfo(
141142
let entryPointPackageJson: EntryPointPackageJson;
142143

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

152155
return INCOMPATIBLE_ENTRY_POINT;
153-
} else {
154-
entryPointPackageJson = loadedEntryPointPackageJson;
155156
}
156157
} else if (entryPointConfig.ignore === true) {
157158
// Explicitly ignored entry-point.
@@ -246,6 +247,69 @@ export function getEntryPointFormat(
246247
}
247248
}
248249

250+
/**
251+
* Parse the JSON from a secondary `package.json` file. If no such file exists, look for a
252+
* corresponding entry in the primary `package.json` file's `exports` property (if any) and
253+
* synthesize the JSON from that.
254+
*
255+
* @param packagePath The absolute path to the containing npm package.
256+
* @param entryPointPath The absolute path to the secondary entry-point.
257+
* @param secondaryPackageJsonPath The absolute path to the secondary `package.json` file.
258+
* @param primaryPackageJson The parsed JSON of the primary `package.json` (or `null` if it failed
259+
* to be loaded).
260+
* @returns Parsed JSON (either loaded from a secondary `package.json` file or synthesized from a
261+
* primary one) if it is valid, `null` otherwise.
262+
*/
263+
function loadOrSynthesizeSecondaryPackageJson(
264+
fs: ReadonlyFileSystem, packagePath: AbsoluteFsPath, entryPointPath: AbsoluteFsPath,
265+
secondaryPackageJsonPath: AbsoluteFsPath,
266+
primaryPackageJson: EntryPointPackageJson|null): EntryPointPackageJson|null {
267+
// If a secondary `package.json` exists and is valid, load and return that.
268+
const loadedPackageJson = loadJson<EntryPointPackageJson>(fs, secondaryPackageJsonPath);
269+
if (loadedPackageJson !== null) {
270+
return loadedPackageJson;
271+
}
272+
273+
// Try to load the entry-point info from the primary `package.json` data.
274+
const entryPointInfo =
275+
loadSecondaryEntryPointInfoForApfV14(fs, primaryPackageJson, packagePath, entryPointPath);
276+
if (entryPointInfo === null) {
277+
return null;
278+
}
279+
280+
// Create a synthesized `package.json`.
281+
//
282+
// NOTE:
283+
// We do not care about being able to update the synthesized `package.json` (for example, updating
284+
// its `__processed_by_ivy_ngcc__` property), because these packages are generated with Angular
285+
// v14+ (following the Angular Package Format v14+) and thus are already in Ivy format and do not
286+
// require processing by `ngcc`.
287+
const synthesizedPackageJson: EntryPointPackageJson = {
288+
synthesized: true,
289+
name: `${primaryPackageJson!.name}/${fs.relative(packagePath, entryPointPath)}`,
290+
};
291+
292+
// Update the synthesized `package.json` with any of the supported format and types properties,
293+
// changing paths to make them relative to the entry-point directory. This makes the synthesized
294+
// `package.json` similar to how a `package.json` inside the entry-point directory would look
295+
// like.
296+
for (const prop of [...SUPPORTED_FORMAT_PROPERTIES, 'types', 'typings']) {
297+
const packageRelativePath = entryPointInfo[prop];
298+
299+
if (typeof packageRelativePath === 'string') {
300+
const absolutePath = fs.resolve(packagePath, packageRelativePath);
301+
const entryPointRelativePath = fs.relative(entryPointPath, absolutePath);
302+
synthesizedPackageJson[prop] =
303+
(fs.isRooted(entryPointRelativePath) || entryPointRelativePath.startsWith('.')) ?
304+
entryPointRelativePath :
305+
`./${entryPointRelativePath}`;
306+
}
307+
}
308+
309+
// Return the synthesized JSON.
310+
return synthesizedPackageJson;
311+
}
312+
249313
function sniffModuleFormat(
250314
fs: ReadonlyFileSystem, sourceFilePath: AbsoluteFsPath): EntryPointFormat|undefined {
251315
const resolvedPath = resolveFileWithPostfixes(fs, sourceFilePath, ['', '.js', '/index.js']);

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

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,3 +186,57 @@ export function loadJson<T extends JsonObject = JsonObject>(
186186
return null;
187187
}
188188
}
189+
190+
/**
191+
* Given the parsed JSON of a `package.json` file, try to extract info for a secondary entry-point
192+
* from the `exports` property. Such info will only be present for packages following Angular
193+
* Package Format v14+.
194+
*
195+
* @param primaryPackageJson The parsed JSON of the primary `package.json` (or `null` if it failed
196+
* to be loaded).
197+
* @param packagePath The absolute path to the containing npm package.
198+
* @param entryPointPath The absolute path to the secondary entry-point.
199+
* @returns The `exports` info for the specified entry-point if it exists, `null` otherwise.
200+
*/
201+
export function loadSecondaryEntryPointInfoForApfV14(
202+
fs: ReadonlyFileSystem, primaryPackageJson: JsonObject|null, packagePath: AbsoluteFsPath,
203+
entryPointPath: AbsoluteFsPath): JsonObject|null {
204+
// Check if primary `package.json` has been loaded and has an `exports` property that is an
205+
// object.
206+
const exportMap = primaryPackageJson?.exports;
207+
if (!isExportObject(exportMap)) {
208+
return null;
209+
}
210+
211+
// Find the `exports` key for the secondary entry-point.
212+
const relativeEntryPointPath = fs.relative(packagePath, entryPointPath);
213+
const entryPointExportKey = `./${relativeEntryPointPath}`;
214+
215+
// Read the info for the entry-point.
216+
const entryPointInfo = exportMap[entryPointExportKey];
217+
218+
// Check whether the entry-point info exists and is an export map.
219+
return isExportObject(entryPointInfo) ? entryPointInfo : null;
220+
}
221+
222+
/**
223+
* Check whether a value read from a JSON file is a Node.js export map (either the top-level one or
224+
* one for a subpath).
225+
*
226+
* In `package.json` files, the `exports` field can be of type `Object | string | string[]`, but APF
227+
* v14+ uses an object with subpath exports for each entry-point, which in turn are conditional
228+
* exports (see references below). This function verifies that a value read from the top-level
229+
* `exports` field or a subpath is of type `Object` (and not `string` or `string[]`).
230+
*
231+
* References:
232+
* - https://nodejs.org/api/packages.html#exports
233+
* - https://nodejs.org/api/packages.html#subpath-exports
234+
* - https://nodejs.org/api/packages.html#conditional-exports
235+
* - https://v14.angular.io/guide/angular-package-format#exports
236+
*
237+
* @param thing The value read from the JSON file
238+
* @returns True if the value is an `Object` (and not an `Array`).
239+
*/
240+
function isExportObject(thing: JsonValue): thing is JsonObject {
241+
return (typeof thing === 'object') && (thing !== null) && !Array.isArray(thing);
242+
}

packages/compiler-cli/ngcc/src/writing/package_json_updater.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ export class PackageJsonUpdate {
101101
*/
102102
writeChanges(packageJsonPath: AbsoluteFsPath, parsedJson?: JsonObject): void {
103103
this.ensureNotApplied();
104+
this.ensureNotSynthesized(parsedJson);
104105
this.writeChangesImpl(this.changes, packageJsonPath, parsedJson);
105106
this.applied = true;
106107
}
@@ -110,6 +111,15 @@ export class PackageJsonUpdate {
110111
throw new Error('Trying to apply a `PackageJsonUpdate` that has already been applied.');
111112
}
112113
}
114+
115+
private ensureNotSynthesized(parsedJson?: JsonObject) {
116+
if (parsedJson?.synthesized) {
117+
// Theoretically, this should never happen, because synthesized `package.json` files should
118+
// only be created for libraries following the Angular Package Format v14+, which means they
119+
// should already be in Ivy format and not require processing by `ngcc`.
120+
throw new Error('Trying to update a non-existent (synthesized) `package.json` file.');
121+
}
122+
}
113123
}
114124

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

packages/compiler-cli/ngcc/test/dependencies/module_resolver_spec.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,25 @@ runInEachFileSystem(() => {
6464
name: _('/node_modules/top-package/package.json'),
6565
contents: 'PACKAGE.JSON for top-package'
6666
},
67+
{
68+
name: _('/node_modules/apf-v14-package/package.json'),
69+
contents: `{
70+
"name": "apf-v14-package",
71+
"exports": {
72+
"./second/ary": {
73+
"main": "./second/ary/index.js"
74+
}
75+
}
76+
}`,
77+
},
78+
{
79+
name: _('/node_modules/apf-v14-package/index.js'),
80+
contents: `export const type = 'primary';`,
81+
},
82+
{
83+
name: _('/node_modules/apf-v14-package/second/ary/index.js'),
84+
contents: `export const type = 'secondary';`,
85+
},
6786
]);
6887
});
6988

@@ -144,6 +163,17 @@ runInEachFileSystem(() => {
144163
.toEqual(new ResolvedDeepImport(
145164
_('/libs/local-package/node_modules/package-1/sub-folder')));
146165
});
166+
167+
it('should resolve to APF v14+ secondary entry-points', () => {
168+
const resolver = new ModuleResolver(getFileSystem());
169+
170+
expect(resolver.resolveModuleImport(
171+
'apf-v14-package/second/ary', _('/libs/local-package/index.js')))
172+
.toEqual(new ResolvedExternalModule(_('/node_modules/apf-v14-package/second/ary')));
173+
expect(resolver.resolveModuleImport(
174+
'apf-v14-package/second/ary', _('/libs/local-package/sub-folder/index.js')))
175+
.toEqual(new ResolvedExternalModule(_('/node_modules/apf-v14-package/second/ary')));
176+
});
147177
});
148178

149179
describe('with mapped path external modules', () => {
@@ -256,6 +286,20 @@ runInEachFileSystem(() => {
256286
'package-4/secondary-entry-point', _('/dist/package-4/index.js')))
257287
.toEqual(new ResolvedExternalModule(_('/dist/package-4/secondary-entry-point')));
258288
});
289+
290+
it('should resolve APF v14+ secondary entry-points', () => {
291+
const resolver = new ModuleResolver(getFileSystem(), {
292+
baseUrl: '/node_modules',
293+
paths: {'package-42': ['apf-v14-package'], 'package-42/*': ['apf-v14-package/*']},
294+
});
295+
296+
expect(resolver.resolveModuleImport(
297+
'package-42/second/ary', _('/libs/local-package/index.js')))
298+
.toEqual(new ResolvedExternalModule(_('/node_modules/apf-v14-package/second/ary')));
299+
expect(resolver.resolveModuleImport(
300+
'package-42/second/ary', _('/libs/local-package/sub-folder/index.js')))
301+
.toEqual(new ResolvedExternalModule(_('/node_modules/apf-v14-package/second/ary')));
302+
});
259303
});
260304

261305
describe('with mapped path relative paths', () => {

packages/compiler-cli/ngcc/test/execution/cluster/package_json_updater_spec.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,14 @@ runInEachFileSystem(() => {
197197
expect(() => update.writeChanges(_('/bar/package.json')))
198198
.toThrowError('Trying to apply a `PackageJsonUpdate` that has already been applied.');
199199
});
200+
201+
it('should throw, if trying to update a synthesized `package.json` file', () => {
202+
const update = updater.createUpdate().addChange(['foo'], 'updated');
203+
204+
expect(() => update.writeChanges(_('/foo/package.json'), {
205+
synthesized: true
206+
})).toThrowError('Trying to update a non-existent (synthesized) `package.json` file.');
207+
});
200208
});
201209
});
202210

0 commit comments

Comments
 (0)