mirror of
https://github.com/nodejs/node.git
synced 2025-08-15 13:48:44 +02:00

This patch implements a "module-sync" exports condition for packages to supply a sycnrhonous ES module to the Node.js module loader, no matter it's being required or imported. This is similar to the "module" condition that bundlers have been using to support `require(esm)` in Node.js, and allows dual-package authors to opt into ESM-first only newer versions of Node.js that supports require(esm) while avoiding the dual-package hazard. ```json { "type": "module", "exports": { "node": { // On new version of Node.js, both require() and import get // the ESM version "module-sync": "./index.js", // On older version of Node.js, where "module" and // require(esm) are not supported, use the transpiled CJS version // to avoid dual-package hazard. Library authors can decide // to drop support for older versions of Node.js when they think // it's time. "default": "./dist/index.cjs" }, // On any other environment, use the ESM version. "default": "./index.js" } } ``` We end up implementing a condition with a different name instead of reusing "module", because existing code in the ecosystem using the "module" condition sometimes also expect the module resolution for these ESM files to work in CJS style, which is supported by bundlers, but the native Node.js loader has intentionally made ESM resolution different from CJS resolution (e.g. forbidding `import './noext'` or `import './directory'`), so it would be semver-major to implement a `"module"` condition without implementing the forbidden ESM resolution rules. For now, this just implments a new condition as semver-minor so it can be backported to older LTS. Refs: https://webpack.js.org/guides/package-exports/#target-environment-independent-packages PR-URL: https://github.com/nodejs/node/pull/54648 Fixes: https://github.com/nodejs/node/issues/52173 Refs: https://github.com/joyeecheung/test-module-condition Refs: https://github.com/nodejs/node/issues/52697 Reviewed-By: Jacob Smith <jacob@frende.me> Reviewed-By: Jan Krems <jan.krems@gmail.com> Reviewed-By: Chengzhong Wu <legendecas@gmail.com>
252 lines
9.2 KiB
JavaScript
252 lines
9.2 KiB
JavaScript
'use strict';
|
|
|
|
const {
|
|
ObjectPrototypeHasOwnProperty,
|
|
RegExpPrototypeExec,
|
|
SafeSet,
|
|
StringPrototypeCharCodeAt,
|
|
StringPrototypeIncludes,
|
|
StringPrototypeSlice,
|
|
} = primordials;
|
|
const { getOptionValue } = require('internal/options');
|
|
const {
|
|
extensionFormatMap,
|
|
getFormatOfExtensionlessFile,
|
|
mimeToFormat,
|
|
} = require('internal/modules/esm/formats');
|
|
|
|
const detectModule = getOptionValue('--experimental-detect-module');
|
|
const { containsModuleSyntax } = internalBinding('contextify');
|
|
const { getPackageScopeConfig, getPackageType } = require('internal/modules/package_json_reader');
|
|
const { fileURLToPath } = require('internal/url');
|
|
const { ERR_UNKNOWN_FILE_EXTENSION } = require('internal/errors').codes;
|
|
|
|
const protocolHandlers = {
|
|
'__proto__': null,
|
|
'data:': getDataProtocolModuleFormat,
|
|
'file:': getFileProtocolModuleFormat,
|
|
'node:'() { return 'builtin'; },
|
|
};
|
|
|
|
/**
|
|
* Determine whether the given ambiguous source contains CommonJS or ES module syntax.
|
|
* @param {string | Buffer | undefined} source
|
|
* @param {URL} url
|
|
*/
|
|
function detectModuleFormat(source, url) {
|
|
if (!source) { return detectModule ? null : 'commonjs'; }
|
|
if (!detectModule) { return 'commonjs'; }
|
|
return containsModuleSyntax(`${source}`, fileURLToPath(url), url) ? 'module' : 'commonjs';
|
|
}
|
|
|
|
/**
|
|
* @param {URL} parsed
|
|
* @returns {string | null}
|
|
*/
|
|
function getDataProtocolModuleFormat(parsed) {
|
|
const { 1: mime } = RegExpPrototypeExec(
|
|
/^([^/]+\/[^;,]+)(?:[^,]*?)(;base64)?,/,
|
|
parsed.pathname,
|
|
) || [ null, null, null ];
|
|
|
|
return mimeToFormat(mime);
|
|
}
|
|
|
|
const DOT_CODE = 46;
|
|
const SLASH_CODE = 47;
|
|
|
|
/**
|
|
* Returns the file extension from a URL. Should give similar result to
|
|
* `require('node:path').extname(require('node:url').fileURLToPath(url))`
|
|
* when used with a `file:` URL.
|
|
* @param {URL} url
|
|
* @returns {string}
|
|
*/
|
|
function extname(url) {
|
|
const { pathname } = url;
|
|
for (let i = pathname.length - 1; i > 0; i--) {
|
|
switch (StringPrototypeCharCodeAt(pathname, i)) {
|
|
case SLASH_CODE:
|
|
return '';
|
|
|
|
case DOT_CODE:
|
|
return StringPrototypeCharCodeAt(pathname, i - 1) === SLASH_CODE ? '' : StringPrototypeSlice(pathname, i);
|
|
}
|
|
}
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* Determine whether the given file URL is under a `node_modules` folder.
|
|
* This function assumes that the input has already been verified to be a `file:` URL,
|
|
* and is a file rather than a folder.
|
|
* @param {URL} url
|
|
*/
|
|
function underNodeModules(url) {
|
|
if (url.protocol !== 'file:') { return false; } // We determine module types for other protocols based on MIME header
|
|
|
|
return StringPrototypeIncludes(url.pathname, '/node_modules/');
|
|
}
|
|
|
|
let typelessPackageJsonFilesWarnedAbout;
|
|
function warnTypelessPackageJsonFile(pjsonPath, url) {
|
|
typelessPackageJsonFilesWarnedAbout ??= new SafeSet();
|
|
if (!underNodeModules(url) && !typelessPackageJsonFilesWarnedAbout.has(pjsonPath)) {
|
|
const warning = `Module type of ${url} is not specified and it doesn't parse as CommonJS.\n` +
|
|
'Reparsing as ES module because module syntax was detected. This incurs a performance overhead.\n' +
|
|
`To eliminate this warning, add "type": "module" to ${pjsonPath}.`;
|
|
process.emitWarning(warning, {
|
|
code: 'MODULE_TYPELESS_PACKAGE_JSON',
|
|
});
|
|
typelessPackageJsonFilesWarnedAbout.add(pjsonPath);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {URL} url
|
|
* @param {{parentURL: string; source?: Buffer}} context
|
|
* @param {boolean} ignoreErrors
|
|
* @returns {string}
|
|
*/
|
|
function getFileProtocolModuleFormat(url, context = { __proto__: null }, ignoreErrors) {
|
|
const { source } = context;
|
|
const ext = extname(url);
|
|
|
|
if (ext === '.js') {
|
|
const { type: packageType, pjsonPath, exists: foundPackageJson } = getPackageScopeConfig(url);
|
|
if (packageType !== 'none') {
|
|
return packageType;
|
|
}
|
|
|
|
// The controlling `package.json` file has no `type` field.
|
|
switch (getOptionValue('--experimental-default-type')) {
|
|
case 'module': { // The user explicitly passed `--experimental-default-type=module`.
|
|
// An exception to the type flag making ESM the default everywhere is that package scopes under `node_modules`
|
|
// should retain the assumption that a lack of a `type` field means CommonJS.
|
|
return underNodeModules(url) ? 'commonjs' : 'module';
|
|
}
|
|
case 'commonjs': { // The user explicitly passed `--experimental-default-type=commonjs`.
|
|
return 'commonjs';
|
|
}
|
|
default: { // The user did not pass `--experimental-default-type`.
|
|
// `source` is undefined when this is called from `defaultResolve`;
|
|
// but this gets called again from `defaultLoad`/`defaultLoadSync`.
|
|
// For ambiguous files (no type field, .js extension) we return
|
|
// undefined from `resolve` and re-run the check in `load`.
|
|
const format = detectModuleFormat(source, url);
|
|
if (format === 'module' && foundPackageJson) {
|
|
// This module has a .js extension, a package.json with no `type` field, and ESM syntax.
|
|
// Warn about the missing `type` field so that the user can avoid the performance penalty of detection.
|
|
warnTypelessPackageJsonFile(pjsonPath, url);
|
|
}
|
|
return format;
|
|
}
|
|
}
|
|
}
|
|
if (ext === '.ts' && getOptionValue('--experimental-strip-types')) {
|
|
const { type: packageType, pjsonPath, exists: foundPackageJson } = getPackageScopeConfig(url);
|
|
if (packageType !== 'none') {
|
|
return `${packageType}-typescript`;
|
|
}
|
|
// The controlling `package.json` file has no `type` field.
|
|
switch (getOptionValue('--experimental-default-type')) {
|
|
case 'module': { // The user explicitly passed `--experimental-default-type=module`.
|
|
// An exception to the type flag making ESM the default everywhere is that package scopes under `node_modules`
|
|
// should retain the assumption that a lack of a `type` field means CommonJS.
|
|
return underNodeModules(url) ? 'commonjs-typescript' : 'module-typescript';
|
|
}
|
|
case 'commonjs': { // The user explicitly passed `--experimental-default-type=commonjs`.
|
|
return 'commonjs-typescript';
|
|
}
|
|
default: { // The user did not pass `--experimental-default-type`.
|
|
// `source` is undefined when this is called from `defaultResolve`;
|
|
// but this gets called again from `defaultLoad`/`defaultLoadSync`.
|
|
// Since experimental-strip-types depends on detect-module, we always return null
|
|
// if source is undefined.
|
|
if (!source) { return null; }
|
|
const { stripTypeScriptTypes, stringify } = require('internal/modules/helpers');
|
|
const stringifiedSource = stringify(source);
|
|
const parsedSource = stripTypeScriptTypes(stringifiedSource, fileURLToPath(url));
|
|
const detectedFormat = detectModuleFormat(parsedSource, url);
|
|
const format = `${detectedFormat}-typescript`;
|
|
if (format === 'module-typescript' && foundPackageJson) {
|
|
// This module has a .js extension, a package.json with no `type` field, and ESM syntax.
|
|
// Warn about the missing `type` field so that the user can avoid the performance penalty of detection.
|
|
warnTypelessPackageJsonFile(pjsonPath, url);
|
|
}
|
|
return format;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (ext === '') {
|
|
const packageType = getPackageType(url);
|
|
if (packageType === 'module') {
|
|
return getFormatOfExtensionlessFile(url);
|
|
}
|
|
if (packageType !== 'none') {
|
|
return packageType; // 'commonjs' or future package types
|
|
}
|
|
|
|
// The controlling `package.json` file has no `type` field.
|
|
switch (getOptionValue('--experimental-default-type')) {
|
|
case 'module': { // The user explicitly passed `--experimental-default-type=module`.
|
|
return underNodeModules(url) ? 'commonjs' : getFormatOfExtensionlessFile(url);
|
|
}
|
|
case 'commonjs': { // The user explicitly passed `--experimental-default-type=commonjs`.
|
|
return 'commonjs';
|
|
}
|
|
default: { // The user did not pass `--experimental-default-type`.
|
|
if (!source) {
|
|
return null;
|
|
}
|
|
const format = getFormatOfExtensionlessFile(url);
|
|
if (format === 'wasm') {
|
|
return format;
|
|
}
|
|
return detectModuleFormat(source, url);
|
|
}
|
|
}
|
|
}
|
|
|
|
const format = extensionFormatMap[ext];
|
|
if (format) { return format; }
|
|
|
|
// Explicit undefined return indicates load hook should rerun format check
|
|
if (ignoreErrors) { return undefined; }
|
|
const filepath = fileURLToPath(url);
|
|
throw new ERR_UNKNOWN_FILE_EXTENSION(ext, filepath);
|
|
}
|
|
|
|
/**
|
|
* @param {URL} url
|
|
* @param {{parentURL: string}} context
|
|
* @returns {Promise<string> | string | undefined} only works when enabled
|
|
*/
|
|
function defaultGetFormatWithoutErrors(url, context) {
|
|
const protocol = url.protocol;
|
|
if (!ObjectPrototypeHasOwnProperty(protocolHandlers, protocol)) {
|
|
return null;
|
|
}
|
|
return protocolHandlers[protocol](url, context, true);
|
|
}
|
|
|
|
/**
|
|
* @param {URL} url
|
|
* @param {{parentURL: string}} context
|
|
* @returns {Promise<string> | string | undefined} only works when enabled
|
|
*/
|
|
function defaultGetFormat(url, context) {
|
|
const protocol = url.protocol;
|
|
if (!ObjectPrototypeHasOwnProperty(protocolHandlers, protocol)) {
|
|
return null;
|
|
}
|
|
return protocolHandlers[protocol](url, context, false);
|
|
}
|
|
|
|
module.exports = {
|
|
defaultGetFormat,
|
|
defaultGetFormatWithoutErrors,
|
|
extensionFormatMap,
|
|
extname,
|
|
};
|