mirror of
https://github.com/nuxt/nuxt.git
synced 2025-02-07 17:32:31 +00:00
feat(nuxt): better formatting errors
This commit is contained in:
parent
16d213bbdc
commit
5eb6411327
@ -19,6 +19,7 @@ import { toArray } from '../utils'
|
|||||||
import { template as defaultSpaLoadingTemplate } from '../../../ui-templates/dist/templates/spa-loading-icon'
|
import { template as defaultSpaLoadingTemplate } from '../../../ui-templates/dist/templates/spa-loading-icon'
|
||||||
import { createImportProtectionPatterns } from './plugins/import-protection'
|
import { createImportProtectionPatterns } from './plugins/import-protection'
|
||||||
import { EXTENSION_RE } from './utils'
|
import { EXTENSION_RE } from './utils'
|
||||||
|
import { resolveErrorHandler } from './runtime/nitro/utils'
|
||||||
|
|
||||||
const logLevelMapReverse = {
|
const logLevelMapReverse = {
|
||||||
silent: 0,
|
silent: 0,
|
||||||
@ -234,7 +235,7 @@ export async function initNitro (nuxt: Nuxt & { _nitro?: Nitro }) {
|
|||||||
|
|
||||||
// add error handler
|
// add error handler
|
||||||
if (!nitroConfig.errorHandler && (nuxt.options.dev || !nuxt.options.experimental.noVueServer)) {
|
if (!nitroConfig.errorHandler && (nuxt.options.dev || !nuxt.options.experimental.noVueServer)) {
|
||||||
nitroConfig.errorHandler = resolve(distDir, 'core/runtime/nitro/error')
|
nitroConfig.errorHandler = resolve(distDir, resolveErrorHandler(nuxt.options.dev))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve user-provided paths
|
// Resolve user-provided paths
|
||||||
@ -520,7 +521,7 @@ export async function initNitro (nuxt: Nuxt & { _nitro?: Nitro }) {
|
|||||||
if (!nuxt.options.dev && nuxt.options.experimental.noVueServer) {
|
if (!nuxt.options.dev && nuxt.options.experimental.noVueServer) {
|
||||||
nitro.hooks.hook('rollup:before', (nitro) => {
|
nitro.hooks.hook('rollup:before', (nitro) => {
|
||||||
if (nitro.options.preset === 'nitro-prerender') {
|
if (nitro.options.preset === 'nitro-prerender') {
|
||||||
nitro.options.errorHandler = resolve(distDir, 'core/runtime/nitro/error')
|
nitro.options.errorHandler = resolve(distDir, resolveErrorHandler(nuxt.options.dev))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const nuxtErrorHandler = nitro.options.handlers.findIndex(h => h.route === '/__nuxt_error')
|
const nuxtErrorHandler = nitro.options.handlers.findIndex(h => h.route === '/__nuxt_error')
|
||||||
|
150
packages/nuxt/src/core/runtime/nitro/dev-error.ts
Normal file
150
packages/nuxt/src/core/runtime/nitro/dev-error.ts
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
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}`
|
||||||
|
}
|
@ -1,153 +0,0 @@
|
|||||||
import { joinURL, withQuery } from 'ufo'
|
|
||||||
import type { NitroErrorHandler } from 'nitro/types'
|
|
||||||
import type { H3Error, H3Event } from 'h3'
|
|
||||||
import { getRequestHeader, getRequestHeaders, send, setResponseHeader, setResponseStatus } from 'h3'
|
|
||||||
import { useNitroApp, useRuntimeConfig } from 'nitro/runtime'
|
|
||||||
import type { NuxtPayload } from 'nuxt/app'
|
|
||||||
|
|
||||||
export default <NitroErrorHandler> async function errorhandler (error: H3Error, event) {
|
|
||||||
// Parse and normalize error
|
|
||||||
const { stack, statusCode, statusMessage, message } = normalizeError(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 (error.unhandled || error.fatal) {
|
|
||||||
const tags = [
|
|
||||||
'[nuxt]',
|
|
||||||
'[request error]',
|
|
||||||
error.unhandled && '[unhandled]',
|
|
||||||
error.fatal && '[fatal]',
|
|
||||||
Number(errorObject.statusCode) !== 200 && `[${errorObject.statusCode}]`,
|
|
||||||
].filter(Boolean).join(' ')
|
|
||||||
console.error(tags, (error.message || error.toString() || 'internal server error') + '\n' + stack.map(l => ' ' + l.text).join(' \n'))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.handled) { return }
|
|
||||||
|
|
||||||
// Set response code and message
|
|
||||||
setResponseStatus(event, (errorObject.statusCode !== 200 && errorObject.statusCode) as any as number || 500, errorObject.statusMessage)
|
|
||||||
|
|
||||||
// JSON response
|
|
||||||
if (isJsonRequest(event)) {
|
|
||||||
setResponseHeader(event, 'Content-Type', 'application/json')
|
|
||||||
return send(event, JSON.stringify(errorObject))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Nitro internal functions extracted from https://github.com/nitrojs/nitro/blob/main/src/runtime/internal/utils.ts
|
|
||||||
*/
|
|
||||||
|
|
||||||
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 statusCode = error.statusCode || 500
|
|
||||||
const statusMessage = error.statusMessage ?? (statusCode === 404 ? 'Not Found' : '')
|
|
||||||
const message = hideDetails ? 'internal server error' : (error.message || error.toString())
|
|
||||||
|
|
||||||
return {
|
|
||||||
stack,
|
|
||||||
statusCode,
|
|
||||||
statusMessage,
|
|
||||||
message,
|
|
||||||
}
|
|
||||||
}
|
|
54
packages/nuxt/src/core/runtime/nitro/prod-error.ts
Normal file
54
packages/nuxt/src/core/runtime/nitro/prod-error.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
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',
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
28
packages/nuxt/src/core/runtime/nitro/utils.ts
Normal file
28
packages/nuxt/src/core/runtime/nitro/utils.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { join } from 'node:path'
|
||||||
|
import { type H3Event, setResponseHeaders } from 'h3'
|
||||||
|
import type { NitroErrorHandler } from 'nitro/types'
|
||||||
|
|
||||||
|
export function defineNitroErrorHandler (
|
||||||
|
handler: NitroErrorHandler,
|
||||||
|
): NitroErrorHandler {
|
||||||
|
return handler
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSecurityHeaders (event: H3Event, allowjs = false) {
|
||||||
|
setResponseHeaders(event, {
|
||||||
|
// Prevent browser from guessing the MIME types of resources.
|
||||||
|
'X-Content-Type-Options': 'nosniff',
|
||||||
|
// Prevent error page from being embedded in an iframe
|
||||||
|
'X-Frame-Options': 'DENY',
|
||||||
|
// Prevent browsers from sending the Referer header
|
||||||
|
'Referrer-Policy': 'no-referrer',
|
||||||
|
// Disable the execution of any js
|
||||||
|
'Content-Security-Policy': allowjs
|
||||||
|
? 'script-src \'self\' \'unsafe-inline\'; object-src \'none\'; base-uri \'self\';'
|
||||||
|
: 'script-src \'none\'; frame-ancestors \'none\';',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveErrorHandler (isDev: boolean) {
|
||||||
|
return join('core/runtime/nitro/', (isDev ? 'dev' : 'prod') + '-error')
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user