Skip to content

Commit 3d60158

Browse files
feat(node-resolve)!: support package entry points (#540)
BREAKING CHANGES: the way modules are resolved have changed, please see the associated pull request for more information. * feat(node-resolve): support package entry points * Update packages/node-resolve/test/browser.js Co-authored-by: Lukas Taegert-Atkinson <lukastaegert@users.noreply.github.com> Co-authored-by: Lukas Taegert-Atkinson <lukastaegert@users.noreply.github.com>
1 parent 83bcdcf commit 3d60158

83 files changed

Lines changed: 698 additions & 58 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/node-resolve/README.md

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,16 +34,27 @@ export default {
3434
input: 'src/index.js',
3535
output: {
3636
dir: 'output',
37-
format: 'cjs'
37+
format: 'cjs',
3838
},
39-
plugins: [nodeResolve()]
39+
plugins: [nodeResolve()],
4040
};
4141
```
4242

4343
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).
4444

4545
## Options
4646

47+
### `exportConditions`
48+
49+
Type: `Array[...String]`<br>
50+
Default: `[]`
51+
52+
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.
53+
54+
When using `@rollup/plugin-commonjs` v16 or higher, this plugin will use the `['default', 'module', 'require']` conditions when resolving require statements.
55+
56+
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.
57+
4758
### `browser`
4859

4960
Type: `Boolean`<br>
@@ -164,9 +175,9 @@ export default {
164175
output: {
165176
file: 'bundle.js',
166177
format: 'iife',
167-
name: 'MyModule'
178+
name: 'MyModule',
168179
},
169-
plugins: [resolve(), commonjs()]
180+
plugins: [resolve(), commonjs()],
170181
};
171182
```
172183

@@ -187,6 +198,19 @@ export default ({
187198
})
188199
```
189200

201+
## Resolving require statements
202+
203+
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.
204+
205+
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:
206+
207+
```js
208+
this.resolve(importee, importer, {
209+
skipSelf: true,
210+
custom: { 'node-resolve': { isRequire: true } },
211+
});
212+
```
213+
190214
## Meta
191215

192216
[CONTRIBUTING](/.github/CONTRIBUTING.md)

packages/node-resolve/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@
6565
"@babel/core": "^7.10.5",
6666
"@babel/plugin-transform-typescript": "^7.10.5",
6767
"@rollup/plugin-babel": "^5.1.0",
68-
"@rollup/plugin-commonjs": "^14.0.0",
68+
"@rollup/plugin-commonjs": "^16.0.0",
6969
"@rollup/plugin-json": "^4.1.0",
7070
"es5-ext": "^0.10.53",
7171
"rollup": "^2.23.0",

packages/node-resolve/src/index.js

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,8 @@ import isModule from 'is-module';
77

88
import { isDirCached, isFileCached, readCachedFile } from './cache';
99
import { exists, readFile, realpath } from './fs';
10-
import {
11-
getMainFields,
12-
getPackageInfo,
13-
getPackageName,
14-
normalizeInput,
15-
resolveImportSpecifiers
16-
} from './util';
10+
import { resolveImportSpecifiers } from './resolveImportSpecifiers';
11+
import { getMainFields, getPackageInfo, getPackageName, normalizeInput } from './util';
1712

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

3025
return object;
3126
};
27+
28+
const baseConditions = ['default', 'module'];
29+
const baseConditionsEsm = [...baseConditions, 'import'];
30+
const baseConditionsCjs = [...baseConditions, 'require'];
3231
const defaults = {
3332
customResolveOptions: {},
3433
dedupe: [],
@@ -42,6 +41,8 @@ export const DEFAULTS = deepFreeze(deepMerge({}, defaults));
4241
export function nodeResolve(opts = {}) {
4342
const options = Object.assign({}, defaults, opts);
4443
const { customResolveOptions, extensions, jail } = options;
44+
const conditionsEsm = [...baseConditionsEsm, ...(options.exportConditions || [])];
45+
const conditionsCjs = [...baseConditionsCjs, ...(options.exportConditions || [])];
4546
const warnings = [];
4647
const packageInfoCache = new Map();
4748
const idToPackageInfo = new Map();
@@ -93,7 +94,7 @@ export function nodeResolve(opts = {}) {
9394
isDirCached.clear();
9495
},
9596

96-
async resolveId(importee, importer) {
97+
async resolveId(importee, importer, opts) {
9798
if (importee === ES6_BROWSER_EMPTY) {
9899
return importee;
99100
}
@@ -222,7 +223,16 @@ export function nodeResolve(opts = {}) {
222223
importSpecifierList.push(importee);
223224
resolveOptions = Object.assign(resolveOptions, customResolveOptions);
224225

225-
let resolved = await resolveImportSpecifiers(importSpecifierList, resolveOptions);
226+
const warn = (...args) => this.warn(...args);
227+
const isRequire =
228+
opts && opts.custom && opts.custom['node-resolve'] && opts.custom['node-resolve'].isRequire;
229+
const exportConditions = isRequire ? conditionsCjs : conditionsEsm;
230+
let resolved = await resolveImportSpecifiers(
231+
importSpecifierList,
232+
resolveOptions,
233+
exportConditions,
234+
warn
235+
);
226236
if (!resolved) {
227237
return null;
228238
}
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import fs from 'fs';
2+
import path from 'path';
3+
import { promisify } from 'util';
4+
5+
import resolve from 'resolve';
6+
7+
import { getPackageName } from './util';
8+
import { exists, realpath } from './fs';
9+
10+
const resolveImportPath = promisify(resolve);
11+
const readFile = promisify(fs.readFile);
12+
13+
const pathNotFoundError = (subPath, pkgPath) =>
14+
new Error(`Package subpath '${subPath}' is not defined by "exports" in ${pkgPath}`);
15+
16+
function findExportKeyMatch(exportMap, subPath) {
17+
for (const key of Object.keys(exportMap)) {
18+
if (key.endsWith('*')) {
19+
// star match: "./foo/*": "./foo/*.js"
20+
const keyWithoutStar = key.substring(0, key.length - 1);
21+
if (subPath.startsWith(keyWithoutStar)) {
22+
return key;
23+
}
24+
}
25+
26+
if (key.endsWith('/') && subPath.startsWith(key)) {
27+
// directory match (deprecated by node): "./foo/": "./foo/.js"
28+
return key;
29+
}
30+
31+
if (key === subPath) {
32+
// literal match
33+
return key;
34+
}
35+
}
36+
return null;
37+
}
38+
39+
function mapSubPath(pkgJsonPath, subPath, key, value) {
40+
if (typeof value === 'string') {
41+
if (typeof key === 'string' && key.endsWith('*')) {
42+
// star match: "./foo/*": "./foo/*.js"
43+
const keyWithoutStar = key.substring(0, key.length - 1);
44+
const subPathAfterKey = subPath.substring(keyWithoutStar.length);
45+
return value.replace(/\*/g, subPathAfterKey);
46+
}
47+
48+
if (value.endsWith('/')) {
49+
// directory match (deprecated by node): "./foo/": "./foo/.js"
50+
return `${value}${subPath.substring(key.length)}`;
51+
}
52+
53+
// mapping is a string, for example { "./foo": "./dist/foo.js" }
54+
return value;
55+
}
56+
57+
if (Array.isArray(value)) {
58+
// mapping is an array with fallbacks, for example { "./foo": ["foo:bar", "./dist/foo.js"] }
59+
return value.find((v) => v.startsWith('./'));
60+
}
61+
62+
throw pathNotFoundError(subPath, pkgJsonPath);
63+
}
64+
65+
function findEntrypoint(pkgJsonPath, subPath, exportMap, conditions, key) {
66+
if (typeof exportMap !== 'object') {
67+
return mapSubPath(pkgJsonPath, subPath, key, exportMap);
68+
}
69+
70+
// iterate conditions recursively, find the first that matches all conditions
71+
for (const [condition, subExportMap] of Object.entries(exportMap)) {
72+
if (conditions.includes(condition)) {
73+
const mappedSubPath = findEntrypoint(pkgJsonPath, subPath, subExportMap, conditions, key);
74+
if (mappedSubPath) {
75+
return mappedSubPath;
76+
}
77+
}
78+
}
79+
throw pathNotFoundError(subPath, pkgJsonPath);
80+
}
81+
82+
export function findEntrypointTopLevel(pkgJsonPath, subPath, exportMap, conditions) {
83+
if (typeof exportMap !== 'object') {
84+
// the export map shorthand, for example { exports: "./index.js" }
85+
if (subPath !== '.') {
86+
// shorthand only supports a main entrypoint
87+
throw pathNotFoundError(subPath, pkgJsonPath);
88+
}
89+
return mapSubPath(pkgJsonPath, subPath, null, exportMap);
90+
}
91+
92+
// export map is an object, the top level can be either conditions or sub path mappings
93+
const keys = Object.keys(exportMap);
94+
const isConditions = keys.every((k) => !k.startsWith('.'));
95+
const isMappings = keys.every((k) => k.startsWith('.'));
96+
97+
if (!isConditions && !isMappings) {
98+
throw new Error(
99+
`Invalid package config ${pkgJsonPath}, "exports" cannot contain some keys starting with '.'` +
100+
' and some not. The exports object must either be an object of package subpath keys or an object of main entry' +
101+
' condition name keys only.'
102+
);
103+
}
104+
105+
let key = null;
106+
let exportMapForSubPath;
107+
108+
if (isConditions) {
109+
// top level is conditions, for example { "import": ..., "require": ..., "module": ... }
110+
if (subPath !== '.') {
111+
// package with top level conditions means it only supports a main entrypoint
112+
throw pathNotFoundError(subPath, pkgJsonPath);
113+
}
114+
exportMapForSubPath = exportMap;
115+
} else {
116+
// top level is sub path mappings, for example { ".": ..., "./foo": ..., "./bar": ... }
117+
key = findExportKeyMatch(exportMap, subPath);
118+
if (!key) {
119+
throw pathNotFoundError(subPath, pkgJsonPath);
120+
}
121+
exportMapForSubPath = exportMap[key];
122+
}
123+
124+
return findEntrypoint(pkgJsonPath, subPath, exportMapForSubPath, conditions, key);
125+
}
126+
127+
async function resolveId(importPath, options, exportConditions, warn) {
128+
const pkgName = getPackageName(importPath);
129+
if (pkgName) {
130+
let pkgJsonPath;
131+
let pkgJson;
132+
try {
133+
pkgJsonPath = await resolveImportPath(`${pkgName}/package.json`, options);
134+
pkgJson = JSON.parse(await readFile(pkgJsonPath, 'utf-8'));
135+
} catch (_) {
136+
// if there is no package.json we defer to regular resolve behavior
137+
}
138+
139+
if (pkgJsonPath && pkgJson && pkgJson.exports) {
140+
try {
141+
const packageSubPath =
142+
pkgName === importPath ? '.' : `.${importPath.substring(pkgName.length)}`;
143+
const mappedSubPath = findEntrypointTopLevel(
144+
pkgJsonPath,
145+
packageSubPath,
146+
pkgJson.exports,
147+
exportConditions
148+
);
149+
const pkgDir = path.dirname(pkgJsonPath);
150+
return path.join(pkgDir, mappedSubPath);
151+
} catch (error) {
152+
warn(error);
153+
return null;
154+
}
155+
}
156+
}
157+
158+
return resolveImportPath(importPath, options);
159+
}
160+
161+
// Resolve module specifiers in order. Promise resolves to the first module that resolves
162+
// successfully, or the error that resulted from the last attempted module resolution.
163+
export function resolveImportSpecifiers(
164+
importSpecifierList,
165+
resolveOptions,
166+
exportConditions,
167+
warn
168+
) {
169+
let promise = Promise.resolve();
170+
171+
for (let i = 0; i < importSpecifierList.length; i++) {
172+
// eslint-disable-next-line no-loop-func
173+
promise = promise.then(async (value) => {
174+
// if we've already resolved to something, just return it.
175+
if (value) {
176+
return value;
177+
}
178+
179+
let result = await resolveId(importSpecifierList[i], resolveOptions, exportConditions, warn);
180+
if (!resolveOptions.preserveSymlinks) {
181+
if (await exists(result)) {
182+
result = await realpath(result);
183+
}
184+
}
185+
return result;
186+
});
187+
188+
// swallow MODULE_NOT_FOUND errors
189+
promise = promise.catch((error) => {
190+
if (error.code !== 'MODULE_NOT_FOUND') {
191+
throw error;
192+
}
193+
});
194+
}
195+
196+
return promise;
197+
}

packages/node-resolve/src/util.js

Lines changed: 1 addition & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,8 @@
11
import { dirname, extname, resolve } from 'path';
2-
import { promisify } from 'util';
32

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

6-
import resolveModule from 'resolve';
7-
8-
import { exists, realpath, realpathSync } from './fs';
9-
10-
const resolveId = promisify(resolveModule);
5+
import { realpathSync } from './fs';
116

127
// returns the imported package name for bare module imports
138
export function getPackageName(id) {
@@ -158,36 +153,3 @@ export function normalizeInput(input) {
158153
// otherwise it's a string
159154
return [input];
160155
}
161-
162-
// Resolve module specifiers in order. Promise resolves to the first module that resolves
163-
// successfully, or the error that resulted from the last attempted module resolution.
164-
export function resolveImportSpecifiers(importSpecifierList, resolveOptions) {
165-
let promise = Promise.resolve();
166-
167-
for (let i = 0; i < importSpecifierList.length; i++) {
168-
// eslint-disable-next-line no-loop-func
169-
promise = promise.then(async (value) => {
170-
// if we've already resolved to something, just return it.
171-
if (value) {
172-
return value;
173-
}
174-
175-
let result = await resolveId(importSpecifierList[i], resolveOptions);
176-
if (!resolveOptions.preserveSymlinks) {
177-
if (await exists(result)) {
178-
result = await realpath(result);
179-
}
180-
}
181-
return result;
182-
});
183-
184-
// swallow MODULE_NOT_FOUND errors
185-
promise = promise.catch((error) => {
186-
if (error.code !== 'MODULE_NOT_FOUND') {
187-
throw error;
188-
}
189-
});
190-
}
191-
192-
return promise;
193-
}

packages/node-resolve/test/fixtures/browser-object-with-false.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ const clientHttp = new Client('http:');
1212
t.is(clientWs.name, 'websocket-tracker');
1313
t.is(clientHttp.name, 'NULL');
1414
t.is(HTTPTracker, ES6_BROWSER_EMPTY);
15-
t.is(HTTPTrackerWithSubPath, ES6_BROWSER_EMPTY);
15+
t.deepEqual(HTTPTrackerWithSubPath, { default: {} });
1616

1717
// expose
1818
export default 'ok';
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
const main = require('exports-cjs');
2+
3+
module.exports = main;

0 commit comments

Comments
 (0)