diff --git a/e2e/cases/server/overlay/index.test.ts b/e2e/cases/server/overlay/index.test.ts index 19dcc4895b..cb5908f940 100644 --- a/e2e/cases/server/overlay/index.test.ts +++ b/e2e/cases/server/overlay/index.test.ts @@ -39,7 +39,7 @@ test('should show overlay correctly', async ({ page }) => { fse.readFileSync(appPath, 'utf-8').replace('', ''), ); - await expect(errorOverlay.locator('.title')).toHaveText('Compilation failed'); + await expect(errorOverlay.locator('.title')).toHaveText('Failed to compile'); await rsbuild.close(); diff --git a/packages/core/modern.config.ts b/packages/core/modern.config.ts index be36b4bc4c..1ef88d3e24 100644 --- a/packages/core/modern.config.ts +++ b/packages/core/modern.config.ts @@ -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, @@ -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, diff --git a/packages/core/package.json b/packages/core/package.json index 3441eeb827..2855f6cb6d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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" }, @@ -56,7 +60,8 @@ "@swc/helpers": "0.5.3", "core-js": "~3.36.0", "html-webpack-plugin": "npm:html-rspack-plugin@5.7.2", - "postcss": "^8.4.38" + "postcss": "^8.4.38", + "source-map-js": "^1.2.0" }, "devDependencies": { "@types/fs-extra": "^11.0.4", diff --git a/packages/core/src/client/findSourceMap.ts b/packages/core/src/client/findSourceMap.ts new file mode 100644 index 0000000000..d0ef5b8414 --- /dev/null +++ b/packages/core/src/client/findSourceMap.ts @@ -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, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + +// 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)}${'^'.repeat(name?.length || 1)}`; + 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, + }; +}; diff --git a/packages/core/src/client/format.ts b/packages/core/src/client/format.ts index 196285688e..f4601c3eba 100644 --- a/packages/core/src/client/format.ts +++ b/packages/core/src/client/format.ts @@ -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. @@ -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; +export async function formatRuntimeErrors( + event: ErrorEvent, + isRejection: false, +): Promise; + +export async function formatRuntimeErrors( + event: PromiseRejectionEvent | ErrorEvent, + isRejection: boolean, +): Promise { + 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)?(?http[^\s]+):(?\d+):(?\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) {} +} diff --git a/packages/core/src/client/hmr.ts b/packages/core/src/client/hmr.ts index 18bf0458e3..7fc5431182 100644 --- a/packages/core/src/client/hmr.ts +++ b/packages/core/src/client/hmr.ts @@ -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 @@ -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; @@ -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. diff --git a/packages/core/src/client/overlay.ts b/packages/core/src/client/overlay.ts index dae07a106d..5451768d1b 100644 --- a/packages/core/src/client/overlay.ts +++ b/packages/core/src/client/overlay.ts @@ -1,3 +1,4 @@ +import type { OverlayError } from '../types'; import { registerOverlay } from './hmr'; function stripAnsi(content: string) { @@ -11,8 +12,13 @@ function stripAnsi(content: string) { return content.replace(regex, ''); } +function displayElement(el: Element) { + (el as HTMLElement).style.display = 'block'; +} + function linkedText(root: ShadowRoot, selector: string, text: string): void { const el = root.querySelector(selector)!; + displayElement(el); const fileRegex = /(?:[a-zA-Z]:\\|\/).*?:\d+:\d+/g; let curIndex = 0; @@ -39,6 +45,24 @@ function linkedText(root: ShadowRoot, selector: string, text: string): void { el.appendChild(document.createTextNode(frag)); } +function updateLink(root: ShadowRoot, selector: string, file: string): void { + const el = root.querySelector(selector)!; + el.addEventListener('click', () => { + fetch(`/__open-in-editor?file=${encodeURIComponent(file)}`); + }); + el.classList.add('cursor-pointer'); +} + +function updateElement( + root: ShadowRoot, + selector: string, + innerHTML: string, +): void { + const el = root.querySelector(selector)!; + el.innerHTML = innerHTML; + displayElement(el); +} + const overlayTemplate = `
-

Compilation failed

-

-    
-

Fix error, click outside, or press Esc to close the overlay.

-

Disable overlay by setting Rsbuild's dev.client.overlay config to false.

-

+

+

+    

+    
+
`; @@ -166,7 +214,7 @@ const { } = typeof window !== 'undefined' ? window : globalThis; class ErrorOverlay extends HTMLElement { - constructor(message: string[]) { + constructor(error: OverlayError) { super(); if (!this.attachShadow) { @@ -176,28 +224,55 @@ class ErrorOverlay extends HTMLElement { return; } + const { type, content, title, stack, sourceFile } = error; const root = this.attachShadow({ mode: 'open' }); root.innerHTML = overlayTemplate; - linkedText(root, '.content', stripAnsi(message.join('/n')).trim()); + updateElement(root, '.title', title); + + if (type === 'build') { + linkedText(root, '.build-content', stripAnsi(content).trim()); + } else { + updateElement(root, '.runtime-content', content); + if (sourceFile) { + updateLink(root, '.runtime-content', sourceFile); + } + } - root.querySelector('.close')?.addEventListener('click', this.close); + if (stack?.length) { + updateElement( + root, + '.stack', + ` +
+ ${stack.length} stack frames +
${stack.join('\n')}
+
+ `, + ); + } - // close overlay when click outside - this.addEventListener('click', this.close); + updateElement( + root, + '.footer', + type === 'build' + ? '

This error occurred during the build time and cannot be dismissed.

' + : "

Fix error, click outside, or press Esc to close the overlay.

Disable overlay by setting Rsbuild's dev.client.runtimeErrors config to false.

", + ); - root.querySelector('.container')!.addEventListener('click', (e) => { - e.stopPropagation(); - }); + if (type === 'runtime') { + displayElement(root.querySelector('.close') as Element); + root.querySelector('.close')?.addEventListener('click', this.close); - const onEscKeydown = (e: KeyboardEvent) => { - if (e.key === 'Escape' || e.code === 'Escape') { - this.close(); - } - document.removeEventListener('keydown', onEscKeydown); - }; + const onEscKeydown = (e: KeyboardEvent) => { + if (e.key === 'Escape' || e.code === 'Escape') { + this.close(); + } + document.removeEventListener('keydown', onEscKeydown); + }; - document.addEventListener('keydown', onEscKeydown); + document.addEventListener('keydown', onEscKeydown); + } } close = () => { @@ -220,17 +295,42 @@ if (customElements && !customElements.get(overlayId)) { customElements.define(overlayId, ErrorOverlay); } -function createOverlay(err: string[]) { +const documentAvailable = typeof document !== 'undefined'; + +export function createOverlay(err: OverlayError) { + if (!documentAvailable) { + console.info( + '[Rsbuild] Failed to display error overlay as document is not available, you can disable the `dev.client.overlay` option.', + ); + return; + } + + if (!err) return; + + if (hasOverlay()) return; + clearOverlay(); document.body.appendChild(new ErrorOverlay(err)); } -function clearOverlay() { +export function clearOverlay() { + if (!documentAvailable) { + return; + } + // use NodeList's forEach api instead of dom.iterable // biome-ignore lint/complexity/noForEach: document.querySelectorAll(overlayId).forEach((n) => n.close()); } +export function hasOverlay() { + if (!documentAvailable) { + return false; + } + + return document.querySelector(overlayId) !== null; +} + if (typeof document !== 'undefined') { registerOverlay(createOverlay, clearOverlay); } else { diff --git a/packages/core/src/client/runtimeErrors.ts b/packages/core/src/client/runtimeErrors.ts new file mode 100644 index 0000000000..0aa6f0fdf6 --- /dev/null +++ b/packages/core/src/client/runtimeErrors.ts @@ -0,0 +1,14 @@ +import { formatRuntimeErrors } from './format'; +import { createOverlay } from './overlay'; + +window.addEventListener('error', async (event) => { + const formatted = await formatRuntimeErrors(event, false); + createOverlay(formatted); +}); + +window.addEventListener('unhandledrejection', async (event) => { + if (event.reason?.stack) { + const formatted = await formatRuntimeErrors(event, true); + createOverlay(formatted); + } +}); diff --git a/packages/core/src/server/compilerDevMiddleware.ts b/packages/core/src/server/compilerDevMiddleware.ts index c1c5333113..af6b75616f 100644 --- a/packages/core/src/server/compilerDevMiddleware.ts +++ b/packages/core/src/server/compilerDevMiddleware.ts @@ -31,6 +31,15 @@ function getClientPaths(devConfig: DevConfig) { clientPaths.push(`${require.resolve('@rsbuild/core/client/overlay')}`); } + if ( + typeof devConfig?.client?.overlay === 'object' && + devConfig.client.overlay.runtimeErrors + ) { + clientPaths.push( + `${require.resolve('@rsbuild/core/client/runtimeErrors')}`, + ); + } + return clientPaths; } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index d5e7765077..b48a7e53fd 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -63,3 +63,15 @@ export type { NormalizedPerformanceConfig, NormalizedModuleFederationConfig, } from '@rsbuild/shared'; + +export interface OverlayError { + content: string; + + title: string; + + type: 'build' | 'runtime'; + + stack?: string[]; + + sourceFile?: string; +} diff --git a/packages/shared/src/types/config/dev.ts b/packages/shared/src/types/config/dev.ts index 314dddcba2..862eea358f 100644 --- a/packages/shared/src/types/config/dev.ts +++ b/packages/shared/src/types/config/dev.ts @@ -27,7 +27,12 @@ export type ClientConfig = { host?: string; protocol?: 'ws' | 'wss'; /** Shows an overlay in the browser when there are compiler errors. */ - overlay?: boolean; + overlay?: + | boolean + | { + // unhandled runtime errors + runtimeErrors?: boolean; + }; }; export type ChokidarWatchOptions = WatchOptions; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 768e8ab3d4..e06ec7ef79 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -704,6 +704,9 @@ importers: postcss: specifier: ^8.4.38 version: 8.4.38 + source-map-js: + specifier: ^1.2.0 + version: 1.2.0 devDependencies: '@types/fs-extra': specifier: ^11.0.4