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 abffd9e
Show file tree
Hide file tree
Showing 79 changed files with 654 additions and 59 deletions.
39 changes: 35 additions & 4 deletions packages/node-resolve/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,34 @@ 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', 'import']` 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>
Default: `false`

If `true`, instructs the plugin to use the `"browser"` property in `package.json` files to specify alternative files to load for bundling. This is useful when bundling for a browser environment. Alternatively, a value of `'browser'` can be added to the `mainFields` option. If `false`, any `"browser"` properties in package files will be ignored. This option takes precedence over `mainFields`.

### `browser`

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

Expand All @@ -187,6 +205,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
169 changes: 169 additions & 0 deletions packages/node-resolve/src/resolveImportSpecifiers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
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(keys, subPath) {
return keys.find((key) => (key.endsWith('/') ? subPath.startsWith(key) : key === subPath));
}

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

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
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const clientHttp = new Client('http:');
t.is(clientWs.name, 'websocket-tracker');
t.is(clientHttp.name, 'NULL');
t.is(HTTPTracker, ES6_BROWSER_EMPTY);
t.is(HTTPTrackerWithSubPath, ES6_BROWSER_EMPTY);
t.deepEqual(HTTPTrackerWithSubPath, { default: {} });

// expose
export default 'ok';
3 changes: 3 additions & 0 deletions packages/node-resolve/test/fixtures/exports-cjs.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
const main = require('exports-cjs');

module.exports = main;
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import main from 'exports-conditions-fallback';

export default main;
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 };
Loading

0 comments on commit abffd9e

Please sign in to comment.