diff --git a/doc/api/errors.md b/doc/api/errors.md
index 6d8eed949203e3..6033e98d1d3520 100644
--- a/doc/api/errors.md
+++ b/doc/api/errors.md
@@ -1178,6 +1178,14 @@ for strict compliance with the API specification (which in some cases may accept
`func(undefined)` and `func()` are treated identically, and the
[`ERR_INVALID_ARG_TYPE`][] error code may be used instead.
+
+### ERR_MISSING_DYNAMIC_INSTANTIATE_HOOK
+
+> Stability: 1 - Experimental
+
+Used when an [ES6 module][] loader hook specifies `format: 'dynamic` but does
+not provide a `dynamicInstantiate` hook.
+
### ERR_MISSING_MODULE
diff --git a/lib/internal/errors.js b/lib/internal/errors.js
index 6f056b1ef8bec1..db09a7c910ee62 100644
--- a/lib/internal/errors.js
+++ b/lib/internal/errors.js
@@ -298,6 +298,9 @@ E('ERR_IPC_ONE_PIPE', 'Child process can have only one IPC pipe');
E('ERR_IPC_SYNC_FORK', 'IPC cannot be used with synchronous forks');
E('ERR_METHOD_NOT_IMPLEMENTED', 'The %s method is not implemented');
E('ERR_MISSING_ARGS', missingArgs);
+E('ERR_MISSING_DYNAMIC_INSTANTIATE_HOOK',
+ 'The ES Module loader may not return a format of \'dynamic\' when no ' +
+ 'dynamicInstantiate function was provided');
E('ERR_MISSING_MODULE', 'Cannot find module %s');
E('ERR_MODULE_RESOLUTION_LEGACY', '%s not found by import in %s.' +
' Legacy behavior in require() would have found it at %s');
diff --git a/lib/internal/loader/Loader.js b/lib/internal/loader/Loader.js
index f2c7fa0cfffc47..49c8699771e819 100644
--- a/lib/internal/loader/Loader.js
+++ b/lib/internal/loader/Loader.js
@@ -10,7 +10,8 @@ const ModuleRequest = require('internal/loader/ModuleRequest');
const errors = require('internal/errors');
const debug = require('util').debuglog('esm');
-function getBase() {
+// Returns a file URL for the current working directory.
+function getURLStringForCwd() {
try {
return getURLFromFilePath(`${process.cwd()}/`).href;
} catch (e) {
@@ -23,22 +24,44 @@ function getBase() {
}
}
+/* A Loader instance is used as the main entry point for loading ES modules.
+ * Currently, this is a singleton -- there is only one used for loading
+ * the main module and everything in its dependency graph. */
class Loader {
- constructor(base = getBase()) {
- this.moduleMap = new ModuleMap();
+ constructor(base = getURLStringForCwd()) {
if (typeof base !== 'string') {
throw new errors.TypeError('ERR_INVALID_ARG_TYPE', 'base', 'string');
}
+
+ this.moduleMap = new ModuleMap();
this.base = base;
- this.resolver = ModuleRequest.resolve.bind(null);
+ // The resolver has the signature
+ // (specifier : string, parentURL : string, defaultResolve)
+ // -> Promise<{ url : string,
+ // format: anything in Loader.validFormats }>
+ // where defaultResolve is ModuleRequest.resolve (having the same
+ // signature itself).
+ // If `.format` on the returned value is 'dynamic', .dynamicInstantiate
+ // will be used as described below.
+ this.resolver = ModuleRequest.resolve;
+ // This hook is only called when resolve(...).format is 'dynamic' and has
+ // the signature
+ // (url : string) -> Promise<{ exports: { ... }, execute: function }>
+ // Where `exports` is an object whose property names define the exported
+ // names of the generated module. `execute` is a function that receives
+ // an object with the same keys as `exports`, whose values are get/set
+ // functions for the actual exported values.
this.dynamicInstantiate = undefined;
}
hook({ resolve = ModuleRequest.resolve, dynamicInstantiate }) {
+ // Use .bind() to avoid giving access to the Loader instance when it is
+ // called as this.resolver(...);
this.resolver = resolve.bind(null);
this.dynamicInstantiate = dynamicInstantiate;
}
+ // Typechecking wrapper around .resolver().
async resolve(specifier, parentURL = this.base) {
if (typeof parentURL !== 'string') {
throw new errors.TypeError('ERR_INVALID_ARG_TYPE',
@@ -48,10 +71,11 @@ class Loader {
const { url, format } = await this.resolver(specifier, parentURL,
ModuleRequest.resolve);
- if (typeof format !== 'string') {
+ if (!Loader.validFormats.includes(format)) {
throw new errors.TypeError('ERR_INVALID_ARG_TYPE', 'format',
- ['esm', 'cjs', 'builtin', 'addon', 'json']);
+ Loader.validFormats);
}
+
if (typeof url !== 'string') {
throw new errors.TypeError('ERR_INVALID_ARG_TYPE', 'url', 'string');
}
@@ -72,14 +96,20 @@ class Loader {
return { url, format };
}
+ // May create a new ModuleJob instance if one did not already exist.
async getModuleJob(specifier, parentURL = this.base) {
const { url, format } = await this.resolve(specifier, parentURL);
let job = this.moduleMap.get(url);
if (job === undefined) {
let loaderInstance;
if (format === 'dynamic') {
+ const { dynamicInstantiate } = this;
+ if (typeof dynamicInstantiate !== 'function') {
+ throw new errors.Error('ERR_MISSING_DYNAMIC_INSTANTIATE_HOOK');
+ }
+
loaderInstance = async (url) => {
- const { exports, execute } = await this.dynamicInstantiate(url);
+ const { exports, execute } = await dynamicInstantiate(url);
return createDynamicModule(exports, url, (reflect) => {
debug(`Loading custom loader ${url}`);
execute(reflect.exports);
@@ -100,5 +130,6 @@ class Loader {
return module.namespace();
}
}
+Loader.validFormats = ['esm', 'cjs', 'builtin', 'addon', 'json', 'dynamic'];
Object.setPrototypeOf(Loader.prototype, null);
module.exports = Loader;
diff --git a/lib/internal/loader/ModuleJob.js b/lib/internal/loader/ModuleJob.js
index 04d6111b87f1f6..a17c501609a426 100644
--- a/lib/internal/loader/ModuleJob.js
+++ b/lib/internal/loader/ModuleJob.js
@@ -1,27 +1,35 @@
'use strict';
+const { ModuleWrap } = internalBinding('module_wrap');
const { SafeSet, SafePromise } = require('internal/safe_globals');
+const assert = require('assert');
const resolvedPromise = SafePromise.resolve();
+const enableDebug = (process.env.NODE_DEBUG || '').match(/\besm\b/) ||
+ process.features.debug;
+
+/* A ModuleJob tracks the loading of a single Module, and the ModuleJobs of
+ * its dependencies, over time. */
class ModuleJob {
- /**
- * @param {module: ModuleWrap?, compiled: Promise} moduleProvider
- */
+ // `loader` is the Loader instance used for loading dependencies.
+ // `moduleProvider` is a function
constructor(loader, url, moduleProvider) {
this.loader = loader;
this.error = null;
this.hadError = false;
- // linked == promise for dependency jobs, with module populated,
- // module wrapper linked
- this.moduleProvider = moduleProvider;
- this.modulePromise = this.moduleProvider(url);
+ // This is a Promise<{ module, reflect }>, whose fields will be copied
+ // onto `this` by `link()` below once it has been resolved.
+ this.modulePromise = moduleProvider(url);
this.module = undefined;
this.reflect = undefined;
- const linked = async () => {
+
+ // Wait for the ModuleWrap instance being linked with all dependencies.
+ const link = async () => {
const dependencyJobs = [];
({ module: this.module,
reflect: this.reflect } = await this.modulePromise);
+ assert(this.module instanceof ModuleWrap);
this.module.link(async (dependencySpecifier) => {
const dependencyJobPromise =
this.loader.getModuleJob(dependencySpecifier, url);
@@ -29,63 +37,57 @@ class ModuleJob {
const dependencyJob = await dependencyJobPromise;
return (await dependencyJob.modulePromise).module;
});
+ if (enableDebug) {
+ // Make sure all dependencies are entered into the list synchronously.
+ Object.freeze(dependencyJobs);
+ }
return SafePromise.all(dependencyJobs);
};
- this.linked = linked();
+ // Promise for the list of all dependencyJobs.
+ this.linked = link();
// instantiated == deep dependency jobs wrappers instantiated,
// module wrapper instantiated
this.instantiated = undefined;
}
- instantiate() {
+ async instantiate() {
if (this.instantiated) {
return this.instantiated;
}
- return this.instantiated = new Promise(async (resolve, reject) => {
- const jobsInGraph = new SafeSet();
- let jobsReadyToInstantiate = 0;
- // (this must be sync for counter to work)
- const queueJob = (moduleJob) => {
- if (jobsInGraph.has(moduleJob)) {
- return;
- }
- jobsInGraph.add(moduleJob);
- moduleJob.linked.then((dependencyJobs) => {
- for (const dependencyJob of dependencyJobs) {
- queueJob(dependencyJob);
- }
- checkComplete();
- }, (e) => {
- if (!this.hadError) {
- this.error = e;
- this.hadError = true;
- }
- checkComplete();
- });
- };
- const checkComplete = () => {
- if (++jobsReadyToInstantiate === jobsInGraph.size) {
- // I believe we only throw once the whole tree is finished loading?
- // or should the error bail early, leaving entire tree to still load?
- if (this.hadError) {
- reject(this.error);
- } else {
- try {
- this.module.instantiate();
- for (const dependencyJob of jobsInGraph) {
- dependencyJob.instantiated = resolvedPromise;
- }
- resolve(this.module);
- } catch (e) {
- e.stack;
- reject(e);
- }
- }
- }
- };
- queueJob(this);
- });
+ return this.instantiated = this._instantiate();
+ }
+
+ // This method instantiates the module associated with this job and its
+ // entire dependency graph, i.e. creates all the module namespaces and the
+ // exported/imported variables.
+ async _instantiate() {
+ const jobsInGraph = new SafeSet();
+
+ const addJobsToDependencyGraph = async (moduleJob) => {
+ if (jobsInGraph.has(moduleJob)) {
+ return;
+ }
+ jobsInGraph.add(moduleJob);
+ const dependencyJobs = await moduleJob.linked;
+ return Promise.all(dependencyJobs.map(addJobsToDependencyGraph));
+ };
+ try {
+ await addJobsToDependencyGraph(this);
+ } catch (e) {
+ if (!this.hadError) {
+ this.error = e;
+ this.hadError = true;
+ }
+ throw e;
+ }
+ this.module.instantiate();
+ for (const dependencyJob of jobsInGraph) {
+ // Calling `this.module.instantiate()` instantiates not only the
+ // ModuleWrap in this module, but all modules in the graph.
+ dependencyJob.instantiated = resolvedPromise;
+ }
+ return this.module;
}
async run() {
diff --git a/lib/internal/loader/ModuleWrap.js b/lib/internal/loader/ModuleWrap.js
index bd1e1f2753401d..c22e149925caed 100644
--- a/lib/internal/loader/ModuleWrap.js
+++ b/lib/internal/loader/ModuleWrap.js
@@ -10,39 +10,49 @@ const createDynamicModule = (exports, url = '', evaluate) => {
`creating ESM facade for ${url} with exports: ${ArrayJoin(exports, ', ')}`
);
const names = ArrayMap(exports, (name) => `${name}`);
- // sanitized ESM for reflection purposes
- const src = `export let executor;
- ${ArrayJoin(ArrayMap(names, (name) => `export let $${name}`), ';\n')}
- ;(() => [
- fn => executor = fn,
- { exports: { ${
- ArrayJoin(ArrayMap(names, (name) => `${name}: {
- get: () => $${name},
- set: v => $${name} = v
- }`), ',\n')
-} } }
- ]);
- `;
+ // Create two modules: One whose exports are get- and set-able ('reflective'),
+ // and one which re-exports all of these but additionally may
+ // run an executor function once everything is set up.
+ const src = `
+ export let executor;
+ ${ArrayJoin(ArrayMap(names, (name) => `export let $${name};`), '\n')}
+ /* This function is implicitly returned as the module's completion value */
+ (() => ({
+ setExecutor: fn => executor = fn,
+ reflect: {
+ exports: { ${
+ ArrayJoin(ArrayMap(names, (name) => `
+ ${name}: {
+ get: () => $${name},
+ set: v => $${name} = v
+ }`), ', \n')}
+ }
+ }
+ }));`;
const reflectiveModule = new ModuleWrap(src, `cjs-facade:${url}`);
reflectiveModule.instantiate();
- const [setExecutor, reflect] = reflectiveModule.evaluate()();
+ const { setExecutor, reflect } = reflectiveModule.evaluate()();
// public exposed ESM
- const reexports = `import { executor,
+ const reexports = `
+ import {
+ executor,
${ArrayMap(names, (name) => `$${name}`)}
} from "";
export {
${ArrayJoin(ArrayMap(names, (name) => `$${name} as ${name}`), ', ')}
}
- // add await to this later if top level await comes along
- typeof executor === "function" ? executor() : void 0;`;
+ if (typeof executor === "function") {
+ // add await to this later if top level await comes along
+ executor()
+ }`;
if (typeof evaluate === 'function') {
setExecutor(() => evaluate(reflect));
}
- const runner = new ModuleWrap(reexports, `${url}`);
- runner.link(async () => reflectiveModule);
- runner.instantiate();
+ const module = new ModuleWrap(reexports, `${url}`);
+ module.link(async () => reflectiveModule);
+ module.instantiate();
return {
- module: runner,
+ module,
reflect
};
};