Conversation
|
Review requested:
|
|
I like the idea, it would greatly reduce the amount of filesystem operations that pnpm has to do in order to create an isolated node_modules layout using symlinks. I also suggested arcanis to possibly go one layer deeper and allow to map the individual files of packages. This would allow to map node_modules directly from a content-addressable store (that consists of package files). Of course, that would increase the size of the file several times but it would also make installation even faster. |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #62239 +/- ##
==========================================
+ Coverage 89.66% 89.69% +0.02%
==========================================
Files 676 677 +1
Lines 206462 206954 +492
Branches 39533 39621 +88
==========================================
+ Hits 185128 185627 +499
+ Misses 13461 13452 -9
- Partials 7873 7875 +2
🚀 New features to boost your workflow:
|
|
This seems quite close to the |
|
I did, but felt that the semantics were too different; import maps have two fields:
Neither of those match the semantics we need, and reusing them just for their name but with different semantics would have been imo misleading for third-party resolver implementors. |
|
Love it. I'll try to give a detailed review on the flight home today if the in flight wifi treats me kindly. |
doc/api/packages.md
Outdated
| 1. Node.js determines which package contains the importing file by checking | ||
| if the file path is within any package's `path`. | ||
| 2. If the importing file is not within any mapped package, standard | ||
| `node_modules` resolution is used. |
There was a problem hiding this comment.
This is surprising to me. Is there a way to make the project's root directory be "within a mapped package" without also including its node_modules? Or alternatively, can there be a way to list the dependencies of the project itself, which would apply to any files which are not within a mapped package?
That is, I would like to be able to have a project with JS in its root directory with that JS being governed by the package map (because I would like all bare specifier resolution in that project to be governed by the package map). You could do "root": { path: "." }, but then every file would be within root's path, including files in node_modules.
Possibly the check to see if a file is "within a package's path" should not includes files within a node_modules subdirectory of that path?
There was a problem hiding this comment.
The way it works is that the longest package path wins. So if you have a package mapped on . (which will usually be the case) you will also map its nested packages:
{
"packages": {
"root": {
"path": "."
},
"react": {
"path": "./node_modules/react"
}
}
Paths in ./node_modules/react will be recognized as belonging to the React package, not the root.
lib/internal/modules/package_map.js
Outdated
|
|
||
| // Walk up to find containing package | ||
| let checkPath = filePath; | ||
| while (isPathContainedIn(checkPath, this.#basePath)) { |
There was a problem hiding this comment.
If I'm reading this right, it looks like this will not allow you to have packages which live outside of the directory where the package-map.json file lives. That's an unfortunate limitation: it would be nice if this let you have a cache of packages shared across projects, where the resolution would still be governed by each individual project's package-map.json.
That is, imagine I have a project foo which depends on direct@1.0.0 and indirect@1.0.0, and a project bar which depends on direct@1.0.0 and indirect@1.1.0 (where direct has a listed dependency on indirect@^1.0.0).
It would be nice if I could have /code/foo/package-map.json with
{ "packages": {
"app": {
"path": "./src",
"dependencies": ["direct"]
},
"direct": {
"name": "direct",
"path": "/shared/direct@1.0.0",
"dependencies": ["indirect"]
},
"indirect": {
"name": "indirect",
"path": "/shared/indirect@1.0.0"
}
} }and /code/bar/package-map.json with
{ "packages": {
"app": {
"path": "./src",
"dependencies": ["direct"]
},
"direct": {
"name": "direct",
"path": "/shared/direct@1.0.0",
"dependencies": ["indirect"]
},
"indirect": {
"name": "indirect",
"path": "/shared/indirect@1.1.0"
}
} }and have that just work. But it looks like the code will never find a package outside the directory where the package-map.json lives.
There was a problem hiding this comment.
I'll double-check and add tests (edit: you were right, the isPathContainedIn check was extraneous), but you're meant to have two options:
-
you can have a relative path that point outside the project (
../../../.cache/react@18) -
or you can have an absolute path
The first option is a little more awkward but works better if the package-map.json is shared between systems (you only need to make sure the relative path between project and cache is kept the same between map generation and runtime).
| { | ||
| "packages": { | ||
| "app": { | ||
| "name": "my-app", | ||
| "path": "./packages/app", | ||
| "dependencies": ["utils", "ui-lib"] | ||
| }, | ||
| "utils": { | ||
| "name": "@myorg/utils", | ||
| "path": "./packages/utils", | ||
| "dependencies": [] | ||
| }, | ||
| "ui-lib": { | ||
| "name": "@myorg/ui-lib", | ||
| "path": "./packages/ui-lib", | ||
| "dependencies": ["utils"] | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
How do you support multiple version resolutions of the same package name here? I'd have expected something like "dependencies" to map from a package name to a path to achieve that.
This PR adds a new
--experimental-package-map=<path>flag letting Node.js resolve packages using a static JSON file instead of walkingnode_modulesdirectories.Why?
The
node_modulesresolution algorithm predates npm and its clear definition of the concept of packages. It works well enough and is widely supported, but has known issues:Phantom dependencies - packages can accidentally import things they don't declare, because hoisting makes transitive dependencies visible
Peer dependency resolution is broken in monorepos - if
website-v1usesreact@18andwebsite-v2usesreact@19, and both use a sharedcomponent-libwith React as a peer dep, there's nonode_moduleslayout that resolves correctly. The shared lib always gets whichever React was hoisted.Hoisting is lossy - runtimes can't tell if an import is legitimate or accidental
Resolution requires I/O - you have to hit the filesystem to resolve packages
Package managers have tried workarounds (pnpm symlinks, Yarn PnP), but are either limited by what the filesystem itself can offer (like symlinks) or by their complexity and lack of standardization (like Yarn PnP). This PR offers a mechanism for such tools to solve the problems listed above in tandem with Node.js.
How it works
A
package-map.jsondeclares packages, their locations (relative to the package map), and what each can import:{ "packages": { "my-app": { "name": "my-app", "path": "./src", "dependencies": ["lodash", "react"] }, "lodash": { "name": "lodash", "path": "./node_modules/lodash" }, "react": { "name": "react", "path": "./node_modules/react" } } }When resolving a bare specifier:
dependenciespathERR_PACKAGE_MAP_ACCESS_DENIEDMODULE_NOT_FOUNDCompatibility
An important aspect of the package maps feature that separates it from competing options like Yarn PnP is its builtin compatibility with
node_modulesinstalls. Package managers can generate bothnode_modulesfolders ANDpackage-map.jsonfiles, with the later referencing paths from the former.Tools that know how to leverage
package-map.jsoncan then use this pattern for both static package resolution and strict dependency checks (with optional fallbacks to hoisting if they just wish to use the package map information to emit warnings rather than strict errors), whereas tools that don't will fallback to the classicalnode_modulesresolution.Differences with import maps
Issue #49443 requested to implement import maps. In practice these aren't a good fit for runtimes like Node.js for reasons described here and which can be summarized as: import maps take full ownership of the resolution pipeline by spec, thus preventing implementing additional runtime-specific behaviours such as
exportsorimportsfields.This PR comes as close from implementing import maps as possible but with a very light difference in design making it possible to stay compatible with other Node.js resolution features.
Why not a loader?
The ecosystem now has to deal with a variety of third-party resolvers, most of them not implementing the loader API for many different reasons: too complex, turing-complete, or dependent on a JS runtime.
After I've been following this path for more than six years I can confidently say that loaders would work for Node.js itself but wouldn't be standard enough to be included in at least some of those popular third-party tools.
Questions
--experimental-strict-package-mapsis set? Or via astrictfield inpackage-map.json.