Skip to content

Commit 9812b07

Browse files
committed
module: add requireStack to ESM exports resolution errors
When require() fails via the ESM exports resolution path (e.g. require('pkg/subpath') where pkg has a package.json exports field), the thrown MODULE_NOT_FOUND error was missing the requireStack property and the "Require stack:" section in the message that the CJS resolution path has always included. Thread the parent Module through createEsmNotFoundErr, finalizeEsmResolution, resolveExports, Module._findPath, and trySelf so all ESM resolution code paths populate requireStack consistently with the CJS path. Fixes TODO(BridgeAR) in lib/internal/modules/cjs/loader.js
1 parent 02104c0 commit 9812b07

File tree

3 files changed

+119
-16
lines changed

3 files changed

+119
-16
lines changed

lib/internal/modules/cjs/loader.js

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -625,9 +625,10 @@ function trySelfParentPath(parent) {
625625
* @param {string} parentPath The path of the parent module
626626
* @param {string} request The module request to resolve
627627
* @param {unknown} conditions
628+
* @param {Module} [parent] The module that issued the require() call
628629
* @returns {false|string}
629630
*/
630-
function trySelf(parentPath, request, conditions) {
631+
function trySelf(parentPath, request, conditions, parent) {
631632
if (!parentPath) { return false; }
632633

633634
const pkg = packageJsonReader.getNearestParentPackageJSON(parentPath);
@@ -648,10 +649,10 @@ function trySelf(parentPath, request, conditions) {
648649
const { packageExportsResolve } = require('internal/modules/esm/resolve');
649650
return finalizeEsmResolution(packageExportsResolve(
650651
pathToFileURL(pkg.path), expansion, pkg.data,
651-
pathToFileURL(parentPath), conditions), parentPath, pkg.path);
652+
pathToFileURL(parentPath), conditions), parentPath, pkg.path, parent);
652653
} catch (e) {
653654
if (e.code === 'ERR_MODULE_NOT_FOUND') {
654-
throw createEsmNotFoundErr(request, pkg.path);
655+
throw createEsmNotFoundErr(request, pkg.path, parent);
655656
}
656657
throw e;
657658
}
@@ -671,7 +672,7 @@ const EXPORTS_PATTERN = /^((?:@[^/\\%]+\/)?[^./\\%][^/\\%]*)(\/.*)?$/;
671672
* @param {Set<string>} conditions The conditions to use for resolution.
672673
* @returns {undefined|string}
673674
*/
674-
function resolveExports(nmPath, request, conditions) {
675+
function resolveExports(nmPath, request, conditions, parent) {
675676
// The implementation's behavior is meant to mirror resolution in ESM.
676677
const { 1: name, 2: expansion = '' } =
677678
RegExpPrototypeExec(EXPORTS_PATTERN, request) || kEmptyObject;
@@ -683,10 +684,10 @@ function resolveExports(nmPath, request, conditions) {
683684
const { packageExportsResolve } = require('internal/modules/esm/resolve');
684685
return finalizeEsmResolution(packageExportsResolve(
685686
pathToFileURL(pkgPath + '/package.json'), '.' + expansion, pkg, null,
686-
conditions), null, pkgPath);
687+
conditions), null, pkgPath, parent);
687688
} catch (e) {
688689
if (e.code === 'ERR_MODULE_NOT_FOUND') {
689-
throw createEsmNotFoundErr(request, pkgPath + '/package.json');
690+
throw createEsmNotFoundErr(request, pkgPath + '/package.json', parent);
690691
}
691692
throw e;
692693
}
@@ -698,9 +699,11 @@ function resolveExports(nmPath, request, conditions) {
698699
* @param {string} request Relative or absolute file path
699700
* @param {Array<string>} paths Folders to search as file paths
700701
* @param {boolean} isMain Whether the request is the main app entry point
702+
* @param {Set<string>} [conditions] The conditions to use for resolution
703+
* @param {Module} [parent] The module that issued the require() call
701704
* @returns {string | false}
702705
*/
703-
Module._findPath = function(request, paths, isMain, conditions = getCjsConditions()) {
706+
Module._findPath = function(request, paths, isMain, conditions = getCjsConditions(), parent) {
704707
const absoluteRequest = path.isAbsolute(request);
705708
if (absoluteRequest) {
706709
paths = [''];
@@ -748,7 +751,7 @@ Module._findPath = function(request, paths, isMain, conditions = getCjsCondition
748751
}
749752

750753
if (!absoluteRequest) {
751-
const exportsResolved = resolveExports(curPath, request, conditions);
754+
const exportsResolved = resolveExports(curPath, request, conditions, parent);
752755
if (exportsResolved) {
753756
return exportsResolved;
754757
}
@@ -1436,10 +1439,11 @@ Module._resolveFilename = function(request, parent, isMain, options) {
14361439
packageImportsResolve(request, pathToFileURL(parentPath), conditions),
14371440
parentPath,
14381441
pkg.path,
1442+
parent,
14391443
);
14401444
} catch (e) {
14411445
if (e.code === 'ERR_MODULE_NOT_FOUND') {
1442-
throw createEsmNotFoundErr(request);
1446+
throw createEsmNotFoundErr(request, undefined, parent);
14431447
}
14441448
throw e;
14451449
}
@@ -1448,7 +1452,7 @@ Module._resolveFilename = function(request, parent, isMain, options) {
14481452

14491453
// Try module self resolution first
14501454
const parentPath = trySelfParentPath(parent);
1451-
const selfResolved = trySelf(parentPath, request, conditions);
1455+
const selfResolved = trySelf(parentPath, request, conditions, parent);
14521456
if (selfResolved) {
14531457
const cacheKey = request + '\x00' +
14541458
(paths.length === 1 ? paths[0] : ArrayPrototypeJoin(paths, '\x00'));
@@ -1457,7 +1461,7 @@ Module._resolveFilename = function(request, parent, isMain, options) {
14571461
}
14581462

14591463
// Look up the filename first, since that's the cache key.
1460-
const filename = Module._findPath(request, paths, isMain, conditions);
1464+
const filename = Module._findPath(request, paths, isMain, conditions, parent);
14611465
if (filename) { return filename; }
14621466
const requireStack = [];
14631467
for (let cursor = parent;
@@ -1483,11 +1487,12 @@ Module._resolveFilename = function(request, parent, isMain, options) {
14831487
* @param {string} resolved The resolved module specifier
14841488
* @param {string} parentPath The path of the parent module
14851489
* @param {string} pkgPath The path of the package.json file
1490+
* @param {Module} [parent] The module that issued the require() call
14861491
* @throws {ERR_INVALID_MODULE_SPECIFIER} If the resolved module specifier contains encoded `/` or `\\` characters
14871492
* @throws {Error} If the module cannot be found
14881493
* @returns {void|string|undefined}
14891494
*/
1490-
function finalizeEsmResolution(resolved, parentPath, pkgPath) {
1495+
function finalizeEsmResolution(resolved, parentPath, pkgPath, parent) {
14911496
const { encodedSepRegEx } = require('internal/modules/esm/resolve');
14921497
if (RegExpPrototypeExec(encodedSepRegEx, resolved) !== null) {
14931498
throw new ERR_INVALID_MODULE_SPECIFIER(
@@ -1498,23 +1503,37 @@ function finalizeEsmResolution(resolved, parentPath, pkgPath) {
14981503
if (actual) {
14991504
return actual;
15001505
}
1501-
throw createEsmNotFoundErr(filename, pkgPath);
1506+
throw createEsmNotFoundErr(filename, pkgPath, parent);
15021507
}
15031508

15041509
/**
15051510
* Creates an error object for when a requested ES module cannot be found.
15061511
* @param {string} request The name of the requested module
15071512
* @param {string} [path] The path to the requested module
1513+
* @param {Module} [parent] The module that issued the require() call
15081514
* @returns {Error}
15091515
*/
1510-
function createEsmNotFoundErr(request, path) {
1516+
function createEsmNotFoundErr(request, path, parent) {
1517+
const requireStack = [];
1518+
for (let cursor = parent;
1519+
cursor;
1520+
cursor = cursor[kLastModuleParent]) {
1521+
ArrayPrototypePush(requireStack, cursor.filename || cursor.id);
1522+
}
1523+
let message = `Cannot find module '${request}'`;
1524+
if (requireStack.length > 0) {
1525+
message = message + '\nRequire stack:\n- ' +
1526+
ArrayPrototypeJoin(requireStack, '\n- ');
1527+
}
15111528
// eslint-disable-next-line no-restricted-syntax
1512-
const err = new Error(`Cannot find module '${request}'`);
1529+
const err = new Error(message);
15131530
err.code = 'MODULE_NOT_FOUND';
15141531
if (path) {
15151532
err.path = path;
15161533
}
1517-
// TODO(BridgeAR): Add the requireStack as well.
1534+
if (requireStack.length > 0) {
1535+
err.requireStack = requireStack;
1536+
}
15181537
return err;
15191538
}
15201539

test/fixtures/node_modules/pkgexports-esm-require-stack/package.json

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// Tests that MODULE_NOT_FOUND errors thrown from the ESM exports resolution
2+
// path include the requireStack property and message, matching the behaviour
3+
// of the CJS resolution path.
4+
// Fixes the TODO(BridgeAR) in lib/internal/modules/cjs/loader.js.
5+
'use strict';
6+
require('../common');
7+
const assert = require('assert');
8+
const fixtures = require('../common/fixtures');
9+
const { createRequire } = require('module');
10+
const fs = require('fs');
11+
12+
// --- Scenario 1: single-level require via createRequire ---
13+
// createRequire creates a fake module at the given path. When the exports
14+
// resolution fails, the fake module's path appears in requireStack.
15+
const requireFixture = createRequire(fixtures.path('node_modules/'));
16+
assert.throws(
17+
() => {
18+
requireFixture('pkgexports-esm-require-stack/missing');
19+
},
20+
(err) => {
21+
assert.strictEqual(err.code, 'MODULE_NOT_FOUND');
22+
23+
// requireStack must be present and non-empty (previously it was undefined).
24+
assert(Array.isArray(err.requireStack),
25+
`expected err.requireStack to be an array, got ${err.requireStack}`);
26+
assert(err.requireStack.length > 0, 'expected err.requireStack to be non-empty');
27+
28+
// The human-readable message must include the require stack section.
29+
assert(
30+
err.message.includes('Require stack:'),
31+
`expected error message to include "Require stack:", got:\n${err.message}`,
32+
);
33+
return true;
34+
},
35+
);
36+
37+
// --- Scenario 2: multi-level require propagates the full requireStack ---
38+
// When an intermediate module (inner) requires the missing export,
39+
// requireStack must list inner first and outer second (nearest caller first).
40+
//
41+
// inner.js lives in test/fixtures/ so Node's normal node_modules lookup finds
42+
// test/fixtures/node_modules/pkgexports-esm-require-stack automatically.
43+
const innerFixture = fixtures.path('require-esm-not-found-inner.js');
44+
const outerFixture = fixtures.path('require-esm-not-found-outer.js');
45+
46+
if (!fs.existsSync(innerFixture)) {
47+
fs.writeFileSync(
48+
innerFixture,
49+
// Uses plain require — test/fixtures/node_modules is in the search path.
50+
`'use strict';\nrequire('pkgexports-esm-require-stack/missing');\n`,
51+
);
52+
}
53+
if (!fs.existsSync(outerFixture)) {
54+
fs.writeFileSync(
55+
outerFixture,
56+
`'use strict';\nrequire(${JSON.stringify(innerFixture)});\n`,
57+
);
58+
}
59+
60+
assert.throws(
61+
() => { require(outerFixture); },
62+
(err) => {
63+
assert.strictEqual(err.code, 'MODULE_NOT_FOUND');
64+
assert(Array.isArray(err.requireStack));
65+
66+
// The inner module (direct caller of the failing require) is first.
67+
assert.strictEqual(err.requireStack[0], innerFixture,
68+
`requireStack[0] should be ${innerFixture}, got ${err.requireStack[0]}`);
69+
// The outer module appears next.
70+
assert.strictEqual(err.requireStack[1], outerFixture,
71+
`requireStack[1] should be ${outerFixture}, got ${err.requireStack[1]}`);
72+
73+
// Both paths appear in the human-readable message.
74+
assert(err.message.includes(innerFixture));
75+
assert(err.message.includes(outerFixture));
76+
return true;
77+
},
78+
);

0 commit comments

Comments
 (0)