镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content
This repository was archived by the owner on Sep 2, 2023. It is now read-only.
This repository was archived by the owner on Sep 2, 2023. It is now read-only.

Revisit dot-main in exports field #368

Closed

Description

@hybrist

Originally the exports proposal had a way to specify the main field as well. The following was the equivalent of setting main to './lib/entry.mjs':

{
  "exports": {
    ".": "./lib/entry.mjs"
  }
}

We discarded this for two reasons:

  1. Without dual-mode, having two separate values seemed confusing.
  2. There were educational and aesthetic concerns with a property key that's just "." without additional context.

But with fallbacks and/or differential serving, we run into a new reason for wanting this second key: Backwards compatibility.

The problem is that if I'm maintaining a package std-x-polyfill and I want to fall back to the native std:x module where it's available, I could express it like this:

{
  "main": ["std:x", "./lib/x-polyfill.cjs"]
}

The problem is now: This package cannot be used in anything but the latest version of node. Older versions will not recognize this kind of main field. If we'd support exports[.] as an alternative with higher priority, the problem can be solved (if a bit verbose):

{
  "main": "./lib/x-polyfill.cjs",
  "exports": {
    ".": ["std:x", "./lib/x-polyfill.cjs"]
  }
}

Activity

  1. ljharb commented on Aug 6, 2019

    @ljharb
    SponsorMember

    i'm not aware of any proposal that would ever alter how "main" works, including adding array support.

    What I'd expect is something like this:

    {
      "main": "./path",
      "exports": {
        "./path": ["std:x", "./lib/x-polyfill.cjs"]
      }
    }

    with a path.js as a backwards-compat fallback.

  2. guybedford commented on Aug 6, 2019

    @guybedford
    Contributor

    Supporting relative ./paths on the LHS in exports is a sort of internal rewriting system.

    The concern with these approaches is that you can import an absolute file path, but not necessarily actually get it when you load it! So there is a lack of transparency to the user.

    Contrast this with exports, where the package name boundary naturally forms the encapsulation, instead of it applying to all types of importing. Users already know there is some indirection with package imports as import 'pkg' will import the main, so this sort of naturally extends to the package subpaths when importing the bare specifier. While importing /path/to/file.js suddenly loading another file is quite a different thing.

    The point being, if we want to do "internal rewrites" that is a decision I think we should make very carefully thinking about the usability implications, and justified based on its unique merits.

  3. hybrist commented on Aug 6, 2019

    @hybrist
    ContributorAuthor

    @ljharb Your solution seems to leak the existence of the polyfill onto the exports while also assuming that exports are something happening after main, adding one more level of indirection. I think both are surprising. Exports, unlike browser, is not an environment-specific override. It's a first-class mapping, just like main. So it applying after main seems counterintuitive.

  4. ljharb commented on Aug 6, 2019

    @ljharb
    SponsorMember

    Right - i see it as more of a virtual filesystem, which to me “main” sits on top of - iow i do not see main and exports as siblings, but rather “main” as a high level thing and “exports” as a low level thing.

  5. hybrist commented on Aug 6, 2019

    @hybrist
    ContributorAuthor

    I'm not sure I follow how main is any different here.main maps "foo" to an internal path, exports maps "foo/bar" to an internal path. The only difference is the substring after "foo" which is "" for main.

    If we start asking people to add intermediate junk paths to exports so they can use its features for main, it seems like we're actively undoing the clean interface main+exports could provide. Now I suddenly need to support both "foo" and "foo/<some-opaque-id>" as my interface..?

  6. ljharb commented on Aug 6, 2019

    @ljharb
    SponsorMember

    Maybe I’m not clear on what you want either. I don’t think the format of “main” should ever change. What exact goal do you want to achieve that couldn’t be done with an unchanging main format?

  7. hybrist commented on Aug 6, 2019

    @hybrist
    ContributorAuthor

    The use case is in the original issue: I want a simple poly fill package that falls back to a native module if it exists. The package has a single entry point, so I don’t need to (and don’t want to) expose additional paths.

    The same idea applies for something like React where I might want to declaratively expose a dev and production build, depending on the env. It’s seems weird if that works for subpaths but not for the package name itself.

  8. ljharb commented on Aug 6, 2019

    @ljharb
    SponsorMember

    then why specify main at all? You can have an exports with just “index.js” in it.

  9. hybrist commented on Aug 6, 2019

    @hybrist
    ContributorAuthor

    Right, that's what this is suggesting. Maybe the confusion is that in the current implementation, exports cannot be used to specify main? We explicitly removed that feature to prevent dual-mode packages.

  10. ljharb commented on Aug 6, 2019

    @ljharb
    SponsorMember

    We explicitly removed that feature to prevent dual-mode packages.

    It would certainly allow packages that worked on both old and new node, but not a package that was both CJS and ESM (which is what some have expressed reservations about). I don't recall any concern about allowing a package to have different entry points for entirely different versions of node.

  11. hybrist commented on Aug 6, 2019

    @hybrist
    ContributorAuthor
  12. ljharb commented on Aug 6, 2019

    @ljharb
    SponsorMember

    If exports couldn't specify "main", but it could overwrite whatever "main" is pointing to - wouldn't that solve all of our concerns?

    Dual-mode packages would still not be enabled; dual-node packages would; "main" wouldn't have to change (ie, break) its format; etc.

    The only downside is that you might have to repeat the RHS of "main", in the LHS of "exports" - but that explicitness kind of seems like a pro, not a con?

  13. hybrist commented on Aug 7, 2019

    @hybrist
    ContributorAuthor

    I think that's a reasonable outcome. Right now we have an explicit check that prevents exports from overwriting the value of main and we could just remove this check if nobody else objects.

  14. GeoffreyBooth commented on Aug 7, 2019

    @GeoffreyBooth
    Member

    An overwrite makes much more sense to me than an extends. In other words, if "exports" is set and defines ".", that defines the package entry point; and that’s it. "main" is irrelevant, whether or not it’s even defined. "main" would only apply to older versions of Node for such a package.

  15. 12 remaining items

  16. hybrist commented on Aug 29, 2019

    @hybrist
    ContributorAuthor

    @ljharb Sure, let me try to sum the two things up.

    --no-exports

    There's a case where with dot-main a node upgrade could fail. Scenario: One of your (potentially indirect) dependencies choose to point main to a CommonJS file and exports[.] to an ESM file. Your app works great on the pre-exports node but after the upgrade attempts to require the package fail (since exports[.] wins over main and CommonJS can't deal with the ESM file it points to). You would now be blocked in rolling out this new node version until either everything is rewritten to ESM (including potential intermediate packages) or you managed to remove the problematic dependency.

    Having a simple flag to opt out of exports (e.g. --no-exports) would allow the node upgrade to be unblocked, assuming you can set it in your environment. The flag globally disables exports (just like the experimental flag today). Once your dependency tree was fixed to properly handle this scenario, you can remove the flag again.

    Agreement on the call was that this would sufficiently address the issue ("we have a reasonable workaround").

    Decide on additional property vs. accepting exports[.]

    This referred to a potential bikeshed over where exactly dot-main would live. There have been readability concerns around exports[.] because "." on its own is somewhat light on signal. Chatting with @guybedford out-of-band one possible path would be to also reintroduce the sugar the proposal started with so that the literal . key would only appear in "advanced" scenarios where multiple paths are exported.

    // equivalent:
    { "exports": "./lib/foo.js" }
    { "exports": { ".": "./lib/foo.js" }
    { "main": { ".": "./lib/foo.js", "exports": {} }
    
    // equivalent:
    { "exports": [{ "future": "syntax" }, "./lib/foo.js"] }
    { "exports": { ".": [{ "future": "syntax" }, "./lib/foo.js"] } }

    In other words: If exports is a simple string or array ("leaf value"), it's considered sugar for setting dot-main to that value.

  17. ljharb commented on Aug 30, 2019

    @ljharb
    SponsorMember

    I don't consider a flag a reasonable workaround; I could be relying on exports working, and then suddenly add a dependency that forces me to use --no-exports, thereby offering no workaround except "don't use the dep", which isn't one.

  18. hybrist commented on Aug 30, 2019

    @hybrist
    ContributorAuthor

    If you just add a dependency that uses exports and it only ships ESM but you want to require it, I'm not sure how that relates to --no-exports. That's the same problem as with a dependency that doesn't support Buffer but you need to pass it one. And its the same solution: You either can't use it or you have to change either end of the API (consumer or implementation)..?

  19. ljharb commented on Aug 30, 2019

    @ljharb
    SponsorMember

    The goal is to increase compatibility, not create a forking point for the ecosystem.

  20. hybrist commented on Aug 30, 2019

    @hybrist
    ContributorAuthor

    I don't think anybody disagrees there. Is your vote that we go back to "add array syntax to main"?

  21. ljharb commented on Aug 30, 2019

    @ljharb
    SponsorMember

    No; that’s much less backwards compatible.

    I think this overlaps heavily with the use case that motivates dual packages - it’s critical to be able to have a package that works on both node 10 and 14 with the same specifiers, and ideal that it can be imported and required with the same specifiers on node 14. I suspect any solution to this will address the concern, making a no-exports flag useless.

    Additionally, since specifying exports is meant to prevent import/require of certain files, I’d not want a trivial node flag to be able to bypass that.

  22. hybrist commented on Aug 30, 2019

    @hybrist
    ContributorAuthor

    I'm not sure what alternatives you see that I'm missing. From what I can tell, when introducing exports we have exactly two options:

    1. "array-in-main": A package that starts using it (including the advanced features like fallbacks) cannot be compatible with environments that don't support fallbacks.
    2. "dot-main": A package that starts using it (including the advanced features like fallbacks) may have a different behavior in an exports-supporting environments vs. a non-exports-supporting environment.

    Any attempt I made in trying to figure out a 3rd way failed. Even when not accounting for overhead/cost. Please also note that this is about packages that explicitly made the choice to have inconsistent behavior between main and exports. We should strongly discourage that kind of design imo but I don't think there's a way to statically verify it.

  23. ljharb commented on Aug 30, 2019

    @ljharb
    SponsorMember

    I think if a package has decided to be user-hostile, and we have no way to prevent it, then we have to just allow it.

    Our mandate should be to make user-friendly ways easy and ergonomic, and failing that, at least possible.

  24. hybrist commented on Aug 30, 2019

    @hybrist
    ContributorAuthor

    Is it fair to say that using the terms above you'd favor "dot-main" with "if your dependency screwed up / is user-hostile, you may need to fix it before you can upgrade node for your existing project" ("no --no-exports")?

  25. ljharb commented on Aug 30, 2019

    @ljharb
    SponsorMember

    Yes, I think that's fair to say. In other words, I actively don't want a flag to bypass the dual-mode/dual-node hazard, I want to (without blocking exports) move forward looking for a solution to the wider problem of that hazard.

  26. MylesBorins commented on Aug 30, 2019

    @MylesBorins
    Contributor

    I don't believe that the --no-exports flag had anything to do with the dual mode hazard. The reason for the flag was compatibility... allowing folks moving from a version of node that didn't support exports to move to a version that does support exports without having to do a wholesale refactor.

    I don't think this has to do with user hostile... Folks may choose to offer legacy support via main so that they can continue to support, for example, Node.js 10 while it is still supported in LTS for another 20 months. I'm having a hard time grasping why this pattern would be problematic. The non-exports variant would be backwards compatible... it would also make sense to be a wholesale change. You are either running in the old mode or the new mode. We have lots of prior art in node of offering this type of opt-out.

  27. hybrist commented on Aug 30, 2019

    @hybrist
    ContributorAuthor

    I don't think this has to do with user hostile... Folks may choose to offer legacy support via main so that they can continue to support, for example, Node.js 10 while it is still supported in LTS for another 20 months

    The problem is when main and exports actively point to different targets, especially while not all tools (or versions of node in LTS) support exports. It means that code can surprisingly break while upgrading tools and it may be hard to fix if the issue is a package ten layers deep into the dependency tree. It doesn't even matter if its CJS and ESM. The same applies to any kind of API difference.

    An example of a "good" use of main+exports could be:

    {
      "main": "./lib/uuid-full.js",
      "exports": [
        "std:uuid",
        { "onlyIfWebCrypto": "./lib/uuid-small.js" },
        "./lib/uuid-full.js"
      ]
    }

    The major point in that example is: main is 100% the same interface and works in the same environments as something that would also be targeted via exports.

    This would be the "user-hostile" version:

    {
      "main": "./lib/uuid-full.js",
      // no need to care about a complete polyfill, surely environments that need one
      // would use main and not exports!
      "exports": "./lib/uuid-minimal.js"
    }

    Having exports resolve to ESM but main to CJS is just one example of such a package. But it's not the only one.

  28. hybrist commented on Sep 11, 2019

    @hybrist
    ContributorAuthor
  29. hybrist commented on Sep 18, 2019

    @hybrist
    ContributorAuthor
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions