GithubHelp home page GithubHelp logo

Comments (88)

bmeck avatar bmeck commented on May 23, 2024 4

@GeoffreyBooth I'm of the opinion that standardizing on ESM is of great value as it has the potential of sharing code without a build step between a variety of environments (not even just Node/ Browsers). If people are wishing to perform transformations that are not supported in all environments it makes some sense to me that it requires some configuration. I think continuing as we are with build tooling and required configuration should be seen as a red flag for your code working in all environments since some things like web browsers are not seeking to add such hooks at this time.

I firmly believe getting unification of hosting environments and developer ecosystems is of higher value than anything else that ESM can provide. We should seek to provide solutions to use cases, but some use cases are served in simple ways like how importing bindings by name may not be necessary if you can read properties of an object similar to how the default based import design works.

We can solve use cases in multiple ways, and that leads to the potential of multiple loaders which are tailored to specific use cases or environments in specific ways. I don't think shipping a specific loader is the best way to unify developers and actually encourages using tooling to continue to be relied on to assist in managing differences between environments.

from modules.

GeoffreyBooth avatar GeoffreyBooth commented on May 23, 2024 4

@MylesBorins So here’s what I’m thinking. Not a concrete proposal yet, just some general thoughts. It’s obvious that a lot of people want interoperability in some form, somewhere on the spectrum from fully transparent to fully opaque, however people want to define those terms. It seems to me that a lot of the debate in this repo has revolved around how to get Node to support various interoperability features natively, by default, without breaking spec or other concerns. Loaders help us bridge the gap, by supporting these interoperability requests while allowing Node’s native, default implementation to keep fully to spec.

I’m sure that some folks are tempted to design Node’s new modules implementation as if CommonJS never existed. It could be a direct copy of how browsers do things, and would have the cleanliness of a blank slate. Whenever the day comes that CommonJS fades from the scene, Node would already have an easy-to-maintain, streamlined modules implementation. To handle the transition period of the next few years, either people keep on transpiling like they do now; or one or more loaders handle all interoperability with CommonJS.

The opposite end of the spectrum would be to get Node to support every interoperability case we can think of, natively and by default, and either break spec (possibly by asserting that it doesn’t apply to these cases) or by going back to the committee for amendments to the spec to enable what we want to do. This would make the transition period easy, but would leave Node with a modules implementation even more complex than it is now.

A middle ground would be to have Node natively support all the interoperability cases that can be handled without breaking spec, and rely on loaders only for the edge cases where we just don’t think we can achieve the feature that’s been requested without violating the spec (or offending some other high priority concern, like browser equivalence). This is basically what experimental-modules and the NPM implementation are doing now, though without loaders. Add loaders to either one, to cover the interoperability features they don’t yet support, and we’re done.

If it were up to me, I would pick this middle ground approach. It doesn’t sound like this group would ever agree to the “break/ignore spec” approach, and the “use ESM as an opportunity to greenfield modules in Node” approach ultimately means that whatever massive CommonJS loader gets written becomes a de facto part of Node for the next several years, until most projects have no CommonJS dependencies. That could be a long, long way off.

Whereas if Node supports as much interoperability as it can, then these compatibility legacy loaders could fade out sooner. For example, let’s say a project only needs a loader to support static named exports from CommonJS, e.g. import { shuffle } from 'underscore'. The user could decide to rewrite all such lines like import _ from 'underscore', or use a transpiler to do so as part of a build step, and then the loader is gone. People who are transpiling anyway, because they want to use even-newer ES features or they’re using TypeScript or CoffeeScript etc., would probably prefer to handle this in a build step that they already have in order to avoid needing a loader in runtime. If most interoperability concerns are handled by Node without opt-in loaders, many projects won’t need a loader; and many other projects will be able to drop loaders sooner, as we approach an all-ESM future.

The tricky part is that we don’t know precisely what features can’t be part of Node proper and need to be implemented as a loader. Even the example I wrote above, import { shuffle } from 'underscore', has inspired debate among people who think it can be achieved without breaking spec. So maybe the way to get from here to there is for one group to go forth and try to write an implementation that achieves full transparent interoperability, with as many of the features in this repo as possible, without breaking spec (or at least, without breaking spec as far as they’re aware). Another group can play “red team” and point out places they’re breaking spec or deviating from browsers, and that code can be excised and reimplemented as one or more loaders. I’m assuming that the first team isn’t ultimately able to produce an implementation that satisfies all feature requests yet somehow passes spec review—but if they can, that would be great! But assuming they can’t, that would lead the way to creating a Node modules implementation that handles as much as it can, with loaders to cover the rest.

from modules.

bmeck avatar bmeck commented on May 23, 2024 2

@GeoffreyBooth I'm not suggesting they throw out applications they have written, and they can continue to use whatever compile to JS system they already do. I'm stating that the satisfaction of use cases might not match a specific compile to JS system and custom tailoring those loaders is probably going to continue. Node providing a compatible system with other environments is a higher priority to me than matching any given compile to JS system of today since people can continue to use those as they migrate. I don't think standardizing on a compile to JS system is a good idea. We have many tools using the ESM syntax for different semantics and need to admit that adopting and/or excluding loaders will have the same effect that you are seeking to avoid. You will break some amount of application, and also are breaking compatibility with other environments. I see unity in allowing loaders and creating a safe system for usability in other environments, not in encouraging semantics that enforce a specific behavior at odds with other environments.

from modules.

bmeck avatar bmeck commented on May 23, 2024 2

@GeoffreyBooth named imports are not destructuring so I don't use them in my example, using destructuring would change the behavior so even if you did some form of code transformation you wouldn't use destructuring, but some form of property dispatch like _.shuffle in order to get the binding value.

It feels like Node supports destructuring except in import statements—but only when you’re importing from CommonJS.

That probably shakes out from a miscommunication / syntactic similarity of named imports and destructuring when they are different mechanisms.

from modules.

GeoffreyBooth avatar GeoffreyBooth commented on May 23, 2024 1

@bmeck So work with me here. Describe to me a solution that you would find acceptable. My criteria for success are:

  • Code like import x from 'commonjs-lib' that people have been writing for years needs to work like it does now with Babel/esm.
  • Users shouldn’t need to install userland solutions like Babel or esm that transpile an entire app into CommonJS.
  • Whatever the interoperability solution is, it needs to be either part of Node proper or officially supported by Node, like how NPM is.

I’m not a fan of transpilation either, that’s just meant as a shorthand of explaining what I want the loader to achieve.

Let’s say we create a loader called commonjs-loaderthat can make import statements work for CommonJS dependencies. When a user does npm install, NPM can see that their app is in ESM mode (because of something in package.json) and also that they have CommonJS dependencies. Then NPM can throw a warning like:

This project has CommonJS dependencies. Install a loader to allow importing them:

  npm install commonjs-loader

I don’t see the point of NPM not just adding it automatically unless there’s some other loader already configured, but if that makes a difference, fine. The point is that it’s not Babel or esm—it’s not transpiling the entire app into CommonJS. It’s just enabling interoperability, while keeping Node in ESM mode (aside from the CommonJS interoperability).

It does seem odd that this wouldn’t be part of Node, though, as CommonJS is part of Node. This feels like something Node should solve or have a solution for built in, like it has core modules like fs and path.

from modules.

GeoffreyBooth avatar GeoffreyBooth commented on May 23, 2024 1

@bmeck Is there a solution that you would find acceptable where import x from 'commonjs-module' works in ESM code? As opposed to a user transpiling that source into CommonJS before Node ever sees it, and then Node runs the CommonJS like it does now.

from modules.

bmeck avatar bmeck commented on May 23, 2024 1

@benjamingr the problem is the nature of "supports named exports" with that. If we can do it, it should be on by default and stay supported. It requires code transformation and I would be against it by default since it currently breaks how the specification requires things to act by doing behaviors like late binding which are not supported by the VM, or synthetic imports. Those breakages lead me to not want the idea to ever land if it is not on standards track for support by ESM.

Shipping a tool that does this transform ahead of runtime for you seems fine to me. The tool needs to not do destructuring assignment as that loses liveness and doesn't have a clear way to determine if a module namespace being imported is CJS or ESM though. The biggest problem is setting up the transformation without knowing if the import is CJS or ESM. You have to determine the mode of your dependencies in order to transform your module. That requires all dependencies be available and is not suitable for localized isolation in a package manager if that changes over time (people move to/from one format to another). So, it probably wouldn't work at the library level, but it probably would work as an application tool.

from modules.

bmeck avatar bmeck commented on May 23, 2024 1

Can you elaborate on why a preprocessor tool would not know that?

Given a library mylibrary that does import 'foo';, it needs foo in order to determine what format the entry point is. When preprocessing like most libraries the output would be in isolation and not have the foo dependency pinned to a specific version. That means that mylibrary doesn't know what format foo is in if you preprocess it in one source tree but run it in another (like by downloading it off a package registry into a different source tree).

Applications don't have this problem as they generally have dependencies pinned / the entire source tree when they are run. They are a great time to run tools that require pinned versions and the source tree to not change.

from modules.

bmeck avatar bmeck commented on May 23, 2024 1

@benjamingr even if you do it on install that kind of workflows don't work with times you run with symlinked dependencies, are manually updating things in your source tree, and/or don't use npm. I had similar talking about problems of doing things at install time only in package-community/discussions#2 (comment) which might help explain the situation here as well.

from modules.

bmeck avatar bmeck commented on May 23, 2024 1

@GeoffreyBooth you can do:

import _ from 'underscore';
_.shuffle([3, 5, 7]);

and load it using --experimental-modules currently.

from modules.

MylesBorins avatar MylesBorins commented on May 23, 2024 1

from modules.

MylesBorins avatar MylesBorins commented on May 23, 2024 1

from modules.

bmeck avatar bmeck commented on May 23, 2024 1

@naturalethic you can't completely invalidate ESM due to spec idempotentcy requirements. It is just as @ljharb said that you have to make a module that does delegation to the currently "hot" module instead of replacing module records.

Edit:

A crude example of a module that does delegation that only works for new calls to import() and does not work for static import / rebind existing values:

export function then (r) {
  r(import(await getHotModuleURL(...)));
}

from modules.

bmeck avatar bmeck commented on May 23, 2024 1

@arcanis I would be against adding non-package (application level) data to package.json

What is exactly the reason that this cannot be done on a per package level?

NODE_OPTIONS="--loader=@yarn/pnp" is not sufficient I take it then as well? Couldn't you turn off the propagation to children by removing the part of process.env.NODE_OPTIONS that is setting --loader?

from modules.

bmeck avatar bmeck commented on May 23, 2024 1

@arcanis i would assume they exist in the cache as well, i am still unclear on what is project specific hence wanting a meeting.

from modules.

devsnek avatar devsnek commented on May 23, 2024

this is pretty much possible with the current system's hooks. using a combo of resolve and dynamic instantiate you can do everything from hot reloading to babel-style cjs named exports.

from modules.

GeoffreyBooth avatar GeoffreyBooth commented on May 23, 2024

Like I wrote in #70, if developers need to still use Babel or esm or the like in order to achieve a common use case, then many of them will just keep using that tool as they do now and have it transpile everything, and it won’t matter that Node has added native module support. So asking developers to configure such a tool as a direct dependency of their apps doesn’t seem like much of a gain to me; it’s essentially where we are now.

I can see the benefit of maybe the package to be loaded having a flag in its package.json saying “use std/esm to load me” as its way of being compatible with both ESM and CommonJS, and therefore the app developer doesn’t need to install/load esm or Babel as a direct app dependency (as opposed to a dependency of that package). But that only works as long as all of an app’s dependencies have such a flag, and it will be many years before that happens unless we intervene somehow. Perhaps the flag could be added automatically for packages that lack it, but then we’re anointing a pseudo-official package loader which essentially needs to be supported as part of Node. Which might be okay! NPM is distributed with Node and more or less supported as part of Node core, so if esm say becomes the “official” loader dependency for CommonJS modules that don’t specify an alternative, that might be a workable solution.

from modules.

GeoffreyBooth avatar GeoffreyBooth commented on May 23, 2024

@bmeck The issue is, ESM doesn’t offer many benefits for Node users over CommonJS. There’s no reason the many thousands of Node developers will rush to use it. Some will, sure, but many others won’t see the need, just as many packages on NPM are still unapologetically using require statements and never switched to import with transpilation. We have 600,000 CommonJS modules on NPM, that will need to still support CommonJS environments for several years. For all practical purposes, every Node developer will need to import CommonJS packages for several years to come; and the more that using ESM is an incompatible breaking change, the less people will be able to start using it or be inclined to.

CommonJS is part of Node. It’s not just some other loader, like some userland thing that maybe Node tries not to break. If you want people to start using ESM, you need to provide them a way to use it in the world we live in now, where almost every dependency they import is a CommonJS module. And if that way is to push users off on userland solutions like Babel or esm, users will keep using those tools to transpile down to CommonJS the way they’re doing now. Adoption of true ESM will be even slower.

Look, I’m all for getting to the nirvana of an ESM-only future. But you need to provide a path for people to get there, because they’re not going to just throw out every app they’ve ever written and every library they’ve ever used to start using some new spec that has no obvious advantages for them.

from modules.

GeoffreyBooth avatar GeoffreyBooth commented on May 23, 2024

What’s the definition of “loader” here? Can a loader be something that converts import x from 'commonjs-lib' into const x = require('commonjs-lib') at runtime? And then can this loader be included by Node automatically when there are CommonJS dependencies in a package.json? Or if that’s a bridge too far, maybe when there’s something in package.json telling Node to load it, and NPM can put that configuration there when it knows it’s needed because of the dependencies?

Because that would be fine. That would solve the “import from CommonJS” use case.

from modules.

devsnek avatar devsnek commented on May 23, 2024

that could be something that a loader could in theory do, however it causes me great stress that node transpiling code is somehow something that people would be okay with.

from modules.

bmeck avatar bmeck commented on May 23, 2024

@GeoffreyBooth there are a ton of topics covered in that paragraph. Lets go with, it could be? it might not be? to all those questions. In general a loader is something that is given control of HostResolveImportedModule in some way. "Pluggable Loaders" as described above do something that isn't the default behavior. Having Node do something automatically would mean it is default so probably wouldn't be called a loader in the context of "Pluggable Loaders" as described at the top of this issue.

from modules.

bmeck avatar bmeck commented on May 23, 2024
  • Code like import x from 'commonjs-lib' that people have been writing for years needs to work like it does now with Babel/esm.
  • Users shouldn’t need to install userland solutions like Babel or esm that transpile an entire app into CommonJS.

These seem in conflict as you need to use tools to get their behavior if it has problems being converted to ESM. I would say to keep using tooling if you need the behavior of tooling and don't want ESM.

  • Whatever the interoperability solution is, it needs to be either part of Node proper or officially supported by Node, like how NPM is.

As with the first two points you are mandating the behavior of existing tooling, so keep using that tooling.

Given those 3 points the only solution is to completely adopt one of the compiler chains and doing it at runtime rather than implementing ESM. I don't think mandating the behavior of tools and then saying not to use the tools makes sense. The solution of just always using a runtime compiler similar to how Meteor does things satisfies your points but doesn't seem desirable to me.

You could change the specification to comply to some specific tooling, but I'm not going to go into that since this was about what can be done today I presume.


The point is that it’s not Babel or esm—it’s not transpiling the entire app into CommonJS. It’s just enabling interoperability, while keeping Node in ESM mode (aside from the CommonJS interoperability).

This is done through some level compilation/manual hooking even with loaders since it has to manipulate how code functions. I think there might be confusion on how loading code is affected by loaders. Loaders just instrument the various parts of ESM. Breaking ESM constraints requires manipulation of behaviors in some way and not using ESM and/or doing code transforms.

from modules.

bmeck avatar bmeck commented on May 23, 2024

@GeoffreyBooth We can load the module namespace still, whatever that means. --experimental-modules works today and doesn't go outside of any specification behavior. The default behavior could expand in various ways for named imports either by altering the specification or preprocessing. Those behaviors for providing named exports are better described in #81

from modules.

benjamingr avatar benjamingr commented on May 23, 2024

@bmeck @GeoffreyBooth

What if we ship a command line flag (like we do for esm) today that supports named exports of commonjs modules so users get the familiar user experience but we show a warning when it is used and ship Node with a tool that users can run on their project and converts ""cjs named imports"" to destructuring assignments?

That would let users starts with running their transpiled code natively as a start with a flag as well as give them an automatic tool to transition to more compliant ESM in the future. We can give babel and TypeScript users transforms that do this automatically too.

Named exports would work between ESM modules anyway so the UX on those isn't hurt.

from modules.

benjamingr avatar benjamingr commented on May 23, 2024

that loses liveness and doesn't have a clear way to determine if a module namespace being imported is CJS or ESM though.

Can you elaborate on why a preprocessor tool would not know that?

from modules.

ljharb avatar ljharb commented on May 23, 2024

@benjamingr not if it's import() from a URL.

from modules.

GeoffreyBooth avatar GeoffreyBooth commented on May 23, 2024

We can load the module namespace still, whatever that means.

So . . . what does that mean? 😄

It sounds like we have two options here regarding getting import x from 'commonjs-module' to work:

  1. Somehow find a way to make this happen within the context of the ESM spec, however this might be, even if it means going back to the committee and requesting spec changes. This sounds like a lot of what was discussed in #81.
  2. Use a loader or some other plugin to change Node’s behavior regarding CommonJS, even if that means the spec is broken, because it’s something explicitly added by the user and doesn’t mean that Node proper is violating spec.

Either or both options can be pursued. @bmeck you’re an expert on the spec, so I would encourage you to propose solutions for either option. Maybe the group doesn’t decide to pursue those solutions for one reason or another, or maybe you think they’re bad ideas, but if you were forced to come up with them, what would they be?

--experimental-modules doesn’t provide any interoperability with CommonJS, and that’s the problem I’m trying to solve.

from modules.

MylesBorins avatar MylesBorins commented on May 23, 2024

@GeoffreyBooth I think we need to also accept that there is another possibility... we don't support transparent interoperability, and to use common-js one must use import.meta.require

from modules.

benjamingr avatar benjamingr commented on May 23, 2024

@ljharb

@benjamingr not if it's import() from a URL.

I have never considered that live bindings need to work with dynamic imports... somehow that makes them even more magical 😮

That said - wouldn't the overhead of wrapping it "in case" be negligible if it's only done for dynamic imports?

from modules.

bmeck avatar bmeck commented on May 23, 2024

@GeoffreyBooth

--experimental-modules doesn’t provide any interoperability with CommonJS, and that’s the problem I’m trying to solve.

What do you mean? You can import CJS with it.

// main.mjs
import dep from './dep.js';
console.log(dep.x);
// dep.js
module.exports = {x: 12345};

from modules.

benjamingr avatar benjamingr commented on May 23, 2024

@bmeck

That means that mylibrary doesn't know what format foo is in if you preprocess it in one source tree but run it in another (like by downloading it off a package registry into a different source tree).

Thanks - that explains things, but couldn't this be alleviated by requiring running the tool on npm install? (or even doing so automatically once the tool is run?)

from modules.

GeoffreyBooth avatar GeoffreyBooth commented on May 23, 2024

I think we need to also accept that there is another possibility… we don’t support transparent interoperability, and to use common-js one must use import.meta.require

@MylesBorins That’s always an option, of course, but I’d prefer that be a last resort. That means users need to keep track of which of their dependencies are CommonJS and which are ESM, and refactor their code whenever a dependency switches from one format to another. When projects usually have dozens or hundreds of dependencies, that’s a burden; though I’m sure someone will write a tool to automatically convert import and require statements back and forth as necessary depending on the dependency. At the very least, I think such a tool should be built and released before Node’s module support is publicized, so that the release notes can say “use this tool to rewrite your code for you”.

But obviously it’s more user friendly if existing code works as is without needing refactoring, automatic or otherwise.

from modules.

MylesBorins avatar MylesBorins commented on May 23, 2024

from modules.

benjamingr avatar benjamingr commented on May 23, 2024

That means users need to keep track of which of their dependencies are CommonJS and which are ESM, and refactor their code whenever a dependency switches from one format to another.

Why? import.meta.require would keep working - switching to import when dependencies switch would make the code nicer but wouldn't be a deal breaker in this case if I understand correctly.

On the other hand it would provide worse UX than the current userland solutions arguably.

from modules.

GeoffreyBooth avatar GeoffreyBooth commented on May 23, 2024

If import.meta.require can import either CommonJS or ESM, then it’s the universal loader I’ve been asking for behind the import statement. If that exists, as opposed to import.meta.require working for CommonJS only, then why would anyone write import statements? Just always write import.meta.require, and your code Just Works.

I thought one of our goals was also to get people using the standardized ES2015 syntax for imports and exports. Offering import.meta.require goes against that, especially since people will be forced to use it for years (whether or not it imports only CommonJS or both).

From a user’s perspective an import.meta.require that supports either type of module feels like Node is acting obtuse. If Node could allow import.meta.require to import any type of module, obviously Node could allow the same for import but it just refuses to, because spec.

from modules.

bmeck avatar bmeck commented on May 23, 2024

@GeoffreyBooth can you explain how import does not work for CommonJS in the example I gave above.

from modules.

MylesBorins avatar MylesBorins commented on May 23, 2024

from modules.

MylesBorins avatar MylesBorins commented on May 23, 2024

from modules.

GeoffreyBooth avatar GeoffreyBooth commented on May 23, 2024

@bmeck I was asking about importing from dependent packages, not files. Can it do this?

// const { shuffle } = require("underscore");
import { shuffle } from "underscore";

shuffle([3, 5, 7]);

Using Underscore in this example because it’s CommonJS-only.

from modules.

GeoffreyBooth avatar GeoffreyBooth commented on May 23, 2024

Okay, so you can’t do import { shuffle } (the destructured version). I think that’s what #81 was referring to. The problem is, the destructured syntax has been the de facto standard since 2015; that’s what all the examples recommend. Sure, users can refactor, but it’s pretty annoying; and it feels like it shouldn’t be necessary, since { shuffle } = require("underscore") works. It feels like Node supports destructuring except in import statements—but only when you’re importing from CommonJS.

from modules.

GeoffreyBooth avatar GeoffreyBooth commented on May 23, 2024

That probably shakes out from a miscommunication / syntactic similarity of named imports and destructuring when they are different mechanisms.

I know. But that’s a hard distinction for users to make. And we still have the issue that they’ve been encouraged to use the named imports/destructuring syntax since 2014.

from modules.

bmeck avatar bmeck commented on May 23, 2024

named imports syntax and destructing syntax are separate things, even if they both use { } around them. named imports do not have nested structures, nor defaulted values with =. destructuring does not provide live bindings, nor aliasing with as. We should not treat them as the same nor state that one uses the syntax of the other. They are separate features.

from modules.

MylesBorins avatar MylesBorins commented on May 23, 2024

from modules.

devsnek avatar devsnek commented on May 23, 2024

@MylesBorins can you clarify what you mean by destructing on imported common js?

from modules.

bmeck avatar bmeck commented on May 23, 2024

@MylesBorins you could always do:

import _ from 'underscore';
const { shuffle } = _;
_.shuffle([3, 5, 7]);

Per changing the specification, you could expand named imports to have something like:

import {default as {shuffle}} from 'underscore';

probably? it doesn't conflict with any lookups on a glance but doesn't have well defined semantics right now without a proposal.

from modules.

ljharb avatar ljharb commented on May 23, 2024

@MylesBorins i believe it wouldn't permit the names to be statically known prior to evaluation, which is what the syntax/spec requires.

@GeoffreyBooth it might be a hard distinction for users to make, but it's a very important one for them to understand - otherwise they might wrongly think that export default { foo: bar } can be imported like import { foo } from 'path'.

from modules.

MylesBorins avatar MylesBorins commented on May 23, 2024

from modules.

ljharb avatar ljharb commented on May 23, 2024

Since the spec requires that they be verified prior to any evaluation, i believe it would (iow, "statically known" is a spec requirement)

from modules.

GeoffreyBooth avatar GeoffreyBooth commented on May 23, 2024

@ljharb Let’s please not get hung up on the fact that I’ve been referring to it as destructuring syntax. I just meant the import { ... } syntax. Whether or not users understand the proper term for it, they’ve been writing import { shuffle } from 'underscore' for three years now thanks to Babel and so they think that that’s the correct syntax—and it is, but only for ESM. As a user, I would feel like this is a breaking change for me.

But @bmeck’s larger point is well taken. If you can import a CommonJS module, the interoperability problem isn’t as terrible as I was assuming. There’s still the option of having a tool automatically refactor all of a project’s import { shuffle } from 'underscore'-like lines into code like import underscore from 'underscore'; const shuffle = underscore.shuffle;. I would encourage us to make building such a tool a prerequisite before releasing any implementation that doesn’t honor the syntaxes that users have been using in the last few years, so that they have an easy upgrade path.

But obviously it would be a better user experience if that weren’t necessary.

from modules.

benjamingr avatar benjamingr commented on May 23, 2024

@GeoffreyBooth

I would encourage us to make building such a tool a prerequisite before releasing any implementation that doesn’t honor the syntaxes that users have been using in the last few years, so that they have an easy upgrade path.

@bmeck made an interesting point when I suggested that in #82 (comment)

from modules.

GeoffreyBooth avatar GeoffreyBooth commented on May 23, 2024

I didn’t say I was satisfied, just that we have a fallback option. Asking users to refactor their code feels like an admission of defeat, that we couldn’t make it work (or arrogance, that we don’t consider their time valuable). I still think it’s worth exploring if there’s a way to avoid that, even if it means a trip back to TC39.

I have lots of other issues with the current --experimental-modules implementation, starting with .mjs (of course). I think the NPM implementation is closer to what I hope the final version would be. But let’s not keep hijacking this thread.

Getting back to the point of this thread, @MylesBorins what can you accomplish in loaders? Could they be a way to bridge gaps like this?

from modules.

MylesBorins avatar MylesBorins commented on May 23, 2024

An expanded stab at this from #86


Implementation

  • plugin system for loaders is introduced
    • default loader supports the resolution mechanism of the package-name-maps proposal
      • the runtime can generate the map at runtime using the filesystem, but should support the exact resolution semantics of the browser implementation
    • default loader is designed to be 100% compatible with the platform (browser + node interop)
    • loaders can be "bundled" to single meta loader
    • additional "official" loaders can be packaged in node as opt in
      • classic loader
        • "bundled" loader combining json, path crawling, native modules, etc.
        • each of these features can be individually opted in to
    • as new goals are introduced to the platform they are support by the default loader
      • wasm can start as an "official" non default loader and be merged into default loader when supported by platform
      • e.g. we can work with standards to try and support importing of JSON ()
    • loaders will define a mapping between file extensions & a custom loader
    • loaders are top-level, only supported at the application level
    • non default loaders can be specified in the package json via a "loaders" field and cascade
      • "loaders": ["@nodejs/classic"]
      • "loaders": ["@std/esm"]
      • "loaders": ["@typescript/loader", "@nodejs/loaders/native-modules", "@nodejs/loaders/json"]
      • "loaders": ["@mycompany/loader"] # same as above 😉
    • loader meta data can be bootstrapped on by transpilers and bundlers such as babel + webpack to allow for minimal configuration 💯
  • import.meta.require can be used to escape hatch into cjs without requiring a custom loader
    • transpilers can handle this out of the box without referencing a custom loader to create browser compatible esm or script

transition user story

The Company has a large code base that is currently transpiling using babel. They are using import for both esm and cjs. They want to start using esm natively in their code base without transpilation

  1. move to node.js esm implementation but cascade the @std/esm or @babel/loader custom loader to simplify things at first
  2. transpile current app code using babel + babel loader to esm code, converting all current import 'cjs-thing' to import.meta.require without requiring direct user intervention.
  3. test transpiled code and confirm it works as expected
  4. remove custom loaders from package.json
  5. as dependencies move from cjs -> esm update the import.meta.require statements to import

from modules.

bmeck avatar bmeck commented on May 23, 2024

I'm somewhat against one particular point but flexible on the rest given enough discussion of details probably.

default loader uses the package-name-maps proposal for resolution

This means that node loading cannot function without a preprocessing tool to create those package-name-maps as I read it. I do not see manual package-name-map synchronization as desirable or something that will be done for anything of decent sized module graph (even for small applications) without requiring a tool.

We need to find a way that does not require such a hefty configuration. Doing something like creating a module map at runtime might be fine, but I am not ok with requiring the ahead of time tooling that this seems to describe if it excludes a variety of workflows. We can just create it on boot or pass a flag if people want to use a precomputed package-name-map.

This would require also that package-name-maps move further along and have much most stringent review from the WG to ensure that other use cases are well served by them alone. I fear that instead of treating package-name-maps as a way to alleviate web concerns by performing precomputation of a resolution mapping, this idea of using them as the default is to force people to maintain their dependencies in a mapping that is not required for node's workflows nor is near the experience and convenience of being able to put a file on disk.

There also remains concerns about symlink based workflows if the idea is to match the web, see #62 which needs a tooling workflow and differing output if you want to have the keys be converted between one or the other. If the idea is to match the web, we would want to move to the cache system the web is using, which I also am against.

I have raised a few concerns about package-name-maps and the cache mismatch but have not had much traction with the members of those areas and have been told that it is "OK if they don't change their mind". Being OK with a mismatch is fine, but I don't want to mismatch and I don't think package-name-maps nor the cache mismatch have enough matching for this idea of using them and the web behaviors to be a sound idea.

from modules.

GeoffreyBooth avatar GeoffreyBooth commented on May 23, 2024

I'm unclear on the user transition story. In step 2, “transpile current app code,” does that mean transpile it and keep the transpiled output as the new app code? So it's more of a conversion step, automatically refactoring the app to use import.meta.require?

And if that's the case, why would anyone bother with step 5, refactoring those lines into ES2015 import statements?

from modules.

MylesBorins avatar MylesBorins commented on May 23, 2024

from modules.

ljharb avatar ljharb commented on May 23, 2024

The problem with that story is that it doesn’t satisfy all the use cases under “transparent interop”, for which import ‘cjs’ and require(‘esm’) are a necessity. I don’t think it’s productive or helpful to suggest implementations that disregard use cases that members of the working group find critical.

from modules.

ljharb avatar ljharb commented on May 23, 2024

What we have is a list of use cases - we should first establish which, if any, the current implementation does not support - and then compare any other implementations on that basis. Instead of arbitrary issue thread comments, perhaps a document that makes a matrix of use cases vs implementations (linking to impl details separately, since those aren’t as important yet), would help us contrast which implementations serve the most use cases?

from modules.

GeoffreyBooth avatar GeoffreyBooth commented on May 23, 2024

I don’t think it’s productive or helpful to suggest implementations that disregard use cases that members of the working group find critical.

@ljharb I think that’s a bit too high of a bar for proposals. I think people suggesting ideas is helpful, even if a proposal is incomplete or doesn’t cover every use case. Yes, we should follow up each proposal with an evaluation of which use cases are unsatisfied, and that can drive revisions of the proposals, but I wouldn’t want people to feel like they couldn’t throw ideas into the mix.

@MylesBorins I still don’t understand “Step 5 is about what happens when those dependencies change from being cjs to esm”. Even when dependencies add ESM support, most will probably be dual-mode ESM/CommonJS for a long, long time (probably longer than most packages will be actively maintained) because packages won’t want to drop support for Node LTS until the oldest LTS supports ESM. So for most dependencies, import.meta.require will likely work indefinitely. Is that what we really want, for users to basically refactor their module import statements into import.meta.require and leave their code as the latter basically indefinitely?

Most apps, in the near term at least, will end up being import statements for all user code (like my app importing its own files) and import.meta.require for all dependencies (since all will support at least CommonJS, and will for years to come). That would become a de facto new standard pattern for Node apps, to the point that naive users will just assume that Node requires import.meta.require for importing dependencies (and people will wonder why that is, will think that Node didn’t fully add support for the import statement, etc.). And there won’t be any reason for users to ever refactor import.meta.require into import; if anything, users will avoid doing so, as that’s a change that could potentially introduce bugs while it provides no benefit.

from modules.

GeoffreyBooth avatar GeoffreyBooth commented on May 23, 2024

@MylesBorins Can we get back to the loader part of your proposal? Can a loader be written that lets people avoid needing to refactor their code/use import.meta.require?

from modules.

MylesBorins avatar MylesBorins commented on May 23, 2024

@GeoffreyBooth I'm imagining a loader can be made that would allow people to avoid import.meta.require. Currently thinking that this should not be the default loader but open to discussing all the thing

@ljharb fwiw this proposal as it currently stands is not complete by any means... it also doesn't cover the case of native modules. I don't think every proposal will be able to solve every use case. The idea with this proposal was that we could handle a baseline use case and create a platform to help support the ecosystem solve ones that core cannot

Making a small edit above

s/default loader uses the package-name-maps proposal for resolution/default loader supports the resolution mechanism of the package-name-maps

This is to imply that we do not necessarily require the name map, this can be generated at runtime.

from modules.

GeoffreyBooth avatar GeoffreyBooth commented on May 23, 2024

Yeah sorry to zero in on the one thing I found objectionable 😄 In general I think the idea of loaders holds a lot of promise, as a way to enable non-default behavior or even deviations from the spec in an opt-in way.

I think we need to keep in mind “how would you explain this to a layperson” with regard to things that are deviations from current user practice. Like if we say “yes, you can do import { x } from 'lib' but only when lib is ESM,” the Node repo is going to get flooded with bug reports/feature requests like “my import statement doesn’t work”. I understand that these users will just be wrong to complain about intended behavior (depending on how this all shakes out), but that’s going to be the reality of the situation. I understand too that this frustrates @bmeck and @devsnek 😄 but the JavaScript community is a big community, and a huge number of those folks are very novice programmers. We have to be able to not only explain that the syntax doesn’t work for CommonJS, but why that’s so, and why that’s not a bug nor something that we’ll add support for in the future. And this is just an example, you could replace import { x } with import.meta.require or any other implementation we want to release that deviates from what users expect based on what they do now with Babel.

So yes, if there are tools we can add to our toolkit that let us do end runs around some of the blockers we’ve run into regarding things “just working,” I get excited by that potential. Especially if those tools can be training wheels that ease a transition to an all-ESM, fully-compliant world whenever that day comes, and also if these aids can be added/applied to a project in an automatic or semi-automatic way.

from modules.

bmeck avatar bmeck commented on May 23, 2024

@GeoffreyBooth loaders don't let you change how ESM works, but do allow you to rewrite the code. They could let you transform your code into another form, but if that transform is shipped with Node needs to be very clear about what it does and isn't free. Since loading CJS still is required to load as a binding for ESM, you need a signal that a dependency is CJS (we have this with the format property in hooks currently), then you need to rewrite all references to the import names from that module to be some form of delegation. That delegation would also need to be standardized but can probably rely on the behaviors in nodejs/node#20403 .

However, even with all of this effort you won't get around the need to know the list of exported names you are wanting to create for a CJS module. The only way to allow that I know of is to take one of the 3 described solutions in #81 . Loaders don't get around those issues.

from modules.

iarna avatar iarna commented on May 23, 2024

I get concerned when I see:

Loader Plugins can be specified via an out of band mechanism (a.k.a. package.json or command line argument).

Out of band is great, but I want to make sure that some facility for setting these in a js file that doesn't require a second Node.js process is also supported from the start. "use vm to create an independent javascript context using these loaders" would be an acceptable solution, IMO, and arguably still counts as "out of band".

from modules.

MylesBorins avatar MylesBorins commented on May 23, 2024

@iarna that is a great idea! fwiw this is meant to be a starting point and welcome additional features about how to do this in a scripted way.

from modules.

bmeck avatar bmeck commented on May 23, 2024

@iarna have you seen the vm.Module work by @devsnek, it lets you specify linking and could be used as a wrapper, but doesn't require you to make a new context.

from modules.

iarna avatar iarna commented on May 23, 2024

@bmeck I haven't! I'm not particularly picky here, so long as there's a facility. (I feel similarly about import.meta.require, ugly as sin, sure, but it arguably should be.)

from modules.

GeoffreyBooth avatar GeoffreyBooth commented on May 23, 2024

@MylesBorins I had some thoughts about how to use loaders to try to stitch together an implementation that satisfies many of our features; should I describe it here or start a new issue?

from modules.

naturalethic avatar naturalethic commented on May 23, 2024

@devsnek You mentioned 'hot reloading' in the second comment in this thread:

this is pretty much possible with the current system's hooks. using a combo of resolve and dynamic instantiate you can do everything from hot reloading to babel-style cjs named exports.

I have found no mechanism for invalidating / reloading a previously loaded module. Can you direct me to any references on that?

from modules.

ljharb avatar ljharb commented on May 23, 2024

You’d have to set up hot reloading prior to a module being imported, just like with CJS.

from modules.

naturalethic avatar naturalethic commented on May 23, 2024

@bmeck & @ljharb Thanks!

from modules.

TheLarkInn avatar TheLarkInn commented on May 23, 2024

One thing I'd like to offer is leveraging an already vetted, tried, and completely extensible resolver that is async and leverages the default node algorithm oob.

https://github.com/webpack/enhanced-resolve

from modules.

bmeck avatar bmeck commented on May 23, 2024

@TheLarkInn it would be good to support such a loader, but I think the discussion is more around how to enable essentially what the plugins field does in your example. enchanced-resolve is very complex and not something I'd like to take on from a support point of view. I think it is sane for us to have a more minimal API and allo people to call enhanced-resolve for their own purposes within any given loader.

from modules.

arcanis avatar arcanis commented on May 23, 2024

Most of the discussions here have been focused on the specific use case of using the loaders to transpile the code in some capacity. I have a quite different use case in mind, so please feel free to ask me to post this message in a separate thread if you think it'd keep discussions more manageable.

We recently unveiled Yarn Plug'n'Play, whose goal is to generate static maps that Node would then be able to consume. Since Yarn knows everything about the dependency tree, including the location of the packages on the disk, it makes sense to fetch information directly from it - and it makes it possible to avoid creating the node_modules folders, amongst other benefits (whitepaper here).

In order to achieve this, we currently require our users to use the --require option to inject the Yarn resolver into Node. To make this a bit easier and more environment-agnostic we've also introduced yarn node that simply wraps Node and automatically injects this flag if needed. That's not great for a variety of reasons (the main one being that we don't want to substitute to Node), and as such we'd really like to find a way to tell Node that a loader must be used in order for a project to work. Using a per-project settings rather than a per-process one, so that users wouldn't have to change their workflows one bit.

All this to say: the loaders field described by Myles in #82#389761269 would be extremely useful in this regard. We could simply add the Plug'n'Play hook at the beginning of the array, and everything else would Just Work™. While important, transpilers aren't the only kind of project that would benefit from this API.

from modules.

bmeck avatar bmeck commented on May 23, 2024

@arcanis would nodejs/node#18233 be sufficient? We are currently unable to land it if we were to PR it, but just check the design for now and see if anything is missing. With resolve(module, specifier) in place you could have a static mapping still.

from modules.

guybedford avatar guybedford commented on May 23, 2024

@arcanis the difficulty with this model is that it becomes very tricky to distinguish between a loader that is necessary for a package to work and a loader that is necessary for a specific project to work. For example, I might in development use Yarn Plugn'n'Play, then publish that package to npm, with the same plugin and play loader declaration in the package.json. Now anyone installing this package would get the Yarn loader applied, even if they were consuming the package via node_modules with npm.

So this is the current snag here on loaders, making this distinction clear and simple. Suggestions very welcome here!

from modules.

arcanis avatar arcanis commented on May 23, 2024

@bmeck I don't think it would be enough unfortunately, because of the distinction @guybedford mentioned - your PR adds support for per-package hooks, but in the case of PnP the hook must be registered globally (since all packages will need to use it in order to resolve their own dependencies).

The use case would be covered by the "Global Composition" section, except that it would be a production loader, not development only. Using the environment to set the global hooks is nice for debugging, more impractical for production (it doesn't remove the need for yarn node, since the environment has to he set in some way; it also poisons child processes environments).

@guybedford What about a globalLoaders option that Node would use from the closest package.json in the filesystem hierarchy (relative to the script being executed, or the cwd in the case of -e / -p), and would ignore after the program has started (using the loader field from @bmeck's PR instead)? 🤔

from modules.

guybedford avatar guybedford commented on May 23, 2024

globalLoaders sounds like a very useful concept to me. And it would also directly benefit the way jspm wants to apply loaders as well.

from modules.

devsnek avatar devsnek commented on May 23, 2024

if your individual package depends on yarn pnp it should still be package level. i don't want my deps being handled by some other package randomly.

from modules.

arcanis avatar arcanis commented on May 23, 2024

@bmeck Some reasons why I think NODE_OPTIONS might not be the right solution:

  • Setting NODE_OPTIONS is an explicit action. The user has to either set it themselves, or let a tool do it for them.

  • In the first case, they have to either set it system-wide (which is not acceptable, since some projects might require the hook and others might not), or temporarily either in their shell session (meaning they have to remember to remove it later) or the single command line (which makes the command lines much more verbose and hard to write/grasp).

  • In the second case, they have to setup something that will create the NODE_OPTIONS as needed. This something may be a shell alias, or yarn node, but whatever it is it will put itself between Node and the user, adding an extra layer of complexity (for example, yarn node requires at least one extra process to spawn, including a full Node context that would run the yarn binary simply for eventually shelling out to the real Node - and it can be worse depending on the setup). It also doesn't compose very well with other loaders (will each global loader have its own wrapper).

  • It will poison the environment for child processes. While it could be somewhat fixed by removing the part of NODE_OPTIONS that sets --loader, as you mentioned, it's not a working solution, because there's no way to know what added this flag in the first place. The intent is completely lost: is the loader meant to been preserved? or removed? (as a side note, it also requires to parse the NODE_OPTIONS string, which comes with its own problems)

  • What happens if you use fork to spawn a new Node process in a directory installed through Plug'n'Play? If Node doesn't have an integration to find out by itself the global loaders that need to be loaded, it means that the code doing the fork will have to implement it. Will Plug'n'Play (or esm, or babel/register) have to monkey-patch fork to support the use case? Same thing for execSync.

if your individual package depends on yarn pnp it should still be package level. i don't want my deps being handled by some other package randomly.

Plug'n'Play is enabled on an application-basis, not package-basis. You cannot have an individual package depend on PnP (hence why globalLoaders would be ignored for anything else than the running script).

from modules.

bmeck avatar bmeck commented on May 23, 2024

@arcanis all of those are reasons why I believe that it should be done on a per package level. You state that it is enabled on an application-basis, but why can it not be enabled on a per package basis? Most packages when you install them don't come with node_modules populated and loaders let you actively opt-out of node_modules

from modules.

zenparsing avatar zenparsing commented on May 23, 2024

@arcanis For your use case, are there multiple entry points into the application that need the same loader-hook treatment, or is there generally only one entry point?

from modules.

arcanis avatar arcanis commented on May 23, 2024

You state that it is enabled on an application-basis, but why can it not be enabled on a per package basis?

@bmeck There are a few reasons:

  • Something that might not be clear from my previous comments is that Plug'n'Play causes a single Javascript file called .pnp.js to be generated where would typically be found the node_modules folder (which isn't created at all). This file is the loader that needs to be injected. This is the one and only source of truth to know where are located the packages on the disk - it contains everything needed for Node to say "package X is requiring file Y - then it needs to be loaded at location /path/to/Y" - and this anywhere on the dependency tree.

  • Packages (most of them downloaded directly from the npm registry) have no idea whether they're used under PnP or regular installs. Nor should they have to: it wouldn't be feasible to ask for all package authors to add the PnP loader to their package.json - and it would break for people not using PnP. No, the packages should be generic, and it's to the loader to work whatever is the package configuration.

  • Even if they were aware of whether they run in a PnP or non-PnP environment, the loader path cannot be known on their side (and writing it inside their respective package.json files at install-time isn't feasible, since the same package folder will be used by multiple .pnp.js for multiple projects).

In case the reason why per-package configuration wouldn't work, I recommend taking a look at the whitepaper - I think it might help clarify some points that can still be confusing, especially the Detailed Design section.

For your use case, are there multiple entry points into the application that need the same loader-hook treatment, or is there generally only one entry point?

@zenparsing It's up to the user, they can have as many as they want, and it cannot be statically determined since they can new ones after the install. Basically, each time they would usually run a script using node ./my-script.js, they'll need to register the loader present in this folder.

from modules.

bmeck avatar bmeck commented on May 23, 2024

@arcanis I did see the design, but I'm still unclear on why it needs to be that way. Maybe we should setup a call. I'm not sure but it seems like there is some confusion or I'm missing something. I agree that the current whitepaper/RFC would not be an ideal fit for either global or per package loaders. I'm interested in solving the use case, but if we must perfectly match that RFC it seems unlikely to be pleasant.

Something that might not be clear from my previous comments is that Plug'n'Play causes a single Javascript file called .pnp.js to be generated where would typically be found the node_modules folder (which isn't created at all). This file is the loader that needs to be injected. This is the one and only source of truth to know where are located the packages on the disk - it contains everything needed for Node to say "package X is requiring file Y - then it needs to be loaded at location /path/to/Y" - and this anywhere on the dependency tree.

So can't a Loader just find that file/generate it as needed? A package manager could even make a big single shared .pnp.js that works across all packages. I don't see how this really relates to needing it to be a application level option. I must be missing something.

Packages (most of them downloaded directly from the npm registry) have no idea whether they're used under PnP or regular installs. Nor should they have to: it wouldn't be feasible to ask for all package authors to add the PnP loader to their package.json - and it would break for people not using PnP. No, the packages should be generic, and it's to the loader to work whatever is the package configuration.

That sounds like you want a global hook, but as described in both the comments and a few notes in the RFC this would be a breaking change to use this resolution algorithm. Wouldn't that mean that users should opt into this behavior? And if they should opt into this behavior how would they do so to state they are using non-standard behavior if not on a per package level.

Even if they were aware of whether they run in a PnP or non-PnP environment, the loader path cannot be known on their side (and writing it inside their respective package.json files at install-time isn't feasible, since the same package folder will be used by multiple .pnp.js for multiple projects).

I don't understand this comment, why can't you have either linkage via symlinks like pnpm or use the default resolution algorithm to get to your loader?

from modules.

arcanis avatar arcanis commented on May 23, 2024

if we must perfectly match that RFC it seems unlikely to be pleasant.

My goal in being here is to help make this pleasant to everyone. If the RFC has to consider new facts, so be it. I want to stress that we're really open to feedback and don't want to push this on anyone 🙂

So can't a Loader just find that file/generate it as needed? A package manager could even make a big single shared .pnp.js that works across all packages.

I'm not entirely sure what you mean - a loader cannot generate that file, since it is meant to be generated by the package manager. Yarn already does make a big single single shared .pnp.js file that works across all packages, so I'm not sure either I understand correctly.

If you mean "accross all projects", this isn't possible - multiple projects have different resolutions (different lockfiles, in sort) that cannot be merged, and the .pnp.js is meant to be checked-in anyway.

this would be a breaking change to use this resolution algorithm. Wouldn't that mean that users should opt into this behavior? And if they should opt into this behavior how would they do so to state they are using non-standard behavior if not on a per package level.

There's a few points here that can be discussed (maybe it'd be better to mention it on the rfc thread, since people here might not be interested about Plug'n'Play's details?):

  • First, something to note is that Plug'n'Play aims to be compatible with the existing ecosystem.
  • Which means that whether Plug'n'Play is enabled or not should not affect the packages.
  • Which in turn means that packages shouldn't have to be aware of whether PnP is enabled or not.

How do we achieve this compatibility? By strictly following the rules of dependencies / devDependencies. It covers almost all needs. The only want it doesn't cover is obviously packages directly accessing the node_modules folder, but the fix is usually quite simple, thanks to require.resolve. Anyway, the main point is: operating under PnP or not is a setting that is done at the installation level, not the package level.

I don't understand this comment, why can't you have either linkage via symlinks like pnpm or use the default resolution algorithm to get to your loader?

Neither symlinks nor the node resolution would solve the problem. Consider the following hierarchy:

/path/to/cache/lodash-1.2.3/package.json -> {"loader": "???", "dependencies": {"left-pad": "*"}}
/path/to/cache/left-pad-1.0/package.json -> {"loader": "???"}
/path/to/cache/left-pad-2.0/package.json -> {"loader": "???"}

/path/to/project-1/my-script.js -> require(`lodash`)
/path/to/project-1/.pnp.js -> lodash=lodash-1.2.3, left-pad=left-pad-1.0

/path/to/project-2/my-script.js -> require(`lodash`)
/path/to/project-2/.pnp.js -> lodash=lodash-1.2.3, left-pad=left-pad-2.0

What would you put in loader that would simultaneously target both project-1/.pnp.js and project-2/.pnp.js, depending on the environment? Note that one of the goal of Plug'n'Play is to avoid I/O, meaning that creating symlinks in project-1 and project-2 isn't allowed (and it would require --preserve-symlinks anyway).

from modules.

bmeck avatar bmeck commented on May 23, 2024

@arcanis I'm getting quite confused with a lot of implementation of how your system works right now being thrown around. I'm going to just state how I would expect things to look given what I'm understanding:

/path/to/cache/[email protected]/package.json -> {"loader":"./.pnp.js"}
/path/to/cache/[email protected]/package.json -> {"loader":"./.pnp.js"}
/path/to/cache/[email protected]/package.json -> {"loader":"./.pnp.js"}
/path/to/cache/[email protected]/package.json -> {"loader":"./.pnp.js", "dependencies": {"foo":"2.0.0"}}
/path/to/cache/[email protected]/package.json -> {"loader":"./.pnp.js", "dependencies": {"foo":"^2"}}

/path/to/project-1/app.js -> require(`foo`) require(`bar`)
/path/to/project-1/package.json -> {"loader": "./.pnp.js"}
# unclear how this handles the `foo@2` nesting?
/path/to/project-1/.pnp.js -> [email protected], [email protected]

/path/to/project-2/app.js -> require(`foo`) require(`bar`)
/path/to/project-2/package.json -> {"loader": "./.pnp.js"}
# no nesting, simple
/path/to/project-2/.pnp.js => [email protected], [email protected]

/path/to/project-3/app.js -> require(`foo`) require(`bar`)
/path/to/project-3/package.json -> {"loader": "./.pnp.js"}
# no nesting if bar using [email protected]
# nesting if bar using [email protected]
/path/to/project-3/.pnp.js => [email protected], [email protected]

Given that .pnp.js can intercept all incoming requests for dependencies within a "project", we can ensure that it loads to a location that properly does the requirements in the RFC.

  1. receive a resolve(moduleId (generally a URL), specifier) request.
  2. create body of /path/to/cache/... and assigned id.
  3. map assigned id to /path/to/cache/... resolutions so that resolve(...) can handle it.
  4. respond with body

This could be done in a ton of other ways, the pnpm style symlinkscould be used, and a loader would prevent the need for using--preserve-symlinks` if it handled that itself.

We also face some problems from taint if we use methods of finding /path/to/cache/... like os.homedir() if someone is using things like child_process.fork, cluster, etc. if the flags are removed / if they set a different user id. Having /path/to/cache be local and resolvable without outside data would be ideal. This is part of why pnpm style symlinks are attractive. These problems can also be problematic if we use CWD and try to run /path/to/project-1/ in a different working directory from /path/to/project-1/ such as using a CWD of /tmp/pid1000.


We also face some problems of creating a distinction of application and package here. If I were to require('/path/to/project-1') and it acted differently from node /path/to/project-1 we face some problems regarding how to determine if something is running as an application or as a package. Using module.parent style detectiong is insufficient since it could be loaded in odd ways:

$ # using the repl
$ node
> require('/path/to/project-1')
$ # as a preload
$ node -r /path/to/project-1 sidecar
$ # bad APIs
$ node -e '...;require("module").runMain(...)'

I have serious concerns in creating systems that create this divergence since it makes behavior very dependent on how things are used, which we don't currently have well defined paths for a variety of things in ESM. Things like module.parent don't even make sense in ESM.

It seems like if you really need that distinction we should clarify what an application is far beyond having it mean that something is running via a specific CLI command. I feel like such a distinction might need to ensure that an application cannot be used as a package and vice versa.

from modules.

arcanis avatar arcanis commented on May 23, 2024

I must be missing something: how come the loader fields in the cache packages reference ./.pnp.js, but there is no .pnp.js file in the cache packages? Wouldn't those relative paths resolve to /path/to/cache/[email protected]/.pnp.js, which doesn't exist (and cannot exist since it contains project-specific data that cannot be stored in the cache)?

from modules.

arcanis avatar arcanis commented on May 23, 2024

Some highlights from my understanding of what we discussed (@bmeck, @MylesBorins, please correct me if I'm mistaken somewhere):

  • The resolution process should be split in two steps:

    • First a pass would resolve the bare imports into a value,
    • Then during the second pass the loader would take this value, finish resolving it if needed, and finally load a module using it.
    • Taking a JSX loader as example: react-calendar would resolve to the unqualified resolution /path/to/cache/react-calendar@1 during the first step, then the second pass would kick in and would internally turn this path into a fully qualified resolution, turning it into /path/to/cache/react-calendar@1/lib/calendar.jsx, which it would finally load.
  • Since the PnP has a global knowledge of all packages of the dependency tree, it needs to affect all packages being loaded

    • Still, this is complex to achieve by pure application-level loaders, because it's not clear what is an application.
    • This can be achieved by a package-level loader that would wrap the loaders from all imported modules.
    • Through this pattern a loader would effectively act as an application-level loader, but would require some boilerplate.
  • The value returned during the first pass might not be an actual filesystem path

    • An Asset API (similar in concept to FS, but read-only) would then allow consumers (both userland code and loaders) to use these values to read the files they point to

A pseudo-implementation for what such a PnP loader would look like this (@bmeck, let me know if I made a conceptual error here). Note that I made PnP more complex than it actually is (it currently returns actual folder paths, not hashes) to illustrate in the next snippet the use of the asset api.

import {resolveToUnqualified} from './.pnp.js';

export default function pnpLoader(parentLoader) {
  return {
    // request=react-calendar/component
    loaderStepOne: (request, issuer) => {
      // return=TZhsV3bGQV2KZIjFIObr/component
      return resolveToUnqualified(request, issuer);
    },
    // request=TZhsV3bGQV2KZIjFIObr/component
    loaderStepTwo: request => {
      const {loader, ... rest} = parentLoader.loaderStepTwo(request);
      // Wrap the loader to substitute it by our own
      return {loader: pnpLoader(loader), ... rest};
    }
  };
}

And a pseudo-implementation for the default loader would be something like this (keep in mind this is SUPER pseudo-code, we haven't discussed code samples and this is just based out of my rough understanding of how the asset api could work):

import * as fs from 'fs';

export default function defaultNodeLoader(parentLoader) {
  return {
    // Note that the PnP loader would entirely shortcut this step, since
    // it implements its own step one.
    loaderStepOne: (request, issuer) => {
      // We need to run the full resolution since the extension and index.js
      // must be resolved in order to select the right node_modules
      return runNodeResolution(request, issuer, fs);
    },
    //
    loaderStepTwo: request => {
      // If there's a parent resolver, use it to resolve the assets (since it's
      // the only one that'll know how to use the special identifier
      // TZhsV3bGQV2KZIjFIObr/component that's been returned by
      // the PnP loader)
      const selectedFs = parentLoader ? parentLoader.assets : fs;
      
      // Then use the FS we've selected to run the resolution; we need to run
      // it again (even if we do it in the step one of this loader), because the
      // step one is not guaranteed to return a fully qualified path (the PnP
      // override wouldn't, for example)
      const qualifiedPath = runNodeResolution(request, issuer, selectedFs);
      // without PnP = /.../node_modules/react-calendar/component/index.js
      // with PnP    = TZhsV3bGQV2KZIjFIObr/component/index.js

      // And then, once it has finished resolving the fully qualified path, it
      // can load it (still using the parent loader FS if needed)
      const body = await selectedFs.readFile(qualifiedPath);
      const loader = /*not sure how it will be loaded?*/;

      return {body, loader};
    }
  };
}

from modules.

MylesBorins avatar MylesBorins commented on May 23, 2024

Closing this as there has been no movement in a while, please feel free to re-open or ask me to do so if you are unable to.

from modules.

Related Issues (20)

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.