Skip to content

Commit c3c945e

Browse files
GeoffreyBoothtargos
authored andcommitted
esm: bypass CommonJS loader under --default-type
PR-URL: #49986 Backport-PR-URL: #50669 Reviewed-By: Jacob Smith <[email protected]> Reviewed-By: Antoine du Hamel <[email protected]>
1 parent a67fa2a commit c3c945e

File tree

8 files changed

+179
-41
lines changed

8 files changed

+179
-41
lines changed

doc/api/cli.md

+9-5
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,16 @@ For more info about `node inspect`, see the [debugger][] documentation.
2525

2626
The program entry point is a specifier-like string. If the string is not an
2727
absolute path, it's resolved as a relative path from the current working
28-
directory. That path is then resolved by [CommonJS][] module loader. If no
29-
corresponding file is found, an error is thrown.
28+
directory. That path is then resolved by [CommonJS][] module loader, or by the
29+
[ES module loader][Modules loaders] if [`--experimental-default-type=module`][]
30+
is passed. If no corresponding file is found, an error is thrown.
3031

3132
If a file is found, its path will be passed to the
3233
[ES module loader][Modules loaders] under any of the following conditions:
3334

3435
* The program was started with a command-line flag that forces the entry
35-
point to be loaded with ECMAScript module loader.
36+
point to be loaded with ECMAScript module loader, such as `--import` or
37+
[`--experimental-default-type=module`][].
3638
* The file has an `.mjs` extension.
3739
* The file does not have a `.cjs` extension, and the nearest parent
3840
`package.json` file contains a top-level [`"type"`][] field with a value of
@@ -45,8 +47,9 @@ Otherwise, the file is loaded using the CommonJS module loader. See
4547

4648
When loading, the [ES module loader][Modules loaders] loads the program
4749
entry point, the `node` command will accept as input only files with `.js`,
48-
`.mjs`, or `.cjs` extensions; and with `.wasm` extensions when
49-
[`--experimental-wasm-modules`][] is enabled.
50+
`.mjs`, or `.cjs` extensions; with `.wasm` extensions when
51+
[`--experimental-wasm-modules`][] is enabled; and with no extension when
52+
[`--experimental-default-type=module`][] is passed.
5053

5154
## Options
5255

@@ -2417,6 +2420,7 @@ done
24172420
[`"type"`]: packages.md#type
24182421
[`--cpu-prof-dir`]: #--cpu-prof-dir
24192422
[`--diagnostic-dir`]: #--diagnostic-dirdirectory
2423+
[`--experimental-default-type=module`]: #--experimental-default-typetype
24202424
[`--experimental-wasm-modules`]: #--experimental-wasm-modules
24212425
[`--heap-prof-dir`]: #--heap-prof-dir
24222426
[`--import`]: #--importmodule

lib/internal/main/run_main_module.js

+14-8
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,24 @@ const {
66
prepareMainThreadExecution,
77
markBootstrapComplete,
88
} = require('internal/process/pre_execution');
9+
const { getOptionValue } = require('internal/options');
910

10-
prepareMainThreadExecution(true);
11+
const mainEntry = prepareMainThreadExecution(true);
1112

1213
markBootstrapComplete();
1314

1415
// Necessary to reset RegExp statics before user code runs.
1516
RegExpPrototypeExec(/^/, '');
1617

17-
// Note: this loads the module through the ESM loader if the module is
18-
// determined to be an ES module. This hangs from the CJS module loader
19-
// because we currently allow monkey-patching of the module loaders
20-
// in the preloaded scripts through require('module').
21-
// runMain here might be monkey-patched by users in --require.
22-
// XXX: the monkey-patchability here should probably be deprecated.
23-
require('internal/modules/cjs/loader').Module.runMain(process.argv[1]);
18+
if (getOptionValue('--experimental-default-type') === 'module') {
19+
require('internal/modules/run_main').executeUserEntryPoint(mainEntry);
20+
} else {
21+
/**
22+
* To support legacy monkey-patching of `Module.runMain`, we call `runMain` here to have the CommonJS loader begin
23+
* the execution of the main entry point, even if the ESM loader immediately takes over because the main entry is an
24+
* ES module or one of the other opt-in conditions (such as the use of `--import`) are met. Users can monkey-patch
25+
* before the main entry point is loaded by doing so via scripts loaded through `--require`. This monkey-patchability
26+
* is undesirable and is removed in `--experimental-default-type=module` mode.
27+
*/
28+
require('internal/modules/cjs/loader').Module.runMain(mainEntry);
29+
}

lib/internal/modules/esm/resolve.js

+22-11
Original file line numberDiff line numberDiff line change
@@ -1204,17 +1204,7 @@ function defaultResolve(specifier, context = {}) {
12041204
if (StringPrototypeStartsWith(specifier, 'file://')) {
12051205
specifier = fileURLToPath(specifier);
12061206
}
1207-
const found = resolveAsCommonJS(specifier, parentURL);
1208-
if (found) {
1209-
// Modify the stack and message string to include the hint
1210-
const lines = StringPrototypeSplit(error.stack, '\n');
1211-
const hint = `Did you mean to import ${found}?`;
1212-
error.stack =
1213-
ArrayPrototypeShift(lines) + '\n' +
1214-
hint + '\n' +
1215-
ArrayPrototypeJoin(lines, '\n');
1216-
error.message += `\n${hint}`;
1217-
}
1207+
decorateErrorWithCommonJSHints(error, specifier, parentURL);
12181208
}
12191209
throw error;
12201210
}
@@ -1227,7 +1217,28 @@ function defaultResolve(specifier, context = {}) {
12271217
};
12281218
}
12291219

1220+
/**
1221+
* Decorates the given error with a hint for CommonJS modules.
1222+
* @param {Error} error - The error to decorate.
1223+
* @param {string} specifier - The specifier that was attempted to be imported.
1224+
* @param {string} parentURL - The URL of the parent module.
1225+
*/
1226+
function decorateErrorWithCommonJSHints(error, specifier, parentURL) {
1227+
const found = resolveAsCommonJS(specifier, parentURL);
1228+
if (found) {
1229+
// Modify the stack and message string to include the hint
1230+
const lines = StringPrototypeSplit(error.stack, '\n');
1231+
const hint = `Did you mean to import ${found}?`;
1232+
error.stack =
1233+
ArrayPrototypeShift(lines) + '\n' +
1234+
hint + '\n' +
1235+
ArrayPrototypeJoin(lines, '\n');
1236+
error.message += `\n${hint}`;
1237+
}
1238+
}
1239+
12301240
module.exports = {
1241+
decorateErrorWithCommonJSHints,
12311242
defaultResolve,
12321243
encodedSepRegEx,
12331244
getPackageScopeConfig,

lib/internal/modules/run_main.js

+28-11
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
'use strict';
22

33
const {
4-
ObjectCreate,
54
StringPrototypeEndsWith,
65
} = primordials;
76

@@ -13,17 +12,33 @@ const path = require('path');
1312
* @param {string} main - Entry point path
1413
*/
1514
function resolveMainPath(main) {
16-
// Note extension resolution for the main entry point can be deprecated in a
17-
// future major.
18-
// Module._findPath is monkey-patchable here.
19-
const { Module } = require('internal/modules/cjs/loader');
20-
let mainPath = Module._findPath(path.resolve(main), null, true);
15+
const defaultType = getOptionValue('--experimental-default-type');
16+
/** @type {string} */
17+
let mainPath;
18+
if (defaultType === 'module') {
19+
if (getOptionValue('--preserve-symlinks-main')) { return; }
20+
mainPath = path.resolve(main);
21+
} else {
22+
// Extension searching for the main entry point is supported only in legacy mode.
23+
// Module._findPath is monkey-patchable here.
24+
const { Module } = require('internal/modules/cjs/loader');
25+
mainPath = Module._findPath(path.resolve(main), null, true);
26+
}
2127
if (!mainPath) { return; }
2228

2329
const preserveSymlinksMain = getOptionValue('--preserve-symlinks-main');
2430
if (!preserveSymlinksMain) {
2531
const { toRealPath } = require('internal/modules/helpers');
26-
mainPath = toRealPath(mainPath);
32+
try {
33+
mainPath = toRealPath(mainPath);
34+
} catch (err) {
35+
if (defaultType === 'module' && err?.code === 'ENOENT') {
36+
const { decorateErrorWithCommonJSHints } = require('internal/modules/esm/resolve');
37+
const { getCWDURL } = require('internal/util');
38+
decorateErrorWithCommonJSHints(err, mainPath, getCWDURL());
39+
}
40+
throw err;
41+
}
2742
}
2843

2944
return mainPath;
@@ -34,6 +49,8 @@ function resolveMainPath(main) {
3449
* @param {string} mainPath - Absolute path to the main entry point
3550
*/
3651
function shouldUseESMLoader(mainPath) {
52+
if (getOptionValue('--experimental-default-type') === 'module') { return true; }
53+
3754
/**
3855
* @type {string[]} userLoaders A list of custom loaders registered by the user
3956
* (or an empty list when none have been registered).
@@ -69,11 +86,10 @@ function shouldUseESMLoader(mainPath) {
6986
function runMainESM(mainPath) {
7087
const { loadESM } = require('internal/process/esm_loader');
7188
const { pathToFileURL } = require('internal/url');
89+
const main = pathToFileURL(mainPath).href;
7290

7391
handleMainPromise(loadESM((esmLoader) => {
74-
const main = path.isAbsolute(mainPath) ?
75-
pathToFileURL(mainPath).href : mainPath;
76-
return esmLoader.import(main, undefined, ObjectCreate(null));
92+
return esmLoader.import(main, undefined, { __proto__: null });
7793
}));
7894
}
7995

@@ -97,8 +113,9 @@ async function handleMainPromise(promise) {
97113
* Parse the CLI main entry point string and run it.
98114
* For backwards compatibility, we have to run a bunch of monkey-patchable code that belongs to the CJS loader (exposed
99115
* by `require('module')`) even when the entry point is ESM.
116+
* This monkey-patchable code is bypassed under `--experimental-default-type=module`.
100117
* Because of backwards compatibility, this function is exposed publicly via `import { runMain } from 'node:module'`.
101-
* @param {string} main - Resolved absolute path for the main entry point, if found
118+
* @param {string} main - First positional CLI argument, such as `'entry.js'` from `node entry.js`
102119
*/
103120
function executeUserEntryPoint(main = process.argv[1]) {
104121
const resolvedMain = resolveMainPath(main);

lib/internal/process/pre_execution.js

+11-4
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ const {
3737
} = require('internal/v8/startup_snapshot');
3838

3939
function prepareMainThreadExecution(expandArgv1 = false, initializeModules = true) {
40-
prepareExecution({
40+
return prepareExecution({
4141
expandArgv1,
4242
initializeModules,
4343
isMainThread: true,
@@ -58,8 +58,8 @@ function prepareExecution(options) {
5858
refreshRuntimeOptions();
5959
reconnectZeroFillToggle();
6060

61-
// Patch the process object with legacy properties and normalizations
62-
patchProcessObject(expandArgv1);
61+
// Patch the process object and get the resolved main entry point.
62+
const mainEntry = patchProcessObject(expandArgv1);
6363
setupTraceCategoryState();
6464
setupPerfHooks();
6565
setupInspectorHooks();
@@ -113,6 +113,8 @@ function prepareExecution(options) {
113113
if (initializeModules) {
114114
setupUserModules();
115115
}
116+
117+
return mainEntry;
116118
}
117119

118120
function setupSymbolDisposePolyfill() {
@@ -167,14 +169,17 @@ function patchProcessObject(expandArgv1) {
167169
process._exiting = false;
168170
process.argv[0] = process.execPath;
169171

172+
/** @type {string} */
173+
let mainEntry;
170174
// If requested, update process.argv[1] to replace whatever the user provided with the resolved absolute file path of
171175
// the entry point.
172176
if (expandArgv1 && process.argv[1] &&
173177
!StringPrototypeStartsWith(process.argv[1], '-')) {
174178
// Expand process.argv[1] into a full path.
175179
const path = require('path');
176180
try {
177-
process.argv[1] = path.resolve(process.argv[1]);
181+
mainEntry = path.resolve(process.argv[1]);
182+
process.argv[1] = mainEntry;
178183
} catch {
179184
// Continue regardless of error.
180185
}
@@ -201,6 +206,8 @@ function patchProcessObject(expandArgv1) {
201206
addReadOnlyProcessAlias('traceDeprecation', '--trace-deprecation');
202207
addReadOnlyProcessAlias('_breakFirstLine', '--inspect-brk', false);
203208
addReadOnlyProcessAlias('_breakNodeFirstLine', '--inspect-brk-node', false);
209+
210+
return mainEntry;
204211
}
205212

206213
function addReadOnlyProcessAlias(name, option, enumerable = true) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { spawnPromisified } from '../common/index.mjs';
2+
import * as fixtures from '../common/fixtures.mjs';
3+
import { describe, it } from 'node:test';
4+
import { match, strictEqual } from 'node:assert';
5+
6+
describe('--experimental-default-type=module should not support extension searching', { concurrency: true }, () => {
7+
it('should support extension searching under --experimental-default-type=commonjs', async () => {
8+
const { code, signal, stdout, stderr } = await spawnPromisified(process.execPath, [
9+
'--experimental-default-type=commonjs',
10+
'index',
11+
], {
12+
cwd: fixtures.path('es-modules/package-without-type'),
13+
});
14+
15+
strictEqual(stdout, 'package-without-type\n');
16+
strictEqual(stderr, '');
17+
strictEqual(code, 0);
18+
strictEqual(signal, null);
19+
});
20+
21+
it('should error with implicit extension under --experimental-default-type=module', async () => {
22+
const { code, signal, stdout, stderr } = await spawnPromisified(process.execPath, [
23+
'--experimental-default-type=module',
24+
'index',
25+
], {
26+
cwd: fixtures.path('es-modules/package-without-type'),
27+
});
28+
29+
match(stderr, /ENOENT.*Did you mean to import .*index\.js\?/s);
30+
strictEqual(stdout, '');
31+
strictEqual(code, 1);
32+
strictEqual(signal, null);
33+
});
34+
});
35+
36+
describe('--experimental-default-type=module should not parse paths as URLs', { concurrency: true }, () => {
37+
it('should not parse a `?` in a filename as starting a query string', async () => {
38+
const { code, signal, stdout, stderr } = await spawnPromisified(process.execPath, [
39+
'--experimental-default-type=module',
40+
'file#1.js',
41+
], {
42+
cwd: fixtures.path('es-modules/package-without-type'),
43+
});
44+
45+
strictEqual(stderr, '');
46+
strictEqual(stdout, 'file#1\n');
47+
strictEqual(code, 0);
48+
strictEqual(signal, null);
49+
});
50+
51+
it('should resolve `..`', async () => {
52+
const { code, signal, stdout, stderr } = await spawnPromisified(process.execPath, [
53+
'--experimental-default-type=module',
54+
'../package-without-type/file#1.js',
55+
], {
56+
cwd: fixtures.path('es-modules/package-without-type'),
57+
});
58+
59+
strictEqual(stderr, '');
60+
strictEqual(stdout, 'file#1\n');
61+
strictEqual(code, 0);
62+
strictEqual(signal, null);
63+
});
64+
65+
it('should allow a leading `./`', async () => {
66+
const { code, signal, stdout, stderr } = await spawnPromisified(process.execPath, [
67+
'--experimental-default-type=module',
68+
'./file#1.js',
69+
], {
70+
cwd: fixtures.path('es-modules/package-without-type'),
71+
});
72+
73+
strictEqual(stderr, '');
74+
strictEqual(stdout, 'file#1\n');
75+
strictEqual(code, 0);
76+
strictEqual(signal, null);
77+
});
78+
79+
it('should not require a leading `./`', async () => {
80+
const { code, signal, stdout, stderr } = await spawnPromisified(process.execPath, [
81+
'--experimental-default-type=module',
82+
'file#1.js',
83+
], {
84+
cwd: fixtures.path('es-modules/package-without-type'),
85+
});
86+
87+
strictEqual(stderr, '');
88+
strictEqual(stdout, 'file#1\n');
89+
strictEqual(code, 0);
90+
strictEqual(signal, null);
91+
});
92+
});

test/fixtures/errors/force_colors.snapshot

+2-2
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Error: Should include grayed stack trace
88
 at Module._extensions..js (node:internal*modules*cjs*loader:1414:10)
99
 at Module.load (node:internal*modules*cjs*loader:1197:32)
1010
 at Module._load (node:internal*modules*cjs*loader:1013:12)
11-
 at Function.executeUserEntryPoint [as runMain] (node:internal*modules*run_main:109:12)
12-
 at node:internal*main*run_main_module:23:47
11+
 at Function.executeUserEntryPoint [as runMain] (node:internal*modules*run_main:128:12)
12+
 at node:internal*main*run_main_module:28:49
1313

1414
Node.js *
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
console.log('file#1');

0 commit comments

Comments
 (0)