WICG/import-maps

How could import.meta.resolve() behave?

domenic opened this issue ยท 9 comments

Related: #75, #18, #76, whatwg/html#3871.

Background: What is import.meta.resolve()?

The original idea of import.meta.resolveURL(x) was that it would be sugar for (new URL(x, import.meta.url)).href. So, you could do import.meta.resolveURL("./x") inside https://example.com/path/to/y and you would get "https://example.com/path/to/x".

But note that in this formulation, you could also do import.meta.resolveURL("x") and get the same result. That's a little strange, since import "x" would not give you that URL.

It would also be ideal if we could do, like Node.js's require.resolve(), a function that gave you a URL from a "package name", of the type provided by import maps. Let's call this import.meta.resolveMapped(). So for example, given the simple example map from the readme at https://example.com/index.html, import.meta.resolveMapped("lodash") would give "https://example.com/node_modules/lodash-es/lodash.js".

We then noticed that having two functions is confusing and redundant. Let's merge them! The result is import.meta.resolve(x), which gets the URL that would be fetched by the module system if you did import(x).

Side note: what are the use cases?

We don't have a great list of canonical use cases for this function. whatwg/html#3871 has some, and elsewhere in this issue tracker people like @bicknellr and @justinfagnani have mentioned web components-related use cases.

Maybe gardening up a master list, with code examples, would be a good next step.

The problem: fallbacks

This proposal's introduction of fallbacks for user-supplied packages throws a wrinkle into our plans. In particular, there's no longer a synchronous way to figure out what to do in the "package" case. E.g. in that example, it's no longer clear what import.meta.resolveMapped("jquery"), or import.meta.resolve("jquery"), should return.

Solution ideas

Return an array

That is, import.meta.resolve("jquery") returns an array containing the normalized absolute URLs that would be potentially used, e.g. ["https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js", "https://example.com/node_modules/jquery/dist/jquery.js"].

This seems hard for consumers to code against, and makes it hard to refactor between fallbacks and no-fallbacks.

Return a time-varying value

For example, it could return null, or an array like the above, while resolution is pending. Once resolution happens (via some actual fetch), it could return the resolved-to URL.

This similarly seems hard for consumers to code against.

Return a promise

The drawback here is developer ergonomics. E.g. if you are trying to use this inside a component, in order to programmatically set up linkEl.href = import.meta.resolve("jquery"), you no longer can; you need to make that async.

This could become less-terrible with top-level await, plus of course the usual mitigations like web packaging/preload/etc. to make sure that top-level await is not overly blocking.

Lazy

In this variant, calling import.meta.resolve() does not do any fetches itself. Instead it waits for some actual fetch to happen (e.g. via import() or the use of an import: URL). The promise may then end up pending for a long time.

This feels like a bit of a footgun.

Eager

In this variant, calling import.meta.resolve() causes a fetch to happen, which updates the appropriate resolution cache (see #76 for some discussions on what that means).

This feels kind of inappropriate for a resolution function to perform I/O.

Punt on this

For example, return null for all fallback cases (except maybe we could do built-in module cases?).

This still makes refactoring hard; you could break parts of your app by moving from no-fallback to fallback.

Return a request, or a promise for a response

In this variant we get even further from what is typically thought of as a "resolution" function. Instead we return something which could be consumed by various endpoints that currently take a URL. This is a long-standing idea, see whatwg/fetch#49 and whatwg/html#3972. But it might solve all the same use cases.

So far I like this the most. It needs a bit more exploration, but it seems like it could work.

Would another alternative be "return a resolution token"? E.g. something that can be passed into import or fetch or other APIs? Strawman:

<script type="module">
// Inside of https://example.com/foo.html
const resolution = import.meta.resolve('jquery');
linkEl.href = resolution; // "import:https://example.com/foo.html:jquery"
</script>

EDIT: The browser would be free to replace/resolve these so that copy link gets the resolved value etc..

I think that's strictly inferior to the last option, as it means creating a new opaque useless type that just needs to get translated into the useful type (Request), adding a bunch of indirection throughout the stack.

In particular, I don't think there'd be much support for implementing that in browsers, whereas there's definite existing support for the Request/Promise<Response> variant.

As mentioned in #84 (comment) I'm still not sure we should support network round-trip fallbacks due to their performance concerns.

If we were to restrict fallbacks to only working for checks that can be done synchronously such as std: and other synchronous protocol-based checks, then perhaps the concern here around import.meta.resolve would no longer apply?

I think that @jkrems approach would be more useful is if instead of an opaque type there was a new type like URLBundle that contains a sequence of urls in preferential order to try. However this would mean existing APIs would need to now accept this type e.g. new Worker, location.href, script.src, etc etc would all need to learn how to retry a failed load (and how for synchronous APIs like location.href? Simply don't accept this new type maybe?).

I could definitely work with a version of import.meta.resolve that returned a Promise. That seems future-proof, and easy to explain, even without top-level await to help with the ergonomics.

I share @guybedford's concerns about fallback performance/predictability and would probably avoid using fallbacks personally, but I appreciate the importance of making the API consistently async. In the future, we might come up with additional reasons (even more compelling than fallback support) why module resolution needs to be async, and I think we'd be glad we chose a generic solution to the problem (such as returning a Promise) rather than something specific to fallbacks. Returning an array of possible URLs feels somewhat fallback-specific, for example.

On a different note, would it be possible to do import.meta.resolve(identifier, parentURL) to use a parent URL that's different from import.meta.url?

@benjamn the latest version of Node.js 13 being released soon will be shipping with a flagged implementation of --experimental-import-meta-resolve, exactly supporting the async API with a parentURL argument permitted as well. See nodejs/node#31032 for more info here.

@guybedford Thank you so much for working on that!

I think the primary reason to use import.meta.resolve is to resolve an asset such as an image or CSS file.

The only overlap I could imagine actually happening with fallbacks and resolve is if either a built in module provides images or fonts or if increased resilience is needed. If assets served using Fastly had a fallback, a lot of headache could have been prevented during the recent incident.

A more appropriate solution than to have resolve() be async would be to introduce a fallback URI scheme.

The URL could be something like https://example.com/widget.js:https://fallback.com/widget.js.

If this is considered a viable future path I propose having import.meta.resolve() throw if there are fallbacks for a module, until fallback URLs are taken further.

As you mentioned, http requests as a side effect of calling a resolver function is pretty iffy.

Having the resolve() be async will also make it harder to use in certain cases. For instance, React components must build the layout synchronously, so async resolve() will require some extra scaffolding.

I believe this presents a pretty solid case for synchronous import.meta.resolve(). What do you think?

import.meta.resolveSync could be more future resilient for a synchronous function though.