Skip to content
This repository has been archived by the owner on Aug 13, 2022. It is now read-only.

Commit

Permalink
Feature: ESM exports support (#55)
Browse files Browse the repository at this point in the history
- Support ESM `package.json:exports` in Modern Node.js (CJS and ESM style). Fixes #51
  • Loading branch information
ryan-roemer authored Jun 2, 2021
1 parent c91df16 commit 2b08df2
Show file tree
Hide file tree
Showing 9 changed files with 2,402 additions and 1,320 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
Changes
=======

## UNRELEASED

* Feature: Add full support for modern Node.js ESM and `exports`.
[#49](https://github.com/FormidableLabs/trace-deps/issues/51)

## 0.3.9

* Bug/Feature: Support relative paths from package name root in `allowMissing`.
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,12 @@ Examples:

* **Only handles single string dependencies**: `require`, `require.resolve`, and dynamic `import()` support calls with variables or other expressions like `require(aVar)`, `import(process.env.VAL + "more-stuff")`. This library presently only supports calls with a **single string** and nothing else. We have a [tracking ticket](https://github.com/FormidableLabs/trace-deps/issues/2) to consider expanding support for things like partial evaluation.

* **Modern Node.js ESM / `package.json:exports` Support**: Node.js v12 and newer now support modern ESM, and `trace-deps` will correctly package your application in any Node.js runtime. Unfortunately, the implementation of how to [resolve an ESM import](https://nodejs.org/api/packages.html) in modern Node.js is quite complex.
* **It's complicated**: For example, for the same import of `my-pkg`, a `require("my-pkg")` call in Node.js v10 might match a file specified in `package.json:main`, while `require("my-pkg")` in Node.js v12 might match a second file specified in `package.json:exports:".":require`, and `import "my-pkg"` in Node,js v12 might match a _third_ file specified in `package.json:exports:".":import`. Then, throw in [conditions](https://nodejs.org/api/packages.html#packages_conditional_exports), [subpaths](https://nodejs.org/api/packages.html#packages_subpath_exports), and even subpath conditions, and it becomes exceedingly difficult to statically analyze what is actually going to be imported at runtime by Node.js ahead of time, which is what `trace-deps` needs to do. 🤯
* **Our solution**: Our approach is to basically give up on trying to figure out the exact runtime conditions that will be used in module resolution, and instead package all reasonable conditions for a given module import. This means that maintain correctness at the cost of slightly larger zip sizes for libraries that ship multiple versions of exports.
* **Our implementation**: When `trace-deps` encounters a dependency, it resolves the file according to old CommonJS (reading `package.json:main`) and then in modern Node.js `package.json:exports` mode with each of the following built-in / suggested official conditions: `import`, `require`, `node`, `default`, `development`, and `production`. (We ignore `browser`).
* **Missing Features**: `trace-deps` does not support the deprecated [subpath folder mappings](https://nodejs.org/api/packages.html#packages_subpath_folder_mappings) feature. Some advanced ESM features are still under development.
* **Includes `package.json` files used in resolution**: As this is a Node.js-focused library, to follow the Node.js [module resolution algorithm](https://nodejs.org/api/modules.html#modules_all_together) which notably uses intermediate encountered `package.json` files to determine how to resolve modules. This means that we include a lot of `package.json` files that seemingly aren't directly imported (such as a `const pkg = require("mod/package.json")`) because they are needed for the list of all traced files to together resolve correctly if all on disk together.

* **Using the `allowMissing` option**: The `allowMissing` function field helps in situations where you want to allow certain dependencies to have known missing sub-dependencies, often seen in patterns like: `try { require("optional-dep"); } catch (e) {}`. If the sub-dependency is found, then it will be returned just like any normal one. If not, the module not found error is just swallowed and normal processing resumes.
Expand Down
65 changes: 64 additions & 1 deletion lib/package.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,71 @@ const getLastPackageSegment = (filePath) => {
return relPath || null;
};

// Get path up to and including the package name (but no further).
const getLastPackageRoot = (filePath) => {
if (!filePath) { return null; }

// Iterate all normalized parts of the file path and extract packages.
const parts = path.normalize(filePath).split(path.sep);
const lastModsIdx = parts.lastIndexOf("node_modules");

// Not within node_modules or potential path possibility.
if (lastModsIdx === -1 || lastModsIdx + 1 === parts.length) { return null; }

// Find package root index. Start with unscoped.
let pkgRootIdx = lastModsIdx + 1;
if (parts[pkgRootIdx][0] === "@") {
// Check possible scoped path possibility.
if (pkgRootIdx + 1 === parts.length) { return null; }
// Scoped.
pkgRootIdx++;
}

return parts.slice(0, pkgRootIdx + 1).join(path.sep);
};

// Return module name and relative path (as array of path parts).
const getDependencyParts = (dep) => {
if (
!dep
|| path.isAbsolute(dep)
|| dep.startsWith(".")
) {
return null;
}

// Note that `package.json:exports` don't normalize/resolve `..` or file
// paths, so leave them intact (which means manually replace `\\` instead
// of using `toPosixPath`).
let parts = dep
.replace(/\\/g, "/")
.split("/")
.filter(Boolean);

let name;
if (parts.length > 0) {
if (parts[0][0] === "@") {
if (parts[1]) {
name = `${parts[0]}/${parts[1]}`;
parts = parts.slice(2); // eslint-disable-line no-magic-numbers
}
} else if (parts[0]) {
name = parts[0];
parts = parts.slice(1);
}
}

if (!name) {
return null;
}

return { name, parts };
};

module.exports = {
getPackages,
getLastPackage,
getLastPackageSegment
getLastPackageSegment,
getLastPackageRoot,
getDependencyParts
};
9 changes: 9 additions & 0 deletions lib/path.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"use strict";

const path = require("path");

const toPosixPath = (file) => !file ? file : path.normalize(file).replace(/\\/g, "/");

module.exports = {
toPosixPath
};
Loading

0 comments on commit 2b08df2

Please sign in to comment.