lib: refactor ES module loader for readability

PR-URL: https://github.com/nodejs/node/pull/16579
Reviewed-By: Michaël Zasso <targos@protonmail.com>
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Colin Ihrig <cjihrig@gmail.com>
This commit is contained in:
Anna Henningsen 2017-10-29 01:36:18 +02:00
parent e22a8f17a5
commit 14181a3368
No known key found for this signature in database
GPG Key ID: 9C63F3A6CD2AD8F9
5 changed files with 136 additions and 82 deletions

View File

@ -1207,6 +1207,14 @@ for strict compliance with the API specification (which in some cases may accept
`func(undefined)` and `func()` are treated identically, and the `func(undefined)` and `func()` are treated identically, and the
[`ERR_INVALID_ARG_TYPE`][] error code may be used instead. [`ERR_INVALID_ARG_TYPE`][] error code may be used instead.
<a id="ERR_MISSING_DYNAMIC_INSTANTIATE_HOOK"></a>
### 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.
<a id="ERR_MISSING_MODULE"></a> <a id="ERR_MISSING_MODULE"></a>
### ERR_MISSING_MODULE ### ERR_MISSING_MODULE

View File

@ -376,6 +376,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_IPC_SYNC_FORK', 'IPC cannot be used with synchronous forks');
E('ERR_METHOD_NOT_IMPLEMENTED', 'The %s method is not implemented'); E('ERR_METHOD_NOT_IMPLEMENTED', 'The %s method is not implemented');
E('ERR_MISSING_ARGS', missingArgs); 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_MISSING_MODULE', 'Cannot find module %s');
E('ERR_MODULE_RESOLUTION_LEGACY', '%s not found by import in %s.' + E('ERR_MODULE_RESOLUTION_LEGACY', '%s not found by import in %s.' +
' Legacy behavior in require() would have found it at %s'); ' Legacy behavior in require() would have found it at %s');

View File

@ -10,7 +10,8 @@ const ModuleRequest = require('internal/loader/ModuleRequest');
const errors = require('internal/errors'); const errors = require('internal/errors');
const debug = require('util').debuglog('esm'); const debug = require('util').debuglog('esm');
function getBase() { // Returns a file URL for the current working directory.
function getURLStringForCwd() {
try { try {
return getURLFromFilePath(`${process.cwd()}/`).href; return getURLFromFilePath(`${process.cwd()}/`).href;
} catch (e) { } 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 { class Loader {
constructor(base = getBase()) { constructor(base = getURLStringForCwd()) {
this.moduleMap = new ModuleMap();
if (typeof base !== 'string') { if (typeof base !== 'string') {
throw new errors.TypeError('ERR_INVALID_ARG_TYPE', 'base', 'string'); throw new errors.TypeError('ERR_INVALID_ARG_TYPE', 'base', 'string');
} }
this.moduleMap = new ModuleMap();
this.base = base; 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; this.dynamicInstantiate = undefined;
} }
hook({ resolve = ModuleRequest.resolve, dynamicInstantiate }) { 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.resolver = resolve.bind(null);
this.dynamicInstantiate = dynamicInstantiate; this.dynamicInstantiate = dynamicInstantiate;
} }
// Typechecking wrapper around .resolver().
async resolve(specifier, parentURL = this.base) { async resolve(specifier, parentURL = this.base) {
if (typeof parentURL !== 'string') { if (typeof parentURL !== 'string') {
throw new errors.TypeError('ERR_INVALID_ARG_TYPE', throw new errors.TypeError('ERR_INVALID_ARG_TYPE',
@ -48,10 +71,11 @@ class Loader {
const { url, format } = await this.resolver(specifier, parentURL, const { url, format } = await this.resolver(specifier, parentURL,
ModuleRequest.resolve); ModuleRequest.resolve);
if (typeof format !== 'string') { if (!Loader.validFormats.includes(format)) {
throw new errors.TypeError('ERR_INVALID_ARG_TYPE', 'format', throw new errors.TypeError('ERR_INVALID_ARG_TYPE', 'format',
['esm', 'cjs', 'builtin', 'addon', 'json']); Loader.validFormats);
} }
if (typeof url !== 'string') { if (typeof url !== 'string') {
throw new errors.TypeError('ERR_INVALID_ARG_TYPE', 'url', 'string'); throw new errors.TypeError('ERR_INVALID_ARG_TYPE', 'url', 'string');
} }
@ -72,14 +96,20 @@ class Loader {
return { url, format }; return { url, format };
} }
// May create a new ModuleJob instance if one did not already exist.
async getModuleJob(specifier, parentURL = this.base) { async getModuleJob(specifier, parentURL = this.base) {
const { url, format } = await this.resolve(specifier, parentURL); const { url, format } = await this.resolve(specifier, parentURL);
let job = this.moduleMap.get(url); let job = this.moduleMap.get(url);
if (job === undefined) { if (job === undefined) {
let loaderInstance; let loaderInstance;
if (format === 'dynamic') { if (format === 'dynamic') {
const { dynamicInstantiate } = this;
if (typeof dynamicInstantiate !== 'function') {
throw new errors.Error('ERR_MISSING_DYNAMIC_INSTANTIATE_HOOK');
}
loaderInstance = async (url) => { loaderInstance = async (url) => {
const { exports, execute } = await this.dynamicInstantiate(url); const { exports, execute } = await dynamicInstantiate(url);
return createDynamicModule(exports, url, (reflect) => { return createDynamicModule(exports, url, (reflect) => {
debug(`Loading custom loader ${url}`); debug(`Loading custom loader ${url}`);
execute(reflect.exports); execute(reflect.exports);
@ -100,5 +130,6 @@ class Loader {
return module.namespace(); return module.namespace();
} }
} }
Loader.validFormats = ['esm', 'cjs', 'builtin', 'addon', 'json', 'dynamic'];
Object.setPrototypeOf(Loader.prototype, null); Object.setPrototypeOf(Loader.prototype, null);
module.exports = Loader; module.exports = Loader;

View File

@ -1,27 +1,35 @@
'use strict'; 'use strict';
const { ModuleWrap } = internalBinding('module_wrap');
const { SafeSet, SafePromise } = require('internal/safe_globals'); const { SafeSet, SafePromise } = require('internal/safe_globals');
const assert = require('assert');
const resolvedPromise = SafePromise.resolve(); 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 { class ModuleJob {
/** // `loader` is the Loader instance used for loading dependencies.
* @param {module: ModuleWrap?, compiled: Promise} moduleProvider // `moduleProvider` is a function
*/
constructor(loader, url, moduleProvider) { constructor(loader, url, moduleProvider) {
this.loader = loader; this.loader = loader;
this.error = null; this.error = null;
this.hadError = false; this.hadError = false;
// linked == promise for dependency jobs, with module populated, // This is a Promise<{ module, reflect }>, whose fields will be copied
// module wrapper linked // onto `this` by `link()` below once it has been resolved.
this.moduleProvider = moduleProvider; this.modulePromise = moduleProvider(url);
this.modulePromise = this.moduleProvider(url);
this.module = undefined; this.module = undefined;
this.reflect = undefined; this.reflect = undefined;
const linked = async () => {
// Wait for the ModuleWrap instance being linked with all dependencies.
const link = async () => {
const dependencyJobs = []; const dependencyJobs = [];
({ module: this.module, ({ module: this.module,
reflect: this.reflect } = await this.modulePromise); reflect: this.reflect } = await this.modulePromise);
assert(this.module instanceof ModuleWrap);
this.module.link(async (dependencySpecifier) => { this.module.link(async (dependencySpecifier) => {
const dependencyJobPromise = const dependencyJobPromise =
this.loader.getModuleJob(dependencySpecifier, url); this.loader.getModuleJob(dependencySpecifier, url);
@ -29,63 +37,57 @@ class ModuleJob {
const dependencyJob = await dependencyJobPromise; const dependencyJob = await dependencyJobPromise;
return (await dependencyJob.modulePromise).module; return (await dependencyJob.modulePromise).module;
}); });
if (enableDebug) {
// Make sure all dependencies are entered into the list synchronously.
Object.freeze(dependencyJobs);
}
return SafePromise.all(dependencyJobs); return SafePromise.all(dependencyJobs);
}; };
this.linked = linked(); // Promise for the list of all dependencyJobs.
this.linked = link();
// instantiated == deep dependency jobs wrappers instantiated, // instantiated == deep dependency jobs wrappers instantiated,
// module wrapper instantiated // module wrapper instantiated
this.instantiated = undefined; this.instantiated = undefined;
} }
instantiate() { async instantiate() {
if (this.instantiated) { if (this.instantiated) {
return this.instantiated; return this.instantiated;
} }
return this.instantiated = new Promise(async (resolve, reject) => { 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 jobsInGraph = new SafeSet();
let jobsReadyToInstantiate = 0;
// (this must be sync for counter to work) const addJobsToDependencyGraph = async (moduleJob) => {
const queueJob = (moduleJob) => {
if (jobsInGraph.has(moduleJob)) { if (jobsInGraph.has(moduleJob)) {
return; return;
} }
jobsInGraph.add(moduleJob); jobsInGraph.add(moduleJob);
moduleJob.linked.then((dependencyJobs) => { const dependencyJobs = await moduleJob.linked;
for (const dependencyJob of dependencyJobs) { return Promise.all(dependencyJobs.map(addJobsToDependencyGraph));
queueJob(dependencyJob); };
} try {
checkComplete(); await addJobsToDependencyGraph(this);
}, (e) => { } catch (e) {
if (!this.hadError) { if (!this.hadError) {
this.error = e; this.error = e;
this.hadError = true; this.hadError = true;
} }
checkComplete(); throw e;
}); }
};
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(); this.module.instantiate();
for (const dependencyJob of jobsInGraph) { 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; dependencyJob.instantiated = resolvedPromise;
} }
resolve(this.module); return this.module;
} catch (e) {
e.stack;
reject(e);
}
}
}
};
queueJob(this);
});
} }
async run() { async run() {

View File

@ -10,39 +10,49 @@ const createDynamicModule = (exports, url = '', evaluate) => {
`creating ESM facade for ${url} with exports: ${ArrayJoin(exports, ', ')}` `creating ESM facade for ${url} with exports: ${ArrayJoin(exports, ', ')}`
); );
const names = ArrayMap(exports, (name) => `${name}`); const names = ArrayMap(exports, (name) => `${name}`);
// sanitized ESM for reflection purposes // Create two modules: One whose exports are get- and set-able ('reflective'),
const src = `export let executor; // and one which re-exports all of these but additionally may
${ArrayJoin(ArrayMap(names, (name) => `export let $${name}`), ';\n')} // run an executor function once everything is set up.
;(() => [ const src = `
fn => executor = fn, export let executor;
{ exports: { ${ ${ArrayJoin(ArrayMap(names, (name) => `export let $${name};`), '\n')}
ArrayJoin(ArrayMap(names, (name) => `${name}: { /* This function is implicitly returned as the module's completion value */
(() => ({
setExecutor: fn => executor = fn,
reflect: {
exports: { ${
ArrayJoin(ArrayMap(names, (name) => `
${name}: {
get: () => $${name}, get: () => $${name},
set: v => $${name} = v set: v => $${name} = v
}`), ',\n') }`), ', \n')}
} } } }
]); }
`; }));`;
const reflectiveModule = new ModuleWrap(src, `cjs-facade:${url}`); const reflectiveModule = new ModuleWrap(src, `cjs-facade:${url}`);
reflectiveModule.instantiate(); reflectiveModule.instantiate();
const [setExecutor, reflect] = reflectiveModule.evaluate()(); const { setExecutor, reflect } = reflectiveModule.evaluate()();
// public exposed ESM // public exposed ESM
const reexports = `import { executor, const reexports = `
import {
executor,
${ArrayMap(names, (name) => `$${name}`)} ${ArrayMap(names, (name) => `$${name}`)}
} from ""; } from "";
export { export {
${ArrayJoin(ArrayMap(names, (name) => `$${name} as ${name}`), ', ')} ${ArrayJoin(ArrayMap(names, (name) => `$${name} as ${name}`), ', ')}
} }
if (typeof executor === "function") {
// add await to this later if top level await comes along // add await to this later if top level await comes along
typeof executor === "function" ? executor() : void 0;`; executor()
}`;
if (typeof evaluate === 'function') { if (typeof evaluate === 'function') {
setExecutor(() => evaluate(reflect)); setExecutor(() => evaluate(reflect));
} }
const runner = new ModuleWrap(reexports, `${url}`); const module = new ModuleWrap(reexports, `${url}`);
runner.link(async () => reflectiveModule); module.link(async () => reflectiveModule);
runner.instantiate(); module.instantiate();
return { return {
module: runner, module,
reflect reflect
}; };
}; };