Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

module: support ES modules without file extension within module scope #49531

Closed
wants to merge 8 commits into from
9 changes: 9 additions & 0 deletions doc/api/cli.md
Expand Up @@ -593,6 +593,14 @@ added: v11.8.0

Use the specified file as a security policy.

### `--experimental-extensionless-modules`

<!-- YAML
added: REPLACEME
-->

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's missing the stability: 1.1 Active Development

Enable extensionless modules support.

### `--no-experimental-fetch`

<!-- YAML
Expand Down Expand Up @@ -2199,6 +2207,7 @@ Node.js options that are allowed are:
* `--enable-network-family-autoselection`
* `--enable-source-maps`
* `--experimental-abortcontroller`
* `--experimental-extensionless-modules`
* `--experimental-import-meta-resolve`
* `--experimental-json-modules`
* `--experimental-loader`
Expand Down
4 changes: 1 addition & 3 deletions doc/api/packages.md
Expand Up @@ -393,8 +393,7 @@ and other JavaScript runtimes, using the extensionless style can result in
bloated import map definitions. Explicit file extensions can avoid this issue by
enabling the import map to utilize a [packages folder mapping][] to map multiple
subpaths where possible instead of a separate map entry per package subpath
export. This also mirrors the requirement of using [the full specifier path][]
in relative and absolute import specifiers.
export.

### Exports sugar

Expand Down Expand Up @@ -1352,4 +1351,3 @@ This field defines [subpath imports][] for the current package.
[subpath imports]: #subpath-imports
[supported package managers]: corepack.md#supported-package-managers
[the dual CommonJS/ES module packages section]: #dual-commonjses-module-packages
[the full specifier path]: esm.md#mandatory-file-extensions
3 changes: 3 additions & 0 deletions doc/node.1
Expand Up @@ -178,6 +178,9 @@ Use this flag to enable ShadowRealm support.
.It Fl -experimental-test-coverage
Enable code coverage in the test runner.
.
.It Fl -experimental-extensionless-modules
Enable extensionless modules support.
.
.It Fl -no-experimental-fetch
Disable experimental support for the Fetch API.
.
Expand Down
4 changes: 3 additions & 1 deletion lib/internal/modules/esm/get_format.js
Expand Up @@ -16,6 +16,8 @@ const {

const experimentalNetworkImports =
getOptionValue('--experimental-network-imports');
const experimentalExtensionlessModules =
getOptionValue('--experimental-extensionless-modules');
const { getPackageType, getPackageScopeConfig } = require('internal/modules/esm/resolve');
const { fileURLToPath } = require('internal/url');
const { ERR_UNKNOWN_FILE_EXTENSION } = require('internal/errors').codes;
Expand Down Expand Up @@ -74,7 +76,7 @@ function extname(url) {
*/
function getFileProtocolModuleFormat(url, context, ignoreErrors) {
const ext = extname(url);
if (ext === '.js') {
if (ext === '.js' || (experimentalExtensionlessModules && ext === '')) {
return getPackageType(url) === 'module' ? 'module' : 'commonjs';
}

Expand Down
4 changes: 4 additions & 0 deletions src/node_options.cc
Expand Up @@ -365,6 +365,10 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() {
&EnvironmentOptions::enable_source_maps,
kAllowedInEnvvar);
AddOption("--experimental-abortcontroller", "", NoOp{}, kAllowedInEnvvar);
AddOption("--experimental-extensionless-modules",
"load extensionless files in module scope as ES modules",
&EnvironmentOptions::experimental_extensionless_modules,
kAllowedInEnvvar);
AddOption("--experimental-fetch",
"experimental Fetch API",
&EnvironmentOptions::experimental_fetch,
Expand Down
1 change: 1 addition & 0 deletions src/node_options.h
Expand Up @@ -110,6 +110,7 @@ class EnvironmentOptions : public Options {
std::vector<std::string> conditions;
std::string dns_result_order;
bool enable_source_maps = false;
bool experimental_extensionless_modules = false;
bool experimental_fetch = true;
bool experimental_global_customevent = true;
bool experimental_global_web_crypto = true;
Expand Down
26 changes: 26 additions & 0 deletions test/parallel/test-esm-no-extension.mjs
@@ -0,0 +1,26 @@
// Flags: --experimental-extensionless-modules
import * as common from '../common/index.mjs';
import * as fixtures from '../common/fixtures.mjs';
import { spawn } from 'node:child_process';
import assert from 'node:assert';

const entry = fixtures.path('/es-modules/package-type-module/noext-esm');

// Run a module that does not have extension.
// This is to ensure that "type": "module" applies to extensionless files.

const child = spawn(process.execPath, [
'--experimental-extensionless-modules',
entry,
]);

let stdout = '';
child.stdout.setEncoding('utf8');
child.stdout.on('data', (data) => {
stdout += data;
});
child.on('close', common.mustCall((code, signal) => {
assert.strictEqual(code, 0);
assert.strictEqual(signal, null);
assert.strictEqual(stdout, 'executed\n');
}));
92 changes: 92 additions & 0 deletions test/parallel/test-esm-unknown-main.mjs
@@ -0,0 +1,92 @@
// Flags: --experimental-extensionless-modules
import * as common from '../common/index.mjs';
import * as fixtures from '../common/fixtures.mjs';
import { spawn } from 'node:child_process';
import assert from 'node:assert';

{
const entry = fixtures.path(
'/es-modules/package-type-module/extension.unknown'
);
const child = spawn(process.execPath, [
'--experimental-extensionless-modules',
entry,
]);
let stdout = '';
let stderr = '';
child.stderr.setEncoding('utf8');
child.stdout.setEncoding('utf8');
child.stdout.on('data', (data) => {
stdout += data;
});
child.stderr.on('data', (data) => {
stderr += data;
});
child.on('close', common.mustCall((code, signal) => {
assert.strictEqual(code, 1);
assert.strictEqual(signal, null);
assert.strictEqual(stdout, '');
assert.ok(stderr.indexOf('ERR_UNKNOWN_FILE_EXTENSION') !== -1);
}));
}
{
const entry = fixtures.path(
'/es-modules/package-type-module/imports-unknownext.mjs'
);
const child = spawn(process.execPath, [
'--experimental-extensionless-modules',
entry,
]);
let stdout = '';
let stderr = '';
child.stderr.setEncoding('utf8');
child.stdout.setEncoding('utf8');
child.stdout.on('data', (data) => {
stdout += data;
});
child.stderr.on('data', (data) => {
stderr += data;
});
child.on('close', common.mustCall((code, signal) => {
assert.strictEqual(code, 1);
assert.strictEqual(signal, null);
assert.strictEqual(stdout, '');
assert.ok(stderr.indexOf('ERR_UNKNOWN_FILE_EXTENSION') !== -1);
}));
}
{
const entry = fixtures.path('/es-modules/package-type-module/noext-esm');
const child = spawn(process.execPath, [
'--experimental-extensionless-modules',
entry,
]);
let stdout = '';
child.stdout.setEncoding('utf8');
child.stdout.on('data', (data) => {
stdout += data;
});
child.on('close', common.mustCall((code, signal) => {
assert.strictEqual(code, 0);
assert.strictEqual(signal, null);
assert.strictEqual(stdout, 'executed\n');
}));
}
{
const entry = fixtures.path(
'/es-modules/package-type-module/imports-noext.mjs'
);
const child = spawn(process.execPath, [
'--experimental-extensionless-modules',
entry,
]);
let stdout = '';
child.stdout.setEncoding('utf8');
child.stdout.on('data', (data) => {
stdout += data;
});
child.on('close', common.mustCall((code, signal) => {
assert.strictEqual(code, 0);
assert.strictEqual(signal, null);
assert.strictEqual(stdout, 'executed\n');
}));
}