diff --git a/knip.json b/knip.json index 342eab88..91765faa 100644 --- a/knip.json +++ b/knip.json @@ -55,12 +55,14 @@ "pkg-types", "rc9", "scule", + "source-map", "srvx", "tinyclip", "ufo", "uqr", "verkit", - "youch" + "youch", + "youch-core" ] }, "packages/create-nuxt": { diff --git a/packages/nuxi/package.json b/packages/nuxi/package.json index ac7ba1d4..e7d7a6c9 100644 --- a/packages/nuxi/package.json +++ b/packages/nuxi/package.json @@ -67,6 +67,7 @@ "rollup": "^4.62.2", "rollup-plugin-visualizer": "^7.0.1", "scule": "^1.3.0", + "source-map": "^0.7.6", "srvx": "^0.11.22", "std-env": "^4.2.0", "tinyclip": "^0.1.15", @@ -78,6 +79,7 @@ "uqr": "^0.1.3", "verkit": "^0.3.0", "vitest": "^4.1.10", - "youch": "^4.1.1" + "youch": "^4.1.1", + "youch-core": "^0.3.3" } } diff --git a/packages/nuxi/src/dev/error.ts b/packages/nuxi/src/dev/error.ts index e6b9cbe8..50207b1d 100644 --- a/packages/nuxi/src/dev/error.ts +++ b/packages/nuxi/src/dev/error.ts @@ -1,5 +1,15 @@ import type { IncomingMessage, ServerResponse } from 'node:http' +import type { SourceLoader, StackFrame } from 'youch-core/types' + +import { readFile } from 'node:fs/promises' +import process from 'node:process' + +import { dirname, resolve } from 'pathe' +import { SourceMapConsumer } from 'source-map' import { Youch } from 'youch' +import { ErrorParser } from 'youch-core' + +import { debug } from '../utils/logger' export async function renderError(req: IncomingMessage, res: ServerResponse, error: unknown) { if (res.headersSent) { @@ -9,12 +19,32 @@ export async function renderError(req: IncomingMessage, res: ServerResponse, err return } - const youch = new Youch() + await loadStackTrace(error).catch(err => debug('Failed to load stack trace:', err)) + + const useJSON = !req.headers.accept?.includes('text/html') + res.statusCode = 500 - res.setHeader('Content-Type', 'text/html') + res.setHeader('Content-Type', useJSON ? 'application/json' : 'text/html') res.setHeader('Cache-Control', 'no-store') + res.setHeader('X-Content-Type-Options', 'nosniff') + res.setHeader('X-Frame-Options', 'DENY') + res.setHeader('Referrer-Policy', 'no-referrer') res.setHeader('Refresh', '3') + if (useJSON) { + const err = error as Partial & { data?: unknown } + res.end(JSON.stringify({ + error: true, + url: req.url, + status: 500, + message: err?.message || 'Unknown error', + data: err?.data, + stack: err?.stack?.split('\n').map(line => line.trim()), + }, null, 2)) + return + } + + const youch = new Youch() const html = await youch.toHTML(error, { request: { url: req.url, @@ -24,3 +54,69 @@ export async function renderError(req: IncomingMessage, res: ServerResponse, err }) res.end(html) } + +/** Render the error with source-mapped frames as ANSI for terminal output. */ +export async function renderErrorAnsi(error: unknown): Promise { + await loadStackTrace(error).catch(err => debug('Failed to load stack trace:', err)) + const ansi = await new Youch().toANSI(error) + return ansi.replaceAll(process.cwd(), '.') +} + +const sourceLoader: SourceLoader = async (frame) => { + if (!frame.fileName || frame.fileType !== 'fs' || frame.type === 'native') { + return + } + if (frame.type === 'app') { + await applySourceMap(frame).catch(error => debug(`Failed to source-map \`${frame.fileName}\`:`, error)) + } + const contents = await readFile(frame.fileName, 'utf8').catch(() => undefined) + return contents ? { contents } : undefined +} + +/** + * Rewrite a frame to its original position. Isolated per frame so a malformed + * `.map` costs only that frame's mapping rather than the whole stack. + */ +async function applySourceMap(frame: StackFrame): Promise { + const rawSourceMap = await readFile(`${frame.fileName}.map`, 'utf8').catch(() => undefined) + if (!rawSourceMap) { + return + } + // Consumers hold WASM memory that is only reclaimed by `destroy()`. + const consumer = await new SourceMapConsumer(rawSourceMap) + try { + const originalPosition = consumer.originalPositionFor({ + line: frame.lineNumber!, + column: frame.columnNumber!, + }) + if (originalPosition.source && originalPosition.line) { + frame.fileName = resolve(dirname(frame.fileName!), originalPosition.source) + frame.lineNumber = originalPosition.line + frame.columnNumber = originalPosition.column || 0 + } + } + finally { + consumer.destroy() + } +} + +/** Rewrite the error stack (and causes) with source-mapped file names and positions. */ +async function loadStackTrace(error: unknown): Promise { + if (!(error instanceof Error)) { + return + } + const parsed = await new ErrorParser().defineSourceLoader(sourceLoader).parse(error) + const stack = `${error.message}\n${parsed.frames.map(frame => fmtFrame(frame)).join('\n')}` + Object.defineProperty(error, 'stack', { value: stack }) + if (error.cause) { + await loadStackTrace(error.cause).catch(err => debug('Failed to load stack trace of cause:', err)) + } +} + +function fmtFrame(frame: StackFrame): string { + if (frame.type === 'native') { + return frame.raw ?? '' + } + const src = `${frame.fileName || ''}:${frame.lineNumber}:${frame.columnNumber}` + return frame.functionName ? ` at ${frame.functionName} (${src})` : ` at ${src}` +} diff --git a/packages/nuxi/src/dev/utils.ts b/packages/nuxi/src/dev/utils.ts index af06acb3..b23b9ed0 100644 --- a/packages/nuxi/src/dev/utils.ts +++ b/packages/nuxi/src/dev/utils.ts @@ -28,7 +28,7 @@ import { loadKit } from '../utils/kit' import { acquireLock, formatLockError, updateLock } from '../utils/lockfile' import { loadNuxtManifest, resolveNuxtManifest, writeNuxtManifest } from '../utils/nuxt' import { withNodePath } from '../utils/paths' -import { renderError } from './error' +import { renderError, renderErrorAnsi } from './error' import { listen } from './listen' export type NuxtParentIPCMessage @@ -205,6 +205,20 @@ export class NuxtDevServer extends EventEmitter { } res.statusCode = 503 + res.setHeader('Cache-Control', 'no-store') + res.setHeader('Refresh', '3') + + if (!req.headers.accept?.includes('text/html')) { + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ + error: true, + status: 503, + message: this.#loadingMessage || 'Dev server is loading...', + hint: 'Please retry once the dev server is ready.', + }, null, 2)) + return + } + res.setHeader('Content-Type', 'text/html') const loadingTemplate = this.options.loadingTemplate || this.#currentNuxt?.options.devServer.loadingTemplate @@ -252,7 +266,10 @@ export class NuxtDevServer extends EventEmitter { this.#loadingError = undefined } catch (error) { - console.error(`Cannot ${reload ? 'restart' : 'start'} nuxt: `, error) + console.error( + `Cannot ${reload ? 'restart' : 'start'} nuxt: `, + await renderErrorAnsi(error).catch(() => error), + ) this.#handler = undefined this.#loadingError = error as Error this.#loadingMessage = 'Error while loading Nuxt. Please check console and fix errors.' diff --git a/packages/nuxt-cli/package.json b/packages/nuxt-cli/package.json index dd8394a3..4b4c0ad5 100644 --- a/packages/nuxt-cli/package.json +++ b/packages/nuxt-cli/package.json @@ -64,6 +64,7 @@ "pkg-types": "^2.3.1", "rc9": "^3.0.1", "scule": "^1.3.0", + "source-map": "^0.7.6", "srvx": "^0.11.22", "std-env": "^4.2.0", "tinyclip": "^0.1.15", @@ -71,7 +72,8 @@ "ufo": "^1.6.4", "uqr": "^0.1.3", "verkit": "^0.3.0", - "youch": "^4.1.1" + "youch": "^4.1.1", + "youch-core": "^0.3.3" }, "devDependencies": { "@nuxt/kit": "^4.4.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 889f9e3f..0b0162eb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -217,6 +217,9 @@ importers: scule: specifier: ^1.3.0 version: 1.3.0 + source-map: + specifier: ^0.7.6 + version: 0.7.6 srvx: specifier: ^0.11.22 version: 0.11.22 @@ -253,6 +256,9 @@ importers: youch: specifier: ^4.1.1 version: 4.1.1 + youch-core: + specifier: ^0.3.3 + version: 0.3.3 packages/nuxt-cli: dependencies: @@ -325,6 +331,9 @@ importers: scule: specifier: ^1.3.0 version: 1.3.0 + source-map: + specifier: ^0.7.6 + version: 0.7.6 srvx: specifier: ^0.11.22 version: 0.11.22 @@ -349,6 +358,9 @@ importers: youch: specifier: ^4.1.1 version: 4.1.1 + youch-core: + specifier: ^0.3.3 + version: 0.3.3 devDependencies: '@nuxt/kit': specifier: ^4.4.6