Repository: unjs/h3
Pull request #822 (2024-07-09 20:18:18)

feat!: integrate router with app and `app.fetch`
This is a major upgrade for h3! Since the initial version, h3's core was modeled as a stack runner, using an array of middleware (path prefixes and event handlers) matching them one by one, and stopping as soon as the first middleware responded.

We then added a new router system to h3 using radix3 which uses route pattern matching.

With advancements of [rou3](https://rou3.unjs.io), we can actually have a much smarter combined app.

In order to keep compatibility with previous versions, the h3 app smartly chooses between 3 strategies (in order):

1. global middleware without prefix registered with `app.use(handler)`
2. middleware with route registered with `app.use("/prefix", {})` (this is not encouraged due to perf overhead!) 
3. routes with method registered with `app.all(handler` or `app.[method](handler)`

Any of the middleware can still return a response to stop the process (this is to keep more backward compatibility) but there are new behavior changes:

- middleware registered with `app.use("/path", handler)` only match `/path` route. For matching all subpaths like before, it should be updated to `app.use("/path/**", handler)`
- The `event.path` received in each handler will have a full path without omitting the prefixes. `useBase(base, handler)` wrapper can be used if old behavior is desired.
- custom `match` function for `app.use` is not supported anymore (middleware can skip themselves based on path)

Other changers:

- `app.options` (to access config) is renamed to `app.config` (`app.options` registers an event handler for [OPTIONS](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/OPTIONS) HTTP method)


**Migration guide:** https://github.com/unjs/h3/issues/787


Patch:
diff --git a/MIGRATION.md b/MIGRATION.md
index 6b59217..f9f9984 100644
--- a/MIGRATION.md
+++ b/MIGRATION.md
@@ -12,42 +12,64 @@ h3 v2 includes some behavior and API changes that you need to consider applying
 > [!NOTE]
 > This is an undergoing migration guide and is not finished yet.
 
-## Fully decoupled from Node.js
+## Web adapter
 
-We started migrating h3 towards Web standards since [v1.8](https://unjs.io/blog/2023-08-15-h3-towards-the-edge-of-the-web). h3 apps are now fully decoupled from Node.js using an adapter-based abstraction layer to support Web and Node.js runtime features and performances natively.
+H3 v2 is now web native and you can directly use `app.fetch(request, init?, { context })`.
 
-This migration significantly reduces your bundle sizes and overhead in Web-native runtimes such as [Bun](https://bun.sh/), [Deno](https://deno.com) and [Cloudflare Workers](https://workers.cloudflare.com/).
+Old utils for plain handler and web handler are removed to embrace web standards.
 
-Since v2, Event properties `event.node.{req,res}` and `event.web` is not available anymore, instead, you can use `getNodeContext(event)` and `getWebContext(event)` to access raw objects for each runtime.
+## Event interface
 
-## Response handling
+Event properties `event.node.{req,res}` and `event.web` are not available anymore, instead, you can use `getNodeContext(event)` and `getWebContext(event)` utils to access raw objects for each runtime.
+
+`event.handler` property is removed since h3 relies on explicit responses.
 
-You should always explicitly `return` or `throw` responses and errors from event handlers.
+## Response handling
 
-Previously h3 had `send*` utils that could interop the response handling lifecycle **anywhere** in any utility or middleware causing unpredictable application state control. To mitigate edge cases of this, previously h3 added `event.handler` property which is now gone!
+You should always explicitly use `return` for response and `throw` for errors from event handlers.
 
-If you were previously using these methods, you can replace them with `return` statements returning a text, JSON value, stream, or web `Response` (h3 smartly detects and handles them):
+If you were previously using these methods, you can replace them with `return` statements returning a text, JSON, stream, or web `Response` (h3 smartly detects and handles them):
 
-- `send(event, value)`: Use `return <value>`
-- `sendNoContent(event)`: Use `return null`
-- `sendError(event, error)`: Use `throw createError()`
-- `sendStream(event, stream)`: Use `return stream`
-- `sendWebResponse(event, response)`: Use `return response`
+- `send(event, value)`: Migrate to `return <value>`.
+- `sendError(event, <error>)`: Migrate to `throw createError(<error>)`.
+- `sendStream(event, <stream>)`: Migrate to `return <stream>`.
+- `sendWebResponse(event, <response>)`: Migrate to `return <response>`.
 
 Other send utils that are renamed and need explicit `return`:
 
-- `sendIterable(event, value)`: Use `return iterable()`
-- `sendRedirect(event, location, code)`: Use `return redirect(event, location, code)`
-- `sendProxy(event, target)`: Use `return proxy(event, target)`
+- `sendNoContent(event)` / `return null`: Migrate to `return noContent(event)`.
+- `sendIterable(event, <value>)`: Migrate to `return iterable(<value>)`.
+- `sendRedirect(event, location, code)`: Migrate to `return redirect(event, location, code)`.
+- `sendProxy(event, target)`: Migrate to `return proxy(event, target)`.
 - `handleCors(event)`: Check return value (boolean) and early `return` if handled.
 - `serveStatic(event, content)`: Make sure to add `return` before.
 
 ## App interface and router
 
-- `app.use(() => handler, { lazy: true })` is no supported anymore. Instead you can use `app.use(defineLazyHabndler(() => handler), { lazy: true })`
+Router functionality is now integrated in h3 app core. Instead of `createApp()` and `createRouter()` you can use `const app = createH3()`.
+
+Methods:
+
+- `app.use(handler)`: Add a global middleware.
+- `app.use(route, handler)`: Add a routed middleware.
+- `app.on(method, handler)` / `app.all(handler)` / `app.[METHOD](handler)`: Add a route handler.
+
+Running order:
+
+- All global middleware by the same order they added
+- All routed middleware by from least specific to most specific paths (auto sorted)
+- Matched route handler
+
+Any of middleware or route handler, can return a response.
+
+Other changes from v1:
+
+- Handlers registered with `app.use("/path", handler)` only match `/path` (not `/path/foo/bar`). For matching all subpaths like before, it should be updated to `app.use("/path/**", handler)`.
+- The `event.path` received in each handler will have a full path without omitting the prefixes. use `withBase(base, handler)` utility to make prefixed app. (example: `withBase("/api", app.handler)`).
+- `app.use(() => handler, { lazy: true })` is no supported anymore. Instead you can use `app.use(defineLazyEventHandler(() => handler), { lazy: true })`
 - `app.use(["/path1", "/path2"], ...)` and `app.use("/path", [handler1, handler2])` are not supported anymore. Instead use multiple `app.use()` calls.
-- `app.use({ route, handler })` should be updated to `app.use({ prefix, handler })` (route property is used for router patterns)
-- `app.resolve(path) => { route, handler }` changed to `app.resolve(method, path) => { prefix?, route?, handler }` (`prefix` is the registred prefix and `route` is the route pattern).
+- Custom `match` function for `app.use` is not supported anymore (middleware can skip themselves).
+- `app.resolve(path) => { route, handler }` changed to `app.resolve(method, path) => { method route, handler }`
 
 ### Router
 
@@ -73,7 +95,7 @@ The legacy `readBody` and `readRawBody` utils are replaced with a new set of bod
 - Body utils won't throw an error if the incoming request has no body (or is a `GET` method for example) but instead, returns `undefined`
 - `readJSONBody` does not use [unjs/destr](https://destr.unjs.io) anymore. You should always filter and sanitize data coming from user to avoid [prototype-poisoning](https://medium.com/intrinsic-blog/javascript-prototype-poisoning-vulnerabilities-in-the-wild-7bc15347c96)
 
-## Cookie and Headers
+## Cookie and headers
 
 h3 migrated to leverage standard web [`Headers`](https://developer.mozilla.org/en-US/docs/Web/API/Headers) for all utils.
 
@@ -85,65 +107,77 @@ For the [`Set-Cookie`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers
 
 h3 v2 deprecated some legacy and aliased utilities.
 
+**App and router:**
+
+- `createRouter`: Migrate to `createH3`
+- `createApp`: Migrate to `createH3`
+
 **Handler:**
 
-- `eventHandler`: Use `defineEventHandler`
-- `toEventHandler`: (it is not required anymore)
-- `lazyEventHandler`: Use `defineLazyEventHandler`
+- `eventHandler`: Migrate to `defineEventHandler`
+- `lazyEventHandler`: Migrate to `defineLazyEventHandler`
+- `useBase`: Migrate to `withbase`
+- `toEventHandler` / `isEventHandler`: (removed) Any function can be an event handler.
 
 **Request:**
 
-- `getHeader`: Use `getRequestHeader`
-- `getHeaders`: Use `getRequestHeaders`
-- `getRequestPath`: Use `event.path`
+- `getHeader`: Migrate to `getRequestHeader`.
+- `getHeaders`: Migrate to `getRequestHeaders`.
+- `getRequestPath`: Migrate to `event.path`.
 
 **Response:**
 
-- `appendHeader`: Use `appendResponseHeader`
-- `appendHeaders`: Use `appendResponseHeaders`
-- `setHeader`: Use `setResponseHeader`
-- `setHeaders` => Use `setResponseHeaders`
+- `appendHeader`: Migrate to `appendResponseHeader`.
+- `appendHeaders`: Migrate to `appendResponseHeaders`.
+- `setHeader`: Migrate to `setResponseHeader`.
+- `setHeaders`: Migrate to `setResponseHeaders`.
 
 **Node.js:**
 
-- `defineNodeListener`: Use `defineNodeHandler`
-- `fromNodeMiddleware`: Use `fromNodeHandler`
-- `createEvent`: Use `fromNodeRequest`
-- `toNodeListener`: Use `toNodeHandler`
-- `callNodeListener`: Use `callNodeHandler`
-- `promisifyNodeListener` (removed)
-- `callNodeHandler`: (internal)
+- `defineNodeListener`: Migrate to `defineNodeHandler`.
+- `fromNodeMiddleware`: Migrate to `fromNodeHandler`.
+- `toNodeListener`: Migrate to `toNodeHandler`.
+- `createEvent`: (removed): Use Node.js adapter (`toNodeHandler(app)`).
+- `fromNodeRequest`: (removed): Use Node.js adapter (`toNodeHandler(app)`).
+- `promisifyNodeListener` (removed).
+- `callNodeListener`: (removed).
 
 **Web:**
 
-- `callWithWebRequest`: (removed)
+- `fromPlainHandler`: (removed) Migrate to Web API.
+- `toPlainHandler`: (removed) Migrate to Web API.
+- `fromPlainRequest` (removed) Migrate to Web API or use `mockEvent` util for testing.
+- `callWithPlainRequest` (removed) Migrate to Web API.
+- `fromWebRequest`: (removed) Migrate to Web API.
+- `callWithWebRequest`: (removed).
 
 **Body:**
 
-- `readBody`: Use `readJSONBody`
-- `readFormData`: Use `readFormDataBody`
-- `readValidatedBody`: Use `readValidatedJSONBody`
-- `getRequestWebStream`: Use `getBodyStream`
-- `readMultipartFormData`: Migrate to `readFormDataBody`
+- `readBody`: Migrate to `readJSONBody`.
+- `readFormData`: Migrate to `readFormDataBody`.
+- `readValidatedBody`: Migrate to `readValidatedJSONBody`.
+- `getRequestWebStream`: Migrate to `getBodyStream`.
+- `readMultipartFormData`: Migrate to `readFormDataBody`.
 
-**Types:**
+- **Utils:**
 
-- `_RequestMiddleware`: Use `RequestMiddleware`
-- `_ResponseMiddleware`: Use `ResponseMiddleware`
-- `NodeListener`: Use `NodeHandler`
-- `TypedHeaders`: Use `RequestHeaders` and `ResponseHeaders`
-- `HTTPHeaderName`: Use `RequestHeaderName` and `ResponseHeaderName`
-- `H3Headers`: Use native `Headers`
-- `H3Response`: Use native `Response`
-- `WebEventContext`
-- `NodeEventContext`
-- `NodePromisifiedHandler`
-- `MultiPartData`: Use `FormData`
-- `RouteNode`: Use `RouterEntry`
-  `CreateRouterOptions`: use `RouterOptions`
+- `isStream`: Migrate to `instanceof ReadableStream` and `.pipe` properties for detecting Node.js `ReadableStream`.
+- `isWebResponse`: Migrate to `use instanceof Response`.
+- `MIMES`: (removed).
 
-- **Utils:**
+**Types:**
 
-- `isStream`: Use `instanceof ReadableStream` and `.pipe` properties for detecting Node.js `ReadableStream`
-- `isWebResponse`: Use `use instanceof Response`
-- `MIMES`: Removed internal map.
+- `App`: Migrate to `H3`.
+- `AppOptions`: Migrate to `H3Config`.
+- `_RequestMiddleware`: Migrate to `RequestMiddleware`.
+- `_ResponseMiddleware`: Migrate to `ResponseMiddleware`.
+- `NodeListener`: Migrate to `NodeHandler`.
+- `TypedHeaders`: Migrate to `RequestHeaders` and `ResponseHeaders`.
+- `HTTPHeaderName`: Migrate to `RequestHeaderName` and `ResponseHeaderName`.
+- `H3Headers`: Migrate to native `Headers`.
+- `H3Response`: Migrate to native `Response`.
+- `MultiPartData`: Migrate to native `FormData`.
+- `RouteNode`: Migrate to `RouterEntry`.
+  `CreateRouterOptions`: Migrate to `RouterOptions`.
+
+Removed type exports: `WebEventContext`, `NodeEventContext`, `NodePromisifiedHandler`, `AppUse`, `Stack`, `InputLayer`, `InputStack`, `Layer`, `Matcher`, `PlainHandler`, `PlainRequest`, `PlainReponse`, `WebHandler`
diff --git a/docs/2.utils/98.advanced.md b/docs/2.utils/98.advanced.md
index 57cb424..a355286 100644
--- a/docs/2.utils/98.advanced.md
+++ b/docs/2.utils/98.advanced.md
@@ -102,18 +102,17 @@ Allowed characters: horizontal tabs, spaces or visible ascii characters: https:/
 
 <!-- automd:jsdocs src="../../src/utils/base.ts" -->
 
-### `useBase(base, handler)`
+### `withBase(base, input)`
 
-Prefixes and executes a handler with a base path.
+Returns a new event handler that removes the base url of the event before calling the original handler.
 
 **Example:**
 
 ```ts
+const api = createApp()
+ .get("/", () => "Hello API!");
 const app = createApp();
-const router = createRouter();
-const apiRouter = createRouter().get("/hello", () => "Hello API!");
-router.use("/api/**", useBase("/api", apiRouter.handler));
-app.use(router.handler);
+ .use("/api/**", withBase("/api", api.handler));
 ```
 
 <!-- /automd -->
diff --git a/package.json b/package.json
index c867254..b2ec6c3 100644
--- a/package.json
+++ b/package.json
@@ -29,7 +29,7 @@
     "build": "unbuild",
     "dev": "vitest",
     "lint": "eslint --cache . && prettier -c src test examples docs",
-    "lint:fix": "eslint --cache . --fix && prettier -c src test examples docs -w",
+    "lint:fix": "automd && eslint --cache . --fix && prettier -c src test examples docs -w",
     "play:bun": "bun ./test/fixture/bun.ts",
     "play:node": "node --import jiti/register test/fixture/node.ts",
     "play:plain": "node --import jiti/register test/fixture/plain.ts",
@@ -42,7 +42,7 @@
     "cookie-es": "^1.1.0",
     "iron-webcrypto": "^1.2.1",
     "ohash": "^1.1.3",
-    "rou3": "^0.2.0",
+    "rou3": "^0.4.0",
     "ufo": "^1.5.3",
     "uncrypto": "^0.1.3"
   },
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 7792721..6381258 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -21,8 +21,8 @@ importers:
         specifier: ^1.1.3
         version: 1.1.3
       rou3:
-        specifier: ^0.2.0
-        version: 0.2.0
+        specifier: ^0.4.0
+        version: 0.4.0
       ufo:
         specifier: ^1.5.3
         version: 1.5.3
@@ -2728,8 +2728,8 @@ packages:
     engines: {node: '>=18.0.0', npm: '>=8.0.0'}
     hasBin: true
 
-  rou3@0.2.0:
-    resolution: {integrity: sha512-pM+Zd++BIMNcfZXOsErMc8ycuYFB6Y5dmanMfyIOXwiDhYx3EDDoLPKEn5oMSl/P9s4Yi0Y2+cqzrTCw83Q7iQ==}
+  rou3@0.4.0:
+    resolution: {integrity: sha512-ETiLqy6XJXJ+254BLwdBCbb4diS0gfiTxUuYym+/TuMVsdS/EOPs07vIkXSRFVnOraFDEKGtRLniJSx441qr2Q==}
 
   run-applescript@5.0.0:
     resolution: {integrity: sha512-XcT5rBksx1QdIhlFOCtgZkB99ZEouFZ1E2Kc2LHqNW13U3/74YGdkQRmThTwxy4QIyookibDKYZOPqX//6BlAg==}
@@ -5851,7 +5851,7 @@ snapshots:
       '@rollup/rollup-win32-x64-msvc': 4.18.0
       fsevents: 2.3.3
 
-  rou3@0.2.0: {}
+  rou3@0.4.0: {}
 
   run-applescript@5.0.0:
     dependencies:
diff --git a/src/deprecated.ts b/src/_deprecated.ts
similarity index 93%
rename from src/deprecated.ts
rename to src/_deprecated.ts
index f116817..5fbfe5a 100644
--- a/src/deprecated.ts
+++ b/src/_deprecated.ts
@@ -25,7 +25,6 @@ import {
 import {
   defineNodeHandler,
   fromNodeHandler,
-  fromNodeRequest,
   toNodeHandler,
 } from "./adapters/node";
 import {
@@ -35,6 +34,8 @@ import {
 } from "./utils/body";
 import { defineEventHandler, defineLazyEventHandler } from "./handler";
 import { proxy } from "./utils/proxy";
+import { createH3 } from "./h3";
+import { withBase } from "./utils/base";
 
 /** @deprecated Please use `getRequestHeader`  */
 export const getHeader = getRequestHeader;
@@ -74,9 +75,6 @@ export const defineNodeListener = defineNodeHandler;
 /** @deprecated Please use `defineNodeHandler` */
 export const fromNodeMiddleware = fromNodeHandler;
 
-/** @deprecated Please use `fromNodeRequest` */
-export const createEvent = fromNodeRequest;
-
 /** @deprecated Please use `toNodeHandler` */
 export const toNodeListener = toNodeHandler;
 
@@ -122,6 +120,15 @@ export function toEventHandler(
   return input;
 }
 
+/** @deprecated Use `createH3()` */
+export const createApp = createH3;
+
+/** @deprecated Use `createH3()` */
+export const createRouter = createH3;
+
+/** @deprecated Use `withBase()` */
+export const useBase = withBase;
+
 // --- Types ---
 
 /** @deprecated Please use `RequestMiddleware` */
diff --git a/src/adapters/node/event.ts b/src/adapters/node/_event.ts
similarity index 95%
rename from src/adapters/node/event.ts
rename to src/adapters/node/_event.ts
index 9910847..e3f6ea9 100644
--- a/src/adapters/node/event.ts
+++ b/src/adapters/node/_event.ts
@@ -2,12 +2,7 @@ import type { HTTPMethod } from "../../types";
 import type { RawEvent } from "../../types/event";
 import { splitCookiesString } from "../../utils/cookie";
 import { NodeHeadersProxy } from "./_headers";
-import {
-  _normalizeHeaders,
-  _readBody,
-  _getBodyStream,
-  _sendResponse,
-} from "./_internal";
+import { readNodeReqBody, getBodyStream } from "./_utils";
 
 import type { NodeIncomingMessage, NodeServerResponse } from "../../types/node";
 
@@ -85,7 +80,7 @@ export class NodeEvent implements RawEvent {
 
   readRawBody() {
     if (!this._rawBody) {
-      this._rawBody = _readBody(this._req);
+      this._rawBody = readNodeReqBody(this._req);
     }
     return this._rawBody;
   }
@@ -115,7 +110,7 @@ export class NodeEvent implements RawEvent {
 
   getBodyStream() {
     if (!this._bodyStream) {
-      this._bodyStream = _getBodyStream(this._req);
+      this._bodyStream = getBodyStream(this._req);
     }
     return this._bodyStream;
   }
diff --git a/src/adapters/node/_internal.ts b/src/adapters/node/_utils.ts
similarity index 94%
rename from src/adapters/node/_internal.ts
rename to src/adapters/node/_utils.ts
index 00b66bb..29d4b75 100644
--- a/src/adapters/node/_internal.ts
+++ b/src/adapters/node/_utils.ts
@@ -14,7 +14,7 @@ import {
   sanitizeStatusMessage,
 } from "../../utils/sanitize";
 
-export function _getBodyStream(
+export function getBodyStream(
   req: NodeIncomingMessage,
 ): ReadableStream<Uint8Array> {
   return new ReadableStream({
@@ -32,7 +32,7 @@ export function _getBodyStream(
   });
 }
 
-export function _sendResponse(
+export function sendNodeResponse(
   nodeRes: NodeServerResponse,
   handlerRes: ResponseBody,
 ): Promise<void> {
@@ -71,7 +71,7 @@ export function _sendResponse(
           },
         }),
       )
-      .then(() => _endResponse(nodeRes));
+      .then(() => endNodeResponse(nodeRes));
   }
 
   // Node.js Readable Streams
@@ -93,14 +93,14 @@ export function _sendResponse(
         // https://react.dev/reference/react-dom/server/renderToPipeableStream
         (handlerRes as any).abort?.();
       });
-    }).then(() => _endResponse(nodeRes));
+    }).then(() => endNodeResponse(nodeRes));
   }
 
   // Send as string or buffer
-  return _endResponse(nodeRes, handlerRes);
+  return endNodeResponse(nodeRes, handlerRes);
 }
 
-export function _endResponse(
+export function endNodeResponse(
   res: NodeServerResponse,
   chunk?: any,
 ): Promise<void> {
@@ -109,7 +109,7 @@ export function _endResponse(
   });
 }
 
-export function _normalizeHeaders(
+export function normalizeHeaders(
   headers: Record<string, string | null | undefined | number | string[]>,
 ): Record<string, string> {
   const normalized: Record<string, string> = Object.create(null);
@@ -123,7 +123,7 @@ export function _normalizeHeaders(
 
 const payloadMethods = ["PATCH", "POST", "PUT", "DELETE"] as string[];
 
-export function _readBody(
+export function readNodeReqBody(
   req: NodeIncomingMessage,
 ): undefined | Promise<Uint8Array | undefined> {
   // Check if request method requires a payload
diff --git a/src/adapters/node/index.ts b/src/adapters/node/index.ts
index aebc620..166244f 100644
--- a/src/adapters/node/index.ts
+++ b/src/adapters/node/index.ts
@@ -1,23 +1,96 @@
+import type {
+  H3,
+  EventHandler,
+  EventHandlerResponse,
+  H3Event,
+} from "../../types";
 import type { NodeHandler, NodeMiddleware } from "../../types/node";
+import { _kRaw } from "../../event";
+import { EventWrapper } from "../../event";
+import { NodeEvent } from "./_event";
+import { sendNodeResponse, callNodeHandler } from "./_utils";
+import { errorToH3Response, prepareResponse } from "../../response";
 
-export {
-  // Handler
-  fromNodeHandler,
-  toNodeHandler,
+/**
+ * Convert H3 app instance to a NodeHandler with (IncomingMessage, ServerResponse) => void signature.
+ */
+export function toNodeHandler(app: H3): NodeHandler {
+  const nodeHandler: NodeHandler = async function (nodeReq, nodeRes) {
+    const rawEvent = new NodeEvent(nodeReq, nodeRes);
+    const event = new EventWrapper(rawEvent);
 
-  // Request
-  fromNodeRequest,
+    const _res = await app.handler(event).catch((error: unknown) => error);
 
-  // Context
-  getNodeContext,
-} from "./utils";
+    if (nodeRes.headersSent || nodeRes.writableEnded) {
+      return;
+    }
 
-export type {
-  NodeHandler,
-  NodeMiddleware,
-  NodeIncomingMessage,
-  NodeServerResponse,
-} from "../../types/node";
+    const resBody = await prepareResponse(event, _res, app.config);
 
-export const defineNodeHandler = (handler: NodeHandler) => handler;
-export const defineNodeMiddleware = (handler: NodeMiddleware) => handler;
+    await sendNodeResponse(nodeRes, resBody).catch((sendError) => {
+      // Possible cases: Stream canceled, headers already sent, etc.
+      if (nodeRes.headersSent || nodeRes.writableEnded) {
+        return;
+      }
+      const errRes = errorToH3Response(sendError, app.config);
+      if (errRes.status) {
+        nodeRes.statusCode = errRes.status;
+      }
+      if (errRes.statusText) {
+        nodeRes.statusMessage = errRes.statusText;
+      }
+      nodeRes.end(errRes.body);
+    });
+    if (app.config.onAfterResponse) {
+      await app.config.onAfterResponse(event, { body: resBody });
+    }
+  };
+
+  return nodeHandler;
+}
+
+/**
+ * Convert a Node.js handler function (req, res, next?) to an EventHandler.
+ *
+ * **Note:** The returned event handler requires to be executed with h3 Node.js handler.
+ */
+export function fromNodeHandler(handler: NodeMiddleware): EventHandler;
+export function fromNodeHandler(handler: NodeHandler): EventHandler;
+export function fromNodeHandler(
+  handler: NodeHandler | NodeMiddleware,
+): EventHandler {
+  if (typeof handler !== "function") {
+    throw new TypeError(`Invalid handler. It should be a function: ${handler}`);
+  }
+  return (event) => {
+    const nodeCtx = getNodeContext(event);
+    if (!nodeCtx) {
+      throw new Error(
+        "[h3] Executing Node.js middleware is not supported in this server!",
+      );
+    }
+    return callNodeHandler(
+      handler,
+      nodeCtx.req,
+      nodeCtx.res,
+    ) as EventHandlerResponse;
+  };
+}
+
+export function getNodeContext(
+  event: H3Event,
+): undefined | ReturnType<NodeEvent["getContext"]> {
+  const raw = event[_kRaw] as NodeEvent;
+  if (!(raw?.constructor as any)?.isNode) {
+    return undefined;
+  }
+  return raw.getContext();
+}
+
+export function defineNodeHandler(handler: NodeHandler) {
+  return handler;
+}
+
+export function defineNodeMiddleware(handler: NodeMiddleware) {
+  return handler;
+}
diff --git a/src/adapters/node/utils.ts b/src/adapters/node/utils.ts
deleted file mode 100644
index a139b7e..0000000
--- a/src/adapters/node/utils.ts
+++ /dev/null
@@ -1,100 +0,0 @@
-import type {
-  App,
-  EventHandler,
-  EventHandlerResponse,
-  H3Event,
-} from "../../types";
-import type {
-  NodeHandler,
-  NodeIncomingMessage,
-  NodeMiddleware,
-  NodeServerResponse,
-} from "../../types/node";
-import { _kRaw } from "../../event";
-import { isEventHandler } from "../../handler";
-import { EventWrapper } from "../../event";
-import { NodeEvent } from "./event";
-import { _sendResponse, callNodeHandler } from "./_internal";
-import { errorToAppResponse } from "../../response";
-
-/**
- * Convert H3 app instance to a NodeHandler with (IncomingMessage, ServerResponse) => void signature.
- */
-export function toNodeHandler(app: App): NodeHandler {
-  const nodeHandler: NodeHandler = async function (req, res) {
-    const rawEvent = new NodeEvent(req, res);
-    const event = new EventWrapper(rawEvent);
-    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;
-      }
-      const errRes = errorToAppResponse(sendError, app.options);
-      if (errRes.status) {
-        res.statusCode = errRes.status;
-      }
-      if (errRes.statusText) {
-        res.statusMessage = errRes.statusText;
-      }
-      res.end(errRes.body);
-    });
-    if (app.options.onAfterResponse) {
-      await app.options.onAfterResponse(event, { body: appResponse });
-    }
-  };
-  return nodeHandler;
-}
-
-/**
- * Convert a Node.js handler function (req, res, next?) to an EventHandler.
- *
- * **Note:** The returned event handler requires to be executed with h3 Node.js handler.
- */
-export function fromNodeHandler(handler: NodeMiddleware): EventHandler;
-export function fromNodeHandler(handler: NodeHandler): EventHandler;
-export function fromNodeHandler(
-  handler: NodeHandler | NodeMiddleware,
-): EventHandler {
-  if (isEventHandler(handler)) {
-    return handler;
-  }
-  if (typeof handler !== "function") {
-    throw new TypeError(`Invalid handler. It should be a function: ${handler}`);
-  }
-  return (event) => {
-    const nodeCtx = getNodeContext(event);
-    if (!nodeCtx) {
-      throw new Error(
-        "[h3] Executing Node.js middleware is not supported in this server!",
-      );
-    }
-    return callNodeHandler(
-      handler,
-      nodeCtx.req,
-      nodeCtx.res,
-    ) as EventHandlerResponse;
-  };
-}
-
-/***
- * Create a H3Event object from a Node.js request and response.
- */
-export function fromNodeRequest(
-  req: NodeIncomingMessage,
-  res: NodeServerResponse,
-): H3Event {
-  const rawEvent = new NodeEvent(req, res);
-  const event = new EventWrapper(rawEvent);
-  return event;
-}
-
-export function getNodeContext(
-  event: H3Event,
-): undefined | ReturnType<NodeEvent["getContext"]> {
-  const raw = event[_kRaw] as NodeEvent;
-  if (!(raw?.constructor as any)?.isNode) {
-    return undefined;
-  }
-  return raw.getContext();
-}
diff --git a/src/adapters/web/_internal.ts b/src/adapters/web/_internal.ts
index aa388c5..42fe7da 100644
--- a/src/adapters/web/_internal.ts
+++ b/src/adapters/web/_internal.ts
@@ -1,8 +1,6 @@
 import type { Readable as NodeReadableStream } from "node:stream";
-import type { App, H3EventContext, ResponseBody } from "../../types";
-import { EventWrapper, _kRaw } from "../../event";
-import { WebEvent } from "./event";
-
+import type { ResponseBody } from "../../types";
+import { _kRaw } from "../../event";
 type WebNormalizedResponseBody = Exclude<ResponseBody, NodeReadableStream>;
 
 export function _normalizeResponse(
@@ -35,34 +33,3 @@ export function _pathToRequestURL(path: string, headers?: HeadersInit): string {
   const protocol = h.get("x-forwarded-proto") === "https" ? "https" : "http";
   return `${protocol}://${host}${path}`;
 }
-
-export const nullBodyResponses = new Set([101, 204, 205, 304]);
-
-export async function appFetch(
-  app: App,
-  request: Request,
-  context?: H3EventContext,
-): Promise<{
-  body: WebNormalizedResponseBody;
-  status: Response["status"];
-  statusText: Response["statusText"];
-  headers: Headers;
-}> {
-  const rawEvent = new WebEvent(request);
-  const event = new EventWrapper(rawEvent, context);
-
-  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(_appResponseBody);
-
-  return {
-    status: rawEvent.responseCode,
-    statusText: rawEvent.responseMessage,
-    headers: rawEvent._responseHeaders,
-    body: responseBody,
-  };
-}
diff --git a/src/adapters/web/index.ts b/src/adapters/web/index.ts
index 9c12356..8b4d208 100644
--- a/src/adapters/web/index.ts
+++ b/src/adapters/web/index.ts
@@ -1,26 +1,49 @@
-export {
-  // --Web--
+import type { H3, EventHandler, H3Event, H3EventContext } from "../../types";
+import { _kRaw } from "../../event";
+import { WebEvent } from "./event";
+import { _normalizeResponse, _pathToRequestURL } from "./_internal";
 
-  // Web Handler
-  fromWebHandler,
-  toWebHandler,
+type WebHandler = (
+  request: Request,
+  context?: H3EventContext,
+) => Promise<Response>;
 
-  // Web Request
-  fromWebRequest,
-  toWebRequest,
+export function toWebHandler(app: H3): WebHandler {
+  return (request, context) => {
+    return app.fetch(request, { h3: context });
+  };
+}
 
-  // Web Context
-  getWebContext,
+export function fromWebHandler(handler: WebHandler): EventHandler {
+  return (event) => handler(toWebRequest(event), event.context);
+}
 
-  // --Plain--
+/**
+ * Convert an H3Event object to a web Request object.
+ *
+ */
+export function toWebRequest(event: H3Event): Request {
+  return (
+    (event[_kRaw] as WebEvent)._req ||
+    new Request(
+      _pathToRequestURL(event[_kRaw].path, event[_kRaw].getHeaders()),
+      {
+        // @ts-ignore Undici option
+        duplex: "half",
+        method: event[_kRaw].method,
+        headers: event[_kRaw].getHeaders(),
+        body: event[_kRaw].getBodyStream(),
+      },
+    )
+  );
+}
 
-  // Plain Handler
-  fromPlainHandler,
-  toPlainHandler,
-
-  // Plain Request
-  fromPlainRequest,
-
-  // Call
-  callWithPlainRequest,
-} from "./utils";
+export function getWebContext(
+  event: H3Event,
+): undefined | ReturnType<WebEvent["getContext"]> {
+  const raw = event[_kRaw] as WebEvent;
+  if (!(raw?.constructor as any)?.isWeb) {
+    return undefined;
+  }
+  return raw.getContext();
+}
diff --git a/src/adapters/web/utils.ts b/src/adapters/web/utils.ts
deleted file mode 100644
index 3f8c376..0000000
--- a/src/adapters/web/utils.ts
+++ /dev/null
@@ -1,173 +0,0 @@
-import type { App, EventHandler, H3Event, H3EventContext } from "../../types";
-import type {
-  WebHandler,
-  PlainHandler,
-  PlainRequest,
-  PlainResponse,
-} from "../../types/web";
-import { EventWrapper, _kRaw } from "../../event";
-import { WebEvent } from "./event";
-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) => {
-    const res = await appFetch(app, request, context);
-    return new Response(res.body, res);
-  };
-  return webHandler;
-}
-
-export function fromWebHandler(handler: WebHandler): EventHandler {
-  return (event) => handler(toWebRequest(event), event.context);
-}
-
-/**
- * Convert an H3Event object to a web Request object.
- *
- */
-export function toWebRequest(event: H3Event): Request {
-  return (
-    (event[_kRaw] as WebEvent)._req ||
-    new Request(
-      _pathToRequestURL(event[_kRaw].path, event[_kRaw].getHeaders()),
-      {
-        // @ts-ignore Undici option
-        duplex: "half",
-        method: event[_kRaw].method,
-        headers: event[_kRaw].getHeaders(),
-        body: event[_kRaw].getBodyStream(),
-      },
-    )
-  );
-}
-
-/**
- * Create an H3Event object from a web Request object.
- */
-export function fromWebRequest(
-  request: Request,
-  context?: H3EventContext,
-): H3Event {
-  const rawEvent = new WebEvent(request);
-  const event = new EventWrapper(rawEvent);
-  if (context) {
-    Object.assign(event.context, context);
-  }
-  return event;
-}
-
-export function getWebContext(
-  event: H3Event,
-): undefined | ReturnType<WebEvent["getContext"]> {
-  const raw = event[_kRaw] as WebEvent;
-  if (!(raw?.constructor as any)?.isWeb) {
-    return undefined;
-  }
-  return raw.getContext();
-}
-
-// ----------------------------
-// Plain
-// ----------------------------
-
-/**
- * Convert H3 app instance to a PlainHandler with (PlainRequest, H3EventContext) => Promise<PlainResponse> signature.
- */
-export function toPlainHandler(app: App) {
-  const handler: PlainHandler = async (request, context) => {
-    return callWithPlainRequest(app, request, context);
-  };
-  return handler;
-}
-
-/**
- * Convert a PlainHandler to an EventHandler.
- */
-export function fromPlainHandler(handler: PlainHandler): EventHandler {
-  return async (event) => {
-    const res = await handler(
-      {
-        get method() {
-          return event.method;
-        },
-        get path() {
-          return event.path;
-        },
-        get headers() {
-          return event[_kRaw].getHeaders();
-        },
-        get body() {
-          return event[_kRaw].getBodyStream();
-        },
-      },
-      event.context,
-    );
-    event[_kRaw].responseCode = res.status;
-    event[_kRaw].responseMessage = res.statusText;
-
-    const hasSetCookie = res.setCookie?.length > 0;
-    for (const [key, value] of Object.entries(res.headers)) {
-      if (key === "set-cookie" && hasSetCookie) {
-        continue;
-      }
-      event[_kRaw].setResponseHeader(key, value);
-    }
-    if (res.setCookie?.length > 0) {
-      for (const cookie of res.setCookie) {
-        event[_kRaw].appendResponseHeader("set-cookie", cookie);
-      }
-    }
-
-    return res.body;
-  };
-}
-
-/**
- * Create an H3Event object from a plain request object.
- */
-export function fromPlainRequest(
-  request: PlainRequest,
-  context?: H3EventContext,
-): H3Event {
-  return fromWebRequest(
-    new Request(_pathToRequestURL(request.path, request.headers), {
-      method: request.method,
-      headers: request.headers,
-      body: request.body,
-    }),
-    context,
-  );
-}
-
-export async function callWithPlainRequest(
-  app: App,
-  request: PlainRequest,
-  context?: H3EventContext,
-): Promise<PlainResponse> {
-  const res = await appFetch(
-    app,
-    new Request(_pathToRequestURL(request.path, request.headers), {
-      method: request.method,
-      headers: request.headers,
-      body: request.body,
-    }),
-    context,
-  );
-
-  const setCookie = res.headers.getSetCookie();
-  const headersObject = Object.fromEntries(res.headers.entries());
-  if (setCookie.length > 0) {
-    headersObject["set-cookie"] = setCookie.join(", ");
-  }
-
-  return {
-    status: res.status,
-    statusText: res.statusText,
-    headers: headersObject,
-    setCookie: setCookie,
-    body: res.body,
-  };
-}
diff --git a/src/app.ts b/src/app.ts
deleted file mode 100644
index 363b7a9..0000000
--- a/src/app.ts
+++ /dev/null
@@ -1,180 +0,0 @@
-import type {
-  App,
-  Stack,
-  AppOptions,
-  EventHandler,
-  InputLayer,
-  HTTPMethod,
-  H3Event,
-  Layer,
-} from "./types";
-import { _kRaw } from "./event";
-import {
-  getPathname,
-  joinURL,
-  withoutTrailingSlash,
-} from "./utils/internal/path";
-import { prepareResponse } from "./response";
-import { createError } from "./error";
-import { ResolvedEventHandler } from "./types/handler";
-
-/**
- * Create a new h3 app instance.
- */
-export function createApp(options: AppOptions = {}): App {
-  const app = new H3App(options);
-  return app;
-}
-
-class H3App implements App {
-  stack: Stack = [];
-  options: AppOptions;
-
-  constructor(options: AppOptions) {
-    this.options = options;
-    this.handler = this.handler.bind(this);
-    (this.handler as EventHandler).__resolve__ = this.resolve.bind(this);
-  }
-
-  get websocket() {
-    return {
-      ...this.options.websocket,
-      resolve: async (info: { url: string; method?: string }) => {
-        const pathname = getPathname(info.url || "/");
-        const method = (info.method || "GET") as HTTPMethod;
-        const resolved = await this.resolve(method, pathname);
-        return resolved?.handler?.__websocket__ || {};
-      },
-    };
-  }
-
-  async handler(event: H3Event) {
-    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 (this.options.onRequest) {
-        await this.options.onRequest(event);
-      }
-
-      // Run through stack
-      for (const layer of this.stack) {
-        // 1. Remove prefix from path
-        if (layer.prefix.length > 1) {
-          if (!_reqPath.startsWith(layer.prefix)) {
-            continue;
-          }
-          _layerPath = _reqPath.slice(layer.prefix.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 prepareResponse(event, _body, this.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 prepareResponse(event, error, this.options);
-    }
-  }
-
-  async resolve(
-    method: HTTPMethod,
-    path: string,
-  ): Promise<Partial<ResolvedEventHandler> | undefined> {
-    let _layerPath: string;
-    for (const layer of this.stack) {
-      if (layer.prefix === "/" && !layer.handler.__resolve__) {
-        continue;
-      }
-      if (!path.startsWith(layer.prefix)) {
-        continue;
-      }
-      _layerPath = path.slice(layer.prefix.length) || "/";
-      if (layer.match && !layer.match(_layerPath, undefined)) {
-        continue;
-      }
-      if (!layer.handler.__resolve__) {
-        return {
-          prefix: layer.prefix,
-          handler: layer.handler,
-          method,
-        };
-      }
-      const _resolved = await layer.handler.__resolve__(method, _layerPath);
-      if (!_resolved) {
-        continue;
-      }
-      const prefix = joinURL(layer.prefix || "/", _resolved.prefix || "/");
-      return {
-        ..._resolved,
-        method: _resolved.method || method,
-        prefix,
-        handler: _resolved.handler || layer.handler,
-      };
-    }
-  }
-
-  use(route: string, handler: EventHandler, options?: Partial<InputLayer>): App;
-  use(handler: EventHandler, options?: Partial<InputLayer>): App;
-  use(options: InputLayer): App;
-  use(
-    arg1: string | EventHandler | InputLayer,
-    arg2?: EventHandler | EventHandler[] | Partial<InputLayer>,
-    arg3?: Partial<InputLayer>,
-  ) {
-    const arg1T = typeof arg1;
-    if (arg1T === "string") {
-      this.stack.push(
-        _normalizeLayer({
-          ...arg3,
-          prefix: arg1 as string,
-          handler: arg2 as EventHandler,
-        }),
-      );
-    } else if (arg1T === "function") {
-      this.stack.push(
-        _normalizeLayer({ ...arg2, handler: arg1 as EventHandler }),
-      );
-    } else {
-      this.stack.push(_normalizeLayer(arg1 as InputLayer));
-    }
-    return this;
-  }
-}
-
-function _normalizeLayer(input: InputLayer): Layer {
-  let handler = input.handler;
-  if (handler.handler) {
-    handler = handler.handler;
-  }
-
-  return {
-    prefix: withoutTrailingSlash(input.prefix),
-    match: input.match,
-    handler,
-  } as Layer;
-}
diff --git a/src/event.ts b/src/event.ts
index 3e8bb83..d24efbc 100644
--- a/src/event.ts
+++ b/src/event.ts
@@ -1,7 +1,8 @@
+import { WebEvent } from "./adapters/web/event";
 import type { H3EventContext, H3Event } from "./types";
 import { RawEvent } from "./types/event";
 
-export const _kRaw: unique symbol = Symbol.for("h3.internal.raw");
+export const _kRaw: unique symbol = Symbol.for("h3.rawEvent");
 
 export class EventWrapper implements H3Event {
   static "__is_event__" = true;
@@ -51,3 +52,23 @@ export function isEvent(input: any): input is H3Event {
     input.__is_event__ /* Backward compatibility with h3 v1 */
   );
 }
+
+export function mockEvent(
+  _request: string | URL | Request,
+  options?: RequestInit & { h3?: H3EventContext },
+) {
+  let request: Request;
+  if (typeof _request === "string") {
+    let url = _request;
+    if (url[0] === "/") {
+      url = `http://localhost${url}`;
+    }
+    request = new Request(url, options);
+  } else if (options || _request instanceof URL) {
+    request = new Request(_request, options);
+  } else {
+    request = _request;
+  }
+  const webEvent = new WebEvent(request);
+  return new EventWrapper(webEvent, options?.h3);
+}
diff --git a/src/h3.ts b/src/h3.ts
new file mode 100644
index 0000000..04cc840
--- /dev/null
+++ b/src/h3.ts
@@ -0,0 +1,285 @@
+import type {
+  H3,
+  H3Config,
+  EventHandler,
+  HTTPMethod,
+  H3Event,
+  H3EventContext,
+  EventHandlerRequest,
+} from "./types";
+import type { H3Route } from "./types/h3";
+import {
+  createRouter,
+  addRoute,
+  findAllRoutes,
+  RouterContext,
+  findRoute,
+} from "rou3";
+import { _kRaw, EventWrapper } from "./event";
+import { getPathname, joinURL } from "./utils/internal/path";
+import { ResolvedEventHandler } from "./types/handler";
+import { WebEvent } from "./adapters/web/event";
+import { _kNotFound, prepareResponse } from "./response";
+import { _normalizeResponse } from "./adapters/web/_internal";
+
+/**
+ * Create a new h3 instance.
+ */
+export function createH3(config: H3Config = {}): H3 {
+  return new _H3(config);
+}
+
+class _H3 implements H3 {
+  config: H3Config;
+
+  _middleware?: H3Route[];
+  _mRouter?: RouterContext<H3Route>;
+  _router?: RouterContext<H3Route>;
+
+  handler: EventHandler<EventHandlerRequest, Promise<unknown>>;
+
+  constructor(config: H3Config) {
+    this.config = config;
+
+    this.fetch = this.fetch.bind(this);
+
+    this.handler = Object.assign((event: H3Event) => this._handler(event), <
+      Partial<EventHandler>
+    >{
+      resolve: (method, path) => this.resolve(method, path),
+      websocket: this.config.websocket,
+    });
+  }
+
+  get websocket() {
+    return {
+      ...this.config.websocket,
+      resolve: async (info: { url: string; method?: string }) => {
+        const pathname = getPathname(info.url || "/");
+        const method = (info.method || "GET") as HTTPMethod;
+        const resolved = await this.resolve(method, pathname);
+        return resolved?.handler?.websocket?.hooks || {};
+      },
+    };
+  }
+
+  async fetch(
+    _request: Request | URL | string,
+    options?: RequestInit & { h3?: H3EventContext },
+  ): Promise<Response> {
+    // Normalize request
+    let request: Request;
+    if (typeof _request === "string") {
+      let url = _request;
+      if (url[0] === "/") {
+        url = `http://localhost${url}`;
+      }
+      request = new Request(url, options);
+    } else if (options || _request instanceof URL) {
+      request = new Request(_request, options);
+    } else {
+      request = _request;
+    }
+
+    // Create event context
+    const rawEvent = new WebEvent(request);
+    const event = new EventWrapper(rawEvent, options?.h3);
+
+    // Handle request
+    const _res = await this._handler(event)
+      .catch((error: any) => error)
+      .then((res) => prepareResponse(event, res, this.config));
+
+    // Create response
+    const status = rawEvent.responseCode;
+    // prettier-ignore
+    // https://developer.mozilla.org/en-US/docs/Web/API/Response/body
+    const isNullBody = status === 101 || status === 204 || status === 205 || status === 304 || request.method === "HEAD";
+    return new Response(isNullBody ? null : _normalizeResponse(_res), {
+      status,
+      statusText: rawEvent.responseMessage,
+      headers: rawEvent.getResponseHeaders(),
+    });
+  }
+
+  async _handler(event: H3Event) {
+    const _path = event.path;
+    const _queryIndex = _path.indexOf("?");
+    const pathname = _queryIndex === -1 ? _path : _path.slice(0, _queryIndex);
+
+    // 1. Hooks
+    if (this.config.onRequest) {
+      await this.config.onRequest(event);
+    }
+
+    // 2. Global middleware
+    const _middleware = this._middleware;
+    if (_middleware) {
+      for (const entry of _middleware) {
+        const result = await entry.handler(event);
+        if (result !== undefined && result !== _kNotFound) {
+          return result;
+        }
+      }
+    }
+
+    // 3. Middleware router
+    const _mRouter = this._mRouter;
+    if (_mRouter) {
+      const matches = findAllRoutes(_mRouter, event.method, pathname);
+      for (const match of matches) {
+        const result = await match.data.handler(event);
+        if (result !== undefined && result !== _kNotFound) {
+          return result;
+        }
+      }
+    }
+
+    // 4. Route handler
+    if (this._router) {
+      const match = findRoute(this._router, event.method, pathname)?.[0];
+      if (match) {
+        event.context.params = match.params;
+        event.context.matchedRoute = match.data;
+        return match.data.handler(event);
+      }
+    }
+
+    // 5. 404
+    return _kNotFound;
+  }
+
+  async resolve(
+    method: HTTPMethod,
+    path: string,
+  ): Promise<ResolvedEventHandler | undefined> {
+    const match =
+      (this._mRouter && findRoute(this._mRouter, method, path)?.pop()) ||
+      (this._router && findRoute(this._router, method, path)?.pop());
+
+    if (!match) {
+      return undefined;
+    }
+
+    const resolved = {
+      route: match.data.route,
+      handler: match.data.handler,
+      params: match.params,
+    };
+
+    while (resolved.handler?.resolve) {
+      const _resolved = await resolved.handler.resolve(method, path);
+      if (!_resolved) {
+        break;
+      }
+      if (_resolved.route) {
+        let base = resolved.route || "";
+        if (base.endsWith("/**")) {
+          base = base.slice(0, -3);
+        }
+        resolved.route = joinURL(base, _resolved.route);
+      }
+      if (_resolved.params) {
+        resolved.params = { ...resolved.params, ..._resolved.params };
+      }
+      if (!_resolved.handler || _resolved.handler === resolved.handler) {
+        break;
+      }
+      resolved.handler = _resolved.handler;
+    }
+
+    return resolved;
+  }
+
+  all(route: string, handler: EventHandler | H3) {
+    return this.on("", route, handler);
+  }
+  get(route: string, handler: EventHandler | H3) {
+    return this.on("GET", route, handler);
+  }
+  post(route: string, handler: EventHandler | H3) {
+    return this.on("POST", route, handler);
+  }
+  put(route: string, handler: EventHandler | H3) {
+    return this.on("PUT", route, handler);
+  }
+  delete(route: string, handler: EventHandler | H3) {
+    return this.on("DELETE", route, handler);
+  }
+  patch(route: string, handler: EventHandler | H3) {
+    return this.on("PATCH", route, handler);
+  }
+  head(route: string, handler: EventHandler | H3) {
+    return this.on("HEAD", route, handler);
+  }
+  options(route: string, handler: EventHandler | H3) {
+    return this.on("OPTIONS", route, handler);
+  }
+  connect(route: string, handler: EventHandler | H3) {
+    return this.on("CONNECT", route, handler);
+  }
+  trace(route: string, handler: EventHandler | H3) {
+    return this.on("TRACE", route, handler);
+  }
+  on(
+    method: HTTPMethod | Lowercase<HTTPMethod> | "",
+    route: string,
+    handler: EventHandler | H3,
+  ): this {
+    if (!this._router) {
+      this._router = createRouter();
+    }
+    const _method = (method || "").toUpperCase();
+    const _handler = (handler as H3)?.handler || handler;
+    addRoute(this._router, _method, route, <H3Route>{
+      method: _method,
+      route,
+      handler: _handler,
+    });
+    return this;
+  }
+
+  use(
+    arg1: string | EventHandler | H3 | H3Route,
+    arg2?: EventHandler | H3 | Partial<H3Route>,
+    arg3?: Partial<H3Route>,
+  ) {
+    const arg1T = typeof arg1;
+    const entry = {} as H3Route;
+    let _handler: EventHandler | H3;
+    if (arg1T === "string") {
+      // (route, handler, details)
+      entry.route = (arg1 as string) || arg3?.route;
+      entry.method = arg3?.method as HTTPMethod;
+      _handler = (arg2 as EventHandler | H3) || arg3?.handler;
+    } else if (arg1T === "function") {
+      // (handler, details)
+      entry.route = (arg2 as H3Route)?.route;
+      entry.method = (arg2 as H3Route)?.method;
+      _handler = (arg1 as EventHandler | H3) || (arg2 as H3Route)?.handler;
+    } else {
+      // (details)
+      entry.route = (arg1 as H3Route).route;
+      entry.method = (arg1 as H3Route).method;
+      _handler = (arg1 as H3Route).handler;
+    }
+
+    entry.handler = (_handler as H3)?.handler || _handler;
+    entry.method = (entry.method || "").toUpperCase() as HTTPMethod;
+
+    if (entry.route) {
+      // Routed middleware/handler
+      if (!this._mRouter) {
+        this._mRouter = createRouter();
+      }
+      addRoute(this._mRouter, entry.method, entry.route, entry);
+    } else {
+      // Global middleware
+      if (!this._middleware) {
+        this._middleware = [];
+      }
+      this._middleware.push(entry);
+    }
+    return this;
+  }
+}
diff --git a/src/handler.ts b/src/handler.ts
index 01da32b..e3b73f0 100644
--- a/src/handler.ts
+++ b/src/handler.ts
@@ -11,7 +11,6 @@ import type {
   EventHandlerObject,
 } from "./types";
 import { _kRaw } from "./event";
-import { hasProp } from "./utils/internal/object";
 
 type _EventHandlerHooks<
   Request extends EventHandlerRequest = EventHandlerRequest,
@@ -53,7 +52,6 @@ export function defineEventHandler<
 ): EventHandler<Request, Response> {
   // Function Syntax
   if (typeof handler === "function") {
-    handler.__is_handler__ = true;
     return handler;
   }
   // Object Syntax
@@ -61,12 +59,11 @@ export function defineEventHandler<
     onRequest: _normalizeArray(handler.onRequest),
     onBeforeResponse: _normalizeArray(handler.onBeforeResponse),
   };
-  const _handler: EventHandler<Request, any> = (event: H3Event) => {
+  const _handler: EventHandler<Request, any> = (event) => {
     return _callHandler(event, handler.handler, _hooks);
   };
-  _handler.__is_handler__ = true;
-  _handler.__resolve__ = handler.handler.__resolve__;
-  _handler.__websocket__ = handler.websocket;
+  _handler.resolve = handler.handler.resolve;
+  _handler.websocket = { hooks: handler.websocket };
   return _handler as EventHandler<Request, Response>;
 }
 
@@ -109,15 +106,6 @@ export function defineResponseMiddleware<
   return fn;
 }
 
-/**
- * Checks if any kind of input is an event handler.
- * @param input The input to check.
- * @returns True if the input is an event handler, false otherwise.
- */
-export function isEventHandler(input: any): input is EventHandler {
-  return hasProp(input, "__is_handler__");
-}
-
 export function dynamicEventHandler(
   initial?: EventHandler,
 ): DynamicEventHandler {
@@ -166,10 +154,10 @@ export function defineLazyEventHandler(
     return resolveHandler().then((r) => r.handler(event));
   };
 
-  handler.__resolve__ = (method, path) =>
+  handler.resolve = (method, path) =>
     Promise.resolve(
-      resolveHandler().then((r) =>
-        r.handler.__resolve__ ? r.handler.__resolve__(method, path) : r,
+      resolveHandler().then(({ handler }) =>
+        handler.resolve ? handler.resolve(method, path) : { handler },
       ),
     );
 
diff --git a/src/index.ts b/src/index.ts
index 4094a4c..e8e4bee 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,15 +1,14 @@
 // Types
 export * from "./types";
 
-// App
-export { createApp } from "./app";
+// H3
+export { createH3 } from "./h3";
 
 // Event
-export { isEvent } from "./event";
+export { isEvent, mockEvent } from "./event";
 
 // Handler
 export {
-  isEventHandler,
   defineEventHandler,
   defineLazyEventHandler,
   dynamicEventHandler,
@@ -20,9 +19,6 @@ export {
 // Error
 export { createError, isError } from "./error";
 
-// Router
-export { createRouter } from "./router";
-
 // ---- Adapters  ----
 
 // Node
@@ -30,7 +26,6 @@ export {
   getNodeContext,
   fromNodeHandler,
   toNodeHandler,
-  fromNodeRequest,
   defineNodeHandler,
   defineNodeMiddleware,
 } from "./adapters/node";
@@ -40,12 +35,7 @@ export {
   getWebContext,
   fromWebHandler,
   toWebHandler,
-  fromWebRequest,
   toWebRequest,
-  fromPlainHandler,
-  toPlainHandler,
-  fromPlainRequest,
-  callWithPlainRequest,
 } from "./adapters/web";
 
 // ------ Utils ------
@@ -85,6 +75,7 @@ export {
   getResponseStatusText,
   redirect,
   iterable,
+  noContent,
 } from "./utils/response";
 
 // Proxy
@@ -127,7 +118,7 @@ export { handleCacheHeaders } from "./utils/cache";
 export { serveStatic } from "./utils/static";
 
 // Base
-export { useBase } from "./utils/base";
+export { withBase } from "./utils/base";
 
 // Session
 export {
@@ -156,4 +147,4 @@ export { defineWebSocketHandler, defineWebSocket } from "./utils/ws";
 
 // ---- Deprecated ----
 
-export * from "./deprecated";
+export * from "./_deprecated";
diff --git a/src/response.ts b/src/response.ts
index 658a28c..2e5f881 100644
--- a/src/response.ts
+++ b/src/response.ts
@@ -1,30 +1,32 @@
-import type { AppOptions, H3Event, ResponseBody } from "./types";
-import type { AppResponse, H3Error } from "./types/app";
+import type { H3Config, H3Event, ResponseBody } from "./types";
+import type { H3Response, H3Error } from "./types/h3";
 import { createError } from "./error";
 import { isJSONSerializable } from "./utils/internal/object";
 import { MIMES } from "./utils/internal/consts";
 import { _kRaw } from "./event";
 
+export const _kNotFound = Symbol.for("h3.notFound");
+
 export async function prepareResponse(
   event: H3Event,
   body: unknown,
-  options: AppOptions,
+  config: H3Config,
 ) {
-  const res = await _normalizeResponseBody(body, options);
+  const res = await normalizeResponseBody(event, body, config);
   if (res.error) {
     if (res.error.unhandled) {
       console.error("[h3] Unhandled Error:", res.error);
     }
-    if (options.onError) {
+    if (config.onError) {
       try {
-        await options.onError(res.error, event);
+        await config.onError(res.error, event);
       } catch (hookError) {
         console.error("[h3] Error while calling `onError` hook:", hookError);
       }
     }
   }
-  if (options.onBeforeResponse) {
-    await options.onBeforeResponse(event, res);
+  if (config.onBeforeResponse) {
+    await config.onBeforeResponse(event, res);
   }
   if (res.contentType && !event[_kRaw].getResponseHeader("content-type")) {
     event[_kRaw].setResponseHeader("content-type", res.contentType);
@@ -43,13 +45,25 @@ export async function prepareResponse(
   return res.body;
 }
 
-function _normalizeResponseBody(
+function normalizeResponseBody(
+  event: H3Event,
   val: unknown,
-  options: AppOptions,
-): AppResponse | Promise<AppResponse> {
+  config: H3Config,
+): H3Response | Promise<H3Response> {
   // Empty Content
   if (val === null || val === undefined) {
-    return { body: "", status: 204 };
+    return { body: "" };
+  }
+
+  // Not found
+  if (val === _kNotFound) {
+    return errorToH3Response(
+      {
+        statusCode: 404,
+        statusMessage: `Cannot find any route matching [${event.method}] ${event.path}`,
+      },
+      config,
+    );
   }
 
   const valType = typeof val;
@@ -66,13 +80,13 @@ function _normalizeResponseBody(
 
   // Error (should be before JSON)
   if (val instanceof Error) {
-    return errorToAppResponse(val, options);
+    return errorToH3Response(val, config);
   }
 
   // JSON
   if (isJSONSerializable(val, valType)) {
     return {
-      body: JSON.stringify(val, undefined, options.debug ? 2 : undefined),
+      body: JSON.stringify(val, undefined, config.debug ? 2 : undefined),
       contentType: MIMES.json,
     };
   }
@@ -104,12 +118,12 @@ function _normalizeResponseBody(
 
   // Symbol or Function is not supported
   if (valType === "symbol" || valType === "function") {
-    return errorToAppResponse(
+    return errorToH3Response(
       {
         statusCode: 500,
         statusMessage: `[h3] Cannot send ${valType} as response.`,
       },
-      options,
+      config,
     );
   }
 
@@ -118,10 +132,10 @@ function _normalizeResponseBody(
   };
 }
 
-export function errorToAppResponse(
+export function errorToH3Response(
   _error: Partial<H3Error> | Error,
-  options: AppOptions,
-): AppResponse {
+  config: H3Config,
+): H3Response {
   const error = createError(_error as H3Error);
   return {
     error,
@@ -133,7 +147,7 @@ export function errorToAppResponse(
       statusMessage: error.statusMessage,
       data: error.data,
       stack:
-        options.debug && error.stack
+        config.debug && error.stack
           ? error.stack.split("\n").map((l) => l.trim())
           : undefined,
     }),
diff --git a/src/router.ts b/src/router.ts
deleted file mode 100644
index 9d62e39..0000000
--- a/src/router.ts
+++ /dev/null
@@ -1,152 +0,0 @@
-import type {
-  RouterOptions,
-  EventHandler,
-  HTTPMethod,
-  RouterEntry,
-  Router,
-  H3Event,
-} from "./types";
-import {
-  type RouterContext,
-  createRouter as _createRouter,
-  findRoute,
-  addRoute,
-} from "rou3";
-import { createError } from "./error";
-
-/**
- * Create a new h3 router instance.
- */
-export function createRouter(opts: RouterOptions = {}): Router {
-  return new H3Router(opts);
-}
-
-class H3Router implements Router {
-  _router: RouterContext<RouterEntry>;
-  _options: RouterOptions;
-  constructor(opts: RouterOptions = {}) {
-    this._router = _createRouter();
-    this._options = opts;
-    this.handler = this.handler.bind(this);
-    (this.handler as EventHandler).__resolve__ = this._resolveRoute.bind(this);
-  }
-
-  all(path: string, handler: EventHandler) {
-    return this.add("", path, handler);
-  }
-
-  use(path: string, handler: EventHandler) {
-    return this.all(path, handler);
-  }
-
-  get(path: string, handler: EventHandler) {
-    return this.add("GET", path, handler);
-  }
-
-  post(path: string, handler: EventHandler) {
-    return this.add("POST", path, handler);
-  }
-
-  put(path: string, handler: EventHandler) {
-    return this.add("PUT", path, handler);
-  }
-
-  delete(path: string, handler: EventHandler) {
-    return this.add("DELETE", path, handler);
-  }
-
-  patch(path: string, handler: EventHandler) {
-    return this.add("PATCH", path, handler);
-  }
-
-  head(path: string, handler: EventHandler) {
-    return this.add("HEAD", path, handler);
-  }
-
-  options(path: string, handler: EventHandler) {
-    return this.add("OPTIONS", path, handler);
-  }
-
-  connect(path: string, handler: EventHandler) {
-    return this.add("CONNECT", path, handler);
-  }
-
-  trace(path: string, handler: EventHandler) {
-    return this.add("TRACE", path, handler);
-  }
-
-  add(
-    method: HTTPMethod | Lowercase<HTTPMethod> | "",
-    path: string,
-    handler: EventHandler,
-  ): this {
-    const _method = (method || "").toUpperCase();
-    addRoute(this._router, _method, path, <RouterEntry>{
-      method: _method,
-      route: path,
-      handler,
-    });
-    return this;
-  }
-
-  handler(event: H3Event) {
-    // Match handler
-    const match = this._findRoute(
-      event.method.toUpperCase() as HTTPMethod,
-      event.path,
-    );
-
-    // No match (method or route)
-    if (!match) {
-      if (this._options.preemptive) {
-        throw createError({
-          statusCode: 404,
-          name: "Not Found",
-          statusMessage: `Cannot find any route matching [${event.method}] ${event.path || "/"}`,
-        });
-      } else {
-        return; // Let app match other handlers
-      }
-    }
-
-    // Add matched route and params to the context
-    event.context.matchedRoute = match.data;
-    event.context.params = match.params || Object.create(null);
-
-    // Call handler
-    return Promise.resolve(match.data.handler(event)).then((res) => {
-      if (res === undefined && this._options.preemptive) {
-        return null; // Send empty content
-      }
-      return res;
-    });
-  }
-
-  _findRoute(method: HTTPMethod = "GET", path = "/") {
-    // Remove query parameters for matching
-    const qIndex = path.indexOf("?");
-    if (qIndex !== -1) {
-      path = path.slice(0, Math.max(0, qIndex));
-    }
-    return findRoute(this._router, method, path) as
-      | { data: RouterEntry; params?: Record<string, string> }
-      | undefined;
-  }
-
-  async _resolveRoute(method: HTTPMethod = "GET", path: string) {
-    const match = this._findRoute(method, path);
-    if (!match) {
-      return;
-    }
-    const resolved = {
-      route: match.data.route,
-      handler: match.data.handler,
-      params: match.params,
-    };
-    if (resolved.handler.__resolve__) {
-      const _resolved = await resolved.handler.__resolve__(method, path);
-      return { ...resolved, ..._resolved };
-    }
-    return resolved;
-  }
-}
diff --git a/src/types/app.ts b/src/types/app.ts
deleted file mode 100644
index 35f8cb9..0000000
--- a/src/types/app.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-import type { AdapterOptions as CrossWSAdapterOptions } from "crossws";
-import type { H3Event } from "./event";
-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 {
-  prefix: string;
-  match?: Matcher;
-  handler: EventHandler;
-}
-
-export type Stack = Layer[];
-
-export interface InputLayer {
-  prefix?: string;
-  match?: Matcher;
-  handler: EventHandler & { handler?: EventHandler };
-}
-
-export type InputStack = InputLayer[];
-
-export type Matcher = (path: string, event?: H3Event) => boolean;
-
-export interface AppResponse {
-  error?: H3Error;
-  body: ResponseBody;
-  contentType?: string;
-  headers?: Headers;
-  status?: number;
-  statusText?: string;
-}
-
-export interface AppUse {
-  (prefix: string, handler: EventHandler, options?: Partial<InputLayer>): App;
-  (handler: EventHandler, options?: Partial<InputLayer>): App;
-  (options: InputLayer): App;
-}
-
-export type WebSocketOptions = CrossWSAdapterOptions;
-
-export interface AppOptions {
-  debug?: boolean;
-  onError?: (error: H3Error, event: H3Event) => MaybePromise<void | unknown>;
-  onRequest?: (event: H3Event) => MaybePromise<void>;
-  onBeforeResponse?: (
-    event: H3Event,
-    response: AppResponse,
-  ) => MaybePromise<void>;
-  onAfterResponse?: (
-    event: H3Event,
-    response?: AppResponse,
-  ) => MaybePromise<void>;
-  websocket?: WebSocketOptions;
-}
-
-export interface App {
-  stack: Stack;
-  handler: EventHandler<EventHandlerRequest, Promise<ResponseBody>>;
-  options: AppOptions;
-  use: AppUse;
-  resolve: EventHandlerResolver;
-  readonly websocket: WebSocketOptions;
-}
diff --git a/src/types/context.ts b/src/types/context.ts
index 3c9d5d1..6717166 100644
--- a/src/types/context.ts
+++ b/src/types/context.ts
@@ -1,17 +1,20 @@
+import type { H3Route } from "./h3";
 import type { Session } from "./utils/session";
-import type { RouterEntry } from "./router";
 
 export interface H3EventContext extends Record<string, any> {
   /* Matched router parameters */
   params?: Record<string, string>;
+
   /**
    * Matched router Node
    *
    * @experimental The object structure may change in non-major version.
    */
-  matchedRoute?: RouterEntry;
+  matchedRoute?: H3Route;
+
   /* Cached session data */
   sessions?: Record<string, Session>;
+
   /* Trusted IP Address of client */
   clientAddress?: string;
 }
diff --git a/src/types/h3.ts b/src/types/h3.ts
new file mode 100644
index 0000000..523f73f
--- /dev/null
+++ b/src/types/h3.ts
@@ -0,0 +1,102 @@
+import type * as crossws from "crossws";
+import type { H3Event } from "./event";
+import type {
+  EventHandler,
+  EventHandlerRequest,
+  ResolvedEventHandler,
+  ResponseBody,
+} from "./handler";
+import type { H3Error } from "../error";
+import type { HTTPMethod } from "./http";
+import { H3EventContext } from "./context";
+
+export type { H3Error } from "../error";
+
+type MaybePromise<T> = T | Promise<T>;
+
+export interface H3Response {
+  error?: H3Error;
+  body: ResponseBody;
+  contentType?: string;
+  headers?: Headers;
+  status?: number;
+  statusText?: string;
+}
+
+export interface H3Config {
+  debug?: boolean;
+  websocket?: WebSocketOptions;
+
+  onError?: (error: H3Error, event: H3Event) => MaybePromise<void | unknown>;
+  onRequest?: (event: H3Event) => MaybePromise<void>;
+  onBeforeResponse?: (
+    event: H3Event,
+    response: H3Response,
+  ) => MaybePromise<void>;
+  onAfterResponse?: (
+    event: H3Event,
+    response?: H3Response,
+  ) => MaybePromise<void>;
+}
+
+export interface WebSocketOptions {
+  resolve?: crossws.ResolveHooks;
+  hooks?: Partial<crossws.Hooks>;
+  adapterHooks?: Partial<crossws.AdapterHooks>;
+}
+
+export interface H3Route {
+  route?: string;
+  method?: HTTPMethod;
+  handler: EventHandler;
+}
+
+type AddRoute = (route: string, handler: EventHandler | H3) => H3;
+
+export interface H3 {
+  readonly config: H3Config;
+
+  /** websocket options */
+  websocket: WebSocketOptions;
+
+  /** fetch request */
+  fetch(
+    request: Request | URL | string,
+    options?: RequestInit & { h3?: H3EventContext },
+  ): Promise<Response>;
+
+  /** main event handler */
+  handler: EventHandler<EventHandlerRequest, Promise<unknown>>;
+  _handler: EventHandler<EventHandlerRequest, Promise<unknown>>;
+
+  /** resolve event handler */
+  resolve: (
+    method: HTTPMethod,
+    path: string,
+  ) => Promise<ResolvedEventHandler | undefined>;
+
+  /** add middleware */
+  use(
+    route: string,
+    handler: EventHandler | H3,
+    details?: Partial<H3Route>,
+  ): H3;
+  use(handler: EventHandler | H3, details?: Partial<H3Route>): H3;
+  use(details: H3Route): H3;
+
+  on: (
+    method: "" | HTTPMethod | Lowercase<HTTPMethod>,
+    path: string,
+    handler: EventHandler | H3,
+  ) => H3;
+  all: AddRoute;
+  get: AddRoute;
+  post: AddRoute;
+  put: AddRoute;
+  delete: AddRoute;
+  patch: AddRoute;
+  head: AddRoute;
+  options: AddRoute;
+  connect: AddRoute;
+  trace: AddRoute;
+}
diff --git a/src/types/handler.ts b/src/types/handler.ts
index d232a4f..0189752 100644
--- a/src/types/handler.ts
+++ b/src/types/handler.ts
@@ -3,6 +3,7 @@ import type { QueryObject } from "ufo";
 import type { H3Event } from "./event";
 import type { Hooks as WSHooks } from "crossws";
 import type { HTTPMethod } from "./http";
+import type { H3 } from "./h3";
 
 export type ResponseBody =
   | undefined // middleware pass
@@ -29,7 +30,6 @@ type MaybePromise<T> = T | Promise<T>;
 export type ResolvedEventHandler = {
   method?: HTTPMethod;
   route?: string;
-  prefix?: string;
   handler?: EventHandler;
   params?: Record<string, string>;
 };
@@ -42,10 +42,7 @@ export type EventHandlerResolver = (
 export interface EventHandler<
   Request extends EventHandlerRequest = EventHandlerRequest,
   Response extends EventHandlerResponse = EventHandlerResponse,
-> {
-  __is_handler__?: true;
-  __resolve__?: EventHandlerResolver;
-  __websocket__?: Partial<WSHooks>;
+> extends Partial<Pick<H3, "handler" | "resolve" | "config" | "websocket">> {
   (event: H3Event<Request>): Response;
 }
 
diff --git a/src/types/index.ts b/src/types/index.ts
index f6fbc42..c2d1f31 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -1,16 +1,5 @@
-// App
-export type {
-  App,
-  AppOptions,
-  AppUse,
-  Stack,
-  InputLayer,
-  InputStack,
-  Layer,
-  WebSocketOptions,
-  Matcher,
-  H3Error,
-} from "./app";
+// H3
+export type { H3, H3Config, WebSocketOptions, H3Error } from "./h3";
 
 // Event
 export type { H3Event } from "./event";
@@ -30,21 +19,10 @@ export type {
   ResponseMiddleware,
 } from "./handler";
 
-// Web
-export type {
-  PlainHandler,
-  PlainRequest,
-  PlainResponse,
-  WebHandler,
-} from "./web";
-
-// Router
-export type { Router, RouterOptions, RouterEntry } from "./router";
-
 // Context
 export type { H3EventContext } from "./context";
 
-// SSE
+// EventStream
 export type { EventStreamMessage, EventStreamOptions } from "./utils/sse";
 export type { EventStream } from "../utils/internal/event-stream";
 
diff --git a/src/types/node.ts b/src/types/node.ts
index 0ec4836..6288792 100644
--- a/src/types/node.ts
+++ b/src/types/node.ts
@@ -19,4 +19,4 @@ export type NodeMiddleware = (
   next: (error?: Error) => void,
 ) => unknown | Promise<unknown>;
 
-export type { NodeEvent } from "../adapters/node/event";
+export type { NodeEvent } from "../adapters/node/_event";
diff --git a/src/types/router.ts b/src/types/router.ts
deleted file mode 100644
index f61dbad..0000000
--- a/src/types/router.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import type { EventHandler } from "./handler";
-import type { HTTPMethod } from "./http";
-
-type AddRoute = (path: string, handler: EventHandler) => Router;
-
-export interface Router {
-  all: AddRoute;
-  /** @deprecated please use router.all */
-  use: Router["all"];
-  get: AddRoute;
-  post: AddRoute;
-  put: AddRoute;
-  delete: AddRoute;
-  patch: AddRoute;
-  head: AddRoute;
-  options: AddRoute;
-  connect: AddRoute;
-  trace: AddRoute;
-  add: (
-    method: "" | HTTPMethod | Lowercase<HTTPMethod>,
-    path: string,
-    handler: EventHandler,
-  ) => Router;
-
-  handler: EventHandler;
-}
-
-export interface RouterEntry {
-  method: HTTPMethod;
-  route: string;
-  handler: EventHandler;
-}
-
-export interface RouterOptions {
-  preemptive?: boolean;
-}
diff --git a/src/types/web.ts b/src/types/web.ts
deleted file mode 100644
index 6b012ea..0000000
--- a/src/types/web.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import type { H3EventContext } from "./context";
-
-export type WebHandler = (
-  request: Request,
-  context?: H3EventContext,
-) => Promise<Response>;
-
-export type PlainHandler = (
-  request: PlainRequest,
-  context?: H3EventContext,
-) => Promise<PlainResponse>;
-
-export interface PlainRequest {
-  path: string;
-  method: string;
-  headers: HeadersInit;
-  body?: BodyInit;
-}
-
-export interface PlainResponse {
-  status: number;
-  statusText: string | undefined;
-  headers: Record<string, string>;
-  setCookie: string[];
-  body?: unknown;
-}
diff --git a/src/utils/base.ts b/src/utils/base.ts
index 8c477a7..c037c7c 100644
--- a/src/utils/base.ts
+++ b/src/utils/base.ts
@@ -1,37 +1,38 @@
-import type { EventHandler } from "../types";
 import { _kRaw } from "../event";
-import { withoutTrailingSlash, withoutBase } from "./internal/path";
+import { H3, EventHandler } from "../types";
+import { withoutBase, withoutTrailingSlash } from "./internal/path";
 
 /**
- * Prefixes and executes a handler with a base path.
+ * Returns a new event handler that removes the base url of the event before calling the original handler.
  *
  * @example
+ * const api = createApp()
+ *  .get("/", () => "Hello API!");
  * const app = createApp();
- * const router = createRouter();
+ *  .use("/api/**", withBase("/api", api.handler));
  *
- * const apiRouter = createRouter().get("/hello", () => "Hello API!");
- *
- * router.use("/api/**", useBase("/api", apiRouter.handler));
- *
- * app.use(router.handler);
- *
- * @param base The base path to prefix. When set to an empty string, the handler will be run as is.
+ * @param base The base path to prefix.
  * @param handler The event handler to use with the adapted path.
  */
-export function useBase(base: string, handler: EventHandler): EventHandler {
+export function withBase(base: string, input: EventHandler | H3): EventHandler {
   base = withoutTrailingSlash(base);
 
-  if (!base || base === "/") {
-    return handler;
-  }
+  const _originalHandler = (input as H3)?.handler || (input as EventHandler);
 
-  return async (event) => {
+  const _handler: EventHandler = async (event) => {
     const _pathBefore = event[_kRaw].path || "/";
     event[_kRaw].path = withoutBase(event.path || "/", base);
-    try {
-      return await handler(event);
-    } finally {
+    return Promise.resolve(_originalHandler(event)).finally(() => {
       event[_kRaw].path = _pathBefore;
-    }
+    });
   };
+
+  _handler.websocket = _originalHandler.websocket;
+  _handler.resolve = _originalHandler.resolve
+    ? (method, path) => {
+        return _originalHandler.resolve!(method, withoutBase(path, base));
+      }
+    : undefined;
+
+  return _handler;
 }
diff --git a/src/utils/cookie.ts b/src/utils/cookie.ts
index 8edc637..4717dae 100644
--- a/src/utils/cookie.ts
+++ b/src/utils/cookie.ts
@@ -63,14 +63,6 @@ export function setCookie(
       parseCookie(cookie),
     );
     if (_key === newCookieKey) {
-      console.log(
-        "Overwriting cookie:",
-        setCookie,
-        "to",
-        newCookie,
-        "key",
-        _key,
-      );
       continue;
     }
     event[_kRaw].appendResponseHeader("set-cookie", cookie);


Test patch:
diff --git a/test/_setup.ts b/test/_setup.ts
index f549e9f..6c6d930 100644
--- a/test/_setup.ts
+++ b/test/_setup.ts
@@ -1,27 +1,18 @@
 import type { Mock } from "vitest";
-import type { App, AppOptions, H3Error, H3Event } from "../src/types";
-import type { PlainHandler, WebHandler } from "../src/types";
+import type { H3, H3Config, H3Error, H3Event } from "../src/types";
 import { beforeEach, afterEach, vi } from "vitest";
 import supertest from "supertest";
 import { Server as NodeServer } from "node:http";
 import { Client as UndiciClient } from "undici";
 import { getRandomPort } from "get-port-please";
-import {
-  createApp,
-  NodeHandler,
-  toNodeHandler,
-  toPlainHandler,
-  toWebHandler,
-} from "../src";
+import { createApp, NodeHandler, toNodeHandler } from "../src";
 
 interface TestContext {
   request: ReturnType<typeof supertest>;
 
-  webHandler: WebHandler;
   nodeHandler: NodeHandler;
-  plainHandler: PlainHandler;
 
-  app: App;
+  app: H3;
 
   server?: NodeServer;
   client?: UndiciClient;
@@ -29,10 +20,10 @@ interface TestContext {
 
   errors: H3Error[];
 
-  onRequest: Mock<Exclude<AppOptions["onRequest"], undefined>>;
-  onError: Mock<Exclude<AppOptions["onError"], undefined>>;
-  onBeforeResponse: Mock<Exclude<AppOptions["onBeforeResponse"], undefined>>;
-  onAfterResponse: Mock<Exclude<AppOptions["onAfterResponse"], undefined>>;
+  onRequest: Mock<Exclude<H3Config["onRequest"], undefined>>;
+  onError: Mock<Exclude<H3Config["onError"], undefined>>;
+  onBeforeResponse: Mock<Exclude<H3Config["onBeforeResponse"], undefined>>;
+  onAfterResponse: Mock<Exclude<H3Config["onAfterResponse"], undefined>>;
 }
 
 export function setupTest(
@@ -57,9 +48,7 @@ export function setupTest(
       onAfterResponse: ctx.onAfterResponse,
     });
 
-    ctx.webHandler = toWebHandler(ctx.app);
     ctx.nodeHandler = toNodeHandler(ctx.app);
-    ctx.plainHandler = toPlainHandler(ctx.app);
 
     ctx.request = supertest(ctx.nodeHandler);
 
diff --git a/test/app.test.ts b/test/app.test.ts
index cc1fa60..9388094 100644
--- a/test/app.test.ts
+++ b/test/app.test.ts
@@ -11,7 +11,7 @@ describe("app", () => {
     ctx.app.use("/api", (event) => ({ url: event.path }));
     const res = await ctx.request.get("/api");
 
-    expect(res.body).toEqual({ url: "/" });
+    expect(res.body).toEqual({ url: "/api" });
   });
 
   it("can return bigint directly", async () => {
@@ -56,11 +56,11 @@ describe("app", () => {
     expect(res.text).toBe("Hello World!");
   });
 
-  it("can return a 204 response", async () => {
+  it("can return a null response", async () => {
     ctx.app.use("/api", () => null);
     const res = await ctx.request.get("/api");
 
-    expect(res.statusCode).toBe(204);
+    expect(res.statusCode).toBe(200);
     expect(res.text).toEqual("");
     expect(res.ok).toBeTruthy();
   });
@@ -222,7 +222,7 @@ describe("app", () => {
   });
 
   it("can take an object", async () => {
-    ctx.app.use({ prefix: "/", handler: () => "valid" });
+    ctx.app.use({ route: "/", handler: () => "valid" });
 
     const response = await ctx.request.get("/");
     expect(response.text).toEqual("valid");
@@ -236,18 +236,6 @@ describe("app", () => {
     expect(response.text).toEqual("done");
   });
 
-  it("can use a custom matcher", async () => {
-    ctx.app.use("/odd", () => "Is odd!", {
-      match: (url) => Boolean(Number(url.slice(1)) % 2),
-    });
-
-    const res = await ctx.request.get("/odd/41");
-    expect(res.text).toBe("Is odd!");
-
-    const notFound = await ctx.request.get("/odd/2");
-    expect(notFound.status).toBe(404);
-  });
-
   it("can normalise route definitions", async () => {
     ctx.app.use("/test/", () => "valid");
 
diff --git a/test/bench/spec.ts b/test/bench/spec.ts
index 0ac2d05..51939a4 100644
--- a/test/bench/spec.ts
+++ b/test/bench/spec.ts
@@ -1,11 +1,4 @@
-import {
-  createRouter,
-  createApp,
-  readJSONBody,
-  toWebHandler,
-  getQuery,
-  setResponseHeader,
-} from "../../src";
+import { createH3, readJSONBody, getQuery, setResponseHeader } from "../../src";
 
 // https://github.com/pi0/web-framework-benchmarks
 // https://github.com/SaltyAom/bun-http-framework-benchmark
@@ -46,23 +39,22 @@ export function createBenchApps() {
 }
 
 export function createH3App() {
-  const router = createRouter();
-  const app = createApp().use(router);
+  const app = createH3();
 
   // [GET] /
-  router.get("/", () => "Hi");
+  app.get("/", () => "Hi");
 
   // [GET] /id/:id
-  router.get("/id/:id", (event) => {
+  app.get("/id/:id", (event) => {
     const query = getQuery(event);
     setResponseHeader(event, "x-powered-by", "benchmark");
     return `${event.context.params!.id} ${query.name}`;
   });
 
   // [POST] /json
-  router.post("/json", (event) => readJSONBody(event));
+  app.post("/json", (event) => readJSONBody(event));
 
-  return toWebHandler(app);
+  return app.fetch;
 }
 
 export function createBaselineApp() {
diff --git a/test/body.test.ts b/test/body.test.ts
index 67b34ee..cd24982 100644
--- a/test/body.test.ts
+++ b/test/body.test.ts
@@ -8,7 +8,7 @@ describe("body", () => {
   const ctx = setupTest({ startServer: true });
 
   it("can read simple string", async () => {
-    ctx.app.use("/", async (request) => {
+    ctx.app.use("/api/test", async (request) => {
       const body = await readTextBody(request);
       expect(body).toEqual('{"bool":true,"name":"string","number":1}');
       return "200";
@@ -28,7 +28,7 @@ describe("body", () => {
 
   it("can read chunked string", async () => {
     const requestJsonUrl = new URL("assets/sample.json", import.meta.url);
-    ctx.app.use("/", async (request) => {
+    ctx.app.use("/api/test", async (request) => {
       const body = await readTextBody(request);
       const json = (await readFile(requestJsonUrl)).toString("utf8");
 
@@ -46,7 +46,7 @@ describe("body", () => {
 
   it("returns undefined if body is not present", async () => {
     let _body: string | undefined = "initial";
-    ctx.app.use("/", async (request) => {
+    ctx.app.use("/api/test", async (request) => {
       _body = await readTextBody(request);
       return "200";
     });
@@ -61,7 +61,7 @@ describe("body", () => {
 
   it("returns an empty string if body is string", async () => {
     let _body: string | undefined = "initial";
-    ctx.app.use("/", async (request) => {
+    ctx.app.use("/api/test", async (request) => {
       _body = await readJSONBody(request);
       return "200";
     });
@@ -77,7 +77,7 @@ describe("body", () => {
 
   it("returns an empty object string if body is empty object", async () => {
     let _body: string | undefined = "initial";
-    ctx.app.use("/", async (request) => {
+    ctx.app.use("/api/test", async (request) => {
       _body = await readTextBody(request);
       return "200";
     });
@@ -92,7 +92,7 @@ describe("body", () => {
   });
 
   it("can parse json payload", async () => {
-    ctx.app.use("/", async (request) => {
+    ctx.app.use("/api/test", async (request) => {
       const body = await readJSONBody(request);
       expect(body).toMatchObject({
         bool: true,
@@ -116,7 +116,7 @@ describe("body", () => {
 
   it("handles non-present body", async () => {
     let _body: string | undefined;
-    ctx.app.use("/", async (request) => {
+    ctx.app.use("/api/test", async (request) => {
       _body = await readJSONBody(request);
       return "200";
     });
@@ -130,7 +130,7 @@ describe("body", () => {
 
   it("handles empty body", async () => {
     let _body: string | undefined = "initial";
-    ctx.app.use("/", async (request) => {
+    ctx.app.use("/api/test", async (request) => {
       _body = await readTextBody(request);
       return "200";
     });
@@ -148,7 +148,7 @@ describe("body", () => {
 
   it("handles empty object as body", async () => {
     let _body: string | undefined = "initial";
-    ctx.app.use("/", async (request) => {
+    ctx.app.use("/api/test", async (request) => {
       _body = await readJSONBody(request);
       return "200";
     });
@@ -162,7 +162,7 @@ describe("body", () => {
   });
 
   it("parse the form encoded into an object", async () => {
-    ctx.app.use("/", async (request) => {
+    ctx.app.use("/api/test", async (request) => {
       const body = await readJSONBody(request);
       expect(body).toMatchObject({
         field: "value",
@@ -184,7 +184,7 @@ describe("body", () => {
   });
 
   it("parses multipart form data", async () => {
-    ctx.app.use("/", async (request) => {
+    ctx.app.use("/api/test", async (request) => {
       const formData = await readFormDataBody(request);
       return [...formData!.entries()].map(([name, value]) => ({
         name,
@@ -222,7 +222,7 @@ describe("body", () => {
 
   it("returns undefined if body is not present with text/plain", async () => {
     let _body: string | undefined;
-    ctx.app.use("/", async (request) => {
+    ctx.app.use("/api/test", async (request) => {
       _body = await readTextBody(request);
       return "200";
     });
@@ -240,7 +240,7 @@ describe("body", () => {
 
   it("returns undefined if body is not present with json", async () => {
     let _body: string | undefined;
-    ctx.app.use("/", async (request) => {
+    ctx.app.use("/api/test", async (request) => {
       _body = await readTextBody(request);
       return "200";
     });
@@ -258,7 +258,7 @@ describe("body", () => {
 
   it("returns the string if content type is text/*", async () => {
     let _body: string | undefined;
-    ctx.app.use("/", async (request) => {
+    ctx.app.use("/api/test", async (request) => {
       _body = await readTextBody(request);
       return "200";
     });
@@ -276,7 +276,7 @@ describe("body", () => {
   });
 
   it("returns string as is if cannot parse with unknown content type", async () => {
-    ctx.app.use("/", async (request) => {
+    ctx.app.use("/api/test", async (request) => {
       const _body = await readTextBody(request);
       return _body;
     });
@@ -294,7 +294,7 @@ describe("body", () => {
   });
 
   it("fails if json is invalid", async () => {
-    ctx.app.use("/", async (request) => {
+    ctx.app.use("/api/test", async (request) => {
       const _body = await readJSONBody(request);
       return _body;
     });
diff --git a/test/cors.test.ts b/test/cors.test.ts
index d5a4eda..6e0c8cf 100644
--- a/test/cors.test.ts
+++ b/test/cors.test.ts
@@ -1,7 +1,6 @@
 import type { H3CorsOptions } from "../src/types";
 import { expect, it, describe } from "vitest";
-import { fromPlainRequest } from "../src/adapters/web";
-import { isPreflightRequest, isCorsOriginAllowed } from "../src";
+import { mockEvent, isPreflightRequest, isCorsOriginAllowed } from "../src";
 import {
   resolveCorsOptions,
   createOriginHeaders,
@@ -53,8 +52,7 @@ describe("resolveCorsOptions", () => {
 
 describe("isPreflightRequest", () => {
   it("can detect preflight request", () => {
-    const eventMock = fromPlainRequest({
-      path: "/",
+    const eventMock = mockEvent("/", {
       method: "OPTIONS",
       headers: {
         origin: "https://example.com",
@@ -66,8 +64,7 @@ describe("isPreflightRequest", () => {
   });
 
   it("can detect request of non-OPTIONS method)", () => {
-    const eventMock = fromPlainRequest({
-      path: "/",
+    const eventMock = mockEvent("/", {
       method: "GET",
       headers: {
         origin: "https://example.com",
@@ -79,8 +76,7 @@ describe("isPreflightRequest", () => {
   });
 
   it("can detect request without origin header", () => {
-    const eventMock = fromPlainRequest({
-      path: "/",
+    const eventMock = mockEvent("/", {
       method: "OPTIONS",
       headers: {
         "access-control-request-method": "GET",
@@ -91,8 +87,7 @@ describe("isPreflightRequest", () => {
   });
 
   it("can detect request without AccessControlRequestMethod header", () => {
-    const eventMock = fromPlainRequest({
-      path: "/",
+    const eventMock = mockEvent("/", {
       method: "OPTIONS",
       headers: {
         origin: "https://example.com",
@@ -178,8 +173,7 @@ describe("isCorsOriginAllowed", () => {
 
 describe("createOriginHeaders", () => {
   it('returns an object whose `access-control-allow-origin` is `"*"` if `origin` option is not defined, or `"*"`', () => {
-    const eventMock = fromPlainRequest({
-      path: "/",
+    const eventMock = mockEvent("/", {
       method: "OPTIONS",
       headers: {
         origin: "https://example.com",
@@ -199,8 +193,7 @@ describe("createOriginHeaders", () => {
   });
 
   it('returns an object whose `access-control-allow-origin` is `"*"` if `origin` header is not defined', () => {
-    const eventMock = fromPlainRequest({
-      path: "/",
+    const eventMock = mockEvent("/", {
       method: "OPTIONS",
       headers: {},
     });
@@ -212,8 +205,7 @@ describe("createOriginHeaders", () => {
   });
 
   it('returns an object with `access-control-allow-origin` and `vary` keys if `origin` option is `"null"`', () => {
-    const eventMock = fromPlainRequest({
-      path: "/",
+    const eventMock = mockEvent("/", {
       method: "OPTIONS",
       headers: {
         origin: "https://example.com",
@@ -230,8 +222,7 @@ describe("createOriginHeaders", () => {
   });
 
   it("returns an object with `access-control-allow-origin` and `vary` keys if `origin` option and `origin` header matches", () => {
-    const eventMock = fromPlainRequest({
-      path: "/",
+    const eventMock = mockEvent("/", {
       method: "OPTIONS",
       headers: {
         origin: "http://example.com",
@@ -255,8 +246,7 @@ describe("createOriginHeaders", () => {
   });
 
   it("returns an empty object if `origin` option is one that is not allowed", () => {
-    const eventMock = fromPlainRequest({
-      path: "/",
+    const eventMock = mockEvent("/", {
       method: "OPTIONS",
       headers: {
         origin: "https://example.com",
@@ -326,8 +316,7 @@ describe("createCredentialsHeaders", () => {
 
 describe("createAllowHeaderHeaders", () => {
   it('returns an object with `access-control-allow-headers` and `vary` keys according to `access-control-request-headers` header if `allowHeaders` option is not defined, `"*"`, or an empty array', () => {
-    const eventMock = fromPlainRequest({
-      path: "/",
+    const eventMock = mockEvent("/", {
       method: "OPTIONS",
       headers: {
         "access-control-request-headers": "CUSTOM-HEADER",
@@ -356,8 +345,7 @@ describe("createAllowHeaderHeaders", () => {
   });
 
   it("returns an object with `access-control-allow-headers` and `vary` keys according to `allowHeaders` option if `access-control-request-headers` header is not defined", () => {
-    const eventMock = fromPlainRequest({
-      path: "/",
+    const eventMock = mockEvent("/", {
       method: "OPTIONS",
       headers: {},
     });
@@ -372,8 +360,7 @@ describe("createAllowHeaderHeaders", () => {
   });
 
   it('returns an empty object if `allowHeaders` option is not defined, `"*"`, or an empty array, and `access-control-request-headers` is not defined', () => {
-    const eventMock = fromPlainRequest({
-      path: "/",
+    const eventMock = mockEvent("/", {
       method: "OPTIONS",
       headers: {},
     });
diff --git a/test/error.test.ts b/test/error.test.ts
index 8b1cfaa..4fc5e8c 100644
--- a/test/error.test.ts
+++ b/test/error.test.ts
@@ -26,7 +26,7 @@ describe("error", () => {
   });
 
   it("can send internal error", async () => {
-    ctx.app.use("/", () => {
+    ctx.app.use("/api/test", () => {
       throw new Error("Booo");
     });
     const result = await ctx.request.get("/api/test");
@@ -40,7 +40,7 @@ describe("error", () => {
   it("can send runtime error", async () => {
     consoleMock.mockReset();
 
-    ctx.app.use("/", () => {
+    ctx.app.use("/api/test", () => {
       throw createError({
         statusCode: 400,
         statusMessage: "Bad Request",
diff --git a/test/event.test.ts b/test/event.test.ts
index 7bcb515..d92a61e 100644
--- a/test/event.test.ts
+++ b/test/event.test.ts
@@ -6,7 +6,7 @@ describe("Event", () => {
   const ctx = setupTest();
 
   it("can read the method", async () => {
-    ctx.app.use("/", (event) => {
+    ctx.app.use("/*", (event) => {
       expect(event.method).toBe(event.method);
       expect(event.method).toBe("POST");
       return "200";
@@ -16,7 +16,7 @@ describe("Event", () => {
   });
 
   it("can read the headers", async () => {
-    ctx.app.use("/", (event) => {
+    ctx.app.use("/*", (event) => {
       return {
         headers: [...event.headers.entries()],
       };
@@ -33,7 +33,7 @@ describe("Event", () => {
   });
 
   it("can get request url", async () => {
-    ctx.app.use("/", (event) => {
+    ctx.app.use("/*", (event) => {
       return getRequestURL(event);
     });
     const result = await ctx.request.get("/hello");
@@ -41,7 +41,7 @@ describe("Event", () => {
   });
 
   it("can read request body", async () => {
-    ctx.app.use("/", async (event) => {
+    ctx.app.use("/*", async (event) => {
       const bodyStream = getBodyStream(event);
       let bytes = 0;
       // @ts-expect-error iterator
@@ -64,10 +64,7 @@ describe("Event", () => {
     ctx.app.use("/", async (event) => {
       expect(event.method).toBe("POST");
       expect(event.headers.get("x-test")).toBe("123");
-      // TODO: Find a workaround for Node.js 16
-      if (!process.versions.node.startsWith("16")) {
-        expect(await readJSONBody(event)).toMatchObject({ hello: "world" });
-      }
+      expect(await readJSONBody(event)).toMatchObject({ hello: "world" });
       return "200";
     });
     const result = await ctx.request
diff --git a/test/fixture/plain.ts b/test/fixture/plain.ts
deleted file mode 100644
index 8f640bc..0000000
--- a/test/fixture/plain.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import { toPlainHandler } from "../../src";
-import { app } from "./app";
-
-const plainHandler = toPlainHandler(app);
-
-const res = await plainHandler({
-  path: "/",
-  method: "GET",
-  headers: {
-    foo: "bar",
-  },
-  body: undefined,
-});
-
-console.log(res);
diff --git a/test/integrations.test.ts b/test/integrations.test.ts
index ed0fb32..8ab33c9 100644
--- a/test/integrations.test.ts
+++ b/test/integrations.test.ts
@@ -45,7 +45,7 @@ describe("integration with express", () => {
   it("can be used as express middleware", async () => {
     const expressApp = express();
     ctx.app.use(
-      "/api/hello",
+      "/api/*",
       fromNodeHandler((_req, res, next) => {
         (res as any).prop = "42";
         next();
@@ -64,7 +64,7 @@ describe("integration with express", () => {
 
     const res = await ctx.request.get("/api/hello");
 
-    expect(res.body).toEqual({ url: "/", prop: "42" });
+    expect(res.body).toEqual({ url: "/api/hello", prop: "42" });
   });
 
   it("can wrap a connect instance", async () => {
@@ -73,7 +73,7 @@ describe("integration with express", () => {
       res.setHeader("content-type", "application/json");
       res.end(JSON.stringify({ connect: "works" }));
     });
-    ctx.app.use("/", fromNodeHandler(connectApp));
+    ctx.app.use("/**", fromNodeHandler(connectApp));
     const res = await ctx.request.get("/api/connect");
 
     expect(res.body).toEqual({ connect: "works" });
@@ -99,6 +99,6 @@ describe("integration with express", () => {
 
     const res = await ctx.request.get("/api/hello");
 
-    expect(res.body).toEqual({ url: "/", prop: "42" });
+    expect(res.body).toEqual({ url: "/api/hello", prop: "42" });
   });
 });
diff --git a/test/package.test.ts b/test/package.test.ts
index 46cb0a1..7636fe0 100644
--- a/test/package.test.ts
+++ b/test/package.test.ts
@@ -12,13 +12,12 @@ describe("h3 package", () => {
         "appendResponseHeader",
         "appendResponseHeaders",
         "assertMethod",
-        "callWithPlainRequest",
         "clearResponseHeaders",
         "clearSession",
         "createApp",
         "createError",
-        "createEvent",
         "createEventStream",
+        "createH3",
         "createRouter",
         "defaultContentType",
         "defineEventHandler",
@@ -36,11 +35,7 @@ describe("h3 package", () => {
         "fetchWithEvent",
         "fromNodeHandler",
         "fromNodeMiddleware",
-        "fromNodeRequest",
-        "fromPlainHandler",
-        "fromPlainRequest",
         "fromWebHandler",
-        "fromWebRequest",
         "getBodyStream",
         "getCookie",
         "getHeader",
@@ -73,11 +68,12 @@ describe("h3 package", () => {
         "isCorsOriginAllowed",
         "isError",
         "isEvent",
-        "isEventHandler",
         "isMethod",
         "isPreflightRequest",
         "iterable",
         "lazyEventHandler",
+        "mockEvent",
+        "noContent",
         "parseCookies",
         "proxy",
         "proxyRequest",
@@ -111,13 +107,13 @@ describe("h3 package", () => {
         "toEventHandler",
         "toNodeHandler",
         "toNodeListener",
-        "toPlainHandler",
         "toWebHandler",
         "toWebRequest",
         "unsealSession",
         "updateSession",
         "useBase",
         "useSession",
+        "withBase",
         "writeEarlyHints",
       ]
     `);
diff --git a/test/plain.test.ts b/test/plain.test.ts
deleted file mode 100644
index 7600eb6..0000000
--- a/test/plain.test.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-import { describe, it, expect } from "vitest";
-import {
-  readTextBody,
-  appendResponseHeader,
-  setResponseStatus,
-  getRequestHeaders,
-  getQuery,
-} from "../src";
-import { setupTest } from "./_setup";
-
-describe("Plain handler", () => {
-  const ctx = setupTest();
-
-  it("works", async () => {
-    ctx.app.use("/test", async (event) => {
-      const body = await readTextBody(event);
-      setResponseStatus(event, 201, "Created");
-      appendResponseHeader(
-        event,
-        "content-type",
-        "application/json;charset=UTF-8",
-      );
-      appendResponseHeader(event, "set-cookie", "a=123");
-      appendResponseHeader(event, "set-cookie", "b=123");
-      appendResponseHeader(event, "set-cookie", "c=123");
-      appendResponseHeader(event, "set-cookie", "d=123");
-      return {
-        method: event.method,
-        path: event.path,
-        headers: getRequestHeaders(event),
-        body,
-        contextKeys: Object.keys(event.context),
-        query: getQuery(event),
-      };
-    });
-
-    const res = await ctx.plainHandler(
-      {
-        method: "POST",
-        path: "/test/foo/bar?test=123",
-        headers: [["x-test", "true"]],
-        body: "request body",
-      },
-      {
-        test: true,
-      },
-    );
-
-    expect(res).toMatchObject({
-      status: 201,
-      statusText: "Created",
-      headers: {
-        "content-type": "application/json;charset=UTF-8",
-        "set-cookie": "a=123, b=123, c=123, d=123",
-      },
-      setCookie: ["a=123", "b=123", "c=123", "d=123"],
-    });
-
-    expect(typeof res.body).toBe("string");
-    expect(JSON.parse(res.body as string)).toMatchObject({
-      method: "POST",
-      path: "/foo/bar?test=123",
-      body: "request body",
-      headers: {
-        "content-type": "text/plain;charset=UTF-8",
-        "x-test": "true",
-      },
-      contextKeys: ["test"],
-      query: { test: "123" },
-    });
-  });
-});
diff --git a/test/resolve.test.ts b/test/resolve.test.ts
index b1db242..9999faa 100644
--- a/test/resolve.test.ts
+++ b/test/resolve.test.ts
@@ -1,79 +1,98 @@
 import { describe, it, expect } from "vitest";
-import { createApp, createRouter, defineLazyEventHandler } from "../src";
+import { createApp, defineLazyEventHandler } from "../src";
+import { withBase } from "../src/utils/base";
 
-describe("Event handler resolver", () => {
-  const testHandlers = Array.from({ length: 10 }).map((_, i) => () => i);
+describe("Event handler resolver", async () => {
+  const _handlers = Object.create(null);
+  const _h = (name: string) => {
+    if (!_handlers[name]) {
+      _handlers[name] = { [name]: () => name }[name];
+    }
+    return _handlers[name];
+  };
 
   const app = createApp();
 
   // Middleware
-  app.use(testHandlers[0]);
-  app.use("/", testHandlers[1]);
+  app.use("/", _h("root middleware"));
+  app.use("/**", _h("/**"));
 
   // Path prefix
-  app.use("/test", testHandlers[2]);
+  app.use("/test/**", _h("/test/**"));
   app.use(
     "/lazy",
-    defineLazyEventHandler(() => testHandlers[3]),
+    defineLazyEventHandler(() => _h("lazy")),
   );
 
   // Sub app
   const nestedApp = createApp();
-  app.use("/nested", nestedApp as any);
-  nestedApp.use("/path", testHandlers[4]);
+  nestedApp.use("/path/**", _h("/nested/path/**"));
   nestedApp.use(
     "/lazy",
-    defineLazyEventHandler(() => Promise.resolve(testHandlers[5])),
+    defineLazyEventHandler(() => Promise.resolve(_h("/nested/lazy"))),
   );
+  app.use("/nested/**", withBase("/nested", nestedApp.handler));
 
   // Router
-  const router = createRouter();
-  app.use("/router", router.handler);
-  router.get("/", testHandlers[6]);
-  router.get("/:id", testHandlers[7]);
+  const router = createApp();
+  router.get("/", _h("/router"));
+  router.get("/:id", _h("/router/:id"));
   router.get(
     "/lazy",
-    defineLazyEventHandler(() => testHandlers[8]),
+    defineLazyEventHandler(() => Promise.resolve(_h("/router/lazy"))),
   );
+  app.use("/router/**", withBase("/router", router));
 
   describe("middleware", () => {
-    it("does not resolves /", async () => {
-      expect(await app.resolve("GET", "/")).toBeUndefined();
+    it("resolves /", async () => {
+      expect(await app.resolve("GET", "/")).toMatchObject({
+        handler: _h("root middleware"),
+      });
+    });
+
+    it("resolves /foo/bar", async () => {
+      expect(await app.resolve("GET", "/foo/bar")).toMatchObject({
+        route: "/**",
+        handler: _h("/**"),
+      });
     });
   });
 
   describe("path prefix", () => {
     it("resolves /test", async () => {
       expect(await app.resolve("GET", "/test")).toMatchObject({
-        prefix: "/test",
-        handler: testHandlers[2],
+        route: "/test/**",
+        handler: _h("/test/**"),
       });
     });
 
     it("resolves /test/foo", async () => {
-      expect((await app.resolve("GET", "/test/foo"))?.prefix).toEqual("/test");
+      expect(await app.resolve("GET", "/test/foo")).toMatchObject({
+        route: "/test/**",
+        handler: _h("/test/**"),
+      });
     });
   });
 
   it("resolves /lazy", async () => {
     expect(await app.resolve("GET", "/lazy")).toMatchObject({
-      prefix: "/lazy",
-      handler: testHandlers[3],
+      route: "/lazy",
+      handler: _h("lazy"),
     });
   });
 
   describe("nested app", () => {
     it("resolves /nested/path/foo", async () => {
       expect(await app.resolve("GET", "/nested/path/foo")).toMatchObject({
-        prefix: "/nested/path",
-        handler: testHandlers[4],
+        route: "/nested/path/**",
+        handler: _h("/nested/path/**"),
       });
     });
 
     it("resolves /nested/lazy", async () => {
       expect(await app.resolve("GET", "/nested/lazy")).toMatchObject({
-        prefix: "/nested/lazy",
-        handler: testHandlers[5],
+        route: "/nested/lazy",
+        handler: _h("/nested/lazy"),
       });
     });
   });
@@ -81,9 +100,8 @@ describe("Event handler resolver", () => {
   describe("router", () => {
     it("resolves /router", async () => {
       expect(await app.resolve("GET", "/router")).toMatchObject({
-        prefix: "/router",
-        route: "/",
-        handler: testHandlers[6],
+        route: "/router",
+        handler: _h("/router"),
       });
       expect(await app.resolve("GET", "/router/")).toMatchObject(
         (await app.resolve("GET", "/router")) as any,
@@ -92,17 +110,15 @@ describe("Event handler resolver", () => {
 
     it("resolves /router/:id", async () => {
       expect(await app.resolve("GET", "/router/foo")).toMatchObject({
-        prefix: "/router",
-        route: "/:id",
-        handler: testHandlers[7],
+        route: "/router/:id",
+        handler: _h("/router/:id"),
       });
     });
 
     it("resolves /router/lazy", async () => {
       expect(await app.resolve("GET", "/router/lazy")).toMatchObject({
-        prefix: "/router",
-        route: "/lazy",
-        handler: testHandlers[8],
+        route: "/router/lazy",
+        handler: _h("/router/lazy"),
       });
     });
   });
diff --git a/test/router.test.ts b/test/router.test.ts
index e32925e..81343fa 100644
--- a/test/router.test.ts
+++ b/test/router.test.ts
@@ -1,15 +1,15 @@
-import type { Router } from "../src/types";
+import type { H3 } from "../src/types";
 import { describe, it, expect, beforeEach } from "vitest";
-import { createRouter, getRouterParams, getRouterParam } from "../src";
+import { getRouterParams, getRouterParam, createApp } from "../src";
 import { setupTest } from "./_setup";
 
 describe("router", () => {
   const ctx = setupTest();
 
-  let router: Router;
+  let router: H3;
 
   beforeEach(() => {
-    router = createRouter()
+    router = createApp()
       .get("/", () => "Hello")
       .get("/test/?/a", () => "/test/?/a")
       .get("/many/routes", () => "many routes")
@@ -26,7 +26,7 @@ describe("router", () => {
   });
 
   it("Multiple Routers", async () => {
-    const secondRouter = createRouter().get("/router2", () => "router2");
+    const secondRouter = createApp().get("/router2", () => "router2");
 
     ctx.app.use(secondRouter);
 
@@ -91,13 +91,13 @@ describe("router", () => {
 describe("router (preemptive)", () => {
   const ctx = setupTest();
 
-  let router: Router;
+  let router: H3;
 
   beforeEach(() => {
-    router = createRouter({ preemptive: true })
+    router = createApp()
       .get("/test", () => "Test")
       .get("/undefined", () => undefined);
-    ctx.app.use(router);
+    ctx.app.all("/**", router);
   });
 
   it("Handle /test", async () => {
@@ -114,7 +114,7 @@ describe("router (preemptive)", () => {
   });
 
   it("Not matching route method", async () => {
-    const res = await ctx.request.head("/test");
+    const res = await ctx.request.head("/404");
     expect(res.status).toEqual(404);
   });
 
@@ -129,7 +129,7 @@ describe("getRouterParams", () => {
 
   describe("with router", () => {
     it("can return router params", async () => {
-      const router = createRouter().get("/test/params/:name", (event) => {
+      const router = createApp().get("/test/params/:name", (event) => {
         expect(getRouterParams(event)).toMatchObject({ name: "string" });
         return "200";
       });
@@ -140,7 +140,7 @@ describe("getRouterParams", () => {
     });
 
     it("can decode router params", async () => {
-      const router = createRouter().get("/test/params/:name", (event) => {
+      const router = createApp().get("/test/params/:name", (event) => {
         expect(getRouterParams(event, { decode: true })).toMatchObject({
           name: "string with space",
         });
@@ -155,7 +155,7 @@ describe("getRouterParams", () => {
 
   describe("without router", () => {
     it("can return an empty object if router is not used", async () => {
-      ctx.app.use("/", (event) => {
+      ctx.app.use("/**", (event) => {
         expect(getRouterParams(event)).toMatchObject({});
         return "200";
       });
@@ -171,7 +171,7 @@ describe("getRouterParam", () => {
 
   describe("with router", () => {
     it("can return a value of router params corresponding to the given name", async () => {
-      const router = createRouter().get("/test/params/:name", (event) => {
+      const router = createApp().get("/test/params/:name", (event) => {
         expect(getRouterParam(event, "name")).toEqual("string");
         return "200";
       });
@@ -182,7 +182,7 @@ describe("getRouterParam", () => {
     });
 
     it("can decode a value of router params corresponding to the given name", async () => {
-      const router = createRouter().get("/test/params/:name", (event) => {
+      const router = createApp().get("/test/params/:name", (event) => {
         expect(getRouterParam(event, "name", { decode: true })).toEqual(
           "string with space",
         );
@@ -197,7 +197,7 @@ describe("getRouterParam", () => {
 
   describe("without router", () => {
     it("can return `undefined` for any keys", async () => {
-      ctx.app.use("/", (request) => {
+      ctx.app.use("/**", (request) => {
         expect(getRouterParam(request, "name")).toEqual(undefined);
         return "200";
       });
@@ -213,7 +213,7 @@ describe("event.context.matchedRoute", () => {
 
   describe("with router", () => {
     it("can return the matched path", async () => {
-      const router = createRouter().get("/test/:template", (event) => {
+      const router = createApp().get("/test/:template", (event) => {
         expect(event.context.matchedRoute).toMatchObject({
           method: "GET",
           route: "/test/:template",
@@ -230,7 +230,7 @@ describe("event.context.matchedRoute", () => {
 
   describe("without router", () => {
     it("can return `undefined` for matched path", async () => {
-      ctx.app.use("/", (event) => {
+      ctx.app.use("/**", (event) => {
         expect(event.context.matchedRoute).toEqual(undefined);
         return "200";
       });
diff --git a/test/session.test.ts b/test/session.test.ts
index f12e9b1..58868ef 100644
--- a/test/session.test.ts
+++ b/test/session.test.ts
@@ -1,12 +1,12 @@
 import type { SessionConfig } from "../src/types";
 import { describe, it, expect, beforeEach } from "vitest";
-import { createRouter, useSession, readJSONBody } from "../src";
+import { useSession, readJSONBody, createApp } from "../src";
 import { setupTest } from "./_setup";
 
 describe("session", () => {
   const ctx = setupTest();
 
-  let router: ReturnType<typeof createRouter>;
+  let router: ReturnType<typeof createApp>;
 
   let cookie = "";
 
@@ -18,7 +18,7 @@ describe("session", () => {
   };
 
   beforeEach(() => {
-    router = createRouter({ preemptive: true });
+    router = createApp({});
     router.use("/", async (event) => {
       const session = await useSession(event, sessionConfig);
       if (event.method === "POST") {
diff --git a/test/static.test.ts b/test/static.test.ts
index fd3f01c..67872c5 100644
--- a/test/static.test.ts
+++ b/test/static.test.ts
@@ -26,7 +26,7 @@ describe("Serve Static", () => {
       encodings: { gzip: ".gz", br: ".br" },
     };
 
-    ctx.app.use("/", (event) => {
+    ctx.app.use("/**", (event) => {
       return serveStatic(event, serveStaticOptions);
     });
   });
diff --git a/test/status.test.ts b/test/status.test.ts
index 26d31b4..14a4748 100644
--- a/test/status.test.ts
+++ b/test/status.test.ts
@@ -1,27 +1,28 @@
-import { describe, it, expect, beforeEach } from "vitest";
-import { toPlainHandler } from "../src/adapters/web";
-import { setResponseStatus } from "../src";
+import { describe, it, expect } from "vitest";
+import { setResponseStatus, noContent } from "../src";
 import { setupTest } from "./_setup";
 
+async function webResponseToPlain(res: Response) {
+  return {
+    status: res.status,
+    statusText: res.statusText,
+    body: await res.text(),
+    headers: Object.fromEntries(res.headers),
+  };
+}
+
 describe("setResponseStatus", () => {
   const ctx = setupTest();
-  let handler: ReturnType<typeof toPlainHandler>;
-
-  beforeEach(() => {
-    handler = toPlainHandler(ctx.app);
-  });
 
   describe("content response", () => {
     it("sets status 200 as default", async () => {
       ctx.app.use("/test", () => "text");
 
-      const res = await handler({
+      const res = await ctx.app.fetch("/test", {
         method: "POST",
-        path: "/test",
-        headers: [],
       });
 
-      expect(res).toMatchObject({
+      expect(await webResponseToPlain(res)).toMatchObject({
         status: 200,
         statusText: "",
         body: "text",
@@ -36,14 +37,12 @@ describe("setResponseStatus", () => {
         return "text";
       });
 
-      const res = await handler({
+      const res = await ctx.app.fetch("/test", {
         method: "POST",
-        path: "/test",
-        headers: [],
         body: "",
       });
 
-      expect(res).toMatchObject({
+      expect(await webResponseToPlain(res)).toMatchObject({
         status: 418,
         statusText: "status-text",
         body: "text",
@@ -56,20 +55,18 @@ describe("setResponseStatus", () => {
 
   describe("no content response", () => {
     it("sets status 204 as default", async () => {
-      ctx.app.use("/test", () => {
-        return null;
+      ctx.app.use("/test", (event) => {
+        return noContent(event);
       });
 
-      const res = await handler({
+      const res = await ctx.app.fetch("/test", {
         method: "POST",
-        path: "/test",
-        headers: [],
       });
 
-      expect(res).toMatchObject({
+      expect(await webResponseToPlain(res)).toMatchObject({
         status: 204,
         statusText: "",
-        body: null,
+        body: "",
         headers: {},
       });
     });
@@ -79,14 +76,12 @@ describe("setResponseStatus", () => {
         return "";
       });
 
-      const res = await handler({
+      const res = await ctx.app.fetch("/test", {
         method: "POST",
-        path: "/test",
-        headers: [],
         body: "",
       });
 
-      expect(res).toMatchObject({
+      expect(await webResponseToPlain(res)).toMatchObject({
         status: 418,
         statusText: "status-text",
         body: "",
@@ -100,18 +95,12 @@ describe("setResponseStatus", () => {
         return "";
       });
 
-      const res = await handler({
-        method: "GET",
-        path: "/test",
-        headers: [],
-      });
-
-      // console.log(res.headers);
+      const res = await ctx.app.fetch("/test");
 
-      expect(res).toMatchObject({
+      expect(await webResponseToPlain(res)).toMatchObject({
         status: 304,
         statusText: "Not Modified",
-        body: null,
+        body: "",
         headers: {},
       });
     });
diff --git a/test/utils.test.ts b/test/utils.test.ts
index 8d6c455..61d108b 100644
--- a/test/utils.test.ts
+++ b/test/utils.test.ts
@@ -160,7 +160,7 @@ describe("", () => {
   describe("useBase", () => {
     it("can prefix routes", async () => {
       ctx.app.use(
-        "/",
+        "/**",
         useBase("/api", (event) => Promise.resolve(event.path)),
       );
       const result = await ctx.request.get("/api/test");
@@ -169,7 +169,7 @@ describe("", () => {
     });
     it("does nothing when not provided a base", async () => {
       ctx.app.use(
-        "/",
+        "/**",
         useBase("", (event) => Promise.resolve(event.path)),
       );
       const result = await ctx.request.get("/api/test");
@@ -180,7 +180,7 @@ describe("", () => {
 
   describe("getQuery", () => {
     it("can parse query params", async () => {
-      ctx.app.use("/", (event) => {
+      ctx.app.use("/**", (event) => {
         const query = getQuery(event);
         expect(query).toMatchObject({
           bool: "true",
@@ -199,7 +199,7 @@ describe("", () => {
 
   describe("getMethod", () => {
     it("can get method", async () => {
-      ctx.app.use("/", (event) => event.method);
+      ctx.app.use("/*", (event) => event.method);
       expect((await ctx.request.get("/api")).text).toBe("GET");
       expect((await ctx.request.post("/api")).text).toBe("POST");
     });
@@ -229,7 +229,7 @@ describe("", () => {
     ];
     for (const test of tests) {
       it("getRequestURL: " + JSON.stringify(test), async () => {
-        ctx.app.use("/", (event) => {
+        ctx.app.use("/**", (event) => {
           const url = getRequestURL(event, {
             xForwardedProto: true,
             xForwardedHost: true,
@@ -366,7 +366,7 @@ describe("", () => {
     it("uses the request ip when no x-forwarded-for header set", async () => {
       ctx.app.use((event) => getRequestFingerprint(event, { hash: false }));
 
-      ctx.app.options.onRequest = (event) => {
+      ctx.app.config.onRequest = (event) => {
         const { socket } = getNodeContext(event)?.req || {};
         Object.defineProperty(socket, "remoteAddress", {
           get(): any {
@@ -395,7 +395,7 @@ describe("", () => {
 
   describe("readFormDataBody", () => {
     it("can handle form as FormData in event handler", async () => {
-      ctx.app.use("/", async (event) => {
+      ctx.app.use("/api/*", async (event) => {
         const formData = await readFormDataBody(event);
         const user = formData!.get("user");
         expect(formData instanceof FormData).toBe(true);
diff --git a/test/web.test.ts b/test/web.test.ts
index e02de4c..ce5b20f 100644
--- a/test/web.test.ts
+++ b/test/web.test.ts
@@ -11,7 +11,7 @@ describe("Web handler", () => {
   const ctx = setupTest();
 
   it("works", async () => {
-    ctx.app.use("/test", async (event) => {
+    ctx.app.use("/test/**", async (event) => {
       const body = await readTextBody(event);
       setResponseStatus(event, 201, "Created");
       return {
@@ -24,18 +24,16 @@ describe("Web handler", () => {
       };
     });
 
-    const res = await ctx.webHandler(
-      new Request(new URL("/test/foo/bar?test=123", "http://localhost"), {
-        method: "POST",
-        headers: {
-          "X-Test": "true",
-        },
-        body: "request body",
-      }),
-      {
+    const res = await ctx.app.fetch("/test/foo/bar?test=123", {
+      method: "POST",
+      headers: {
+        "X-Test": "true",
+      },
+      body: "request body",
+      h3: {
         test: true,
       },
-    );
+    });
 
     expect(res.status).toBe(201);
     expect(res.statusText).toBe("Created");
@@ -45,7 +43,7 @@ describe("Web handler", () => {
 
     expect(await res.json()).toMatchObject({
       method: "POST",
-      path: "/foo/bar?test=123",
+      path: "/test/foo/bar?test=123",
       body: "request body",
       headers: {
         "content-type": "text/plain;charset=UTF-8",