Skip to content

Commit d24ea56

Browse files
committed
module: show user location for missing module errors, resolve #38892
1 parent eb54e70 commit d24ea56

8 files changed

Lines changed: 437 additions & 8 deletions

File tree

lib/internal/errors/error_source.js

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
const {
44
FunctionPrototypeBind,
5+
MathMax,
6+
MathMin,
7+
NumberIsFinite,
8+
RegExpPrototypeSymbolReplace,
9+
StringPrototypeRepeat,
510
StringPrototypeSlice,
611
} = primordials;
712

@@ -14,6 +19,80 @@ const {
1419
getSourceLine,
1520
} = require('internal/source_map/source_map_cache');
1621

22+
const kSourceLineMaxLength = 120;
23+
const kSourceLineContext = 40;
24+
const kLineEllipsis = '...';
25+
26+
function createSourceUnderline(sourceLine, startColumn, underlineLength) {
27+
const prefix = RegExpPrototypeSymbolReplace(
28+
/[^\t]/g, StringPrototypeSlice(sourceLine, 0, startColumn), ' ');
29+
return prefix + StringPrototypeRepeat('^', underlineLength);
30+
}
31+
32+
function clipSourceLine(sourceLine, startColumn, underlineLength) {
33+
if (sourceLine.length <= kSourceLineMaxLength) {
34+
return {
35+
sourceLine,
36+
startColumn,
37+
underlineLength,
38+
};
39+
}
40+
41+
const targetEnd = startColumn + underlineLength;
42+
const windowStart = MathMax(0, startColumn - kSourceLineContext);
43+
const windowEnd = MathMin(
44+
sourceLine.length,
45+
windowStart + kSourceLineMaxLength,
46+
targetEnd + kSourceLineContext,
47+
);
48+
49+
const leftEllipsis = windowStart > 0 ? kLineEllipsis : '';
50+
const rightEllipsis = windowEnd < sourceLine.length ? kLineEllipsis : '';
51+
const clippedLine = leftEllipsis +
52+
StringPrototypeSlice(sourceLine, windowStart, windowEnd) +
53+
rightEllipsis;
54+
const clippedStartColumn = leftEllipsis.length + startColumn - windowStart;
55+
const clippedUnderlineLength = MathMax(
56+
1,
57+
MathMin(underlineLength, windowEnd - startColumn),
58+
);
59+
60+
return {
61+
sourceLine: clippedLine,
62+
startColumn: clippedStartColumn,
63+
underlineLength: clippedUnderlineLength,
64+
};
65+
}
66+
67+
/**
68+
* Format a source line with a caret underline for an error message.
69+
* @param {string} filename The file containing the source line.
70+
* @param {number} lineNumber The 1-based line number.
71+
* @param {string} sourceLine The source line text.
72+
* @param {number} startColumn The 0-based underline start column.
73+
* @param {number} underlineLength The underline length.
74+
* @returns {string|undefined}
75+
*/
76+
function getErrorSourceMessage(filename, lineNumber, sourceLine, startColumn, underlineLength) {
77+
if (typeof sourceLine !== 'string' ||
78+
!NumberIsFinite(lineNumber) ||
79+
!NumberIsFinite(startColumn) ||
80+
!NumberIsFinite(underlineLength)) {
81+
return;
82+
}
83+
84+
startColumn = MathMax(0, startColumn);
85+
underlineLength = MathMax(1, underlineLength);
86+
87+
const clipped = clipSourceLine(sourceLine, startColumn, underlineLength);
88+
const arrow = createSourceUnderline(
89+
clipped.sourceLine,
90+
clipped.startColumn,
91+
clipped.underlineLength,
92+
);
93+
return `${filename}:${lineNumber}\n${clipped.sourceLine}\n${arrow}\n`;
94+
}
95+
1796
/**
1897
* Get the source location of an error. If source map is enabled, resolve the source location
1998
* based on the source map.
@@ -162,4 +241,5 @@ function getErrorSourceExpression(error) {
162241
module.exports = {
163242
getErrorSourceLocation,
164243
getErrorSourceExpression,
244+
getErrorSourceMessage,
165245
};

lib/internal/modules/cjs/loader.js

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ const {
3838
Error,
3939
FunctionPrototypeCall,
4040
JSONParse,
41+
Number,
4142
ObjectDefineProperty,
4243
ObjectFreeze,
4344
ObjectGetOwnPropertyDescriptor,
@@ -57,7 +58,6 @@ const {
5758
StringPrototypeCharCodeAt,
5859
StringPrototypeEndsWith,
5960
StringPrototypeIndexOf,
60-
StringPrototypeRepeat,
6161
StringPrototypeSlice,
6262
StringPrototypeSplit,
6363
StringPrototypeStartsWith,
@@ -142,6 +142,7 @@ const {
142142
kEmptyObject,
143143
setOwnProperty,
144144
getLazy,
145+
getStructuredStack,
145146
isWindows,
146147
isUnderNodeModules,
147148
} = require('internal/util');
@@ -200,6 +201,9 @@ const {
200201
},
201202
setArrowMessage,
202203
} = require('internal/errors');
204+
const {
205+
getErrorSourceMessage,
206+
} = require('internal/errors/error_source');
203207
const { validateString } = require('internal/validators');
204208

205209
const {
@@ -1475,6 +1479,7 @@ Module._resolveFilename = function(request, parent, isMain, options) {
14751479
const err = new Error(message);
14761480
err.code = 'MODULE_NOT_FOUND';
14771481
err.requireStack = requireStack;
1482+
decorateModuleNotFoundError(err, requireStack, request);
14781483
throw err;
14791484
};
14801485

@@ -1518,6 +1523,60 @@ function createEsmNotFoundErr(request, path) {
15181523
return err;
15191524
}
15201525

1526+
function decorateModuleNotFoundError(err, requireStack, request) {
1527+
const parentPath = requireStack[0];
1528+
if (!parentPath || StringPrototypeIndexOf(parentPath, path.sep) === -1) {
1529+
return;
1530+
}
1531+
1532+
const stack = getStructuredStack();
1533+
let line;
1534+
let col;
1535+
for (let i = 0; i < stack.length; i++) {
1536+
const frame = stack[i];
1537+
if (frame.getFileName() === parentPath) {
1538+
line = frame.getLineNumber();
1539+
col = frame.getColumnNumber();
1540+
break;
1541+
}
1542+
}
1543+
1544+
if (!line || !col) {
1545+
return;
1546+
}
1547+
1548+
let source;
1549+
try {
1550+
source = fs.readFileSync(parentPath, 'utf8');
1551+
} catch {
1552+
return;
1553+
}
1554+
1555+
const sourceLine = StringPrototypeSplit(source, '\n', line)[line - 1];
1556+
if (sourceLine === undefined) {
1557+
return;
1558+
}
1559+
1560+
let column = StringPrototypeIndexOf(sourceLine, request, col - 1);
1561+
if (column === -1) {
1562+
column = StringPrototypeIndexOf(sourceLine, request);
1563+
}
1564+
if (column === -1) {
1565+
column = col - 1;
1566+
}
1567+
1568+
const message = getErrorSourceMessage(
1569+
parentPath,
1570+
Number(line),
1571+
sourceLine,
1572+
column,
1573+
request.length,
1574+
);
1575+
if (message !== undefined) {
1576+
setArrowMessage(err, message);
1577+
}
1578+
}
1579+
15211580
function getExtensionForFormat(format) {
15221581
switch (format) {
15231582
case 'addon':
@@ -1879,8 +1938,10 @@ function reconstructErrorStack(err, parentPath, parentSource) {
18791938
RegExpPrototypeExec(/(\d+):(\d+)\)/, errLine) || [];
18801939
if (line && col) {
18811940
const srcLine = StringPrototypeSplit(parentSource, '\n', line)[line - 1];
1882-
const frame = `${parentPath}:${line}\n${srcLine}\n${StringPrototypeRepeat(' ', col - 1)}^\n`;
1883-
setArrowMessage(err, frame);
1941+
const message = getErrorSourceMessage(parentPath, Number(line), srcLine, col - 1, 1);
1942+
if (message !== undefined) {
1943+
setArrowMessage(err, message);
1944+
}
18841945
}
18851946
}
18861947

lib/internal/modules/esm/loader.js

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,15 @@ const {
66
ArrayPrototypeReduce,
77
FunctionPrototypeCall,
88
JSONStringify,
9+
Number,
910
ObjectSetPrototypeOf,
1011
Promise,
1112
PromisePrototypeThen,
13+
RegExpPrototypeExec,
1214
RegExpPrototypeSymbolReplace,
15+
StringPrototypeIndexOf,
16+
StringPrototypeSplit,
17+
StringPrototypeStartsWith,
1318
encodeURIComponent,
1419
hardenRegExp,
1520
} = primordials;
@@ -30,8 +35,13 @@ const {
3035
ERR_REQUIRE_ESM_RACE_CONDITION,
3136
ERR_UNKNOWN_MODULE_FORMAT,
3237
} = require('internal/errors').codes;
38+
const { setArrowMessage } = require('internal/errors');
39+
const {
40+
getErrorSourceMessage,
41+
} = require('internal/errors/error_source');
3342
const { getOptionValue } = require('internal/options');
3443
const { isURL, pathToFileURL } = require('internal/url');
44+
const { readFileSync } = require('fs');
3545
const {
3646
getDeprecationWarningEmitter,
3747
kEmptyObject,
@@ -79,6 +89,81 @@ let debug = require('internal/util/debuglog').debuglog('esm', (fn) => {
7989

8090
const { isPromise } = require('internal/util/types');
8191

92+
function getOrCreateModuleJobWithStackTraceLimit(loader, parentURL, request, limit) {
93+
const originalLimit = Error.stackTraceLimit;
94+
try {
95+
if (originalLimit < limit) {
96+
Error.stackTraceLimit = limit;
97+
}
98+
return loader.getOrCreateModuleJob(parentURL, request);
99+
} finally {
100+
Error.stackTraceLimit = originalLimit;
101+
}
102+
}
103+
104+
function decorateDynamicImportModuleNotFoundError(error, parentURL, specifier) {
105+
if (error?.code !== 'ERR_MODULE_NOT_FOUND' ||
106+
typeof parentURL !== 'string' ||
107+
!StringPrototypeStartsWith(parentURL, 'file://')) {
108+
return;
109+
}
110+
111+
let filename;
112+
try {
113+
filename = urlToFilename(parentURL);
114+
} catch {
115+
return;
116+
}
117+
118+
const stackLines = StringPrototypeSplit(error.stack, '\n');
119+
let frame;
120+
for (let i = 0; i < stackLines.length; i++) {
121+
if (StringPrototypeStartsWith(stackLines[i], ' at ') &&
122+
(StringPrototypeIndexOf(stackLines[i], parentURL) !== -1 ||
123+
StringPrototypeIndexOf(stackLines[i], filename) !== -1)) {
124+
frame = stackLines[i];
125+
break;
126+
}
127+
}
128+
129+
const { 1: line, 2: col } =
130+
RegExpPrototypeExec(/:(\d+):(\d+)\)?$/, frame) || [];
131+
if (!line || !col) {
132+
return;
133+
}
134+
135+
let source;
136+
try {
137+
source = readFileSync(filename, 'utf8');
138+
} catch {
139+
return;
140+
}
141+
142+
const sourceLine = StringPrototypeSplit(source, '\n', line)[line - 1];
143+
if (sourceLine === undefined) {
144+
return;
145+
}
146+
147+
let column = StringPrototypeIndexOf(sourceLine, specifier, col - 1);
148+
if (column === -1) {
149+
column = StringPrototypeIndexOf(sourceLine, specifier);
150+
}
151+
if (column === -1) {
152+
column = col - 1;
153+
}
154+
155+
const message = getErrorSourceMessage(
156+
filename,
157+
Number(line),
158+
sourceLine,
159+
column,
160+
specifier.length,
161+
);
162+
if (message !== undefined) {
163+
setArrowMessage(error, message);
164+
}
165+
}
166+
82167
/**
83168
* @typedef {import('./hooks.js').AsyncLoaderHookWorker} AsyncLoaderHookWorker
84169
* @typedef {import('./module_job.js').ModuleJobBase} ModuleJobBase
@@ -612,11 +697,16 @@ class ModuleLoader {
612697
const request = { specifier, phase, attributes: importAttributes, __proto__: null };
613698
let moduleJob;
614699
try {
615-
moduleJob = await this.getOrCreateModuleJob(parentURL, request);
700+
const maybeModuleJob =
701+
typeof parentURL === 'string' && StringPrototypeStartsWith(parentURL, 'file://') ?
702+
getOrCreateModuleJobWithStackTraceLimit(this, parentURL, request, 100) :
703+
this.getOrCreateModuleJob(parentURL, request);
704+
moduleJob = await maybeModuleJob;
616705
} catch (e) {
617706
if (e?.code === 'ERR_ASYNC_LOADER_REQUEST_NEVER_SETTLED') {
618707
return new Promise(() => {});
619708
}
709+
decorateDynamicImportModuleNotFoundError(e, parentURL, specifier);
620710
throw e;
621711
}
622712
if (phase === kSourcePhase) {

0 commit comments

Comments
 (0)