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

Feature: ESM exports support #55

Merged
merged 38 commits into from
Jun 2, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
278ff94
WIP: Reorganize and make placeholder for ESM tests
ryan-roemer May 15, 2021
3ee5e1c
Start setting up tests
ryan-roemer May 19, 2021
87ace17
WIP: Start refactoring for mode
ryan-roemer May 19, 2021
5909680
Need another mode
ryan-roemer May 19, 2021
d7e14ad
Start prepping modes.
ryan-roemer May 19, 2021
7856c03
WIP: Enumerate more failing tests for feature implementation.
ryan-roemer May 19, 2021
e942feb
Remove the modes skeleton work
ryan-roemer May 20, 2021
4a35585
Start adding lots of debugging / skeleton stuff
ryan-roemer May 20, 2021
67309a0
WIP: More prep work
ryan-roemer May 20, 2021
0c9de80
Add idea
ryan-roemer May 20, 2021
b9db39c
Start setting up abstracted mock fs
ryan-roemer May 20, 2021
9f6888a
WIP: Failing tests for initial exports support
ryan-roemer May 20, 2021
8a37751
More scenarios
ryan-roemer May 21, 2021
ec60119
Add tyler's scenario
ryan-roemer May 21, 2021
0971a24
Get in testable starting shape
ryan-roemer May 24, 2021
a7662e0
Add in more debug stuff.
ryan-roemer May 24, 2021
d28e574
WIP: Tests have started working.
ryan-roemer May 24, 2021
d6b5e63
Passes checks
ryan-roemer May 24, 2021
40ebf5e
Add more scenarios and swallow export resolve errors
ryan-roemer May 24, 2021
fa13c6c
Don't posixify actual paths
ryan-roemer May 24, 2021
a218822
Add new scenario. Identify issues with subpath scenario and add TODO …
ryan-roemer May 24, 2021
002b9d3
Handle no package.json:main
ryan-roemer May 25, 2021
b3403d9
WIP: Start on subpath tests
ryan-roemer May 26, 2021
84e67e9
WIP: At failing sub2 subpath scenario.
ryan-roemer May 26, 2021
65d78a2
Get subpath 2 scenario working
ryan-roemer May 26, 2021
ff44bc2
Handle the multiple package.json scenario more explicitly.
ryan-roemer May 26, 2021
51949b0
Add missing export scenario
ryan-roemer May 26, 2021
c2e404a
Finish docs
ryan-roemer May 26, 2021
9af4ae8
WIP: Disable root dot passthrough scenario.
ryan-roemer May 27, 2021
d948131
Make sure tests have references to local non-exported subfiles
ryan-roemer May 27, 2021
db9c326
Detect files, make files unique when aggregating.
ryan-roemer May 27, 2021
957eb84
Handle passthrough exports by removing them.
ryan-roemer May 27, 2021
0a5ad4e
Minor
ryan-roemer May 27, 2021
3de7dd6
Document missing features, abstract PASSTHROUGH_EXPORTS
ryan-roemer May 28, 2021
7a6ce02
Add failing test case
ryan-roemer May 28, 2021
f1b973f
WIP: Starting on a new idea
ryan-roemer May 29, 2021
012f8e9
Add two-pass resolve is missing package.json:main (#59)
ryan-roemer Jun 2, 2021
d23ec56
Remove temp notes
ryan-roemer Jun 2, 2021
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
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