Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(node-resolve): support package entry points #540

Merged
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
guybedford marked this conversation as resolved.
Show resolved Hide resolved
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;
}
guybedford marked this conversation as resolved.
Show resolved Hide resolved
}

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) => {
LarsDenBakker marked this conversation as resolved.
Show resolved Hide resolved
LarsDenBakker marked this conversation as resolved.
Show resolved Hide resolved
const bundle = await rollup({
input: 'browser-object-with-false.js',
plugins: [nodeResolve({ browser: true }), commonjs()]
Expand Down
Loading