import type { Handler, Middleware, MiddlewareConfig, RouteConfig, RouteConfigurator, RouterContext, } from "./types.ts"; import { NotFound } from "./util/response.ts"; /** * A simple router for Deno * * @author Emmaline Autumn * * @example * ```ts * 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 * }); */ export class Router { private routes: RouteConfig[] = []; private middleware: MiddlewareConfig[] = []; route(path: string): RouteConfigurator { 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, ): 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; 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 }); } const matchingMiddleware = this.getMatchingMiddleware(url); const baseCtx: RouterContext = { url, params: {}, state: {}, pattern: matchedRoute.config.pattern, request: req, }; baseCtx.params = { ...baseCtx.params, ...matchedRoute.params, }; let index = 0; const executeMiddleware = async (): Promise => { if (index < matchingMiddleware.length) { const { middleware, params } = matchingMiddleware[index++]; const middlewareCtx: RouterContext = { ...baseCtx, params: { ...baseCtx.params, ...params }, pattern: middleware.pattern, }; return await middleware.handler(req, middlewareCtx, executeMiddleware); } return await handler(req, 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}`; } serveDirectory(dir: string, root: string, opts?: { showIndex: boolean }) { this.route(root + "*").get(async (_req, ctx) => { const { showIndex } = opts ?? { showIndex: false }; let normalizedPath = (dir + "/" + ctx.url.pathname.replace(new RegExp("^" + root), "")).trim().replace( "//", "/", ); normalizedPath = normalizedPath.replace( /\/\s?$/, "", ); let fileInfo: Deno.FileInfo; try { fileInfo = await Deno.stat(normalizedPath); } catch (error) { if (error instanceof Deno.errors.NotFound) { return NotFound(); } else { throw error; } } if (fileInfo.isDirectory) { if (!showIndex) return NotFound(); normalizedPath += "/index.html"; } try { const file = await Deno.readFile(normalizedPath); return new Response(file); } catch (e) { if (e instanceof Deno.errors.NotFound) { return showIndex ? generateIndex(normalizedPath) : NotFound(); } throw e; } }); } } async function generateIndex(dir: string) { dir = dir.replace(/index\.html$/, ""); const items: Deno.DirEntry[] = []; for await (const entry of Deno.readDir(dir)) { items.push(entry); } const fileIcon = ` `; const folderIcon = ` `; const template = ` ${dir}

${dir}

    ${ items .sort((a, b) => { if (a.isDirectory && b.isDirectory) return a.name > b.name ? 1 : -1; if (a.isDirectory) return -1; if (b.isDirectory) return 1; return a.name > b.name ? 1 : -1; }) .map((e) => `
  • ${e.isFile ? fileIcon : folderIcon}${e.name}
  • ` ) .join("\n") || "
  • Directory is empty
  • " }
`; return new Response(template, { headers: { "Content-Type": "text/html", }, }); } 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 (_req, _ctx, next) => { console.log("Using middleware"); return await next(); }); Deno.serve({ port: 8000, handler: router.handle, }); }