diff --git a/deno.json b/deno.json index cd278d8..c4d6b01 100644 --- a/deno.json +++ b/deno.json @@ -1,5 +1,5 @@ { - "name": "BearMetal Router", + "name": "@bearmetal/router", "description": "A simple router for Deno", "version": "0.1.0", "stable": true, @@ -8,5 +8,10 @@ "mod.ts", "README.md", "LICENSE" - ] + ], + "exports": "./mod.ts", + "imports": { + "@std/assert": "jsr:@std/assert@^1.0.7", + "@std/testing": "jsr:@std/testing@^1.0.4" + } } diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..d787b32 --- /dev/null +++ b/deno.lock @@ -0,0 +1,51 @@ +{ + "version": "4", + "specifiers": { + "jsr:@std/assert@^1.0.7": "1.0.7", + "jsr:@std/data-structures@^1.0.4": "1.0.4", + "jsr:@std/fs@^1.0.5": "1.0.5", + "jsr:@std/internal@^1.0.5": "1.0.5", + "jsr:@std/path@^1.0.7": "1.0.8", + "jsr:@std/path@^1.0.8": "1.0.8", + "jsr:@std/testing@^1.0.4": "1.0.4" + }, + "jsr": { + "@std/assert@1.0.7": { + "integrity": "64ce9fac879e0b9f3042a89b3c3f8ccfc9c984391af19e2087513a79d73e28c3", + "dependencies": [ + "jsr:@std/internal" + ] + }, + "@std/data-structures@1.0.4": { + "integrity": "fa0e20c11eb9ba673417450915c750a0001405a784e2a4e0c3725031681684a0" + }, + "@std/fs@1.0.5": { + "integrity": "41806ad6823d0b5f275f9849a2640d87e4ef67c51ee1b8fb02426f55e02fd44e", + "dependencies": [ + "jsr:@std/path@^1.0.7" + ] + }, + "@std/internal@1.0.5": { + "integrity": "54a546004f769c1ac9e025abd15a76b6671ddc9687e2313b67376125650dc7ba" + }, + "@std/path@1.0.8": { + "integrity": "548fa456bb6a04d3c1a1e7477986b6cffbce95102d0bb447c67c4ee70e0364be" + }, + "@std/testing@1.0.4": { + "integrity": "ca1368d720b183f572d40c469bb9faf09643ddd77b54f8b44d36ae6b94940576", + "dependencies": [ + "jsr:@std/assert", + "jsr:@std/data-structures", + "jsr:@std/fs", + "jsr:@std/internal", + "jsr:@std/path@^1.0.8" + ] + } + }, + "workspace": { + "dependencies": [ + "jsr:@std/assert@^1.0.7", + "jsr:@std/testing@^1.0.4" + ] + } +} diff --git a/mod.ts b/mod.ts index 1451f3e..fa92574 100644 --- a/mod.ts +++ b/mod.ts @@ -1,222 +1 @@ -interface RouterContext { - url: URL; - params: Record; - state: Record; - pattern: URLPattern; - request: Request; -} - -type Handler = (ctx: RouterContext) => Promise | Response; -type Middleware = (ctx: RouterContext, next: () => Promise) => Promise; - -interface RouteConfig { - pattern: URLPattern; - handlers: { [method: string]: Handler }; -} - -interface MiddlewareConfig { - pattern: URLPattern; - handler: Middleware; -} - -export class Router { - private routes: RouteConfig[] = []; - private middleware: MiddlewareConfig[] = []; - - route(path: string) { - path = path.startsWith('/') ? path : `/${path}`; - - const pattern = new URLPattern({ pathname: path }); - const routeConfig: RouteConfig = { - pattern, - handlers: {} - }; - this.routes.push(routeConfig); - - return { - get: (handler: Handler) => { - routeConfig.handlers['GET'] = handler; - return this; - }, - post: (handler: Handler) => { - routeConfig.handlers['POST'] = handler; - return this; - }, - put: (handler: Handler) => { - routeConfig.handlers['PUT'] = handler; - return this; - }, - delete: (handler: Handler) => { - routeConfig.handlers['DELETE'] = handler; - return this; - }, - patch: (handler: Handler) => { - routeConfig.handlers['PATCH'] = handler; - return this; - }, - options: (handler: Handler) => { - routeConfig.handlers['OPTIONS'] = handler; - return this; - } - }; - } - - use(pathOrMiddleware: string | Middleware, middlewareOrRouter?: Middleware | Router) { - // Handle the case where only middleware is provided - if (typeof pathOrMiddleware === 'function') { - const pattern = new URLPattern({ pathname: '/*' }); - this.middleware.push({ - pattern, - handler: pathOrMiddleware - }); - return this; - } - - const path = pathOrMiddleware; - const middleware = middlewareOrRouter; - - if (!middleware) { - throw new Error('Middleware or Router is required'); - } - - // Normalize the path - const normalizedPath = path.startsWith('/') ? path : `/${path}`; - // Only add wildcard if there isn't already a parameter or pattern at the end - const wildcardPath = normalizedPath.includes(':') || normalizedPath.includes('*') ? - normalizedPath : - `${normalizedPath.replace(/\/+$/, '')}/*`; - - if (middleware instanceof Router) { - // Merge the nested router's routes - for (const nestedRoute of middleware.routes) { - const combinedPath = this.combinePaths(path, nestedRoute.pattern.pathname); - const newPattern = new URLPattern({ pathname: combinedPath }); - - this.routes.push({ - pattern: newPattern, - handlers: { ...nestedRoute.handlers } - }); - } - - // Merge the nested router's middleware - for (const nestedMiddleware of middleware.middleware) { - const combinedPath = this.combinePaths(path, nestedMiddleware.pattern.pathname); - const newPattern = new URLPattern({ pathname: combinedPath }); - - this.middleware.push({ - pattern: newPattern, - handler: nestedMiddleware.handler - }); - } - } else { - // Handle regular middleware - const pattern = new URLPattern({ pathname: wildcardPath }); - this.middleware.push({ - pattern, - handler: middleware - }); - } - - return this; - } - - handle = async (req: Request): Promise => { - const url = new URL(req.url); - const method = req.method; - - // Find the matching route - const matchedRoute = this.findMatchingRoute(url); - if (!matchedRoute) { - return new Response('Not Found', { status: 404 }); - } - - const handler = matchedRoute.config.handlers[method]; - if (!handler) { - return new Response('Method Not Allowed', { status: 405 }); - } - - // Get matching middleware with their parameters - const matchingMiddleware = this.getMatchingMiddleware(url); - - // Create the base context object - const baseCtx: RouterContext = { - url, - params: {}, - state: {}, - pattern: matchedRoute.config.pattern, - request: req - }; - - // Combine route parameters with the base context - baseCtx.params = { - ...baseCtx.params, - ...matchedRoute.params - }; - - // Execute middleware chain - let index = 0; - const executeMiddleware = async (): Promise => { - if (index < matchingMiddleware.length) { - const { middleware, params } = matchingMiddleware[index++]; - // Create a new context for each middleware with its specific parameters - const middlewareCtx: RouterContext = { - ...baseCtx, - params: { ...baseCtx.params, ...params }, - pattern: middleware.pattern - }; - return await middleware.handler(middlewareCtx, executeMiddleware); - } - // Final handler gets the accumulated parameters - return await handler(baseCtx); - }; - - try { - return await executeMiddleware(); - } catch (error) { - console.error('Error handling request:', error); - return new Response('Internal Server Error', { status: 500 }); - } - } - - private findMatchingRoute(url: URL): { config: RouteConfig; params: Record } | null { - for (const route of this.routes) { - const result = route.pattern.exec(url); - if (result) { - return { - config: route, - params: result.pathname.groups - }; - } - } - return null; - } - - private getMatchingMiddleware(url: URL): Array<{ - middleware: MiddlewareConfig; - params: Record - }> { - return this.middleware - .map(mw => { - const result = mw.pattern.exec(url); - if (result) { - return { - middleware: mw, - params: result.pathname.groups - }; - } - return null; - }) - .filter((item): item is { - middleware: MiddlewareConfig; - params: Record - } => item !== null); - } - - private combinePaths(basePath: string, routePath: string): string { - const normalizedBase = basePath.replace(/\/+$/, '').replace(/^\/*/, '/'); - const normalizedRoute = routePath.replace(/^\/*/, ''); - return `${normalizedBase}/${normalizedRoute}`; - } -} - -export default Router; \ No newline at end of file +export { Router } from "./router.ts"; \ No newline at end of file diff --git a/router.test.ts b/router.test.ts new file mode 100644 index 0000000..66d1650 --- /dev/null +++ b/router.test.ts @@ -0,0 +1,294 @@ +// deno-lint-ignore-file require-await +import { assertEquals } from "@std/assert"; +import { beforeEach, describe, it } from "@std/testing/bdd"; +import Router from "./router.ts"; + +describe("Router", () => { + let router: Router; + + beforeEach(() => { + router = new Router(); + }); + + describe("Basic Routing", () => { + it("should handle basic GET request", async () => { + router.route("/test") + .get(async () => new Response("test response")); + + const req = new Request("http://localhost/test", { + method: "GET", + }); + + const res = await router.handle(req); + assertEquals(res.status, 200); + assertEquals(await res.text(), "test response"); + }); + + it("should return 404 for non-existent route", async () => { + const req = new Request("http://localhost/nonexistent", { + method: "GET", + }); + + const res = await router.handle(req); + assertEquals(res.status, 404); + }); + + it("should return 405 for unsupported method", async () => { + router.route("/test") + .get(async () => new Response("test")); + + const req = new Request("http://localhost/test", { + method: "POST", + }); + + const res = await router.handle(req); + assertEquals(res.status, 405); + }); + }); + + describe("Route Parameters", () => { + it("should handle route parameters", async () => { + router.route("/users/:id") + .get(async (ctx) => new Response(ctx.params.id)); + + const req = new Request("http://localhost/users/123", { + method: "GET", + }); + + const res = await router.handle(req); + assertEquals(await res.text(), "123"); + }); + + it("should handle multiple route parameters", async () => { + router.route("/users/:userId/posts/:postId") + .get(async (ctx) => { + return new Response(JSON.stringify(ctx.params)); + }); + + const req = new Request("http://localhost/users/123/posts/456", { + method: "GET", + }); + + const res = await router.handle(req); + const params = JSON.parse(await res.text()); + assertEquals(params.userId, "123"); + assertEquals(params.postId, "456"); + }); + }); + + describe("Middleware", () => { + it("should execute global middleware", async () => { + let middlewareExecuted = false; + + router.use(async (_ctx, next) => { + middlewareExecuted = true; + return await next(); + }); + + router.route("/test") + .get(async () => new Response("test")); + + const req = new Request("http://localhost/test", { + method: "GET", + }); + + await router.handle(req); + assertEquals(middlewareExecuted, true); + }); + + it("should execute path-specific middleware", async () => { + let middlewareExecuted = false; + + router.use("/test", async (_ctx, next) => { + middlewareExecuted = true; + console.log('middlware happened') + return await next(); + }); + + router.route("/test") + .get(async () => new Response("test")); + + const req = new Request("http://localhost/test", { + method: "GET", + }); + + await router.handle(req); + assertEquals(middlewareExecuted, true); + }); + + it("should handle middleware parameters", async () => { + const capturedParams: Record = {}; + + router.use("/:version/.*", async (ctx, next) => { + capturedParams.version = ctx.params.version; + return await next(); + }); + + router.route("/:version/test") + .get(async () => new Response("test")); + + const req = new Request("http://localhost/v1/test", { + method: "GET", + }); + + await router.handle(req); + assertEquals(capturedParams.version, "v1"); + }); + + it("should execute middleware in correct order", async () => { + const order: number[] = []; + + router.use(async (_ctx, next) => { + order.push(1); + return await next(); + }); + + router.use("/test", async (_ctx, next) => { + order.push(2); + return await next(); + }); + + router.route("/test") + .get(async () => { + order.push(3); + return new Response("test"); + }); + + const req = new Request("http://localhost/test", { + method: "GET", + }); + + await router.handle(req); + assertEquals(order, [1, 2, 3]); + }); + }); + + describe("Nested Routers", () => { + it("should handle nested routes", async () => { + const apiRouter = new Router(); + apiRouter.route("/test") + .get(async () => new Response("api test")); + + router.use("/api", apiRouter); + + const req = new Request("http://localhost/api/test", { + method: "GET", + }); + + const res = await router.handle(req); + assertEquals(await res.text(), "api test"); + }); + + it("should handle nested routes with parameters", async () => { + const apiRouter = new Router(); + apiRouter.route("/users/:id") + .get(async (ctx) => new Response(ctx.params.id)); + + router.use("/api/:version", apiRouter); + + const req = new Request("http://localhost/api/v1/users/123", { + method: "GET", + }); + + const res = await router.handle(req); + assertEquals(await res.text(), "123"); + }); + + it("should handle nested middleware", async () => { + const apiRouter = new Router(); + let middlewareExecuted = false; + + apiRouter.use(async (_ctx, next) => { + middlewareExecuted = true; + return await next(); + }); + + apiRouter.route("/test") + .get(async () => new Response("test")); + + router.use("/api", apiRouter); + + const req = new Request("http://localhost/api/test", { + method: "GET", + }); + + await router.handle(req); + assertEquals(middlewareExecuted, true); + }); + }); + + describe("Context State", () => { + it("should maintain state across middleware chain", async () => { + router.use(async (ctx, next) => { + ctx.state.test = "value"; + return await next(); + }); + + router.route("/test") + .get(async (ctx) => { + return new Response(ctx.state.test as string); + }); + + const req = new Request("http://localhost/test", { + method: "GET", + }); + + const res = await router.handle(req); + assertEquals(await res.text(), "value"); + }); + }); + + describe("Error Handling", () => { + it("should handle errors in handlers", async () => { + router.route("/error") + .get(async () => { + throw new Error("Test error"); + }); + + const req = new Request("http://localhost/error", { + method: "GET", + }); + + const res = await router.handle(req); + assertEquals(res.status, 500); + assertEquals(await res.text(), "Internal Server Error"); + }); + + it("should handle errors in middleware", async () => { + router.use(async () => { + throw new Error("Middleware error"); + }); + + router.route("/test") + .get(async () => new Response("test")); + + const req = new Request("http://localhost/test", { + method: "GET", + }); + + const res = await router.handle(req); + assertEquals(res.status, 500); + }); + }); + + describe("HTTP Methods", () => { + const methods = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]; + + methods.forEach(method => { + it(`should handle ${method} requests`, async () => { + const route = router.route("/test"); + route[method.toLowerCase() as keyof typeof route]( + async () => new Response(method) + ); + + const req = new Request("http://localhost/test", { + method: method, + }); + + const res = await router.handle(req); + assertEquals(res.status, 200); + assertEquals(await res.text(), method); + }); + }); + }); +}); \ No newline at end of file diff --git a/router.ts b/router.ts new file mode 100644 index 0000000..0b86423 --- /dev/null +++ b/router.ts @@ -0,0 +1,312 @@ +interface RouterContext { + url: URL; + params: Record; + state: Record; + pattern: URLPattern; + request: Request; +} + +type Handler = (ctx: RouterContext) => Promise | Response; +type Middleware = (ctx: RouterContext, next: () => Promise) => Promise; + +interface RouteConfig { + pattern: URLPattern; + handlers: { [method: string]: Handler }; +} + +interface MiddlewareConfig { + pattern: URLPattern; + handler: Middleware; + // Add path for sorting + path: string; +} + +export class Router { + private routes: RouteConfig[] = []; + private middleware: MiddlewareConfig[] = []; + + route(path: string) { + path = path.startsWith('/') ? path : `/${path}`; + + const pattern = new URLPattern({ pathname: path }); + const routeConfig: RouteConfig = { + pattern, + handlers: {} + }; + this.routes.push(routeConfig); + + return { + get(handler: Handler) { + routeConfig.handlers['GET'] = handler; + return this; + }, + post(handler: Handler) { + routeConfig.handlers['POST'] = handler; + return this; + }, + put(handler: Handler) { + routeConfig.handlers['PUT'] = handler; + return this; + }, + delete(handler: Handler) { + routeConfig.handlers['DELETE'] = handler; + return this; + }, + patch(handler: Handler) { + routeConfig.handlers['PATCH'] = handler; + return this; + }, + options(handler: Handler) { + routeConfig.handlers['OPTIONS'] = handler; + return this; + } + }; + } + + use(pathOrMiddleware: string | Middleware, middlewareOrRouter?: Middleware | Router) { + // Handle the case where only middleware is provided + if (typeof pathOrMiddleware === 'function') { + const pattern = new URLPattern({ pathname: '/*' }); + this.middleware.push({ + pattern, + handler: pathOrMiddleware, + path: '/*' + }); + return this; + } + + const path = pathOrMiddleware; + const middleware = middlewareOrRouter; + + if (!middleware) { + throw new Error('Middleware or Router is required'); + } + + // Normalize the path to handle both exact matches and nested paths + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + const isParameterPath = normalizedPath.includes(':'); + const wildcardPath = isParameterPath ? + normalizedPath : + normalizedPath === '/' ? + '/*' : + `${normalizedPath.replace(/\/+$/, '')}/*?`; + + if (middleware instanceof Router) { + // Merge the nested router's routes + for (const nestedRoute of middleware.routes) { + const combinedPath = this.combinePaths(path, nestedRoute.pattern.pathname); + const newPattern = new URLPattern({ pathname: combinedPath }); + + this.routes.push({ + pattern: newPattern, + handlers: { ...nestedRoute.handlers } + }); + } + + // Merge the nested router's middleware + for (const nestedMiddleware of middleware.middleware) { + const combinedPath = this.combinePaths(path, nestedMiddleware.path); + const newPattern = new URLPattern({ pathname: combinedPath }); + + this.middleware.push({ + pattern: newPattern, + handler: nestedMiddleware.handler, + path: combinedPath + }); + } + } else { + // Handle regular middleware + const pattern = new URLPattern({ pathname: wildcardPath }); + this.middleware.push({ + pattern, + handler: middleware, + path: wildcardPath + }); + } + + return this; + } + + handle = async (req: Request): Promise => { + const url = new URL(req.url); + const method = req.method; + + // Find the matching route + const matchedRoute = this.findMatchingRoute(url); + if (!matchedRoute) { + return new Response('Not Found', { status: 404 }); + } + + const handler = matchedRoute.config.handlers[method]; + if (!handler) { + return new Response('Method Not Allowed', { status: 405 }); + } + + // Get matching middleware and sort by path specificity + const matchingMiddleware = this.getMatchingMiddleware(url); + + // Create the base context object + const baseCtx: RouterContext = { + url, + params: {}, + state: {}, + pattern: matchedRoute.config.pattern, + request: req + }; + + // Combine route parameters with the base context + baseCtx.params = { + ...baseCtx.params, + ...matchedRoute.params + }; + + // Execute middleware chain + let index = 0; + const executeMiddleware = async (): Promise => { + if (index < matchingMiddleware.length) { + const { middleware, params } = matchingMiddleware[index++]; + // Create a new context for each middleware with its specific parameters + const middlewareCtx: RouterContext = { + ...baseCtx, + params: { ...baseCtx.params, ...params }, + pattern: middleware.pattern + }; + return await middleware.handler(middlewareCtx, executeMiddleware); + } + // Final handler gets the accumulated parameters + return await handler(baseCtx); + }; + + try { + return await executeMiddleware(); + } catch (error) { + console.error('Error handling request:', error); + return new Response('Internal Server Error', { status: 500 }); + } + } + + private findMatchingRoute(url: URL): { config: RouteConfig; params: Record } | null { + for (const route of this.routes) { + const result = route.pattern.exec(url); + if (result) { + return { + config: route, + params: result.pathname.groups + }; + } + } + return null; + } + + private getMatchingMiddleware(url: URL): Array<{ + middleware: MiddlewareConfig; + params: Record + }> { + const matches = this.middleware + .map(mw => { + const result = mw.pattern.exec(url) ?? mw.pattern.exec(url.href + '/'); + // console.log(url, mw.pattern, result); + if (result) { + return { + middleware: mw, + params: result.pathname.groups + }; + } + return null; + }) + .filter((item): item is { + middleware: MiddlewareConfig; + params: Record + } => item !== null); + + // Sort middleware by path specificity + return matches.sort((a, b) => { + const pathA = a.middleware.path; + const pathB = b.middleware.path; + + // Global middleware comes first + if (pathA === '/*' && pathB !== '/*') return -1; + if (pathB === '/*' && pathA !== '/*') return 1; + + // More specific paths (with more segments) come later + const segmentsA = pathA.split('/').length; + const segmentsB = pathB.split('/').length; + + if (segmentsA !== segmentsB) { + return segmentsA - segmentsB; + } + + // If same number of segments, longer paths are more specific + return pathA.length - pathB.length; + }); + } + + private combinePaths(basePath: string, routePath: string): string { + const normalizedBase = basePath.replace(/\/+$/, '').replace(/^\/*/, '/'); + const normalizedRoute = routePath.replace(/^\/*/, ''); + return normalizedBase === '/' ? `/${normalizedRoute}` : `${normalizedBase}/${normalizedRoute}`; + } +} + +export default Router; + +if (import.meta.main) { + console.log('Starting server...'); + const router = new Router(); + router.route('/users') + .get((ctx) => { + return new Response('GET /users'); + }) + // .post((ctx) => { + // return new Response('POST /users'); + // }); + + // router.route('/users/:id') + // .get((ctx) => { + // return new Response(`GET /users/${ctx.params.id}`); + // }) + // .put((ctx) => { + // return new Response(`PUT /users/${ctx.params.id}`); + // }) + // .delete((ctx) => { + // return new Response(`DELETE /users/${ctx.params.id}`); + // }); + + // router.route('/posts') + // .get((ctx) => { + // return new Response('GET /posts'); + // }) + // .post((ctx) => { + // return new Response('POST /posts'); + // }); + + // router.route('/posts/:id') + // .get((ctx) => { + // return new Response(`GET /posts/${ctx.params.id}`); + // }) + // .put((ctx) => { + // return new Response(`PUT /posts/${ctx.params.id}`); + // }) + // .delete((ctx) => { + // return new Response(`DELETE /posts/${ctx.params.id}`); + // }); + + // router.route('/*') + // .get((ctx) => { + // return new Response('GET /*'); + // }) + // .post((ctx) => { + // return new Response('POST /*'); + // }); + + router.use('/users', async (_, next) => { + console.log('Using middleware'); + return await next(); + }); + + Deno.serve({ + port: 8000, + + handler: router.handle + }); +}