Skip to content

Commit

Permalink
fix(node-resolve): Implement package exports / imports resolution alg…
Browse files Browse the repository at this point in the history
…orithm 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>
  • Loading branch information
susnux committed Aug 22, 2023
1 parent 49dcfe5 commit daf06f7
Show file tree
Hide file tree
Showing 19 changed files with 464 additions and 233 deletions.
25 changes: 25 additions & 0 deletions packages/node-resolve/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,31 @@ rootDir: path.join(process.cwd(), '..')

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.

### `allowExportsFolderMapping`

Older Node versions supported exports mappings of folders like

```json
{
"exports": {
"./foo/": "./dist/foo/"
}
}
```

This was deprecated with Node 14 and removed in Node 17, instead it is recommended to use exports patterns like

```json
{
"exports": {
"./foo/*": "./dist/foo/*"
}
}
```

But for backwards compatibility this behavior is still supported by enabling the `allowExportsFolderMapping` (defaults to `true`).
The default value might change in a futur major release.

## Preserving symlinks

This plugin honours the rollup [`preserveSymlinks`](https://rollupjs.org/guide/en/#preservesymlinks) option.
Expand Down
3 changes: 2 additions & 1 deletion packages/node-resolve/rollup.config.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { readFileSync } from 'fs';

import json from '@rollup/plugin-json';
import typescript from '@rollup/plugin-typescript';

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

Expand All @@ -9,5 +10,5 @@ export default {
pkg: JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'))
}),
input: 'src/index.js',
plugins: [json()]
plugins: [json(), typescript()]
};
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export const realpath = promisify(fs.realpath);
export { realpathSync } from 'fs';
export const stat = promisify(fs.stat);

export async function fileExists(filePath) {
export async function fileExists(filePath: fs.PathLike) {
try {
const res = await stat(filePath);
return res.isFile();
Expand All @@ -17,6 +17,6 @@ export async function fileExists(filePath) {
}
}

export async function resolveSymlink(path) {
export async function resolveSymlink(path: fs.PathLike) {
return (await fileExists(path)) ? realpath(path) : path;
}
7 changes: 5 additions & 2 deletions packages/node-resolve/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ const defaults = {
extensions: ['.mjs', '.js', '.json', '.node'],
resolveOnly: [],
moduleDirectories: ['node_modules'],
ignoreSideEffectsForRoot: false
ignoreSideEffectsForRoot: false,
// TODO: set to false in next major release or remove
allowExportsFolderMapping: true
};
export const DEFAULTS = deepFreeze(deepMerge({}, defaults));

Expand Down Expand Up @@ -183,7 +185,8 @@ export function nodeResolve(opts = {}) {
moduleDirectories,
modulePaths,
rootDir,
ignoreSideEffectsForRoot
ignoreSideEffectsForRoot,
allowExportsFolderMapping: options.allowExportsFolderMapping
});

const importeeIsBuiltin = isBuiltinModule(importee);
Expand Down
48 changes: 0 additions & 48 deletions packages/node-resolve/src/package/resolvePackageExports.js

This file was deleted.

71 changes: 71 additions & 0 deletions packages/node-resolve/src/package/resolvePackageExports.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import {
InvalidModuleSpecifierError,
InvalidConfigurationError,
isMappings,
isConditions,
isMixedExports
} from './utils';
import resolvePackageTarget from './resolvePackageTarget';
import resolvePackageImportsExports from './resolvePackageImportsExports';

/**
* Implementation of PACKAGE_EXPORTS_RESOLVE
*/
async function resolvePackageExports(context: any, subpath: string, exports: any) {
// If exports is an Object with both a key starting with "." and a key not starting with "."
if (isMixedExports(exports)) {
// throw an Invalid Package Configuration error.
throw new InvalidConfigurationError(
context,
'All keys must either start with ./, or without one.'
);
}

// If subpath is equal to ".", then
if (subpath === '.') {
// Let mainExport be undefined.
let mainExport: string | string[] | Record<string, any> | undefined;
// If exports is a String or Array, or an Object containing no keys starting with ".", then
if (typeof exports === 'string' || Array.isArray(exports) || isConditions(exports)) {
// Set mainExport to exports
mainExport = exports;
// Otherwise if exports is an Object containing a "." property, then
} else if (isMappings(exports)) {
// Set mainExport to exports["."]
mainExport = exports['.'];
}

// If mainExport is not undefined, then
if (mainExport) {
// Let resolved be the result of PACKAGE_TARGET_RESOLVE with target = mainExport
const resolved = await resolvePackageTarget(context, {
target: mainExport,
patternMatch: '',
isImports: false
});
// If resolved is not null or undefined, return resolved.
if (resolved) {
return resolved;
}
}

// Otherwise, if exports is an Object and all keys of exports start with ".", then
} else if (isMappings(exports)) {
// Let resolved be the result of PACKAGE_IMPORTS_EXPORTS_RESOLVE
const resolvedMatch = await resolvePackageImportsExports(context, {
matchKey: subpath,
matchObj: exports,
isImports: false
});

// If resolved is not null or undefined, return resolved.
if (resolvedMatch) {
return resolvedMatch;
}
}

// Throw a Package Path Not Exported error.
throw new InvalidModuleSpecifierError(context);
}

export default resolvePackageExports;
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,26 @@ import { pathToFileURL } from 'url';
import { createBaseErrorMsg, findPackageJson, InvalidModuleSpecifierError } from './utils';
import resolvePackageImportsExports from './resolvePackageImportsExports';

interface ParamObject {
importSpecifier: string;
importer: string;
moduleDirs: readonly string[];
conditions: readonly string[];
resolveId: (id: string) => any;
}

async function resolvePackageImports({
importSpecifier,
importer,
moduleDirs,
conditions,
resolveId
}) {
}: ParamObject) {
const result = await findPackageJson(importer, moduleDirs);
if (!result) {
throw new Error(createBaseErrorMsg('. Could not find a parent package.json.'));
throw new Error(
`${createBaseErrorMsg(importSpecifier, importer)}. Could not find a parent package.json.`
);
}

const { pkgPath, pkgJsonPath, pkgJson } = result;
Expand All @@ -27,19 +37,28 @@ async function resolvePackageImports({
resolveId
};

const { imports } = pkgJson;
if (!imports) {
throw new InvalidModuleSpecifierError(context, true);
// Assert: specifier begins with "#".
if (!importSpecifier.startsWith('#')) {
throw new InvalidModuleSpecifierError(context, true, 'Invalid import specifier.');
}

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

const { imports } = pkgJson;
if (!imports) {
throw new InvalidModuleSpecifierError(context, true);
}

// Let packageURL be the result of LOOKUP_PACKAGE_SCOPE(parentURL).
// If packageURL is not null, then
return resolvePackageImportsExports(context, {
matchKey: importSpecifier,
matchObj: imports,
internal: true
isImports: true
});
}

Expand Down
44 changes: 0 additions & 44 deletions packages/node-resolve/src/package/resolvePackageImportsExports.js

This file was deleted.

0 comments on commit daf06f7

Please sign in to comment.