diff --git a/packages/vite/src/client/client.ts b/packages/vite/src/client/client.ts index 5ec7df6c0a2cfe..fb7030fa10f850 100644 --- a/packages/vite/src/client/client.ts +++ b/packages/vite/src/client/client.ts @@ -109,6 +109,16 @@ if (typeof window !== 'undefined') { window.addEventListener?.('beforeunload', () => { willUnload = true }) + window.addEventListener('error', (error) => { + transport.send({ + type: 'browser-error', + stack: error.error.stack, + filename: error.filename, + message: error.message, + colno: error.colno, + lineno: error.lineno, + }) + }) } function cleanUrl(pathname: string): string { diff --git a/packages/vite/src/node/constants.ts b/packages/vite/src/node/constants.ts index f8d4ceff7905e3..4d6b4ae75c2ed5 100644 --- a/packages/vite/src/node/constants.ts +++ b/packages/vite/src/node/constants.ts @@ -104,6 +104,7 @@ export const SPECIAL_QUERY_RE = /[?&](?:worker|sharedworker|raw|url)\b/ export const FS_PREFIX = `/@fs/` export const CLIENT_PUBLIC_PATH = `/@vite/client` +export const CLIENT_ERROR_RELAY_PATH = '/@vite/errors' export const ENV_PUBLIC_PATH = `/@vite/env` export const VITE_PACKAGE_DIR = resolve( fileURLToPath(import.meta.url), diff --git a/packages/vite/src/node/server/errorIngestion.ts b/packages/vite/src/node/server/errorIngestion.ts new file mode 100644 index 00000000000000..6646ae2046ff48 --- /dev/null +++ b/packages/vite/src/node/server/errorIngestion.ts @@ -0,0 +1,53 @@ +import path from 'node:path' +import repl from 'node:repl' +import { readFileSync } from 'fs'; + +import { stripVTControlCharacters as strip } from 'node:util' +import colors from 'picocolors' +import type { RollupError } from 'rollup' +import bodyParser from 'body-parser' +import type { Connect } from 'dep-types/connect' +import type { ErrorPayload } from 'types/hmrPayload' +import { pad } from '../../utils' +import type { ResolvedConfig } from '../..' +import { CLIENT_ERROR_RELAY_PATH } from '../../constants' + +export interface BrowserErrorInfo { + type: 'browser-error' + stack: string + lineno: number + colno: number + message: string +} + +export async function logBrowserError( + source: string, + error: BrowserErrorInfo, + stack: string, + config: ResolvedConfig +) { + const fragment = await getErrorFragment(error, source) + const msg = `${colors.magenta('[browser]')} ${colors.red(stack)}` + config.logger.error(`${msg}\n\n${fragment}`, { + clear: true, + timestamp: true, + }) +} + +async function getErrorFragment (error, source) { + const filtered = source.split(/\r?\n/g) + .map((line, i) => [i, line]) + .slice(Math.max(1, error.lineno - 2), error.lineno + 3) + let fragment = "" + let padding = String(error.lineno).length + for (const [lineno, line] of filtered) { + fragment += `${ + lineno === (error.lineno - 1) ? colors.red('>') : ' ' + }${String(lineno).padStart(padding)} | ${line}\n` + if (lineno === (error.lineno - 1)) { + const leftPadding = Math.max(0, (error.colno - 1) + padding + 5) + fragment += `${new Array(leftPadding).fill('').join(' ')}${colors.red('^')}\n` + } + } + return fragment +} diff --git a/packages/vite/src/node/server/index.ts b/packages/vite/src/node/server/index.ts index 6f420fc7d6ecb5..aee567960716ec 100644 --- a/packages/vite/src/node/server/index.ts +++ b/packages/vite/src/node/server/index.ts @@ -482,7 +482,14 @@ export async function _createServer( ? null : await resolveHttpServer(serverConfig, middlewares, httpsOptions) - const ws = createWebSocketServer(httpServer, config, httpsOptions) + const environments: Record = {} + + let moduleGraph = new ModuleGraph({ + client: () => environments.client.moduleGraph, + ssr: () => environments.ssr.moduleGraph, + }) + + const ws = createWebSocketServer(httpServer, config, httpsOptions, moduleGraph) const publicFiles = await initPublicFilesPromise const { publicDir } = config @@ -509,7 +516,6 @@ export async function _createServer( ) as FSWatcher) : createNoopWatcher(resolvedWatchOptions) - const environments: Record = {} for (const [name, environmentOptions] of Object.entries( config.environments, @@ -530,10 +536,6 @@ export async function _createServer( // Backward compatibility - let moduleGraph = new ModuleGraph({ - client: () => environments.client.moduleGraph, - ssr: () => environments.ssr.moduleGraph, - }) let pluginContainer = createPluginContainer(environments) const closeHttpServer = createServerCloseFn(httpServer) diff --git a/packages/vite/src/node/server/stacktrace.ts b/packages/vite/src/node/server/stacktrace.ts new file mode 100644 index 00000000000000..29b2512247a1a0 --- /dev/null +++ b/packages/vite/src/node/server/stacktrace.ts @@ -0,0 +1,80 @@ +import path from 'node:path' +import { TraceMap, originalPositionFor } from '@jridgewell/trace-mapping' +import type { EnvironmentModuleGraph } from './server' + +let offset: number + +function calculateOffsetOnce() { + if (offset !== undefined) { + return + } + + try { + new Function('throw new Error(1)')() + } catch (e) { + // in Node 12, stack traces account for the function wrapper. + // in Node 13 and later, the function wrapper adds two lines, + // which must be subtracted to generate a valid mapping + const match = /:(\d+):\d+\)$/.exec(e.stack.split('\n')[1]) + offset = match ? +match[1] - 1 : 0 + } +} + + +export function browserRewriteStacktrace( + stack: string, + moduleGraph: EnvironmentModuleGraph, +): [string, string] { + let firstSource + const processedStack = stack + .split('\n') + .map((line) => { + return line.replace( + /^ {4}at (?:(\S.*?)\s\()?(.*?)\)$/, + (input, varName, url) => { + const { pathname } = new URL(url) + const [,id, line, column] = pathname.match(/^(.*?):(\d+):(\d+)$/) + if (!id) return input + + const mod = moduleGraph.urlToModuleMap.get(id) + const rawSource = mod?._clientModule.transformResult.code + if (!firstSource) { + firstSource = rawSource + } + const rawSourceMap = mod?._clientModule.transformResult?.map + const trimmedVarName = varName?.trim() + + if (!rawSourceMap) { + if (!trimmedVarName || trimmedVarName === 'eval') { + return ` at (${pathname})` + } else { + return ` at ${trimmedVarName} (${pathname})` + } + } + + const traced = new TraceMap(rawSourceMap as any) + + const pos = originalPositionFor(traced, { + line: Number(line) - offset, + // stacktrace's column is 1-indexed, but sourcemap's one is 0-indexed + column: Number(column) - 1, + }) + + if (!pos.source) { + return input + } + + const sourceFile = path.resolve(path.dirname(id), pos.source) + // stacktrace's column is 1-indexed, but sourcemap's one is 0-indexed + const source = `${sourceFile}:${pos.line}:${pos.column + 1}` + if (!trimmedVarName || trimmedVarName === 'eval') { + return ` at ${source}` + } else { + return ` at ${trimmedVarName} (${source})` + } + }, + ) + }) + .join('\n') + return [firstSource, processedStack] +} diff --git a/packages/vite/src/node/server/ws.ts b/packages/vite/src/node/server/ws.ts index 0a3f1d15c8b689..307416d73d205e 100644 --- a/packages/vite/src/node/server/ws.ts +++ b/packages/vite/src/node/server/ws.ts @@ -18,6 +18,9 @@ import { isObject } from '../utils' import type { NormalizedHotChannel, NormalizedHotChannelClient } from './hmr' import { normalizeHotChannel } from './hmr' import type { HttpServer } from '.' +import type { EnvironmentModuleGraph } from '..' +import { browserRewriteStacktrace } from './stacktrace' +import { logBrowserError } from './errorIngestion' /* In Bun, the `ws` module is overridden to hook into the native code. Using the bundled `js` version * of `ws` will not work as Bun's req.socket does not allow reading/writing to the underlying socket. @@ -116,6 +119,7 @@ export function createWebSocketServer( server: HttpServer | null, config: ResolvedConfig, httpsOptions?: HttpsServerOptions, + moduleGraph: EnvironmentModuleGraph, ): WebSocketServer { if (config.server.ws === false) { return { @@ -283,11 +287,15 @@ export function createWebSocketServer( wss.on('connection', (socket) => { socket.on('message', (raw) => { - if (!customListeners.size) return let parsed: any try { parsed = JSON.parse(String(raw)) } catch {} + if (parsed.type === 'browser-error') { + const [source, stack] = browserRewriteStacktrace(parsed.stack, moduleGraph) + logBrowserError(source, parsed, stack, config) + } + if (!customListeners.size) return if (!parsed || parsed.type !== 'custom' || !parsed.event) return const listeners = customListeners.get(parsed.event) if (!listeners?.size) return diff --git a/packages/vite/src/node/stacktrace.ts b/packages/vite/src/node/stacktrace.ts new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/packages/vite/src/node/utils.ts b/packages/vite/src/node/utils.ts index b9d096b882dbf8..75bb6d7a47a416 100644 --- a/packages/vite/src/node/utils.ts +++ b/packages/vite/src/node/utils.ts @@ -33,6 +33,7 @@ import { VALID_ID_PREFIX } from '../shared/constants' import { CLIENT_ENTRY, CLIENT_PUBLIC_PATH, + CLIENT_ERROR_RELAY_PATH, CSS_LANGS_RE, ENV_PUBLIC_PATH, FS_PREFIX, @@ -326,6 +327,7 @@ const internalPrefixes = [ FS_PREFIX, VALID_ID_PREFIX, CLIENT_PUBLIC_PATH, + CLIENT_ERROR_RELAY_PATH, ENV_PUBLIC_PATH, ] const InternalPrefixRE = new RegExp(`^(?:${internalPrefixes.join('|')})`) diff --git a/playground/client-errors/index.html b/playground/client-errors/index.html new file mode 100644 index 00000000000000..74f4d86956e79e --- /dev/null +++ b/playground/client-errors/index.html @@ -0,0 +1,5 @@ + +

Test Client Error Ingestion

+ + + diff --git a/playground/client-errors/index.js b/playground/client-errors/index.js new file mode 100644 index 00000000000000..da16ac2820870a --- /dev/null +++ b/playground/client-errors/index.js @@ -0,0 +1,8 @@ + +const input = document.querySelector('input') + +input.addEventListener('click', handleClick) + +function handleClick () { + throw new Error('Something went wrong') +} diff --git a/playground/client-errors/package.json b/playground/client-errors/package.json new file mode 100644 index 00000000000000..73df251135ccfa --- /dev/null +++ b/playground/client-errors/package.json @@ -0,0 +1,15 @@ +{ + "name": "@vitejs/test-client-errors", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "debug": "node --inspect-brk ../../packages/vite/bin/vite", + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "devDependencies": { + "vite": "workspace:^" + } +} diff --git a/playground/client-errors/vite.config.ts b/playground/client-errors/vite.config.ts new file mode 100644 index 00000000000000..4c9c4be6ba0c82 --- /dev/null +++ b/playground/client-errors/vite.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from 'vite' + +export default defineConfig({ + server: {}, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2a25729c62a9b2..31ca155d323556 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -229,6 +229,12 @@ importers: packages/vite: dependencies: + '@shikijs/cli': + specifier: ^3.8.1 + version: 3.8.1 + body-parser: + specifier: ^2.2.0 + version: 2.2.0 esbuild: specifier: ^0.25.0 version: 0.25.0 @@ -576,6 +582,12 @@ importers: specifier: ^0.11.4 version: 0.11.4 + playground/client-errors: + devDependencies: + vite: + specifier: workspace:* + version: link:../../packages/vite + playground/client-reload: {} playground/csp: {} @@ -3260,6 +3272,10 @@ packages: '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + '@shikijs/cli@3.8.1': + resolution: {integrity: sha512-YBnAkJSJI+sW9WPhFakjb7a7V31BlFOUF0Ujah8b4OyB0Z/HqBL8b95+X1bK465sYx0g7wT7N0T6cWWbQbZ2Rg==} + hasBin: true + '@shikijs/core@3.7.0': resolution: {integrity: sha512-yilc0S9HvTPyahHpcum8eonYrQtmGTU0lbtwxhA6jHv4Bm1cAdlPFRCJX4AHebkCm75aKTjjRAW+DezqD1b/cg==} @@ -5130,10 +5146,6 @@ packages: resolution: {integrity: sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==} engines: {node: '>=18'} - get-intrinsic@1.2.7: - resolution: {integrity: sha512-VW6Pxhsrk0KAOqs3WEd0klDiF/+V7gQOpAvY1jVU/LHmaD/kQO4523aiJuikX/QAKYiW6x8Jh+RJej1almdtCA==} - engines: {node: '>= 0.4'} - get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -8976,6 +8988,13 @@ snapshots: '@sec-ant/readable-stream@0.4.1': {} + '@shikijs/cli@3.8.1': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + ansis: 4.1.0 + cac: 6.7.14 + shiki: 3.8.1 + '@shikijs/core@3.7.0': dependencies: '@shikijs/types': 3.7.0 @@ -10161,7 +10180,7 @@ snapshots: call-bound@1.0.3: dependencies: call-bind-apply-helpers: 1.0.2 - get-intrinsic: 1.2.7 + get-intrinsic: 1.3.0 callsites@3.1.0: {} @@ -10927,19 +10946,6 @@ snapshots: get-east-asian-width@1.3.0: {} - get-intrinsic@1.2.7: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - function-bind: 1.1.2 - get-proto: 1.0.1 - gopd: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.2 - math-intrinsics: 1.1.0 - get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -12663,14 +12669,14 @@ snapshots: dependencies: call-bound: 1.0.3 es-errors: 1.3.0 - get-intrinsic: 1.2.7 + get-intrinsic: 1.3.0 object-inspect: 1.13.4 side-channel-weakmap@1.0.2: dependencies: call-bound: 1.0.3 es-errors: 1.3.0 - get-intrinsic: 1.2.7 + get-intrinsic: 1.3.0 object-inspect: 1.13.4 side-channel-map: 1.0.1