Repository: unjs/h3
Pull request #831 (2024-07-19 11:03:32)

perf: improve response handling
This PR improves raw web perf by ~10% by avoiding creating a `Headers` object in default/fast paths (finally `new Response()` does create one)

A new `event.response.setHeader` utility is introduced to allow setting headers on headers-init object via fast path (if `event.response.headers` is accessed, we fallback to full headers functionality)

This PR additionally makes behavior changes:
- `onError` is only called when error is throwing with `throw`. `return <error>` is normal response
- Returning a static string, does not sets HTML content type
- Default content type is `application/json; charset=utf-8` (normalized value of headers)


Patch:
diff --git a/src/adapters/node/index.ts b/src/adapters/node/index.ts
index 6532552..48954f1 100644
--- a/src/adapters/node/index.ts
+++ b/src/adapters/node/index.ts
@@ -2,7 +2,8 @@ import type { H3, EventHandler, EventHandlerResponse } from "../../types";
 import type { NodeHandler, NodeMiddleware } from "../../types/node";
 import { NodeEvent } from "./event";
 import { sendNodeResponse, callNodeHandler } from "./internal/utils";
-import { prepareResponse, errorToRes } from "../../response";
+import { prepareResponse, prepareErrorResponseBody } from "../../response";
+import { createError } from "../../error";
 
 export { kNodeReq, kNodeRes } from "./internal/utils";
 
@@ -11,46 +12,47 @@ export { kNodeReq, kNodeRes } from "./internal/utils";
  */
 export function toNodeHandler(app: H3): NodeHandler {
   const nodeHandler: NodeHandler = async function (nodeReq, nodeRes) {
+    // Create a new event instance
     const event = new NodeEvent(nodeReq, nodeRes);
 
-    let _handlerRes: unknown | Promise<unknown>;
+    // Execute the handler
+    let handlerRes: unknown | Promise<unknown>;
     try {
-      _handlerRes = app._handler(event);
+      handlerRes = app._handler(event);
     } catch (error: any) {
-      _handlerRes = error;
+      handlerRes = Promise.reject(error);
     }
 
-    const _body =
-      _handlerRes instanceof Promise
-        ? _handlerRes
-            .catch((error) => error)
-            .then((_resolvedRes) =>
-              prepareResponse(event, _resolvedRes, app.config),
-            )
-        : prepareResponse(event, _handlerRes, app.config);
-
-    return Promise.resolve(_body)
-      .then((body) => {
-        const _chain = sendNodeResponse(nodeRes, body);
-        return app.config.onAfterResponse
-          ? Promise.resolve(_chain).then(() =>
-              app.config.onAfterResponse!(event, { body }),
+    // Handle the response
+    return Promise.resolve(handlerRes)
+      .catch((error) => {
+        const h3Error = createError(error);
+        return app.config.onError
+          ? Promise.resolve(app.config.onError(h3Error, event)).then(
+              (_res) => _res ?? h3Error,
             )
-          : _chain;
+          : h3Error;
+      })
+      .then((resolvedRes) => {
+        const preparedRes = prepareResponse(resolvedRes, event, app.config);
+        let promise = app.config.onBeforeResponse
+          ? Promise.resolve(
+              app.config.onBeforeResponse(event, preparedRes),
+            ).then(() => sendNodeResponse(nodeRes, preparedRes.body))
+          : sendNodeResponse(nodeRes, preparedRes.body);
+        if (app.config.onAfterResponse) {
+          promise = promise.then(() =>
+            app.config.onAfterResponse!(event, preparedRes),
+          );
+        }
+        return promise;
       })
       .catch((error) => {
         // Possible cases: Stream canceled, headers already sent, etc.
         if (nodeRes.headersSent || nodeRes.writableEnded) {
           return;
         }
-        const errRes = errorToRes(error, app.config);
-        if (errRes.status) {
-          nodeRes.statusCode = errRes.status;
-        }
-        if (errRes.statusText) {
-          nodeRes.statusMessage = errRes.statusText;
-        }
-        nodeRes.end(errRes.body);
+        nodeRes.end(prepareErrorResponseBody(error, event, app.config));
       });
   };
 
diff --git a/src/adapters/node/internal/response.ts b/src/adapters/node/internal/response.ts
index 743375c..f0f28f5 100644
--- a/src/adapters/node/internal/response.ts
+++ b/src/adapters/node/internal/response.ts
@@ -1,12 +1,10 @@
 import type { ServerResponse } from "node:http";
-import type { H3Event } from "../../../types";
+import type { H3EventResponse } from "../../../types/event";
 import { kNodeInspect, kNodeRes } from "./utils";
 import { NodeResHeadersProxy } from "./headers";
 
-type H3Response = H3Event["response"];
-
 export const NodeResponseProxy = /* @__PURE__ */ (() =>
-  class NodeResponseProxy implements H3Response {
+  class NodeResponseProxy implements H3EventResponse {
     [kNodeRes]: ServerResponse;
 
     headers: Headers;
@@ -36,6 +34,10 @@ export const NodeResponseProxy = /* @__PURE__ */ (() =>
       return "Response";
     }
 
+    setHeader(name: string, value: string): void {
+      this[kNodeRes].setHeader(name, value);
+    }
+
     [kNodeInspect]() {
       return {
         status: this.status,
diff --git a/src/adapters/web/event.ts b/src/adapters/web/event.ts
index 559ece1..895f2ea 100644
--- a/src/adapters/web/event.ts
+++ b/src/adapters/web/event.ts
@@ -1,17 +1,36 @@
 import type { H3Event, H3EventContext } from "../../types";
+import type { H3EventResponse } from "../../types/event";
 import { BaseEvent } from "../base/event";
 
 export class WebEvent extends BaseEvent implements H3Event {
   request: Request;
   url: URL;
-  response: H3Event["response"];
+  response: H3EventResponse;
 
   constructor(request: Request, context?: H3EventContext) {
     super(context);
     this.request = request;
     this.url = new URL(request.url);
-    this.response = {
-      headers: new Headers(),
-    };
+    this.response = new WebEventResponse();
+  }
+}
+
+class WebEventResponse implements H3EventResponse {
+  _headersInit: Record<string, string> = Object.create(null);
+  _headers?: Headers;
+
+  get headers() {
+    if (!this._headers) {
+      this._headers = new Headers(this._headersInit);
+    }
+    return this._headers;
+  }
+
+  setHeader(name: string, value: string): void {
+    if (this._headers) {
+      this._headers.set(name, value);
+    } else {
+      this._headersInit[name] = value;
+    }
   }
 }
diff --git a/src/h3.ts b/src/h3.ts
index 2a56d0e..56d7aeb 100644
--- a/src/h3.ts
+++ b/src/h3.ts
@@ -19,6 +19,7 @@ import { getPathname, joinURL } from "./utils/internal/path";
 import { ResolvedEventHandler } from "./types/handler";
 import { WebEvent } from "./adapters/web/event";
 import { kNotFound, prepareResponse } from "./response";
+import { createError } from "./error";
 
 /**
  * Create a new h3 instance.
@@ -79,29 +80,44 @@ class _H3 implements H3 {
       request = _request;
     }
 
-    // Create event context
+    // Create a new event instance
     const event = new WebEvent(request, options?.h3);
 
-    let _handlerRes: unknown | Promise<unknown>;
+    // Execute the handler
+    let handlerRes: unknown | Promise<unknown>;
     try {
-      _handlerRes = this._handler(event);
+      handlerRes = this._handler(event);
     } catch (error: any) {
-      _handlerRes = error;
+      handlerRes = Promise.reject(error);
     }
 
-    if (_handlerRes instanceof Promise) {
-      return _handlerRes.then((_resolvedRes) => {
-        const _body = prepareResponse(event, _resolvedRes, this.config);
-        return _body instanceof Promise
-          ? _body.then((body) => new Response(body, event.response))
-          : new Response(_body, event.response);
-      });
+    // Prepare response
+    const config = this.config;
+    if (!(handlerRes instanceof Promise)) {
+      const preparedRes = prepareResponse(handlerRes, event, config);
+      return config.onBeforeResponse
+        ? Promise.resolve(config.onBeforeResponse(event, preparedRes)).then(
+            () => new Response(preparedRes.body, preparedRes),
+          )
+        : new Response(preparedRes.body, preparedRes);
     }
-
-    const _body = prepareResponse(event, _handlerRes, this.config);
-    return _body instanceof Promise
-      ? _body.then((body) => new Response(body, event.response))
-      : new Response(_body, event.response);
+    return Promise.resolve(handlerRes)
+      .catch((error) => {
+        const h3Error = createError(error);
+        return config.onError
+          ? Promise.resolve(config.onError(h3Error, event)).then(
+              (res) => res ?? h3Error,
+            )
+          : h3Error;
+      })
+      .then((resolvedRes) => {
+        const preparedRes = prepareResponse(resolvedRes, event, config);
+        return config.onBeforeResponse
+          ? Promise.resolve(config.onBeforeResponse(event, preparedRes)).then(
+              () => new Response(preparedRes.body, preparedRes),
+            )
+          : new Response(preparedRes.body, preparedRes);
+      });
   }
 
   _handler(event: H3Event) {
diff --git a/src/response.ts b/src/response.ts
index 7e35ff5..3849d9b 100644
--- a/src/response.ts
+++ b/src/response.ts
@@ -1,86 +1,56 @@
 import type { H3Config, H3Event } from "./types";
-import type { H3Response, H3Error } from "./types/h3";
+import type { H3Error, PreparedResponse } from "./types/h3";
 import { createError } from "./error";
 import { isJSONSerializable } from "./utils/internal/object";
-import { MIMES } from "./utils/internal/consts";
 
 export const kNotFound = Symbol.for("h3.notFound");
 
 export function prepareResponse(
+  val: unknown,
   event: H3Event,
-  body: unknown,
   config: H3Config,
-): BodyInit | null | undefined | Promise<BodyInit | null | undefined> {
-  const res: H3Response = bodyToRes(event, body, config);
-
+): PreparedResponse {
+  const body = prepareResponseBody(val, event, config);
   const status = event.response.status;
-  if (
-    status === 100 ||
-    status === 101 ||
-    status === 102 ||
-    status === 204 ||
-    status === 205 ||
-    status === 304 ||
-    event.method === "HEAD"
-  ) {
-    res.body = null;
-  }
-
-  if (res.contentType && !event.response.headers.has("content-type")) {
-    event.response.headers.set("content-type", res.contentType);
-  }
+  const isNullBody =
+    (status &&
+      (status === 100 ||
+        status === 101 ||
+        status === 102 ||
+        status === 204 ||
+        status === 205 ||
+        status === 304)) ||
+    event.method === "HEAD";
 
-  if (res.headers) {
-    for (const [key, value] of res.headers.entries()) {
-      event.response.headers.set(key, value);
-    }
-  }
-
-  if (res.status) {
-    event.response.status = res.status;
-  }
-
-  if (res.statusText) {
-    event.response.statusText = res.statusText;
-  }
-
-  let promise: Promise<unknown> | undefined;
-
-  if (res.error) {
-    if (res.error.unhandled) {
-      console.error("[h3] Unhandled Error:", res.error);
-    }
-    if (config.onError) {
-      try {
-        promise = Promise.resolve(config.onError(res.error, event));
-      } catch (hookError) {
-        console.error("[h3] Error while calling `onError` hook:", hookError);
-      }
-    }
-  }
-
-  if (config.onBeforeResponse) {
-    promise = (promise || Promise.resolve()).then(() =>
-      config.onBeforeResponse!(event, res),
-    );
-  }
-
-  return promise ? promise.then(() => res.body) : res.body;
+  return {
+    body: isNullBody ? null : body,
+    status,
+    statusText: event.response.statusText,
+    headers:
+      event.response._headers ||
+      event.response._headersInit ||
+      event.response.headers,
+  };
 }
 
-function bodyToRes(event: H3Event, val: unknown, config: H3Config): H3Response {
+export function prepareResponseBody(
+  val: unknown,
+  event: H3Event,
+  config: H3Config,
+): BodyInit | null | undefined {
   // Empty Content
   if (val === null || val === undefined) {
-    return { body: "" };
+    return "";
   }
 
   // Not found
   if (val === kNotFound) {
-    return errorToRes(
+    return prepareErrorResponseBody(
       {
         statusCode: 404,
         statusMessage: `Cannot find any route matching [${event.request.method}] ${event.path}`,
       },
+      event,
       config,
     );
   }
@@ -89,84 +59,81 @@ function bodyToRes(event: H3Event, val: unknown, config: H3Config): H3Response {
 
   // Text
   if (valType === "string") {
-    return { body: val as string, contentType: MIMES.html };
+    return val as string;
   }
 
   // Buffer (should be before JSON)
   if (val instanceof Uint8Array) {
-    return { body: val, contentType: MIMES.octetStream };
+    event.response.setHeader("content-type", "application/octet-stream");
+    event.response.setHeader("content-length", val.byteLength.toString());
+    return val;
   }
 
   // Error (should be before JSON)
   if (val instanceof Error) {
-    return errorToRes(val, config);
+    return prepareErrorResponseBody(val, event, config);
   }
 
   // JSON
   if (isJSONSerializable(val, valType)) {
-    return {
-      body: JSON.stringify(val, undefined, config.debug ? 2 : undefined),
-      contentType: MIMES.json,
-    };
+    event.response.setHeader("content-type", "application/json; charset=utf-8");
+    return JSON.stringify(val, undefined, config.debug ? 2 : undefined);
   }
 
   // BigInt
   if (valType === "bigint") {
-    return { body: val.toString(), contentType: MIMES.json };
+    event.response.setHeader("content-type", "application/json; charset=utf-8");
+    return val.toString();
   }
 
   // Web Response
   if (val instanceof Response) {
-    return {
-      body: val.body,
-      headers: val.headers,
-      status: val.status,
-      statusText: val.statusText,
-    };
+    event.response.status = val.status;
+    event.response.statusText = val.statusText;
+    for (const [name, value] of val.headers) {
+      event.response.setHeader(name, value);
+    }
+    return val.body;
   }
 
   // Blob
   if (val instanceof Blob) {
-    return {
-      contentType: val.type,
-      body: val.stream(),
-    };
+    event.response.setHeader("content-type", val.type);
+    event.response.setHeader("content-length", val.size.toString());
+    return val.stream();
   }
 
   // Symbol or Function is not supported
   if (valType === "symbol" || valType === "function") {
-    return errorToRes(
+    return prepareErrorResponseBody(
       {
         statusCode: 500,
         statusMessage: `[h3] Cannot send ${valType} as response.`,
       },
+      event,
       config,
     );
   }
 
-  return {
-    body: val as BodyInit,
-  };
+  return val as BodyInit;
 }
 
-export function errorToRes(
-  _error: Partial<H3Error> | Error,
+export function prepareErrorResponseBody(
+  val: Partial<H3Error> | Error,
+  event: H3Event,
   config: H3Config,
-): H3Response {
-  const error = createError(_error as H3Error);
-  return {
-    error,
-    status: error.statusCode,
-    statusText: error.statusMessage,
-    contentType: MIMES.json,
-    body: JSON.stringify({
-      statusCode: error.statusCode,
-      statusMessage: error.statusMessage,
-      data: error.data,
-      stack:
-        config.debug && error.stack
-          ? error.stack.split("\n").map((l) => l.trim())
-          : undefined,
-    }),
-  };
+): string {
+  const error = createError(val as H3Error);
+  event.response.status = error.statusCode;
+  event.response.statusText = error.statusMessage;
+  event.response.setHeader("content-type", "application/json; charset=utf-8");
+  return JSON.stringify({
+    statusCode: error.statusCode,
+    statusMessage: error.statusMessage,
+    data: error.data,
+    stack:
+      config.debug && error.stack
+        ? error.stack.split("\n").map((l) => l.trim())
+        : undefined,
+  });
 }
diff --git a/src/types/event.ts b/src/types/event.ts
index b9dfa66..8fda157 100644
--- a/src/types/event.ts
+++ b/src/types/event.ts
@@ -22,5 +22,17 @@ export interface H3Event<
   readonly ip?: string | undefined;
 
   // Response
-  response: { headers: Headers; status?: number; statusText?: string };
+  response: H3EventResponse;
+}
+
+export interface H3EventResponse {
+  status?: number;
+  statusText?: string;
+
+  _headersInit?: HeadersInit;
+  _headers?: Headers;
+
+  readonly headers: Headers;
+
+  setHeader(name: string, value: string): void;
 }
diff --git a/src/types/h3.ts b/src/types/h3.ts
index f26e5d6..71cab2d 100644
--- a/src/types/h3.ts
+++ b/src/types/h3.ts
@@ -7,19 +7,10 @@ import type {
 } from "./handler";
 import type { H3Error } from "../error";
 import type { HTTPMethod } from "./http";
-import { H3EventContext } from "./context";
+import type { H3EventContext } from "./context";
 
 export type { H3Error } from "../error";
 
-export interface H3Response {
-  error?: H3Error;
-  body?: BodyInit | null;
-  contentType?: string;
-  headers?: Headers;
-  status?: number;
-  statusText?: string;
-}
-
 export interface H3Config {
   debug?: boolean;
   websocket?: WebSocketOptions;
@@ -28,14 +19,21 @@ export interface H3Config {
   onRequest?: (event: H3Event) => MaybePromise<void>;
   onBeforeResponse?: (
     event: H3Event,
-    response: H3Response,
+    response: PreparedResponse,
   ) => MaybePromise<void>;
   onAfterResponse?: (
     event: H3Event,
-    response?: H3Response,
+    response?: PreparedResponse,
   ) => MaybePromise<void>;
 }
 
+export interface PreparedResponse {
+  status?: number;
+  statusText?: string;
+  headers?: HeadersInit;
+  body?: BodyInit | null;
+}
+
 export interface WebSocketOptions {
   resolve?: crossws.ResolveHooks;
   hooks?: Partial<crossws.Hooks>;
diff --git a/src/utils/internal/consts.ts b/src/utils/internal/consts.ts
deleted file mode 100644
index 3cfd5e5..0000000
--- a/src/utils/internal/consts.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-export const MIMES = {
-  html: "text/html",
-  json: "application/json",
-  octetStream: "application/octet-stream",
-} as const;


Test patch:
diff --git a/test/app.test.ts b/test/app.test.ts
index 9388094..4865844 100644
--- a/test/app.test.ts
+++ b/test/app.test.ts
@@ -166,17 +166,16 @@ describe("app", () => {
     expect(JSON.parse(res.text).statusMessage).toBe("test");
   });
 
-  it("can return HTML directly", async () => {
-    ctx.app.use(() => "<h1>Hello world!</h1>");
+  it("can return text directly", async () => {
+    ctx.app.use(() => "Hello world!");
     const res = await ctx.request.get("/");
 
-    expect(res.text).toBe("<h1>Hello world!</h1>");
-    expect(res.header["content-type"]).toBe("text/html");
+    expect(res.text).toBe("Hello world!");
   });
 
   it("allows overriding Content-Type", async () => {
     ctx.app.use((event) => {
-      setResponseHeader(event, "content-type", "text/xhtml");
+      event.response.setHeader("content-type", "text/xhtml");
       return "<h1>Hello world!</h1>";
     });
     const res = await ctx.request.get("/");
diff --git a/test/bench/impl.ts b/test/bench/impl.ts
index 913f455..99e3d62 100644
--- a/test/bench/impl.ts
+++ b/test/bench/impl.ts
@@ -17,7 +17,7 @@ export function h3(lib: typeof _h3src) {
 
   // [GET] /id/:id
   app.get("/id/:id", (event) => {
-    event.response.headers.set("x-powered-by", "benchmark");
+    event.response.setHeader("x-powered-by", "benchmark");
     return `${event.context.params!.id} ${event.url.searchParams.get("name")}`;
   });
 
diff --git a/test/hooks.test.ts b/test/hooks.test.ts
index 5cccfdc..1aae8fa 100644
--- a/test/hooks.test.ts
+++ b/test/hooks.test.ts
@@ -40,9 +40,9 @@ describe("app", () => {
     expect(ctx.onAfterResponse).toHaveBeenCalledTimes(1);
   });
 
-  it("calls onRequest and onResponse when an error is returned", async () => {
+  it("calls onRequest and onResponse when an error is thrown", async () => {
     ctx.app.use(() => {
-      return createError({
+      throw createError({
         statusCode: 404,
       });
     });
@@ -68,10 +68,12 @@ describe("app", () => {
     vi.spyOn(console, "error").mockImplementation(() => {});
     await ctx.request.get("/foo");
 
-    expect(ctx.errors.length).toBe(1);
-    expect(ctx.errors[0].statusCode).toBe(500);
+    const errors = ctx.errors;
     ctx.errors = [];
 
+    expect(errors.length).toBe(1);
+    expect(errors[0].statusCode).toBe(500);
+
     expect(ctx.onRequest).toHaveBeenCalledTimes(1);
     expect(ctx.onRequest.mock.calls[0][0].path).toBe("/foo");
 
diff --git a/test/proxy.test.ts b/test/proxy.test.ts
index b11102d..87669d8 100644
--- a/test/proxy.test.ts
+++ b/test/proxy.test.ts
@@ -452,7 +452,9 @@ describe("proxy", () => {
 
       await ctx.request.get("/");
 
-      expect(headers?.["content-type"]).toEqual("application/json");
+      expect(headers?.["content-type"]).toEqual(
+        "application/json; charset=utf-8",
+      );
     });
   });
 });
diff --git a/test/status.test.ts b/test/status.test.ts
index 14a4748..e7db941 100644
--- a/test/status.test.ts
+++ b/test/status.test.ts
@@ -27,13 +27,14 @@ describe("setResponseStatus", () => {
         statusText: "",
         body: "text",
         headers: {
-          "content-type": "text/html",
+          "content-type": "text/plain;charset=UTF-8",
         },
       });
     });
-    it("override status and statusText with setResponseStatus method", async () => {
+    it("override status and statusText", async () => {
       ctx.app.use("/test", (event) => {
-        setResponseStatus(event, 418, "status-text");
+        event.response.status = 418;
+        event.response.statusText = "status-text";
         return "text";
       });
 
@@ -47,7 +48,7 @@ describe("setResponseStatus", () => {
         statusText: "status-text",
         body: "text",
         headers: {
-          "content-type": "text/html",
+          "content-type": "text/plain;charset=UTF-8",
         },
       });
     });
diff --git a/test/web.test.ts b/test/web.test.ts
index c295866..4e60c81 100644
--- a/test/web.test.ts
+++ b/test/web.test.ts
@@ -34,7 +34,7 @@ describe("Web handler", () => {
     expect(res.status).toBe(201);
     expect(res.statusText).toBe("Created");
     expect([...res.headers.entries()]).toMatchObject([
-      ["content-type", "application/json"],
+      ["content-type", "application/json; charset=utf-8"],
     ]);
 
     expect(await res.json()).toMatchObject({