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

esm: refine ERR_REQUIRE_ESM errors #39175

Closed
wants to merge 17 commits into from
Closed
71 changes: 56 additions & 15 deletions lib/internal/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const {
AggregateError,
ArrayFrom,
ArrayIsArray,
ArrayPrototypeFilter,
ArrayPrototypeIncludes,
ArrayPrototypeIndexOf,
ArrayPrototypeJoin,
Expand Down Expand Up @@ -788,6 +789,34 @@ const fatalExceptionStackEnhancers = {
}
};

// Ensures the printed error line is from user code.
let _kArrowMessagePrivateSymbol, _setHiddenValue;
Copy link
Member

Choose a reason for hiding this comment

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

do they have to be lazy?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

They don't have to be, but we only hit this path on ERR_REQUIRE_ESM errors. I was following the pattern of other lazy requires used in this module.

function setArrowMessage(err, arrowMessage) {
if (!_kArrowMessagePrivateSymbol) {
({
arrow_message_private_symbol: _kArrowMessagePrivateSymbol,
setHiddenValue: _setHiddenValue,
} = internalBinding('util'));
}
_setHiddenValue(err, _kArrowMessagePrivateSymbol, arrowMessage);
}

// Hide stack lines before the first user code line.
function hideInternalStackFrames(error) {
overrideStackTrace.set(error, (error, stackFrames) => {
let frames = stackFrames;
if (typeof stackFrames === 'object') {
frames = ArrayPrototypeFilter(
stackFrames,
(frm) => !StringPrototypeStartsWith(frm.getFileName(),
'node:internal')
);
}
ArrayPrototypeUnshift(frames, error);
return ArrayPrototypeJoin(frames, '\n at ');
});
}

// Node uses an AbortError that isn't exactly the same as the DOMException
// to make usage of the error in userland and readable-stream easier.
// It is a regular error with `.code` and `.name`.
Expand All @@ -806,8 +835,10 @@ module.exports = {
exceptionWithHostPort,
getMessage,
hideStackFrames,
hideInternalStackFrames,
isErrorStackTraceLimitWritable,
isStackOverflowError,
setArrowMessage,
connResetException,
uvErrmapGet,
uvException,
Expand Down Expand Up @@ -842,6 +873,7 @@ module.exports = {
// Note: Please try to keep these in alphabetical order
//
// Note: Node.js specific errors must begin with the prefix ERR_

E('ERR_AMBIGUOUS_ARGUMENT', 'The "%s" argument is ambiguous. %s', TypeError);
E('ERR_ARG_NOT_ITERABLE', '%s must be iterable', TypeError);
E('ERR_ASSERTION', '%s', Error);
Expand Down Expand Up @@ -1406,23 +1438,32 @@ E('ERR_PERFORMANCE_INVALID_TIMESTAMP',
'%d is not a valid timestamp', TypeError);
E('ERR_PERFORMANCE_MEASURE_INVALID_OPTIONS', '%s', TypeError);
E('ERR_REQUIRE_ESM',
(filename, parentPath = null, packageJsonPath = null) => {
let msg = `Must use import to load ES Module: ${filename}`;
if (parentPath && packageJsonPath) {
const path = require('path');
const basename = path.basename(filename) === path.basename(parentPath) ?
filename : path.basename(filename);
msg +=
'\nrequire() of ES modules is not supported.\nrequire() of ' +
`${filename} from ${parentPath} ` +
'is an ES module file as it is a .js file whose nearest parent ' +
'package.json contains "type": "module" which defines all .js ' +
'files in that package scope as ES modules.\nInstead rename ' +
`${basename} to end in .cjs, change the requiring code to use ` +
'import(), or remove "type": "module" from ' +
`${packageJsonPath}.\n`;
function(filename, hasEsmSyntax, parentPath = null, packageJsonPath = null) {
hideInternalStackFrames(this);
let msg = `require() of ES Module ${filename}${parentPath ? ` from ${
parentPath}` : ''} not supported.`;
if (!packageJsonPath) {
if (StringPrototypeEndsWith(filename, '.mjs'))
msg += `\nInstead change the require of ${filename} to a dynamic ` +
'import() which is available in all CommonJS modules.';
return msg;
}
const path = require('path');
const basename = path.basename(filename) === path.basename(parentPath) ?
filename : path.basename(filename);
if (hasEsmSyntax) {
msg += `\nInstead change the require of ${basename} in ${parentPath} to` +
' a dynamic import() which is available in all CommonJS modules.';
return msg;
}
msg += `\n${basename} is treated as an ES module file as it is a .js ` +
'file whose nearest parent package.json contains "type": "module" ' +
'which declares all .js files in that package scope as ES modules.' +
`\nInstead rename ${basename} to end in .cjs, change the requiring ` +
'code to use dynamic import() which is available in all CommonJS ' +
`modules, or remove "type": "module" from ${packageJsonPath} to ` +
'treat all .js files as CommonJS (using .mjs for all ES modules ' +
'instead).\n';
guybedford marked this conversation as resolved.
Show resolved Hide resolved
return msg;
}, Error);
E('ERR_SCRIPT_EXECUTION_INTERRUPTED',
Expand Down
19 changes: 19 additions & 0 deletions lib/internal/modules/cjs/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
const {
ArrayPrototypeForEach,
ArrayPrototypeJoin,
ArrayPrototypeSome,
ObjectDefineProperty,
ObjectPrototypeHasOwnProperty,
SafeMap,
Expand All @@ -29,6 +30,8 @@ let debug = require('internal/util/debuglog').debuglog('module', (fn) => {
debug = fn;
});

const parser = require('internal/deps/acorn/acorn/dist/acorn').Parser;

// TODO: Use this set when resolving pkg#exports conditions in loader.js.
const cjsConditions = new SafeSet(['require', 'node', ...userConditions]);

Expand Down Expand Up @@ -184,9 +187,25 @@ function normalizeReferrerURL(referrer) {
return new URL(referrer).href;
}

// For error messages only - used to check if ESM syntax is in use.
function hasEsmSyntax(code) {
let root;
try {
root = parser.parse(code, { sourceType: 'module', ecmaVersion: 'latest' });
} catch {
return false;
}

return ArrayPrototypeSome(root.body, (stmt) =>
stmt.type === 'ExportNamedDeclaration' ||
stmt.type === 'ImportDeclaration' ||
stmt.type === 'ExportAllDeclaration');
}

module.exports = {
addBuiltinLibsToObject,
cjsConditions,
hasEsmSyntax,
loadNativeModule,
makeRequireFunction,
normalizeReferrerURL,
Expand Down
65 changes: 47 additions & 18 deletions lib/internal/modules/cjs/loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ const {
Proxy,
ReflectApply,
ReflectSet,
RegExpPrototypeExec,
RegExpPrototypeTest,
SafeMap,
SafeWeakMap,
Expand All @@ -58,6 +59,7 @@ const {
StringPrototypeLastIndexOf,
StringPrototypeIndexOf,
StringPrototypeMatch,
StringPrototypeRepeat,
StringPrototypeSlice,
StringPrototypeSplit,
StringPrototypeStartsWith,
Expand Down Expand Up @@ -88,11 +90,12 @@ const { internalModuleStat } = internalBinding('fs');
const packageJsonReader = require('internal/modules/package_json_reader');
const { safeGetenv } = internalBinding('credentials');
const {
cjsConditions,
hasEsmSyntax,
loadNativeModule,
makeRequireFunction,
normalizeReferrerURL,
stripBOM,
cjsConditions,
loadNativeModule
} = require('internal/modules/cjs/helpers');
const { getOptionValue } = require('internal/options');
const preserveSymlinks = getOptionValue('--preserve-symlinks');
Expand All @@ -107,11 +110,14 @@ const policy = getOptionValue('--experimental-policy') ?
let hasLoadedAnyUserCJSModule = false;

const {
ERR_INVALID_ARG_VALUE,
ERR_INVALID_MODULE_SPECIFIER,
ERR_REQUIRE_ESM,
ERR_UNKNOWN_BUILTIN_MODULE,
} = require('internal/errors').codes;
codes: {
ERR_INVALID_ARG_VALUE,
ERR_INVALID_MODULE_SPECIFIER,
ERR_REQUIRE_ESM,
ERR_UNKNOWN_BUILTIN_MODULE,
},
setArrowMessage,
} = require('internal/errors');
const { validateString } = require('internal/validators');
const pendingDeprecation = getOptionValue('--pending-deprecation');

Expand Down Expand Up @@ -970,7 +976,7 @@ Module.prototype.load = function(filename) {
const extension = findLongestRegisteredExtension(filename);
// allow .mjs to be overridden
if (StringPrototypeEndsWith(filename, '.mjs') && !Module._extensions['.mjs'])
throw new ERR_REQUIRE_ESM(filename);
throw new ERR_REQUIRE_ESM(filename, true);

Module._extensions[extension](this, filename);
this.loaded = true;
Expand Down Expand Up @@ -1102,16 +1108,6 @@ Module.prototype._compile = function(content, filename) {

// Native extension for .js
Module._extensions['.js'] = function(module, filename) {
if (StringPrototypeEndsWith(filename, '.js')) {
const pkg = readPackageScope(filename);
// Function require shouldn't be used in ES modules.
if (pkg?.data?.type === 'module') {
const parent = moduleParentCache.get(module);
const parentPath = parent?.filename;
const packageJsonPath = path.resolve(pkg.path, 'package.json');
throw new ERR_REQUIRE_ESM(filename, parentPath, packageJsonPath);
}
}
// If already analyzed the source, then it will be cached.
const cached = cjsParseCache.get(module);
let content;
Expand All @@ -1121,6 +1117,39 @@ Module._extensions['.js'] = function(module, filename) {
} else {
content = fs.readFileSync(filename, 'utf8');
}
if (StringPrototypeEndsWith(filename, '.js')) {
const pkg = readPackageScope(filename);
// Function require shouldn't be used in ES modules.
if (pkg?.data?.type === 'module') {
const parent = moduleParentCache.get(module);
const parentPath = parent?.filename;
const packageJsonPath = path.resolve(pkg.path, 'package.json');
const usesEsm = hasEsmSyntax(content);
const err = new ERR_REQUIRE_ESM(filename, usesEsm, parentPath,
packageJsonPath);
// Attempt to reconstruct the parent require frame.
if (Module._cache[parentPath]) {
let parentSource;
try {
parentSource = fs.readFileSync(parentPath, 'utf8');
} catch {}
if (parentSource) {
const errLine = StringPrototypeSplit(
StringPrototypeSlice(err.stack, StringPrototypeIndexOf(
err.stack, ' at ')), '\n', 1)[0];
const { 1: line, 2: col } =
RegExpPrototypeExec(/(\d+):(\d+)\)/, errLine) || [];
if (line && col) {
const srcLine = StringPrototypeSplit(parentSource, '\n')[line - 1];
const frame = `${parentPath}:${line}\n${srcLine}\n${
StringPrototypeRepeat(' ', col - 1)}^\n`;
setArrowMessage(err, frame);
}
}
}
throw err;
}
}
module._compile(content, filename);
};

Expand Down
6 changes: 6 additions & 0 deletions src/node_errors.cc
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,12 @@ void AppendExceptionLine(Environment* env,
Local<Object> err_obj;
if (!er.IsEmpty() && er->IsObject()) {
err_obj = er.As<Object>();
// If arrow_message is already set, skip.
auto maybe_value = err_obj->GetPrivate(env->context(),
env->arrow_message_private_symbol());
Local<Value> lvalue;
if (!maybe_value.ToLocal(&lvalue) || lvalue->IsString())
return;
}

bool added_exception_line = false;
Expand Down
77 changes: 50 additions & 27 deletions test/es-module/test-cjs-esm-warn.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,36 +6,59 @@ const { spawn } = require('child_process');
const assert = require('assert');
const path = require('path');

const requiring = path.resolve(fixtures.path('/es-modules/cjs-esm.js'));
const required = path.resolve(
fixtures.path('/es-modules/package-type-module/cjs.js')
);
const requiringCjsAsEsm = path.resolve(fixtures.path('/es-modules/cjs-esm.js'));
const requiringEsm = path.resolve(fixtures.path('/es-modules/cjs-esm-esm.js'));
const pjson = path.resolve(
fixtures.path('/es-modules/package-type-module/package.json')
);

const basename = 'cjs.js';
{
const required = path.resolve(
fixtures.path('/es-modules/package-type-module/cjs.js')
);
const basename = 'cjs.js';
const child = spawn(process.execPath, [requiringCjsAsEsm]);
let stderr = '';
child.stderr.setEncoding('utf8');
child.stderr.on('data', (data) => {
stderr += data;
});
child.on('close', common.mustCall((code, signal) => {
assert.strictEqual(code, 1);
assert.strictEqual(signal, null);

assert.ok(stderr.replaceAll('\r', '').includes(
`Error [ERR_REQUIRE_ESM]: require() of ES Module ${required} from ${
requiringCjsAsEsm} not supported.\n`));
assert.ok(stderr.includes(
`Instead rename ${basename} to end in .cjs, change the requiring ` +
'code to use dynamic import() which is available in all CommonJS ' +
`modules, or change "type": "module" to "type": "commonjs" from ${pjson} to ` +
'treat all .js files as CommonJS (using .mjs for all ES modules ' +
'instead).\n'));
}));
}

const child = spawn(process.execPath, [requiring]);
let stderr = '';
child.stderr.setEncoding('utf8');
child.stderr.on('data', (data) => {
stderr += data;
});
child.on('close', common.mustCall((code, signal) => {
assert.strictEqual(code, 1);
assert.strictEqual(signal, null);
{
const required = path.resolve(
fixtures.path('/es-modules/package-type-module/esm.js')
);
const basename = 'esm.js';
const child = spawn(process.execPath, [requiringEsm]);
let stderr = '';
child.stderr.setEncoding('utf8');
child.stderr.on('data', (data) => {
stderr += data;
});
child.on('close', common.mustCall((code, signal) => {
assert.strictEqual(code, 1);
assert.strictEqual(signal, null);

assert.ok(stderr.replace(/\r/g, '').includes(
`Error [ERR_REQUIRE_ESM]: Must use import to load ES Module: ${required}` +
'\nrequire() of ES modules is not supported.\nrequire() of ' +
`${required} from ${requiring} ` +
'is an ES module file as it is a .js file whose nearest parent ' +
'package.json contains "type": "module" which defines all .js ' +
'files in that package scope as ES modules.\nInstead rename ' +
`${basename} to end in .cjs, change the requiring code to use ` +
'import(), or remove "type": "module" from ' +
`${pjson}.\n`));
assert.ok(stderr.includes(
'Error [ERR_REQUIRE_ESM]: Must use import to load ES Module'));
}));
assert.ok(stderr.replace(/\r/g, '').includes(
`Error [ERR_REQUIRE_ESM]: require() of ES Module ${required} from ${
requiringEsm} not supported.\n`));
assert.ok(stderr.includes(
`Instead change the require of ${basename} in ${requiringEsm} to` +
' a dynamic import() which is available in all CommonJS modules.\n'));
}));
}
4 changes: 2 additions & 2 deletions test/es-module/test-esm-type-flag-errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ try {
} catch (e) {
assert.strictEqual(e.name, 'Error');
assert.strictEqual(e.code, 'ERR_REQUIRE_ESM');
assert(e.toString().match(/Must use import to load ES Module/g));
assert(e.message.match(/Must use import to load ES Module/g));
assert(e.toString().match(/require\(\) of ES Module/g));
assert(e.message.match(/require\(\) of ES Module/g));
}

function expect(opt = '', inputFile, want, wantsError = false) {
Expand Down
1 change: 1 addition & 0 deletions test/fixtures/es-modules/cjs-esm-esm.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
require('./package-type-module/esm.js');
1 change: 1 addition & 0 deletions test/fixtures/es-modules/package-type-module/esm.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export var p = 5;
1 change: 1 addition & 0 deletions test/parallel/test-bootstrap-modules.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ const expectedModules = new Set([
'NativeModule internal/console/constructor',
'NativeModule internal/console/global',
'NativeModule internal/constants',
'NativeModule internal/deps/acorn/acorn/dist/acorn',
'NativeModule internal/encoding',
'NativeModule internal/errors',
'NativeModule internal/event_target',
Expand Down
Loading