Skip to content

Commit 2019b02

Browse files
addaleaxgibfahn
authored andcommitted
lib: refactor ES module loader for readability
PR-URL: #16579 Backport-PR-URL: #18085 Reviewed-By: Michaël Zasso <targos@protonmail.com> Reviewed-By: James M Snell <jasnell@gmail.com> Reviewed-By: Colin Ihrig <cjihrig@gmail.com>
1 parent f12db24 commit 2019b02

File tree

5 files changed

+151
-82
lines changed

5 files changed

+151
-82
lines changed

doc/api/errors.md

+22
Original file line numberDiff line numberDiff line change
@@ -981,6 +981,28 @@ strict compliance with the API specification (which in some cases may accept
981981
`func(undefined)` and `func()` are treated identically, and the
982982
[`ERR_INVALID_ARG_TYPE`][] error code may be used instead.
983983

984+
<a id="ERR_MISSING_DYNAMIC_INSTANTIATE_HOOK"></a>
985+
### ERR_MISSING_DYNAMIC_INSTANTIATE_HOOK
986+
987+
> Stability: 1 - Experimental
988+
989+
Used when an [ES6 module][] loader hook specifies `format: 'dynamic` but does
990+
not provide a `dynamicInstantiate` hook.
991+
992+
<a id="ERR_MISSING_MODULE"></a>
993+
### ERR_MISSING_MODULE
994+
995+
> Stability: 1 - Experimental
996+
997+
Used when an [ES6 module][] cannot be resolved.
998+
999+
<a id="ERR_MODULE_RESOLUTION_LEGACY"></a>
1000+
### ERR_MODULE_RESOLUTION_LEGACY
1001+
1002+
> Stability: 1 - Experimental
1003+
1004+
Used when a failure occurs resolving imports in an [ES6 module][].
1005+
9841006
<a id="ERR_NAPI_CONS_FUNCTION"></a>
9851007
### ERR_NAPI_CONS_FUNCTION
9861008

lib/internal/errors.js

+3
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,9 @@ E('ERR_IPC_DISCONNECTED', 'IPC channel is already disconnected');
257257
E('ERR_IPC_ONE_PIPE', 'Child process can have only one IPC pipe');
258258
E('ERR_IPC_SYNC_FORK', 'IPC cannot be used with synchronous forks');
259259
E('ERR_MISSING_ARGS', missingArgs);
260+
E('ERR_MISSING_DYNAMIC_INSTANTIATE_HOOK',
261+
'The ES Module loader may not return a format of \'dynamic\' when no ' +
262+
'dynamicInstantiate function was provided');
260263
E('ERR_MISSING_MODULE', 'Cannot find module %s');
261264
E('ERR_MODULE_RESOLUTION_LEGACY', '%s not found by import in %s.' +
262265
' 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

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

3+
const { ModuleWrap } =
4+
require('internal/process').internalBinding('module_wrap');
35
const { SafeSet, SafePromise } = require('internal/safe_globals');
6+
const assert = require('assert');
47
const resolvedPromise = SafePromise.resolve();
58

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

15-
// linked == promise for dependency jobs, with module populated,
16-
// module wrapper linked
17-
this.moduleProvider = moduleProvider;
18-
this.modulePromise = this.moduleProvider(url);
22+
// This is a Promise<{ module, reflect }>, whose fields will be copied
23+
// onto `this` by `link()` below once it has been resolved.
24+
this.modulePromise = moduleProvider(url);
1925
this.module = undefined;
2026
this.reflect = undefined;
21-
const linked = async () => {
27+
28+
// Wait for the ModuleWrap instance being linked with all dependencies.
29+
const link = async () => {
2230
const dependencyJobs = [];
2331
({ module: this.module,
2432
reflect: this.reflect } = await this.modulePromise);
33+
assert(this.module instanceof ModuleWrap);
2534
this.module.link(async (dependencySpecifier) => {
2635
const dependencyJobPromise =
2736
this.loader.getModuleJob(dependencySpecifier, url);
2837
dependencyJobs.push(dependencyJobPromise);
2938
const dependencyJob = await dependencyJobPromise;
3039
return (await dependencyJob.modulePromise).module;
3140
});
41+
if (enableDebug) {
42+
// Make sure all dependencies are entered into the list synchronously.
43+
Object.freeze(dependencyJobs);
44+
}
3245
return SafePromise.all(dependencyJobs);
3346
};
34-
this.linked = linked();
47+
// Promise for the list of all dependencyJobs.
48+
this.linked = link();
3549

3650
// instantiated == deep dependency jobs wrappers instantiated,
3751
// module wrapper instantiated
3852
this.instantiated = undefined;
3953
}
4054

41-
instantiate() {
55+
async instantiate() {
4256
if (this.instantiated) {
4357
return this.instantiated;
4458
}
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-
});
59+
return this.instantiated = this._instantiate();
60+
}
61+
62+
// This method instantiates the module associated with this job and its
63+
// entire dependency graph, i.e. creates all the module namespaces and the
64+
// exported/imported variables.
65+
async _instantiate() {
66+
const jobsInGraph = new SafeSet();
67+
68+
const addJobsToDependencyGraph = async (moduleJob) => {
69+
if (jobsInGraph.has(moduleJob)) {
70+
return;
71+
}
72+
jobsInGraph.add(moduleJob);
73+
const dependencyJobs = await moduleJob.linked;
74+
return Promise.all(dependencyJobs.map(addJobsToDependencyGraph));
75+
};
76+
try {
77+
await addJobsToDependencyGraph(this);
78+
} catch (e) {
79+
if (!this.hadError) {
80+
this.error = e;
81+
this.hadError = true;
82+
}
83+
throw e;
84+
}
85+
this.module.instantiate();
86+
for (const dependencyJob of jobsInGraph) {
87+
// Calling `this.module.instantiate()` instantiates not only the
88+
// ModuleWrap in this module, but all modules in the graph.
89+
dependencyJob.instantiated = resolvedPromise;
90+
}
91+
return this.module;
8992
}
9093

9194
async run() {

lib/internal/loader/ModuleWrap.js

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

0 commit comments

Comments
 (0)