Repository: unjs/h3
Pull request #669 (2024-02-25 11:13:33)

feat: handler resolver
<!---
☝️ PR title should follow conventional commits (https://conventionalcommits.org) 
-->

### 🔗 Linked issue

<!-- Please ensure there is an open issue and mention its number as #123 -->

### ❓ Type of change

<!-- What types of changes does your code introduce? Put an `x` in all the boxes that apply. -->

- [ ] 📖 Documentation (updates to the documentation, readme, or JSdoc annotations)
- [ ] 🐞 Bug fix (a non-breaking change that fixes an issue)
- [ ] 👌 Enhancement (improving an existing functionality like performance)
- [ ] ✨ New feature (a non-breaking change that adds functionality)
- [ ] 🧹 Chore (updates to the build process or auxiliary tools and libraries)
- [ ] ⚠️ Breaking change (fix or feature that would cause existing functionality to change)

### 📚 Description

This PR exposes a new `app.resolve(path)` method that can be used to resolve an even handler matching path (method unspecified/get for now).

This API is useful to access handlers for new APIs such as WebSocket or accessing handler meta.

Resolver is async to allow lazy methods.

App, Nested App, Lazy handlers and Router are supported and tested.

### 📝 Checklist

<!-- Put an `x` in all the boxes that apply. -->
<!-- If your change requires a documentation PR, please link it appropriately -->
<!-- If you're unsure about any of these, don't hesitate to ask. We're here to help! -->

- [ ] I have linked an issue or discussion.
- [ ] I have updated the documentation accordingly.


cloudflare-pages[bot]: ## Deploying with &nbsp;<a href="https://pages.dev"><img alt="Cloudflare Pages" src="https://user-images.githubusercontent.com/23264/106598434-9e719e00-654f-11eb-9e59-6167043cfa01.png" width="16"></a> &nbsp;Cloudflare Pages

<table><tr><td><strong>Latest commit:</strong> </td><td>
<code>2eb08eb</code>
</td></tr>
<tr><td><strong>Status:</strong></td><td>⚡️&nbsp; Build in progress...</td></tr>
</table>

[View logs](https://dash.cloudflare.com/?to=/:account/pages/view/unjs-h3/020f2127-64da-49ba-8080-48c4f23f1c97)


Patch:
diff --git a/src/app.ts b/src/app.ts
index d88170c..a839278 100644
--- a/src/app.ts
+++ b/src/app.ts
@@ -1,4 +1,4 @@
-import { withoutTrailingSlash } from "ufo";
+import { joinURL, withoutTrailingSlash } from "ufo";
 import {
   lazyEventHandler,
   toEventHandler,
@@ -16,7 +16,11 @@ import {
   isWebResponse,
   sendNoContent,
 } from "./utils";
-import type { EventHandler, LazyEventHandler } from "./types";
+import type {
+  EventHandler,
+  EventHandlerResolver,
+  LazyEventHandler,
+} from "./types";
 
 export interface Layer {
   route: string;
@@ -66,6 +70,7 @@ export interface App {
   handler: EventHandler;
   options: AppOptions;
   use: AppUse;
+  resolve: EventHandlerResolver;
 }
 
 /**
@@ -73,10 +78,13 @@ export interface App {
  */
 export function createApp(options: AppOptions = {}): App {
   const stack: Stack = [];
+  const resolve = createResolver(stack);
   const handler = createAppEventHandler(stack, options);
+  handler.__resolve__ = resolve;
   const app: App = {
     // @ts-ignore
     use: (arg1, arg2, arg3) => use(app as App, arg1, arg2, arg3),
+    resolve,
     handler,
     stack,
     options,
@@ -103,9 +111,7 @@ export function use(
       normalizeLayer({ ...arg3, route: arg1, handler: arg2 as EventHandler }),
     );
   } else if (typeof arg1 === "function") {
-    app.stack.push(
-      normalizeLayer({ ...arg2, route: "/", handler: arg1 as EventHandler }),
-    );
+    app.stack.push(normalizeLayer({ ...arg2, handler: arg1 as EventHandler }));
   } else {
     app.stack.push(normalizeLayer({ ...arg1 }));
   }
@@ -190,6 +196,37 @@ export function createAppEventHandler(stack: Stack, options: AppOptions) {
   });
 }
 
+function createResolver(stack: Stack): EventHandlerResolver {
+  return async (path: string) => {
+    let _layerPath: string;
+    for (const layer of stack) {
+      if (layer.route === "/" && !layer.handler.__resolve__) {
+        continue;
+      }
+      if (!path.startsWith(layer.route)) {
+        continue;
+      }
+      _layerPath = path.slice(layer.route.length) || "/";
+      if (layer.match && !layer.match(_layerPath, undefined)) {
+        continue;
+      }
+      let res = { route: layer.route, handler: layer.handler };
+      if (res.handler.__resolve__) {
+        const _res = await res.handler.__resolve__(_layerPath);
+        if (!_res) {
+          continue;
+        }
+        res = {
+          ...res,
+          ..._res,
+          route: joinURL(res.route || "/", _res.route || "/"),
+        };
+      }
+      return res;
+    }
+  };
+}
+
 function normalizeLayer(input: InputLayer) {
   let handler = input.handler;
   // @ts-ignore
diff --git a/src/event/utils.ts b/src/event/utils.ts
index 24ee2b1..7c11d9a 100644
--- a/src/event/utils.ts
+++ b/src/event/utils.ts
@@ -148,8 +148,9 @@ export function dynamicEventHandler(
 export function defineLazyEventHandler<T extends LazyEventHandler>(
   factory: T,
 ): Awaited<ReturnType<T>> {
-  let _promise: Promise<EventHandler>;
-  let _resolved: EventHandler;
+  let _promise: Promise<typeof _resolved>;
+  let _resolved: { handler: EventHandler };
+
   const resolveHandler = () => {
     if (_resolved) {
       return Promise.resolve(_resolved);
@@ -163,17 +164,22 @@ export function defineLazyEventHandler<T extends LazyEventHandler>(
             handler,
           );
         }
-        _resolved = toEventHandler(r.default || r);
+        _resolved = { handler: toEventHandler(r.default || r) };
         return _resolved;
       });
     }
     return _promise;
   };
-  return eventHandler((event) => {
+
+  const handler = eventHandler((event) => {
     if (_resolved) {
-      return _resolved(event);
+      return _resolved.handler(event);
     }
-    return resolveHandler().then((handler) => handler(event));
+    return resolveHandler().then((r) => r.handler(event));
   }) as Awaited<ReturnType<T>>;
+
+  handler.__resolve__ = resolveHandler;
+
+  return handler;
 }
 export const lazyEventHandler = defineLazyEventHandler;
diff --git a/src/router.ts b/src/router.ts
index 262def9..8a691b8 100644
--- a/src/router.ts
+++ b/src/router.ts
@@ -3,6 +3,7 @@ import {
   toRouteMatcher,
   RouteMatcher,
 } from "radix3";
+import { withLeadingSlash } from "ufo";
 import type { HTTPMethod, EventHandler } from "./types";
 import { createError } from "./error";
 import { eventHandler, toEventHandler } from "./event";
@@ -82,10 +83,9 @@ export function createRouter(opts: CreateRouterOptions = {}): Router {
     router[method] = (path, handle) => router.add(path, handle, method);
   }
 
-  // Main handle
-  router.handler = eventHandler((event) => {
+  // Handler matcher
+  const matchHandler = (path = "/", method: RouterMethod = "get") => {
     // Remove query parameters for matching
-    let path = event.path || "/";
     const qIndex = path.indexOf("?");
     if (qIndex !== -1) {
       path = path.slice(0, Math.max(0, qIndex));
@@ -94,26 +94,20 @@ export function createRouter(opts: CreateRouterOptions = {}): Router {
     // Match route
     const matched = _router.lookup(path);
     if (!matched || !matched.handlers) {
-      if (opts.preemptive || opts.preemtive) {
-        throw createError({
+      return {
+        error: createError({
           statusCode: 404,
           name: "Not Found",
-          statusMessage: `Cannot find any route matching ${event.path || "/"}.`,
-        });
-      } else {
-        return; // Let app match other handlers
-      }
+          statusMessage: `Cannot find any route matching ${path || "/"}.`,
+        }),
+      };
     }
 
     // Match method
-    const method = (
-      event.node.req.method || "get"
-    ).toLowerCase() as RouterMethod;
-
     let handler: EventHandler | undefined =
       matched.handlers[method] || matched.handlers.all;
 
-    // Fallback to search for shadowed routes
+    // Fallback to search for (method) shadowed routes
     if (!handler) {
       if (!_matcher) {
         _matcher = toRouteMatcher(_router);
@@ -134,32 +128,71 @@ export function createRouter(opts: CreateRouterOptions = {}): Router {
       }
     }
 
-    // Method not matched
     if (!handler) {
-      if (opts.preemptive || opts.preemtive) {
-        throw createError({
+      return {
+        error: createError({
           statusCode: 405,
           name: "Method Not Allowed",
           statusMessage: `Method ${method} is not allowed on this route.`,
-        });
+        }),
+      };
+    }
+
+    return { matched, handler };
+  };
+
+  // Main handle
+  const isPreemptive = opts.preemptive || opts.preemtive;
+  router.handler = eventHandler((event) => {
+    // Match handler
+    const match = matchHandler(
+      event.path,
+      event.method.toLowerCase() as RouterMethod,
+    );
+
+    // No match (method or route)
+    if ("error" in match) {
+      if (isPreemptive) {
+        throw match.error;
       } else {
         return; // Let app match other handlers
       }
     }
 
     // Add matched route and params to the context
-    event.context.matchedRoute = matched;
-    const params = matched.params || {};
+    event.context.matchedRoute = match.matched;
+    const params = match.matched.params || {};
     event.context.params = params;
 
     // Call handler
-    return Promise.resolve(handler(event)).then((res) => {
-      if (res === undefined && (opts.preemptive || opts.preemtive)) {
+    return Promise.resolve(match.handler(event)).then((res) => {
+      if (res === undefined && isPreemptive) {
         return null; // Send empty content
       }
       return res;
     });
   });
 
+  // Resolver
+  router.handler.__resolve__ = async (path) => {
+    path = withLeadingSlash(path);
+    const match = matchHandler(path);
+    if ("error" in match) {
+      return;
+    }
+    let res = {
+      route: match.matched.path,
+      handler: match.handler,
+    };
+    if (match.handler.__resolve__) {
+      const _res = await match.handler.__resolve__(path);
+      if (!_res) {
+        return;
+      }
+      res = { ...res, ..._res };
+    }
+    return res;
+  };
+
   return router;
 }
diff --git a/src/types.ts b/src/types.ts
index 1c664df..977c3f9 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -62,11 +62,18 @@ export type InferEventInput<
   T,
 > = void extends T ? (Event extends H3Event<infer E> ? E[Key] : never) : T;
 
+type MaybePromise<T> = T | Promise<T>;
+
+export type EventHandlerResolver = (
+  path: string,
+) => MaybePromise<undefined | { route?: string; handler: EventHandler }>;
+
 export interface EventHandler<
   Request extends EventHandlerRequest = EventHandlerRequest,
   Response extends EventHandlerResponse = EventHandlerResponse,
 > {
   __is_handler__?: true;
+  __resolve__?: EventHandlerResolver;
   (event: H3Event<Request>): Response;
 }
 


Test patch:
diff --git a/test/resolve.test.ts b/test/resolve.test.ts
new file mode 100644
index 0000000..50e4a4d
--- /dev/null
+++ b/test/resolve.test.ts
@@ -0,0 +1,108 @@
+import { describe, it, expect } from "vitest";
+import {
+  createApp,
+  createRouter,
+  eventHandler,
+  lazyEventHandler,
+} from "../src";
+
+describe("Event handler resolver", () => {
+  const testHandlers = Array.from({ length: 10 }).map((_, i) =>
+    eventHandler(() => i),
+  );
+
+  const app = createApp();
+
+  const router = createRouter();
+  app.use(router);
+
+  // Middlware
+  app.use(testHandlers[0]);
+  app.use("/", testHandlers[1]);
+
+  // Path prefix
+  app.use("/test", testHandlers[2]);
+  app.use("/lazy", () => testHandlers[3], { lazy: true });
+
+  // Sub app
+  const app2 = createApp();
+  app.use("/nested", app2 as any);
+  app2.use("/path", testHandlers[4]);
+  // app2.use("/lazy", () => testHandlers[5], { lazy: true });
+
+  // Router
+  router.get("/router", testHandlers[6]);
+  router.get("/router/:id", testHandlers[7]);
+  router.get(
+    "/router/lazy",
+    lazyEventHandler(() => testHandlers[8]),
+  );
+
+  describe("middleware", () => {
+    it("does not resolves /", async () => {
+      expect(await app.resolve("/")).toBeUndefined();
+    });
+  });
+
+  describe("path prefix", () => {
+    it("resolves /test", async () => {
+      expect(await app.resolve("/test")).toMatchObject({
+        route: "/test",
+        handler: testHandlers[2],
+      });
+    });
+
+    it("resolves /test/foo", async () => {
+      expect((await app.resolve("/test/foo"))?.route).toEqual("/test");
+    });
+  });
+
+  it("resolves /lazy", async () => {
+    expect(await app.resolve("/lazy")).toMatchObject({
+      route: "/lazy",
+      handler: testHandlers[3],
+    });
+  });
+
+  describe("nested app", () => {
+    it("resolves /nested/path/foo", async () => {
+      expect(await app.resolve("/nested/path/foo")).toMatchObject({
+        route: "/nested/path",
+        handler: testHandlers[4],
+      });
+    });
+
+    it.skip("resolves /nested/lazy", async () => {
+      expect(await app.resolve("/nested/lazy")).toMatchObject({
+        route: "/nested/lazy",
+        handler: testHandlers[5],
+      });
+    });
+  });
+
+  describe("router", () => {
+    it("resolves /router", async () => {
+      expect(await app.resolve("/router")).toMatchObject({
+        route: "/router",
+        handler: testHandlers[6],
+      });
+      expect(await app.resolve("/router/")).toMatchObject(
+        (await app.resolve("/router")) as any,
+      );
+    });
+
+    it("resolves /router/:id", async () => {
+      expect(await app.resolve("/router/foo")).toMatchObject({
+        route: "/router/:id",
+        handler: testHandlers[7],
+      });
+    });
+
+    it("resolves /router/lazy", async () => {
+      expect(await app.resolve("/router/lazy")).toMatchObject({
+        route: "/router/lazy",
+        handler: testHandlers[8],
+      });
+    });
+  });
+});
diff --git a/test/status.test.ts b/test/status.test.ts
index 9304df6..ca29feb 100644
--- a/test/status.test.ts
+++ b/test/status.test.ts
@@ -126,7 +126,7 @@ describe("setResponseStatus", () => {
         body: "",
       });
 
-      console.log(res.headers);
+      // console.log(res.headers);
 
       expect(res).toMatchObject({
         status: 304,