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

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 <[email protected]>
  • Loading branch information
susnux committed Aug 3, 2023
1 parent 5ec2abe commit 99c413a
Show file tree
Hide file tree
Showing 19 changed files with 466 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.

Loading

0 comments on commit 99c413a

Please sign in to comment.