fix: reverting prod error handling

This commit is contained in:
Saeid Zareie 2025-01-16 11:31:50 +03:30
parent 786156eef5
commit 7c40b7a705
5 changed files with 289 additions and 212 deletions

View File

@ -19,7 +19,6 @@ import { toArray } from '../utils'
import { template as defaultSpaLoadingTemplate } from '../../../ui-templates/dist/templates/spa-loading-icon'
import { createImportProtectionPatterns } from './plugins/import-protection'
import { EXTENSION_RE } from './utils'
import { resolveErrorHandler } from './runtime/nitro/utils'
const logLevelMapReverse = {
silent: 0,
@ -235,7 +234,7 @@ export async function initNitro (nuxt: Nuxt & { _nitro?: Nitro }) {
// add error handler
if (!nitroConfig.errorHandler && (nuxt.options.dev || !nuxt.options.experimental.noVueServer)) {
nitroConfig.errorHandler = resolve(distDir, resolveErrorHandler(nuxt.options.dev))
nitroConfig.errorHandler = resolve(distDir, 'core/runtime/nitro/error')
}
// Resolve user-provided paths
@ -521,7 +520,7 @@ export async function initNitro (nuxt: Nuxt & { _nitro?: Nitro }) {
if (!nuxt.options.dev && nuxt.options.experimental.noVueServer) {
nitro.hooks.hook('rollup:before', (nitro) => {
if (nitro.options.preset === 'nitro-prerender') {
nitro.options.errorHandler = resolve(distDir, resolveErrorHandler(nuxt.options.dev))
nitro.options.errorHandler = resolve(distDir, 'core/runtime/nitro/error')
return
}
const nuxtErrorHandler = nitro.options.handlers.findIndex(h => h.route === '/__nuxt_error')

View File

@ -1,150 +0,0 @@
import { readFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import type { H3Event } from 'h3'
import {
getRequestHeader,
getRequestHeaders,
getRequestURL,
getResponseHeader,
send,
setResponseHeader,
setResponseStatus,
} from 'h3'
import consola from 'consola'
import { ErrorParser } from 'youch-core'
import { Youch } from 'youch'
import { SourceMapConsumer } from 'source-map'
import { defineNitroErrorHandler, setSecurityHeaders } from './utils'
export default defineNitroErrorHandler(
async function defaultNitroErrorHandler (error, _event) {
const isSensitive = error.unhandled || error.fatal
const statusCode = error.statusCode || 500
const statusMessage = error.statusMessage || 'Server Error'
// prettier-ignore
const event = _event as H3Event
const url = getRequestURL(event, { xForwardedHost: true, xForwardedProto: true }).toString()
// Load stack trace with source maps
await loadStackTrace(error).catch(consola.error)
// https://github.com/poppinss/youch
const youch = new Youch()
// Console output
if (isSensitive) {
// prettier-ignore
const tags = [error.unhandled && '[unhandled]', error.fatal && '[fatal]'].filter(Boolean).join(' ')
const columns = process.stderr.columns
if (!columns) {
process.stdout.columns = 90 // Temporary workaround for youch wrapping issue
}
const ansiError = await (
await youch.toANSI(error)
).replaceAll(process.cwd(), '.')
if (!columns) {
process.stderr.columns = columns
}
consola.error(
`[nitro] [request error] ${tags} [${event.method}] ${url}\n\n`,
ansiError,
)
}
// Send response
setResponseStatus(event, statusCode, statusMessage)
setSecurityHeaders(event, true /* allow js */)
if (statusCode === 404 || !getResponseHeader(event, 'cache-control')) {
setResponseHeader(event, 'cache-control', 'no-cache')
}
return getRequestHeader(event, 'accept')?.includes('text/html')
? send(
event,
await youch.toHTML(error, {
request: {
url,
method: event.method,
headers: getRequestHeaders(event),
},
}),
'text/html',
)
: send(
event,
JSON.stringify(
{
error: true,
url,
statusCode,
statusMessage,
message: error.message,
data: error.data,
stack: error.stack?.split('\n').map(line => line.trim()),
},
null,
2,
),
'application/json',
)
},
)
// ---- Source Map support ----
export async function loadStackTrace (error: any) {
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(consola.error)
}
}
type SourceLoader = Parameters<ErrorParser['defineSourceLoader']>[0]
type StackFrame = Parameters<SourceLoader>[0]
async function sourceLoader (frame: StackFrame) {
if (!frame.fileName || frame.fileType !== 'fs' || frame.type === 'native') {
return
}
if (frame.type === 'app') {
// prettier-ignore
const rawSourceMap = await readFile(`${frame.fileName}.map`, 'utf8').catch(() => {})
if (rawSourceMap) {
const consumer = await new SourceMapConsumer(rawSourceMap)
// prettier-ignore
const originalPosition = consumer.originalPositionFor({ line: frame.lineNumber!, column: frame.columnNumber! })
if (originalPosition.source && originalPosition.line) {
// prettier-ignore
frame.fileName = resolve(dirname(frame.fileName), originalPosition.source)
frame.lineNumber = originalPosition.line
frame.columnNumber = originalPosition.column || 0
}
}
}
const contents = await readFile(frame.fileName, 'utf8').catch(() => {})
return contents ? { contents } : undefined
}
function fmtFrame (frame: StackFrame) {
if (frame.type === 'native') {
return frame.raw
}
const src = `${frame.fileName || ''}:${frame.lineNumber}:${frame.columnNumber})`
return frame.functionName ? `at ${frame.functionName} (${src}` : `at ${src}`
}

View File

@ -0,0 +1,287 @@
import { readFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import type { H3Event } from 'h3'
import {
getRequestHeader,
getRequestHeaders,
getRequestURL,
getResponseHeader,
send,
setResponseHeader,
setResponseStatus,
} from 'h3'
import consola from 'consola'
import { ErrorParser } from 'youch-core'
import { Youch } from 'youch'
import { SourceMapConsumer } from 'source-map'
import { useNitroApp, useRuntimeConfig } from 'nitro/runtime'
import { joinURL, withQuery } from 'ufo'
import type { NuxtPayload } from 'nuxt/app'
import { defineNitroErrorHandler, setSecurityHeaders } from './utils'
export default defineNitroErrorHandler(
async function defaultNitroErrorHandler (error, event) {
const { stack, message, isSensitive, statusCode, statusMessage } = normalizeError(error)
const url = getRequestURL(event, { xForwardedHost: true, xForwardedProto: true }).toString()
// https://github.com/poppinss/youch
const youch = new Youch()
if (import.meta.dev) {
// Load stack trace with source maps
await loadStackTrace(error).catch(consola.error)
}
// Create an error object
const errorObject = {
url: event.path,
statusCode,
statusMessage,
message,
stack: import.meta.dev && statusCode !== 404
? `<pre>${stack.map(i => `<span class="stack${i.internal ? ' internal' : ''}">${i.text}</span>`).join('\n')}</pre>`
: '',
// TODO: check and validate error.data for serialisation into query
data: error.data as any,
} satisfies Partial<NuxtPayload['error']> & { url: string }
// Console output
if (isSensitive) {
let errorToLog: string = ''
const tags = [
'[nuxt]',
'[request error]',
error.unhandled && '[unhandled]',
error.fatal && '[fatal]',
Number(statusCode) !== 200 && `[${statusCode}]`,
].filter(Boolean).join(' ')
if (import.meta.dev) {
const columns = process.stderr.columns
if (!columns) {
process.stdout.columns = 90 // Temporary workaround for youch wrapping issue
}
const ansiError = (
await youch.toANSI(error)
).replaceAll(process.cwd(), '.')
if (!columns) {
process.stderr.columns = columns
}
errorToLog = ansiError
} else {
errorToLog = error.message || error.toString() || 'internal server error'
}
console.error(`${tags} [${event.method}] ${url}\n\n`, errorToLog)
}
if (event.handled) { return }
// Send response
setResponseStatus(event, (statusCode !== 200 && statusCode) as any as number || 500, statusMessage)
setSecurityHeaders(event, true /* allow js */)
if (statusCode === 404 || !getResponseHeader(event, 'cache-control')) {
setResponseHeader(event, 'cache-control', 'no-cache')
}
const isHtml = getRequestHeader(event, 'accept')?.includes('text/html')
if (isHtml && import.meta.dev) {
return send(
event,
await youch.toHTML(error, {
request: {
url,
method: event.method,
headers: getRequestHeaders(event),
},
}),
'text/html',
)
}
// JSON response
if (isJsonRequest(event)) {
return send(
event,
JSON.stringify(
{
error: true,
url,
statusCode,
statusMessage,
message: error.message,
data: error.data,
stack: error.stack?.split('\n').map(line => line.trim()),
},
null,
2,
),
'application/json',
)
}
// Access request headers
const reqHeaders = getRequestHeaders(event)
// Detect to avoid recursion in SSR rendering of errors
const isRenderingError = event.path.startsWith('/__nuxt_error') || !!reqHeaders['x-nuxt-error']
// HTML response (via SSR)
const res = isRenderingError
? null
: await useNitroApp().localFetch(
withQuery(joinURL(useRuntimeConfig(event).app.baseURL, '/__nuxt_error'), errorObject),
{
headers: { ...reqHeaders, 'x-nuxt-error': 'true' },
redirect: 'manual',
},
).catch(() => null)
// Fallback to static rendered error page
if (!res) {
const { template } = import.meta.dev ? await import('./error-dev') : await import('./error-500')
if (import.meta.dev) {
// TODO: Support `message` in template
(errorObject as any).description = errorObject.message
}
if (event.handled) { return }
setResponseHeader(event, 'Content-Type', 'text/html;charset=UTF-8')
return send(event, template(errorObject))
}
const html = await res.text()
if (event.handled) { return }
for (const [header, value] of res.headers.entries()) {
setResponseHeader(event, header, value)
}
setResponseStatus(event, res.status && res.status !== 200 ? res.status : undefined, res.statusText)
return send(event, html)
},
)
// ---- Source Map support ----
export async function loadStackTrace (error: any) {
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(consola.error)
}
}
type SourceLoader = Parameters<ErrorParser['defineSourceLoader']>[0]
type StackFrame = Parameters<SourceLoader>[0]
async function sourceLoader (frame: StackFrame) {
if (!frame.fileName || frame.fileType !== 'fs' || frame.type === 'native') {
return
}
if (frame.type === 'app') {
// prettier-ignore
const rawSourceMap = await readFile(`${frame.fileName}.map`, 'utf8').catch(() => { })
if (rawSourceMap) {
const consumer = await new SourceMapConsumer(rawSourceMap)
// prettier-ignore
const originalPosition = consumer.originalPositionFor({ line: frame.lineNumber!, column: frame.columnNumber! })
if (originalPosition.source && originalPosition.line) {
// prettier-ignore
frame.fileName = resolve(dirname(frame.fileName), originalPosition.source)
frame.lineNumber = originalPosition.line
frame.columnNumber = originalPosition.column || 0
}
}
}
const contents = await readFile(frame.fileName, 'utf8').catch(() => { })
return contents ? { contents } : undefined
}
function fmtFrame (frame: StackFrame) {
if (frame.type === 'native') {
return frame.raw
}
const src = `${frame.fileName || ''}:${frame.lineNumber}:${frame.columnNumber})`
return frame.functionName ? `at ${frame.functionName} (${src}` : `at ${src}`
}
function isJsonRequest (event: H3Event) {
// If the client specifically requests HTML, then avoid classifying as JSON.
if (hasReqHeader(event, 'accept', 'text/html')) {
return false
}
return (
hasReqHeader(event, 'accept', 'application/json') ||
hasReqHeader(event, 'user-agent', 'curl/') ||
hasReqHeader(event, 'user-agent', 'httpie/') ||
hasReqHeader(event, 'sec-fetch-mode', 'cors') ||
event.path.startsWith('/api/') ||
event.path.endsWith('.json')
)
}
function hasReqHeader (event: H3Event, name: string, includes: string) {
const value = getRequestHeader(event, name)
return (
value && typeof value === 'string' && value.toLowerCase().includes(includes)
)
}
function normalizeError (error: any) {
// temp fix for https://github.com/nitrojs/nitro/issues/759
// TODO: investigate vercel-edge not using unenv pollyfill
const cwd = typeof process.cwd === 'function' ? process.cwd() : '/'
// Hide details of unhandled/fatal errors in production
const hideDetails = !import.meta.dev && error.unhandled
const stack = hideDetails && !import.meta.prerender
? []
: ((error.stack as string) || '')
.split('\n')
.splice(1)
.filter(line => line.includes('at '))
.map((line) => {
const text = line
.replace(cwd + '/', './')
.replace('webpack:/', '')
.replace('file://', '')
.trim()
return {
text,
internal:
(line.includes('node_modules') && !line.includes('.cache')) ||
line.includes('internal') ||
line.includes('new Promise'),
}
})
const message = hideDetails ? 'internal server error' : (error.message || error.toString())
const isSensitive = error.unhandled || error.fatal
const statusCode = error.statusCode || 500
const statusMessage = error.statusMessage || 'Server Error'
return {
stack,
message,
isSensitive,
statusCode,
statusMessage,
}
}

View File

@ -1,54 +0,0 @@
import type {
H3Event } from 'h3'
import {
getRequestURL,
getResponseHeader,
send,
setResponseHeader,
setResponseStatus,
} from 'h3'
import { defineNitroErrorHandler, setSecurityHeaders } from './utils'
export default defineNitroErrorHandler(
function defaultNitroErrorHandler (error, _event) {
const isSensitive = error.unhandled || error.fatal
const statusCode = error.statusCode || 500
const statusMessage = error.statusMessage || 'Server Error'
const event = _event as H3Event
// prettier-ignore
const url = getRequestURL(event, { xForwardedHost: true, xForwardedProto: true }).toString()
// Console output
if (isSensitive) {
// prettier-ignore
const tags = [error.unhandled && '[unhandled]', error.fatal && '[fatal]'].filter(Boolean).join(' ')
console.error(
`[nitro] [request error] ${tags} [${event.method}] ${url}\n`,
error,
)
}
// Send response
setSecurityHeaders(event, false /* no js */)
setResponseStatus(event, statusCode, statusMessage)
if (statusCode === 404 || !getResponseHeader(event, 'cache-control')) {
setResponseHeader(event, 'cache-control', 'no-cache')
}
return send(
event,
JSON.stringify(
{
error: true,
url,
statusCode,
statusMessage,
message: isSensitive ? 'Server Error' : error.message,
data: isSensitive ? undefined : error.data,
},
null,
2,
),
'application/json',
)
},
)

View File

@ -1,4 +1,3 @@
import { join } from 'node:path'
import { type H3Event, setResponseHeaders } from 'h3'
import type { NitroErrorHandler } from 'nitro/types'
@ -22,7 +21,3 @@ export function setSecurityHeaders (event: H3Event, allowjs = false) {
: 'script-src \'none\'; frame-ancestors \'none\';',
})
}
export function resolveErrorHandler (isDev: boolean) {
return join('core/runtime/nitro/', (isDev ? 'dev' : 'prod') + '-error')
}