Skip to content

Commit e2c1337

Browse files
esm: add chaining to loaders
1 parent b9e9797 commit e2c1337

File tree

7 files changed

+164
-62
lines changed

7 files changed

+164
-62
lines changed

doc/api/errors.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1737,6 +1737,13 @@ An import assertion is not supported by this version of Node.js.
17371737
An option pair is incompatible with each other and cannot be used at the same
17381738
time.
17391739

1740+
<a id="ERR_INCOMPLETE_LOADER_CHAIN"></a>
1741+
1742+
### `ERR_INCOMPLETE_LOADER_CHAIN`
1743+
1744+
An ESM loader hook returned without calling `next()` and without explicitly
1745+
signally a short-circuit.
1746+
17401747
<a id="ERR_INPUT_TYPE_NOT_ALLOWED"></a>
17411748

17421749
### `ERR_INPUT_TYPE_NOT_ALLOWED`

lib/internal/errors.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1111,6 +1111,13 @@ E('ERR_IMPORT_ASSERTION_TYPE_UNSUPPORTED',
11111111
'Import assertion type "%s" is unsupported', TypeError);
11121112
E('ERR_INCOMPATIBLE_OPTION_PAIR',
11131113
'Option "%s" cannot be used in combination with option "%s"', TypeError);
1114+
E(
1115+
'ERR_INCOMPLETE_LOADER_CHAIN',
1116+
'The "%s" hook from %s did not call the next hook in its chain and did not' +
1117+
' explicitly signal a short-circuit. If this is intentional, include' +
1118+
' `shortCircuit: true` in the hook\'s return.',
1119+
Error
1120+
);
11141121
E('ERR_INPUT_TYPE_NOT_ALLOWED', '--input-type can only be used with string ' +
11151122
'input via --eval, --print, or STDIN', Error);
11161123
E('ERR_INSPECTOR_ALREADY_ACTIVATED',

lib/internal/modules/esm/loader.js

Lines changed: 131 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,15 @@ const {
1616
PromiseAll,
1717
RegExpPrototypeExec,
1818
SafeArrayIterator,
19+
SafeMap,
1920
SafeWeakMap,
2021
StringPrototypeStartsWith,
2122
globalThis,
2223
} = primordials;
2324
const { MessageChannel } = require('internal/worker/io');
2425

2526
const {
27+
ERR_INCOMPLETE_LOADER_CHAIN,
2628
ERR_INTERNAL_ASSERTION,
2729
ERR_INVALID_ARG_TYPE,
2830
ERR_INVALID_ARG_VALUE,
@@ -70,28 +72,30 @@ class ESMLoader {
7072
/**
7173
* Prior to ESM loading. These are called once before any modules are started.
7274
* @private
73-
* @property {Function[]} globalPreloaders First-in-first-out list of
74-
* preload hooks.
75+
* @property {Map<URL['href'], Function>} globalPreloaders Last-in-first-out
76+
* list of preload hooks.
7577
*/
76-
#globalPreloaders = [];
78+
#globalPreloaders = new SafeMap();
7779

7880
/**
7981
* Phase 2 of 2 in ESM loading.
8082
* @private
81-
* @property {Function[]} loaders First-in-first-out list of loader hooks.
83+
* @property {Map<URL['href'], Function>} loaders Last-in-first-out
84+
* collection of loader hooks.
8285
*/
83-
#loaders = [
84-
defaultLoad,
85-
];
86+
#loaders = new SafeMap([
87+
['node:esm/load.js', defaultLoad],
88+
]);
8689

8790
/**
8891
* Phase 1 of 2 in ESM loading.
8992
* @private
90-
* @property {Function[]} resolvers First-in-first-out list of resolver hooks
93+
* @property {Map<URL['href'], Function>} resolvers Last-in-first-out
94+
* collection of resolver hooks.
9195
*/
92-
#resolvers = [
93-
defaultResolve,
94-
];
96+
#resolvers = new SafeMap([
97+
['node:esm/resolve.js', defaultResolve],
98+
]);
9599

96100
#importMetaInitializer = initializeImportMeta;
97101

@@ -115,7 +119,9 @@ class ESMLoader {
115119
*/
116120
translators = translators;
117121

118-
constructor() {
122+
constructor({ isInternal = false } = {}) {
123+
this.isInternal = isInternal;
124+
119125
if (getOptionValue('--experimental-loader')) {
120126
emitExperimentalWarning('Custom ESM Loaders');
121127
}
@@ -198,32 +204,46 @@ class ESMLoader {
198204
* user-defined loaders (as returned by ESMLoader.import()).
199205
*/
200206
async addCustomLoaders(
201-
customLoaders = [],
207+
customLoaders = new SafeMap(),
202208
) {
203-
if (!ArrayIsArray(customLoaders)) customLoaders = [customLoaders];
204-
205-
for (let i = 0; i < customLoaders.length; i++) {
206-
const exports = customLoaders[i];
209+
// Maps are first-in-first-out, but hook chains are last-in-first-out,
210+
// so create a new container for the incoming hooks (which have already
211+
// been reversed).
212+
const globalPreloaders = new SafeMap();
213+
const resolvers = new SafeMap();
214+
const loaders = new SafeMap();
215+
216+
for (const { 0: url, 1: exports } of customLoaders) {
207217
const {
208218
globalPreloader,
209219
resolver,
210220
loader,
211221
} = ESMLoader.pluckHooks(exports);
212222

213-
if (globalPreloader) ArrayPrototypePush(
214-
this.#globalPreloaders,
223+
if (globalPreloader) globalPreloaders.set(
224+
url,
215225
FunctionPrototypeBind(globalPreloader, null), // [1]
216226
);
217-
if (resolver) ArrayPrototypePush(
218-
this.#resolvers,
227+
if (resolver) resolvers.set(
228+
url,
219229
FunctionPrototypeBind(resolver, null), // [1]
220230
);
221-
if (loader) ArrayPrototypePush(
222-
this.#loaders,
231+
if (loader) loaders.set(
232+
url,
223233
FunctionPrototypeBind(loader, null), // [1]
224234
);
225235
}
226236

237+
// Append the pre-existing hooks (the builtin/default ones)
238+
for (const p of this.#globalPreloaders) globalPreloaders.set(p[0], p[1]);
239+
for (const p of this.#resolvers) resolvers.set(p[0], p[1]);
240+
for (const p of this.#loaders) loaders.set(p[0], p[1]);
241+
242+
// Replace the obsolete maps with the fully-loaded & properly sequenced one
243+
this.#globalPreloaders = globalPreloaders;
244+
this.#resolvers = resolvers;
245+
this.#loaders = loaders;
246+
227247
// [1] ensure hook function is not bound to ESMLoader instance
228248

229249
this.preload();
@@ -308,14 +328,21 @@ class ESMLoader {
308328
*/
309329
async getModuleJob(specifier, parentURL, importAssertions) {
310330
let importAssertionsForResolve;
311-
if (this.#loaders.length !== 1) {
312-
// We can skip cloning if there are no user provided loaders because
331+
332+
if (this.#loaders.size !== 1) {
333+
// We can skip cloning if there are no user-provided loaders because
313334
// the Node.js default resolve hook does not use import assertions.
314-
importAssertionsForResolve =
315-
ObjectAssign(ObjectCreate(null), importAssertions);
335+
importAssertionsForResolve = ObjectAssign(
336+
ObjectCreate(null),
337+
importAssertions,
338+
);
316339
}
317-
const { format, url } =
318-
await this.resolve(specifier, parentURL, importAssertionsForResolve);
340+
341+
const { format, url } = await this.resolve(
342+
specifier,
343+
parentURL,
344+
importAssertionsForResolve,
345+
);
319346

320347
let job = this.moduleMap.get(url, importAssertions.type);
321348

@@ -408,9 +435,13 @@ class ESMLoader {
408435

409436
const namespaces = await PromiseAll(new SafeArrayIterator(jobs));
410437

411-
return wasArr ?
412-
namespaces :
413-
namespaces[0];
438+
if (!wasArr) return namespaces[0];
439+
440+
const namespaceMap = new SafeMap();
441+
442+
for (let i = 0; i < count; i++) namespaceMap.set(specifiers[i], namespaces[i]);
443+
444+
return namespaceMap;
414445
}
415446

416447
/**
@@ -423,12 +454,33 @@ class ESMLoader {
423454
* @returns {object}
424455
*/
425456
async load(url, context = {}) {
426-
const defaultLoader = this.#loaders[0];
457+
const loaders = this.#loaders.entries();
458+
let {
459+
0: loaderFilePath,
460+
1: loader,
461+
} = loaders.next().value;
462+
let chainFinished = this.#loaders.size === 1;
463+
464+
function next(nextUrl) {
465+
const {
466+
done,
467+
value,
468+
} = loaders.next();
469+
({
470+
0: loaderFilePath,
471+
1: loader,
472+
} = value);
473+
474+
if (done || loader === defaultLoad) chainFinished = true;
475+
476+
return loader(nextUrl, context, next);
477+
}
427478

428-
const loader = this.#loaders.length === 1 ?
429-
defaultLoader :
430-
this.#loaders[1];
431-
const loaded = await loader(url, context, defaultLoader);
479+
const loaded = await loader(
480+
url,
481+
context,
482+
next,
483+
);
432484

433485
if (typeof loaded !== 'object') {
434486
throw new ERR_INVALID_RETURN_VALUE(
@@ -440,9 +492,14 @@ class ESMLoader {
440492

441493
const {
442494
format,
495+
shortCircuit,
443496
source,
444497
} = loaded;
445498

499+
if (!chainFinished && !shortCircuit) {
500+
throw new ERR_INCOMPLETE_LOADER_CHAIN('load', loaderFilePath);
501+
}
502+
446503
if (format == null) {
447504
const dataUrl = RegExpPrototypeExec(
448505
/^data:([^/]+\/[^;,]+)(?:[^,]*?)(;base64)?,/,
@@ -594,21 +651,38 @@ class ESMLoader {
594651
parentURL,
595652
);
596653

597-
const conditions = DEFAULT_CONDITIONS;
654+
const resolvers = this.#resolvers.entries();
655+
let {
656+
0: resolverFilePath,
657+
1: resolver,
658+
} = resolvers.next().value;
659+
let chainFinished = this.#resolvers.size === 1;
598660

599-
const defaultResolver = this.#resolvers[0];
661+
const context = {
662+
conditions: DEFAULT_CONDITIONS,
663+
importAssertions,
664+
parentURL,
665+
};
666+
667+
function next(suppliedUrl) {
668+
const {
669+
done,
670+
value,
671+
} = resolvers.next();
672+
({
673+
0: resolverFilePath,
674+
1: resolver,
675+
} = value);
676+
677+
if (done || resolver === defaultResolve) chainFinished = true;
678+
679+
return resolver(suppliedUrl, context, next);
680+
}
600681

601-
const resolver = this.#resolvers.length === 1 ?
602-
defaultResolver :
603-
this.#resolvers[1];
604682
const resolution = await resolver(
605683
originalSpecifier,
606-
{
607-
conditions,
608-
importAssertions,
609-
parentURL,
610-
},
611-
defaultResolver,
684+
context,
685+
next,
612686
);
613687

614688
if (typeof resolution !== 'object') {
@@ -619,7 +693,15 @@ class ESMLoader {
619693
);
620694
}
621695

622-
const { format, url } = resolution;
696+
const {
697+
format,
698+
shortCircuit,
699+
url,
700+
} = resolution;
701+
702+
if (!chainFinished && !shortCircuit) {
703+
throw new ERR_INCOMPLETE_LOADER_CHAIN('resolve', resolverFilePath);
704+
}
623705

624706
if (
625707
format != null &&

lib/internal/modules/run_main.js

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,19 @@ function runMainESM(mainPath) {
4848
const { loadESM } = require('internal/process/esm_loader');
4949
const { pathToFileURL } = require('internal/url');
5050

51-
handleMainPromise(loadESM((esmLoader) => {
52-
const main = path.isAbsolute(mainPath) ?
53-
pathToFileURL(mainPath).href : mainPath;
54-
return esmLoader.import(main, undefined, ObjectCreate(null));
55-
}));
51+
handleMainPromise(
52+
loadESM((esmLoader) => {
53+
const main = path.isAbsolute(mainPath) ?
54+
pathToFileURL(mainPath).href :
55+
mainPath;
56+
57+
return esmLoader.import(
58+
main,
59+
undefined,
60+
ObjectCreate(null),
61+
);
62+
})
63+
);
5664
}
5765

5866
async function handleMainPromise(promise) {

lib/internal/process/esm_loader.js

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,6 @@ exports.esmLoader = esmLoader;
4949
*/
5050
async function initializeLoader() {
5151
const { getOptionValue } = require('internal/options');
52-
// customLoaders CURRENTLY can be only 1 (a string)
53-
// Once chaining is implemented, it will be string[]
5452
const customLoaders = getOptionValue('--experimental-loader');
5553

5654
if (!customLoaders.length) return;
@@ -65,18 +63,18 @@ async function initializeLoader() {
6563
// A separate loader instance is necessary to avoid cross-contamination
6664
// between internal Node.js and userland. For example, a module with internal
6765
// state (such as a counter) should be independent.
68-
const internalEsmLoader = new ESMLoader();
66+
const internalEsmLoader = new ESMLoader({ isInternal: true });
6967

7068
// Importation must be handled by internal loader to avoid poluting userland
71-
const exports = await internalEsmLoader.import(
72-
customLoaders,
69+
const exportsMap = await internalEsmLoader.import(
70+
customLoaders.reverse(), // last-in-first-out
7371
pathToFileURL(cwd).href,
7472
ObjectCreate(null),
7573
);
7674

7775
// Hooks must then be added to external/public loader
7876
// (so they're triggered in userland)
79-
await esmLoader.addCustomLoaders(exports);
77+
await esmLoader.addCustomLoaders(exportsMap);
8078
}
8179

8280
exports.loadESM = async function loadESM(callback) {

src/node_options.cc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -328,7 +328,7 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() {
328328
AddOption("--experimental-json-modules", "", NoOp{}, kAllowedInEnvironment);
329329
AddOption("--experimental-loader",
330330
"use the specified module as a custom loader",
331-
&EnvironmentOptions::userland_loader,
331+
&EnvironmentOptions::userland_loaders,
332332
kAllowedInEnvironment);
333333
AddAlias("--loader", "--experimental-loader");
334334
AddOption("--experimental-modules", "", NoOp{}, kAllowedInEnvironment);

src/node_options.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ class EnvironmentOptions : public Options {
159159
bool trace_warnings = false;
160160
bool extra_info_on_fatal_exception = true;
161161
std::string unhandled_rejections;
162-
std::string userland_loader;
162+
std::vector<std::string> userland_loaders;
163163
bool verify_base_objects =
164164
#ifdef DEBUG
165165
true;

0 commit comments

Comments
 (0)