Skip to content

Commit 490b598

Browse files
arcanistargos
authored andcommitted
esm: leverage loaders when resolving subsequent loaders
PR-URL: #43772 Reviewed-By: James M Snell <[email protected]> Reviewed-By: Jacob Smith <[email protected]> Reviewed-By: Geoffrey Booth <[email protected]>
1 parent edf46c1 commit 490b598

15 files changed

+140
-24
lines changed

doc/api/esm.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -700,8 +700,9 @@ changes:
700700
To customize the default module resolution, loader hooks can optionally be
701701
provided via a `--experimental-loader ./loader-name.mjs` argument to Node.js.
702702
703-
When hooks are used they apply to the entry point and all `import` calls. They
704-
won't apply to `require` calls; those still follow [CommonJS][] rules.
703+
When hooks are used they apply to each subsequent loader, the entry point, and
704+
all `import` calls. They won't apply to `require` calls; those still follow
705+
[CommonJS][] rules.
705706
706707
Loaders follow the pattern of `--require`:
707708

lib/internal/modules/esm/loader.js

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -322,7 +322,7 @@ class ESMLoader {
322322

323323
/**
324324
* Collect custom/user-defined hook(s). After all hooks have been collected,
325-
* calls global preload hook(s).
325+
* the global preload hook(s) must be called.
326326
* @param {KeyedExports} customLoaders
327327
* A list of exports from user-defined loaders (as returned by
328328
* ESMLoader.import()).
@@ -369,8 +369,6 @@ class ESMLoader {
369369
);
370370
}
371371
}
372-
373-
this.preload();
374372
}
375373

376374
async eval(

lib/internal/process/esm_loader.js

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@
22

33
const {
44
ArrayIsArray,
5-
ObjectCreate,
5+
ArrayPrototypePushApply,
66
} = primordials;
77

88
const { ESMLoader } = require('internal/modules/esm/loader');
99
const {
1010
hasUncaughtExceptionCaptureCallback,
1111
} = require('internal/process/execution');
1212
const { pathToFileURL } = require('internal/url');
13+
const { kEmptyObject } = require('internal/util');
1314

1415
const esmLoader = new ESMLoader();
1516
exports.esmLoader = esmLoader;
@@ -29,41 +30,61 @@ async function initializeLoader() {
2930
const { getOptionValue } = require('internal/options');
3031
const customLoaders = getOptionValue('--experimental-loader');
3132
const preloadModules = getOptionValue('--import');
32-
const loaders = await loadModulesInIsolation(customLoaders);
33+
34+
let cwd;
35+
try {
36+
cwd = process.cwd() + '/';
37+
} catch {
38+
cwd = '/';
39+
}
40+
41+
const internalEsmLoader = new ESMLoader();
42+
const allLoaders = [];
43+
44+
const parentURL = pathToFileURL(cwd).href;
45+
46+
for (let i = 0; i < customLoaders.length; i++) {
47+
const customLoader = customLoaders[i];
48+
49+
// Importation must be handled by internal loader to avoid polluting user-land
50+
const keyedExportsSublist = await internalEsmLoader.import(
51+
[customLoader],
52+
parentURL,
53+
kEmptyObject,
54+
);
55+
56+
internalEsmLoader.addCustomLoaders(keyedExportsSublist);
57+
ArrayPrototypePushApply(allLoaders, keyedExportsSublist);
58+
}
3359

3460
// Hooks must then be added to external/public loader
3561
// (so they're triggered in userland)
36-
esmLoader.addCustomLoaders(loaders);
62+
esmLoader.addCustomLoaders(allLoaders);
63+
esmLoader.preload();
3764

3865
// Preload after loaders are added so they can be used
3966
if (preloadModules?.length) {
40-
await loadModulesInIsolation(preloadModules, loaders);
67+
await loadModulesInIsolation(parentURL, preloadModules, allLoaders);
4168
}
4269

4370
isESMInitialized = true;
4471
}
4572

46-
function loadModulesInIsolation(specifiers, loaders = []) {
73+
function loadModulesInIsolation(parentURL, specifiers, loaders = []) {
4774
if (!ArrayIsArray(specifiers) || specifiers.length === 0) { return; }
4875

49-
let cwd;
50-
try {
51-
cwd = process.cwd() + '/';
52-
} catch {
53-
cwd = 'file:///';
54-
}
55-
5676
// A separate loader instance is necessary to avoid cross-contamination
5777
// between internal Node.js and userland. For example, a module with internal
5878
// state (such as a counter) should be independent.
5979
const internalEsmLoader = new ESMLoader();
6080
internalEsmLoader.addCustomLoaders(loaders);
81+
internalEsmLoader.preload();
6182

6283
// Importation must be handled by internal loader to avoid poluting userland
6384
return internalEsmLoader.import(
6485
specifiers,
65-
pathToFileURL(cwd).href,
66-
ObjectCreate(null),
86+
parentURL,
87+
kEmptyObject,
6788
);
6889
}
6990

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

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import assert from 'node:assert';
44
import { execPath } from 'node:process';
55
import { describe, it } from 'node:test';
66

7-
87
const setupArgs = [
98
'--no-warnings',
109
'--input-type=module',
@@ -253,6 +252,23 @@ describe('ESM: loader chaining', { concurrency: true }, () => {
253252
assert.strictEqual(code, 0);
254253
});
255254

255+
it('should allow loaders to influence subsequent loader resolutions', async () => {
256+
const { code, stderr } = await spawnPromisified(
257+
execPath,
258+
[
259+
'--loader',
260+
fixtures.fileURL('es-module-loaders', 'loader-resolve-strip-xxx.mjs'),
261+
'--loader',
262+
'xxx/loader-resolve-strip-yyy.mjs',
263+
...commonArgs,
264+
],
265+
{ encoding: 'utf8', cwd: fixtures.path('es-module-loaders') },
266+
);
267+
268+
assert.strictEqual(stderr, '');
269+
assert.strictEqual(code, 0);
270+
});
271+
256272
it('should throw when the resolve chain is broken', async () => {
257273
const { code, stderr, stdout } = await spawnPromisified(
258274
execPath,

test/fixtures/es-module-loaders/loader-load-foo-or-42.mjs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1-
export async function load(url) {
1+
export async function load(url, context, next) {
2+
// This check is needed to make sure that we don't prevent the
3+
// resolution from follow-up loaders. It wouldn't be a problem
4+
// in real life because loaders aren't supposed to break the
5+
// resolution, but the ones used in our tests do, for convenience.
6+
if (url.includes('loader')) {
7+
return next(url);
8+
}
9+
210
const val = url.includes('42')
311
? '42'
412
: '"foo"';

test/fixtures/es-module-loaders/loader-load-incomplete.mjs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1-
export async function load() {
1+
export async function load(url, context, next) {
2+
// This check is needed to make sure that we don't prevent the
3+
// resolution from follow-up loaders. It wouldn't be a problem
4+
// in real life because loaders aren't supposed to break the
5+
// resolution, but the ones used in our tests do, for convenience.
6+
if (url.includes('loader')) {
7+
return next(url);
8+
}
9+
210
return {
311
format: 'module',
412
source: 'export default 42',
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
11
export async function load(url, context, next) {
2+
// This check is needed to make sure that we don't prevent the
3+
// resolution from follow-up loaders. It wouldn't be a problem
4+
// in real life because loaders aren't supposed to break the
5+
// resolution, but the ones used in our tests do, for convenience.
6+
if (url.includes('loader')) {
7+
return next(url);
8+
}
9+
210
console.log('load passthru'); // This log is deliberate
311
return next(url);
412
}

test/fixtures/es-module-loaders/loader-resolve-42.mjs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
11
export async function resolve(specifier, context, next) {
2+
// This check is needed to make sure that we don't prevent the
3+
// resolution from follow-up loaders. It wouldn't be a problem
4+
// in real life because loaders aren't supposed to break the
5+
// resolution, but the ones used in our tests do, for convenience.
6+
if (specifier.includes('loader')) {
7+
return next(specifier);
8+
}
9+
210
console.log('resolve 42'); // This log is deliberate
311
console.log('next<HookName>:', next.name); // This log is deliberate
412

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
11
export async function resolve(specifier, context, next) {
2+
// This check is needed to make sure that we don't prevent the
3+
// resolution from follow-up loaders. It wouldn't be a problem
4+
// in real life because loaders aren't supposed to break the
5+
// resolution, but the ones used in our tests do, for convenience.
6+
if (specifier.includes('loader')) {
7+
return next(specifier);
8+
}
9+
210
console.log('resolve foo'); // This log is deliberate
311
return next('file:///foo.mjs');
412
}

test/fixtures/es-module-loaders/loader-resolve-incomplete.mjs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1-
export async function resolve() {
1+
export async function resolve(specifier, context, next) {
2+
// This check is needed to make sure that we don't prevent the
3+
// resolution from follow-up loaders. It wouldn't be a problem
4+
// in real life because loaders aren't supposed to break the
5+
// resolution, but the ones used in our tests do, for convenience.
6+
if (specifier.includes('loader')) {
7+
return next(specifier);
8+
}
9+
210
return {
311
url: 'file:///incomplete-resolve-chain.js',
412
};

test/fixtures/es-module-loaders/loader-resolve-next-modified.mjs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
11
export async function resolve(url, context, next) {
2+
// This check is needed to make sure that we don't prevent the
3+
// resolution from follow-up loaders. It wouldn't be a problem
4+
// in real life because loaders aren't supposed to break the
5+
// resolution, but the ones used in our tests do, for convenience.
6+
if (url.includes('loader')) {
7+
return next(url);
8+
}
9+
210
const {
311
format,
412
url: nextUrl,
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
11
export async function resolve(specifier, context, next) {
2+
// This check is needed to make sure that we don't prevent the
3+
// resolution from follow-up loaders. It wouldn't be a problem
4+
// in real life because loaders aren't supposed to break the
5+
// resolution, but the ones used in our tests do, for convenience.
6+
if (specifier.includes('loader')) {
7+
return next(specifier);
8+
}
9+
210
console.log('resolve passthru'); // This log is deliberate
311
return next(specifier);
412
}

test/fixtures/es-module-loaders/loader-resolve-shortcircuit.mjs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1-
export async function resolve(specifier) {
1+
export async function resolve(specifier, context, next) {
2+
// This check is needed to make sure that we don't prevent the
3+
// resolution from follow-up loaders. It wouldn't be a problem
4+
// in real life because loaders aren't supposed to break the
5+
// resolution, but the ones used in our tests do, for convenience.
6+
if (specifier.includes('loader')) {
7+
return next(specifier);
8+
}
9+
210
return {
311
shortCircuit: true,
412
url: specifier,
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export async function resolve(specifier, context, nextResolve) {
2+
console.log(`loader-a`, {specifier});
3+
return nextResolve(specifier.replace(/^xxx\//, `./`));
4+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export async function resolve(specifier, context, nextResolve) {
2+
console.log(`loader-b`, {specifier});
3+
return nextResolve(specifier.replace(/^yyy\//, `./`));
4+
}

0 commit comments

Comments
 (0)