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 pkg imports and export array #693

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
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
16 changes: 11 additions & 5 deletions packages/node-resolve/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,18 @@ 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).

## Package entrypoints

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.

## Options

### `exportConditions`
Expand All @@ -62,6 +66,8 @@ 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`.

> This option does not work when a package is using [package entrypoints](https://nodejs.org/api/packages.html#packages_package_entry_points)

### `moduleDirectories`

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

Expand Down Expand Up @@ -203,7 +209,7 @@ The node resolve plugin uses `import` by default, you can opt into using the `re
```js
this.resolve(importee, importer, {
skipSelf: true,
custom: { 'node-resolve': { isRequire: true } },
custom: { 'node-resolve': { isRequire: true } }
});
```

Expand Down
2 changes: 1 addition & 1 deletion packages/node-resolve/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import isModule from 'is-module';

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

Expand Down
48 changes: 48 additions & 0 deletions packages/node-resolve/src/package/resolvePackageExports.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import {
InvalidModuleSpecifierError,
InvalidConfigurationError,
isMappings,
isConditions,
isMixedExports
} from './utils';
import resolvePackageTarget from './resolvePackageTarget';
import resolvePackageImportsExports from './resolvePackageImportsExports';

async function resolvePackageExports(context, subpath, exports) {
if (isMixedExports(exports)) {
throw new InvalidConfigurationError(
context,
'All keys must either start with ./, or without one.'
);
}

if (subpath === '.') {
let mainExport;
// If exports is a String or Array, or an Object containing no keys starting with ".", then
if (typeof exports === 'string' || Array.isArray(exports) || isConditions(exports)) {
mainExport = exports;
} else if (isMappings(exports)) {
mainExport = exports['.'];
}

if (mainExport) {
const resolved = await resolvePackageTarget(context, { target: mainExport, subpath: '' });
if (resolved) {
return resolved;
}
}
} else if (isMappings(exports)) {
const resolvedMatch = await resolvePackageImportsExports(context, {
matchKey: subpath,
matchObj: exports
});

if (resolvedMatch) {
return resolvedMatch;
}
}

throw new InvalidModuleSpecifierError(context);
}

export default resolvePackageExports;
46 changes: 46 additions & 0 deletions packages/node-resolve/src/package/resolvePackageImports.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { pathToFileURL } from 'url';

import { createBaseErrorMsg, findPackageJson, InvalidModuleSpecifierError } from './utils';
import resolvePackageImportsExports from './resolvePackageImportsExports';

async function resolvePackageImports({
importSpecifier,
importer,
moduleDirs,
conditions,
resolveId
}) {
const result = await findPackageJson(importer, moduleDirs);
if (!result) {
throw new Error(createBaseErrorMsg('. Could not find a parent package.json.'));
}

const { pkgPath, pkgJsonPath, pkgJson } = result;
const pkgURL = pathToFileURL(`${pkgPath}/`);
const context = {
importer,
importSpecifier,
moduleDirs,
pkgURL,
pkgJsonPath,
conditions,
resolveId
};

const { imports } = pkgJson;
if (!imports) {
throw new InvalidModuleSpecifierError(context, true);
}

if (importSpecifier === '#' || importSpecifier.startsWith('#/')) {
throw new InvalidModuleSpecifierError(context, 'Invalid import specifier.');
}

return resolvePackageImportsExports(context, {
matchKey: importSpecifier,
matchObj: imports,
internal: true
});
}

export default resolvePackageImports;
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/* eslint-disable no-await-in-loop */
import resolvePackageTarget from './resolvePackageTarget';

import { InvalidModuleSpecifierError } from './utils';

async function resolvePackageImportsExports(context, { matchKey, matchObj, internal }) {
if (!matchKey.endsWith('*') && matchKey in matchObj) {
const target = matchObj[matchKey];
const resolved = await resolvePackageTarget(context, { target, subpath: '', internal });
return resolved;
}

const expansionKeys = Object.keys(matchObj)
.filter((k) => k.endsWith('/') || k.endsWith('*'))
.sort((a, b) => b.length - a.length);

for (const expansionKey of expansionKeys) {
const prefix = expansionKey.substring(0, expansionKey.length - 1);

if (expansionKey.endsWith('*') && matchKey.startsWith(prefix)) {
const target = matchObj[expansionKey];
const subpath = matchKey.substring(expansionKey.length - 1);
const resolved = await resolvePackageTarget(context, {
target,
subpath,
pattern: true,
internal
});
return resolved;
}

if (matchKey.startsWith(expansionKey)) {
const target = matchObj[expansionKey];
const subpath = matchKey.substring(expansionKey.length);

const resolved = await resolvePackageTarget(context, { target, subpath, internal });
return resolved;
}
}

throw new InvalidModuleSpecifierError(context, internal);
}

export default resolvePackageImportsExports;
114 changes: 114 additions & 0 deletions packages/node-resolve/src/package/resolvePackageTarget.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/* eslint-disable no-await-in-loop, no-undefined */
import { pathToFileURL } from 'url';

import { isUrl, InvalidModuleSpecifierError, InvalidPackageTargetError } from './utils';

function includesInvalidSegments(pathSegments, moduleDirs) {
return pathSegments
.split('/')
.slice(1)
.some((t) => ['.', '..', ...moduleDirs].includes(t));
}

async function resolvePackageTarget(context, { target, subpath, pattern, internal }) {
if (typeof target === 'string') {
if (!pattern && subpath.length > 0 && !target.endsWith('/')) {
throw new InvalidModuleSpecifierError(context);
}

if (!target.startsWith('./')) {
if (internal && !['/', '../'].some((p) => target.startsWith(p)) && !isUrl(target)) {
// this is a bare package import, remap it and resolve it using regular node resolve
if (pattern) {
const result = await context.resolveId(
target.replace(/\*/g, subpath),
context.pkgURL.href
);
return result ? pathToFileURL(result.location) : null;
}

const result = await context.resolveId(`${target}${subpath}`, context.pkgURL.href);
return result ? pathToFileURL(result.location) : null;
}
throw new InvalidPackageTargetError(context, `Invalid mapping: "${target}".`);
}

if (includesInvalidSegments(target, context.moduleDirs)) {
throw new InvalidPackageTargetError(context, `Invalid mapping: "${target}".`);
}

const resolvedTarget = new URL(target, context.pkgURL);
if (!resolvedTarget.href.startsWith(context.pkgURL.href)) {
throw new InvalidPackageTargetError(
context,
`Resolved to ${resolvedTarget.href} which is outside package ${context.pkgURL.href}`
);
}

if (includesInvalidSegments(subpath, context.moduleDirs)) {
throw new InvalidModuleSpecifierError(context);
}

if (pattern) {
return resolvedTarget.href.replace(/\*/g, subpath);
}
return new URL(subpath, resolvedTarget).href;
}

if (Array.isArray(target)) {
let lastError;
for (const item of target) {
try {
const resolved = await resolvePackageTarget(context, {
target: item,
subpath,
pattern,
internal
});

// return if defined or null, but not undefined
if (resolved !== undefined) {
return resolved;
}
} catch (error) {
if (!(error instanceof InvalidPackageTargetError)) {
throw error;
} else {
lastError = error;
}
}
}

if (lastError) {
throw lastError;
}
return null;
}

if (target && typeof target === 'object') {
for (const [key, value] of Object.entries(target)) {
if (key === 'default' || context.conditions.includes(key)) {
const resolved = await resolvePackageTarget(context, {
target: value,
subpath,
pattern,
internal
});

// return if defined or null, but not undefined
if (resolved !== undefined) {
return resolved;
}
}
}
return undefined;
}

if (target === null) {
return null;
}

throw new InvalidPackageTargetError(context, `Invalid exports field.`);
}

export default resolvePackageTarget;
77 changes: 77 additions & 0 deletions packages/node-resolve/src/package/utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/* eslint-disable no-await-in-loop */
import path from 'path';
import fs from 'fs';
import { promisify } from 'util';

const fileExists = promisify(fs.exists);

function isModuleDir(current, moduleDirs) {
return moduleDirs.some((dir) => current.endsWith(dir));
}

export async function findPackageJson(base, moduleDirs) {
const { root } = path.parse(base);
let current = base;

while (current !== root && !isModuleDir(current, moduleDirs)) {
const pkgJsonPath = path.join(current, 'package.json');
if (await fileExists(pkgJsonPath)) {
const pkgJsonString = fs.readFileSync(pkgJsonPath, 'utf-8');
return { pkgJson: JSON.parse(pkgJsonString), pkgPath: current, pkgJsonPath };
}
current = path.resolve(current, '..');
}
return null;
}

export function isUrl(str) {
try {
return !!new URL(str);
} catch (_) {
return false;
}
}

export function isConditions(exports) {
return typeof exports === 'object' && Object.keys(exports).every((k) => !k.startsWith('.'));
}

export function isMappings(exports) {
return typeof exports === 'object' && !isConditions(exports);
}

export function isMixedExports(exports) {
const keys = Object.keys(exports);
return keys.some((k) => k.startsWith('.')) && keys.some((k) => !k.startsWith('.'));
}

export function createBaseErrorMsg(importSpecifier, importer) {
return `Could not resolve import "${importSpecifier}" in ${importer}`;
}

export function createErrorMsg(context, reason, internal) {
const { importSpecifier, importer, pkgJsonPath } = context;
const base = createBaseErrorMsg(importSpecifier, importer);
const field = internal ? 'imports' : 'exports';
return `${base} using ${field} defined in ${pkgJsonPath}.${reason ? ` ${reason}` : ''}`;
}

export class ResolveError extends Error {}

export class InvalidConfigurationError extends ResolveError {
constructor(context, reason) {
super(createErrorMsg(context, `Invalid "exports" field. ${reason}`));
}
}

export class InvalidModuleSpecifierError extends ResolveError {
constructor(context, internal) {
super(createErrorMsg(context, internal));
}
}

export class InvalidPackageTargetError extends ResolveError {
constructor(context, reason) {
super(createErrorMsg(context, reason));
}
}
Loading