feat(ses): Support importHook alias returns
This change enables importHook to return a record for a module that has a different specifier than the requested module specifier.  This enables the Node.js pattern of redirecting `name` to `name/index.js` and respecting the latter name as the referrer for its own imports.
MODIFY
packages/ses/NEWS.md
packages/ses/NEWS.md
@@ -2,6 +2,8 @@ User-visible changes in SES:
## Next release
+* Allows a compartment's `importHook` to return an "alias" if the returned
+ static module record has a different specifier than requested.
* Adds the `name` option to the `Compartment` constructor and `name` accessor
to the `Compartment` prototype.
Errors that propagate through the module loader will be rethrown anew with

MODIFY
packages/ses/README.md
packages/ses/README.md
@@ -178,6 +178,82 @@ const c2 = new Compartment({}, {
});
```
+### importHook aliases
+
+If a compartment imports a module specified as `"./utility"` but actually
+implemented by an alias like `"./utility/index.js"`, the `importHook` may
+follow redirects, symbolic links, or search for candidates using its own logic
+and return a module that has a different "response specifier" than the original
+"request specifier".
+The `importHook` may return an "alias" objeect with `record`, `compartment`,
+and `module` properties.
+
+- `record` must be a `StaticModuleRecord`,
+- `compartment` is optional, to be specified if the alias transits to a
+ different compartment, and
+- `module` is the full module specifier of the module in its compartment.
+ This defaults to the request specifier, which is only useful if the
+ compartment is different.
+
+In the following example, the importHook searches for a file and returns an
+alias.
+
+```js
+const importHook = async specifier => {
+ const candidates = [specifier, `${specifier}.js`, `${specifier}/index.js`];
+ for (const candidate of candidates) {
+ const record = await wrappedImportHook(candidate).catch(_ => undefined);
+ if (record !== undefined) {
+ return { alias: record, specifier };
+ }
+ }
+ throw new Error(`Cannot find module ${specifier}`);
+};
+
+const c = new Compartment({}, {}, {
+ resolveHook,
+ importHook,
+});
+```
+
+### moduleMapHook
+
+The module map above allows modules to be introduced to a compartment up-front.
+Some modules cannot be known that early.
+For example, in Node.js, a package might have a dependency that brings in an
+entire subtree of modules.
+Also, a pair of compartments with cyclic dependencies between modules they each
+contain cannot use `compartment.module` to link the second compartment
+constructed to the first.
+For these cases, the `Compartment` constructor accepts a `moduleMapHook` option
+that is like the dynamic version of the static `moduleMap` argument.
+This is a function that accepts a module specifier and returns the module
+namespace for that module specifier, or `undefined`.
+If the `moduleMapHook` returns `undefined`, the compartment proceeds to the
+`importHook` to attempt to asynchronously obtain the module's source.
+
+```js
+const moduleMapHook = moduleSpecifier => {
+ if (moduleSpecifier === 'even') {
+ return even.module('./index.js');
+ } else if (moduleSpecifier === 'odd') {
+ return odd.module('./index.js');
+ }
+};
+
+const even = new Compartment({}, {}, {
+ resolveHook: nodeResolveHook,
+ importHook: makeImportHook('https://example.com/even'),
+ moduleMapHook,
+});
+
+const odd = new Compartment({}, {}, {
+ resolveHook: nodeResolveHook,
+ importHook: makeImportHook('https://example.com/odd'),
+ moduleMapHook,
+});
+```
+
### Third-party modules
To incorporate modules not implemented as ECMAScript modules, third-parties may

MODIFY
packages/ses/src/module-load.js
packages/ses/src/module-load.js
@@ -34,6 +34,45 @@ const resolveAll = (imports, resolveHook, fullReferrerSpecifier) => {
return freeze(resolvedImports);
};
+const loadRecord = async (
+ compartmentPrivateFields,
+ moduleAliases,
+ compartment,
+ moduleSpecifier,
+ staticModuleRecord,
+) => {
+ const { resolveHook, moduleRecords } = compartmentPrivateFields.get(
+ compartment,
+ );
+
+ // resolve all imports relative to this referrer module.
+ const resolvedImports = resolveAll(
+ staticModuleRecord.imports,
+ resolveHook,
+ moduleSpecifier,
+ );
+ const moduleRecord = freeze({
+ compartment,
+ staticModuleRecord,
+ moduleSpecifier,
+ resolvedImports,
+ });
+
+ // Memoize.
+ moduleRecords.set(moduleSpecifier, moduleRecord);
+
+ // Await all dependencies to load, recursively.
+ await Promise.all(
+ values(resolvedImports).map(fullSpecifier =>
+ // Behold: recursion.
+ // eslint-disable-next-line no-use-before-define
+ load(compartmentPrivateFields, moduleAliases, compartment, fullSpecifier),
+ ),
+ );
+
+ return moduleRecord;
+};
+
const loadWithoutErrorAnnotation = async (
compartmentPrivateFields,
moduleAliases,
@@ -41,7 +80,6 @@ const loadWithoutErrorAnnotation = async (
moduleSpecifier,
) => {
const {
- resolveHook,
importHook,
moduleMap,
moduleMapHook,
@@ -70,14 +108,15 @@ const loadWithoutErrorAnnotation = async (
}
// Behold: recursion.
// eslint-disable-next-line no-use-before-define
- const moduleRecord = await load(
+ const aliasRecord = await load(
compartmentPrivateFields,
moduleAliases,
alias.compartment,
alias.specifier,
);
- moduleRecords.set(moduleSpecifier, moduleRecord);
- return moduleRecord;
+ // Memoize.
+ moduleRecords.set(moduleSpecifier, aliasRecord);
+ return aliasRecord;
}
// Memoize.
@@ -87,32 +126,32 @@ const loadWithoutErrorAnnotation = async (
const staticModuleRecord = await importHook(moduleSpecifier);
- // resolve all imports relative to this referrer module.
- const resolvedImports = resolveAll(
- staticModuleRecord.imports,
- resolveHook,
- moduleSpecifier,
+ if (staticModuleRecord.record !== undefined) {
+ const {
+ compartment: aliasCompartment = compartment,
+ specifier: aliasSpecifier = moduleSpecifier,
+ record: aliasModuleRecord,
+ } = staticModuleRecord;
+
+ const aliasRecord = await loadRecord(
+ compartmentPrivateFields,
+ moduleAliases,
+ aliasCompartment,
+ aliasSpecifier,
+ aliasModuleRecord,
);
- const moduleRecord = freeze({
+ // Memoize by aliased specifier.
+ moduleRecords.set(moduleSpecifier, aliasRecord);
+ return aliasRecord;
+ }
+
+ return loadRecord(
+ compartmentPrivateFields,
+ moduleAliases,
compartment,
- staticModuleRecord,
moduleSpecifier,
- resolvedImports,
- });
-
- // Memoize.
- moduleRecords.set(moduleSpecifier, moduleRecord);
-
- // Await all dependencies to load, recursively.
- await Promise.all(
- values(resolvedImports).map(fullSpecifier =>
- // Behold: recursion.
- // eslint-disable-next-line no-use-before-define
- load(compartmentPrivateFields, moduleAliases, compartment, fullSpecifier),
- ),
+ staticModuleRecord,
);
-
- return moduleRecord;
};
// `load` asynchronously loads `StaticModuleRecords` and creates a complete

MODIFY
packages/ses/test/import.test.js
packages/ses/test/import.test.js
@@ -413,3 +413,65 @@ test('mutual dependency between compartments', async t => {
await compartment.import('./main.js');
});
+
+test('module alias', async t => {
+ // The following use of Math.random() is informative but does not
+ // affect the outcome of the test, just makes the nature of the error
+ // obvious in test output.
+ // The containing objects should be identical.
+ // The contained value should incidentally be identical.
+ // The test depends on the former.
+
+ const makeImportHook = makeNodeImporter({
+ 'https://example.com/main/index.js': `
+ export const unique = {n: Math.random()};
+ export const meaning = 42;
+ `,
+ });
+
+ const wrappedImportHook = makeImportHook('https://example.com');
+
+ const importHook = async specifier => {
+ const candidates = [specifier, `${specifier}.js`, `${specifier}/index.js`];
+ for (const candidate of candidates) {
+ // eslint-disable-next-line no-await-in-loop
+ const record = await wrappedImportHook(candidate).catch(_ => undefined);
+ if (record !== undefined) {
+ return { record, specifier };
+ }
+ }
+ throw new Error(`Cannot find module ${specifier}`);
+ };
+
+ const compartment = new Compartment(
+ {
+ Math,
+ },
+ {},
+ {
+ resolveHook: resolveNode,
+ importHook,
+ },
+ );
+
+ const { namespace } = await compartment.import('./main');
+ t.equal(
+ namespace.meaning,
+ 42,
+ 'dynamically imports the meaning through a redirect',
+ );
+
+ // TODO The following commented test does not pass, and might not be valid.
+ // Web browsers appear to have taken the stance that they will load a static
+ // module record once per *response url* and create unique a unique module
+ // instance per *request url*.
+ //
+ // const { namespace: aliasNamespace } = await compartment.import(
+ // './main/index.js',
+ // );
+ // t.strictEqual(
+ // namespace.unique,
+ // aliasNamespace.unique,
+ // 'alias modules have identical instance',
+ // );
+});

feat(ses): Support importHook alias returns (#432)

This change enables importHook to return a record for a module that has a different specifier than the requested module specifier.  This enables the Node.js pattern of redirecting `name` to `name/index.js` and respecting the latter name as the referrer for its own imports.