Skip to content

Commit 19b470f

Browse files
esm: bypass CommonJS loader under --default-type
PR-URL: #49986 Reviewed-By: Jacob Smith <[email protected]> Reviewed-By: Antoine du Hamel <[email protected]>
1 parent b55adfb commit 19b470f

File tree

7 files changed

+176
-37
lines changed

7 files changed

+176
-37
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

@@ -2741,6 +2744,7 @@ done
27412744
[`--allow-worker`]: #--allow-worker
27422745
[`--cpu-prof-dir`]: #--cpu-prof-dir
27432746
[`--diagnostic-dir`]: #--diagnostic-dirdirectory
2747+
[`--experimental-default-type=module`]: #--experimental-default-typetype
27442748
[`--experimental-sea-config`]: single-executable-applications.md#generating-single-executable-preparation-blobs
27452749
[`--experimental-wasm-modules`]: #--experimental-wasm-modules
27462750
[`--heap-prof-dir`]: #--heap-prof-dir

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
@@ -1132,17 +1132,7 @@ function defaultResolve(specifier, context = {}) {
11321132
if (StringPrototypeStartsWith(specifier, 'file://')) {
11331133
specifier = fileURLToPath(specifier);
11341134
}
1135-
const found = resolveAsCommonJS(specifier, parentURL);
1136-
if (found) {
1137-
// Modify the stack and message string to include the hint
1138-
const lines = StringPrototypeSplit(error.stack, '\n');
1139-
const hint = `Did you mean to import ${found}?`;
1140-
error.stack =
1141-
ArrayPrototypeShift(lines) + '\n' +
1142-
hint + '\n' +
1143-
ArrayPrototypeJoin(lines, '\n');
1144-
error.message += `\n${hint}`;
1145-
}
1135+
decorateErrorWithCommonJSHints(error, specifier, parentURL);
11461136
}
11471137
throw error;
11481138
}
@@ -1156,7 +1146,28 @@ function defaultResolve(specifier, context = {}) {
11561146
};
11571147
}
11581148

1149+
/**
1150+
* Decorates the given error with a hint for CommonJS modules.
1151+
* @param {Error} error - The error to decorate.
1152+
* @param {string} specifier - The specifier that was attempted to be imported.
1153+
* @param {string} parentURL - The URL of the parent module.
1154+
*/
1155+
function decorateErrorWithCommonJSHints(error, specifier, parentURL) {
1156+
const found = resolveAsCommonJS(specifier, parentURL);
1157+
if (found) {
1158+
// Modify the stack and message string to include the hint
1159+
const lines = StringPrototypeSplit(error.stack, '\n');
1160+
const hint = `Did you mean to import ${found}?`;
1161+
error.stack =
1162+
ArrayPrototypeShift(lines) + '\n' +
1163+
hint + '\n' +
1164+
ArrayPrototypeJoin(lines, '\n');
1165+
error.message += `\n${hint}`;
1166+
}
1167+
}
1168+
11591169
module.exports = {
1170+
decorateErrorWithCommonJSHints,
11601171
defaultResolve,
11611172
encodedSepRegEx,
11621173
getPackageScopeConfig,

lib/internal/modules/run_main.js

+27-9
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,33 @@ const path = require('path');
1212
* @param {string} main - Entry point path
1313
*/
1414
function resolveMainPath(main) {
15-
// Note extension resolution for the main entry point can be deprecated in a
16-
// future major.
17-
// Module._findPath is monkey-patchable here.
18-
const { Module } = require('internal/modules/cjs/loader');
19-
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+
}
2027
if (!mainPath) { return; }
2128

2229
const preserveSymlinksMain = getOptionValue('--preserve-symlinks-main');
2330
if (!preserveSymlinksMain) {
2431
const { toRealPath } = require('internal/modules/helpers');
25-
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+
}
2642
}
2743

2844
return mainPath;
@@ -33,6 +49,8 @@ function resolveMainPath(main) {
3349
* @param {string} mainPath - Absolute path to the main entry point
3450
*/
3551
function shouldUseESMLoader(mainPath) {
52+
if (getOptionValue('--experimental-default-type') === 'module') { return true; }
53+
3654
/**
3755
* @type {string[]} userLoaders A list of custom loaders registered by the user
3856
* (or an empty list when none have been registered).
@@ -62,10 +80,9 @@ function shouldUseESMLoader(mainPath) {
6280
function runMainESM(mainPath) {
6381
const { loadESM } = require('internal/process/esm_loader');
6482
const { pathToFileURL } = require('internal/url');
83+
const main = pathToFileURL(mainPath).href;
6584

6685
handleMainPromise(loadESM((esmLoader) => {
67-
const main = path.isAbsolute(mainPath) ?
68-
pathToFileURL(mainPath).href : mainPath;
6986
return esmLoader.import(main, undefined, { __proto__: null });
7087
}));
7188
}
@@ -90,8 +107,9 @@ async function handleMainPromise(promise) {
90107
* Parse the CLI main entry point string and run it.
91108
* For backwards compatibility, we have to run a bunch of monkey-patchable code that belongs to the CJS loader (exposed
92109
* by `require('module')`) even when the entry point is ESM.
110+
* This monkey-patchable code is bypassed under `--experimental-default-type=module`.
93111
* Because of backwards compatibility, this function is exposed publicly via `import { runMain } from 'node:module'`.
94-
* @param {string} main - Resolved absolute path for the main entry point, if found
112+
* @param {string} main - First positional CLI argument, such as `'entry.js'` from `node entry.js`
95113
*/
96114
function executeUserEntryPoint(main = process.argv[1]) {
97115
const resolvedMain = resolveMainPath(main);

lib/internal/process/pre_execution.js

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

5454
function prepareMainThreadExecution(expandArgv1 = false, initializeModules = true) {
55-
prepareExecution({
55+
return prepareExecution({
5656
expandArgv1,
5757
initializeModules,
5858
isMainThread: true,
@@ -73,8 +73,8 @@ function prepareExecution(options) {
7373
refreshRuntimeOptions();
7474
reconnectZeroFillToggle();
7575

76-
// Patch the process object with legacy properties and normalizations
77-
patchProcessObject(expandArgv1);
76+
// Patch the process object and get the resolved main entry point.
77+
const mainEntry = patchProcessObject(expandArgv1);
7878
setupTraceCategoryState();
7979
setupInspectorHooks();
8080
setupWarningHandler();
@@ -131,6 +131,8 @@ function prepareExecution(options) {
131131
if (initializeModules) {
132132
setupUserModules();
133133
}
134+
135+
return mainEntry;
134136
}
135137

136138
function setupSymbolDisposePolyfill() {
@@ -202,14 +204,17 @@ function patchProcessObject(expandArgv1) {
202204
process._exiting = false;
203205
process.argv[0] = process.execPath;
204206

207+
/** @type {string} */
208+
let mainEntry;
205209
// If requested, update process.argv[1] to replace whatever the user provided with the resolved absolute file path of
206210
// the entry point.
207211
if (expandArgv1 && process.argv[1] &&
208212
!StringPrototypeStartsWith(process.argv[1], '-')) {
209213
// Expand process.argv[1] into a full path.
210214
const path = require('path');
211215
try {
212-
process.argv[1] = path.resolve(process.argv[1]);
216+
mainEntry = path.resolve(process.argv[1]);
217+
process.argv[1] = mainEntry;
213218
} catch {
214219
// Continue regardless of error.
215220
}
@@ -236,6 +241,8 @@ function patchProcessObject(expandArgv1) {
236241
addReadOnlyProcessAlias('traceDeprecation', '--trace-deprecation');
237242
addReadOnlyProcessAlias('_breakFirstLine', '--inspect-brk', false);
238243
addReadOnlyProcessAlias('_breakNodeFirstLine', '--inspect-brk-node', false);
244+
245+
return mainEntry;
239246
}
240247

241248
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+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
console.log('file#1');

0 commit comments

Comments
 (0)