From c5660f6440a85697670046d4209f9e8ef74e224a Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Tue, 24 Feb 2026 10:52:06 +0100 Subject: [PATCH 1/3] fix: make error handlers consistent with h3 --- src/runtime/internal/error/dev.ts | 76 +++++++++++------------------ src/runtime/internal/error/prod.ts | 44 ++++------------- src/runtime/internal/error/utils.ts | 8 +-- test/fixture/server/routes/error.ts | 22 +++++++++ test/tests.ts | 57 ++++++++++++++++++++++ 5 files changed, 121 insertions(+), 86 deletions(-) create mode 100644 test/fixture/server/routes/error.ts diff --git a/src/runtime/internal/error/dev.ts b/src/runtime/internal/error/dev.ts index 5e0fc2d846..80f3cc970a 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 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,42 @@ 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"; - } - - // Prepare body - const body = useJSON - ? { - error: true, - url, - status, - statusText: error.statusText, - message: error.message, - data: error.data, + if (useJSON) { + const headers = new Headers(error.headers); + headers.set("Content-Type", "application/json; charset=utf-8"); + return { + status, + statusText: error.statusText, + headers, + body: { + ...error?.toJSON?.(), 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()), - }, - }); + }, + }; + } + // HTML response + const headers = new Headers(error.headers); + headers.set("Content-Type", "text/html; charset=utf-8"); return { status, statusText: 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..371363f079 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"; @@ -18,56 +18,30 @@ export function defaultHandler( event: HTTPEvent, opts?: { silent?: boolean; json?: boolean } ): InternalHandlerResponse { - const isSensitive = error.unhandled; const status = error.status || 500; + const unhandled = error.unhandled ?? !HTTPError.isError(error); const url = (event as H3Event).url || new URL(event.req.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 }, - 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); + if (unhandled) { + !opts?.silent && + console.error(new Error(`[request error] [${event.req.method}] ${url}`, { cause: error })); + return { status }; } - // 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, - }; - return { status, statusText: error.statusText, - headers, - body, + headers: new Headers(error.headers), + body: 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/error.ts b/test/fixture/server/routes/error.ts new file mode 100644 index 0000000000..d5f89f1a21 --- /dev/null +++ b/test/fixture/server/routes/error.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: "Service Unavailable", + message: "Handled error", + data: { custom: "data" }, + body: { custom: "body" }, + }); + + if (shouldThrow) { + throw error; + } + + return error; +}; diff --git a/test/tests.ts b/test/tests.ts index 684406edc3..959189851a 100644 --- a/test/tests.ts +++ b/test/tests.ts @@ -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, }; } @@ -646,6 +651,58 @@ export function testNitro( const { data } = await callHandler({ url: "/error-stack" }); expect(data.stack).toMatch("test/fixture/server/routes/error-stack.ts"); }); + + for (const errorAction of ["throw", "return"]) { + it.only(`handled errors (${errorAction})`, async () => { + const res = await callHandler({ url: `/error?handled&action=${errorAction}` }); + console.log(res); + expect(res).toMatchObject({ + status: 503, + statusText: "Service Unavailable", + headers: {}, + data: { + message: "Handled error", + status: 503, + statusText: "Service Unavailable", + custom: "body", + data: { custom: "data" }, + }, + }); + }); + + it.only(`unhandled errors (${errorAction})`, async () => { + const res = await callHandler({ + url: `/error?unhandled&action=${errorAction}`, + headers: { Accept: "application/json" }, + }); + if (!ctx.isDev) { + // Prod + expect(res).toMatchObject({ + status: 500, + statusText: "", + headers: { + "content-type": "text/plain; charset=UTF-8", + }, + data: "", + }); + } else { + // Dev + expect(res).toMatchObject({ + status: 500, + statusText: "Internal Server Error", + headers: { + "content-type": "application/json; charset=utf-8", + }, + data: { + status: 500, + unhandled: true, + message: "HTTPError", + stack: expect.arrayContaining(["Unhandled error"]), + }, + }); + } + }); + } }); describe("async context", () => { From 234767ab7fc510462ebaa6411ede917181e8f87c Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Tue, 24 Feb 2026 11:56:03 +0100 Subject: [PATCH 2/3] up --- src/runtime/internal/error/dev.ts | 13 ++-- src/runtime/internal/error/prod.ts | 26 +++---- test/fixture/server/routes/api/error.ts | 8 --- .../{api/errors.ts => errors/captured.ts} | 0 .../{error-stack.ts => errors/stack.ts} | 0 .../routes/{error.ts => errors/throw.ts} | 0 test/presets/vercel.test.ts | 26 +++---- test/tests.ts | 72 ++++++++----------- 8 files changed, 61 insertions(+), 84 deletions(-) delete mode 100644 test/fixture/server/routes/api/error.ts rename test/fixture/server/routes/{api/errors.ts => errors/captured.ts} (100%) rename test/fixture/server/routes/{error-stack.ts => errors/stack.ts} (100%) rename test/fixture/server/routes/{error.ts => errors/throw.ts} (100%) diff --git a/src/runtime/internal/error/dev.ts b/src/runtime/internal/error/dev.ts index 80f3cc970a..0d9702f8f2 100644 --- a/src/runtime/internal/error/dev.ts +++ b/src/runtime/internal/error/dev.ts @@ -28,8 +28,8 @@ export async function defaultHandler( event: HTTPEvent, opts?: { silent?: boolean; json?: boolean } ): Promise { - const status = error.status || 500; 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 @@ -60,26 +60,27 @@ export async function defaultHandler( // Use HTML response only when user-agent expects it (browsers) const useJSON = opts?.json ?? !event.req.headers.get("accept")?.includes("text/html"); + const headers = new Headers(unhandled ? {} : error.headers); + if (useJSON) { - const headers = new Headers(error.headers); headers.set("Content-Type", "application/json; charset=utf-8"); return { status, - statusText: error.statusText, + statusText, headers, body: { - ...error?.toJSON?.(), + error: true, stack: error.stack?.split("\n").map((line) => line.trim()), + ...error?.toJSON?.(), }, }; } // HTML response - const headers = new Headers(error.headers); headers.set("Content-Type", "text/html; charset=utf-8"); return { status, - statusText: error.statusText, + statusText: unhandled ? "" : error.statusText, headers, body: await youch.toHTML(error, { request: { diff --git a/src/runtime/internal/error/prod.ts b/src/runtime/internal/error/prod.ts index 371363f079..20a9d08267 100644 --- a/src/runtime/internal/error/prod.ts +++ b/src/runtime/internal/error/prod.ts @@ -13,16 +13,12 @@ const errorHandler: NitroErrorHandler = (error, event) => { export default errorHandler; -export function defaultHandler( - error: HTTPError, - event: HTTPEvent, - opts?: { silent?: boolean; json?: boolean } -): InternalHandlerResponse { - const status = error.status || 500; +export function defaultHandler(error: HTTPError, event: HTTPEvent): InternalHandlerResponse { const unhandled = error.unhandled ?? !HTTPError.isError(error); - const url = (event as H3Event).url || new URL(event.req.url); + 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)) { return { @@ -32,16 +28,16 @@ export function defaultHandler( } } - if (unhandled) { - !opts?.silent && - console.error(new Error(`[request error] [${event.req.method}] ${url}`, { cause: error })); - return { status }; - } + const headers = new Headers(unhandled ? {} : error.headers); + headers.set("content-type", "application/json; charset=utf-8"); return { status, - statusText: error.statusText, - headers: new Headers(error.headers), - body: error.toJSON?.(), + statusText, + headers, + body: { + error: true, + ...(unhandled ? { status, unhandled: true } : error.toJSON?.()), + }, }; } 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/error.ts b/test/fixture/server/routes/errors/throw.ts similarity index 100% rename from test/fixture/server/routes/error.ts rename to test/fixture/server/routes/errors/throw.ts 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 959189851a..fd08f13a68 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; @@ -401,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 @@ -636,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( @@ -648,42 +623,54 @@ 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.only(`handled errors (${errorAction})`, async () => { - const res = await callHandler({ url: `/error?handled&action=${errorAction}` }); - console.log(res); + it(`handled errors (${errorAction})`, async () => { + const res = await callHandler({ url: `/errors/throw?handled&action=${errorAction}` }); expect(res).toMatchObject({ status: 503, statusText: "Service Unavailable", headers: {}, data: { - message: "Handled error", + error: true, status: 503, statusText: "Service Unavailable", - custom: "body", + message: "Handled error", data: { custom: "data" }, + custom: "body", }, }); }); - it.only(`unhandled errors (${errorAction})`, async () => { + 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: `/error?unhandled&action=${errorAction}`, + 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, - statusText: "", + statusText: "Internal Server Error", headers: { - "content-type": "text/plain; charset=UTF-8", + "content-type": "application/json; charset=utf-8", + }, + data: { + error: true, + unhandled: true, + status: 500, }, - data: "", }); } else { // Dev @@ -694,8 +681,9 @@ export function testNitro( "content-type": "application/json; charset=utf-8", }, data: { - status: 500, + error: true, unhandled: true, + status: 500, message: "HTTPError", stack: expect.arrayContaining(["Unhandled error"]), }, From 79ede01a11902878b6cd9b91b38e6100b98acdaf Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Tue, 24 Feb 2026 12:21:51 +0100 Subject: [PATCH 3/3] up --- test/fixture/server/routes/errors/throw.ts | 2 +- test/tests.ts | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/test/fixture/server/routes/errors/throw.ts b/test/fixture/server/routes/errors/throw.ts index d5f89f1a21..905fad9585 100644 --- a/test/fixture/server/routes/errors/throw.ts +++ b/test/fixture/server/routes/errors/throw.ts @@ -8,7 +8,7 @@ export default ({ url }: H3Event) => { ? new Error("Unhandled error") : new HTTPError({ status: 503, - statusText: "Service Unavailable", + statusText: "Custom Status Text", message: "Handled error", data: { custom: "data" }, body: { custom: "body" }, diff --git a/test/tests.ts b/test/tests.ts index fd08f13a68..a79beabcb1 100644 --- a/test/tests.ts +++ b/test/tests.ts @@ -632,12 +632,16 @@ export function testNitro( const res = await callHandler({ url: `/errors/throw?handled&action=${errorAction}` }); expect(res).toMatchObject({ status: 503, - statusText: "Service Unavailable", + statusText: /deno|bun/.test(ctx.preset) + ? "Service Unavailable" + : /aws/.test(ctx.preset) + ? "" + : "Custom Status Text", headers: {}, data: { error: true, status: 503, - statusText: "Service Unavailable", + statusText: "Custom Status Text", message: "Handled error", data: { custom: "data" }, custom: "body", @@ -662,7 +666,6 @@ export function testNitro( // Prod expect(res).toMatchObject({ status: 500, - statusText: "Internal Server Error", headers: { "content-type": "application/json; charset=utf-8", }, @@ -676,7 +679,6 @@ export function testNitro( // Dev expect(res).toMatchObject({ status: 500, - statusText: "Internal Server Error", headers: { "content-type": "application/json; charset=utf-8", },