Skip to content

Commit 9cfef37

Browse files
committed
feat: support overlay display unhandled runtime errors
1 parent 7298fd7 commit 9cfef37

File tree

8 files changed

+355
-39
lines changed

8 files changed

+355
-39
lines changed

packages/core/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,8 @@
5858
"@swc/helpers": "0.5.3",
5959
"core-js": "~3.36.0",
6060
"html-webpack-plugin": "npm:[email protected]",
61-
"postcss": "^8.4.38"
61+
"postcss": "^8.4.38",
62+
"source-map": "0.5.7"
6263
},
6364
"devDependencies": {
6465
"@types/node": "18.x",
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
// @ts-expect-error
2+
import { SourceMapConsumer } from 'source-map';
3+
4+
const fetchContent = (url: string) => fetch(url).then((r) => r.text());
5+
6+
const findSourceMap = async (fileSource: string, filename: string) => {
7+
try {
8+
// Prefer to get it via filename + '.map'.
9+
const mapUrl = filename + '.map';
10+
return await fetchContent(mapUrl);
11+
} catch (e) {
12+
const mapUrl = fileSource.match(/\/\/# sourceMappingURL=(.*)$/)?.[1];
13+
if (mapUrl) return await fetchContent(mapUrl);
14+
}
15+
};
16+
17+
// Format line numbers to ensure alignment
18+
const parseLineNumber = (start: number, end: number) => {
19+
const digit = Math.max(start.toString().length, end.toString().length);
20+
return (line: number) => line.toString().padStart(digit);
21+
};
22+
23+
// Escapes html tags to prevent them from being parsed in pre tags
24+
const escapeHTML = (str: string) =>
25+
str
26+
.replace(/&/g, '&')
27+
.replace(/</g, '&lt;')
28+
.replace(/>/g, '&gt;')
29+
.replace(/"/g, '&quot;')
30+
.replace(/'/g, '&#39;');
31+
32+
// Based on the sourceMap information, beautify the source code and mark the error lines
33+
const formatSourceCode = (sourceCode: string, pos: any) => {
34+
// Note that the line starts at 1, not 0.
35+
const { line: crtLine, column, name } = pos;
36+
let lines = sourceCode.split('\n');
37+
38+
// Display up to 6 lines of source code
39+
const lineCount = Math.min(lines.length, 6);
40+
const result = [];
41+
42+
const startLine = Math.max(1, crtLine - 2);
43+
const endLine = Math.min(startLine + lineCount - 1, lines.length);
44+
45+
const parse = parseLineNumber(startLine, endLine);
46+
47+
for (let line = startLine; line <= endLine; line++) {
48+
const prefix = `${line === crtLine ? '->' : ' '} ${parse(line)} | `;
49+
const lineCode = escapeHTML(lines[line - 1] ?? '');
50+
result.push(prefix + lineCode);
51+
52+
// When the sourcemap information includes specific column details, add an error hint below the error line.
53+
if (line === crtLine && column > 0) {
54+
const errorLine =
55+
' '.repeat(prefix.length + column) +
56+
'<span style="color: #fc5e5e;">' +
57+
'^'.repeat(name?.length || 1) +
58+
'</span>';
59+
60+
result.push(errorLine);
61+
}
62+
}
63+
64+
return result.filter(Boolean).join('\n');
65+
};
66+
67+
// Try to find the source based on the sourceMap information.
68+
export const findSourceCode = async (sourceInfo: any) => {
69+
const { filename, line, column } = sourceInfo;
70+
const fileSource = await fetch(filename).then((r) => r.text());
71+
72+
const smContent = await findSourceMap(fileSource, filename);
73+
74+
if (!smContent) return;
75+
const rawSourceMap = JSON.parse(smContent);
76+
77+
const consumer = await new SourceMapConsumer(rawSourceMap);
78+
79+
// Use sourcemap to find the source code location
80+
const pos = consumer.originalPositionFor({
81+
line: parseInt(line, 10),
82+
column: parseInt(column, 10),
83+
});
84+
85+
const url = `${pos.source}:${pos.line}:${pos.column}`;
86+
const sourceCode = consumer.sourceContentFor(pos.source);
87+
return {
88+
sourceCode: formatSourceCode(sourceCode, pos),
89+
sourceFile: url,
90+
};
91+
};

packages/core/src/client/format.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import type { StatsCompilation, StatsError } from '@rspack/core';
2+
import { type OverlayError } from '../types';
3+
import { findSourceCode } from './findSourceMap';
24

35
function resolveFileName(stats: StatsError) {
46
// Get the real source file path with stats.moduleIdentifier.
@@ -59,3 +61,86 @@ export function formatStatsMessages(
5961
warnings: formattedWarnings,
6062
};
6163
}
64+
65+
function isRejectionEvent(
66+
isRejection: boolean,
67+
_event: any,
68+
): _event is PromiseRejectionEvent {
69+
return !!isRejection;
70+
}
71+
72+
export async function formatRuntimeErrors(
73+
event: PromiseRejectionEvent,
74+
isRejection: true,
75+
): Promise<OverlayError>;
76+
export async function formatRuntimeErrors(
77+
event: ErrorEvent,
78+
isRejection: false,
79+
): Promise<OverlayError>;
80+
81+
export async function formatRuntimeErrors(
82+
event: PromiseRejectionEvent | ErrorEvent,
83+
isRejection: boolean,
84+
): Promise<OverlayError | undefined> {
85+
let error = isRejectionEvent(isRejection, event)
86+
? event.reason
87+
: event?.error;
88+
89+
if (!error) return;
90+
const errorName = isRejection
91+
? 'Unhandled Rejection (' + error.name + ')'
92+
: error.name;
93+
94+
const stack = parseRuntimeStack(error.stack);
95+
const content = await createRuntimeContent(error.stack);
96+
return {
97+
title: `${errorName}: ${error.message}`,
98+
content: content?.sourceCode || error.stack,
99+
type: 'runtime',
100+
stack: stack,
101+
sourceFile: content?.sourceFile,
102+
};
103+
}
104+
105+
export function formatBuildErrors(errors: StatsError[]): OverlayError {
106+
const content = formatMessage(errors[0]);
107+
108+
return {
109+
title: 'Failed to compile',
110+
type: 'build',
111+
content: content,
112+
};
113+
}
114+
115+
function parseRuntimeStack(stack: string) {
116+
let lines = stack.split('\n').slice(1);
117+
lines = lines.map((info) => info.trim()).filter((line) => line !== '');
118+
return lines;
119+
}
120+
121+
/**
122+
* Get the source code according to the error stack
123+
* click on it and open the editor to jump to the corresponding source code location
124+
*/
125+
async function createRuntimeContent(stack: string) {
126+
const lines = stack.split('\n').slice(1);
127+
128+
// Matches file paths in the error stack, generated via chatgpt.
129+
const regex = /(?:at|in)?(?<filename>http[^\s]+):(?<line>\d+):(?<column>\d+)/;
130+
let sourceInfo = {} as any;
131+
for (let i = 0; i < lines.length; i++) {
132+
const line = lines[i];
133+
const match = line.match(regex);
134+
if (match) {
135+
const { filename, line, column } = match.groups as any;
136+
sourceInfo = { filename, line, column };
137+
break;
138+
}
139+
}
140+
if (!sourceInfo.filename) return;
141+
142+
try {
143+
const content = await findSourceCode(sourceInfo);
144+
return content;
145+
} catch (e) {}
146+
}

packages/core/src/client/hmr.ts

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
*/
77
import type { StatsError } from '@rsbuild/shared';
88
import type { ClientConfig } from '@rsbuild/shared';
9-
import { formatStatsMessages } from './format';
9+
import type { OverlayError } from '../types';
10+
import { formatBuildErrors, formatStatsMessages } from './format';
1011

1112
/**
1213
* hmr socket connect path
@@ -62,11 +63,11 @@ function clearOutdatedErrors() {
6263
}
6364
}
6465

65-
let createOverlay: undefined | ((err: string[]) => void);
66+
let createOverlay: undefined | ((err: OverlayError) => void);
6667
let clearOverlay: undefined | (() => void);
6768

6869
export const registerOverlay = (
69-
createFn: (err: string[]) => void,
70+
createFn: (err: OverlayError) => void,
7071
clearFn: () => void,
7172
) => {
7273
createOverlay = createFn;
@@ -124,18 +125,13 @@ function handleErrors(errors: StatsError[]) {
124125
hasCompileErrors = true;
125126

126127
// "Massage" webpack messages.
127-
const formatted = formatStatsMessages({
128-
errors,
129-
warnings: [],
130-
});
128+
const overlayError = formatBuildErrors(errors);
131129

132130
// Also log them to the console.
133-
for (const error of formatted.errors) {
134-
console.error(error);
135-
}
131+
console.error(overlayError.content);
136132

137133
if (createOverlay) {
138-
createOverlay(formatted.errors);
134+
createOverlay(overlayError);
139135
}
140136

141137
// Do not attempt to reload now.

0 commit comments

Comments
 (0)