Skip to content

Commit 0e4f014

Browse files
feat(node-resolve): support pkg imports and export array (#693)
* feat(node-resolve): support pkg imports and export array * feat(node-resolve): add support for self-package imports
1 parent d8f0cf4 commit 0e4f014

Some content is hidden

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

58 files changed

+712
-188
lines changed

packages/node-resolve/README.md

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,18 @@ 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

45+
## Package entrypoints
46+
47+
This plugin supports the package entrypoints feature from node js, specified in the `exports` or `imports` field of a package. Check the [official documentation](https://nodejs.org/api/packages.html#packages_package_entry_points) for more information on how this works.
48+
4549
## Options
4650

4751
### `exportConditions`
@@ -62,6 +66,8 @@ Default: `false`
6266

6367
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`.
6468

69+
> This option does not work when a package is using [package entrypoints](https://nodejs.org/api/packages.html#packages_package_entry_points)
70+
6571
### `moduleDirectories`
6672

6773
Type: `Array[...String]`<br>
@@ -169,9 +175,9 @@ export default {
169175
output: {
170176
file: 'bundle.js',
171177
format: 'iife',
172-
name: 'MyModule',
178+
name: 'MyModule'
173179
},
174-
plugins: [nodeResolve(), commonjs()],
180+
plugins: [nodeResolve(), commonjs()]
175181
};
176182
```
177183

@@ -203,7 +209,7 @@ The node resolve plugin uses `import` by default, you can opt into using the `re
203209
```js
204210
this.resolve(importee, importer, {
205211
skipSelf: true,
206-
custom: { 'node-resolve': { isRequire: true } },
212+
custom: { 'node-resolve': { isRequire: true } }
207213
});
208214
```
209215

packages/node-resolve/src/index.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import isModule from 'is-module';
77

88
import { isDirCached, isFileCached, readCachedFile } from './cache';
99
import { exists, readFile, realpath } from './fs';
10-
import { resolveImportSpecifiers } from './resolveImportSpecifiers';
10+
import resolveImportSpecifiers from './resolveImportSpecifiers';
1111
import { getMainFields, getPackageName, normalizeInput } from './util';
1212
import handleDeprecatedOptions from './deprecated-options';
1313

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import {
2+
InvalidModuleSpecifierError,
3+
InvalidConfigurationError,
4+
isMappings,
5+
isConditions,
6+
isMixedExports
7+
} from './utils';
8+
import resolvePackageTarget from './resolvePackageTarget';
9+
import resolvePackageImportsExports from './resolvePackageImportsExports';
10+
11+
async function resolvePackageExports(context, subpath, exports) {
12+
if (isMixedExports(exports)) {
13+
throw new InvalidConfigurationError(
14+
context,
15+
'All keys must either start with ./, or without one.'
16+
);
17+
}
18+
19+
if (subpath === '.') {
20+
let mainExport;
21+
// If exports is a String or Array, or an Object containing no keys starting with ".", then
22+
if (typeof exports === 'string' || Array.isArray(exports) || isConditions(exports)) {
23+
mainExport = exports;
24+
} else if (isMappings(exports)) {
25+
mainExport = exports['.'];
26+
}
27+
28+
if (mainExport) {
29+
const resolved = await resolvePackageTarget(context, { target: mainExport, subpath: '' });
30+
if (resolved) {
31+
return resolved;
32+
}
33+
}
34+
} else if (isMappings(exports)) {
35+
const resolvedMatch = await resolvePackageImportsExports(context, {
36+
matchKey: subpath,
37+
matchObj: exports
38+
});
39+
40+
if (resolvedMatch) {
41+
return resolvedMatch;
42+
}
43+
}
44+
45+
throw new InvalidModuleSpecifierError(context);
46+
}
47+
48+
export default resolvePackageExports;
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { pathToFileURL } from 'url';
2+
3+
import { createBaseErrorMsg, findPackageJson, InvalidModuleSpecifierError } from './utils';
4+
import resolvePackageImportsExports from './resolvePackageImportsExports';
5+
6+
async function resolvePackageImports({
7+
importSpecifier,
8+
importer,
9+
moduleDirs,
10+
conditions,
11+
resolveId
12+
}) {
13+
const result = await findPackageJson(importer, moduleDirs);
14+
if (!result) {
15+
throw new Error(createBaseErrorMsg('. Could not find a parent package.json.'));
16+
}
17+
18+
const { pkgPath, pkgJsonPath, pkgJson } = result;
19+
const pkgURL = pathToFileURL(`${pkgPath}/`);
20+
const context = {
21+
importer,
22+
importSpecifier,
23+
moduleDirs,
24+
pkgURL,
25+
pkgJsonPath,
26+
conditions,
27+
resolveId
28+
};
29+
30+
const { imports } = pkgJson;
31+
if (!imports) {
32+
throw new InvalidModuleSpecifierError(context, true);
33+
}
34+
35+
if (importSpecifier === '#' || importSpecifier.startsWith('#/')) {
36+
throw new InvalidModuleSpecifierError(context, 'Invalid import specifier.');
37+
}
38+
39+
return resolvePackageImportsExports(context, {
40+
matchKey: importSpecifier,
41+
matchObj: imports,
42+
internal: true
43+
});
44+
}
45+
46+
export default resolvePackageImports;
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/* eslint-disable no-await-in-loop */
2+
import resolvePackageTarget from './resolvePackageTarget';
3+
4+
import { InvalidModuleSpecifierError } from './utils';
5+
6+
async function resolvePackageImportsExports(context, { matchKey, matchObj, internal }) {
7+
if (!matchKey.endsWith('*') && matchKey in matchObj) {
8+
const target = matchObj[matchKey];
9+
const resolved = await resolvePackageTarget(context, { target, subpath: '', internal });
10+
return resolved;
11+
}
12+
13+
const expansionKeys = Object.keys(matchObj)
14+
.filter((k) => k.endsWith('/') || k.endsWith('*'))
15+
.sort((a, b) => b.length - a.length);
16+
17+
for (const expansionKey of expansionKeys) {
18+
const prefix = expansionKey.substring(0, expansionKey.length - 1);
19+
20+
if (expansionKey.endsWith('*') && matchKey.startsWith(prefix)) {
21+
const target = matchObj[expansionKey];
22+
const subpath = matchKey.substring(expansionKey.length - 1);
23+
const resolved = await resolvePackageTarget(context, {
24+
target,
25+
subpath,
26+
pattern: true,
27+
internal
28+
});
29+
return resolved;
30+
}
31+
32+
if (matchKey.startsWith(expansionKey)) {
33+
const target = matchObj[expansionKey];
34+
const subpath = matchKey.substring(expansionKey.length);
35+
36+
const resolved = await resolvePackageTarget(context, { target, subpath, internal });
37+
return resolved;
38+
}
39+
}
40+
41+
throw new InvalidModuleSpecifierError(context, internal);
42+
}
43+
44+
export default resolvePackageImportsExports;
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/* eslint-disable no-await-in-loop, no-undefined */
2+
import { pathToFileURL } from 'url';
3+
4+
import { isUrl, InvalidModuleSpecifierError, InvalidPackageTargetError } from './utils';
5+
6+
function includesInvalidSegments(pathSegments, moduleDirs) {
7+
return pathSegments
8+
.split('/')
9+
.slice(1)
10+
.some((t) => ['.', '..', ...moduleDirs].includes(t));
11+
}
12+
13+
async function resolvePackageTarget(context, { target, subpath, pattern, internal }) {
14+
if (typeof target === 'string') {
15+
if (!pattern && subpath.length > 0 && !target.endsWith('/')) {
16+
throw new InvalidModuleSpecifierError(context);
17+
}
18+
19+
if (!target.startsWith('./')) {
20+
if (internal && !['/', '../'].some((p) => target.startsWith(p)) && !isUrl(target)) {
21+
// this is a bare package import, remap it and resolve it using regular node resolve
22+
if (pattern) {
23+
const result = await context.resolveId(
24+
target.replace(/\*/g, subpath),
25+
context.pkgURL.href
26+
);
27+
return result ? pathToFileURL(result.location) : null;
28+
}
29+
30+
const result = await context.resolveId(`${target}${subpath}`, context.pkgURL.href);
31+
return result ? pathToFileURL(result.location) : null;
32+
}
33+
throw new InvalidPackageTargetError(context, `Invalid mapping: "${target}".`);
34+
}
35+
36+
if (includesInvalidSegments(target, context.moduleDirs)) {
37+
throw new InvalidPackageTargetError(context, `Invalid mapping: "${target}".`);
38+
}
39+
40+
const resolvedTarget = new URL(target, context.pkgURL);
41+
if (!resolvedTarget.href.startsWith(context.pkgURL.href)) {
42+
throw new InvalidPackageTargetError(
43+
context,
44+
`Resolved to ${resolvedTarget.href} which is outside package ${context.pkgURL.href}`
45+
);
46+
}
47+
48+
if (includesInvalidSegments(subpath, context.moduleDirs)) {
49+
throw new InvalidModuleSpecifierError(context);
50+
}
51+
52+
if (pattern) {
53+
return resolvedTarget.href.replace(/\*/g, subpath);
54+
}
55+
return new URL(subpath, resolvedTarget).href;
56+
}
57+
58+
if (Array.isArray(target)) {
59+
let lastError;
60+
for (const item of target) {
61+
try {
62+
const resolved = await resolvePackageTarget(context, {
63+
target: item,
64+
subpath,
65+
pattern,
66+
internal
67+
});
68+
69+
// return if defined or null, but not undefined
70+
if (resolved !== undefined) {
71+
return resolved;
72+
}
73+
} catch (error) {
74+
if (!(error instanceof InvalidPackageTargetError)) {
75+
throw error;
76+
} else {
77+
lastError = error;
78+
}
79+
}
80+
}
81+
82+
if (lastError) {
83+
throw lastError;
84+
}
85+
return null;
86+
}
87+
88+
if (target && typeof target === 'object') {
89+
for (const [key, value] of Object.entries(target)) {
90+
if (key === 'default' || context.conditions.includes(key)) {
91+
const resolved = await resolvePackageTarget(context, {
92+
target: value,
93+
subpath,
94+
pattern,
95+
internal
96+
});
97+
98+
// return if defined or null, but not undefined
99+
if (resolved !== undefined) {
100+
return resolved;
101+
}
102+
}
103+
}
104+
return undefined;
105+
}
106+
107+
if (target === null) {
108+
return null;
109+
}
110+
111+
throw new InvalidPackageTargetError(context, `Invalid exports field.`);
112+
}
113+
114+
export default resolvePackageTarget;
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/* eslint-disable no-await-in-loop */
2+
import path from 'path';
3+
import fs from 'fs';
4+
import { promisify } from 'util';
5+
6+
const fileExists = promisify(fs.exists);
7+
8+
function isModuleDir(current, moduleDirs) {
9+
return moduleDirs.some((dir) => current.endsWith(dir));
10+
}
11+
12+
export async function findPackageJson(base, moduleDirs) {
13+
const { root } = path.parse(base);
14+
let current = base;
15+
16+
while (current !== root && !isModuleDir(current, moduleDirs)) {
17+
const pkgJsonPath = path.join(current, 'package.json');
18+
if (await fileExists(pkgJsonPath)) {
19+
const pkgJsonString = fs.readFileSync(pkgJsonPath, 'utf-8');
20+
return { pkgJson: JSON.parse(pkgJsonString), pkgPath: current, pkgJsonPath };
21+
}
22+
current = path.resolve(current, '..');
23+
}
24+
return null;
25+
}
26+
27+
export function isUrl(str) {
28+
try {
29+
return !!new URL(str);
30+
} catch (_) {
31+
return false;
32+
}
33+
}
34+
35+
export function isConditions(exports) {
36+
return typeof exports === 'object' && Object.keys(exports).every((k) => !k.startsWith('.'));
37+
}
38+
39+
export function isMappings(exports) {
40+
return typeof exports === 'object' && !isConditions(exports);
41+
}
42+
43+
export function isMixedExports(exports) {
44+
const keys = Object.keys(exports);
45+
return keys.some((k) => k.startsWith('.')) && keys.some((k) => !k.startsWith('.'));
46+
}
47+
48+
export function createBaseErrorMsg(importSpecifier, importer) {
49+
return `Could not resolve import "${importSpecifier}" in ${importer}`;
50+
}
51+
52+
export function createErrorMsg(context, reason, internal) {
53+
const { importSpecifier, importer, pkgJsonPath } = context;
54+
const base = createBaseErrorMsg(importSpecifier, importer);
55+
const field = internal ? 'imports' : 'exports';
56+
return `${base} using ${field} defined in ${pkgJsonPath}.${reason ? ` ${reason}` : ''}`;
57+
}
58+
59+
export class ResolveError extends Error {}
60+
61+
export class InvalidConfigurationError extends ResolveError {
62+
constructor(context, reason) {
63+
super(createErrorMsg(context, `Invalid "exports" field. ${reason}`));
64+
}
65+
}
66+
67+
export class InvalidModuleSpecifierError extends ResolveError {
68+
constructor(context, internal) {
69+
super(createErrorMsg(context, internal));
70+
}
71+
}
72+
73+
export class InvalidPackageTargetError extends ResolveError {
74+
constructor(context, reason) {
75+
super(createErrorMsg(context, reason));
76+
}
77+
}

0 commit comments

Comments
 (0)