From 7c40b7a705b356bdba513e4aae992062c87e1458 Mon Sep 17 00:00:00 2001 From: Saeid Zareie Date: Thu, 16 Jan 2025 11:31:50 +0330 Subject: [PATCH] fix: reverting prod error handling --- packages/nuxt/src/core/nitro.ts | 5 +- .../nuxt/src/core/runtime/nitro/dev-error.ts | 150 --------- packages/nuxt/src/core/runtime/nitro/error.ts | 287 ++++++++++++++++++ .../nuxt/src/core/runtime/nitro/prod-error.ts | 54 ---- packages/nuxt/src/core/runtime/nitro/utils.ts | 5 - 5 files changed, 289 insertions(+), 212 deletions(-) delete mode 100644 packages/nuxt/src/core/runtime/nitro/dev-error.ts create mode 100644 packages/nuxt/src/core/runtime/nitro/error.ts delete mode 100644 packages/nuxt/src/core/runtime/nitro/prod-error.ts diff --git a/packages/nuxt/src/core/nitro.ts b/packages/nuxt/src/core/nitro.ts index da95dacdb1..085a2d8f80 100644 --- a/packages/nuxt/src/core/nitro.ts +++ b/packages/nuxt/src/core/nitro.ts @@ -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') diff --git a/packages/nuxt/src/core/runtime/nitro/dev-error.ts b/packages/nuxt/src/core/runtime/nitro/dev-error.ts deleted file mode 100644 index 60340fb702..0000000000 --- a/packages/nuxt/src/core/runtime/nitro/dev-error.ts +++ /dev/null @@ -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[0] -type StackFrame = Parameters[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}` -} diff --git a/packages/nuxt/src/core/runtime/nitro/error.ts b/packages/nuxt/src/core/runtime/nitro/error.ts new file mode 100644 index 0000000000..e1d63a1829 --- /dev/null +++ b/packages/nuxt/src/core/runtime/nitro/error.ts @@ -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 + ? `
${stack.map(i => `${i.text}`).join('\n')}
` + : '', + // TODO: check and validate error.data for serialisation into query + data: error.data as any, + } satisfies Partial & { 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[0] +type StackFrame = Parameters[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, + } +} diff --git a/packages/nuxt/src/core/runtime/nitro/prod-error.ts b/packages/nuxt/src/core/runtime/nitro/prod-error.ts deleted file mode 100644 index c10b99f55a..0000000000 --- a/packages/nuxt/src/core/runtime/nitro/prod-error.ts +++ /dev/null @@ -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', - ) - }, -) diff --git a/packages/nuxt/src/core/runtime/nitro/utils.ts b/packages/nuxt/src/core/runtime/nitro/utils.ts index 764bd2cff8..c71febf51e 100644 --- a/packages/nuxt/src/core/runtime/nitro/utils.ts +++ b/packages/nuxt/src/core/runtime/nitro/utils.ts @@ -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') -}