diff --git a/docs/config/server-options.md b/docs/config/server-options.md index 6e646c97472e92..1a3ccc59a341ea 100644 --- a/docs/config/server-options.md +++ b/docs/config/server-options.md @@ -226,6 +226,27 @@ The error that appears in the Browser when the fallback happens can be ignored. ::: +## server.forwardRuntimeLogs + +- **Type:** `boolean` +- **Default:** `false` + +Forward unhandled runtime errors from the browser to the Vite server console during development. When enabled, errors like unhandled promise rejections and uncaught exceptions that occur in the browser will be logged in the server terminal with enhanced formatting, for example: + +```log +1:18:38 AM [vite] (client) [Unhandled error] Error: this is test error + > testError src/main.ts:20:8 + 18| + 19| function testError() { + 20| throw new Error('this is test error') + | ^ + 21| } + 22| + > HTMLButtonElement. src/main.ts:6:2 +``` + +This feature is useful when working with AI coding assistants that can only see terminal output for context. + ## server.warmup - **Type:** `{ clientFiles?: string[], ssrFiles?: string[] }` diff --git a/packages/vite/LICENSE.md b/packages/vite/LICENSE.md index ed5c85681d20bb..1b19839f87fd77 100644 --- a/packages/vite/LICENSE.md +++ b/packages/vite/LICENSE.md @@ -168,6 +168,34 @@ Repository: rollup/plugins --------------------------------------- +## @vitest/utils +License: MIT +Repository: git+https://github.com/vitest-dev/vitest.git + +> MIT License +> +> Copyright (c) 2021-Present Vitest Team +> +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to deal +> in the Software without restriction, including without limitation the rights +> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in all +> copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +> SOFTWARE. + +--------------------------------------- + ## anymatch License: ISC By: Elan Shanker diff --git a/packages/vite/package.json b/packages/vite/package.json index 7b6e3dd8b02cf3..2d2fe899f74de0 100644 --- a/packages/vite/package.json +++ b/packages/vite/package.json @@ -104,6 +104,7 @@ "@rollup/pluginutils": "^5.3.0", "@types/escape-html": "^1.0.4", "@types/pnpapi": "^0.0.5", + "@vitest/utils": "^3.2.4", "artichokie": "^0.4.2", "baseline-browser-mapping": "^2.8.16", "cac": "^6.7.14", diff --git a/packages/vite/src/client/client.ts b/packages/vite/src/client/client.ts index 1fc8c2b522fdb3..ca1ce3cc2cc383 100644 --- a/packages/vite/src/client/client.ts +++ b/packages/vite/src/client/client.ts @@ -6,6 +6,7 @@ import { normalizeModuleRunnerTransport, } from '../shared/moduleRunnerTransport' import { createHMRHandler } from '../shared/hmrHandler' +import { setupRuntimeLogHandler } from '../shared/runtimeLog' import { ErrorOverlay, cspNonce, overlayId } from './overlay' import '@vite/env' @@ -20,6 +21,7 @@ declare const __HMR_BASE__: string declare const __HMR_TIMEOUT__: number declare const __HMR_ENABLE_OVERLAY__: boolean declare const __WS_TOKEN__: string +declare const __SERVER_FORWARD_RUNTIME_LOGS__: boolean console.debug('[vite] connecting...') @@ -169,6 +171,10 @@ const hmrClient = new HMRClient( ) transport.connect!(createHMRHandler(handleMessage)) +if (__SERVER_FORWARD_RUNTIME_LOGS__) { + setupRuntimeLogHandler(transport) +} + async function handleMessage(payload: HotPayload) { switch (payload.type) { case 'connected': diff --git a/packages/vite/src/node/plugins/clientInjections.ts b/packages/vite/src/node/plugins/clientInjections.ts index 3fa745f7d2f220..575e03c935236b 100644 --- a/packages/vite/src/node/plugins/clientInjections.ts +++ b/packages/vite/src/node/plugins/clientInjections.ts @@ -77,6 +77,9 @@ export function clientInjectionsPlugin(config: ResolvedConfig): Plugin { const hmrEnableOverlayReplacement = escapeReplacement(overlay) const hmrConfigNameReplacement = escapeReplacement(hmrConfigName) const wsTokenReplacement = escapeReplacement(config.webSocketToken) + const serverForwardRuntimeLogsReplacement = escapeReplacement( + config.server.forwardRuntimeLogs, + ) injectConfigValues = (code: string) => { return code @@ -92,6 +95,10 @@ export function clientInjectionsPlugin(config: ResolvedConfig): Plugin { .replace(`__HMR_ENABLE_OVERLAY__`, hmrEnableOverlayReplacement) .replace(`__HMR_CONFIG_NAME__`, hmrConfigNameReplacement) .replace(`__WS_TOKEN__`, wsTokenReplacement) + .replace( + `__SERVER_FORWARD_RUNTIME_LOGS__`, + serverForwardRuntimeLogsReplacement, + ) } }, async transform(code, id) { diff --git a/packages/vite/src/node/plugins/index.ts b/packages/vite/src/node/plugins/index.ts index 82d87ab1f621b7..fc3cc112dc7a7a 100644 --- a/packages/vite/src/node/plugins/index.ts +++ b/packages/vite/src/node/plugins/index.ts @@ -28,6 +28,7 @@ import { createFilterForTransform, createIdFilter, } from './pluginFilter' +import { runtimeLogPlugin } from './runtimeLog' export async function resolvePlugins( config: ResolvedConfig, @@ -73,6 +74,8 @@ export async function resolvePlugins( wasmHelperPlugin(), webWorkerPlugin(config), assetPlugin(config), + config.server.forwardRuntimeLogs && + runtimeLogPlugin({ environments: ['client'] }), ...normalPlugins, diff --git a/packages/vite/src/node/plugins/runtimeLog.ts b/packages/vite/src/node/plugins/runtimeLog.ts new file mode 100644 index 00000000000000..c116cde6ca967a --- /dev/null +++ b/packages/vite/src/node/plugins/runtimeLog.ts @@ -0,0 +1,96 @@ +import path from 'node:path' +import fs from 'node:fs' +import { parseErrorStacktrace } from '@vitest/utils/source-map' +import c from 'picocolors' +import type { RuntimeLogPayload } from 'types/customEvent' +import type { DevEnvironment, Plugin } from '..' +import { normalizePath } from '..' +import { generateCodeFrame } from '../utils' + +export function runtimeLogPlugin(pluginOpts: { + environments: string[] +}): Plugin { + return { + name: 'vite:runtime-log', + apply: 'serve', + configureServer(server) { + for (const name of pluginOpts.environments) { + const environment = server.environments[name] + environment.hot.on('vite:runtime-log', (payload) => { + if (payload.error) { + const output = formatError(payload.error, environment) + environment.config.logger.error(output, { + timestamp: true, + }) + } + }) + } + }, + } +} + +function formatError( + error: NonNullable, + environment: DevEnvironment, +) { + // https://github.com/vitest-dev/vitest/blob/4783137cd8d766cf998bdf2d638890eaa51e08d9/packages/browser/src/node/projectParent.ts#L58 + const stacks = parseErrorStacktrace(error, { + getUrlId(id) { + const moduleGraph = environment.moduleGraph + const mod = moduleGraph.getModuleById(id) + if (mod) { + return id + } + const resolvedPath = normalizePath( + path.resolve(environment.config.root, id.slice(1)), + ) + const modUrl = moduleGraph.getModuleById(resolvedPath) + if (modUrl) { + return resolvedPath + } + // some browsers (looking at you, safari) don't report queries in stack traces + // the next best thing is to try the first id that this file resolves to + const files = moduleGraph.getModulesByFile(resolvedPath) + if (files && files.size) { + return files.values().next().value!.id! + } + return id + }, + getSourceMap(id) { + // stack is already rewritten on server + if (environment.name === 'client') { + return environment.moduleGraph.getModuleById(id)?.transformResult?.map + } + }, + // Vitest uses this option to skip internal files + // https://github.com/vitejs/vitest/blob/4783137cd8d766cf998bdf2d638890eaa51e08d9/packages/utils/src/source-map.ts#L17 + ignoreStackEntries: [], + }) + + // https://github.com/vitest-dev/vitest/blob/4783137cd8d766cf998bdf2d638890eaa51e08d9/packages/vitest/src/node/printError.ts#L64 + const nearest = stacks.find((stack) => { + const modules = environment.moduleGraph.getModulesByFile(stack.file) + return ( + [...(modules || [])].some((m) => m.transformResult) && + fs.existsSync(stack.file) + ) + }) + + let output = '' + output += c.red(`[Unhandled error] ${c.bold(error.name)}: ${error.message}\n`) + for (const stack of stacks) { + const file = normalizePath( + path.relative(environment.config.root, stack.file), + ) + output += ` > ${[stack.method, `${file}:${stack.line}:${stack.column}`] + .filter(Boolean) + .join(' ')}\n` + if (stack === nearest) { + const code = fs.readFileSync(stack.file, 'utf-8') + // TODO: highlight? + output += generateCodeFrame(code, stack).replace(/^/gm, ' ') + output += '\n' + } + } + return output +} diff --git a/packages/vite/src/node/server/index.ts b/packages/vite/src/node/server/index.ts index 47dd95f0c146bc..b21ff19fba31f7 100644 --- a/packages/vite/src/node/server/index.ts +++ b/packages/vite/src/node/server/index.ts @@ -188,6 +188,8 @@ export interface ServerOptions extends CommonServerOptions { server: ViteDevServer, hmr: (environment: DevEnvironment) => Promise, ) => Promise + + forwardRuntimeLogs?: boolean } export interface ResolvedServerOptions @@ -1103,6 +1105,7 @@ export const serverConfigDefaults = Object.freeze({ // sourcemapIgnoreList perEnvironmentStartEndDuringDev: false, // hotUpdateEnvironments + forwardRuntimeLogs: false, } satisfies ServerOptions) export function resolveServerOptions( diff --git a/packages/vite/src/shared/runtimeLog.ts b/packages/vite/src/shared/runtimeLog.ts new file mode 100644 index 00000000000000..48f933381f0804 --- /dev/null +++ b/packages/vite/src/shared/runtimeLog.ts @@ -0,0 +1,33 @@ +import type { RuntimeLogPayload } from 'types/customEvent' +import type { NormalizedModuleRunnerTransport } from './moduleRunnerTransport' + +export function setupRuntimeLogHandler( + transport: NormalizedModuleRunnerTransport, +): void { + function sendError(error: any) { + // TODO: serialize extra properties, recursive cause, etc. + transport.send({ + type: 'custom', + event: 'vite:runtime-log', + data: { + error: { + name: error?.name || 'Error', + message: error?.message || String(error), + stack: error?.stack, + }, + } satisfies RuntimeLogPayload, + }) + } + + if (typeof window !== 'undefined') { + window.addEventListener('error', (event) => { + sendError(event.error) + }) + window.addEventListener('unhandledrejection', (event) => { + sendError(event.reason) + }) + } + + // TODO: server runtime? + // if (typeof process !== 'undefined') {} +} diff --git a/packages/vite/types/customEvent.d.ts b/packages/vite/types/customEvent.d.ts index 96145a6fddadf4..fd7ce7c97506dc 100644 --- a/packages/vite/types/customEvent.d.ts +++ b/packages/vite/types/customEvent.d.ts @@ -12,6 +12,7 @@ export interface CustomEventMap { 'vite:beforeFullReload': FullReloadPayload 'vite:error': ErrorPayload 'vite:invalidate': InvalidatePayload + 'vite:runtime-log': RuntimeLogPayload 'vite:ws:connect': WebSocketConnectionPayload 'vite:ws:disconnect': WebSocketConnectionPayload } @@ -33,6 +34,15 @@ export interface InvalidatePayload { firstInvalidatedBy: string } +export interface RuntimeLogPayload { + // make it optional for future extension? + error?: { + name: string + message: string + stack?: string + } +} + /** * provides types for payloads of built-in Vite events */ diff --git a/playground/runtime-log/__test__/runtime-log.spec.ts b/playground/runtime-log/__test__/runtime-log.spec.ts new file mode 100644 index 00000000000000..5953dcc9a7c627 --- /dev/null +++ b/playground/runtime-log/__test__/runtime-log.spec.ts @@ -0,0 +1,35 @@ +import { stripVTControlCharacters } from 'node:util' +import { expect, test } from 'vitest' +import { isServe, page, serverLogs } from '~utils' + +test.runIf(isServe)('unhandled error', async () => { + await page.click('#test-error') + await expect.poll(() => stripVTControlCharacters(serverLogs.at(-1))) + .toEqual(`\ +[Unhandled error] Error: this is test error + > testError src/main.ts:20:8 + 18 | + 19 | function testError() { + 20 | throw new Error('this is test error') + | ^ + 21 | } + 22 | + > HTMLButtonElement. src/main.ts:6:2 +`) +}) + +test.runIf(isServe)('unhandled rejection', async () => { + await page.click('#test-unhandledrejection') + await expect.poll(() => stripVTControlCharacters(serverLogs.at(-1))) + .toEqual(`\ +[Unhandled error] Error: this is test unhandledrejection + > testUnhandledRejection src/main.ts:24:8 + 22 | + 23 | async function testUnhandledRejection() { + 24 | throw new Error('this is test unhandledrejection') + | ^ + 25 | } + 26 | + > HTMLButtonElement. src/main.ts:12:4 +`) +}) diff --git a/playground/runtime-log/index.html b/playground/runtime-log/index.html new file mode 100644 index 00000000000000..dd0f5ecab45f54 --- /dev/null +++ b/playground/runtime-log/index.html @@ -0,0 +1,3 @@ + + + diff --git a/playground/runtime-log/package.json b/playground/runtime-log/package.json new file mode 100644 index 00000000000000..1a1262012bd799 --- /dev/null +++ b/playground/runtime-log/package.json @@ -0,0 +1,14 @@ +{ + "name": "@vitejs/test-runtime-log", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "debug": "node --inspect-brk ../../packages/vite/bin/vite", + "preview": "vite preview" + }, + "dependencies": {}, + "devDependencies": {} +} diff --git a/playground/runtime-log/public/favicon.ico b/playground/runtime-log/public/favicon.ico new file mode 100644 index 00000000000000..df36fcfb72584e Binary files /dev/null and b/playground/runtime-log/public/favicon.ico differ diff --git a/playground/runtime-log/src/main.ts b/playground/runtime-log/src/main.ts new file mode 100644 index 00000000000000..0f064890c35c06 --- /dev/null +++ b/playground/runtime-log/src/main.ts @@ -0,0 +1,25 @@ +export type SomePadding = { + here: boolean +} + +document.getElementById('test-error').addEventListener('click', () => { + testError() +}) + +document + .getElementById('test-unhandledrejection') + .addEventListener('click', () => { + testUnhandledRejection() + }) + +export type AnotherPadding = { + there: boolean +} + +function testError() { + throw new Error('this is test error') +} + +async function testUnhandledRejection() { + throw new Error('this is test unhandledrejection') +} diff --git a/playground/runtime-log/vite.config.ts b/playground/runtime-log/vite.config.ts new file mode 100644 index 00000000000000..380bc046e26b33 --- /dev/null +++ b/playground/runtime-log/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' + +export default defineConfig({ + server: { + forwardRuntimeLogs: true, + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1e62197e66cf35..e87dec400a5b72 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -291,6 +291,9 @@ importers: '@types/pnpapi': specifier: ^0.0.5 version: 0.0.5 + '@vitest/utils': + specifier: ^3.2.4 + version: 3.2.4 artichokie: specifier: ^0.4.2 version: 0.4.2 @@ -1397,6 +1400,8 @@ importers: playground/resolve/utf8-bom-package: {} + playground/runtime-log: {} + playground/self-referencing: {} playground/ssr: