Repository: unjs/h3
Pull request #820 (2024-07-09 15:05:29)

refactor!: reimplement app  with class
This PR is part 1/2 of app core refactors, rewriting app implementation as a class.

API changes:
- `app.use(handler, { lazy: true })` is removed to save bundle. the alternative is using `defineLazyEventHandler` wrapper to explicitly do this
- `app.use(string[])` and `app.use(handler[])` are removed to reduce internal complexity one can simply use multiple `app.use` calls (there are more reasons for this for future works)
- `app.use({})` with object syntax, accepts `prefix` instead of route while route is for router path patterns.
- `app.resolve` and `__resolve__` returns `prefix` (this also fixes issues with resolving lazy handler and nested router paths)
- 


Patch:
diff --git a/MIGRATION.md b/MIGRATION.md
index 64f06e5..6b59217 100644
--- a/MIGRATION.md
+++ b/MIGRATION.md
@@ -14,7 +14,7 @@ h3 v2 includes some behavior and API changes that you need to consider applying
 
 ## Fully decoupled from Node.js
 
-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. 
+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.
 
 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/).
 
@@ -42,6 +42,22 @@ Other send utils that are renamed and need explicit `return`:
 - `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 })`
+- `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).
+
+### Router
+
+h3 migrated to a brand new route-matching engine [unjs/rou3](https://rou3.unjs.io/).
+
+You might experience slight (and more intuitive) behavior changes for matching patterns.
+
+- `router.use(path, handler)` is deprecated. Use `router.all(path, handler)` instead.
+- `router.add(path, method: Method | Method[]` signature is changed to `router.add(method: Method, path)` (**important**)
+
 ## Body utils
 
 The legacy `readBody` and `readRawBody` utils are replaced with a new set of body utils that can leverage native runtime primitives better.
@@ -57,16 +73,6 @@ 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)
 
-## Router
-
-h3 migrated to a brand new route-matching engine [unjs/rou3](https://rou3.unjs.io/).
-
-You might experience slight (and more intuitive) behavior changes for matching patterns.
-
-- `router.use(path, handler)` is deprecated. Use `router.all(path, handler)` instead.
-- `router.add(path, method: Method | Method[]` signature is changed to `router.add(method: Method, path)` (**important**)
-- `app.resolve` and `handler.__resolve` signature changed from `(path)` to `(method, path)`.
-
 ## Cookie and Headers
 
 h3 migrated to leverage standard web [`Headers`](https://developer.mozilla.org/en-US/docs/Web/API/Headers) for all utils.
diff --git a/src/adapters/node/utils.ts b/src/adapters/node/utils.ts
index 01a6a38..a139b7e 100644
--- a/src/adapters/node/utils.ts
+++ b/src/adapters/node/utils.ts
@@ -15,7 +15,7 @@ import { isEventHandler } from "../../handler";
 import { EventWrapper } from "../../event";
 import { NodeEvent } from "./event";
 import { _sendResponse, callNodeHandler } from "./_internal";
-import { errorToAppResponse } from "../../app/_response";
+import { errorToAppResponse } from "../../response";
 
 /**
  * Convert H3 app instance to a NodeHandler with (IncomingMessage, ServerResponse) => void signature.
diff --git a/src/app.ts b/src/app.ts
new file mode 100644
index 0000000..363b7a9
--- /dev/null
+++ b/src/app.ts
@@ -0,0 +1,180 @@
+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/app/_handler.ts b/src/app/_handler.ts
deleted file mode 100644
index 44e2dfe..0000000
--- a/src/app/_handler.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-import type {
-  AppOptions,
-  EventHandler,
-  EventHandlerRequest,
-  ResponseBody,
-  Stack,
-} from "../types";
-import { _kRaw } from "../event";
-import { createError } from "../error";
-import { handleAppResponse } from "./_response";
-
-export function createAppEventHandler(
-  stack: Stack,
-  options: AppOptions,
-): EventHandler<EventHandlerRequest, Promise<ResponseBody>> {
-  return async (event) => {
-    try {
-      // Keep a copy of incoming url
-      const _reqPath = event[_kRaw].path || "/";
-
-      // Layer path is the path without the prefix
-      let _layerPath: string;
-
-      // Call onRequest hook
-      if (options.onRequest) {
-        await options.onRequest(event);
-      }
-
-      // Run through stack
-      for (const layer of stack) {
-        // 1. Remove prefix from path
-        if (layer.route.length > 1) {
-          if (!_reqPath.startsWith(layer.route)) {
-            continue;
-          }
-          _layerPath = _reqPath.slice(layer.route.length) || "/";
-        } else {
-          _layerPath = _reqPath;
-        }
-
-        // 2. Custom matcher
-        if (layer.match && !layer.match(_layerPath, event)) {
-          continue;
-        }
-
-        // 3. Update event path with layer path
-        event[_kRaw].path = _layerPath;
-
-        // 4. Handle request
-        const val = await layer.handler(event);
-
-        // 5. Handle response
-        const _body = val === undefined ? undefined : await val;
-        if (_body !== undefined) {
-          return handleAppResponse(event, _body, options);
-        }
-      }
-
-      // Throw 404 is no handler in the stack responded
-      throw createError({
-        statusCode: 404,
-        statusMessage: `Cannot find any path matching ${event.path || "/"}.`,
-      });
-    } catch (error: unknown) {
-      return handleAppResponse(event, error, options);
-    }
-  };
-}
diff --git a/src/app/_utils.ts b/src/app/_utils.ts
deleted file mode 100644
index 7e9565f..0000000
--- a/src/app/_utils.ts
+++ /dev/null
@@ -1,120 +0,0 @@
-import type {
-  Stack,
-  EventHandlerResolver,
-  LazyEventHandler,
-  InputLayer,
-  Layer,
-  AppOptions,
-  WebSocketOptions,
-  App,
-  EventHandler,
-  HTTPMethod,
-} from "../types";
-import { _kRaw } from "../event";
-import { defineLazyEventHandler } from "../handler";
-import {
-  getPathname,
-  joinURL,
-  withoutTrailingSlash,
-} from "../utils/internal/path";
-
-export function use(
-  app: App,
-  arg1: string | EventHandler | InputLayer | InputLayer[],
-  arg2?: Partial<InputLayer> | EventHandler | EventHandler[],
-  arg3?: Partial<InputLayer>,
-) {
-  if (Array.isArray(arg1)) {
-    for (const i of arg1) {
-      use(app, i, arg2, arg3);
-    }
-  } else if (Array.isArray(arg2)) {
-    for (const i of arg2) {
-      use(app, arg1, i, arg3);
-    }
-  } else if (typeof arg1 === "string") {
-    app.stack.push(
-      normalizeLayer({ ...arg3, route: arg1, handler: arg2 as EventHandler }),
-    );
-  } else if (typeof arg1 === "function") {
-    app.stack.push(normalizeLayer({ ...arg2, handler: arg1 as EventHandler }));
-  } else {
-    app.stack.push(normalizeLayer({ ...arg1 }));
-  }
-  return app;
-}
-
-export function createResolver(stack: Stack): EventHandlerResolver {
-  return async (method, path) => {
-    let _layerPath: string;
-    for (const layer of stack) {
-      if (layer.route === "/" && !layer.handler.__resolve__) {
-        continue;
-      }
-      if (!path.startsWith(layer.route)) {
-        continue;
-      }
-      _layerPath = path.slice(layer.route.length) || "/";
-      if (layer.match && !layer.match(_layerPath, undefined)) {
-        continue;
-      }
-      let res = { route: layer.route, handler: layer.handler };
-      if (res.handler.__resolve__) {
-        const _res = await res.handler.__resolve__(method, _layerPath);
-        if (!_res) {
-          continue;
-        }
-        res = {
-          ...res,
-          ..._res,
-          route: joinURL(res.route || "/", _res.route || "/"),
-        };
-      }
-      return res;
-    }
-  };
-}
-
-export function normalizeLayer(input: InputLayer) {
-  let handler = input.handler;
-  // @ts-ignore
-  if (handler.handler) {
-    // @ts-ignore
-    handler = handler.handler;
-  }
-
-  if (input.lazy) {
-    handler = defineLazyEventHandler(handler as unknown as LazyEventHandler);
-  }
-
-  return {
-    route: withoutTrailingSlash(input.route),
-    match: input.match,
-    handler,
-  } as Layer;
-}
-
-export function resolveWebsocketOptions(
-  resolver: EventHandlerResolver,
-  appOptions: AppOptions,
-): WebSocketOptions {
-  return {
-    ...appOptions.websocket,
-    async resolve(info) {
-      const pathname = getPathname(info.url || "/");
-      const method = ((info as any).method || "GET") as HTTPMethod;
-      const resolved = await resolver(method, pathname);
-      return resolved?.handler?.__websocket__ || {};
-    },
-  };
-}
-
-export function cachedFn<T>(fn: () => T): () => T {
-  let cache: T;
-  return () => {
-    if (!cache) {
-      cache = fn();
-    }
-    return cache;
-  };
-}
diff --git a/src/app/app.ts b/src/app/app.ts
deleted file mode 100644
index 8fab4e6..0000000
--- a/src/app/app.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import type { App, Stack, AppOptions } from "../types";
-import { _kRaw } from "../event";
-import { createAppEventHandler } from "./_handler";
-import {
-  cachedFn,
-  createResolver,
-  resolveWebsocketOptions,
-  use,
-} from "./_utils";
-
-/**
- * Create a new H3 app instance.
- */
-export function createApp(options: AppOptions = {}): App {
-  const stack: Stack = [];
-
-  const handler = createAppEventHandler(stack, options);
-
-  const resolve = createResolver(stack);
-  handler.__resolve__ = resolve;
-
-  const getWebsocket = cachedFn(() =>
-    resolveWebsocketOptions(resolve, options),
-  );
-
-  const _use = (arg1: any, arg2: any, arg3: any) => use(app, arg1, arg2, arg3);
-
-  const app: App = {
-    use: _use as App["use"],
-    resolve,
-    handler,
-    stack,
-    options,
-    get websocket() {
-      return getWebsocket();
-    },
-  };
-
-  return app;
-}
diff --git a/src/handler.ts b/src/handler.ts
index 5b802cb..1f2f24e 100644
--- a/src/handler.ts
+++ b/src/handler.ts
@@ -134,9 +134,9 @@ export function dynamicEventHandler(
   return wrapper;
 }
 
-export function defineLazyEventHandler<T extends LazyEventHandler>(
-  factory: T,
-): Awaited<ReturnType<T>> {
+export function defineLazyEventHandler(
+  load: () => Promise<EventHandler> | EventHandler,
+): EventHandler {
   let _promise: Promise<typeof _resolved>;
   let _resolved: { handler: EventHandler };
 
@@ -145,7 +145,7 @@ export function defineLazyEventHandler<T extends LazyEventHandler>(
       return Promise.resolve(_resolved);
     }
     if (!_promise) {
-      _promise = Promise.resolve(factory()).then((r: any) => {
+      _promise = Promise.resolve(load()).then((r: any) => {
         const handler = r.default || r;
         if (typeof handler !== "function") {
           throw new (TypeError as any)(
@@ -160,14 +160,19 @@ export function defineLazyEventHandler<T extends LazyEventHandler>(
     return _promise;
   };
 
-  const handler = defineEventHandler((event) => {
+  const handler: EventHandler = (event) => {
     if (_resolved) {
       return _resolved.handler(event);
     }
     return resolveHandler().then((r) => r.handler(event));
-  }) as Awaited<ReturnType<T>>;
+  };
 
-  handler.__resolve__ = resolveHandler;
+  handler.__resolve__ = (method, path) =>
+    Promise.resolve(
+      resolveHandler().then((r) =>
+        r.handler.__resolve__ ? r.handler.__resolve__(method, path) : r,
+      ),
+    );
 
   return handler;
 }
diff --git a/src/index.ts b/src/index.ts
index 33b9b1f..4094a4c 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -2,7 +2,7 @@
 export * from "./types";
 
 // App
-export { createApp } from "./app/app";
+export { createApp } from "./app";
 
 // Event
 export { isEvent } from "./event";
diff --git a/src/app/_response.ts b/src/response.ts
similarity index 87%
rename from src/app/_response.ts
rename to src/response.ts
index ec33737..658a28c 100644
--- a/src/app/_response.ts
+++ b/src/response.ts
@@ -1,13 +1,11 @@
-import type { AppOptions, H3Event, ResponseBody } from "../types";
-import type { AppResponse, H3Error } from "../types/app";
-import { createError } from "../error";
-import { isJSONSerializable } from "../utils/internal/object";
-import { MIMES } from "../utils/internal/consts";
-import { _kRaw } from "../event";
+import type { AppOptions, H3Event, ResponseBody } from "./types";
+import type { AppResponse, H3Error } from "./types/app";
+import { createError } from "./error";
+import { isJSONSerializable } from "./utils/internal/object";
+import { MIMES } from "./utils/internal/consts";
+import { _kRaw } from "./event";
 
-type MaybePromise<T> = T | Promise<T>;
-
-export async function handleAppResponse(
+export async function prepareResponse(
   event: H3Event,
   body: unknown,
   options: AppOptions,
@@ -48,7 +46,7 @@ export async function handleAppResponse(
 function _normalizeResponseBody(
   val: unknown,
   options: AppOptions,
-): MaybePromise<AppResponse> {
+): AppResponse | Promise<AppResponse> {
   // Empty Content
   if (val === null || val === undefined) {
     return { body: "", status: 204 };
diff --git a/src/types/app.ts b/src/types/app.ts
index cf39b2c..35f8cb9 100644
--- a/src/types/app.ts
+++ b/src/types/app.ts
@@ -1,4 +1,4 @@
-import type { AdapterOptions as WSOptions } from "crossws";
+import type { AdapterOptions as CrossWSAdapterOptions } from "crossws";
 import type { H3Event } from "./event";
 import type {
   EventHandler,
@@ -13,7 +13,7 @@ type MaybePromise<T> = T | Promise<T>;
 export type { H3Error } from "../error";
 
 export interface Layer {
-  route: string;
+  prefix: string;
   match?: Matcher;
   handler: EventHandler;
 }
@@ -21,15 +21,14 @@ export interface Layer {
 export type Stack = Layer[];
 
 export interface InputLayer {
-  route?: string;
+  prefix?: string;
   match?: Matcher;
-  handler: EventHandler;
-  lazy?: boolean;
+  handler: EventHandler & { handler?: EventHandler };
 }
 
 export type InputStack = InputLayer[];
 
-export type Matcher = (url: string, event?: H3Event) => boolean;
+export type Matcher = (path: string, event?: H3Event) => boolean;
 
 export interface AppResponse {
   error?: H3Error;
@@ -41,16 +40,12 @@ export interface AppResponse {
 }
 
 export interface AppUse {
-  (
-    route: string | string[],
-    handler: EventHandler | EventHandler[],
-    options?: Partial<InputLayer>,
-  ): App;
-  (handler: EventHandler | EventHandler[], options?: Partial<InputLayer>): App;
+  (prefix: string, handler: EventHandler, options?: Partial<InputLayer>): App;
+  (handler: EventHandler, options?: Partial<InputLayer>): App;
   (options: InputLayer): App;
 }
 
-export type WebSocketOptions = WSOptions;
+export type WebSocketOptions = CrossWSAdapterOptions;
 
 export interface AppOptions {
   debug?: boolean;
diff --git a/src/types/handler.ts b/src/types/handler.ts
index 665e8aa..d232a4f 100644
--- a/src/types/handler.ts
+++ b/src/types/handler.ts
@@ -3,7 +3,6 @@ import type { QueryObject } from "ufo";
 import type { H3Event } from "./event";
 import type { Hooks as WSHooks } from "crossws";
 import type { HTTPMethod } from "./http";
-import type { RouterEntry } from "./router";
 
 export type ResponseBody =
   | undefined // middleware pass
@@ -27,12 +26,18 @@ export type InferEventInput<
 
 type MaybePromise<T> = T | Promise<T>;
 
+export type ResolvedEventHandler = {
+  method?: HTTPMethod;
+  route?: string;
+  prefix?: string;
+  handler?: EventHandler;
+  params?: Record<string, string>;
+};
+
 export type EventHandlerResolver = (
   method: HTTPMethod,
   path: string,
-) => MaybePromise<
-  undefined | (Partial<RouterEntry> & { params?: Record<string, string> })
->;
+) => MaybePromise<undefined | ResolvedEventHandler>;
 
 export interface EventHandler<
   Request extends EventHandlerRequest = EventHandlerRequest,


Test patch:
diff --git a/test/app.test.ts b/test/app.test.ts
index ddcad26..cc1fa60 100644
--- a/test/app.test.ts
+++ b/test/app.test.ts
@@ -209,23 +209,6 @@ describe("app", () => {
     expect(res.text).toBe("42");
   });
 
-  it("can use route arrays", async () => {
-    ctx.app.use(["/1", "/2"], () => "valid");
-
-    const responses = [
-      await ctx.request.get("/1"),
-      await ctx.request.get("/2"),
-    ].map((r) => r.text);
-    expect(responses).toEqual(["valid", "valid"]);
-  });
-
-  it("can use handler arrays", async () => {
-    ctx.app.use("/", [() => {}, () => {}, () => {}, () => "valid"]);
-
-    const response = await ctx.request.get("/");
-    expect(response.text).toEqual("valid");
-  });
-
   it("prohibits use of next() in non-promisified handlers", () => {
     ctx.app.use("/", () => {});
   });
@@ -239,7 +222,7 @@ describe("app", () => {
   });
 
   it("can take an object", async () => {
-    ctx.app.use({ route: "/", handler: () => "valid" });
+    ctx.app.use({ prefix: "/", handler: () => "valid" });
 
     const response = await ctx.request.get("/");
     expect(response.text).toEqual("valid");
diff --git a/test/lazy.test.ts b/test/lazy.test.ts
index 68d2d1f..d74bb11 100644
--- a/test/lazy.test.ts
+++ b/test/lazy.test.ts
@@ -1,5 +1,6 @@
 import { describe, it, expect, vi } from "vitest";
 import { setupTest } from "./_setup";
+import { defineLazyEventHandler } from "../src";
 
 (global.console.error as any) = vi.fn();
 
@@ -18,16 +19,20 @@ describe("lazy loading", () => {
   for (const [type, handler] of handlers) {
     for (const [kind, resolution] of kinds) {
       it(`can load ${type} handlers lazily from a ${kind}`, async () => {
-        ctx.app.use("/big", () => Promise.resolve(resolution(handler)), {
-          lazy: true,
-        });
+        ctx.app.use(
+          "/big",
+          defineLazyEventHandler(() => Promise.resolve(resolution(handler))),
+        );
         const result = await ctx.request.get("/big");
 
         expect(result.text).toBe("lazy");
       });
 
       it(`can handle ${type} functions that don't return promises from a ${kind}`, async () => {
-        ctx.app.use("/big", () => resolution(handler), { lazy: true });
+        ctx.app.use(
+          "/big",
+          defineLazyEventHandler(() => resolution(handler)),
+        );
         const result = await ctx.request.get("/big");
 
         expect(result.text).toBe("lazy");
diff --git a/test/resolve.test.ts b/test/resolve.test.ts
index eff1fcd..b1db242 100644
--- a/test/resolve.test.ts
+++ b/test/resolve.test.ts
@@ -6,28 +6,33 @@ describe("Event handler resolver", () => {
 
   const app = createApp();
 
-  const router = createRouter();
-  app.use(router);
-
   // Middleware
   app.use(testHandlers[0]);
   app.use("/", testHandlers[1]);
 
   // Path prefix
   app.use("/test", testHandlers[2]);
-  app.use("/lazy", () => testHandlers[3], { lazy: true });
+  app.use(
+    "/lazy",
+    defineLazyEventHandler(() => testHandlers[3]),
+  );
 
   // Sub app
-  const app2 = createApp();
-  app.use("/nested", app2 as any);
-  app2.use("/path", testHandlers[4]);
-  // app2.use("/lazy", () => testHandlers[5], { lazy: true });
+  const nestedApp = createApp();
+  app.use("/nested", nestedApp as any);
+  nestedApp.use("/path", testHandlers[4]);
+  nestedApp.use(
+    "/lazy",
+    defineLazyEventHandler(() => Promise.resolve(testHandlers[5])),
+  );
 
   // Router
-  router.get("/router", testHandlers[6]);
-  router.get("/router/:id", testHandlers[7]);
+  const router = createRouter();
+  app.use("/router", router.handler);
+  router.get("/", testHandlers[6]);
+  router.get("/:id", testHandlers[7]);
   router.get(
-    "/router/lazy",
+    "/lazy",
     defineLazyEventHandler(() => testHandlers[8]),
   );
 
@@ -40,19 +45,19 @@ describe("Event handler resolver", () => {
   describe("path prefix", () => {
     it("resolves /test", async () => {
       expect(await app.resolve("GET", "/test")).toMatchObject({
-        route: "/test",
+        prefix: "/test",
         handler: testHandlers[2],
       });
     });
 
     it("resolves /test/foo", async () => {
-      expect((await app.resolve("GET", "/test/foo"))?.route).toEqual("/test");
+      expect((await app.resolve("GET", "/test/foo"))?.prefix).toEqual("/test");
     });
   });
 
   it("resolves /lazy", async () => {
     expect(await app.resolve("GET", "/lazy")).toMatchObject({
-      route: "/lazy",
+      prefix: "/lazy",
       handler: testHandlers[3],
     });
   });
@@ -60,14 +65,14 @@ describe("Event handler resolver", () => {
   describe("nested app", () => {
     it("resolves /nested/path/foo", async () => {
       expect(await app.resolve("GET", "/nested/path/foo")).toMatchObject({
-        route: "/nested/path",
+        prefix: "/nested/path",
         handler: testHandlers[4],
       });
     });
 
-    it.skip("resolves /nested/lazy", async () => {
+    it("resolves /nested/lazy", async () => {
       expect(await app.resolve("GET", "/nested/lazy")).toMatchObject({
-        route: "/nested/lazy",
+        prefix: "/nested/lazy",
         handler: testHandlers[5],
       });
     });
@@ -76,7 +81,8 @@ describe("Event handler resolver", () => {
   describe("router", () => {
     it("resolves /router", async () => {
       expect(await app.resolve("GET", "/router")).toMatchObject({
-        route: "/router",
+        prefix: "/router",
+        route: "/",
         handler: testHandlers[6],
       });
       expect(await app.resolve("GET", "/router/")).toMatchObject(
@@ -86,14 +92,16 @@ describe("Event handler resolver", () => {
 
     it("resolves /router/:id", async () => {
       expect(await app.resolve("GET", "/router/foo")).toMatchObject({
-        route: "/router/:id",
+        prefix: "/router",
+        route: "/:id",
         handler: testHandlers[7],
       });
     });
 
     it("resolves /router/lazy", async () => {
       expect(await app.resolve("GET", "/router/lazy")).toMatchObject({
-        route: "/router/lazy",
+        prefix: "/router",
+        route: "/lazy",
         handler: testHandlers[8],
       });
     });