Skip to content

Commit 62bee79

Browse files
module: add --experimental-ext
1 parent 8ec29f2 commit 62bee79

24 files changed

+349
-41
lines changed

doc/api/cli.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1053,6 +1053,19 @@ added:
10531053

10541054
Enable exposition of [EventSource Web API][] on the global scope.
10551055

1056+
### `--experimental-ext=ext`
1057+
1058+
<!-- YAML
1059+
added: REPLACEME
1060+
-->
1061+
1062+
> Stability: 1.0 - Early development
1063+
1064+
Overrides the default entrypoint file extension resolution with a custom file extension.
1065+
This is particularly useful for entrypoints that don't have a file extension.
1066+
The allowed values are `js`, `cjs`, `mjs`, `ts`, `cts`, `mts`.
1067+
The `ts` values are not available with the flag `--no-experimental-strip-types`.
1068+
10561069
### `--experimental-import-meta-resolve`
10571070

10581071
<!-- YAML
@@ -3449,6 +3462,7 @@ one is included in the list below.
34493462
* `--experimental-addon-modules`
34503463
* `--experimental-detect-module`
34513464
* `--experimental-eventsource`
3465+
* `--experimental-ext`
34523466
* `--experimental-import-meta-resolve`
34533467
* `--experimental-json-modules`
34543468
* `--experimental-loader`

doc/node-config-schema.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,9 @@
136136
"experimental-eventsource": {
137137
"type": "boolean"
138138
},
139+
"experimental-ext": {
140+
"type": "string"
141+
},
139142
"experimental-global-navigator": {
140143
"type": "boolean"
141144
},

doc/node.1

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,10 @@ Enable code coverage in the test runner.
196196
Enable module mocking in the test runner.
197197
.
198198
.It Fl -experimental-transform-types
199-
Enable transformation of TypeScript-only syntax into JavaScript code.
199+
Override extension format for the entrypoint.
200+
.
201+
.It Fl -experimental-ext
202+
Enable experimental support for the EventSource Web API.
200203
.
201204
.It Fl -experimental-eventsource
202205
Enable experimental support for the EventSource Web API.

lib/internal/modules/cjs/loader.js

Lines changed: 70 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -578,13 +578,22 @@ function tryExtensions(basePath, exts, isMain) {
578578
/**
579579
* Find the longest (possibly multi-dot) extension registered in `Module._extensions`.
580580
* @param {string} filename The filename to find the longest registered extension for.
581+
* @param {boolean} isMain Whether the module is the main module.
581582
* @returns {string}
582583
*/
583-
function findLongestRegisteredExtension(filename) {
584+
function findLongestRegisteredExtension(filename, isMain = false) {
584585
const name = path.basename(filename);
585586
let currentExtension;
586587
let index;
587588
let startIndex = 0;
589+
// We need to check that --experimental-ext is an entry point
590+
// And doesn't already have a loader (example: .json or .node)
591+
// If unknown let the .js loader handle it
592+
const experimentalExtension = getOptionValue('--experimental-ext');
593+
if (experimentalExtension && isMain && !Module._extensions[`.${experimentalExtension}`]) {
594+
return '.js';
595+
}
596+
588597
while ((index = StringPrototypeIndexOf(name, '.', startIndex)) !== -1) {
589598
startIndex = index + 1;
590599
if (index === 0) { continue; } // Skip dotfiles like .gitignore
@@ -1474,7 +1483,7 @@ Module.prototype.load = function(filename) {
14741483
this.filename ??= filename;
14751484
this.paths ??= Module._nodeModulePaths(path.dirname(filename));
14761485

1477-
const extension = findLongestRegisteredExtension(filename);
1486+
const extension = findLongestRegisteredExtension(filename, this[kIsMainSymbol]);
14781487

14791488
Module._extensions[extension](this, filename);
14801489
this.loaded = true;
@@ -1849,39 +1858,71 @@ function getRequireESMError(mod, pkg, content, filename) {
18491858
return err;
18501859
}
18511860

1861+
/**
1862+
* Get the module format for a given file extension.
1863+
* @param {string} filename The name of the file.
1864+
* @param {boolean} isMain Whether the module is the main module.
1865+
* @returns {object} An object containing the format and package information.
1866+
*/
1867+
function getModuleFormatForExtension(filename, isMain = false) {
1868+
let pkg;
1869+
const extensionMap = {
1870+
'.cjs': () => 'commonjs',
1871+
'.mjs': () => 'module',
1872+
'.js': (filename) => {
1873+
pkg = packageJsonReader.getNearestParentPackageJSON(filename);
1874+
const typeFromPjson = pkg?.data.type;
1875+
if (typeFromPjson === 'module' || typeFromPjson === 'commonjs' || !typeFromPjson) {
1876+
return typeFromPjson;
1877+
}
1878+
},
1879+
'.mts': () => (getOptionValue('--experimental-strip-types') ? 'module-typescript' : undefined),
1880+
'.cts': () => (getOptionValue('--experimental-strip-types') ? 'commonjs-typescript' : undefined),
1881+
'.ts': (filename) => {
1882+
if (!getOptionValue('--experimental-strip-types')) { return; }
1883+
pkg = packageJsonReader.getNearestParentPackageJSON(filename);
1884+
switch (pkg?.data.type) {
1885+
case 'module':
1886+
return 'module-typescript';
1887+
case 'commonjs':
1888+
return 'commonjs-typescript';
1889+
default:
1890+
return 'typescript';
1891+
}
1892+
},
1893+
};
1894+
1895+
// Allow experimental extension via --experimental-ext
1896+
const experimentalExtension = getOptionValue('--experimental-ext');
1897+
if (experimentalExtension) {
1898+
emitExperimentalWarning('--experimental-ext');
1899+
if (isMain) {
1900+
const format = extensionMap[`.${experimentalExtension}`](filename);
1901+
return { format, pkg };
1902+
}
1903+
}
1904+
1905+
const keys = ObjectKeys(extensionMap);
1906+
for (let i = 0; i < keys.length; i++) {
1907+
const ext = keys[i];
1908+
if (StringPrototypeEndsWith(filename, ext)) {
1909+
const format = extensionMap[ext](filename);
1910+
return { format, pkg };
1911+
}
1912+
}
1913+
// Unknown extension, return undefined to let loadSource handle it.
1914+
return { pkg, format: undefined };
1915+
}
1916+
18521917
/**
18531918
* Built-in handler for `.js` files.
18541919
* @param {Module} module The module to compile
18551920
* @param {string} filename The file path of the module
18561921
*/
18571922
Module._extensions['.js'] = function(module, filename) {
1858-
let format, pkg;
1859-
const tsEnabled = getOptionValue('--experimental-strip-types');
1860-
if (StringPrototypeEndsWith(filename, '.cjs')) {
1861-
format = 'commonjs';
1862-
} else if (StringPrototypeEndsWith(filename, '.mjs')) {
1863-
format = 'module';
1864-
} else if (StringPrototypeEndsWith(filename, '.js')) {
1865-
pkg = packageJsonReader.getNearestParentPackageJSON(filename);
1866-
const typeFromPjson = pkg?.data.type;
1867-
if (typeFromPjson === 'module' || typeFromPjson === 'commonjs' || !typeFromPjson) {
1868-
format = typeFromPjson;
1869-
}
1870-
} else if (StringPrototypeEndsWith(filename, '.mts') && tsEnabled) {
1871-
format = 'module-typescript';
1872-
} else if (StringPrototypeEndsWith(filename, '.cts') && tsEnabled) {
1873-
format = 'commonjs-typescript';
1874-
} else if (StringPrototypeEndsWith(filename, '.ts') && tsEnabled) {
1875-
pkg = packageJsonReader.getNearestParentPackageJSON(filename);
1876-
const typeFromPjson = pkg?.data.type;
1877-
if (typeFromPjson === 'module') {
1878-
format = 'module-typescript';
1879-
} else if (typeFromPjson === 'commonjs') {
1880-
format = 'commonjs-typescript';
1881-
} else {
1882-
format = 'typescript';
1883-
}
1884-
}
1923+
// If the file is extensionless, or the extension is unknown
1924+
// it will fall into this handler.
1925+
const { format, pkg } = getModuleFormatForExtension(filename, module[kIsMainSymbol]);
18851926
const { source, format: loadedFormat } = loadSource(module, filename, format);
18861927
// Function require shouldn't be used in ES modules when require(esm) is disabled.
18871928
if ((loadedFormat === 'module' || loadedFormat === 'module-typescript') &&

lib/internal/modules/esm/get_format.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,9 @@ function warnTypelessPackageJsonFile(pjsonPath, url) {
112112
*/
113113
function getFileProtocolModuleFormat(url, context = { __proto__: null }, ignoreErrors) {
114114
const { source } = context;
115-
const ext = extname(url);
115+
const experimentalExtension = getOptionValue('--experimental-ext');
116+
const isMain = context.parentURL === undefined; // If it has a parent is not an entrypoint
117+
const ext = experimentalExtension && isMain ? `.${experimentalExtension}` : extname(url);
116118

117119
if (ext === '.js') {
118120
const { type: packageType, pjsonPath, exists: foundPackageJson } = getPackageScopeConfig(url);

lib/internal/modules/esm/translators.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ const {
4545
resolveForCJSWithHooks,
4646
loadSourceForCJSWithHooks,
4747
populateCJSExportsFromESM,
48+
kIsMainSymbol,
4849
} = require('internal/modules/cjs/loader');
4950
const { fileURLToPath, pathToFileURL, URL } = require('internal/url');
5051
let debug = require('internal/util/debuglog').debuglog('esm', (fn) => {
@@ -427,7 +428,8 @@ function cjsPreparseModuleExports(filename, source, format) {
427428
}
428429

429430
if (format === 'commonjs' ||
430-
(!BuiltinModule.normalizeRequirableId(resolved) && findLongestRegisteredExtension(resolved) === '.js')) {
431+
(!BuiltinModule.normalizeRequirableId(resolved) &&
432+
findLongestRegisteredExtension(resolved, module[kIsMainSymbol]) === '.js')) {
431433
const { exportNames: reexportNames } = cjsPreparseModuleExports(resolved, undefined, format);
432434
for (const name of reexportNames) {
433435
exportNames.add(name);

lib/internal/modules/run_main.js

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ const { getNearestParentPackageJSONType } = internalBinding('modules');
99
const { getOptionValue } = require('internal/options');
1010
const path = require('path');
1111
const { pathToFileURL, URL } = require('internal/url');
12-
const { kEmptyObject, getCWDURL } = require('internal/util');
12+
const { emitExperimentalWarning, kEmptyObject, getCWDURL } = require('internal/util');
1313
const {
1414
hasUncaughtExceptionCaptureCallback,
1515
} = require('internal/process/execution');
@@ -62,17 +62,30 @@ function shouldUseESMLoader(mainPath) {
6262
const userImports = getOptionValue('--import');
6363
if (userLoaders.length > 0 || userImports.length > 0) { return true; }
6464

65-
// Determine the module format of the entry point.
66-
if (mainPath && StringPrototypeEndsWith(mainPath, '.mjs')) { return true; }
67-
if (mainPath && StringPrototypeEndsWith(mainPath, '.wasm')) { return true; }
68-
if (!mainPath || StringPrototypeEndsWith(mainPath, '.cjs')) { return false; }
65+
let ext;
66+
const experimentalExtension = getOptionValue('--experimental-ext');
67+
if (experimentalExtension) {
68+
emitExperimentalWarning('--experimental-ext');
69+
ext = `.${experimentalExtension}`;
70+
} else if (mainPath) {
71+
const candidates = ['.mjs', '.wasm', '.cjs', '.cts', '.mts'];
72+
for (let i = 0; i < candidates.length; i++) {
73+
if (StringPrototypeEndsWith(mainPath, candidates[i])) {
74+
ext = candidates[i];
75+
break;
76+
}
77+
}
6978

70-
if (getOptionValue('--experimental-strip-types')) {
71-
if (!mainPath || StringPrototypeEndsWith(mainPath, '.cts')) { return false; }
72-
// This will likely change in the future to start with commonjs loader by default
73-
if (mainPath && StringPrototypeEndsWith(mainPath, '.mts')) { return true; }
79+
if (ext === '.mjs' || ext === '.wasm') { return true; }
80+
if (ext === '.cjs') { return false; }
81+
if (getOptionValue('--experimental-strip-types')) {
82+
// This will likely change in the future to start with commonjs loader by default
83+
if (ext === '.mts') { return true; }
84+
}
7485
}
7586

87+
if (!mainPath || ext === '.cts') { return false; }
88+
7689
const type = getNearestParentPackageJSONType(mainPath);
7790

7891
// No package.json or no `type` field.

src/node_options.cc

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,16 @@ void EnvironmentOptions::CheckOptions(std::vector<std::string>* errors,
173173
}
174174
}
175175

176+
if (!experimental_ext.empty()) {
177+
if (experimental_ext != "js" && experimental_ext != "mjs" &&
178+
experimental_ext != "cjs" && experimental_ext != "ts" &&
179+
experimental_ext != "cts" && experimental_ext != "mts") {
180+
errors->push_back(
181+
"--experimental-ext must be \"js\", \"mjs\", \"cjs\", \"ts\", "
182+
"\"cts\" or \"mts\"");
183+
}
184+
}
185+
176186
if (syntax_check_only && has_eval_string) {
177187
errors->push_back("either --check or --eval can be used, not both");
178188
}
@@ -1051,6 +1061,10 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() {
10511061
"ES module to preload (option can be repeated)",
10521062
&EnvironmentOptions::preload_esm_modules,
10531063
kAllowedInEnvvar);
1064+
AddOption("--experimental-ext",
1065+
"Override extension format on entrypoint",
1066+
&EnvironmentOptions::experimental_ext,
1067+
kAllowedInEnvvar);
10541068
AddOption("--experimental-strip-types",
10551069
"Experimental type-stripping for TypeScript files.",
10561070
&EnvironmentOptions::experimental_strip_types,

src/node_options.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,7 @@ class EnvironmentOptions : public Options {
260260

261261
bool experimental_strip_types = true;
262262
bool experimental_transform_types = false;
263+
std::string experimental_ext; // Value of --experimental-ext
263264

264265
std::vector<std::string> user_argv;
265266

0 commit comments

Comments
 (0)