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 Oct 31, 2020
1 parent 4387386 commit 9fe41a0
Show file tree
Hide file tree
Showing 84 changed files with 703 additions and 59 deletions.
32 changes: 28 additions & 4 deletions packages/node-resolve/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,27 @@ 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: `[]`

Additional conditions of the package.json exports field to match when resolving modules. By default, this plugin looks for the `['default', 'module', 'import']` conditions when resolving imports.

When using `@rollup/plugin-commonjs` v16 or higher, this plugin will use the `['default', 'module', 'require']` conditions when resolving require statements.

Setting this option will add extra conditions on top of the default conditions. See https://nodejs.org/api/packages.html#packages_conditional_exports for more information.

### `browser`

Type: `Boolean`<br>
Expand Down Expand Up @@ -164,9 +175,9 @@ export default {
output: {
file: 'bundle.js',
format: 'iife',
name: 'MyModule'
name: 'MyModule',
},
plugins: [resolve(), commonjs()]
plugins: [resolve(), commonjs()],
};
```

Expand All @@ -187,6 +198,19 @@ export default ({
})
```

## Resolving require statements

According to [NodeJS module resolution](https://nodejs.org/api/packages.html#packages_package_entry_points) `require` statements should resolve using the `require` condition in the package exports field, while es modules should use the `import` condition.

The node resolve plugin uses `import` by default, you can opt into using the `require` semantics by passing an extra option to the resolve function:

```js
this.resolve(importee, importer, {
skipSelf: true,
custom: { 'node-resolve': { isRequire: true } },
});
```

## Meta

[CONTRIBUTING](/.github/CONTRIBUTING.md)
Expand Down
2 changes: 1 addition & 1 deletion packages/node-resolve/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@
"@babel/core": "^7.10.5",
"@babel/plugin-transform-typescript": "^7.10.5",
"@rollup/plugin-babel": "^5.1.0",
"@rollup/plugin-commonjs": "^14.0.0",
"@rollup/plugin-commonjs": "^16.0.0",
"@rollup/plugin-json": "^4.1.0",
"es5-ext": "^0.10.53",
"rollup": "^2.23.0",
Expand Down
28 changes: 19 additions & 9 deletions packages/node-resolve/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,8 @@ import isModule from 'is-module';

import { isDirCached, isFileCached, readCachedFile } from './cache';
import { exists, readFile, realpath } from './fs';
import {
getMainFields,
getPackageInfo,
getPackageName,
normalizeInput,
resolveImportSpecifiers
} from './util';
import { resolveImportSpecifiers } from './resolveImportSpecifiers';
import { getMainFields, getPackageInfo, getPackageName, normalizeInput } from './util';

const builtins = new Set(builtinList);
const ES6_BROWSER_EMPTY = '\0node-resolve:empty.js';
Expand All @@ -29,6 +24,10 @@ const deepFreeze = (object) => {

return object;
};

const baseConditions = ['default', 'module'];
const baseConditionsEsm = [...baseConditions, 'import'];
const baseConditionsCjs = [...baseConditions, 'require'];
const defaults = {
customResolveOptions: {},
dedupe: [],
Expand All @@ -42,6 +41,8 @@ export const DEFAULTS = deepFreeze(deepMerge({}, defaults));
export function nodeResolve(opts = {}) {
const options = Object.assign({}, defaults, opts);
const { customResolveOptions, extensions, jail } = options;
const conditionsEsm = [...baseConditionsEsm, ...(options.exportConditions || [])];
const conditionsCjs = [...baseConditionsCjs, ...(options.exportConditions || [])];
const warnings = [];
const packageInfoCache = new Map();
const idToPackageInfo = new Map();
Expand Down Expand Up @@ -93,7 +94,7 @@ export function nodeResolve(opts = {}) {
isDirCached.clear();
},

async resolveId(importee, importer) {
async resolveId(importee, importer, opts) {
if (importee === ES6_BROWSER_EMPTY) {
return importee;
}
Expand Down Expand Up @@ -222,7 +223,16 @@ export function nodeResolve(opts = {}) {
importSpecifierList.push(importee);
resolveOptions = Object.assign(resolveOptions, customResolveOptions);

let resolved = await resolveImportSpecifiers(importSpecifierList, resolveOptions);
const warn = (...args) => this.warn(...args);
const isRequire =
opts && opts.custom && opts.custom['node-resolve'] && opts.custom['node-resolve'].isRequire;
const exportConditions = isRequire ? conditionsCjs : conditionsEsm;
let resolved = await resolveImportSpecifiers(
importSpecifierList,
resolveOptions,
exportConditions,
warn
);
if (!resolved) {
return null;
}
Expand Down
197 changes: 197 additions & 0 deletions packages/node-resolve/src/resolveImportSpecifiers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import fs from 'fs';
import path from 'path';
import { promisify } from 'util';

import resolve from 'resolve';

import { getPackageName } from './util';
import { exists, realpath } from './fs';

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(exportMap, subPath) {
for (const key of Object.keys(exportMap)) {
if (key.endsWith('*')) {
// star match: "./foo/*": "./foo/*.js"
const keyWithoutStar = key.substring(0, key.length - 1);
if (subPath.startsWith(keyWithoutStar)) {
return key;
}
}

if (key.endsWith('/') && subPath.startsWith(key)) {
// directory match (deprecated by node): "./foo/": "./foo/.js"
return key;
}

if (key === subPath) {
// literal match
return key;
}
}
return null;
}

function mapSubPath(pkgJsonPath, subPath, key, value) {
if (typeof value === 'string') {
if (typeof key === 'string' && key.endsWith('*')) {
// star match: "./foo/*": "./foo/*.js"
const keyWithoutStar = key.substring(0, key.length - 1);
const subPathAfterKey = subPath.substring(keyWithoutStar.length);
return value.replace(/\*/g, subPathAfterKey);
}

if (value.endsWith('/')) {
// directory match (deprecated by node): "./foo/": "./foo/.js"
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(exportMap, subPath);
if (!key) {
throw pathNotFoundError(subPath, pkgJsonPath);
}
exportMapForSubPath = exportMap[key];
}

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

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);
}

// 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,
exportConditions,
warn
) {
let promise = Promise.resolve();

for (let i = 0; i < importSpecifierList.length; i++) {
// eslint-disable-next-line no-loop-func
promise = promise.then(async (value) => {
// if we've already resolved to something, just return it.
if (value) {
return value;
}

let result = await resolveId(importSpecifierList[i], resolveOptions, exportConditions, warn);
if (!resolveOptions.preserveSymlinks) {
if (await exists(result)) {
result = await realpath(result);
}
}
return result;
});

// swallow MODULE_NOT_FOUND errors
promise = promise.catch((error) => {
if (error.code !== 'MODULE_NOT_FOUND') {
throw error;
}
});
}

return promise;
}
40 changes: 1 addition & 39 deletions packages/node-resolve/src/util.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
import { dirname, extname, resolve } from 'path';
import { promisify } from 'util';

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

import resolveModule from 'resolve';

import { exists, realpath, realpathSync } from './fs';

const resolveId = promisify(resolveModule);
import { realpathSync } from './fs';

// returns the imported package name for bare module imports
export function getPackageName(id) {
Expand Down Expand Up @@ -158,36 +153,3 @@ export function normalizeInput(input) {
// otherwise it's a string
return [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) {
let promise = Promise.resolve();

for (let i = 0; i < importSpecifierList.length; i++) {
// eslint-disable-next-line no-loop-func
promise = promise.then(async (value) => {
// if we've already resolved to something, just return it.
if (value) {
return value;
}

let result = await resolveId(importSpecifierList[i], resolveOptions);
if (!resolveOptions.preserveSymlinks) {
if (await exists(result)) {
result = await realpath(result);
}
}
return result;
});

// swallow MODULE_NOT_FOUND errors
promise = promise.catch((error) => {
if (error.code !== 'MODULE_NOT_FOUND') {
throw error;
}
});
}

return promise;
}
2 changes: 1 addition & 1 deletion packages/node-resolve/test/browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ test('supports `false` in browser field', async (t) => {
await testBundle(t, bundle);
});

test('pkg.browser with mapping to prevent bundle by specifying a value of false', async (t) => {
test.only('pkg.browser with mapping to prevent bundle by specifying a value of false', async (t) => {
const bundle = await rollup({
input: 'browser-object-with-false.js',
plugins: [nodeResolve({ browser: true }), commonjs()]
Expand Down
Loading

0 comments on commit 9fe41a0

Please sign in to comment.