Repository navigation
Feature request: Utility functions for the steps of resolution and loading #26
Description
Today it is possible to alter the resolve algorithm to some extent using --experimental-specifier-resolution. However that switch only supports a couple of fixed use-cases and is not available programmatically within a loader implementation. When implementing a custom loader it would be nice to have access to a fine grained API so we can re-use parts of the default resolve algorithm but still be able to interject whatever logic you want in between steps, or skip/replace steps, without having to re-implement the full algorithm. That way a custom loader can be composed from the API rather than have to re-implement the whole resolve algorithm.
The old node resolve algorithm was quite easy to re-implement so for that we did not really need a fine grained API. The new algorithm supports more features and is therefore more work to re-implement. So IMHO it would make sense to expose an API for the steps in this more complex algorithm rather than having custom loaders re-implement or copy it in full.
The use-cases for this would be for example implementing a loader for .ts files in ts-node where the algorithm is the same but only differ in file extension. Also, Yarn PnP could use this for its resolve. See below for those requests.
ORIGINAL POST (before the issue was re-formed as a feature request):
I'm sorry if this is not the right forum to ask this, if not pls let me know where it is appropriate to ask.
I'm curious about how the loader specification and --experimental-specifier-resolution interact. Specifically I'm looking into loading ES modules directly from typescript .ts files using any specifier (bare, relative). With the PR "esm: consolidate ESM Loader methods #37468" merged, this seems to have gotten easier. From what I understand it would now be possible to have something like this:
import { foo } from "./foo.ts"And then just implement the loader hook as the resolve hook will not throw on the unknown .ts extension. This seems like a nice improvement as we don't have to re-implement the full resolve anymore. However most typescript projects are setup to import without extensions, like this:
import { foo } from "./foo"I believe having this resolved is related to --experimental-specifier-resolution but AFAIK this only support .js. So in order to resolve the above to foo.ts the only way I see today is to re-implement the whole module resolution algorithm, which with the new spec is a rather big task. I think that is what ts-node is doing here, basically copying the impl from the internal nodejs loader.
I'm guessing the --experimental-specifier-resolution=node flag somehow augments the internal loader to check for .js and index.js (I have not looked deep into the source code though). The point being I think it would be nice if a loader could be allowed to somehow do the same augmentation. Specifically allowing imports without extension to be resolved to .ts files without having to re-implement the full resolution algorithm. Or is there some other way to achieve this?
Activity
GeoffreyBooth commentedon Sep 12, 2021
So to paraphrase, basically what you’re asking for is a hook within Node’s resolution algorithm, to inject custom logic to add
.tsto the list of inferred suffixes, which under--experimental-specifier-resolution=nodeis.js,.json,.node,/index.js,/index.json,/index.node. (In the explicit/ESM-default version of the resolution algorithm, this inference doesn’t happen.) Is this more or less correct?I understand why you’d want this, because the full resolution algorithm is daunting to reimplement. But I think that’s exactly what popular tools like
ts-nodeare already doing, often depending on a libraryresolvethat provides exactly that.In the near term, I think that’s the path you’ll need to take; we have enough on our plate at the moment trying to complete the loaders roadmap, and providing utility functions for things that can be (and already are) implemented in the ecosystem should naturally be the lowest priority. That’s not to say that such utilities aren’t worth providing in Node core; we already provide
require.resolve, and an ESM equivalent (or multiple equivalents, to perhaps break up the parts of the resolution algorithm) might be a request you can get the core collaborators to agree to support. But it wouldn’t happen soon, unless you want to take the initiative and make the PRs yourself. If that’s something that interests you, we can use this repo to discuss what the various functions should be and their APIs, so that you have an approved design before you start coding anything.Here are some references to catch you up on related discussions that have happened so far (deep linking to my own comments to spare you from reading the entirety of these long threads, and because I think I summarized some consensuses in those comments):
Also, have you tried simply checking for existence of
`${specifier}.ts`? What edge cases would be missed by a loader like the following?cspotcode commentedon Sep 12, 2021
I want to clarify for anyone else reading this: ts-node is not technically reimplementing node's resolver.
We ship a copy of node's source code, with a minimal patch applied to essentially expose the API being described here. Ideally node would provide this API so we don't need to ship a copy of logic that is already within node's binary.
This is not to say I disagree about priorities or time constraints, but there's also the risk that this use-case becomes harder than it needs to be because it's not considered in earlier loader design work.
I think it'll miss stuff like when package.json "main" points to
./indexwhich should get./index.tsarcanis commentedon Sep 12, 2021
To support this with another example, Yarn PnP as well doesn't want to replace the full resolver. We only want to inject into a very limited part of the resolution (package name to directory), and it's the way Node currently works that requires us to copy a (much) larger part of the Node logic.
This isn't helped with Node's internals sometimes relying on hidden bindings that not even loaders have officially access to (cf nodejs/node#39513).
jonaskello commentedon Sep 12, 2021
@GeoffreyBooth Thanks for the reply! :-). Yes you are correct that is what I'm looking for. I currently can see two main ways it could be implemented. Either as options to the default loader or as a separate API. Your example would work for relative specifiers but not for bare specifiers? For bare specifiers currently you need to re-implement the full ESM algorithm?
To give an example of what I mean as options to default loader it could be something like this (which probably is not a good idea):
EDIT: To clarify, the above is intended as a programmatic and more flexible version of
--experimental-specifier-resolution.However, I think an API would be more useful so you can compose your own resolve from that without having to fully re-implement all of it. I did not fully follow if the discussions you linked are about such an API or are they about something else? I guess I need to read the full threads after all :-).
I totally understand about the priorities. It is sure possible to re-implement the algorithm so perhaps I will do that but just wanted to check if there was an easier way. I'm not aware of the ESM algorithm being implemented in the ecosystem yet? There is an issue on the resolve repo but that seems to not have gotten far yet:
browserify/resolve#222
As mentioned above ts-node is maintaining a full copy of the whole ESM resolver from node's code with some tweaks (to solve what I mentioned above).
cspotcode commentedon Sep 12, 2021
For what it's worth, the relevant files are in
rawanddist-raw. The former are copy-pasted from node, kept there merely for convenience when using thediffcommand. The latter are patched and distributed.https://github.com/TypeStrong/ts-node/tree/main/raw
https://github.com/TypeStrong/ts-node/tree/main/dist-raw
For example, you can
diff ./raw/node-esm-resolve-implementation-v15.3.0.js ./dist-raw/node-esm-resolve-implementation.jsto see what we changed.JakobJingleheimer commentedon Sep 13, 2021
At some point recently, we did discuss exposing
defaultGetFormatanddefaultGetSourcein theloadhook's arguments (instead ofdefaultLoad).Or do you mean you'd want it exposed from some builtin package, like Node's module builtin?
GeoffreyBooth commentedon Sep 13, 2021
My first instinct is to create publicly accessible versions of many of the functions in https://github.com/nodejs/node/blob/master/lib/internal/modules/esm/resolve.js, which I guess is more or less what https://github.com/TypeStrong/ts-node/tree/main/raw?rgh-link-date=2021-09-12T22%3A14%3A08Z is doing. This would correspond with the steps in the algorithm, listed at https://nodejs.org/api/esm.html#esm_resolution_algorithm. So then within your
resolvehook, you could call the same functions that Node’s internaldefaultResolvecalls, but choose to replace one of them (or inject extra logic between some of them).jonaskello commentedon Sep 13, 2021
Yes this is exactly what I meant. Having a fine grained API with functions that does a little part each so you can compose your own loader from that and interject whatever logic you want in between, or skip/replace steps, without having to re-implement the full algorithm.
GeoffreyBooth commentedon Sep 13, 2021
Okay, perhaps we should rename the issue (and reword the initial post)? And this can be a feature request. Once you can compose your own
resolveand override only the pieces you want, there won’t be a need for--experimental-specifier-resolutionanymore since it would be trivial to make a custom loader that does what--experimental-specifier-resolutiondoes. (Or does what it does, plus an extra inferred extension, in your case.)[-]Interaction between loader hooks and --experimental-specifier-resolution[/-][+]Feature request: Fine grained API to implement loaders that can do what --experimental-specifier-resolution does and more[/+]JakobJingleheimer commentedon Sep 13, 2021
I think there might be 2 separate issues here, the second being:
You'll need a custom resolve hook to return a
urlbecause Node'sdefaultResolvewill throw. But that's trivial to write (there's an example for CoffeeScript in the landed and soon-to-be-released update to the ESM docs).(I realise, Andrew, you are quite familiar with TypeScript; including extra detail for others)
In both examples you will suffer the same problem forced by the TypeScript team. The former is the correct usage; however, the latter was the lesser-of-two-evils (as well as a popular corner-cut) at the time as a workaround to avoid the TS team's decision to disallow TypeScript file extensions in import specifiers.
For the first example, that will work just fine because it's factually accurate (the file being read literally does contain the extension
.tsin its name); however, if that itself appears in a typescript file, you'd need to replace the.tsfile extension after the file/source is read but before it is passed totscto be transformed (otherwise,tscwill self-destruct). This is likely the easiest avenue because it leverages the most built-in behaviour of the various tools involved. I think this might be enough to get both sides to do what they're supposed to do.I think relying on
--experimental-specifier-resolutionis not the way to go (for a number of reasons, most obvious that it is "experimental").I think this is a promising avenue. We've talked about doing that for at least one of them already; @cspotcode @jonaskello do you need all of them or just a subset?
cspotcode commentedon Sep 13, 2021
Another couple cases that I need to support:
foo.tswhen the only file on disk isfoo.tsfoo.tswhen bothfoo.jsandfoo.tsexist(In this example, the user has followed my recommendation and their code is
import { foo } from './foo.js';, and our loader has resolved it to/foo.ts)When we need to
getFormatany.tsfile, we can replace the extension with.jsand askdefaultGetFormathow it will classify a .js file in the same location. Effectively, .ts follows the same rules as .js. In the example above, we calldefaultGetFormat('/foo.js')without risking a filesystem error or needlessly loadingfoo.jsfrom disk.Ideally, I imagine we could do something like this pseudocode:
cspotcode commentedon Sep 13, 2021
I will have to think about this.
We need
defaultGetFormat, either with pluggable extensions as described above, or we can get away with using today's extension-replacement hack.We also need
resolve, but it will not be useful to us if we cannot customize its behavior. We can make a diff of our changes to highlight which behaviors need to be customized. One change: we added.js->.tsresolution, which is a bit different than--experimental-specifier-resolution.GeoffreyBooth commentedon Sep 13, 2021
See the updated CoffeeScript loader example: https://github.com/nodejs/node/blob/master/doc/api/esm.md#transpiler-loader. We added a new helper function that’s probably a better solution in general than the extension-replacement hack. I think there’s a good argument for this helper being part of core, since if it’s in core it could reference the cached
package.jsonfiles rather than needing to crawl the filesystem for files that the Node process has already loaded. (This is also much more generic and flexible than something particular to file extensions.)cspotcode commentedon Sep 14, 2021
Currently our duplicated
resolveloader also needs to readpackage.jsonsince it needs access to other fields likemainandexports. It might be worthwhile exposing thepackage.jsoncache for more than just getting package types.I forget, will an http loader composed with a TS loader be able to respect
package.jsontype if thatpackage.jsonlives on the http server? Since node could theoretically loadpackage.jsonfiles via loaders as 'json' modules?6 remaining items
cspotcode commentedon Sep 15, 2021
Are you referring to one of the
*Hostinterface declarations as an example? Or an implementation of TypeScript'sSystem, which declares every bit of functionality that the compiler attempts to use from the runtime / OS, such as filesystem access, timers, native implementations of hashing algorithms, etc? I mentioned it as an example of dependency injection, though I imagine node's reusable resolver utilities would accept implementations of their ownHostinterfaces, requiring only the subset of filesystem functionality they require.GeoffreyBooth commentedon Sep 15, 2021
@guybedford, if I can loop you in, what do you think of #26 (comment)? Since you wrote many of those functions and designed the overall ESM resolution algorithm.
jonaskello commentedon Sep 19, 2021
Regarding which functions I would need in the API.
My goal is to be able to resolve typescript files, not just in simple cases for absolute/relative files, but in more complicated cases where you have
tsconfig.references(project references) in a yarn workspace and alsotsconfig.pathsspecified. The way typescript works it requires you to import from the output that it will create. This means that you can for example do this:Now the source for the file being imported above lives in
packages/pkg-a/src/foo.tsandpackages/pkg-a/lib/foo.jsdoes not have to exist on disk at the time we want to resolve becuase we have not run the compiler yet. However we still need to be able to resolve it and load the corresponding source file. In the above example we have a sub-path for the package specifier but the same is true if there was no sub-path. For example you can have this:And in
@myapp/pkg-a/package.jsonthemainfield is set tolib/index.jsbut the source for that lives insrc/index.tsand the filelib/index.jsmay not exist if the compiler has not run. However we need to be able to resolve it back tosrc/index.tsand load that instead.I've done some initial experiments with the code in
resolve.js. I found two main places where it hits the filesystem and both of them are in thepackageResolvefunction. One is to ascend the filesystem looking forpackage.jsonand the other is whenlegacyMainResolveis called becuase that function will probe for files in the file system according to the legacy rules.If there was an API of helper functions for a loader, I think ideally they should be able to be called without hitting the filesystem. One reason is for typescript as I stated above about the files not being compiled yet so if the resolution tries to probe for them there will be an exception. Another reason may be if you don't want to lookup
package.jsonin the filesystem but get it from somewhere else (I think this is what @arcanis wanted above but I might have misunderstood).Currently I'm experimenting with some refactorings to the
resolve.jsfile. InpackageExportsResolveandpackageImportsResolve, I've added a parameter forpackageResolveso you can inject your own impl of that. This means you can avoid filesystem access inpackageExportsResolveandpackageImportsResolve. I've also exported these functions that I use in my loader:Probably I don't need all of them in the end. The problem I encountered now is that
legacyMainResolvehits the filesystem. My current thinking is that instead of probing for files, it could return an array of possible files. This means thatpackageResolvewould return an array of possible files. This would leave the filesystem probing to the loader so it can decide which file is the actual file to use. For example iflegacyMainResolvecomes up withlib/index.jsas an alternative and that does not exist, I can still translate it tosrc/foo.tswithout hitting an exception in between.I'll continue to experiment a bit and see where it takes me.
JakobJingleheimer commentedon Oct 19, 2021
I'm wondering if
tsconfig.pathsandtsconfig.referencesneed support as it seems they'll be obsolete with typescript supporting workspaces and subpaths / module resolution (currently in beta).jonaskello commentedon Oct 19, 2021
Yes, I also think subpath imports could replace
tsconfig.paths. I'm kind of hoping the typescript team makestsconfig.pathsincompatible with the node12 modules setting so we don't have to supporttsconfig.pathsin loader hooks.For
tsconfig.referencesI'm not sure there is an overlap with subpath imports. Could you elaborate about that?JakobJingleheimer commentedon Oct 20, 2021
Ah, sorry, I meant
tsconfig.paths↔packageJson.imports(subpath)tsconfig.references↔packageJson.workspacesI have not used
tsconfig.references, but they look exactly likepackageJson.workspacesand a quick read of the TS project references doc sounds like workspaces (npm and yarn).cspotcode commentedon Oct 20, 2021
tsconfig.referencestells the TS compiler to dooutDir->rootDirmappings for additional tsconfigs.I think, but I'm not sure, that
outDir->rootDirresolution is unnecessary within a single-tsconfig project, because all relative imports happen within the samerootDir. Once you get into composite projects, then code in one of the projects is attempting to import from theoutDirof another. For the language service, and for our purposes, this means resolving from non-existentoutDirs to their correspondingrootDirs.I don't think workspaces describe that
outDir->rootDirmapping.I didn't mention
rootDirsabove, but that would also need to be considered.Caveat that I haven't read the entire thread above, so maybe I'm retreading previous conversation.
[-]Feature request: Fine grained API to implement loaders that can do what --experimental-specifier-resolution does and more[/-][+]Feature request: Utility functions for the steps of resolution and loading[/+]jonaskello commentedon Oct 24, 2021
Yes,
packageJson.workspacesandtsconfig.referenceswork in tandem. Using workspaces you get symlinks for your packages innode_modules. Usingtsconfig.referenceseach package can have it's owntsconfig.jsonfor separate root/outDir and other compiler options.Additionally
tsconfig.referencesis also used to describe the dependencies between the packages. Eg.packageAcan have atsconfig.referencestopackageBand thentscknows it has to compilePackageBbeforePackageA. Grantedtsccould infer this frompackage.json.dependenciesbut this is not how the typescript team choose to do it.giltayar commentedon Nov 11, 2021
Jumping in to this conversation pretty late, but I did implement a TS Loader without the need for implementing module resolution. You can see the specific lines that did it here: https://github.com/giltayar/babel-register-esm/blob/eeb9a79c7646c7ee38e0cbb64de85b0548057076/src/babel-register-esm.js#L27.
(It's been working pretty well at our company for the past half year or so. No gotchas found.)
The idea is that if the
.jsfile does not exist (because transpilation didn't occur), it will use thedefaultResolveto find the equivalent.tsfile, and if it exists, use that.In essence,
defaultResolveis the "utility" I use to implement module resolution.Am I missing something? Is this a bad idea?
jonaskello commentedon Nov 12, 2021
@giltayar Yes that works for simple cases. For example esbuild-node-loader also takes a simple approach. I don't think there is something bad about this if that is all you need.
The problem is that it does not work in more advanced cases. The two specific cases that I know of are:
srcandlib). I describe this problem in more detail here.Also you will probably get worse performance if you first scan for
jsfiles using the default resolve, and then a second time fortsfiles with your own logic, but this is probably not a big issue if you use the loader only in development.rxliuli commentedon Oct 2, 2022
I had a similar problem, I needed to point directly to the source code instead of the bundle dist when using a local bundle in a monorepo to avoid a lot of initial builds at the beginning, and also to debug the code
Update, I solved my problem with
createRequire()which allows me to import another file from a specified file, e.g. when importing theamodule, it is automatically redirected toa/src/by the pluginplugin code