Skip to content

Commit

Permalink
feat(node-resolve): support pkg imports and export array
Browse files Browse the repository at this point in the history
  • Loading branch information
LarsDenBakker committed Dec 9, 2020
1 parent 2283377 commit 408b700
Show file tree
Hide file tree
Showing 50 changed files with 636 additions and 186 deletions.
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;
71 changes: 71 additions & 0 deletions packages/node-resolve/src/package/resolvePackageImports.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/* eslint-disable no-await-in-loop */
import path from 'path';
import fs from 'fs';
import { pathToFileURL } from 'url';
import { promisify } from 'util';

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

const fileExists = promisify(fs.exists);

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

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

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;
44 changes: 44 additions & 0 deletions packages/node-resolve/src/package/resolvePackageImportsExports.js
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;
111 changes: 111 additions & 0 deletions packages/node-resolve/src/package/resolvePackageTarget.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/* eslint-disable no-await-in-loop */
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 (target == null) {
return null;
}

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

if (resolved) {
return resolved;
}
} catch (error) {
if (!(error instanceof InvalidPackageTargetError)) {
throw error;
} else {
lastError = error;
}
}
}

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

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

if (resolved) {
return resolved;
}
}
}
}

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

export default resolvePackageTarget;
51 changes: 51 additions & 0 deletions packages/node-resolve/src/package/utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
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

0 comments on commit 408b700

Please sign in to comment.