Skip to content

Commit daf06f7

Browse files
authored
fix(node-resolve): Implement package exports / imports resolution algorithm according to Node documentation (#1549)
This fixes the package exports and imports resolution algorithm by strictly following the Node API documentation. For backwards compatibility a new option `allowExportsFolderMapping` is introduced which will enable deprecated folder mappings. Test case included Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
1 parent 49dcfe5 commit daf06f7

19 files changed

+464
-233
lines changed

packages/node-resolve/README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,31 @@ rootDir: path.join(process.cwd(), '..')
175175

176176
If you use the `sideEffects` property in the package.json, by default this is respected for files in the root package. Set to `true` to ignore the `sideEffects` configuration for the root package.
177177

178+
### `allowExportsFolderMapping`
179+
180+
Older Node versions supported exports mappings of folders like
181+
182+
```json
183+
{
184+
"exports": {
185+
"./foo/": "./dist/foo/"
186+
}
187+
}
188+
```
189+
190+
This was deprecated with Node 14 and removed in Node 17, instead it is recommended to use exports patterns like
191+
192+
```json
193+
{
194+
"exports": {
195+
"./foo/*": "./dist/foo/*"
196+
}
197+
}
198+
```
199+
200+
But for backwards compatibility this behavior is still supported by enabling the `allowExportsFolderMapping` (defaults to `true`).
201+
The default value might change in a futur major release.
202+
178203
## Preserving symlinks
179204

180205
This plugin honours the rollup [`preserveSymlinks`](https://rollupjs.org/guide/en/#preservesymlinks) option.
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { readFileSync } from 'fs';
22

33
import json from '@rollup/plugin-json';
4+
import typescript from '@rollup/plugin-typescript';
45

56
import { createConfig } from '../../shared/rollup.config.mjs';
67

@@ -9,5 +10,5 @@ export default {
910
pkg: JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'))
1011
}),
1112
input: 'src/index.js',
12-
plugins: [json()]
13+
plugins: [json(), typescript()]
1314
};

packages/node-resolve/src/fs.js renamed to packages/node-resolve/src/fs.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export const realpath = promisify(fs.realpath);
88
export { realpathSync } from 'fs';
99
export const stat = promisify(fs.stat);
1010

11-
export async function fileExists(filePath) {
11+
export async function fileExists(filePath: fs.PathLike) {
1212
try {
1313
const res = await stat(filePath);
1414
return res.isFile();
@@ -17,6 +17,6 @@ export async function fileExists(filePath) {
1717
}
1818
}
1919

20-
export async function resolveSymlink(path) {
20+
export async function resolveSymlink(path: fs.PathLike) {
2121
return (await fileExists(path)) ? realpath(path) : path;
2222
}

packages/node-resolve/src/index.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,9 @@ const defaults = {
3737
extensions: ['.mjs', '.js', '.json', '.node'],
3838
resolveOnly: [],
3939
moduleDirectories: ['node_modules'],
40-
ignoreSideEffectsForRoot: false
40+
ignoreSideEffectsForRoot: false,
41+
// TODO: set to false in next major release or remove
42+
allowExportsFolderMapping: true
4143
};
4244
export const DEFAULTS = deepFreeze(deepMerge({}, defaults));
4345

@@ -183,7 +185,8 @@ export function nodeResolve(opts = {}) {
183185
moduleDirectories,
184186
modulePaths,
185187
rootDir,
186-
ignoreSideEffectsForRoot
188+
ignoreSideEffectsForRoot,
189+
allowExportsFolderMapping: options.allowExportsFolderMapping
187190
});
188191

189192
const importeeIsBuiltin = isBuiltinModule(importee);

packages/node-resolve/src/package/resolvePackageExports.js

Lines changed: 0 additions & 48 deletions
This file was deleted.
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import {
2+
InvalidModuleSpecifierError,
3+
InvalidConfigurationError,
4+
isMappings,
5+
isConditions,
6+
isMixedExports
7+
} from './utils';
8+
import resolvePackageTarget from './resolvePackageTarget';
9+
import resolvePackageImportsExports from './resolvePackageImportsExports';
10+
11+
/**
12+
* Implementation of PACKAGE_EXPORTS_RESOLVE
13+
*/
14+
async function resolvePackageExports(context: any, subpath: string, exports: any) {
15+
// If exports is an Object with both a key starting with "." and a key not starting with "."
16+
if (isMixedExports(exports)) {
17+
// throw an Invalid Package Configuration error.
18+
throw new InvalidConfigurationError(
19+
context,
20+
'All keys must either start with ./, or without one.'
21+
);
22+
}
23+
24+
// If subpath is equal to ".", then
25+
if (subpath === '.') {
26+
// Let mainExport be undefined.
27+
let mainExport: string | string[] | Record<string, any> | undefined;
28+
// If exports is a String or Array, or an Object containing no keys starting with ".", then
29+
if (typeof exports === 'string' || Array.isArray(exports) || isConditions(exports)) {
30+
// Set mainExport to exports
31+
mainExport = exports;
32+
// Otherwise if exports is an Object containing a "." property, then
33+
} else if (isMappings(exports)) {
34+
// Set mainExport to exports["."]
35+
mainExport = exports['.'];
36+
}
37+
38+
// If mainExport is not undefined, then
39+
if (mainExport) {
40+
// Let resolved be the result of PACKAGE_TARGET_RESOLVE with target = mainExport
41+
const resolved = await resolvePackageTarget(context, {
42+
target: mainExport,
43+
patternMatch: '',
44+
isImports: false
45+
});
46+
// If resolved is not null or undefined, return resolved.
47+
if (resolved) {
48+
return resolved;
49+
}
50+
}
51+
52+
// Otherwise, if exports is an Object and all keys of exports start with ".", then
53+
} else if (isMappings(exports)) {
54+
// Let resolved be the result of PACKAGE_IMPORTS_EXPORTS_RESOLVE
55+
const resolvedMatch = await resolvePackageImportsExports(context, {
56+
matchKey: subpath,
57+
matchObj: exports,
58+
isImports: false
59+
});
60+
61+
// If resolved is not null or undefined, return resolved.
62+
if (resolvedMatch) {
63+
return resolvedMatch;
64+
}
65+
}
66+
67+
// Throw a Package Path Not Exported error.
68+
throw new InvalidModuleSpecifierError(context);
69+
}
70+
71+
export default resolvePackageExports;

packages/node-resolve/src/package/resolvePackageImports.js renamed to packages/node-resolve/src/package/resolvePackageImports.ts

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,26 @@ import { pathToFileURL } from 'url';
33
import { createBaseErrorMsg, findPackageJson, InvalidModuleSpecifierError } from './utils';
44
import resolvePackageImportsExports from './resolvePackageImportsExports';
55

6+
interface ParamObject {
7+
importSpecifier: string;
8+
importer: string;
9+
moduleDirs: readonly string[];
10+
conditions: readonly string[];
11+
resolveId: (id: string) => any;
12+
}
13+
614
async function resolvePackageImports({
715
importSpecifier,
816
importer,
917
moduleDirs,
1018
conditions,
1119
resolveId
12-
}) {
20+
}: ParamObject) {
1321
const result = await findPackageJson(importer, moduleDirs);
1422
if (!result) {
15-
throw new Error(createBaseErrorMsg('. Could not find a parent package.json.'));
23+
throw new Error(
24+
`${createBaseErrorMsg(importSpecifier, importer)}. Could not find a parent package.json.`
25+
);
1626
}
1727

1828
const { pkgPath, pkgJsonPath, pkgJson } = result;
@@ -27,19 +37,28 @@ async function resolvePackageImports({
2737
resolveId
2838
};
2939

30-
const { imports } = pkgJson;
31-
if (!imports) {
32-
throw new InvalidModuleSpecifierError(context, true);
40+
// Assert: specifier begins with "#".
41+
if (!importSpecifier.startsWith('#')) {
42+
throw new InvalidModuleSpecifierError(context, true, 'Invalid import specifier.');
3343
}
3444

45+
// If specifier is exactly equal to "#" or starts with "#/", then
3546
if (importSpecifier === '#' || importSpecifier.startsWith('#/')) {
47+
// Throw an Invalid Module Specifier error.
3648
throw new InvalidModuleSpecifierError(context, true, 'Invalid import specifier.');
3749
}
3850

51+
const { imports } = pkgJson;
52+
if (!imports) {
53+
throw new InvalidModuleSpecifierError(context, true);
54+
}
55+
56+
// Let packageURL be the result of LOOKUP_PACKAGE_SCOPE(parentURL).
57+
// If packageURL is not null, then
3958
return resolvePackageImportsExports(context, {
4059
matchKey: importSpecifier,
4160
matchObj: imports,
42-
internal: true
61+
isImports: true
4362
});
4463
}
4564

packages/node-resolve/src/package/resolvePackageImportsExports.js

Lines changed: 0 additions & 44 deletions
This file was deleted.

0 commit comments

Comments
 (0)