Description
(Credit to @dvoytenko for this proposal.)
Problem statement
It's a common pattern to serve most of your JavaScript from CDNs, but use an inline script for configuration settings. E.g.
<script>
self.configuration = {
deviceType: "tier1"
/* ... */
};
</script>
<script src="https://cdn.example.net/script.js">
where https://cdn.example.net/script.js
could end up using self.configuration
.
This setup is fragile, as it relies on the web page author to ensure the inline script appears before any CDN-included scripts, and and doesn't play well with async=""
which might cause scripts to execute early.
Additionally, some authors would prefer to use modules for this, so that the dependency between script.js
and the global configuration is explicit.
Proposal
We could add an exports
property to HTMLScriptElement
. This was mentioned previously in #2235, but without use cases. Then we could have this setup:
<!-- Order no longer matters; async="" is fine -->
<script type="module" async src="https://cdn.example.net/script.mjs">
<script type="module" async id="configuration">
export const deviceType = "tier1";
// ...
</script>
Where script.mjs
contains
const { deviceType } = await (await waitForElement("#configuration")).exports;
and waitForElement
is a developer-written helper utility that uses a MutationObserver
to wait for the specified element to appear.
Note that exports
itself would be a promise, because in the general case the inline module might itself use top-level await. That's not what's going on in our configuration-based example, so in our case the promise will immediately fulfill, but it seems like the right primitive at the spec level.
There could also be a level of indirection, so that script.mjs
doesn't need its own waitForElement
function. For example if script.mjs
did
import { deviceType } from "./configuration.mjs";
where https://cdn.example.net/constants.mjs
contains some default configuration values, then the page itself could use an import map to remap https://cdn.example.com/configuration.mjs
to its own script that looks like the following:
const { deviceType } = await (await waitForElement("#configuration")).exports;
export deviceType;
Alternative considered
Another way of accomplishing this, which is less powerful but potentially easier to use, would be to introduce the ability to directly import an inline script. Something like the following:
import { deviceType } from "document:configuration";
The problem with this idea is that the semantics of resolving document:configuration
specifiers is tricky:
-
The most natural thing would be that resolving does a synchronous lookup in the current state of the document, and fails if there is no element with the specified ID. However, then you basically go back to the current state of things, just with modules: you'll still have problems if your inline module isn't before any CDN-provided modules, or if you use
async=""
. You could combine it withwaitForElement("#configuration")
+ dynamicimport()
like so:await waitForElement("#configuration"); const { deviceType } = await import("document:configuration");
but this is not much of a win.
-
Alternately, we could try to specify a semantic where if the element with such an ID doesn't exist yet, we wait until it does before finishing module resolution. This would be nice to use, but it breaks some existing properties of modules, such as how they execute in order. I.e., we would have to delay the resolution and fetching of
script.mjs
's dependencies until an element with the appropriate ID appears in the tree, and then we would have to go skip the usual ordering to execute that element's inline script (and any dependencies) immediately. Or, we could end up waiting indefinitely, if no such element appears. Also, these strange semantics an be caused deep in the tree, by anyimport
statement. So this seems bad.
Combined with the idea that there might be speculative future HTML modules-related use cases for an exports
property, per #2235, probably this alternative is not a good direction and we should do exports
instead.
Details
We've said exports
should be a promise. What about in the non-module script case? It could be a forever-pending promise, or a promise already resolved with null. Or maybe it could be null, instead of a promise? Web IDL might make the latter impossible currently...
This feature makes inline JSON and CSS modules useful. Should we consider allowing them at the same time?
/cc @whatwg/modules