mirror of
https://github.com/nuxt/nuxt.git
synced 2025-03-20 16:25:55 +00:00
feat(nuxt): align nuxt error handling with nitro (#31230)
Co-authored-by: Daniel Philip Johnson <daniel-philip-johnson@outlook.com> Co-authored-by: Saeid Zareie <saeid.za98@gmail.com>
This commit is contained in:
parent
726c029b33
commit
8109d8625b
@ -23,7 +23,7 @@ export default createConfigForNuxt({
|
|||||||
'packages/schema/schema/**',
|
'packages/schema/schema/**',
|
||||||
'packages/nuxt/src/app/components/welcome.vue',
|
'packages/nuxt/src/app/components/welcome.vue',
|
||||||
'packages/nuxt/src/app/components/error-*.vue',
|
'packages/nuxt/src/app/components/error-*.vue',
|
||||||
'packages/nuxt/src/core/runtime/nitro/handlers/error-*',
|
'packages/nuxt/src/core/runtime/nitro/templates/error-*',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -11,8 +11,9 @@ export const NUXT_ERROR_SIGNATURE = '__nuxt_error'
|
|||||||
/** @since 3.0.0 */
|
/** @since 3.0.0 */
|
||||||
export const useError = (): Ref<NuxtPayload['error']> => toRef(useNuxtApp().payload, 'error')
|
export const useError = (): Ref<NuxtPayload['error']> => toRef(useNuxtApp().payload, 'error')
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
export interface NuxtError<DataT = unknown> extends H3Error<DataT> {
|
||||||
export interface NuxtError<DataT = unknown> extends H3Error<DataT> {}
|
error?: true
|
||||||
|
}
|
||||||
|
|
||||||
/** @since 3.0.0 */
|
/** @since 3.0.0 */
|
||||||
export const showError = <DataT = unknown>(
|
export const showError = <DataT = unknown>(
|
||||||
|
@ -1,49 +1,39 @@
|
|||||||
import { joinURL, withQuery } from 'ufo'
|
import { joinURL, withQuery } from 'ufo'
|
||||||
import type { NitroErrorHandler } from 'nitro/types'
|
import type { NitroErrorHandler } from 'nitro/types'
|
||||||
import type { H3Error, H3Event } from 'h3'
|
import { getRequestHeaders, send, setResponseHeader, setResponseHeaders, setResponseStatus } from 'h3'
|
||||||
import { getRequestHeader, getRequestHeaders, send, setResponseHeader, setResponseStatus } from 'h3'
|
|
||||||
import { useNitroApp, useRuntimeConfig } from 'nitro/runtime'
|
import { useNitroApp, useRuntimeConfig } from 'nitro/runtime'
|
||||||
import type { NuxtPayload } from 'nuxt/app'
|
import { isJsonRequest } from '../utils/error'
|
||||||
|
import type { NuxtPayload } from '#app/nuxt'
|
||||||
|
|
||||||
export default <NitroErrorHandler> async function errorhandler (error: H3Error, event) {
|
export default <NitroErrorHandler> async function errorhandler (error, event, { defaultHandler }) {
|
||||||
// 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)) {
|
if (isJsonRequest(event)) {
|
||||||
setResponseHeader(event, 'Content-Type', 'application/json')
|
// let Nitro handle JSON errors
|
||||||
return send(event, JSON.stringify(errorObject))
|
return
|
||||||
}
|
}
|
||||||
|
// invoke default Nitro error handler (which will log appropriately if required)
|
||||||
|
const defaultRes = await defaultHandler(error, event, { json: true })
|
||||||
|
|
||||||
|
// let Nitro handle redirect if appropriate
|
||||||
|
const statusCode = error.statusCode || 500
|
||||||
|
if (statusCode === 404 && defaultRes.status === 302) {
|
||||||
|
setResponseHeaders(event, defaultRes.headers)
|
||||||
|
setResponseStatus(event, defaultRes.status, defaultRes.statusText)
|
||||||
|
return send(event, JSON.stringify(defaultRes.body, null, 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.dev && typeof defaultRes.body !== 'string' && Array.isArray(defaultRes.body.stack)) {
|
||||||
|
// normalize to string format expected by nuxt `error.vue`
|
||||||
|
defaultRes.body.stack = defaultRes.body.stack.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorObject = defaultRes.body as Pick<NonNullable<NuxtPayload['error']>, 'error' | 'statusCode' | 'statusMessage' | 'message' | 'stack'> & { url: string, data: any }
|
||||||
|
errorObject.message ||= 'Server Error'
|
||||||
|
|
||||||
|
delete defaultRes.headers['content-type'] // this would be set to application/json
|
||||||
|
delete defaultRes.headers['content-security-policy'] // this would disable JS execution in the error page
|
||||||
|
|
||||||
|
setResponseHeaders(event, defaultRes.headers)
|
||||||
|
|
||||||
// Access request headers
|
// Access request headers
|
||||||
const reqHeaders = getRequestHeaders(event)
|
const reqHeaders = getRequestHeaders(event)
|
||||||
@ -62,92 +52,24 @@ export default <NitroErrorHandler> async function errorhandler (error: H3Error,
|
|||||||
},
|
},
|
||||||
).catch(() => null)
|
).catch(() => null)
|
||||||
|
|
||||||
|
if (event.handled) { return }
|
||||||
|
|
||||||
// Fallback to static rendered error page
|
// Fallback to static rendered error page
|
||||||
if (!res) {
|
if (!res) {
|
||||||
const { template } = import.meta.dev ? await import('./error-dev') : await import('./error-500')
|
const { template } = import.meta.dev ? await import('../templates/error-dev') : await import('../templates/error-500')
|
||||||
if (import.meta.dev) {
|
if (import.meta.dev) {
|
||||||
// TODO: Support `message` in template
|
// TODO: Support `message` in template
|
||||||
(errorObject as any).description = errorObject.message
|
(errorObject as any).description = errorObject.message
|
||||||
}
|
}
|
||||||
if (event.handled) { return }
|
|
||||||
setResponseHeader(event, 'Content-Type', 'text/html;charset=UTF-8')
|
setResponseHeader(event, 'Content-Type', 'text/html;charset=UTF-8')
|
||||||
return send(event, template(errorObject))
|
return send(event, template(errorObject))
|
||||||
}
|
}
|
||||||
|
|
||||||
const html = await res.text()
|
const html = await res.text()
|
||||||
if (event.handled) { return }
|
|
||||||
|
|
||||||
for (const [header, value] of res.headers.entries()) {
|
for (const [header, value] of res.headers.entries()) {
|
||||||
setResponseHeader(event, header, value)
|
setResponseHeader(event, header, value)
|
||||||
}
|
}
|
||||||
setResponseStatus(event, res.status && res.status !== 200 ? res.status : undefined, res.statusText)
|
setResponseStatus(event, res.status && res.status !== 200 ? res.status : defaultRes.status, res.statusText || defaultRes.statusText)
|
||||||
|
|
||||||
return send(event, html)
|
return send(event, html)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Nitro internal functions extracted from https://github.com/nitrojs/nitro/blob/v2/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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
27
packages/nuxt/src/core/runtime/nitro/utils/error.ts
Normal file
27
packages/nuxt/src/core/runtime/nitro/utils/error.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { type H3Event, getRequestHeader } from 'h3'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nitro internal functions extracted from https://github.com/nitrojs/nitro/blob/main/src/runtime/internal/utils.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
export 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')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasReqHeader (event: H3Event, name: string, includes: string) {
|
||||||
|
const value = getRequestHeader(event, name)
|
||||||
|
return (
|
||||||
|
value && typeof value === 'string' && value.toLowerCase().includes(includes)
|
||||||
|
)
|
||||||
|
}
|
@ -1160,11 +1160,14 @@ describe('errors', () => {
|
|||||||
expect(res.statusText).toBe('This is a custom error')
|
expect(res.statusText).toBe('This is a custom error')
|
||||||
const error = await res.json()
|
const error = await res.json()
|
||||||
delete error.stack
|
delete error.stack
|
||||||
|
const url = new URL(error.url)
|
||||||
|
url.host = 'localhost:3000'
|
||||||
|
error.url = url.toString()
|
||||||
expect(error).toMatchObject({
|
expect(error).toMatchObject({
|
||||||
message: 'This is a custom error',
|
message: isDev() ? 'This is a custom error' : 'Server Error',
|
||||||
statusCode: 422,
|
statusCode: 422,
|
||||||
statusMessage: 'This is a custom error',
|
statusMessage: 'This is a custom error',
|
||||||
url: '/error',
|
url: 'http://localhost:3000/error',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -1183,12 +1186,17 @@ describe('errors', () => {
|
|||||||
expect(res.status).toBe(404)
|
expect(res.status).toBe(404)
|
||||||
const error = await res.json()
|
const error = await res.json()
|
||||||
delete error.stack
|
delete error.stack
|
||||||
|
const url = new URL(error.url)
|
||||||
|
url.host = 'localhost:3000'
|
||||||
|
error.url = url.toString()
|
||||||
|
|
||||||
expect(error).toMatchInlineSnapshot(`
|
expect(error).toMatchInlineSnapshot(`
|
||||||
{
|
{
|
||||||
|
"error": true,
|
||||||
"message": "Page Not Found: /__nuxt_error",
|
"message": "Page Not Found: /__nuxt_error",
|
||||||
"statusCode": 404,
|
"statusCode": 404,
|
||||||
"statusMessage": "Page Not Found: /__nuxt_error",
|
"statusMessage": "Page Not Found: /__nuxt_error",
|
||||||
"url": "/__nuxt_error",
|
"url": "http://localhost:3000/__nuxt_error",
|
||||||
}
|
}
|
||||||
`)
|
`)
|
||||||
})
|
})
|
||||||
|
@ -58,7 +58,7 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM
|
|||||||
const serverDir = join(rootDir, '.output/server')
|
const serverDir = join(rootDir, '.output/server')
|
||||||
|
|
||||||
const serverStats = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir)
|
const serverStats = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir)
|
||||||
expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot(`"190k"`)
|
expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot(`"188k"`)
|
||||||
|
|
||||||
const modules = await analyzeSizes(['node_modules/**/*'], serverDir)
|
const modules = await analyzeSizes(['node_modules/**/*'], serverDir)
|
||||||
expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot(`"1384k"`)
|
expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot(`"1384k"`)
|
||||||
@ -95,7 +95,7 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM
|
|||||||
const serverDir = join(rootDir, '.output-inline/server')
|
const serverDir = join(rootDir, '.output-inline/server')
|
||||||
|
|
||||||
const serverStats = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir)
|
const serverStats = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir)
|
||||||
expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot(`"541k"`)
|
expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot(`"539k"`)
|
||||||
|
|
||||||
const modules = await analyzeSizes(['node_modules/**/*'], serverDir)
|
const modules = await analyzeSizes(['node_modules/**/*'], serverDir)
|
||||||
expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot(`"78.3k"`)
|
expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot(`"78.3k"`)
|
||||||
@ -117,7 +117,7 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM
|
|||||||
const serverDir = join(pagesRootDir, '.output/server')
|
const serverDir = join(pagesRootDir, '.output/server')
|
||||||
|
|
||||||
const serverStats = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir)
|
const serverStats = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir)
|
||||||
expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot(`"281k"`)
|
expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot(`"280k"`)
|
||||||
|
|
||||||
const modules = await analyzeSizes(['node_modules/**/*'], serverDir)
|
const modules = await analyzeSizes(['node_modules/**/*'], serverDir)
|
||||||
expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot(`"1395k"`)
|
expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot(`"1395k"`)
|
||||||
|
Loading…
Reference in New Issue
Block a user