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 2 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
3 changes: 2 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,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": "0.5.7"
},
"devDependencies": {
"@types/node": "18.x",
Expand Down
86 changes: 86 additions & 0 deletions packages/core/src/client/findSourceMap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// @ts-expect-error
import { SourceMapConsumer } from 'source-map';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think import source-map will resolve to the Node.js version and get an error like this:

image

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And we do not want to add third-party dependencies to @rsbuild/core, unless it can be prebundled to the compiled folder.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I tried to upgrade source-map to 0.7.4 and ran into some problems. It seems to be a problem with the source-map package itself.

Do you think it would work if I prebundled [email protected] into the compiled directory? If yes, I'll try to do it this way

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, [email protected] was released 7 years ago and we won't use a legacy version..

image

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

However, version 0.7.4 has a major problem that makes it unusable on the browser side. I don't think 0.5.7 should be rejected just because it's outdated, but whether it's more important to consider its usability.

I have found no good solution to this problem without changing the version. mozilla/source-map#459

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we can use source-map-js.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we can use source-map-js.

@9aoy Thanks for sharing.

I have a new problem. When I prebundler, I get an error: __filename is not defined.

I didn't find a good solution and had to improvise a less elegant solution.

The esm polyfill for __dirname is mostly Node-based. Sincerely hope to find a better solution.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The prebundle is used to bundle Node.js packages, and source-map-js in imported in the client code, so it can not be prebundled.

Copy link
Member

@chenjiahan chenjiahan May 17, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the client code, I think the new code added in the PR should be imported on demand.

In other words, when the user does not enable the runtime error overlay, these code and source-map-js should not be bundled into the client code, otherwise it will introduce unused code and slow down the build.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In other words, when the user does not enable the runtime error overlay, these code and source-map-js should not be bundled into the client code, otherwise it will introduce unused code and slow down the build.

Okay, let's take a look


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, '&')
.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 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),
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 @@ -59,3 +61,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