Skip to content

Commit d1c068c

Browse files
esm: support nested loader chains
Fixes #48515
1 parent 42d8143 commit d1c068c

File tree

4 files changed

+136
-23
lines changed

4 files changed

+136
-23
lines changed

lib/internal/modules/esm/hooks.js

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ const {
5454
const {
5555
getDefaultConditions,
5656
loaderWorkerId,
57+
createHooksLoader,
5758
} = require('internal/modules/esm/utils');
5859
const { deserializeError } = require('internal/error_serdes');
5960
const {
@@ -136,6 +137,10 @@ class Hooks {
136137
this.addCustomLoader(urlOrSpecifier, keyedExports);
137138
}
138139

140+
getChains() {
141+
return this.#chains;
142+
}
143+
139144
/**
140145
* Collect custom/user-defined module loader hook(s).
141146
* After all hooks have been collected, the global preload hook(s) must be initialized.
@@ -220,16 +225,25 @@ class Hooks {
220225
originalSpecifier,
221226
parentURL,
222227
importAssertions = { __proto__: null },
228+
) {
229+
return this.resolveWithChain(this.#chains.resolve, originalSpecifier, parentURL, importAssertions);
230+
}
231+
232+
async resolveWithChain(
233+
chain,
234+
originalSpecifier,
235+
parentURL,
236+
importAssertions = { __proto__: null },
223237
) {
224238
throwIfInvalidParentURL(parentURL);
225239

226-
const chain = this.#chains.resolve;
227240
const context = {
228241
conditions: getDefaultConditions(),
229242
importAssertions,
230243
parentURL,
231244
};
232245
const meta = {
246+
hooks: this,
233247
chainFinished: null,
234248
context,
235249
hookErrIdentifier: '',
@@ -344,8 +358,12 @@ class Hooks {
344358
* @returns {Promise<{ format: ModuleFormat, source: ModuleSource }>}
345359
*/
346360
async load(url, context = {}) {
347-
const chain = this.#chains.load;
361+
return this.loadWithChain(this.#chains.load, url, context)
362+
}
363+
364+
async loadWithChain(chain, url, context = {}) {
348365
const meta = {
366+
hooks: this,
349367
chainFinished: null,
350368
context,
351369
hookErrIdentifier: '',
@@ -749,7 +767,31 @@ function nextHookFactory(chain, meta, { validateArgs, validateOutput }) {
749767
ObjectAssign(meta.context, context);
750768
}
751769

752-
const output = await hook(arg0, meta.context, nextNextHook);
770+
const withESMLoader = require('internal/process/esm_loader').withESMLoader;
771+
772+
const chains = meta.hooks.getChains();
773+
const loadChain = chain === chains.load ? chains.load.slice(0, generatedHookIndex) : chains.load;
774+
const resolveChain = chain === chains.resolve ? chains.resolve.slice(0, generatedHookIndex) : chains.resolve;
775+
const loader = createHooksLoader({
776+
async resolve(
777+
originalSpecifier,
778+
parentURL,
779+
importAssertions = { __proto__: null }
780+
) {
781+
return await meta.hooks.resolveWithChain(
782+
resolveChain,
783+
originalSpecifier,
784+
parentURL,
785+
importAssertions,
786+
);
787+
},
788+
async load(url, context = {}) {
789+
return await meta.hooks.loadWithChain(loadChain, url, context);
790+
},
791+
})
792+
const output = await withESMLoader(loader, async () => {
793+
return await hook(arg0, meta.context, nextNextHook);
794+
});
753795

754796
validateOutput(outputErrIdentifier, output);
755797

lib/internal/modules/esm/utils.js

Lines changed: 48 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const {
2424
} = require('internal/vm/module');
2525
const assert = require('internal/assert');
2626

27+
2728
const callbackMap = new SafeWeakMap();
2829
function setCallbackForWrap(wrap, data) {
2930
callbackMap.set(wrap, data);
@@ -107,26 +108,19 @@ function isLoaderWorker() {
107108
return _isLoaderWorker;
108109
}
109110

110-
async function initializeHooks() {
111-
const customLoaderURLs = getOptionValue('--experimental-loader');
112-
113-
let cwd;
114-
try {
115-
// `process.cwd()` can fail if the parent directory is deleted while the process runs.
116-
cwd = process.cwd() + '/';
117-
} catch {
118-
cwd = '/';
119-
}
120-
121-
122-
const { Hooks } = require('internal/modules/esm/hooks');
123-
const hooks = new Hooks();
124-
111+
const createHooksLoader = (hooks) => {
112+
// TODO: HACK: `DefaultModuleLoader` depends on `getDefaultConditions` defined in
113+
// this file so we have a circular reference going on. If that function was in
114+
// it's on file we could just expose this class generically.
125115
const { DefaultModuleLoader } = require('internal/modules/esm/loader');
126-
class ModuleLoader extends DefaultModuleLoader {
127-
loaderType = 'internal';
116+
class HooksModuleLoader extends DefaultModuleLoader {
117+
#hooks;
118+
constructor(hooks) {
119+
super();
120+
this.#hooks = hooks;
121+
}
128122
async #getModuleJob(specifier, parentURL, importAssertions) {
129-
const resolveResult = await hooks.resolve(specifier, parentURL, importAssertions);
123+
const resolveResult = await this.#hooks.resolve(specifier, parentURL, importAssertions);
130124
return this.getJobFromResolveResult(resolveResult, parentURL, importAssertions);
131125
}
132126
getModuleJob(specifier, parentURL, importAssertions) {
@@ -143,9 +137,42 @@ async function initializeHooks() {
143137
},
144138
};
145139
}
146-
load(url, context) { return hooks.load(url, context); }
140+
resolve(
141+
originalSpecifier,
142+
parentURL,
143+
importAssertions = { __proto__: null },
144+
) {
145+
return this.#hooks.resolve(
146+
originalSpecifier,
147+
parentURL,
148+
importAssertions
149+
);
150+
}
151+
load(url, context = {}) {
152+
return this.#hooks.load(url, context);
153+
}
147154
}
148-
const privateModuleLoader = new ModuleLoader();
155+
return new HooksModuleLoader(hooks);
156+
}
157+
158+
async function initializeHooks() {
159+
const customLoaderURLs = getOptionValue('--experimental-loader');
160+
161+
let cwd;
162+
try {
163+
// `process.cwd()` can fail if the parent directory is deleted while the process runs.
164+
cwd = process.cwd() + '/';
165+
} catch {
166+
cwd = '/';
167+
}
168+
169+
170+
const { Hooks } = require('internal/modules/esm/hooks');
171+
const hooks = new Hooks();
172+
173+
174+
const privateModuleLoader = createHooksLoader(hooks);
175+
privateModuleLoader.loaderType = 'internal';
149176
const parentURL = pathToFileURL(cwd).href;
150177

151178
// TODO(jlenon7): reuse the `Hooks.register()` method for registering loaders.
@@ -175,4 +202,5 @@ module.exports = {
175202
getConditionsSet,
176203
loaderWorkerId: 'internal/modules/esm/worker',
177204
isLoaderWorker,
205+
createHooksLoader,
178206
};

lib/internal/process/esm_loader.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,15 @@ const { kEmptyObject } = require('internal/util');
1515
let esmLoader;
1616

1717
module.exports = {
18+
async withESMLoader(loader, fn) {
19+
const oldLoader = esmLoader;
20+
esmLoader = loader;
21+
try {
22+
return await fn();
23+
} finally {
24+
esmLoader = oldLoader;
25+
}
26+
},
1827
get esmLoader() {
1928
return esmLoader ??= createModuleLoader(true);
2029
},

test/es-module/test-esm-loader-chaining.mjs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -470,4 +470,38 @@ describe('ESM: loader chaining', { concurrency: true }, () => {
470470
assert.match(stderr, /'load' hook's nextLoad\(\) context/);
471471
assert.strictEqual(code, 1);
472472
});
473+
474+
it('should allow loaders to influence subsequent loader `import()` calls in `resolve`', async () => {
475+
const { code, stderr, stdout } = await spawnPromisified(
476+
execPath,
477+
[
478+
'--loader',
479+
fixtures.fileURL('es-module-loaders', 'loader-resolve-strip-xxx.mjs'),
480+
'--loader',
481+
fixtures.fileURL('es-module-loaders', 'loader-resolve-dynamic-import.mjs'),
482+
...commonArgs,
483+
],
484+
{ encoding: 'utf8' },
485+
);
486+
assert.strictEqual(stderr, '');
487+
assert.match(stdout, /resolve dynamic import/); // It did go thru resolve-passthru
488+
assert.strictEqual(code, 0);
489+
});
490+
491+
it('should allow loaders to influence subsequent loader `import()` calls in `load`', async () => {
492+
const { code, stderr, stdout } = await spawnPromisified(
493+
execPath,
494+
[
495+
'--loader',
496+
fixtures.fileURL('es-module-loaders', 'loader-resolve-strip-xxx.mjs'),
497+
'--loader',
498+
fixtures.fileURL('es-module-loaders', 'loader-load-dynamic-import.mjs'),
499+
...commonArgs,
500+
],
501+
{ encoding: 'utf8' },
502+
);
503+
assert.strictEqual(stderr, '');
504+
assert.match(stdout, /load dynamic import/); // It did go thru resolve-passthru
505+
assert.strictEqual(code, 0);
506+
});
473507
});

0 commit comments

Comments
 (0)