-
Notifications
You must be signed in to change notification settings - Fork 30.1k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
module: ESM loader approach #36954
Comments
@GeoffreyBooth I feel rather vindicated RE Concerns with Next() #3: When I was setting up the I think if we opt for the |
One point of concern about the Done method: when implementing a loader for mocking (as I did for testdouble's ESM support), you need to add cache busting to the final url, which means that you want to be the last in the chain. And yet, when loading the source, you want to be the first in the chain, because you know you're not really transforming the code, but rather replacing it. This is why I prefer the Next() approach. While it means that loader developers need to be more aware of how the chaining works, it gives them more flexibility in how they implement the resolve and load functions of the loader, and does not "chain" them to the opinionated thinking of how things should happen, which is what the Done approach is trying to do. Exhibit A 😊, the ESM mock loader for testdouble: https://github.com/testdouble/quibble/blob/main/lib/quibble.mjs |
@giltayar sorry, I'm not following (but would like to understand your concern). Regarding needing the particular I looked through your code example (I've looked thru Quibble previously—slick stuff!), but the "why" didn't jump out at me. At first read, it sounds like what you're describing meticulously strong-arms a process that would probably naturally result in what you want anyway. If this is an edge-case, unless it's a very compelling edge-case, I would likely lean towards the option that does not ensure all developers have a much more complicated and gotcha-prone experience so that a small subset might use a small bit of extra functionality. |
If I understand correctly, neither API allows awaiting an entirely different module resolution. For example if the format of |
Currently, node does read the package's package.json, checking its If you need to orchestrate the sequence of imports such that const { default: foo } = await import('foo');
const { default: bar } = await import('bar'); If you're saying that |
This comment has been minimized.
This comment has been minimized.
Yes, you understood!
Is a mocking library an edge case for loaders? I don't think so. I'm guessing there will be a few of those.
How does that happen in the Done method? If the
Maybe I'm missing something, but how would it naturally occur?
As I said above, I believe mocking libraries are a compelling case. |
For sure supporting mocking is compelling. I'm thinking mocking would be used only during automated testing (if not, please let me know of the other scenario(s)). If so, I would expect the mocking loader to need only a "test": "NODE_OPTIONS='--loader mockLoader' …" Then in mockLoader's load, read a static list of what to mock, check the url against that, and do on match (and opt-out on mismatch): export async function load(input, url /* … */) {
const mock = mapOfMocks.get(url);
if (mock) return {
format: 'module',
source: mock,
};
// omitting a return = opt-out
} If you wanted to bypass a mock, you could add some kind of query param flag to the specifier, like export async function load(input, inputUrl, context, done, defaultLoader) {
const url = new URL(inputUrl);
const params = new URLSearchParams(url.search);
if (params.has('noMock')) return;
if (params.has('doMock')) {
const mockUrl = …;
const mock = defaultLoader(mockUrl);
if (mock) return {
format: 'module',
source: mock,
};
}
else { /* global mocks */ }
// omitting a return → opt-out
} Perhaps don't call |
The reason mocking loaders also need to hook the (in Quibble currently it's also used as a hacky way to figure out where the module file is (search for "dummyImportModuleToGetAtPath" in my blog post, but I expect this hack to go away once we have |
Does jest's mocking loader need |
@JakobJingleheimer In your HTTPS loader example, what is export async function load(
input,
inputUrl,
context,
// done,
// defaultLoader,
) {
if (input) return; // opt-out
I use mocking of network calls during development to control API responses without needing live backends in particular states. That could be implemented at a low level (mock/intercept the networking machinery itself) or by mocking the function where the network fetch occurs. Application monitoring could also be considered another form of mocking. If you think of a use case like a live dashboard of an app’s traffic, getting the data for such a tool could be implemented by mocking/proxying lower level functions like core parts of Connect/Express/other framework or of Node. |
@giltayar so actually I had read that blog post way back when! Having re-read it, now I remember the purpose of the "generation" query param. To sum up the problem, it sounds like you're using the generation number in
When running tests in parallel, Have I got that right? If so, why does your resolve need to be last? I think all that matters is that your query params exist in the final value of Example--loader=httpsLoader \
--loader=quibbleLoader \
--loader=babelLoader
// quibbleLoader
export async function resolve(specifier, parentUrl) {
if (!isQuibbly) return;
// …
const url = new URL(/* … */);
url.searchParams.set('__quibble', global.__quibble.stubModuleGeneration);
return {
url: url.href,
};
} // babelLoader
export async function resolve(specifier, parentUrl) {
if (!isBabelly) return;
// …
const url = new URL(/* … */);
url.searchParams.set('__babel', babelStuff);
return {
url: url.href,
};
} The result of the resolve chain would be something like |
@cspotcode yes for the same reason(s) Quibble does. |
@GeoffreyBooth In the case of HTTPS Loader, if interim source already exists, that indicates there's nothing for HTTPS Loader to do. My example may have over-simplified slightly, since
Ah, true. In that case, just ensure that mocker occurs ahead of HTTPS Loader (and any other remote loaders) in the queue 😉 I'm thinking that they would not need to conditionally change sequence.
I think in that case, it would just need to be first in the queue (which they already need, citing something like "ensure Foo is the first The updates to ESMLoader.load() would also need to ensure individual properties are not inadvertently stomped (eg. loader4 returns only a |
Nope. There can be only one specific mock per-process at a specific time. But a test could mock the module in one way at a certain time, and serially after that mock it in another way. And that is the purpose of the generations: to allow the test to change the mocks serially.
It needs to be last because I don't really care how the module is resolved. I just want to add my query parameter to it. Hence, it needs to be the last.
Exactly. That's why it needs to be last! Or did I misunderstand? |
I think not important for
Yes, I'm pretty sure you misunderstood 🙂 If all you need is your query param to be present in the final value, that can happen anywhere in the chain (as long as other loaders behave themselves). Think of it like an assembly-line, where each loader does its own part: I make and attach the headlights, you make and attach the upholstery; neither of us does that to the exclusion of the other (hence why calling |
Won't work, because same file might be importing the module multiple times (using
|
That sounds like something everybody would want, which is mutually exclusive / impossible (regardless of
If quibble is before another loader that would supply that, I thiiink this is addressed by leveraging the export async function resolve(specifier, …, defaultResolver) {
// …
const fileUrl = defaultResolver(specifier, …);
const url = new URL(fileUrl);
// …
} |
There are 2 leading approach proposals for ESM loaders and chaining them.
Similarities
Both approaches:
resolve()
: finding the source (equivalent to the current experimentalresolve()
); returns {Object}format?
: see esm → getFormaturl
: same as current (see esm → resolve)load()
: supplying the source (a combination of the current experimentalgetFormat()
,getSource()
, andtransformSource()
); returns {Object}format
: same as current (see esm → getFormat)source
: same as current (see esm → getSource)resolve
andload
) are chained:resolve()
s are executed (resolve1, resolve2, …resolveN)load()
s are executed (load1, load2, …loadN)Differences
Next()
This approach is originally detailed in #36396.
Hooks are called in reverse order (last first): a hook's 3rd argument would be a
next()
function, which is a reference to the previous loader hook. Ex there are 3 loaders:unpkg
,http-to-https
, andcache-buster
(cache-buster
is the final loader in the chain):cache-buster
invokeshttp-to-https
, which in turn invokesunpkg
(which itself invokes Node's default):cache-buster
←http-to-https
←unpkg
← Node's defaultThe user must actively connect the chain or it (likely) fails: If a hook does not call
next
, "the loader short-circuits the chain and no further loaders are called".Done()
This approach was also proposed in #36396 (in this comment).
The guiding principle of this approach is principal of least knowledge.
Hooks are called in the order they're declared/listed, and the return of the previous is fed as the input of the subsequent/next hook, and each hook is called automatically (unless short-circuited):
unpkg
→http-to-https
→cache-buster
(if none of the supplied loaders output valid values, node's default loader/hook is invoked, enabling a hook to potentially handle only part and avoid re-implementing the native functionality node already provides via the default hook).Hooks have a
done
argument, used in rare circumstances to short-circuit the chain.Additionally, this proposal includes a polymorphic return:
done(validValue)
validValue
as final value (skipping any remaining loaders)false
Examples
Resulting in
https-loader
being invoked first,mock-loader
second, etc, and node's internaldefaultLoader
last.For illustrative purposes, I've separated
resolve
andload
hooks into different code blocks, but they would actually appear in the same module IRL.Resolve hook chain
HTTPS Loader
Mock Loader
Load hook chain
HTTPS Loader
Mock Loader
CoffeeScript Loader
Updates to
ESMLoader.load()
Concerns Raised
Next()
next
function does not behave as many current, well-known implementations behave (ex javascript's native generator'snext
is the inverse order to this's, and not calling ExpressJS's route-handler'snext
does not break the chain).next
is effectively required (not callingnext
will likely lead to adverse/undesirable behaviour, and in many cases, break in very confusing ways).Done()
This could potentially cause issue for APMs (does theAfter chatting with @bengl, it seems like this is not an issue as V8 exposes what they need.next
approach also?)A hook that unintentionally does not return / returns nullish might be difficult to track downI believe this was resolved in the previous issue discussion?The text was updated successfully, but these errors were encountered: