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

feat: support overlay display unhandled runtime errors #2310

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion e2e/cases/server/overlay/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ test('should show overlay correctly', async ({ page }) => {
fse.readFileSync(appPath, 'utf-8').replace('</div>', '</aaaaa>'),
);

await expect(errorOverlay.locator('.title')).toHaveText('Compilation failed');
await expect(errorOverlay.locator('.title')).toHaveText('Failed to compile');

await rsbuild.close();

Expand Down
4 changes: 3 additions & 1 deletion packages/core/modern.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const externals = [
...commonExternals,
'@rsbuild/core/client/hmr',
'@rsbuild/core/client/overlay',
'@rsbuild/core/client/runtimeErrors',
];

// Since the relative paths of bundle and compiled have changed,
Expand Down Expand Up @@ -69,10 +70,11 @@ export default defineConfig({
input: {
hmr: 'src/client/hmr.ts',
overlay: 'src/client/overlay.ts',
runtimeErrors: 'src/client/runtimeErrors.ts',
},
target: BUILD_TARGET.client,
dts: false,
externals: ['./hmr'],
externals: ['./hmr', './overlay'],
outDir: './dist/client',
autoExtension: true,
externalHelpers: true,
Expand Down
7 changes: 6 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@
"types": "./dist-types/client/overlay.d.ts",
"default": "./dist/client/overlay.js"
},
"./client/runtimeErrors": {
"types": "./dist/client/runtimeErrors.d.ts",
"default": "./dist/client/runtimeErrors.js"
},
"./types": {
"types": "./types.d.ts"
},
Expand Down Expand Up @@ -56,7 +60,8 @@
"@swc/helpers": "0.5.3",
"core-js": "~3.36.0",
"html-webpack-plugin": "npm:[email protected]",
"postcss": "^8.4.38"
"postcss": "^8.4.38",
"source-map-js": "^1.2.0"
},
"devDependencies": {
"@types/fs-extra": "^11.0.4",
Expand Down
87 changes: 87 additions & 0 deletions packages/core/src/client/findSourceMap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
const fetchContent = (url: string) => fetch(url).then((r) => r.text());

const findSourceMap = async (fileSource: string, filename: string) => {
try {
// Prefer to get it via filename + '.map'.
const mapUrl = `${filename}.map`;
return await fetchContent(mapUrl);
} catch (e) {
const mapUrl = fileSource.match(/\/\/# sourceMappingURL=(.*)$/)?.[1];
if (mapUrl) return await fetchContent(mapUrl);
}
};

// Format line numbers to ensure alignment
const parseLineNumber = (start: number, end: number) => {
const digit = Math.max(start.toString().length, end.toString().length);
return (line: number) => line.toString().padStart(digit);
};

// Escapes html tags to prevent them from being parsed in pre tags
const escapeHTML = (str: string) =>
str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');

// Based on the sourceMap information, beautify the source code and mark the error lines
const formatSourceCode = (sourceCode: string, pos: any) => {
// Note that the line starts at 1, not 0.
const { line: crtLine, column, name } = pos;
const lines = sourceCode.split('\n');

// Display up to 6 lines of source code
const lineCount = Math.min(lines.length, 6);
const result = [];

const startLine = Math.max(1, crtLine - 2);
const endLine = Math.min(startLine + lineCount - 1, lines.length);

const parse = parseLineNumber(startLine, endLine);

for (let line = startLine; line <= endLine; line++) {
const prefix = `${line === crtLine ? '->' : ' '} ${parse(line)} | `;
const lineCode = escapeHTML(lines[line - 1] ?? '');
result.push(prefix + lineCode);

// When the sourcemap information includes specific column details, add an error hint below the error line.
if (line === crtLine && column > 0) {
const errorLine = `${' '.repeat(prefix.length + column)}<span style="color: #fc5e5e;">${'^'.repeat(name?.length || 1)}</span>`;
result.push(errorLine);
}
}

return result.filter(Boolean).join('\n');
};

// Try to find the source based on the sourceMap information.
export const findSourceCode = async (sourceInfo: any) => {
const { filename, line, column } = sourceInfo;
const fileSource = await fetch(filename).then((r) => r.text());

const smContent = await findSourceMap(fileSource, filename);

if (!smContent) return;
const rawSourceMap = JSON.parse(smContent);

const { SourceMapConsumer } = await import('source-map-js');

const consumer = await new SourceMapConsumer(rawSourceMap);

// Use sourcemap to find the source code location
const pos = consumer.originalPositionFor({
line: Number.parseInt(line, 10),
column: Number.parseInt(column, 10),
});

const url = `${pos.source}:${pos.line}:${pos.column}`;
const sourceCode = consumer.sourceContentFor(pos.source);
return {
sourceCode: formatSourceCode(sourceCode, pos),
// Please use an absolute path in order to open it in vscode.
// Take webpack as an example. Please configure it correctly for [output.devtoolModuleFilenameTemplate](https://www.webpackjs.com/configuration/output/#outputdevtoolmodulefilenametemplate)
sourceFile: url,
};
};
85 changes: 85 additions & 0 deletions packages/core/src/client/format.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import type { StatsCompilation, StatsError } from '@rspack/core';
import type { OverlayError } from '../types';
import { findSourceCode } from './findSourceMap';

function resolveFileName(stats: StatsError) {
// Get the real source file path with stats.moduleIdentifier.
Expand Down Expand Up @@ -65,3 +67,86 @@ export function formatStatsMessages(
warnings: formattedWarnings,
};
}

function isRejectionEvent(
isRejection: boolean,
_event: any,
): _event is PromiseRejectionEvent {
return !!isRejection;
}

export async function formatRuntimeErrors(
event: PromiseRejectionEvent,
isRejection: true,
): Promise<OverlayError>;
export async function formatRuntimeErrors(
event: ErrorEvent,
isRejection: false,
): Promise<OverlayError>;

export async function formatRuntimeErrors(
event: PromiseRejectionEvent | ErrorEvent,
isRejection: boolean,
): Promise<OverlayError | undefined> {
const error = isRejectionEvent(isRejection, event)
? event.reason
: event?.error;

if (!error) return;
const errorName = isRejection
? `Unhandled Rejection (${error.name})`
: error.name;

const stack = parseRuntimeStack(error.stack);
const content = await createRuntimeContent(error.stack);
return {
title: `${errorName}: ${error.message}`,
content: content?.sourceCode || error.stack,
type: 'runtime',
stack: stack,
sourceFile: content?.sourceFile,
};
}

export function formatBuildErrors(errors: StatsError[]): OverlayError {
const content = formatMessage(errors[0]);

return {
title: 'Failed to compile',
type: 'build',
content: content,
};
}

function parseRuntimeStack(stack: string) {
let lines = stack.split('\n').slice(1);
lines = lines.map((info) => info.trim()).filter((line) => line !== '');
return lines;
}

/**
* Get the source code according to the error stack
* click on it and open the editor to jump to the corresponding source code location
*/
async function createRuntimeContent(stack: string) {
const lines = stack.split('\n').slice(1);

// Matches file paths in the error stack, generated via chatgpt.
const regex = /(?:at|in)?(?<filename>http[^\s]+):(?<line>\d+):(?<column>\d+)/;
let sourceInfo = {} as any;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const match = line.match(regex);
if (match) {
const { filename, line, column } = match.groups as any;
sourceInfo = { filename, line, column };
break;
}
}
if (!sourceInfo.filename) return;

try {
const content = await findSourceCode(sourceInfo);
return content;
} catch (e) {}
}
18 changes: 7 additions & 11 deletions packages/core/src/client/hmr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
*/
import type { StatsError } from '@rsbuild/shared';
import type { ClientConfig } from '@rsbuild/shared';
import { formatStatsMessages } from './format';
import type { OverlayError } from '../types';
import { formatBuildErrors, formatStatsMessages } from './format';

/**
* hmr socket connect path
Expand Down Expand Up @@ -62,11 +63,11 @@ function clearOutdatedErrors() {
}
}

let createOverlay: undefined | ((err: string[]) => void);
let createOverlay: undefined | ((err: OverlayError) => void);
let clearOverlay: undefined | (() => void);

export const registerOverlay = (
createFn: (err: string[]) => void,
createFn: (err: OverlayError) => void,
clearFn: () => void,
) => {
createOverlay = createFn;
Expand Down Expand Up @@ -124,18 +125,13 @@ function handleErrors(errors: StatsError[]) {
hasCompileErrors = true;

// "Massage" webpack messages.
const formatted = formatStatsMessages({
errors,
warnings: [],
});
const overlayError = formatBuildErrors(errors);

// Also log them to the console.
for (const error of formatted.errors) {
console.error(error);
}
console.error(overlayError.content);

if (createOverlay) {
createOverlay(formatted.errors);
createOverlay(overlayError);
}

// Do not attempt to reload now.
Expand Down
Loading
Loading