Repository: unjs/h3
Pull request #792 (2024-06-25 15:46:51)

refactor!: overhaul app event handler
#787

This PR rewrites app internal event handling for more stable hooks and explicit response handling.

One noticeable benefit of this PR is that it unblocks the ability to have native `app.fetch`

## Deprecations:

- `sendIterable` => `return iteratable()`
- `sendNoContent` => `return noContent()`
- `sendRedirect` => `return redirect()`
- `sendProxy` => `return proxy()`
- `sendWebResponse` => (directly return web response!)
- `callWithWebRequest`: unreleased method removed for now

`handleCors` and `serveStatic` should also always return the value to allow a response send.



Patch:
diff --git a/docs/2.utils/2.response.md b/docs/2.utils/2.response.md
index d2f187f..598756a 100644
--- a/docs/2.utils/2.response.md
+++ b/docs/2.utils/2.response.md
@@ -91,19 +91,7 @@ export default defineEventHandler((event) => {
 });
 ```
 
-### `removeResponseHeader(event, name)`
-
-Remove a response header by name.
-
-**Example:**
-
-```ts
-export default defineEventHandler((event) => {
-  removeResponseHeader(event, "content-type"); // Remove content-type header
-});
-```
-
-### `sendIterable(event, iterable)`
+### `iterable(iterable)`
 
 Iterate a source of chunks and send back each chunk in order. Supports mixing async work together with emitting chunks.
 
@@ -114,8 +102,7 @@ For generator (yielding) functions, the returned value is treated the same as yi
 **Example:**
 
 ```ts
-sendIterable(event, work());
-async function* work() {
+return iterable(async function* work() {
   // Open document body
   yield "<!DOCTYPE html>\n<html><body><h1>Executing...</h1><ol>\n";
   // Do work ...
@@ -128,36 +115,25 @@ async function* work() {
   }
   // Close out the report
   return `</ol></body></html>`;
-}
+})
 async function delay(ms) {
   return new Promise(resolve => setTimeout(resolve, ms));
 }
 ```
 
-### `sendNoContent(event, code?)`
+### `noContent(event, code?)`
 
 Respond with an empty payload.<br>
 
-Note that calling this function will close the connection and no other data can be sent to the client afterwards.
-
 **Example:**
 
 ```ts
 export default defineEventHandler((event) => {
-  return sendNoContent(event);
+  return noContent(event);
 });
 ```
 
-**Example:**
-
-```ts
-export default defineEventHandler((event) => {
-  sendNoContent(event); // Close the connection
-  console.log("This will not be executed");
-});
-```
-
-### `sendRedirect(event, location, code)`
+### `redirect(event, location, code)`
 
 Send a redirect response to the client.
 
@@ -169,7 +145,7 @@ In the body, it sends a simple HTML page with a meta refresh tag to redirect the
 
 ```ts
 export default defineEventHandler((event) => {
-  return sendRedirect(event, "https://example.com");
+  return redirect(event, "https://example.com");
 });
 ```
 
@@ -177,13 +153,21 @@ export default defineEventHandler((event) => {
 
 ```ts
 export default defineEventHandler((event) => {
-  return sendRedirect(event, "https://example.com", 301); // Permanent redirect
+  return redirect(event, "https://example.com", 301); // Permanent redirect
 });
 ```
 
-### `sendWebResponse(event, response)`
+### `removeResponseHeader(event, name)`
+
+Remove a response header by name.
 
-Send a Web besponse object to the client.
+**Example:**
+
+```ts
+export default defineEventHandler((event) => {
+  removeResponseHeader(event, "content-type"); // Remove content-type header
+});
+```
 
 ### `setResponseHeader(event, name, value)`
 
diff --git a/docs/2.utils/98.advanced.md b/docs/2.utils/98.advanced.md
index 7d42b05..e4ac94c 100644
--- a/docs/2.utils/98.advanced.md
+++ b/docs/2.utils/98.advanced.md
@@ -145,13 +145,13 @@ Make a fetch request with the event's context and headers.
 
 Get the request headers object without headers known to cause issues when proxying.
 
-### `proxyRequest(event, target, opts)`
+### `proxy(event, target, opts)`
 
-Proxy the incoming request to a target URL.
+Make a proxy request to a target URL and send the response back to the client.
 
-### `sendProxy(event, target, opts)`
+### `proxyRequest(event, target, opts)`
 
-Make a proxy request to a target URL and send the response back to the client.
+Proxy the incoming request to a target URL.
 
 <!-- /automd -->
 
@@ -182,15 +182,15 @@ const app = createApp();
 const router = createRouter();
 router.use('/',
   defineEventHandler(async (event) => {
-      const didHandleCors = handleCors(event, {
+      const corsRes = handleCors(event, {
         origin: '*',
         preflight: {
          statusCode: 204,
         },
      methods: '*',
    });
-   if (didHandleCors) {
-     return;
+   if (corsRes) {
+     return corsRes;
    }
    // Your code here
  })
diff --git a/src/adapters/node/_internal.ts b/src/adapters/node/_internal.ts
index 62337a1..00b66bb 100644
--- a/src/adapters/node/_internal.ts
+++ b/src/adapters/node/_internal.ts
@@ -1,13 +1,18 @@
 import type { Readable as NodeReadableStream } from "node:stream";
-import type { RawResponse } from "../../types/event";
 import type {
   NodeHandler,
   NodeIncomingMessage,
   NodeMiddleware,
   NodeServerResponse,
 } from "../../types/node";
+import type { ResponseBody } from "../../types";
 import { _kRaw } from "../../event";
 import { createError } from "../../error";
+import { splitCookiesString } from "../../utils/cookie";
+import {
+  sanitizeStatusCode,
+  sanitizeStatusMessage,
+} from "../../utils/sanitize";
 
 export function _getBodyStream(
   req: NodeIncomingMessage,
@@ -28,47 +33,71 @@ export function _getBodyStream(
 }
 
 export function _sendResponse(
-  res: NodeServerResponse,
-  data: RawResponse,
+  nodeRes: NodeServerResponse,
+  handlerRes: ResponseBody,
 ): Promise<void> {
+  // Web Response
+  if (handlerRes instanceof Response) {
+    for (const [key, value] of handlerRes.headers) {
+      if (key === "set-cookie") {
+        for (const setCookie of splitCookiesString(value)) {
+          nodeRes.appendHeader(key, setCookie);
+        }
+      } else {
+        nodeRes.setHeader(key, value);
+      }
+    }
+
+    if (handlerRes.status) {
+      nodeRes.statusCode = sanitizeStatusCode(handlerRes.status);
+    }
+    if (handlerRes.statusText) {
+      nodeRes.statusMessage = sanitizeStatusMessage(handlerRes.statusText);
+    }
+    if (handlerRes.redirected) {
+      nodeRes.setHeader("location", handlerRes.url);
+    }
+    handlerRes = handlerRes.body; // Next step will send body as stream!
+  }
+
   // Native Web Streams
   // https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream
-  if (typeof (data as ReadableStream)?.pipeTo === "function") {
-    return (data as ReadableStream)
+  if (typeof (handlerRes as ReadableStream)?.pipeTo === "function") {
+    return (handlerRes as ReadableStream)
       .pipeTo(
         new WritableStream({
           write: (chunk) => {
-            res.write(chunk);
+            nodeRes.write(chunk);
           },
         }),
       )
-      .then(() => _endResponse(res));
+      .then(() => _endResponse(nodeRes));
   }
 
   // Node.js Readable Streams
   // https://nodejs.org/api/stream.html#readable-streams
-  if (typeof (data as NodeReadableStream)?.pipe === "function") {
+  if (typeof (handlerRes as NodeReadableStream)?.pipe === "function") {
     return new Promise<void>((resolve, reject) => {
       // Pipe stream to response
-      (data as NodeReadableStream).pipe(res);
+      (handlerRes as NodeReadableStream).pipe(nodeRes);
 
       // Handle stream events (if supported)
-      if ((data as NodeReadableStream).on) {
-        (data as NodeReadableStream).on("end", resolve);
-        (data as NodeReadableStream).on("error", reject);
+      if ((handlerRes as NodeReadableStream).on) {
+        (handlerRes as NodeReadableStream).on("end", resolve);
+        (handlerRes as NodeReadableStream).on("error", reject);
       }
 
       // Handle request aborts
-      res.once("close", () => {
-        (data as NodeReadableStream).destroy?.();
+      nodeRes.once("close", () => {
+        (handlerRes as NodeReadableStream).destroy?.();
         // https://react.dev/reference/react-dom/server/renderToPipeableStream
-        (data as any).abort?.();
+        (handlerRes as any).abort?.();
       });
-    }).then(() => _endResponse(res));
+    }).then(() => _endResponse(nodeRes));
   }
 
   // Send as string or buffer
-  return _endResponse(res, data);
+  return _endResponse(nodeRes, handlerRes);
 }
 
 export function _endResponse(
diff --git a/src/adapters/node/event.ts b/src/adapters/node/event.ts
index 7751647..9910847 100644
--- a/src/adapters/node/event.ts
+++ b/src/adapters/node/event.ts
@@ -1,5 +1,5 @@
 import type { HTTPMethod } from "../../types";
-import { RawEvent, type RawResponse } from "../../types/event";
+import type { RawEvent } from "../../types/event";
 import { splitCookiesString } from "../../utils/cookie";
 import { NodeHeadersProxy } from "./_headers";
 import {
@@ -17,8 +17,6 @@ export class NodeEvent implements RawEvent {
   _req: NodeIncomingMessage;
   _res: NodeServerResponse;
 
-  _handled?: boolean;
-
   _originalPath?: string | undefined;
 
   _rawBody?: Promise<undefined | Uint8Array>;
@@ -125,7 +123,7 @@ export class NodeEvent implements RawEvent {
   // -- response --
 
   get handled() {
-    return this._handled || this._res.writableEnded || this._res.headersSent;
+    return this._res.writableEnded || this._res.headersSent;
   }
 
   get responseCode() {
@@ -195,13 +193,4 @@ export class NodeEvent implements RawEvent {
       });
     }
   }
-
-  sendResponse(data: RawResponse) {
-    this._handled = true;
-    return _sendResponse(this._res, data).catch((error) => {
-      // TODO: better way?
-      this._handled = false;
-      throw error;
-    });
-  }
 }
diff --git a/src/adapters/node/utils.ts b/src/adapters/node/utils.ts
index 43fb585..29e0917 100644
--- a/src/adapters/node/utils.ts
+++ b/src/adapters/node/utils.ts
@@ -11,11 +11,11 @@ import type {
   NodeServerResponse,
 } from "../../types/node";
 import { _kRaw } from "../../event";
-import { createError, errorToResponse, isError } from "../../error";
 import { defineEventHandler, isEventHandler } from "../../handler";
 import { EventWrapper } from "../../event";
 import { NodeEvent } from "./event";
-import { callNodeHandler } from "./_internal";
+import { _sendResponse, callNodeHandler } from "./_internal";
+import { errorToAppResponse } from "../../app/_response";
 
 /**
  * Convert H3 app instance to a NodeHandler with (IncomingMessage, ServerResponse) => void signature.
@@ -24,48 +24,23 @@ export function toNodeHandler(app: App): NodeHandler {
   const nodeHandler: NodeHandler = async function (req, res) {
     const rawEvent = new NodeEvent(req, res);
     const event = new EventWrapper(rawEvent);
-    try {
-      await app.handler(event);
-    } catch (_error: any) {
-      const error = createError(_error);
-      if (!isError(_error)) {
-        error.unhandled = true;
-      }
-
-      // #754 Make sure hooks see correct status code and message
-      event[_kRaw].responseCode = error.statusCode;
-      event[_kRaw].responseMessage = error.statusMessage;
-
-      if (app.options.onError) {
-        await app.options.onError(error, event);
-      }
-
-      if (error.unhandled || error.fatal) {
-        console.error("[h3]", error.fatal ? "[fatal]" : "[unhandled]", error);
-      }
-
-      if (event[_kRaw].handled) {
+    const appResponse = await app.handler(event);
+    await _sendResponse(res, appResponse).catch((sendError) => {
+      // Possible cases: Stream canceled, headers already sent, etc.
+      if (res.headersSent || res.writableEnded) {
         return;
       }
-
-      if (app.options.onBeforeResponse && !event._onBeforeResponseCalled) {
-        await app.options.onBeforeResponse(event, { body: error });
+      const errRes = errorToAppResponse(sendError, app.options);
+      if (errRes.status) {
+        res.statusCode = errRes.status;
       }
-
-      const response = errorToResponse(error, app.options.debug);
-
-      event[_kRaw].responseCode = response.status;
-      event[_kRaw].responseMessage = response.statusText;
-
-      for (const [key, value] of Object.entries(response.headers)) {
-        event[_kRaw].setResponseHeader(key, value);
-      }
-
-      await event[_kRaw].sendResponse(response.body);
-
-      if (app.options.onAfterResponse && !event._onAfterResponseCalled) {
-        await app.options.onAfterResponse(event, { body: error });
+      if (errRes.statusText) {
+        res.statusMessage = errRes.statusText;
       }
+      res.end(errRes.body);
+    });
+    if (app.options.onAfterResponse) {
+      await app.options.onAfterResponse(event, { body: appResponse });
     }
   };
   return nodeHandler;
diff --git a/src/adapters/web/_internal.ts b/src/adapters/web/_internal.ts
index dc6eb96..aa388c5 100644
--- a/src/adapters/web/_internal.ts
+++ b/src/adapters/web/_internal.ts
@@ -1,11 +1,13 @@
 import type { Readable as NodeReadableStream } from "node:stream";
-import type { App, EventHandler, H3EventContext } from "../../types";
-import type { RawResponse } from "../../types/event";
+import type { App, H3EventContext, ResponseBody } from "../../types";
 import { EventWrapper, _kRaw } from "../../event";
 import { WebEvent } from "./event";
-import { createError, errorToResponse, isError } from "../../error";
 
-export function _normalizeResponse(data: RawResponse) {
+type WebNormalizedResponseBody = Exclude<ResponseBody, NodeReadableStream>;
+
+export function _normalizeResponse(
+  data: ResponseBody,
+): WebNormalizedResponseBody {
   // Node.js Readable Streams
   // https://nodejs.org/api/stream.html#readable-streams
   if (typeof (data as NodeReadableStream)?.pipe === "function") {
@@ -21,7 +23,7 @@ export function _normalizeResponse(data: RawResponse) {
       },
     });
   }
-  return data as Exclude<RawResponse, NodeReadableStream>;
+  return data as WebNormalizedResponseBody;
 }
 
 export function _pathToRequestURL(path: string, headers?: HeadersInit): string {
@@ -36,51 +38,26 @@ export function _pathToRequestURL(path: string, headers?: HeadersInit): string {
 
 export const nullBodyResponses = new Set([101, 204, 205, 304]);
 
-export async function _callWithWebRequest(
-  handler: EventHandler,
+export async function appFetch(
+  app: App,
   request: Request,
   context?: H3EventContext,
-  app?: App,
-) {
+): Promise<{
+  body: WebNormalizedResponseBody;
+  status: Response["status"];
+  statusText: Response["statusText"];
+  headers: Headers;
+}> {
   const rawEvent = new WebEvent(request);
-  const event = new EventWrapper(rawEvent);
-
-  if (context) {
-    Object.assign(event.context, context);
-  }
-
-  let error;
+  const event = new EventWrapper(rawEvent, context);
 
-  try {
-    await handler(event);
-  } catch (_error: any) {
-    error = createError(_error);
-    if (!isError(_error)) {
-      error.unhandled = true;
-    }
-  }
-
-  if (error) {
-    if (error.unhandled || error.fatal) {
-      console.error("[h3]", error.fatal ? "[fatal]" : "[unhandled]", error);
-    }
-    if (app?.options.onError) {
-      await app?.options.onError(error, event);
-    }
-    const errRes = errorToResponse(error, app?.options.debug);
-    return {
-      status: errRes.status,
-      statusText: errRes.statusText,
-      headers: new Headers(errRes.headers),
-      body: errRes.body,
-    };
-  }
+  const _appResponseBody = await app.handler(event);
 
   // https://developer.mozilla.org/en-US/docs/Web/API/Response/body
   const responseBody =
     nullBodyResponses.has(rawEvent.responseCode!) || request.method === "HEAD"
       ? null
-      : _normalizeResponse(rawEvent._responseBody);
+      : _normalizeResponse(_appResponseBody);
 
   return {
     status: rawEvent.responseCode,
diff --git a/src/adapters/web/event.ts b/src/adapters/web/event.ts
index 1177018..82efc54 100644
--- a/src/adapters/web/event.ts
+++ b/src/adapters/web/event.ts
@@ -1,17 +1,14 @@
-import { RawEvent, type RawResponse } from "../../types/event";
-import { HTTPMethod } from "../../types";
+import type { RawEvent } from "../../types/event";
+import type { HTTPMethod } from "../../types";
 
 export class WebEvent implements RawEvent {
   static isWeb = true;
 
   _req: Request;
 
-  _handled?: boolean;
-
   _path?: string;
   _originalPath?: string | undefined;
 
-  _responseBody?: RawResponse;
   _responseCode?: number;
   _responseMessage?: string;
   _responseHeaders: Headers = new Headers();
@@ -107,10 +104,6 @@ export class WebEvent implements RawEvent {
 
   // -- response --
 
-  get handled() {
-    return this._handled;
-  }
-
   get responseCode() {
     return this._responseCode || 200;
   }
@@ -156,7 +149,6 @@ export class WebEvent implements RawEvent {
   }
 
   writeHead(code: number, message?: string) {
-    this._handled = true;
     if (code) {
       this.responseCode = code;
     }
@@ -168,9 +160,4 @@ export class WebEvent implements RawEvent {
   writeEarlyHints(_hints: Record<string, string | string[]>) {
     // noop
   }
-
-  sendResponse(data: RawResponse) {
-    this._handled = true;
-    this._responseBody = data;
-  }
 }
diff --git a/src/adapters/web/index.ts b/src/adapters/web/index.ts
index 7465d26..9c12356 100644
--- a/src/adapters/web/index.ts
+++ b/src/adapters/web/index.ts
@@ -12,9 +12,6 @@ export {
   // Web Context
   getWebContext,
 
-  // Call
-  callWithWebRequest,
-
   // --Plain--
 
   // Plain Handler
diff --git a/src/adapters/web/utils.ts b/src/adapters/web/utils.ts
index 55fc01f..d8f064f 100644
--- a/src/adapters/web/utils.ts
+++ b/src/adapters/web/utils.ts
@@ -8,18 +8,16 @@ import type {
 import { defineEventHandler } from "../../handler";
 import { EventWrapper, _kRaw } from "../../event";
 import { WebEvent } from "./event";
-import {
-  _callWithWebRequest,
-  _normalizeResponse,
-  _pathToRequestURL,
-} from "./_internal";
+import { _normalizeResponse, _pathToRequestURL, appFetch } from "./_internal";
 
 /**
  * Convert H3 app instance to a WebHandler with (Request, H3EventContext) => Promise<Response> signature.
  */
 export function toWebHandler(app: App): WebHandler {
-  const webHandler: WebHandler = async (request, context) =>
-    callWithWebRequest(app.handler, request, context, app);
+  const webHandler: WebHandler = async (request, context) => {
+    const res = await appFetch(app, request, context);
+    return new Response(res.body, res);
+  };
   return webHandler;
 }
 
@@ -74,20 +72,6 @@ export function getWebContext(
   return raw.getContext();
 }
 
-export async function callWithWebRequest(
-  handler: EventHandler,
-  request: Request,
-  context?: H3EventContext,
-  app?: App,
-) {
-  const res = await _callWithWebRequest(handler, request, context, app);
-  return new Response(res.body, {
-    status: res.status,
-    statusText: res.statusText,
-    headers: res.headers,
-  });
-}
-
 // ----------------------------
 // Plain
 // ----------------------------
@@ -97,7 +81,7 @@ export async function callWithWebRequest(
  */
 export function toPlainHandler(app: App) {
   const handler: PlainHandler = async (request, context) => {
-    return callWithPlainRequest(app.handler, request, context, app);
+    return callWithPlainRequest(app, request, context);
   };
   return handler;
 }
@@ -162,20 +146,18 @@ export function fromPlainRequest(
 }
 
 export async function callWithPlainRequest(
-  handler: EventHandler,
+  app: App,
   request: PlainRequest,
   context?: H3EventContext,
-  app?: App,
 ): Promise<PlainResponse> {
-  const res = await _callWithWebRequest(
-    handler,
+  const res = await appFetch(
+    app,
     new Request(_pathToRequestURL(request.path, request.headers), {
       method: request.method,
       headers: request.headers,
       body: request.body,
     }),
     context,
-    app,
   );
 
   const setCookie = res.headers.getSetCookie();
diff --git a/src/app.ts b/src/app.ts
deleted file mode 100644
index b6a56af..0000000
--- a/src/app.ts
+++ /dev/null
@@ -1,305 +0,0 @@
-import type {
-  App,
-  Stack,
-  H3Event,
-  EventHandler,
-  EventHandlerResolver,
-  LazyEventHandler,
-  AppOptions,
-  InputLayer,
-  WebSocketOptions,
-  Layer,
-} from "./types";
-import { _kRaw } from "./event";
-import {
-  defineLazyEventHandler,
-  toEventHandler,
-  isEventHandler,
-  defineEventHandler,
-} from "./handler";
-import { createError } from "./error";
-import {
-  sendWebResponse,
-  sendNoContent,
-  defaultContentType,
-} from "./utils/response";
-import { isJSONSerializable } from "./utils/internal/object";
-import {
-  joinURL,
-  getPathname,
-  withoutTrailingSlash,
-} from "./utils/internal/path";
-import { MIMES } from "./utils/internal/consts";
-
-/**
- * Create a new H3 app instance.
- */
-export function createApp(options: AppOptions = {}): App {
-  const stack: Stack = [];
-
-  const handler = createAppEventHandler(stack, options);
-
-  const resolve = createResolver(stack);
-  handler.__resolve__ = resolve;
-
-  const getWebsocket = cachedFn(() => websocketOptions(resolve, options));
-
-  const app: App = {
-    // @ts-expect-error
-    use: (arg1, arg2, arg3) => use(app as App, arg1, arg2, arg3),
-    resolve,
-    handler,
-    stack,
-    options,
-    get websocket() {
-      return getWebsocket();
-    },
-  };
-
-  return app;
-}
-
-export function use(
-  app: App,
-  arg1: string | EventHandler | InputLayer | InputLayer[],
-  arg2?: Partial<InputLayer> | EventHandler | EventHandler[],
-  arg3?: Partial<InputLayer>,
-) {
-  if (Array.isArray(arg1)) {
-    for (const i of arg1) {
-      use(app, i, arg2, arg3);
-    }
-  } else if (Array.isArray(arg2)) {
-    for (const i of arg2) {
-      use(app, arg1, i, arg3);
-    }
-  } else if (typeof arg1 === "string") {
-    app.stack.push(
-      normalizeLayer({ ...arg3, route: arg1, handler: arg2 as EventHandler }),
-    );
-  } else if (typeof arg1 === "function") {
-    app.stack.push(normalizeLayer({ ...arg2, handler: arg1 as EventHandler }));
-  } else {
-    app.stack.push(normalizeLayer({ ...arg1 }));
-  }
-  return app;
-}
-
-export function createAppEventHandler(stack: Stack, options: AppOptions) {
-  const spacing = options.debug ? 2 : undefined;
-
-  return defineEventHandler(async (event) => {
-    // Keep a copy of incoming url
-    const _reqPath = event[_kRaw].path || "/";
-
-    // Layer path is the path without the prefix
-    let _layerPath: string;
-
-    // Call onRequest hook
-    if (options.onRequest) {
-      await options.onRequest(event);
-    }
-
-    for (const layer of stack) {
-      // 1. Remove prefix from path
-      if (layer.route.length > 1) {
-        if (!_reqPath.startsWith(layer.route)) {
-          continue;
-        }
-        _layerPath = _reqPath.slice(layer.route.length) || "/";
-      } else {
-        _layerPath = _reqPath;
-      }
-
-      // 2. Custom matcher
-      if (layer.match && !layer.match(_layerPath, event)) {
-        continue;
-      }
-
-      // 3. Update event path with layer path
-      event[_kRaw].path = _layerPath;
-
-      // 4. Handle request
-      const val = await layer.handler(event);
-
-      // 5. Try to handle return value
-      const _body = val === undefined ? undefined : await val;
-      if (_body !== undefined) {
-        const _response = { body: _body };
-        if (options.onBeforeResponse) {
-          event._onBeforeResponseCalled = true;
-          await options.onBeforeResponse(event, _response);
-        }
-        await handleHandlerResponse(event, _response.body, spacing);
-        if (options.onAfterResponse) {
-          event._onAfterResponseCalled = true;
-          await options.onAfterResponse(event, _response);
-        }
-        return;
-      }
-
-      // Already handled
-      if (event[_kRaw].handled) {
-        if (options.onAfterResponse) {
-          event._onAfterResponseCalled = true;
-          await options.onAfterResponse(event, undefined);
-        }
-        return;
-      }
-    }
-
-    if (!event[_kRaw].handled) {
-      throw createError({
-        statusCode: 404,
-        statusMessage: `Cannot find any path matching ${event.path || "/"}.`,
-      });
-    }
-
-    if (options.onAfterResponse) {
-      event._onAfterResponseCalled = true;
-      await options.onAfterResponse(event, undefined);
-    }
-  });
-}
-
-function createResolver(stack: Stack): EventHandlerResolver {
-  return async (path: string) => {
-    let _layerPath: string;
-    for (const layer of stack) {
-      if (layer.route === "/" && !layer.handler.__resolve__) {
-        continue;
-      }
-      if (!path.startsWith(layer.route)) {
-        continue;
-      }
-      _layerPath = path.slice(layer.route.length) || "/";
-      if (layer.match && !layer.match(_layerPath, undefined)) {
-        continue;
-      }
-      let res = { route: layer.route, handler: layer.handler };
-      if (res.handler.__resolve__) {
-        const _res = await res.handler.__resolve__(_layerPath);
-        if (!_res) {
-          continue;
-        }
-        res = {
-          ...res,
-          ..._res,
-          route: joinURL(res.route || "/", _res.route || "/"),
-        };
-      }
-      return res;
-    }
-  };
-}
-
-function normalizeLayer(input: InputLayer) {
-  let handler = input.handler;
-  // @ts-ignore
-  if (handler.handler) {
-    // @ts-ignore
-    handler = handler.handler;
-  }
-
-  if (input.lazy) {
-    handler = defineLazyEventHandler(handler as LazyEventHandler);
-  } else if (!isEventHandler(handler)) {
-    handler = toEventHandler(handler, undefined, input.route);
-  }
-
-  return {
-    route: withoutTrailingSlash(input.route),
-    match: input.match,
-    handler,
-  } as Layer;
-}
-
-function handleHandlerResponse(event: H3Event, val: any, jsonSpace?: number) {
-  // Empty Content
-  if (val === null) {
-    return sendNoContent(event);
-  }
-
-  const valType = typeof val;
-
-  // Undefined
-  if (valType === "undefined") {
-    return sendNoContent(event);
-  }
-
-  // Text
-  if (valType === "string") {
-    defaultContentType(event, MIMES.html);
-    return event[_kRaw].sendResponse(val);
-  }
-
-  // Buffer (should be before JSON)
-  if (val.buffer) {
-    return event[_kRaw].sendResponse(val);
-  }
-
-  // Error (should be before JSON)
-  if (val instanceof Error) {
-    throw createError(val);
-  }
-
-  // JSON
-  if (isJSONSerializable(val, valType)) {
-    defaultContentType(event, MIMES.json);
-    return event[_kRaw].sendResponse(JSON.stringify(val, undefined, jsonSpace));
-  }
-
-  // BigInt
-  if (valType === "bigint") {
-    defaultContentType(event, MIMES.json);
-    return event[_kRaw].sendResponse(val.toString());
-  }
-
-  // Web Response
-  if (val instanceof Response) {
-    return sendWebResponse(event, val);
-  }
-
-  // Blob
-  if (val.arrayBuffer && typeof val.arrayBuffer === "function") {
-    return (val as Blob).arrayBuffer().then((arrayBuffer) => {
-      defaultContentType(event, val.type);
-      return event[_kRaw].sendResponse(Buffer.from(arrayBuffer));
-    });
-  }
-
-  // Symbol or Function is not supported
-  if (valType === "symbol" || valType === "function") {
-    throw createError({
-      statusCode: 500,
-      statusMessage: `[h3] Cannot send ${valType} as response.`,
-    });
-  }
-
-  // Other values: direct send
-  return event[_kRaw].sendResponse(val);
-}
-
-function cachedFn<T>(fn: () => T): () => T {
-  let cache: T;
-  return () => {
-    if (!cache) {
-      cache = fn();
-    }
-    return cache;
-  };
-}
-
-function websocketOptions(
-  evResolver: EventHandlerResolver,
-  appOptions: AppOptions,
-): WebSocketOptions {
-  return {
-    ...appOptions.websocket,
-    async resolve(info) {
-      const pathname = getPathname(info.url || "/");
-      const resolved = await evResolver(pathname);
-      return resolved?.handler?.__websocket__ || {};
-    },
-  };
-}
diff --git a/src/app/_handler.ts b/src/app/_handler.ts
new file mode 100644
index 0000000..f55dcfc
--- /dev/null
+++ b/src/app/_handler.ts
@@ -0,0 +1,69 @@
+import type {
+  AppOptions,
+  EventHandler,
+  EventHandlerRequest,
+  ResponseBody,
+  Stack,
+} from "../types";
+import { defineEventHandler } from "../handler";
+import { _kRaw } from "../event";
+import { createError } from "../error";
+import { handleAppResponse } from "./_response";
+
+export function createAppEventHandler(
+  stack: Stack,
+  options: AppOptions,
+): EventHandler<EventHandlerRequest, Promise<ResponseBody>> {
+  return defineEventHandler(async (event) => {
+    try {
+      // Keep a copy of incoming url
+      const _reqPath = event[_kRaw].path || "/";
+
+      // Layer path is the path without the prefix
+      let _layerPath: string;
+
+      // Call onRequest hook
+      if (options.onRequest) {
+        await options.onRequest(event);
+      }
+
+      // Run through stack
+      for (const layer of stack) {
+        // 1. Remove prefix from path
+        if (layer.route.length > 1) {
+          if (!_reqPath.startsWith(layer.route)) {
+            continue;
+          }
+          _layerPath = _reqPath.slice(layer.route.length) || "/";
+        } else {
+          _layerPath = _reqPath;
+        }
+
+        // 2. Custom matcher
+        if (layer.match && !layer.match(_layerPath, event)) {
+          continue;
+        }
+
+        // 3. Update event path with layer path
+        event[_kRaw].path = _layerPath;
+
+        // 4. Handle request
+        const val = await layer.handler(event);
+
+        // 5. Handle response
+        const _body = val === undefined ? undefined : await val;
+        if (_body !== undefined) {
+          return handleAppResponse(event, _body, options);
+        }
+      }
+
+      // Throw 404 is no handler in the stack responded
+      throw createError({
+        statusCode: 404,
+        statusMessage: `Cannot find any path matching ${event.path || "/"}.`,
+      });
+    } catch (error: unknown) {
+      return handleAppResponse(event, error, options);
+    }
+  });
+}
diff --git a/src/app/_response.ts b/src/app/_response.ts
new file mode 100644
index 0000000..ec33737
--- /dev/null
+++ b/src/app/_response.ts
@@ -0,0 +1,143 @@
+import type { AppOptions, H3Event, ResponseBody } from "../types";
+import type { AppResponse, H3Error } from "../types/app";
+import { createError } from "../error";
+import { isJSONSerializable } from "../utils/internal/object";
+import { MIMES } from "../utils/internal/consts";
+import { _kRaw } from "../event";
+
+type MaybePromise<T> = T | Promise<T>;
+
+export async function handleAppResponse(
+  event: H3Event,
+  body: unknown,
+  options: AppOptions,
+) {
+  const res = await _normalizeResponseBody(body, options);
+  if (res.error) {
+    if (res.error.unhandled) {
+      console.error("[h3] Unhandled Error:", res.error);
+    }
+    if (options.onError) {
+      try {
+        await options.onError(res.error, event);
+      } catch (hookError) {
+        console.error("[h3] Error while calling `onError` hook:", hookError);
+      }
+    }
+  }
+  if (options.onBeforeResponse) {
+    await options.onBeforeResponse(event, res);
+  }
+  if (res.contentType && !event[_kRaw].getResponseHeader("content-type")) {
+    event[_kRaw].setResponseHeader("content-type", res.contentType);
+  }
+  if (res.headers) {
+    for (const [key, value] of res.headers.entries()) {
+      event[_kRaw].setResponseHeader(key, value);
+    }
+  }
+  if (res.status) {
+    event[_kRaw].responseCode = res.status;
+  }
+  if (res.statusText) {
+    event[_kRaw].responseMessage = res.statusText;
+  }
+  return res.body;
+}
+
+function _normalizeResponseBody(
+  val: unknown,
+  options: AppOptions,
+): MaybePromise<AppResponse> {
+  // Empty Content
+  if (val === null || val === undefined) {
+    return { body: "", status: 204 };
+  }
+
+  const valType = typeof val;
+
+  // Text
+  if (valType === "string") {
+    return { body: val as string, contentType: MIMES.html };
+  }
+
+  // Buffer (should be before JSON)
+  if (val instanceof Uint8Array) {
+    return { body: val, contentType: MIMES.octetStream };
+  }
+
+  // Error (should be before JSON)
+  if (val instanceof Error) {
+    return errorToAppResponse(val, options);
+  }
+
+  // JSON
+  if (isJSONSerializable(val, valType)) {
+    return {
+      body: JSON.stringify(val, undefined, options.debug ? 2 : undefined),
+      contentType: MIMES.json,
+    };
+  }
+
+  // BigInt
+  if (valType === "bigint") {
+    return { body: val.toString(), contentType: MIMES.json };
+  }
+
+  // Web Response
+  if (val instanceof Response) {
+    return {
+      body: val.body,
+      headers: val.headers,
+      status: val.status,
+      statusText: val.statusText,
+    };
+  }
+
+  // Blob
+  if (val instanceof Blob) {
+    return val.arrayBuffer().then((arrayBuffer) => {
+      return {
+        contentType: val.type,
+        body: new Uint8Array(arrayBuffer),
+      };
+    });
+  }
+
+  // Symbol or Function is not supported
+  if (valType === "symbol" || valType === "function") {
+    return errorToAppResponse(
+      {
+        statusCode: 500,
+        statusMessage: `[h3] Cannot send ${valType} as response.`,
+      },
+      options,
+    );
+  }
+
+  return {
+    body: val as ResponseBody,
+  };
+}
+
+export function errorToAppResponse(
+  _error: Partial<H3Error> | Error,
+  options: AppOptions,
+): AppResponse {
+  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:
+        options.debug && error.stack
+          ? error.stack.split("\n").map((l) => l.trim())
+          : undefined,
+    }),
+  };
+}
diff --git a/src/app/_utils.ts b/src/app/_utils.ts
new file mode 100644
index 0000000..9617b13
--- /dev/null
+++ b/src/app/_utils.ts
@@ -0,0 +1,124 @@
+import type {
+  Stack,
+  EventHandlerResolver,
+  LazyEventHandler,
+  InputLayer,
+  Layer,
+  AppOptions,
+  WebSocketOptions,
+  App,
+  EventHandler,
+} from "../types";
+import { _kRaw } from "../event";
+import {
+  defineLazyEventHandler,
+  toEventHandler,
+  isEventHandler,
+} from "../handler";
+import {
+  getPathname,
+  joinURL,
+  withoutTrailingSlash,
+} from "../utils/internal/path";
+
+export function use(
+  app: App,
+  arg1: string | EventHandler | InputLayer | InputLayer[],
+  arg2?: Partial<InputLayer> | EventHandler | EventHandler[],
+  arg3?: Partial<InputLayer>,
+) {
+  if (Array.isArray(arg1)) {
+    for (const i of arg1) {
+      use(app, i, arg2, arg3);
+    }
+  } else if (Array.isArray(arg2)) {
+    for (const i of arg2) {
+      use(app, arg1, i, arg3);
+    }
+  } else if (typeof arg1 === "string") {
+    app.stack.push(
+      normalizeLayer({ ...arg3, route: arg1, handler: arg2 as EventHandler }),
+    );
+  } else if (typeof arg1 === "function") {
+    app.stack.push(normalizeLayer({ ...arg2, handler: arg1 as EventHandler }));
+  } else {
+    app.stack.push(normalizeLayer({ ...arg1 }));
+  }
+  return app;
+}
+
+export function createResolver(stack: Stack): EventHandlerResolver {
+  return async (path: string) => {
+    let _layerPath: string;
+    for (const layer of stack) {
+      if (layer.route === "/" && !layer.handler.__resolve__) {
+        continue;
+      }
+      if (!path.startsWith(layer.route)) {
+        continue;
+      }
+      _layerPath = path.slice(layer.route.length) || "/";
+      if (layer.match && !layer.match(_layerPath, undefined)) {
+        continue;
+      }
+      let res = { route: layer.route, handler: layer.handler };
+      if (res.handler.__resolve__) {
+        const _res = await res.handler.__resolve__(_layerPath);
+        if (!_res) {
+          continue;
+        }
+        res = {
+          ...res,
+          ..._res,
+          route: joinURL(res.route || "/", _res.route || "/"),
+        };
+      }
+      return res;
+    }
+  };
+}
+
+export function normalizeLayer(input: InputLayer) {
+  let handler = input.handler;
+  // @ts-ignore
+  if (handler.handler) {
+    // @ts-ignore
+    handler = handler.handler;
+  }
+
+  if (input.lazy) {
+    handler = defineLazyEventHandler(handler as unknown as LazyEventHandler);
+  } else if (!isEventHandler(handler)) {
+    handler = toEventHandler(handler, undefined, input.route);
+  }
+
+  return {
+    route: withoutTrailingSlash(input.route),
+    match: input.match,
+    handler,
+  } as Layer;
+}
+
+export function resolveWebsocketOptions(
+  evResolver: EventHandlerResolver,
+  appOptions: AppOptions,
+): WebSocketOptions {
+  return {
+    ...appOptions.websocket,
+    async resolve(info) {
+      const pathname = getPathname(info.url || "/");
+      const resolved = await evResolver(pathname);
+      return resolved?.handler?.__websocket__ || {};
+    },
+  };
+}
+
+export function cachedFn<T>(fn: () => T): () => T {
+  let cache: T;
+  return () => {
+    if (!cache) {
+      cache = fn();
+    }
+    return cache;
+  };
+}
diff --git a/src/app/app.ts b/src/app/app.ts
new file mode 100644
index 0000000..8fab4e6
--- /dev/null
+++ b/src/app/app.ts
@@ -0,0 +1,40 @@
+import type { App, Stack, AppOptions } from "../types";
+import { _kRaw } from "../event";
+import { createAppEventHandler } from "./_handler";
+import {
+  cachedFn,
+  createResolver,
+  resolveWebsocketOptions,
+  use,
+} from "./_utils";
+
+/**
+ * Create a new H3 app instance.
+ */
+export function createApp(options: AppOptions = {}): App {
+  const stack: Stack = [];
+
+  const handler = createAppEventHandler(stack, options);
+
+  const resolve = createResolver(stack);
+  handler.__resolve__ = resolve;
+
+  const getWebsocket = cachedFn(() =>
+    resolveWebsocketOptions(resolve, options),
+  );
+
+  const _use = (arg1: any, arg2: any, arg3: any) => use(app, arg1, arg2, arg3);
+
+  const app: App = {
+    use: _use as App["use"],
+    resolve,
+    handler,
+    stack,
+    options,
+    get websocket() {
+      return getWebsocket();
+    },
+  };
+
+  return app;
+}
diff --git a/src/deprecated.ts b/src/deprecated.ts
index 71f5beb..4d99dd2 100644
--- a/src/deprecated.ts
+++ b/src/deprecated.ts
@@ -15,6 +15,9 @@ import { getBodyStream } from "./utils/body";
 import {
   appendResponseHeader,
   appendResponseHeaders,
+  iterable,
+  noContent,
+  redirect,
   setResponseHeader,
   setResponseHeaders,
 } from "./utils/response";
@@ -30,6 +33,7 @@ import {
   readValidatedJSONBody,
 } from "./utils/body";
 import { defineEventHandler, defineLazyEventHandler } from "./handler";
+import { proxy } from "./utils/proxy";
 
 /** @deprecated Please use `getRequestHeader`  */
 export const getHeader = getRequestHeader;
@@ -39,10 +43,10 @@ export const getHeaders = getRequestHeaders;
 
 /** @deprecated Directly return stream */
 export function sendStream(
-  event: H3Event,
+  _event: H3Event,
   value: ReadableStream | NodeReadableStream,
 ) {
-  return event[_kRaw].sendResponse(value);
+  return value;
 }
 
 /** Please use `defineEventHandler`  */
@@ -90,6 +94,24 @@ export const getRequestWebStream = getBodyStream;
 /** @deprecated Please use `event.path` instead */
 export const getRequestPath = (event: H3Event) => event.path;
 
+/** @deprecated Use `return iterable()` */
+export const sendIterable = (
+  _event: H3Event,
+  ...args: Parameters<typeof iterable>
+) => iterable(...args);
+
+/** @deprecated Use `return noContent()` */
+export const sendNoContent = noContent;
+
+/** @deprecated Use `return redirect()` */
+export const sendRedirect = redirect;
+
+/** @deprecated Use `return response` */
+export const sendWebResponse = (response: Response) => response;
+
+/** @deprecated Use `return proxy()` */
+export const sendProxy = proxy;
+
 // --- Types ---
 
 /** @deprecated Please use `RequestMiddleware` */
diff --git a/src/error.ts b/src/error.ts
index b7ca684..956e3f9 100644
--- a/src/error.ts
+++ b/src/error.ts
@@ -1,7 +1,6 @@
 import { _kRaw } from "./event";
 import { hasProp } from "./utils/internal/object";
 import { sanitizeStatusMessage, sanitizeStatusCode } from "./utils/sanitize";
-import { MIMES } from "./utils/internal/consts";
 
 /**
  * H3 Runtime Error
@@ -142,31 +141,6 @@ export function createError<DataT = unknown>(
   return err;
 }
 
-export function errorToResponse(error: Error | H3Error, debug?: boolean) {
-  const h3Error = isError(error) ? error : createError(error);
-  const response = {
-    error: h3Error,
-    status: h3Error.statusCode,
-    statusText: h3Error.statusMessage,
-    headers: {
-      "content-type": MIMES.json,
-    },
-    body: JSON.stringify(
-      {
-        statusCode: h3Error.statusCode,
-        statusMessage: h3Error.statusMessage,
-        data: h3Error.data,
-        stack: debug
-          ? (h3Error.stack || "").split("\n").map((l) => l.trim())
-          : undefined,
-      },
-      undefined,
-      2,
-    ),
-  };
-  return response;
-}
-
 /**
  * Checks if the given input is an instance of H3Error.
  *
diff --git a/src/event.ts b/src/event.ts
index 2fc4760..9d83a86 100644
--- a/src/event.ts
+++ b/src/event.ts
@@ -1,4 +1,5 @@
-import type { H3Event, RawEvent } from "./types/event";
+import type { H3EventContext, H3Event } from "./types";
+import { RawEvent } from "./types/event";
 
 export const _kRaw: unique symbol = Symbol.for("h3.internal.raw");
 
@@ -12,8 +13,11 @@ export class EventWrapper implements H3Event {
   _onBeforeResponseCalled: boolean | undefined;
   _onAfterResponseCalled: boolean | undefined;
 
-  constructor(raw: RawEvent) {
+  constructor(raw: RawEvent, initialContext?: H3EventContext) {
     this[_kRaw] = raw;
+    if (initialContext) {
+      Object.assign(this.context, initialContext);
+    }
   }
 
   get method() {
diff --git a/src/handler.ts b/src/handler.ts
index c83838c..f5b22f9 100644
--- a/src/handler.ts
+++ b/src/handler.ts
@@ -86,9 +86,6 @@ async function _callHandler<
   if (hooks.onRequest) {
     for (const hook of hooks.onRequest) {
       await hook(event);
-      if (event[_kRaw].handled) {
-        return;
-      }
     }
   }
   const body = await handler(event);
diff --git a/src/index.ts b/src/index.ts
index d717433..8365226 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -2,7 +2,7 @@
 export * from "./types";
 
 // App
-export { createApp, use, createAppEventHandler } from "./app";
+export { createApp } from "./app/app";
 
 // Event
 export { isEvent } from "./event";
@@ -46,7 +46,6 @@ export {
   fromPlainHandler,
   toPlainHandler,
   fromPlainRequest,
-  callWithWebRequest,
   callWithPlainRequest,
 } from "./adapters/web";
 
@@ -74,10 +73,6 @@ export {
 export {
   appendResponseHeader,
   appendResponseHeaders,
-  sendIterable,
-  sendNoContent,
-  sendRedirect,
-  sendWebResponse,
   setResponseHeader,
   setResponseHeaders,
   setResponseStatus,
@@ -93,7 +88,7 @@ export {
 
 // Proxy
 export {
-  sendProxy,
+  proxy,
   getProxyRequestHeaders,
   proxyRequest,
   fetchWithEvent,
diff --git a/src/types/app.ts b/src/types/app.ts
index e8a7aca..cf39b2c 100644
--- a/src/types/app.ts
+++ b/src/types/app.ts
@@ -1,8 +1,15 @@
 import type { AdapterOptions as WSOptions } from "crossws";
 import type { H3Event } from "./event";
-import type { EventHandler, EventHandlerResolver } from "./handler";
+import type {
+  EventHandler,
+  EventHandlerRequest,
+  EventHandlerResolver,
+  ResponseBody,
+} from "./handler";
 import type { H3Error } from "../error";
 
+type MaybePromise<T> = T | Promise<T>;
+
 export type { H3Error } from "../error";
 
 export interface Layer {
@@ -24,6 +31,15 @@ export type InputStack = InputLayer[];
 
 export type Matcher = (url: string, event?: H3Event) => boolean;
 
+export interface AppResponse {
+  error?: H3Error;
+  body: ResponseBody;
+  contentType?: string;
+  headers?: Headers;
+  status?: number;
+  statusText?: string;
+}
+
 export interface AppUse {
   (
     route: string | string[],
@@ -38,22 +54,22 @@ export type WebSocketOptions = WSOptions;
 
 export interface AppOptions {
   debug?: boolean;
-  onError?: (error: H3Error, event: H3Event) => any;
-  onRequest?: (event: H3Event) => void | Promise<void>;
+  onError?: (error: H3Error, event: H3Event) => MaybePromise<void | unknown>;
+  onRequest?: (event: H3Event) => MaybePromise<void>;
   onBeforeResponse?: (
     event: H3Event,
-    response: { body?: unknown },
-  ) => void | Promise<void>;
+    response: AppResponse,
+  ) => MaybePromise<void>;
   onAfterResponse?: (
     event: H3Event,
-    response?: { body?: unknown },
-  ) => void | Promise<void>;
+    response?: AppResponse,
+  ) => MaybePromise<void>;
   websocket?: WebSocketOptions;
 }
 
 export interface App {
   stack: Stack;
-  handler: EventHandler;
+  handler: EventHandler<EventHandlerRequest, Promise<ResponseBody>>;
   options: AppOptions;
   use: AppUse;
   resolve: EventHandlerResolver;
diff --git a/src/types/event.ts b/src/types/event.ts
index 4e986d2..84acd6b 100644
--- a/src/types/event.ts
+++ b/src/types/event.ts
@@ -1,4 +1,3 @@
-import type { Readable as NodeReadableStream } from "node:stream";
 import type { EventHandlerRequest, H3EventContext, HTTPMethod } from ".";
 import type { _kRaw } from "../event";
 
@@ -26,14 +25,6 @@ export interface H3Event<
   _onAfterResponseCalled: boolean | undefined;
 }
 
-export type RawResponse =
-  | undefined
-  | null
-  | Uint8Array
-  | string
-  | ReadableStream
-  | NodeReadableStream;
-
 export interface RawEvent {
   // -- Context --
   getContext: () => Record<string, unknown>;
@@ -57,8 +48,6 @@ export interface RawEvent {
 
   // -- Response --
 
-  readonly handled: boolean | undefined;
-
   responseCode: number | undefined;
   responseMessage: string | undefined;
 
@@ -69,6 +58,6 @@ export interface RawEvent {
   getResponseSetCookie: () => string[];
   removeResponseHeader: (key: string) => void;
   writeHead: (code: number, message?: string) => void;
-  sendResponse: (data?: RawResponse) => void | Promise<void>;
+  // sendResponse: (data?: RawResponse) => void | Promise<void>;
   writeEarlyHints: (hints: Record<string, string>) => void | Promise<void>;
 }
diff --git a/src/types/handler.ts b/src/types/handler.ts
index 8692e4b..9bd11a2 100644
--- a/src/types/handler.ts
+++ b/src/types/handler.ts
@@ -1,7 +1,14 @@
+import type { Readable as NodeReadableStream } from "node:stream";
 import type { QueryObject } from "ufo";
 import type { H3Event } from "./event";
 import type { Hooks as WSHooks } from "crossws";
 
+export type ResponseBody =
+  | undefined // middleware pass
+  | null // empty content
+  | BodyInit
+  | NodeReadableStream;
+
 export type EventHandlerResponse<T = unknown> = T | Promise<T>;
 
 export interface EventHandlerRequest {
diff --git a/src/types/index.ts b/src/types/index.ts
index 711afb5..b19cab5 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -23,6 +23,7 @@ export type {
   EventHandlerResolver,
   EventHandlerResponse,
   DynamicEventHandler,
+  ResponseBody,
   LazyEventHandler,
   InferEventInput,
   RequestMiddleware,
diff --git a/src/types/utils/static.ts b/src/types/utils/static.ts
index f6cfc67..b478c9b 100644
--- a/src/types/utils/static.ts
+++ b/src/types/utils/static.ts
@@ -1,4 +1,4 @@
-import type { RawResponse } from "../event";
+import type { ResponseBody } from "../handler";
 
 export interface StaticAssetMeta {
   type?: string;
@@ -20,7 +20,7 @@ export interface ServeStaticOptions {
   /**
    * This function should resolve asset content
    */
-  getContents: (id: string) => RawResponse | Promise<RawResponse>;
+  getContents: (id: string) => ResponseBody | Promise<ResponseBody>;
 
   /**
    * Map of supported encodings (compressions) and their file extensions.
diff --git a/src/utils/cache.ts b/src/utils/cache.ts
index 924b2c8..601f19e 100644
--- a/src/utils/cache.ts
+++ b/src/utils/cache.ts
@@ -38,9 +38,6 @@ export function handleCacheHeaders(
 
   if (cacheMatched) {
     event[_kRaw].responseCode = 304;
-    if (!event[_kRaw].handled) {
-      event[_kRaw].sendResponse();
-    }
     return true;
   }
 
diff --git a/src/utils/cors.ts b/src/utils/cors.ts
index 5ba2c45..b7f2a7f 100644
--- a/src/utils/cors.ts
+++ b/src/utils/cors.ts
@@ -1,7 +1,7 @@
-import type { H3Event } from "../types";
+import type { H3Event, ResponseBody } from "../types";
 import type { H3CorsOptions } from "../types/utils/cors";
 import { _kRaw } from "../event";
-import { sendNoContent, appendResponseHeaders } from "./response";
+import { noContent, appendResponseHeaders } from "./response";
 import {
   createAllowHeaderHeaders,
   createCredentialsHeaders,
@@ -60,26 +60,28 @@ export function appendCorsHeaders(event: H3Event, options: H3CorsOptions) {
  * const router = createRouter();
  * router.use('/',
  *   defineEventHandler(async (event) => {
- *       const didHandleCors = handleCors(event, {
+ *       const corsRes = handleCors(event, {
  *         origin: '*',
  *         preflight: {
  *          statusCode: 204,
  *         },
  *      methods: '*',
  *    });
- *    if (didHandleCors) {
- *      return;
+ *    if (corsRes) {
+ *      return corsRes;
  *    }
  *    // Your code here
  *  })
  * );
  */
-export function handleCors(event: H3Event, options: H3CorsOptions): boolean {
+export function handleCors(
+  event: H3Event,
+  options: H3CorsOptions,
+): false | ResponseBody {
   const _options = resolveCorsOptions(options);
   if (isPreflightRequest(event)) {
     appendCorsPreflightHeaders(event, options);
-    sendNoContent(event, _options.preflight.statusCode);
-    return true;
+    return noContent(event, _options.preflight.statusCode);
   }
   appendCorsHeaders(event, options);
   return false;
diff --git a/src/utils/internal/consts.ts b/src/utils/internal/consts.ts
index e4f30b5..3cfd5e5 100644
--- a/src/utils/internal/consts.ts
+++ b/src/utils/internal/consts.ts
@@ -1,4 +1,5 @@
 export const MIMES = {
   html: "text/html",
   json: "application/json",
+  octetStream: "application/octet-stream",
 } as const;
diff --git a/src/utils/internal/event-stream.ts b/src/utils/internal/event-stream.ts
index 2e2363a..d88cc54 100644
--- a/src/utils/internal/event-stream.ts
+++ b/src/utils/internal/event-stream.ts
@@ -1,4 +1,4 @@
-import type { H3Event } from "../../types";
+import type { H3Event, ResponseBody } from "../../types";
 import type { ResponseHeaders } from "../../types/http";
 import type { NodeEvent } from "../../types/node";
 import type {
@@ -139,10 +139,6 @@ export class EventStream {
         // Ignore
       }
     }
-    // check if the stream has been given to the client before closing the connection
-    if (this._event[_kRaw].handled && this._handled) {
-      this._event[_kRaw].sendResponse();
-    }
     this._disposed = true;
   }
 
@@ -154,11 +150,11 @@ export class EventStream {
     this._writer.closed.then(cb);
   }
 
-  async send() {
+  async send(): Promise<ResponseBody> {
     setEventStreamHeaders(this._event);
     setResponseStatus(this._event, 200);
     this._handled = true;
-    this._event[_kRaw].sendResponse(this._transformStream.readable);
+    return this._transformStream.readable;
   }
 }
 
diff --git a/src/utils/proxy.ts b/src/utils/proxy.ts
index 47a24ed..ae9937d 100644
--- a/src/utils/proxy.ts
+++ b/src/utils/proxy.ts
@@ -1,4 +1,10 @@
-import type { H3EventContext, H3Event, ProxyOptions, Duplex } from "../types";
+import type {
+  H3EventContext,
+  H3Event,
+  ProxyOptions,
+  Duplex,
+  ResponseBody,
+} from "../types";
 import { splitCookiesString } from "./cookie";
 import { sanitizeStatusMessage, sanitizeStatusCode } from "./sanitize";
 import { _kRaw } from "../event";
@@ -41,7 +47,7 @@ export async function proxyRequest(
     opts.headers,
   );
 
-  return sendProxy(event, target, {
+  return proxy(event, target, {
     ...opts,
     fetchOptions: {
       method,
@@ -56,11 +62,11 @@ export async function proxyRequest(
 /**
  * Make a proxy request to a target URL and send the response back to the client.
  */
-export async function sendProxy(
+export async function proxy(
   event: H3Event,
   target: string,
   opts: ProxyOptions = {},
-) {
+): Promise<ResponseBody> {
   let response: Response | undefined;
   try {
     response = await getFetch(opts.fetch)(target, {
@@ -125,19 +131,13 @@ export async function sendProxy(
     return (response as any)._data;
   }
 
-  // Ensure event is not handled
-  if (event[_kRaw].handled) {
-    return;
-  }
-
   // Send at once
   if (opts.sendStream === false) {
-    const data = new Uint8Array(await response.arrayBuffer());
-    return event[_kRaw].sendResponse(data);
+    return new Uint8Array(await response.arrayBuffer());
   }
 
   // Send as stream
-  return event[_kRaw].sendResponse(response.body);
+  return response.body;
 }
 
 /**
diff --git a/src/utils/response.ts b/src/utils/response.ts
index 7acf63b..7de2937 100644
--- a/src/utils/response.ts
+++ b/src/utils/response.ts
@@ -1,10 +1,13 @@
-import type { H3Event } from "../types";
-import type { ResponseHeaders, ResponseHeaderName } from "../types/http";
-import type { MimeType, StatusCode } from "../types";
+import type {
+  H3Event,
+  ResponseHeaders,
+  ResponseHeaderName,
+  MimeType,
+  StatusCode,
+} from "../types";
 import { _kRaw } from "../event";
 import { MIMES } from "./internal/consts";
 import { sanitizeStatusCode, sanitizeStatusMessage } from "./sanitize";
-import { splitCookiesString } from "./cookie";
 import {
   serializeIterableValue,
   coerceIterable,
@@ -15,26 +18,15 @@ import {
 /**
  * Respond with an empty payload.<br>
  *
- * Note that calling this function will close the connection and no other data can be sent to the client afterwards.
- *
- * @example
- * export default defineEventHandler((event) => {
- *   return sendNoContent(event);
- * });
  * @example
  * export default defineEventHandler((event) => {
- *   sendNoContent(event); // Close the connection
- *   console.log("This will not be executed");
+ *   return noContent(event);
  * });
  *
  * @param event H3 event
  * @param code status code to be send. By default, it is `204 No Content`.
  */
-export function sendNoContent(event: H3Event, code?: StatusCode) {
-  if (event[_kRaw].handled) {
-    return;
-  }
-
+export function noContent(event: H3Event, code?: StatusCode) {
   if (!code && event[_kRaw].responseCode !== 200) {
     // status code was set with setResponseStatus
     code = event[_kRaw].responseCode;
@@ -46,7 +38,7 @@ export function sendNoContent(event: H3Event, code?: StatusCode) {
     event[_kRaw].removeResponseHeader("content-length");
   }
   event[_kRaw].writeHead(_code);
-  event[_kRaw].sendResponse();
+  return "";
 }
 
 /**
@@ -122,15 +114,15 @@ export function defaultContentType(event: H3Event, type?: MimeType) {
  *
  * @example
  * export default defineEventHandler((event) => {
- *   return sendRedirect(event, "https://example.com");
+ *   return redirect(event, "https://example.com");
  * });
  *
  * @example
  * export default defineEventHandler((event) => {
- *   return sendRedirect(event, "https://example.com", 301); // Permanent redirect
+ *   return redirect(event, "https://example.com", 301); // Permanent redirect
  * });
  */
-export function sendRedirect(
+export function redirect(
   event: H3Event,
   location: string,
   code: StatusCode = 302,
@@ -143,7 +135,7 @@ export function sendRedirect(
   const encodedLoc = location.replace(/"/g, "%22");
   const html = `<!DOCTYPE html><html><head><meta http-equiv="refresh" content="0; url=${encodedLoc}"></head></html>`;
   defaultContentType(event, MIMES.html);
-  return event[_kRaw].sendResponse(html);
+  return html;
 }
 
 /**
@@ -298,38 +290,6 @@ export function writeEarlyHints(
   return event[_kRaw].writeEarlyHints(hints);
 }
 
-/**
- * Send a Web besponse object to the client.
- */
-export function sendWebResponse(
-  event: H3Event,
-  response: Response,
-): void | Promise<void> {
-  for (const [key, value] of response.headers) {
-    if (key === "set-cookie") {
-      for (const setCookie of splitCookiesString(value)) {
-        event[_kRaw].appendResponseHeader(key, setCookie);
-      }
-    } else {
-      event[_kRaw].setResponseHeader(key, value);
-    }
-  }
-
-  if (response.status) {
-    event[_kRaw].responseCode = sanitizeStatusCode(
-      response.status,
-      event[_kRaw].responseCode,
-    );
-  }
-  if (response.statusText) {
-    event[_kRaw].responseMessage = sanitizeStatusMessage(response.statusText);
-  }
-  if (response.redirected) {
-    event[_kRaw].setResponseHeader("location", response.url);
-  }
-  return event[_kRaw].sendResponse(response.body);
-}
-
 /**
  * Iterate a source of chunks and send back each chunk in order.
  * Supports mixing async work together with emitting chunks.
@@ -344,8 +304,7 @@ export function sendWebResponse(
  * @template Value - Test
  *
  * @example
- * sendIterable(event, work());
- * async function* work() {
+ * return iterable(async function* work() {
  *   // Open document body
  *   yield "<!DOCTYPE html>\n<html><body><h1>Executing...</h1><ol>\n";
  *   // Do work ...
@@ -358,37 +317,34 @@ export function sendWebResponse(
  *   }
  *   // Close out the report
  *   return `</ol></body></html>`;
- * }
+ * })
  * async function delay(ms) {
  *   return new Promise(resolve => setTimeout(resolve, ms));
  * }
  */
-export function sendIterable<Value = unknown, Return = unknown>(
-  event: H3Event,
+export function iterable<Value = unknown, Return = unknown>(
   iterable: IterationSource<Value, Return>,
   options?: {
     serializer: IteratorSerializer<Value | Return>;
   },
-): void | Promise<void> {
+): ReadableStream {
   const serializer = options?.serializer ?? serializeIterableValue;
   const iterator = coerceIterable(iterable);
-  event[_kRaw].sendResponse(
-    new ReadableStream({
-      async pull(controller) {
-        const { value, done } = await iterator.next();
-        if (value !== undefined) {
-          const chunk = serializer(value);
-          if (chunk !== undefined) {
-            controller.enqueue(chunk);
-          }
-        }
-        if (done) {
-          controller.close();
+  return new ReadableStream({
+    async pull(controller) {
+      const { value, done } = await iterator.next();
+      if (value !== undefined) {
+        const chunk = serializer(value);
+        if (chunk !== undefined) {
+          controller.enqueue(chunk);
         }
-      },
-      cancel() {
-        iterator.return?.();
-      },
-    }),
-  );
+      }
+      if (done) {
+        controller.close();
+      }
+    },
+    cancel() {
+      iterator.return?.();
+    },
+  });
 }
diff --git a/src/utils/static.ts b/src/utils/static.ts
index 2e79a01..ae14d4b 100644
--- a/src/utils/static.ts
+++ b/src/utils/static.ts
@@ -1,4 +1,9 @@
-import type { H3Event, StaticAssetMeta, ServeStaticOptions } from "../types";
+import type {
+  H3Event,
+  StaticAssetMeta,
+  ServeStaticOptions,
+  ResponseBody,
+} from "../types";
 import { decodePath } from "ufo";
 import { _kRaw } from "../event";
 import { createError } from "../error";
@@ -14,7 +19,7 @@ import {
 export async function serveStatic(
   event: H3Event,
   options: ServeStaticOptions,
-): Promise<void | false> {
+): Promise<false | ResponseBody> {
   if (event.method !== "GET" && event.method !== "HEAD") {
     if (!options.fallthrough) {
       throw createError({
@@ -75,7 +80,7 @@ export async function serveStatic(
   if (ifNotMatch) {
     event[_kRaw].responseCode = 304;
     event[_kRaw].responseMessage = "Not Modified";
-    return event?.[_kRaw]?.sendResponse("");
+    return "";
   }
 
   if (meta.mtime) {
@@ -85,7 +90,7 @@ export async function serveStatic(
     if (ifModifiedSinceH && new Date(ifModifiedSinceH) >= mtimeDate) {
       event[_kRaw].responseCode = 304;
       event[_kRaw].responseMessage = "Not Modified";
-      return event?.[_kRaw]?.sendResponse("");
+      return "";
     }
 
     if (!event[_kRaw].getResponseHeader("last-modified")) {
@@ -110,11 +115,11 @@ export async function serveStatic(
   }
 
   if (event.method === "HEAD") {
-    return event?.[_kRaw]?.sendResponse();
+    return "";
   }
 
   const contents = await options.getContents(id);
-  return event?.[_kRaw]?.sendResponse(contents);
+  return contents;
 }
 
 // --- Internal Utils ---


Test patch:
diff --git a/test/_playground.ts b/test/_playground.ts
new file mode 100644
index 0000000..9101cf2
--- /dev/null
+++ b/test/_playground.ts
@@ -0,0 +1,16 @@
+import { createServer } from "node:http";
+import { createApp, createRouter, eventHandler, toNodeHandler } from "../src";
+
+export const app = createApp();
+
+const router = createRouter();
+app.use(router);
+
+router.get(
+  "/",
+  eventHandler(() => Buffer.from("<h1>Hello world!</h1>", "utf8")),
+);
+
+createServer(toNodeHandler(app)).listen(3000, () => {
+  console.log("Server listening on http://localhost:3000");
+});
diff --git a/test/app.test.ts b/test/app.test.ts
index 4983c98..6023a37 100644
--- a/test/app.test.ts
+++ b/test/app.test.ts
@@ -117,7 +117,7 @@ describe("app", () => {
     );
     const res = await ctx.request.get("/");
 
-    expect(res.text).toBe("<h1>Hello world!</h1>");
+    expect(res.body.toString("utf8")).toBe("<h1>Hello world!</h1>");
   });
 
   it("Node.js Readable Stream", async () => {
diff --git a/test/proxy.test.ts b/test/proxy.test.ts
index bc16b14..6c7c4bc 100644
--- a/test/proxy.test.ts
+++ b/test/proxy.test.ts
@@ -10,7 +10,7 @@ import {
   readRawBody,
   appendResponseHeader,
 } from "../src";
-import { sendProxy, proxyRequest } from "../src/utils/proxy";
+import { proxy, proxyRequest } from "../src/utils/proxy";
 import { setupTest } from "./_utils";
 
 const spy = vi.spyOn(console, "error");
@@ -18,7 +18,7 @@ const spy = vi.spyOn(console, "error");
 describe("proxy", () => {
   const ctx = setupTest();
 
-  describe("sendProxy", () => {
+  describe("proxy()", () => {
     it("works", async () => {
       ctx.app.use(
         "/hello",
@@ -27,7 +27,7 @@ describe("proxy", () => {
       ctx.app.use(
         "/",
         eventHandler((event) => {
-          return sendProxy(event, ctx.url + "/hello", { fetch });
+          return proxy(event, ctx.url + "/hello", { fetch });
         }),
       );
 
@@ -251,7 +251,7 @@ describe("proxy", () => {
       ctx.app.use(
         "/",
         eventHandler((event) => {
-          return sendProxy(event, ctx.url + "/setcookies", { fetch });
+          return proxy(event, ctx.url + "/setcookies", { fetch });
         }),
       );
 
diff --git a/test/status.test.ts b/test/status.test.ts
index b20174e..b5d0279 100644
--- a/test/status.test.ts
+++ b/test/status.test.ts
@@ -89,7 +89,7 @@ describe("setResponseStatus", () => {
         "/test",
         eventHandler((event) => {
           setResponseStatus(event, 418, "status-text");
-          return null;
+          return "";
         }),
       );
 
@@ -103,7 +103,7 @@ describe("setResponseStatus", () => {
       expect(res).toMatchObject({
         status: 418,
         statusText: "status-text",
-        body: undefined,
+        body: "",
         headers: {},
       });
     });