Skip to content

Commit a0b94f4

Browse files
addaleaxcjihrig
authored andcommitted
lib: refactor ES module loader for readability
PR-URL: #16579 Reviewed-By: Michaël Zasso <[email protected]> Reviewed-By: James M Snell <[email protected]> Reviewed-By: Colin Ihrig <[email protected]>
1 parent a814786 commit a0b94f4

File tree

5 files changed

+136
-82
lines changed

5 files changed

+136
-82
lines changed

doc/api/errors.md

+8
Original file line numberDiff line numberDiff line change
@@ -1178,6 +1178,14 @@ for strict compliance with the API specification (which in some cases may accept
11781178
`func(undefined)` and `func()` are treated identically, and the
11791179
[`ERR_INVALID_ARG_TYPE`][] error code may be used instead.
11801180

1181+
<a id="ERR_MISSING_DYNAMIC_INSTANTIATE_HOOK"></a>
1182+
### ERR_MISSING_DYNAMIC_INSTANTIATE_HOOK
1183+
1184+
> Stability: 1 - Experimental
1185+
1186+
Used when an [ES6 module][] loader hook specifies `format: 'dynamic` but does
1187+
not provide a `dynamicInstantiate` hook.
1188+
11811189
<a id="ERR_MISSING_MODULE"></a>
11821190
### ERR_MISSING_MODULE
11831191

lib/internal/errors.js

+3
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,9 @@ E('ERR_IPC_ONE_PIPE', 'Child process can have only one IPC pipe');
298298
E('ERR_IPC_SYNC_FORK', 'IPC cannot be used with synchronous forks');
299299
E('ERR_METHOD_NOT_IMPLEMENTED', 'The %s method is not implemented');
300300
E('ERR_MISSING_ARGS', missingArgs);
301+
E('ERR_MISSING_DYNAMIC_INSTANTIATE_HOOK',
302+
'The ES Module loader may not return a format of \'dynamic\' when no ' +
303+
'dynamicInstantiate function was provided');
301304
E('ERR_MISSING_MODULE', 'Cannot find module %s');
302305
E('ERR_MODULE_RESOLUTION_LEGACY', '%s not found by import in %s.' +
303306
' Legacy behavior in require() would have found it at %s');

lib/internal/loader/Loader.js

+38-7
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ const ModuleRequest = require('internal/loader/ModuleRequest');
1010
const errors = require('internal/errors');
1111
const debug = require('util').debuglog('esm');
1212

13-
function getBase() {
13+
// Returns a file URL for the current working directory.
14+
function getURLStringForCwd() {
1415
try {
1516
return getURLFromFilePath(`${process.cwd()}/`).href;
1617
} catch (e) {
@@ -23,22 +24,44 @@ function getBase() {
2324
}
2425
}
2526

27+
/* A Loader instance is used as the main entry point for loading ES modules.
28+
* Currently, this is a singleton -- there is only one used for loading
29+
* the main module and everything in its dependency graph. */
2630
class Loader {
27-
constructor(base = getBase()) {
28-
this.moduleMap = new ModuleMap();
31+
constructor(base = getURLStringForCwd()) {
2932
if (typeof base !== 'string') {
3033
throw new errors.TypeError('ERR_INVALID_ARG_TYPE', 'base', 'string');
3134
}
35+
36+
this.moduleMap = new ModuleMap();
3237
this.base = base;
33-
this.resolver = ModuleRequest.resolve.bind(null);
38+
// The resolver has the signature
39+
// (specifier : string, parentURL : string, defaultResolve)
40+
// -> Promise<{ url : string,
41+
// format: anything in Loader.validFormats }>
42+
// where defaultResolve is ModuleRequest.resolve (having the same
43+
// signature itself).
44+
// If `.format` on the returned value is 'dynamic', .dynamicInstantiate
45+
// will be used as described below.
46+
this.resolver = ModuleRequest.resolve;
47+
// This hook is only called when resolve(...).format is 'dynamic' and has
48+
// the signature
49+
// (url : string) -> Promise<{ exports: { ... }, execute: function }>
50+
// Where `exports` is an object whose property names define the exported
51+
// names of the generated module. `execute` is a function that receives
52+
// an object with the same keys as `exports`, whose values are get/set
53+
// functions for the actual exported values.
3454
this.dynamicInstantiate = undefined;
3555
}
3656

3757
hook({ resolve = ModuleRequest.resolve, dynamicInstantiate }) {
58+
// Use .bind() to avoid giving access to the Loader instance when it is
59+
// called as this.resolver(...);
3860
this.resolver = resolve.bind(null);
3961
this.dynamicInstantiate = dynamicInstantiate;
4062
}
4163

64+
// Typechecking wrapper around .resolver().
4265
async resolve(specifier, parentURL = this.base) {
4366
if (typeof parentURL !== 'string') {
4467
throw new errors.TypeError('ERR_INVALID_ARG_TYPE',
@@ -48,10 +71,11 @@ class Loader {
4871
const { url, format } = await this.resolver(specifier, parentURL,
4972
ModuleRequest.resolve);
5073

51-
if (typeof format !== 'string') {
74+
if (!Loader.validFormats.includes(format)) {
5275
throw new errors.TypeError('ERR_INVALID_ARG_TYPE', 'format',
53-
['esm', 'cjs', 'builtin', 'addon', 'json']);
76+
Loader.validFormats);
5477
}
78+
5579
if (typeof url !== 'string') {
5680
throw new errors.TypeError('ERR_INVALID_ARG_TYPE', 'url', 'string');
5781
}
@@ -72,14 +96,20 @@ class Loader {
7296
return { url, format };
7397
}
7498

99+
// May create a new ModuleJob instance if one did not already exist.
75100
async getModuleJob(specifier, parentURL = this.base) {
76101
const { url, format } = await this.resolve(specifier, parentURL);
77102
let job = this.moduleMap.get(url);
78103
if (job === undefined) {
79104
let loaderInstance;
80105
if (format === 'dynamic') {
106+
const { dynamicInstantiate } = this;
107+
if (typeof dynamicInstantiate !== 'function') {
108+
throw new errors.Error('ERR_MISSING_DYNAMIC_INSTANTIATE_HOOK');
109+
}
110+
81111
loaderInstance = async (url) => {
82-
const { exports, execute } = await this.dynamicInstantiate(url);
112+
const { exports, execute } = await dynamicInstantiate(url);
83113
return createDynamicModule(exports, url, (reflect) => {
84114
debug(`Loading custom loader ${url}`);
85115
execute(reflect.exports);
@@ -100,5 +130,6 @@ class Loader {
100130
return module.namespace();
101131
}
102132
}
133+
Loader.validFormats = ['esm', 'cjs', 'builtin', 'addon', 'json', 'dynamic'];
103134
Object.setPrototypeOf(Loader.prototype, null);
104135
module.exports = Loader;

lib/internal/loader/ModuleJob.js

+56-54
Original file line numberDiff line numberDiff line change
@@ -1,91 +1,93 @@
11
'use strict';
22

3+
const { ModuleWrap } = internalBinding('module_wrap');
34
const { SafeSet, SafePromise } = require('internal/safe_globals');
5+
const assert = require('assert');
46
const resolvedPromise = SafePromise.resolve();
57

8+
const enableDebug = (process.env.NODE_DEBUG || '').match(/\besm\b/) ||
9+
process.features.debug;
10+
11+
/* A ModuleJob tracks the loading of a single Module, and the ModuleJobs of
12+
* its dependencies, over time. */
613
class ModuleJob {
7-
/**
8-
* @param {module: ModuleWrap?, compiled: Promise} moduleProvider
9-
*/
14+
// `loader` is the Loader instance used for loading dependencies.
15+
// `moduleProvider` is a function
1016
constructor(loader, url, moduleProvider) {
1117
this.loader = loader;
1218
this.error = null;
1319
this.hadError = false;
1420

15-
// linked == promise for dependency jobs, with module populated,
16-
// module wrapper linked
17-
this.moduleProvider = moduleProvider;
18-
this.modulePromise = this.moduleProvider(url);
21+
// This is a Promise<{ module, reflect }>, whose fields will be copied
22+
// onto `this` by `link()` below once it has been resolved.
23+
this.modulePromise = moduleProvider(url);
1924
this.module = undefined;
2025
this.reflect = undefined;
21-
const linked = async () => {
26+
27+
// Wait for the ModuleWrap instance being linked with all dependencies.
28+
const link = async () => {
2229
const dependencyJobs = [];
2330
({ module: this.module,
2431
reflect: this.reflect } = await this.modulePromise);
32+
assert(this.module instanceof ModuleWrap);
2533
this.module.link(async (dependencySpecifier) => {
2634
const dependencyJobPromise =
2735
this.loader.getModuleJob(dependencySpecifier, url);
2836
dependencyJobs.push(dependencyJobPromise);
2937
const dependencyJob = await dependencyJobPromise;
3038
return (await dependencyJob.modulePromise).module;
3139
});
40+
if (enableDebug) {
41+
// Make sure all dependencies are entered into the list synchronously.
42+
Object.freeze(dependencyJobs);
43+
}
3244
return SafePromise.all(dependencyJobs);
3345
};
34-
this.linked = linked();
46+
// Promise for the list of all dependencyJobs.
47+
this.linked = link();
3548

3649
// instantiated == deep dependency jobs wrappers instantiated,
3750
// module wrapper instantiated
3851
this.instantiated = undefined;
3952
}
4053

41-
instantiate() {
54+
async instantiate() {
4255
if (this.instantiated) {
4356
return this.instantiated;
4457
}
45-
return this.instantiated = new Promise(async (resolve, reject) => {
46-
const jobsInGraph = new SafeSet();
47-
let jobsReadyToInstantiate = 0;
48-
// (this must be sync for counter to work)
49-
const queueJob = (moduleJob) => {
50-
if (jobsInGraph.has(moduleJob)) {
51-
return;
52-
}
53-
jobsInGraph.add(moduleJob);
54-
moduleJob.linked.then((dependencyJobs) => {
55-
for (const dependencyJob of dependencyJobs) {
56-
queueJob(dependencyJob);
57-
}
58-
checkComplete();
59-
}, (e) => {
60-
if (!this.hadError) {
61-
this.error = e;
62-
this.hadError = true;
63-
}
64-
checkComplete();
65-
});
66-
};
67-
const checkComplete = () => {
68-
if (++jobsReadyToInstantiate === jobsInGraph.size) {
69-
// I believe we only throw once the whole tree is finished loading?
70-
// or should the error bail early, leaving entire tree to still load?
71-
if (this.hadError) {
72-
reject(this.error);
73-
} else {
74-
try {
75-
this.module.instantiate();
76-
for (const dependencyJob of jobsInGraph) {
77-
dependencyJob.instantiated = resolvedPromise;
78-
}
79-
resolve(this.module);
80-
} catch (e) {
81-
e.stack;
82-
reject(e);
83-
}
84-
}
85-
}
86-
};
87-
queueJob(this);
88-
});
58+
return this.instantiated = this._instantiate();
59+
}
60+
61+
// This method instantiates the module associated with this job and its
62+
// entire dependency graph, i.e. creates all the module namespaces and the
63+
// exported/imported variables.
64+
async _instantiate() {
65+
const jobsInGraph = new SafeSet();
66+
67+
const addJobsToDependencyGraph = async (moduleJob) => {
68+
if (jobsInGraph.has(moduleJob)) {
69+
return;
70+
}
71+
jobsInGraph.add(moduleJob);
72+
const dependencyJobs = await moduleJob.linked;
73+
return Promise.all(dependencyJobs.map(addJobsToDependencyGraph));
74+
};
75+
try {
76+
await addJobsToDependencyGraph(this);
77+
} catch (e) {
78+
if (!this.hadError) {
79+
this.error = e;
80+
this.hadError = true;
81+
}
82+
throw e;
83+
}
84+
this.module.instantiate();
85+
for (const dependencyJob of jobsInGraph) {
86+
// Calling `this.module.instantiate()` instantiates not only the
87+
// ModuleWrap in this module, but all modules in the graph.
88+
dependencyJob.instantiated = resolvedPromise;
89+
}
90+
return this.module;
8991
}
9092

9193
async run() {

lib/internal/loader/ModuleWrap.js

+31-21
Original file line numberDiff line numberDiff line change
@@ -10,39 +10,49 @@ const createDynamicModule = (exports, url = '', evaluate) => {
1010
`creating ESM facade for ${url} with exports: ${ArrayJoin(exports, ', ')}`
1111
);
1212
const names = ArrayMap(exports, (name) => `${name}`);
13-
// sanitized ESM for reflection purposes
14-
const src = `export let executor;
15-
${ArrayJoin(ArrayMap(names, (name) => `export let $${name}`), ';\n')}
16-
;(() => [
17-
fn => executor = fn,
18-
{ exports: { ${
19-
ArrayJoin(ArrayMap(names, (name) => `${name}: {
20-
get: () => $${name},
21-
set: v => $${name} = v
22-
}`), ',\n')
23-
} } }
24-
]);
25-
`;
13+
// Create two modules: One whose exports are get- and set-able ('reflective'),
14+
// and one which re-exports all of these but additionally may
15+
// run an executor function once everything is set up.
16+
const src = `
17+
export let executor;
18+
${ArrayJoin(ArrayMap(names, (name) => `export let $${name};`), '\n')}
19+
/* This function is implicitly returned as the module's completion value */
20+
(() => ({
21+
setExecutor: fn => executor = fn,
22+
reflect: {
23+
exports: { ${
24+
ArrayJoin(ArrayMap(names, (name) => `
25+
${name}: {
26+
get: () => $${name},
27+
set: v => $${name} = v
28+
}`), ', \n')}
29+
}
30+
}
31+
}));`;
2632
const reflectiveModule = new ModuleWrap(src, `cjs-facade:${url}`);
2733
reflectiveModule.instantiate();
28-
const [setExecutor, reflect] = reflectiveModule.evaluate()();
34+
const { setExecutor, reflect } = reflectiveModule.evaluate()();
2935
// public exposed ESM
30-
const reexports = `import { executor,
36+
const reexports = `
37+
import {
38+
executor,
3139
${ArrayMap(names, (name) => `$${name}`)}
3240
} from "";
3341
export {
3442
${ArrayJoin(ArrayMap(names, (name) => `$${name} as ${name}`), ', ')}
3543
}
36-
// add await to this later if top level await comes along
37-
typeof executor === "function" ? executor() : void 0;`;
44+
if (typeof executor === "function") {
45+
// add await to this later if top level await comes along
46+
executor()
47+
}`;
3848
if (typeof evaluate === 'function') {
3949
setExecutor(() => evaluate(reflect));
4050
}
41-
const runner = new ModuleWrap(reexports, `${url}`);
42-
runner.link(async () => reflectiveModule);
43-
runner.instantiate();
51+
const module = new ModuleWrap(reexports, `${url}`);
52+
module.link(async () => reflectiveModule);
53+
module.instantiate();
4454
return {
45-
module: runner,
55+
module,
4656
reflect
4757
};
4858
};

0 commit comments

Comments
 (0)