Skip to content

Commit

Permalink
feat(ses): Support importHook alias returns (#432)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
kriskowal committed Aug 26, 2020
1 parent 765172a commit 1c8e706
Show file tree
Hide file tree
Showing 4 changed files with 206 additions and 27 deletions.
2 changes: 2 additions & 0 deletions packages/ses/NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
76 changes: 76 additions & 0 deletions packages/ses/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
93 changes: 66 additions & 27 deletions packages/ses/src/module-load.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,52 @@ 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,
compartment,
moduleSpecifier,
) => {
const {
resolveHook,
importHook,
moduleMap,
moduleMapHook,
Expand Down Expand Up @@ -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.
Expand All @@ -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,
);
const moduleRecord = freeze({
compartment,
staticModuleRecord,
moduleSpecifier,
resolvedImports,
});
if (staticModuleRecord.record !== undefined) {
const {
compartment: aliasCompartment = compartment,
specifier: aliasSpecifier = moduleSpecifier,
record: aliasModuleRecord,
} = staticModuleRecord;

// Memoize.
moduleRecords.set(moduleSpecifier, moduleRecord);
const aliasRecord = await loadRecord(
compartmentPrivateFields,
moduleAliases,
aliasCompartment,
aliasSpecifier,
aliasModuleRecord,
);
// Memoize by aliased specifier.
moduleRecords.set(moduleSpecifier, aliasRecord);
return aliasRecord;
}

// 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 loadRecord(
compartmentPrivateFields,
moduleAliases,
compartment,
moduleSpecifier,
staticModuleRecord,
);

return moduleRecord;
};

// `load` asynchronously loads `StaticModuleRecords` and creates a complete
Expand Down
62 changes: 62 additions & 0 deletions packages/ses/test/import.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
// );
});

0 comments on commit 1c8e706

Please sign in to comment.