Skip to content
66 changes: 53 additions & 13 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,8 +172,23 @@ export class App<State> {
return this;
}

use(middleware: MiddlewareFn<State>): this {
this.#router.addMiddleware(middleware);
use(pathOrMiddleware: MiddlewareFn<State>): this;
use(
pathOrMiddleware: string | URLPattern,
middleware: MiddlewareFn<State>,
): this;
use(
pathOrMiddleware: string | URLPattern | MiddlewareFn<State>,
middleware?: MiddlewareFn<State>,
): this {
if (typeof pathOrMiddleware === "function") {
this.#router.addMiddleware("/*", pathOrMiddleware);
} else {
if (!middleware) {
throw new Error("Middleware is required when path is provided");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
throw new Error("Middleware is required when path is provided");
throw new TypeError("Middleware is required when path is provided");

Nit

}
this.#router.addMiddleware(pathOrMiddleware, middleware);
}
return this;
}

Expand Down Expand Up @@ -212,19 +227,44 @@ export class App<State> {
// - `app.mountApp("/*", otherApp)`
const isSelf = path === "/*" || path === "/";
if (isSelf && middlewares.length > 0) {
this.#router._middlewares.push(...middlewares);
}
// When mounting at the root, add middleware directly to the host app
for (const middleware of middlewares) {
this.#router._middlewares.push(middleware);
}

for (let i = 0; i < routes.length; i++) {
const route = routes[i];
// Add routes as-is since we're mounting at the root
for (let i = 0; i < routes.length; i++) {
const route = routes[i];
this.#router.add(route.method, route.path, [...route.handlers]);
}
} else {
// For non-root mounts, we need to merge paths and apply middlewares to routes
for (let i = 0; i < routes.length; i++) {
const route = routes[i];
const merged = typeof route.path === "string"
? mergePaths(path, route.path)
: mergePaths(path, route.path.pathname);

// If there are no middlewares, just add the route as-is with the merged path
if (middlewares.length === 0) {
this.#router.add(route.method, merged, [...route.handlers]);
continue;
}

// When there are middlewares, we need to add middleware handlers to each route
// Extract middleware handlers into a flat array ensuring we get the right type
const middlewareHandlers = middlewares.flatMap((middleware) =>
middleware.handlers
);

const merged = typeof route.path === "string"
? mergePaths(path, route.path)
: route.path;
const combined = isSelf
? route.handlers
: middlewares.concat(route.handlers);
this.#router.add(route.method, merged, combined);
// Create a new array of handlers with the correct type
const combinedHandlers: Array<MiddlewareFn<State>> = [
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const combinedHandlers: Array<MiddlewareFn<State>> = [
const combinedHandlers = [

Nit: this might not be needed

...middlewareHandlers,
...route.handlers,
];
Comment on lines +263 to +264
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Create a new array of handlers with the correct type
const combinedHandlers: Array<MiddlewareFn<State>> = [
...middlewareHandlers,
...route.handlers.map((h) => h as unknown as MiddlewareFn<State>),
];
const combinedHandlers = [
...middlewareHandlers,
...route.handlers,
];


this.#router.add(route.method, merged, combinedHandlers);
}
}

return this;
Expand Down
39 changes: 38 additions & 1 deletion src/app_test.tsx
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice tests!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,13 @@ Deno.test(
ctx.state.text += "B";
return ctx.next();
})
.post("/foo", (ctx) => new Response(ctx.state.text));
.use("/bar", function C(ctx) {
ctx.state.text += "C";
return ctx.next();
})
.post("/foo", (ctx) => new Response(ctx.state.text))
.get("/bar", (ctx) => new Response(ctx.state.text))
.post("/bar", (ctx) => new Response(ctx.state.text));

const app = new App<{ text: string }>()
.use(function A(ctx) {
Expand All @@ -348,6 +354,12 @@ Deno.test(

res = await server.post("/foo");
expect(await res.text()).toEqual("AB");

res = await server.get("/bar");
expect(await res.text()).toEqual("ABC");

res = await server.post("/bar");
expect(await res.text()).toEqual("ABC");
},
);

Expand All @@ -368,6 +380,31 @@ Deno.test("FreshApp - .mountApp() self mount empty", async () => {
expect(await res.text()).toEqual("A");
});

Deno.test("FreshApp - .mountApp() self mount sub path", async () => {
const innerApp = new App<{ text: string }>()
.use((ctx) => {
ctx.state.text = "B";
return ctx.next();
})
.get("/foo", (ctx) => new Response(ctx.state.text));

const app = new App<{ text: string }>()
.use((ctx) => {
ctx.state.text = "A";
return ctx.next();
})
.get("/foo", (ctx) => new Response(ctx.state.text))
.mountApp("/aaa", innerApp);

const server = new FakeServer(app.handler());

let res = await server.get("/aaa/foo");
expect(await res.text()).toEqual("B");

res = await server.get("/foo");
expect(await res.text()).toEqual("A");
});

Deno.test(
"FreshApp - .mountApp() self mount with middleware",
async () => {
Expand Down
69 changes: 44 additions & 25 deletions src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ export interface RouteResult<T> {

export interface Router<T> {
_routes: Route<T>[];
_middlewares: T[];
addMiddleware(fn: T): void;
_middlewares: Route<T>[];
addMiddleware(pathname: string | URLPattern, fn: T): void;
add(
method: Method | "ALL",
pathname: string | URLPattern,
Expand All @@ -30,29 +30,37 @@ export const IS_PATTERN = /[*:{}+?()]/;

export class UrlPatternRouter<T> implements Router<T> {
readonly _routes: Route<T>[] = [];
readonly _middlewares: T[] = [];
readonly _middlewares: Route<T>[] = [];

addMiddleware(fn: T): void {
this._middlewares.push(fn);
isURLPattern(value: unknown): value is URLPattern {
return value instanceof URLPattern;
}

add(method: Method | "ALL", pathname: string | URLPattern, handlers: T[]) {
if (
typeof pathname === "string" && pathname !== "/*" &&
IS_PATTERN.test(pathname)
) {
this._routes.push({
path: new URLPattern({ pathname }),
handlers,
method,
});
} else {
this._routes.push({
path: pathname,
handlers,
method,
});
}
addMiddleware(pathname: string | URLPattern, handler: T): void {
this._middlewares.push({
path: this.isURLPattern(pathname)
? pathname
: new URLPattern({ pathname }),
handlers: [handler],
method: "ALL",
});
}

add(
method: Method | "ALL",
pathname: string | URLPattern,
handlers: T[],
): void {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
): void {
) {

Nit: the Deno team consider defining void and Promise<void> as explicit return types as needless boilerplate.

this._routes.push({
path: (
typeof pathname === "string" && pathname !== "/*" &&
!this.isURLPattern(pathname)
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: To clarify, my idea was to have everything inside this condition be factored out. Not just the URLPattern bit. Also, I don't see a reason why it should be a method when it doesn't use any property from this class.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apologies — I didn’t fully grasp your intent at first.
I’ve now moved the logic out of the class and reorganized the content.

? new URLPattern({ pathname })
: pathname,
handlers,
method,
});
}

match(method: Method, url: URL): RouteResult<T> {
Expand All @@ -64,8 +72,20 @@ export class UrlPatternRouter<T> implements Router<T> {
pattern: null,
};

if (this._middlewares.length > 0) {
result.handlers.push(this._middlewares);
for (let i = 0; i < this._middlewares.length; i++) {
const middleware = this._middlewares[i];

if (
typeof middleware.path === "string" &&
(middleware.path === "/*" || middleware.path === url.pathname)
) {
result.handlers.push(middleware.handlers);
} else if (middleware.path instanceof URLPattern) {
const match = middleware.path.exec(url);
if (match !== null) {
result.handlers.push(middleware.handlers);
}
}
}

for (let i = 0; i < this._routes.length; i++) {
Expand Down Expand Up @@ -100,7 +120,6 @@ export class UrlPatternRouter<T> implements Router<T> {
if (route.method === "ALL" || route.method === method) {
result.handlers.push(route.handlers);

// Decode matched params
for (const [key, value] of Object.entries(match.pathname.groups)) {
result.params[key] = value === undefined ? "" : decodeURI(value);
}
Expand Down
44 changes: 44 additions & 0 deletions src/router_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,50 @@ Deno.test("UrlPatternRouter - GET get matches with middlewares", () => {
});
});

Deno.test("UrlPatternRouter - GET get matches with middlewares with Path", () => {
const router = new UrlPatternRouter();
const A = () => {};
const B = () => {};
const C = () => {};
const D = () => {};
router.addMiddleware("/*", D);
router.add("ALL", "/*", [A]);
router.add("ALL", "/*", [B]);
router.add("GET", "/", [C]);

const res = router.match("GET", new URL("/", "http://localhost"));

expect(res).toEqual({
params: {},
handlers: [[D], [A], [B], [C]],
methodMatch: true,
pattern: "/",
patternMatch: true,
});
});

Deno.test("UrlPatternRouter - GET get matches with middlewares with Path(eq endpoint path)", () => {
const router = new UrlPatternRouter();
const A = () => {};
const B = () => {};
const C = () => {};
const D = () => {};
router.addMiddleware("/a/1", D);
router.add("ALL", "/*", [A]);
router.add("ALL", "/*", [B]);
router.add("GET", "/a/1", [C]);

const res = router.match("GET", new URL("/a/1", "http://localhost"));

expect(res).toEqual({
params: {},
handlers: [[D], [A], [B], [C]],
methodMatch: true,
pattern: "/a/1",
patternMatch: true,
});
});

Deno.test("UrlPatternRouter - GET extract params", () => {
const router = new UrlPatternRouter();
const A = () => {};
Expand Down
Loading