Skip to content

Commit

Permalink
feat: handler resolver (#669)
Browse files Browse the repository at this point in the history
  • Loading branch information
pi0 authored Feb 25, 2024
1 parent cc80a2d commit 978440b
Show file tree
Hide file tree
Showing 6 changed files with 226 additions and 35 deletions.
47 changes: 42 additions & 5 deletions src/app.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { withoutTrailingSlash } from "ufo";
import { joinURL, withoutTrailingSlash } from "ufo";
import {
lazyEventHandler,
toEventHandler,
Expand All @@ -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;
Expand Down Expand Up @@ -66,17 +70,21 @@ export interface App {
handler: EventHandler;
options: AppOptions;
use: AppUse;
resolve: EventHandlerResolver;
}

/**
* Create a new H3 app instance.
*/
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,
Expand All @@ -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 }));
}
Expand Down Expand Up @@ -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
Expand Down
18 changes: 12 additions & 6 deletions src/event/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
79 changes: 56 additions & 23 deletions src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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));
Expand All @@ -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);
Expand All @@ -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;
}
7 changes: 7 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
108 changes: 108 additions & 0 deletions test/resolve.test.ts
Original file line number Diff line number Diff line change
@@ -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],
});
});
});
});
2 changes: 1 addition & 1 deletion test/status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ describe("setResponseStatus", () => {
body: "",
});

console.log(res.headers);
// console.log(res.headers);

expect(res).toMatchObject({
status: 304,
Expand Down

0 comments on commit 978440b

Please sign in to comment.