Skip to content

Commit

Permalink
feat(node-resolve): support package entry points
Browse files Browse the repository at this point in the history
  • Loading branch information
LarsDenBakker committed Aug 22, 2020
1 parent e4d21ba commit 5959a48
Show file tree
Hide file tree
Showing 68 changed files with 510 additions and 17 deletions.
2 changes: 1 addition & 1 deletion .eslintignore
Original file line number Diff line number Diff line change
@@ -1 +1 @@
.eslintrc.js
.eslintrc.js
17 changes: 13 additions & 4 deletions packages/node-resolve/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,23 @@ export default {
input: 'src/index.js',
output: {
dir: 'output',
format: 'cjs'
format: 'cjs',
},
plugins: [nodeResolve()]
plugins: [nodeResolve()],
};
```

Then call `rollup` either via the [CLI](https://www.rollupjs.org/guide/en/#command-line-reference) or the [API](https://www.rollupjs.org/guide/en/#javascript-api).

## Options

### `exportConditions`

Type: `Array[...String]`<br>
Default: `['module', 'import']`

Conditions used to select exports defined using node js [package entrypoints](https://nodejs.org/api/esm.html#esm_packages). Conditions are evaluated left to right, returning the first match that is found. Setting this property overwrites the default values.

### `browser`

Type: `Boolean`<br>
Expand Down Expand Up @@ -111,6 +118,8 @@ Valid values: `['browser', 'jsnext:main', 'module', 'main']`

Specifies the properties to scan within a `package.json`, used to determine the bundle entry point. The order of property names is significant, as the first-found property is used as the resolved entry point. If the array contains `'browser'`, key/values specified in the `package.json` `browser` property will be used.

If a package is using [package entrypoints](https://nodejs.org/api/esm.html#esm_packages) by specifying an `exports` field, that always takes priority over `mainFields`.

### `only`

DEPRECATED: use "resolveOnly" instead
Expand Down Expand Up @@ -164,9 +173,9 @@ export default {
output: {
file: 'bundle.js',
format: 'iife',
name: 'MyModule'
name: 'MyModule',
},
plugins: [resolve(), commonjs()]
plugins: [resolve(), commonjs()],
};
```

Expand Down
12 changes: 10 additions & 2 deletions packages/node-resolve/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
const builtins = new Set(builtinList);
const ES6_BROWSER_EMPTY = '\0node-resolve:empty.js';
const nullFn = () => null;
const deepFreeze = object => {
const deepFreeze = (object) => {
Object.freeze(object);

for (const value of Object.values(object)) {
Expand All @@ -30,6 +30,7 @@ const deepFreeze = object => {
return object;
};
const defaults = {
exportConditions: ['module', 'import'],
customResolveOptions: {},
dedupe: [],
// It's important that .mjs is listed before .js so that Rollup will interpret npm modules
Expand All @@ -42,6 +43,7 @@ export const DEFAULTS = deepFreeze(deepMerge({}, defaults));
export function nodeResolve(opts = {}) {
const options = Object.assign({}, defaults, opts);
const { customResolveOptions, extensions, jail } = options;
const exportConditions = [...options.exportConditions, 'default'];
const warnings = [];
const packageInfoCache = new Map();
const idToPackageInfo = new Map();
Expand Down Expand Up @@ -220,7 +222,13 @@ export function nodeResolve(opts = {}) {
resolveOptions = Object.assign(resolveOptions, customResolveOptions);

try {
let resolved = await resolveImportSpecifiers(importSpecifierList, resolveOptions);
const warn = (...args) => this.warn(...args);
let resolved = await resolveImportSpecifiers(
importSpecifierList,
resolveOptions,
exportConditions,
warn
);

if (resolved && packageBrowserField) {
if (Object.prototype.hasOwnProperty.call(packageBrowserField, resolved)) {
Expand Down
130 changes: 130 additions & 0 deletions packages/node-resolve/src/resolveId.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import fs from 'fs';
import path from 'path';
import { promisify } from 'util';

import resolve from 'resolve';

import { getPackageName } from './util';

const resolveImportPath = promisify(resolve);
const readFile = promisify(fs.readFile);

const pathNotFoundError = (subPath, pkgPath) =>
new Error(`Package subpath '${subPath}' is not defined by "exports" in ${pkgPath}`);

function findExportKeyMatch(keys, subPath) {
return keys.find((key) => subPath.startsWith(key));
}

function mapSubPath(pkgJsonPath, subPath, key, value) {
if (typeof value === 'string') {
if (value.endsWith('/')) {
return `${value}${subPath.substring(key.length)}`;
}
// mapping is a string, for example { "./foo": "./dist/foo.js" }
return value;
}

if (Array.isArray(value)) {
// mapping is an array with fallbacks, for example { "./foo": ["foo:bar", "./dist/foo.js"] }
return value.find((v) => v.startsWith('./'));
}

throw pathNotFoundError(subPath, pkgJsonPath);
}

function findEntrypoint(pkgJsonPath, subPath, exportMap, conditions, key) {
if (typeof exportMap !== 'object') {
return mapSubPath(pkgJsonPath, subPath, key, exportMap);
}

// iterate conditions recursively, find the first that matches all conditions
for (const [condition, subExportMap] of Object.entries(exportMap)) {
if (conditions.includes(condition)) {
const mappedSubPath = findEntrypoint(pkgJsonPath, subPath, subExportMap, conditions, key);
if (mappedSubPath) {
return mappedSubPath;
}
}
}
throw pathNotFoundError(subPath, pkgJsonPath);
}

export function findEntrypointTopLevel(pkgJsonPath, subPath, exportMap, conditions) {
if (typeof exportMap !== 'object') {
// the export map shorthand, for example { exports: "./index.js" }
if (subPath !== '.') {
// shorthand only supports a main entrypoint
throw pathNotFoundError(subPath, pkgJsonPath);
}
return mapSubPath(pkgJsonPath, subPath, null, exportMap);
}

// export map is an object, the top level can be either conditions or sub path mappings
const keys = Object.keys(exportMap);
const isConditions = keys.every((k) => !k.startsWith('.'));
const isMappings = keys.every((k) => k.startsWith('.'));

if (!isConditions && !isMappings) {
throw new Error(
`Invalid package config ${pkgJsonPath}, "exports" cannot contain some keys starting with '.'` +
' and some not. The exports object must either be an object of package subpath keys or an object of main entry' +
' condition name keys only.'
);
}

let key = null;
let exportMapForSubPath;

if (isConditions) {
// top level is conditions, for example { "import": ..., "require": ..., "module": ... }
if (subPath !== '.') {
// package with top level conditions means it only supports a main entrypoint
throw pathNotFoundError(subPath, pkgJsonPath);
}
exportMapForSubPath = exportMap;
} else {
// top level is sub path mappings, for example { ".": ..., "./foo": ..., "./bar": ... }
key = findExportKeyMatch(keys, subPath);
if (!key) {
throw pathNotFoundError(subPath, pkgJsonPath);
}
exportMapForSubPath = exportMap[key];
}

return findEntrypoint(pkgJsonPath, subPath, exportMapForSubPath, conditions, key);
}

export default async function resolveId(importPath, options, exportConditions, warn) {
const pkgName = getPackageName(importPath);
if (pkgName) {
let pkgJsonPath;
let pkgJson;
try {
pkgJsonPath = await resolveImportPath(`${pkgName}/package.json`, options);
pkgJson = JSON.parse(await readFile(pkgJsonPath, 'utf-8'));
} catch (_) {
// if there is no package.json we defer to regular resolve behavior
}

if (pkgJsonPath && pkgJson && pkgJson.exports) {
try {
const packageSubPath =
pkgName === importPath ? '.' : `.${importPath.substring(pkgName.length)}`;
const mappedSubPath = findEntrypointTopLevel(
pkgJsonPath,
packageSubPath,
pkgJson.exports,
exportConditions
);
const pkgDir = path.dirname(pkgJsonPath);
return path.join(pkgDir, mappedSubPath);
} catch (error) {
warn(error);
return null;
}
}
}

return resolveImportPath(importPath, options);
}
24 changes: 14 additions & 10 deletions packages/node-resolve/src/util.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
import { dirname, extname, resolve } from 'path';
import { promisify } from 'util';

import { createFilter } from '@rollup/pluginutils';

import resolveModule from 'resolve';
import resolveId from './resolveId';

import { realpathSync } from './fs';

const resolveId = promisify(resolveModule);

// returns the imported package name for bare module imports
export function getPackageName(id) {
if (id.startsWith('.') || id.startsWith('/')) {
Expand Down Expand Up @@ -161,7 +158,12 @@ export function normalizeInput(input) {

// Resolve module specifiers in order. Promise resolves to the first module that resolves
// successfully, or the error that resulted from the last attempted module resolution.
export function resolveImportSpecifiers(importSpecifierList, resolveOptions) {
export function resolveImportSpecifiers(
importSpecifierList,
resolveOptions,
exportConditions,
warn
) {
let promise = Promise.resolve();

for (let i = 0; i < importSpecifierList.length; i++) {
Expand All @@ -171,12 +173,14 @@ export function resolveImportSpecifiers(importSpecifierList, resolveOptions) {
return value;
}

return resolveId(importSpecifierList[i], resolveOptions).then((result) => {
if (!resolveOptions.preserveSymlinks) {
result = realpathSync(result);
return resolveId(importSpecifierList[i], resolveOptions, exportConditions, warn).then(
(result) => {
if (!resolveOptions.preserveSymlinks) {
result = realpathSync(result);
}
return result;
}
return result;
});
);
});

if (i < importSpecifierList.length - 1) {
Expand Down
5 changes: 5 additions & 0 deletions packages/node-resolve/test/fixtures/exports-directory.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import a from 'exports-directory/foo/a.js';
import b from 'exports-directory/foo/b.js';
import c from 'exports-directory/foo/nested/c.js';

export default { a, b, c };
5 changes: 5 additions & 0 deletions packages/node-resolve/test/fixtures/exports-main-directory.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import a from 'exports-main-directory/a.js';
import b from 'exports-main-directory/foo/b.js';
import c from 'exports-main-directory/foo/nested/c.js';

export default { a, b, c };
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import main from 'exports-mappings-and-conditions';
import foo from 'exports-mappings-and-conditions/foo';
import bar from 'exports-mappings-and-conditions/bar';

export default { main, foo, bar };
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import main from 'exports-nested-conditions';

export default main;
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import bar from 'exports-non-existing-subpath/bar';

export default bar;
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import bar from 'exports-top-level-mappings/bar';

export default bar;
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import exportsMapEntry from 'exports-shorthand';

export default exportsMapEntry;
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import exportsMapEntry from 'exports-shorthand/foo';

export default exportsMapEntry;
3 changes: 3 additions & 0 deletions packages/node-resolve/test/fixtures/exports-shorthand.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import exportsMapEntry from 'exports-shorthand';

export default exportsMapEntry;
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import main from 'exports-top-level-conditions';

export default main;
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import main from 'exports-top-level-mappings';
import foo from 'exports-top-level-mappings/foo';

export default { main, foo };

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit 5959a48

Please sign in to comment.