Node: ESM "exports" field disables "main" lookup

Created on 11 Oct 2019  路  14Comments  路  Source: nodejs/node

If there is an "exports" field in the package.json file of a module, the "main" field becomes ignored by the resolver and if one tries to import something from 'package' where there is no '.' export, it throws an error:
Cannot resolve package exports target 'undefined' matched for '.' in /home/mzasso/test/bug-exports/node_modules/my-module/package.json, imported from /home/mzasso/test/bug-exports/test.mjs

Is this the expected behavior? If so, the documentation is not clear about that.

By the way, the exports resolver could probably be improved, saying that there was no matching key instead of trying to use "undefined" as the value.

Repro: https://github.com/targos/bug-esm-exports

@nodejs/modules-active-members

ES Modules

Most helpful comment

I also thought the intent was for "exports" to become the new main in Node.js, whereas supporting both very much keeps "main" around.

I think forcing people to repeat the same string twice for the next 2+ years isn't worth the win that most packages will get from locking down. Especially if they anyhow need to support users that don't respect lockdowns (that's why they still set main after all). So I would assume those authors would do something like this:

"main": "./foo.js",
"exports": {} // reuse main but lock down in modern node, alternatively false

Once pre-exports node cycles out of LTS, those package authors could switch to:

"exports": "./foo.js"

If they also have subpaths they need to map:

"main": "./foo.js",
"exports": {
  "./sub": "/lib/sub.js"
}

After dropping support for pre-export node:

"exports": {
  ".": "./foo.js",
  "./sub": "/lib/sub.js"
}

And then they'd also delete the top-level sub.js that just forwards to the real file which they created back in the day. Afterwards they would be in a clean state that has all their exported files in a single field.

I believe "exports is the new main" is a valuable aspiration but I don't think that making the transitional period more painful for people trying to use exports while still supporting valid LTS releases of node is how we'll make it more likely. I'd much rather make it less painful to add exports to a package than more painful to keep main.

All 14 comments

That does sound like a bug. main should be "merged into" exports effectively. Also, that error message should definitely be better. Thanks for trying this out!

Thanks to @tpoisseau for reporting it to me 馃槈

That does sound like a bug. main should be "merged into" exports effectively.

Is it? I was under the impression that main was to be ignored when exports was present, this way you could specify an entrypoint for pre-exports (aka pre-modules) node. Meaning the current behavior is intentional, if poorly documented.

Is there a clear reason it needs to be ignored?

If you want them to differ, it already works; if you want one to be unresolvable, you can assign main/dot to false or similar; what鈥檚 the use case where you want to force an explicit (non backwards compatible, if someone changes main) dot?

Is it? I was under the impression that main was to be ignored when exports was present

The way I think it was supposed to work is, in pseudo-code and ignoring some sugar:

isTopLevelMapping =  (e) => typeof e === 'string' || Array.isArray(e);
desuragedExports = isTopLevelMapping(e) ?
  { ".": pkg.exports } : pkg.exports;
exports = { ".": pkg.main, ...desuragedExports };

So the exports value will win over a pre-exports main field IFF you want to by explicitly adding a . mapping. There's nothing actively ignoring main. It just has a lower precedence than exports[.].

That sounds exactly like the way I'd expect it to work (modulo, useful error messages when things are invalid).

Agreed this is a bug and that the logic should explicitly be that we only use the main if "exports" is not an array, object with a . property or string.

I do feel like this makes the distinction to users for when "main" applies quite complex to understand though, as some uses of the "exports" field allow it, while other uses of the "exports" field do not. That we overlooked it is also a bad sign.

The point is that "exports" is supposed to be encapsulating by default and an explicit definition of the exports of the package.

Eg - "exports": { "./": "./features/" } allows defining a package with no main that can only load features by name. If we default to the "main", that also means defaulting to an "index.js" lookup... breaking the exports encapsulation feature.

If you want that case tho, then you can do something like this, no?

"exports": {
  ".": false,
  "./": "./features/"
}

which imo is a more explicit indication of that intention.

@ljharb false for an exports target is not currently supported and would throw something like "invalid package target 'false'" I believe when loading the main, as opposed to something more specific like "main entry point not defined". Supporting null mappings as an explicit feature could correct this.

I also thought the intent was for "exports" to become the _new main_ in Node.js, whereas supporting both very much keeps "main" around.

when dot is present, it is the new main - but when not, it seems like supporting null is the better approach.

The following works if you want to actively prevent a main:

"exports": {
  ".": [],
  "./": "./features/"
}

But I would argue that this is a very uncommon case and usually you'd... just not create a file called exactly "index" in the root of your package and you'd be good.

I also thought the intent was for "exports" to become the new main in Node.js, whereas supporting both very much keeps "main" around.

I think forcing people to repeat the same string twice for the next 2+ years isn't worth the win that most packages will get from locking down. Especially if they anyhow need to support users that don't respect lockdowns (that's why they still set main after all). So I would assume those authors would do something like this:

"main": "./foo.js",
"exports": {} // reuse main but lock down in modern node, alternatively false

Once pre-exports node cycles out of LTS, those package authors could switch to:

"exports": "./foo.js"

If they also have subpaths they need to map:

"main": "./foo.js",
"exports": {
  "./sub": "/lib/sub.js"
}

After dropping support for pre-export node:

"exports": {
  ".": "./foo.js",
  "./sub": "/lib/sub.js"
}

And then they'd also delete the top-level sub.js that just forwards to the real file which they created back in the day. Afterwards they would be in a clean state that has all their exported files in a single field.

I believe "exports is the new main" is a valuable aspiration but I don't think that making the transitional period more painful for people trying to use exports while still supporting valid LTS releases of node is how we'll make it more likely. I'd much rather make it less painful to add exports to a package than more painful to keep main.

This fallback behaviour has been implemented and landed in https://github.com/nodejs/node/pull/29978. Thanks again @targos for the testing work here, that's a huge help.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

TazmanianDI picture TazmanianDI  路  127Comments

speakeasypuncture picture speakeasypuncture  路  152Comments

egoroof picture egoroof  路  90Comments

feross picture feross  路  208Comments

Trott picture Trott  路  87Comments