Disclaimer: This text is exclusively focused on the existing capabilities of the language, and does not weight into additional capabilities like wasm, or virtual modules, or compartments. Those can be discussed later on.
The bare minimum mechanism to create a module graph that matches ESM requires few main pieces:
- A portable (serializable) structure that represents the source text of the module.
ModuleSource
must be portable across processes and across realms. - A realm based
Module
that closes over aModuleSource
. - A kicker that can trigger the existing module linkage mechanism to populate the module graph of the corresponding realm. This kicker is the dynamic
import()
syntax.
With these 3 pieces in place, a module graph can be constructed in user-land.
- ecma262 to introduce two new intrinsics:
ModuleSource
andModule
. - ecma262 to extends the semantics of
import()
statements so that the first argument can be aModule
that can be used to create the corresponding Source Text Module Record. - Minor modifications on ecma262's module mechanics so a Source Text Module Record derived from a
Module
can delegate the resolution of its dependencies and meta object to user-land code associated to theModule
itself. - No modifications on the behavior of the host.
interface ModuleSource {
constructor(source: string);
}
Semantics: A ModuleSource
give you no powers. It is a mere representation of a source text with no meta information attached to it.
Note 1: This represents a solution to the eval
and CSP, where you have a betted source text available for evaluation without violating the unsafe-eval CSP rules.
Note 2: A ModuleSource
can be reused to create multiple Modules
associated to it.
Note 3: A ModuleSource
could be propagated to other realms and processes via structuredClone or callable boundary wrapping mechanism.
type ImportHook = (specifier: ImportSpecifier, meta: object) => Promise<Module>;
// Module reifies an entangled pair of module environment record
// and module exports namespace from a particular array of bindings
// that correspond to the `import` and `export` declarations of a
// module.
interface Module {
// Creates a module instance for a module source.
// The Module to be bound to the realm associated to the Module constructor.
// The ModuleEnvironmentRecord to be bound to the `Module` constructor.
constructor(
source: ModuleSource,
importHook: ImportHook,
importMeta: Object,
);
readonly source: ModuleSource,
#namespace: ModuleExportsNamespace;
#environment: ModuleEnvironmentRecord;
}
Semantics: A Module
has a 1-1-1-1 relationship with a Environment Record, a Module Record and a Module Namespace Exotic Object (aka namespace).
Any dynamic import function is suitable for initializing, link and evaluate a module instance and all of its transitive dependencies.
const source = new ModuleSource(``);
const instance = new Module(source, importHook, import.meta);
const namespace = await import(instance);
Since the Module has a bound module namespace exotic object, importing the same instance should yield the same result:
const source = new ModuleSource(``);
const instance = new Module(source, importHook, import.meta);
const namespace1 = await import(instance);
const namespace2 = await import(instance);
namespace1 === namespace2; // true
Any dynamic import function is suitable for initializing a module instance and any of its transitive dependencies that have not yet been initialized.
const source = new ModuleSource(``);
const instance1 = new Module(source, importHook1, import.meta);
const instance2 = new Module(source, importHook2, import.meta);
instance1 === instance2; // false
const namespace1 = await import(instance1);
const namespace2 = await import(instance2);
namespace1 === namespace2; // false
Proposal: https://github.com/tc39/proposal-js-module-blocks
In relation to module blocks, we can extend the proposal to accommodate both, the concept of a module block instance and module block source:
const instance = module {};
instance instanceof Module;
instance.source instanceof ModuleSource;
const namespace = await import(instance);
To avoid needing a throw-away module-instance in order to get a module source, we can extend the syntax:
const source = static module {};
source instanceof ModuleSource;
const instance = new Module(source, importHook, import.meta);
const namespace = await import(instance);
The possibility to load the source, and create the instance with the default importHook
and the import.meta
of the importer, that can be imported at any given time, is sufficient:
import instance from 'module.js' deferred execution syntax;
instance instanceof Module;
instance.source instanceof ModuleSource;
const namespace = await import(instance);
If the goal is to also control the importHook
and the importMeta
of the importer, then a new syntax can be provided to only get the ModuleSource
:
import source from 'module.js' static source syntax;
source instanceof ModuleSource;
const instance = new Module(source, importHook, import.meta);
const namespace = await import(instance);
This is important, because it is analogous to block modules, but instead of inline source, it is a source that must be fetched.
Proposal: whatwg/html#5572
const importHook = (specifier, meta) => {
const url = meta.resolve(specifier);
const response = await fetch(url);
const sourceText = await.response.text();
return new Module(sourceText, importHook, createCustomImportMeta(url));
}
const source = new ModuleSource(`export foo from './foo.js'`);
const instance = new Module(source, importHook, import.meta);
const namespace = await import(instance);
In the example above, we re-use the ImportHook
declaration for two instances, the source
, and the corresponding dependency for specifier ./foo.js
. When the kicker import(instance)
is executed, the importHook
will be invoked once with the specifier
argument as ./foo.js
, and the meta
argument with the value of the import.meta
associated to the kicker itself. As a result, the specifier
can be resolved based on the provided meta
to calculate the url
, fetch the source, and create a new Module
for the new source. This new instance opts to reuse the same importHook
function while constructing the meta
object. It is important to notice that the meta
object has to purposes, to be referenced by syntax in the source text (via import.meta
) and to be passed to the importHook
for any dependencies of ./foo.js
itself.
When a source module imports from a module specifier, you might not have the source at hand to create the corresponding Module
to be returned. If importHook
is synchronous, then you must have the source ready when the importHook
is invoked for each dependency.
Since the importHook
is only triggered via the kicker (import(instance)
), going async there has no implications whatsoever. In prior iterations of this, the user was responsible for loop thru the dependencies, and prepare the instance before kicking the next phase, that's not longer the case here, where the level of control on the different phases is limited to the invocation of the importHook
.
Yes, importHook
can return a Module
that was either import()
already or was returned by an importHook
already.
Any import()
statement inside a module source will result of a possible importHook
invocation on the Module
, and the decision on whether or not to call the importHook
depends on whether or not the Module
has already invoked it for the specifier
in question. Basically, this means a Module
most keep a map for every specifier
and its corresponding Module
to guarantee the idempotency of those static and dynamic import statements.
It certainly can by a) extending the signature of the ImportHook
API to allow returning a Module
instance of a Module Namespace Exotic Object as described below:
type ImportHook = (specifier: ImportSpecifier, meta: object) => Promise<Module | Namespace>;
This will basically allow developers to load a source, but still delegate to the UA's resolution for its dependencies by using the dynamic import form, e.g.:
const importHook = (specifier, meta) => {
const url = meta.resolve(specifier);
return import(url); // or Module.get(await import(url));
}
const source = new ModuleSource(`export foo from './foo.js'`);
const instance = new Module(source, importHook, import.meta);
const namespace = await import(instance);
Or b) implements a reflection mechanism, e.g.: Module.get(ns)
that can reify a module instance from a Module Namespace Exotic Object, which is analogous but less ergonomic.
This solves the issue of bundlers trying to create a bundle that contains or defines external dependencies to be loaded by the UA rather than pack them all together in one single bundle.
Note: when delegating to the UA, you not longer have the ability to intercept resolution of dependencies, meaning cycles can't be created where some instances are handled by the UA, and some other are handled in user-land.