diff --git a/src/core/config/defaults.ts b/src/core/config/defaults.ts index b69f439ed8..7dddfabbae 100644 --- a/src/core/config/defaults.ts +++ b/src/core/config/defaults.ts @@ -58,7 +58,7 @@ export const NitroDefaults: NitroConfig = { baseURL: process.env.NITRO_APP_BASE_URL || "/", handlers: [], devHandlers: [], - errorHandler: join(runtimeDir, "internal/error"), + errorHandler: join(runtimeDir, "internal/error/handler"), routeRules: {}, prerender: { autoSubfolderIndex: true, diff --git a/src/core/config/resolvers/imports.ts b/src/core/config/resolvers/imports.ts index d3e82102da..7668350e81 100644 --- a/src/core/config/resolvers/imports.ts +++ b/src/core/config/resolvers/imports.ts @@ -103,7 +103,7 @@ function getNitroImportsPreset(): Preset[] { imports: ["defineTask", "runTask"], }, { - from: "nitropack/runtime/internal/error", + from: "nitropack/runtime/internal/error/utils", imports: ["defineNitroErrorHandler"], }, ]; diff --git a/src/runtime/error.ts b/src/runtime/error.ts index c08fefcf35..093887c87c 100644 --- a/src/runtime/error.ts +++ b/src/runtime/error.ts @@ -1,3 +1,121 @@ // Backward compatibility for imports from "#internal/nitro/*" or "nitropack/runtime/*" -export { default } from "./internal/error"; +import { + send, + setResponseHeader, + setResponseHeaders, + setResponseStatus, +} from "h3"; +import { defineNitroErrorHandler } from "./internal/error/utils"; +import { isJsonRequest, normalizeError } from "./utils"; + +export { defineNitroErrorHandler } from "./internal/error/utils"; + +const isDev = process.env.NODE_ENV === "development"; + +interface ParsedError { + url: string; + statusCode: number; + statusMessage: number; + message: string; + stack?: string[]; +} + +/** + * @deprecated This export is only provided for backward compatibility and will be removed in v3. + */ +export default defineNitroErrorHandler( + function defaultNitroErrorHandler(error, event) { + const { stack, statusCode, statusMessage, message } = normalizeError( + error, + isDev + ); + + const showDetails = isDev && statusCode !== 404; + + const errorObject = { + url: event.path || "", + statusCode, + statusMessage, + message, + stack: showDetails ? stack.map((i) => i.text) : undefined, + }; + + // Console output + if (error.unhandled || error.fatal) { + const tags = [ + "[nitro]", + "[request error]", + error.unhandled && "[unhandled]", + error.fatal && "[fatal]", + ] + .filter(Boolean) + .join(" "); + console.error( + tags, + error.message + "\n" + stack.map((l) => " " + l.text).join(" \n") + ); + } + + if (statusCode === 404) { + setResponseHeader(event, "Cache-Control", "no-cache"); + } + + // Security headers + setResponseHeaders(event, { + // Disable the execution of any js + "Content-Security-Policy": "script-src 'none'; frame-ancestors 'none';", + // 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", + }); + + setResponseStatus(event, statusCode, statusMessage); + + if (isJsonRequest(event)) { + setResponseHeader(event, "Content-Type", "application/json"); + return send(event, JSON.stringify(errorObject)); + } + setResponseHeader(event, "Content-Type", "text/html"); + return send(event, renderHTMLError(errorObject)); + } +); + +function renderHTMLError(error: ParsedError): string { + const statusCode = error.statusCode || 500; + const statusMessage = error.statusMessage || "Request Error"; + return ` + + + + + ${statusCode} ${statusMessage} + + + +
+ +
+
+

${statusCode} ${statusMessage}

+
+ + ${error.message}

+ ${ + "\n" + + (error.stack || []).map((i) => `  ${i}`).join("
") + } +
+ +
+
+
+ + +`; +} diff --git a/src/runtime/index.ts b/src/runtime/index.ts index 6c3a7ef2b5..f1c9cc6b7a 100644 --- a/src/runtime/index.ts +++ b/src/runtime/index.ts @@ -12,7 +12,7 @@ export { useStorage } from "./internal/storage"; // Type (only) helpers export { defineNitroPlugin } from "./internal/plugin"; export { defineRouteMeta } from "./internal/meta"; -export { defineNitroErrorHandler } from "./internal/error"; +export { defineNitroErrorHandler } from "./internal/error/utils"; // Renderer export { defineRenderHandler } from "./internal/renderer"; diff --git a/src/runtime/internal/error.ts b/src/runtime/internal/error/handler.ts similarity index 92% rename from src/runtime/internal/error.ts rename to src/runtime/internal/error/handler.ts index d8997c5f50..039f701485 100644 --- a/src/runtime/internal/error.ts +++ b/src/runtime/internal/error/handler.ts @@ -1,18 +1,15 @@ -// import ansiHTML from 'ansi-html' import { send, setResponseHeader, setResponseHeaders, setResponseStatus, } from "h3"; -import type { NitroErrorHandler } from "nitropack/types"; -import { isJsonRequest, normalizeError } from "./utils"; -export function defineNitroErrorHandler( - handler: NitroErrorHandler -): NitroErrorHandler { - return handler; -} +import { + defineNitroErrorHandler, + isJsonRequest, + normalizeError, +} from "./utils"; const isDev = process.env.NODE_ENV === "development"; diff --git a/src/runtime/internal/error/utils.ts b/src/runtime/internal/error/utils.ts new file mode 100644 index 0000000000..38458ae78a --- /dev/null +++ b/src/runtime/internal/error/utils.ts @@ -0,0 +1,74 @@ +import type { NitroErrorHandler } from "nitropack/types"; +import type { H3Event } from "h3"; +import { getRequestHeader } from "h3"; + +export function defineNitroErrorHandler( + handler: NitroErrorHandler +): NitroErrorHandler { + return handler; +} + +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") + ); +} + +function hasReqHeader(event: H3Event, name: string, includes: string) { + const value = getRequestHeader(event, name); + return ( + value && typeof value === "string" && value.toLowerCase().includes(includes) + ); +} + +export function normalizeError(error: any, isDev?: boolean) { + // 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() : "/"; + + const stack = + !isDev && !import.meta.prerender && (error.unhandled || error.fatal) + ? [] + : ((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 = + !isDev && error.unhandled + ? "internal server error" + : error.message || error.toString(); + + return { + stack, + statusCode, + statusMessage, + message, + }; +} diff --git a/src/runtime/internal/utils.ts b/src/runtime/internal/utils.ts index a8f1e979a5..88ee171e35 100644 --- a/src/runtime/internal/utils.ts +++ b/src/runtime/internal/utils.ts @@ -33,71 +33,6 @@ export async function useRequestBody( return URL.createObjectURL(blob); } -export function hasReqHeader(event: H3Event, name: string, includes: string) { - const value = getRequestHeader(event, name); - return ( - value && typeof value === "string" && value.toLowerCase().includes(includes) - ); -} - -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 normalizeError(error: any, isDev?: boolean) { - // 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() : "/"; - - const stack = - !isDev && !import.meta.prerender && (error.unhandled || error.fatal) - ? [] - : ((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 = - !isDev && error.unhandled - ? "internal server error" - : error.message || error.toString(); - - return { - stack, - statusCode, - statusMessage, - message, - }; -} - function _captureError(error: Error, type: string) { console.error(`[nitro] [${type}]`, error); useNitroApp().captureError(error, { tags: [type] }); diff --git a/src/runtime/utils.ts b/src/runtime/utils.ts index 2b7ecfb52c..153c191d41 100644 --- a/src/runtime/utils.ts +++ b/src/runtime/utils.ts @@ -1,2 +1,77 @@ // Backward compatibility for imports from "#internal/nitro/*" or "nitropack/runtime/*" -export { isJsonRequest, normalizeError } from "./internal/utils"; +import type { H3Event } from "h3"; +import { getRequestHeader } from "h3"; + +/** + * @deprecated This util is only provided for backward compatibility and will be removed in v3. + */ +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") + ); +} + +/** + * Internal + */ +function hasReqHeader(event: H3Event, name: string, includes: string) { + const value = getRequestHeader(event, name); + return ( + value && typeof value === "string" && value.toLowerCase().includes(includes) + ); +} + +/** + * @deprecated This util is only provided for backward compatibility and will be removed in v3. + */ +export function normalizeError(error: any, isDev?: boolean) { + // 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() : "/"; + + const stack = + !isDev && !import.meta.prerender && (error.unhandled || error.fatal) + ? [] + : ((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 = + !isDev && error.unhandled + ? "internal server error" + : error.message || error.toString(); + + return { + stack, + statusCode, + statusMessage, + message, + }; +}