Skip to content

Commit a9aeca0

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

8 files changed

Lines changed: 454 additions & 17 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');
@@ -183,6 +184,8 @@ const { stripTypeScriptModuleTypes } = require('internal/modules/typescript');
183184
const packageJsonReader = require('internal/modules/package_json_reader');
184185
const { getOptionValue, getEmbedderOptions } = require('internal/options');
185186
const shouldReportRequiredModules = getLazy(() => process.env.WATCH_REPORT_DEPENDENCIES);
187+
const lazyGetErrorSourceMessage =
188+
getLazy(() => require('internal/errors/error_source').getErrorSourceMessage);
186189

187190
const {
188191
vm_dynamic_import_default_internal,
@@ -1475,6 +1478,7 @@ Module._resolveFilename = function(request, parent, isMain, options) {
14751478
const err = new Error(message);
14761479
err.code = 'MODULE_NOT_FOUND';
14771480
err.requireStack = requireStack;
1481+
decorateModuleNotFoundError(err, requireStack, request);
14781482
throw err;
14791483
};
14801484

@@ -1518,6 +1522,60 @@ function createEsmNotFoundErr(request, path) {
15181522
return err;
15191523
}
15201524

1525+
function decorateModuleNotFoundError(err, requireStack, request) {
1526+
const parentPath = requireStack[0];
1527+
if (!parentPath || StringPrototypeIndexOf(parentPath, path.sep) === -1) {
1528+
return;
1529+
}
1530+
1531+
const stack = getStructuredStack();
1532+
let line;
1533+
let col;
1534+
for (let i = 0; i < stack.length; i++) {
1535+
const frame = stack[i];
1536+
if (frame.getFileName() === parentPath) {
1537+
line = frame.getLineNumber();
1538+
col = frame.getColumnNumber();
1539+
break;
1540+
}
1541+
}
1542+
1543+
if (!line || !col) {
1544+
return;
1545+
}
1546+
1547+
let source;
1548+
try {
1549+
source = fs.readFileSync(parentPath, 'utf8');
1550+
} catch {
1551+
return;
1552+
}
1553+
1554+
const sourceLine = StringPrototypeSplit(source, '\n', line)[line - 1];
1555+
if (sourceLine === undefined) {
1556+
return;
1557+
}
1558+
1559+
let column = StringPrototypeIndexOf(sourceLine, request, col - 1);
1560+
if (column === -1) {
1561+
column = StringPrototypeIndexOf(sourceLine, request);
1562+
}
1563+
if (column === -1) {
1564+
column = col - 1;
1565+
}
1566+
1567+
const message = lazyGetErrorSourceMessage()(
1568+
parentPath,
1569+
Number(line),
1570+
sourceLine,
1571+
column,
1572+
request.length,
1573+
);
1574+
if (message !== undefined) {
1575+
setArrowMessage(err, message);
1576+
}
1577+
}
1578+
15211579
function getExtensionForFormat(format) {
15221580
switch (format) {
15231581
case 'addon':
@@ -1879,8 +1937,11 @@ function reconstructErrorStack(err, parentPath, parentSource) {
18791937
RegExpPrototypeExec(/(\d+):(\d+)\)/, errLine) || [];
18801938
if (line && col) {
18811939
const srcLine = StringPrototypeSplit(parentSource, '\n', line)[line - 1];
1882-
const frame = `${parentPath}:${line}\n${srcLine}\n${StringPrototypeRepeat(' ', col - 1)}^\n`;
1883-
setArrowMessage(err, frame);
1940+
const message = lazyGetErrorSourceMessage()(
1941+
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: 99 additions & 7 deletions
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;
@@ -24,16 +29,21 @@ const { imported_cjs_symbol } = internalBinding('symbols');
2429

2530
const assert = require('internal/assert');
2631
const {
27-
ERR_REQUIRE_ASYNC_MODULE,
28-
ERR_REQUIRE_CYCLE_MODULE,
29-
ERR_REQUIRE_ESM,
30-
ERR_REQUIRE_ESM_RACE_CONDITION,
31-
ERR_UNKNOWN_MODULE_FORMAT,
32-
} = require('internal/errors').codes;
32+
codes: {
33+
ERR_REQUIRE_ASYNC_MODULE,
34+
ERR_REQUIRE_CYCLE_MODULE,
35+
ERR_REQUIRE_ESM,
36+
ERR_REQUIRE_ESM_RACE_CONDITION,
37+
ERR_UNKNOWN_MODULE_FORMAT,
38+
},
39+
setArrowMessage,
40+
} = require('internal/errors');
3341
const { getOptionValue } = require('internal/options');
3442
const { isURL, pathToFileURL } = require('internal/url');
43+
const { readFileSync } = require('fs');
3544
const {
3645
getDeprecationWarningEmitter,
46+
getLazy,
3747
kEmptyObject,
3848
} = require('internal/util');
3949
const {
@@ -78,6 +88,83 @@ let debug = require('internal/util/debuglog').debuglog('esm', (fn) => {
7888
});
7989

8090
const { isPromise } = require('internal/util/types');
91+
const lazyGetErrorSourceMessage =
92+
getLazy(() => require('internal/errors/error_source').getErrorSourceMessage);
93+
94+
function getOrCreateModuleJobWithStackTraceLimit(loader, parentURL, request, limit) {
95+
const originalLimit = Error.stackTraceLimit;
96+
try {
97+
if (originalLimit < limit) {
98+
Error.stackTraceLimit = limit;
99+
}
100+
return loader.getOrCreateModuleJob(parentURL, request);
101+
} finally {
102+
Error.stackTraceLimit = originalLimit;
103+
}
104+
}
105+
106+
function decorateDynamicImportModuleNotFoundError(error, parentURL, specifier) {
107+
if (error?.code !== 'ERR_MODULE_NOT_FOUND' ||
108+
typeof parentURL !== 'string' ||
109+
!StringPrototypeStartsWith(parentURL, 'file://')) {
110+
return;
111+
}
112+
113+
let filename;
114+
try {
115+
filename = urlToFilename(parentURL);
116+
} catch {
117+
return;
118+
}
119+
120+
const stackLines = StringPrototypeSplit(error.stack, '\n');
121+
let frame;
122+
for (let i = 0; i < stackLines.length; i++) {
123+
if (StringPrototypeStartsWith(stackLines[i], ' at ') &&
124+
(StringPrototypeIndexOf(stackLines[i], parentURL) !== -1 ||
125+
StringPrototypeIndexOf(stackLines[i], filename) !== -1)) {
126+
frame = stackLines[i];
127+
break;
128+
}
129+
}
130+
131+
const { 1: line, 2: col } =
132+
RegExpPrototypeExec(/:(\d+):(\d+)\)?$/, frame) || [];
133+
if (!line || !col) {
134+
return;
135+
}
136+
137+
let source;
138+
try {
139+
source = readFileSync(filename, 'utf8');
140+
} catch {
141+
return;
142+
}
143+
144+
const sourceLine = StringPrototypeSplit(source, '\n', line)[line - 1];
145+
if (sourceLine === undefined) {
146+
return;
147+
}
148+
149+
let column = StringPrototypeIndexOf(sourceLine, specifier, col - 1);
150+
if (column === -1) {
151+
column = StringPrototypeIndexOf(sourceLine, specifier);
152+
}
153+
if (column === -1) {
154+
column = col - 1;
155+
}
156+
157+
const message = lazyGetErrorSourceMessage()(
158+
filename,
159+
Number(line),
160+
sourceLine,
161+
column,
162+
specifier.length,
163+
);
164+
if (message !== undefined) {
165+
setArrowMessage(error, message);
166+
}
167+
}
81168

82169
/**
83170
* @typedef {import('./hooks.js').AsyncLoaderHookWorker} AsyncLoaderHookWorker
@@ -612,11 +699,16 @@ class ModuleLoader {
612699
const request = { specifier, phase, attributes: importAttributes, __proto__: null };
613700
let moduleJob;
614701
try {
615-
moduleJob = await this.getOrCreateModuleJob(parentURL, request);
702+
const maybeModuleJob =
703+
typeof parentURL === 'string' && StringPrototypeStartsWith(parentURL, 'file://') ?
704+
getOrCreateModuleJobWithStackTraceLimit(this, parentURL, request, 100) :
705+
this.getOrCreateModuleJob(parentURL, request);
706+
moduleJob = await maybeModuleJob;
616707
} catch (e) {
617708
if (e?.code === 'ERR_ASYNC_LOADER_REQUEST_NEVER_SETTLED') {
618709
return new Promise(() => {});
619710
}
711+
decorateDynamicImportModuleNotFoundError(e, parentURL, specifier);
620712
throw e;
621713
}
622714
if (phase === kSourcePhase) {

0 commit comments

Comments
 (0)