Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
4 changes: 3 additions & 1 deletion knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,14 @@
"pkg-types",
"rc9",
"scule",
"source-map",
"srvx",
"tinyclip",
"ufo",
"uqr",
"verkit",
"youch"
"youch",
"youch-core"
]
},
"packages/create-nuxt": {
Expand Down
4 changes: 3 additions & 1 deletion packages/nuxi/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
}
}
100 changes: 98 additions & 2 deletions packages/nuxi/src/dev/error.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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<Error> & { 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,
Expand All @@ -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<string> {
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<void> {
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<void> {
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))
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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}`
}
21 changes: 19 additions & 2 deletions packages/nuxi/src/dev/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -205,6 +205,20 @@ export class NuxtDevServer extends EventEmitter<DevServerEventMap> {
}

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
Expand Down Expand Up @@ -252,7 +266,10 @@ export class NuxtDevServer extends EventEmitter<DevServerEventMap> {
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.'
Expand Down
4 changes: 3 additions & 1 deletion packages/nuxt-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,14 +64,16 @@
"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",
"tinyexec": "^1.2.4",
"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",
Expand Down
12 changes: 12 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading