diff --git a/src/runtime/internal/error/dev.ts b/src/runtime/internal/error/dev.ts index 5e0fc2d846..0d9702f8f2 100644 --- a/src/runtime/internal/error/dev.ts +++ b/src/runtime/internal/error/dev.ts @@ -1,4 +1,4 @@ -import type { H3Event, HTTPError, HTTPEvent } from "h3"; +import { HTTPError, type HTTPEvent } from "h3"; import { getRequestURL } from "h3"; import { readFile } from "node:fs/promises"; import { resolve, dirname } from "node:path"; @@ -28,20 +28,18 @@ export async function defaultHandler( event: HTTPEvent, opts?: { silent?: boolean; json?: boolean } ): Promise { - const isSensitive = error.unhandled; - const status = error.status || 500; - // prettier-ignore - const url = getRequestURL(event, { xForwardedHost: true, xForwardedProto: true }) + const unhandled = error.unhandled ?? !HTTPError.isError(error); + const { status = 500, statusText = "" } = unhandled ? {} : error; + const url = getRequestURL(event, { xForwardedHost: true, xForwardedProto: true }); // Redirects with base URL if (status === 404) { const baseURL = import.meta.baseURL || "/"; if (/^\/[^/]/.test(baseURL) && !url.pathname.startsWith(baseURL)) { - const redirectTo = `${baseURL}${url.pathname.slice(1)}${url.search}`; return { status: 302, statusText: "Found", - headers: { location: redirectTo }, + headers: new Headers({ location: `${baseURL}${url.pathname.slice(1)}${url.search}` }), body: `Redirecting...`, }; } @@ -54,58 +52,43 @@ export async function defaultHandler( const youch = new Youch(); // Console output - if (isSensitive && !opts?.silent) { - // prettier-ignore - const tags = [error.unhandled && "[unhandled]"].filter(Boolean).join(" ") - const ansiError = await (await youch.toANSI(error)).replaceAll(process.cwd(), "."); - consola.error(`[request error] ${tags} [${event.req.method}] ${url}\n\n`, ansiError); + if (unhandled && !opts?.silent) { + const ansiError = (await youch.toANSI(error)).replaceAll(process.cwd(), "."); + consola.error(`[request error] [${event.req.method}] ${url}\n\n`, ansiError); } // Use HTML response only when user-agent expects it (browsers) const useJSON = opts?.json ?? !event.req.headers.get("accept")?.includes("text/html"); - // Prepare headers - const headers: HeadersInit = { - "content-type": useJSON ? "application/json" : "text/html", - // 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": - "script-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'self';", - }; - - if (status === 404 || !(event as H3Event).res.headers.has("cache-control")) { - headers["cache-control"] = "no-cache"; - } + const headers = new Headers(unhandled ? {} : error.headers); - // Prepare body - const body = useJSON - ? { + if (useJSON) { + headers.set("Content-Type", "application/json; charset=utf-8"); + return { + status, + statusText, + headers, + body: { error: true, - url, - status, - statusText: error.statusText, - message: error.message, - data: error.data, stack: error.stack?.split("\n").map((line) => line.trim()), - } - : await youch.toHTML(error, { - request: { - url: url.href, - method: event.req.method, - headers: Object.fromEntries(event.req.headers.entries()), - }, - }); + ...error?.toJSON?.(), + }, + }; + } + // HTML response + headers.set("Content-Type", "text/html; charset=utf-8"); return { status, - statusText: error.statusText, + statusText: unhandled ? "" : error.statusText, headers, - body, + body: await youch.toHTML(error, { + request: { + url: url.href, + method: event.req.method, + headers: Object.fromEntries(event.req.headers.entries()), + }, + }), }; } diff --git a/src/runtime/internal/error/prod.ts b/src/runtime/internal/error/prod.ts index 750be30fff..20a9d08267 100644 --- a/src/runtime/internal/error/prod.ts +++ b/src/runtime/internal/error/prod.ts @@ -1,4 +1,4 @@ -import type { H3Event, HTTPError, HTTPEvent } from "h3"; +import { HTTPError, type H3Event, type HTTPEvent } from "h3"; import type { InternalHandlerResponse } from "./utils.ts"; import { FastResponse } from "srvx"; import type { NitroErrorHandler } from "nitro/types"; @@ -13,61 +13,31 @@ const errorHandler: NitroErrorHandler = (error, event) => { export default errorHandler; -export function defaultHandler( - error: HTTPError, - event: HTTPEvent, - opts?: { silent?: boolean; json?: boolean } -): InternalHandlerResponse { - const isSensitive = error.unhandled; - const status = error.status || 500; - const url = (event as H3Event).url || new URL(event.req.url); +export function defaultHandler(error: HTTPError, event: HTTPEvent): InternalHandlerResponse { + const unhandled = error.unhandled ?? !HTTPError.isError(error); + const { status = 500, statusText = "" } = unhandled ? {} : error; if (status === 404) { + const url = (event as H3Event).url || new URL(event.req.url); const baseURL = import.meta.baseURL || "/"; if (/^\/[^/]/.test(baseURL) && !url.pathname.startsWith(baseURL)) { - const redirectTo = `${baseURL}${url.pathname.slice(1)}${url.search}`; return { status: 302, - statusText: "Found", - headers: { location: redirectTo }, - body: `Redirecting...`, + headers: new Headers({ location: `${baseURL}${url.pathname.slice(1)}${url.search}` }), }; } } - // Console output - if (isSensitive && !opts?.silent) { - // prettier-ignore - const tags = [error.unhandled && "[unhandled]"].filter(Boolean).join(" ") - console.error(`[request error] ${tags} [${event.req.method}] ${url}\n`, error); - } - - // Send response - const headers: HeadersInit = { - "content-type": "application/json", - "x-content-type-options": "nosniff", - "x-frame-options": "DENY", - "referrer-policy": "no-referrer", - "content-security-policy": "script-src 'none'; frame-ancestors 'none';", - }; - - if (status === 404 || !(event as H3Event).res.headers.has("cache-control")) { - headers["cache-control"] = "no-cache"; - } - - const body = { - error: true, - url: url.href, - status, - statusText: error.statusText, - message: isSensitive ? "Server Error" : error.message, - data: isSensitive ? undefined : error.data, - }; + const headers = new Headers(unhandled ? {} : error.headers); + headers.set("content-type", "application/json; charset=utf-8"); return { status, - statusText: error.statusText, + statusText, headers, - body, + body: { + error: true, + ...(unhandled ? { status, unhandled: true } : error.toJSON?.()), + }, }; } diff --git a/src/runtime/internal/error/utils.ts b/src/runtime/internal/error/utils.ts index b71329ba42..6a92d4988c 100644 --- a/src/runtime/internal/error/utils.ts +++ b/src/runtime/internal/error/utils.ts @@ -5,8 +5,8 @@ export function defineNitroErrorHandler(handler: NitroErrorHandler): NitroErrorH } export type InternalHandlerResponse = { - status: number; - statusText: string | undefined; - headers: Record; - body: string | Record; + status?: number; + statusText?: string | undefined; + headers?: Headers; + body?: string | Record; }; diff --git a/test/fixture/server/routes/api/error.ts b/test/fixture/server/routes/api/error.ts deleted file mode 100644 index ae679efd46..0000000000 --- a/test/fixture/server/routes/api/error.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { HTTPError } from "nitro/h3"; - -export default () => { - throw new HTTPError({ - status: 503, - statusText: "Service Unavailable", - }); -}; diff --git a/test/fixture/server/routes/api/errors.ts b/test/fixture/server/routes/errors/captured.ts similarity index 100% rename from test/fixture/server/routes/api/errors.ts rename to test/fixture/server/routes/errors/captured.ts diff --git a/test/fixture/server/routes/error-stack.ts b/test/fixture/server/routes/errors/stack.ts similarity index 100% rename from test/fixture/server/routes/error-stack.ts rename to test/fixture/server/routes/errors/stack.ts diff --git a/test/fixture/server/routes/errors/throw.ts b/test/fixture/server/routes/errors/throw.ts new file mode 100644 index 0000000000..905fad9585 --- /dev/null +++ b/test/fixture/server/routes/errors/throw.ts @@ -0,0 +1,22 @@ +import { H3Event, HTTPError } from "nitro/h3"; + +export default ({ url }: H3Event) => { + const unhandled = url.searchParams.has("unhandled"); + const shouldThrow = url.searchParams.get("action") === "throw"; + + const error = unhandled + ? new Error("Unhandled error") + : new HTTPError({ + status: 503, + statusText: "Custom Status Text", + message: "Handled error", + data: { custom: "data" }, + body: { custom: "body" }, + }); + + if (shouldThrow) { + throw error; + } + + return error; +}; diff --git a/test/presets/vercel.test.ts b/test/presets/vercel.test.ts index 732d2ebd33..e61e808760 100644 --- a/test/presets/vercel.test.ts +++ b/test/presets/vercel.test.ts @@ -244,8 +244,16 @@ describe("nitro:preset:vercel:web", async () => { "src": "/fetch", }, { - "dest": "/error-stack", - "src": "/error-stack", + "dest": "/errors/throw", + "src": "/errors/throw", + }, + { + "dest": "/errors/stack", + "src": "/errors/stack", + }, + { + "dest": "/errors/captured", + "src": "/errors/captured", }, { "dest": "/env", @@ -303,14 +311,6 @@ describe("nitro:preset:vercel:web", async () => { "dest": "/api/headers", "src": "/api/headers", }, - { - "dest": "/api/errors", - "src": "/api/errors", - }, - { - "dest": "/api/error", - "src": "/api/error", - }, { "dest": "/api/echo", "src": "/api/echo", @@ -421,8 +421,6 @@ describe("nitro:preset:vercel:web", async () => { "functions/api/cached.func (symlink)", "functions/api/db.func (symlink)", "functions/api/echo.func (symlink)", - "functions/api/error.func (symlink)", - "functions/api/errors.func (symlink)", "functions/api/headers.func (symlink)", "functions/api/hello.func (symlink)", "functions/api/hey.func (symlink)", @@ -441,7 +439,9 @@ describe("nitro:preset:vercel:web", async () => { "functions/config.func (symlink)", "functions/context.func (symlink)", "functions/env.func (symlink)", - "functions/error-stack.func (symlink)", + "functions/errors/captured.func (symlink)", + "functions/errors/stack.func (symlink)", + "functions/errors/throw.func (symlink)", "functions/fetch.func (symlink)", "functions/file.func (symlink)", "functions/icon.png.func (symlink)", diff --git a/test/tests.ts b/test/tests.ts index 684406edc3..a79beabcb1 100644 --- a/test/tests.ts +++ b/test/tests.ts @@ -19,7 +19,7 @@ import { fetch } from "ofetch"; import type { FetchOptions } from "ofetch"; import { join, resolve } from "pathe"; import { isWindows } from "std-env"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; export interface Context { preset: string; @@ -186,6 +186,7 @@ export async function startServer(ctx: Context, handle: RequestListener) { type TestHandlerResult = { data: any; status: number; + statusText?: string; headers: Record; }; type TestHandler = (options: any) => Promise; @@ -228,12 +229,16 @@ export function testNitro( } } headers["set-cookie"] = (result as Response).headers.getSetCookie(); + if (headers["set-cookie"].length === 0) { + delete headers["set-cookie"]; + } return { data: callOpts.binary ? Buffer.from(await (result as Response).arrayBuffer()) : destr(await (result as Response).text()), status: result.status, + statusText: result.statusText, headers, }; } @@ -396,32 +401,6 @@ export function testNitro( expect(base.headers["x-test"]).toBe("test"); }); - it("handles errors", async () => { - const { status, headers } = await callHandler({ - url: "/api/error", - headers: { - Accept: "application/json", - }, - }); - expect(status).toBe(503); - - expect(headers).toMatchObject({ - "content-type": "application/json", - "content-security-policy": ctx.isDev - ? "script-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'self';" - : "script-src 'none'; frame-ancestors 'none';", - "referrer-policy": "no-referrer", - "x-content-type-options": "nosniff", - "x-frame-options": "DENY", - }); - - const { data } = await callHandler({ - url: "/api/error?json", - }); - expect(status).toBe(503); - expect(data.json.error).toBe(true); - }); - it.skipIf( // TODO! ctx.preset === "vercel" && ctx.nitro?.options.vercel?.entryFormat === "node" && isWindows @@ -631,9 +610,10 @@ export function testNitro( describe("errors", () => { it.skipIf(ctx.isIsolated)("captures errors", async () => { - const { data } = await callHandler({ url: "/api/errors" }); + await callHandler({ url: "/errors/throw" }); + const { data } = await callHandler({ url: "/errors/captured" }); const allErrorMessages = (data.allErrors || []).map((entry: any) => entry.message); - expect(allErrorMessages).to.includes("Service Unavailable"); + expect(allErrorMessages).to.includes("Handled error"); }); it.skipIf( @@ -643,9 +623,76 @@ export function testNitro( ctx.preset === "deno-server" || ctx.preset === "nitro-dev" )("sourcemap works", async () => { - const { data } = await callHandler({ url: "/error-stack" }); - expect(data.stack).toMatch("test/fixture/server/routes/error-stack.ts"); - }); + const { data } = await callHandler({ url: "/errors/stack" }); + expect(data.stack).toMatch("test/fixture/server/routes/errors/stack.ts"); + }); + + for (const errorAction of ["throw", "return"]) { + it(`handled errors (${errorAction})`, async () => { + const res = await callHandler({ url: `/errors/throw?handled&action=${errorAction}` }); + expect(res).toMatchObject({ + status: 503, + statusText: /deno|bun/.test(ctx.preset) + ? "Service Unavailable" + : /aws/.test(ctx.preset) + ? "" + : "Custom Status Text", + headers: {}, + data: { + error: true, + status: 503, + statusText: "Custom Status Text", + message: "Handled error", + data: { custom: "data" }, + custom: "body", + }, + }); + }); + + it(`unhandled errors (${errorAction})`, async () => { + const stderrMock = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + const consoleErrorMock = vi.spyOn(console, "error").mockImplementation(() => {}); + const res = await callHandler({ + url: `/errors/throw?unhandled&action=${errorAction}`, + headers: { Accept: "application/json" }, + }); + stderrMock.mockRestore(); + consoleErrorMock.mockRestore(); + // TODO + // expect(consoleErrorMock).toHaveBeenCalledExactlyOnceWith( + // expect.stringContaining("Unhandled error") + // ); + if (!ctx.isDev) { + // Prod + expect(res).toMatchObject({ + status: 500, + headers: { + "content-type": "application/json; charset=utf-8", + }, + data: { + error: true, + unhandled: true, + status: 500, + }, + }); + } else { + // Dev + expect(res).toMatchObject({ + status: 500, + headers: { + "content-type": "application/json; charset=utf-8", + }, + data: { + error: true, + unhandled: true, + status: 500, + message: "HTTPError", + stack: expect.arrayContaining(["Unhandled error"]), + }, + }); + } + }); + } }); describe("async context", () => {