File extension/directory index resolution in ESM #268
Description
The topic of file extension and directory index resolution (whether automatic or opt-in) is on the agenda for this week, but I can’t seem to find an issue where it has been discussed on its own. It’s not one of the features on the list in our README, though it was mentioned in our road map as being a feature removed as part of Phase 1. It was mentioned by @devsnek and @ljharb in #253 as features that they would want before the new implementation is released. I thought this topic should get its own issue so that the broader group’s attention is drawn to it, since the entire group is significantly larger than the people who attend the meetings.
I’m going to try to keep this post neutral, and I’ll update this top post as needed when people point out things I haven’t thought of.
What file extension/directory index resolution is
In CommonJS, it’s idiomatic to write code like this:
require('./startup');
Where ./startup
is resolved as either ./startup.js
or ./startup/index.js
, or potentially other file extensions.
Why users like it
Aside from saving users from typing three or nine characters, there’s a big usability benefit in saving refactoring effort when a file grows to the point where it needs to be refactored from one into several. In the example above, your project could start with a startup.js
file and when it grows unwieldy you could refactor into startup/index.js
which in turn require
s several other files, and you wouldn’t need to update any of the files that already require('./startup')
.
What we need to decide
Since we’re planning on building loaders, and since it seems fair to assume that loaders will be capable of rewriting specifiers, file extension/directory index resolution will be possible one way or another in ESM. So the question isn’t whether it should be possible, but rather how easy it should be: enabled by default, enabled via opt-in package.json
configuration, or enabled via a package-level loader. Or looked at another way, do we want to encourage the practice, slightly discourage it, or strongly discourage it? The arguments for not enabling it automatically are usually the same, so I’m grouping those together.
Arguments for supporting file extension/directory index resolution automatically
-
This is already supported automatically by
require
in CommonJS. That’s what users are familiar with. -
It’s idiomatic to leave off extensions in CommonJS; actually typing them is unusual.
-
It’s also idiomatic to leave off extensions in ESM JavaScript written to be transpiled by Babel or
esm
. There are countless projects out there written in ESM syntax that assume automatic file extension/directory index resolution, and many of those projects might very well work out-of-the-box if we continued to support automatic file extension/directory index resolution.
Arguments against supporting file extension/directory index resolution automatically
-
Browsers don’t automatically resolve file extensions or directory index files (unless a server is explicitly configured to do so, but this is practically unheard of), and we’re aiming to be as equivalent with browsers as possible. As the import maps proposal puts it:
Note how unlike some Node.js usages, we include the ending
.js
here. File extensions are required in browsers; unlike in Node, we do not have the luxury of trying multiple file extensions until we find a good match. Fortunately, including file extensions also works in Node.js; that is, if everyone uses file extensions for submodules, their code will work in both environments. . . .Unlike in Node.js, in the browser we don’t have the luxury of a reasonably-fast file system that we can crawl looking for modules. Thus, we cannot implement the Node module resolution algorithm directly; it would require performing multiple server round-trips for every
import
statement, wasting bandwidth and time as we continue to get 404s. We need to ensure that everyimport
statement causes only one HTTP request; this necessitates some measure of precomputation.By making file extension/directory index resolution opt-in, we encourage people to write packages that are compatible with browsers by default. This is especially important because leaving off the extension is currently idiomatic; we’re encouraging the establishment of a new idiom, where code is idiomatically capable of running in either environment (at least, with regards to this; obviously there are lots of other ways people can create packages that are incapable of running in browsers).
-
Node may someday support
import
of network path URLs, likeimport Three from 'https://unpkg.com/three@0.102.1/src/Three.js'
. Presumably extension searching will not apply to such URLs. It would be inconsistent for automatic file extension/directory index resolution to work forfile://
URLs but nothttps://
URLs. Such a system would mean that a package relying on automatic file extension/directory index resolution would work when installed locally but fail when loaded from a network URL. -
There’s a performance cost to automatic file extension/directory index resolution, that continues to grow as more supported file extensions are added. It’s one thing to search for
startup.js
, and then when it’s not found to search forstartup/index.js
; it’s another to search forstartup.js
,startup.mjs
,startup.wasm
(etc.) and thenstartup/index.js
,startup/index.mjs
,startup/index.wasm
etc. This performance hit is a big reason thatrequire.extensions
was deprecated. From Node’s docs:Note that the number of file system operations that the module system has to perform in order to resolve a
require(…)
statement to a filename scales linearly with the number of registered extensions. In other words, adding extensions slows down the module loader and should be discouraged.By requiring this to be opt-in via configuration or a loader, that configuration or loader can specify how this feature should behave. For example, maybe
.mjs
is the only supported extension automatically resolved, and the performance cost is reduced. -
The UX becomes more complicated now that we have multiple supported extensions. When a folder contains
file.js
,file.mjs
andfile.wasm
and youimport ‘./file’
, which file gets loaded? When support for new extensions is added to core, do they always get added last in precedence? -
We can release our new implementation unflagged without automatic file extension/directory index resolution and choose to add it later, but we can’t do the reverse.
Arguments for supporting it via package-level configuration
-
As part of Node proper, performance would be as optimized as it could be.
-
This would standardize users’ use of file extension/directory index resolution in a way that supporting it via third-party loaders would not.
Arguments for supporting it via a package-level loader but not via configuration
- This would send a strong signal from Node that leaving off extensions is discouraged. Getting the feature would require either the performance hit of adding a loader, or the complexity of transpilation/building.
If people offer new arguments (or correct these) I’ll update this post.
Background: https://youtu.be/M3BM9TB-8yA?t=835