import fs from "fs"; import path from "path"; import Tpl from "./template.js"; import { Request } from "./index.js"; import { ServerResponse } from "http"; type RouteCallback = (req: Request, res: ServerResponse) => void; export default class Router { private routes: Map; private tpl?: Tpl; private mimes: Map; constructor() { this.routes = new Map(); this.mimes = new Map(); } /** * Dynamically imports routes from a directory and registers them. * @param p - Path to the directory containing route files. * @param tpl - Optional template instance to use with the routes. * @returns The Router instance for chaining. */ async importRoutesFromPath(p: string, tpl: Tpl | false = false): Promise { const dirEntries = await fs.promises.readdir(path.resolve() + '/' + p, { withFileTypes: true }); for(const tmp of dirEntries) { if(tmp.isFile() && (tmp.name.endsWith(".mjs") || tmp.name.endsWith(".js"))) { const routeModule = (await import(`${path.resolve()}/${p}/${tmp.name}`)).default; this.use(routeModule(this, tpl)); } else if(tmp.isDirectory()) { await this.importRoutesFromPath(p + '/' + tmp.name); } } return this; } /** * Groups multiple routes under a shared base path. * @param path - Base path for the route group. * @param cb - Callback to define the grouped routes. * @returns The Router instance for chaining. */ group(path: string, cb: (methods: any) => void): this { const methods: { [key: string]: (path: string | RegExp, cb: RouteCallback, middleware?: RouteCallback) => Router } = { get: this.get.bind(this), post: this.post.bind(this), head: this.head.bind(this), put: this.put.bind(this), delete: this.delete.bind(this), patch: this.patch.bind(this), }; const target = { path: new RegExp(path) }; const handler = { get: (opt: any, method: string) => (p: string, ...args: any[]) => methods[method]( new RegExp( [opt.path, new RegExp(p === "/" ? "$" : p)] .map((regex) => regex.source) .join("") .replace(/(\\\/){1,}/g, "/") ), ...(args as [RouteCallback, RouteCallback?]) ), }; cb(new Proxy(target, handler)); return this; } /** * Merges routes or assigns a template instance to the Router. * @param obj - An instance of Router or Tpl. */ use(obj: Router | Tpl): void { if(obj instanceof Router) { this.routes = new Map([...this.routes, ...obj.routes]); this.sortRoutes(); } if(obj instanceof Tpl) { this.tpl = obj; } } /** * Registers a GET route. * @param path - Route path or RegExp. * @param cb - Callback to handle the route. * @param middleware - Optional middleware function. * @returns The Router instance for chaining. */ get(path: string | RegExp, cb: RouteCallback, middleware?: RouteCallback): this { this.registerRoute(path, cb, "get", middleware); return this; } /** * Registers a POST route. * @param path - Route path or RegExp. * @param cb - Callback to handle the route. * @param middleware - Optional middleware function. * @returns The Router instance for chaining. */ post(path: string | RegExp, cb: RouteCallback, middleware?: RouteCallback): this { this.registerRoute(path, cb, "post", middleware); return this; } /** * Registers a HEAD route. * @param path - Route path or RegExp. * @param cb - Callback to handle the route. * @param middleware - Optional middleware function. * @returns The Router instance for chaining. */ head(path: string | RegExp, cb: RouteCallback, middleware?: RouteCallback): this { this.registerRoute(path, cb, "head", middleware); return this; } /** * Registers a PUT route. * @param path - Route path or RegExp. * @param cb - Callback to handle the route. * @param middleware - Optional middleware function. * @returns The Router instance for chaining. */ put(path: string | RegExp, cb: RouteCallback, middleware?: RouteCallback): this { this.registerRoute(path, cb, "put", middleware); return this; } /** * Registers a DELETE route. * @param path - Route path or RegExp. * @param cb - Callback to handle the route. * @param middleware - Optional middleware function. * @returns The Router instance for chaining. */ delete(path: string | RegExp, cb: RouteCallback, middleware?: RouteCallback): this { this.registerRoute(path, cb, "delete", middleware); return this; } /** * Registers a PATCH route. * @param path - Route path or RegExp. * @param cb - Callback to handle the route. * @param middleware - Optional middleware function. * @returns The Router instance for chaining. */ patch(path: string | RegExp, cb: RouteCallback, middleware?: RouteCallback): this { this.registerRoute(path, cb, "patch", middleware); return this; } /** * Registers a new route with an optional middleware. * @param path - The route path or RegExp. * @param cb - Callback to handle the route. * @param method - The HTTP method (e.g., "get", "post"). * @param middleware - Optional middleware function. * @returns The Router instance for chaining. */ private registerRoute( path: string | RegExp, cb: RouteCallback, method: string, middleware: RouteCallback | RouteCallback[] = [] ): this { const middlewareArray = Array.isArray(middleware) ? middleware : [middleware]; if(!this.routes.has(path)) { this.routes.set(path, {}); } this.routes.set(path, { ...this.routes.get(path), [method]: cb, [`${method}mw`]: middlewareArray, }); console.log("route set:", method.toUpperCase(), path); this.sortRoutes(); return this; } /** * Finds and returns the route matching the given path and method. * @param path - The requested path. * @param method - The HTTP method (e.g., "GET"). * @returns The matching route or undefined. */ getRoute(path: string, method: string): any { method = method.toLowerCase(); return [...this.routes.entries()].find(r => { return (typeof r[0] === "string" ? r[0] === path : r[0].exec?.(path)) && r[1][method]; }); } /** * Sorts the routes by their keys in reverse order. * @returns The Router instance for chaining. */ private sortRoutes(): this { this.routes = new Map([...this.routes.entries()].sort().reverse()); return this; } /** * Reads MIME types from a file and stores them in a map. * @param file - Path to the MIME types file. */ private readMimes(file: string = "/etc/mime.types"): void { fs.readFileSync(file, "utf-8") .split("\n") .filter(line => !line.startsWith("#") && line) .forEach(line => { const [mimeType, extensions] = line.split(/\s+/); extensions?.split(" ").forEach(ext => this.mimes.set(ext, mimeType)); }); } /** * Serves static files from a specified directory. * @param options - Options for serving static files. * @param options.dir - Directory containing the static files. * @param options.route - Regular expression to match the route for static files. * @returns The Router instance for chaining. */ static({ dir = path.resolve() + "/public", route = /^\/public/ }: { dir?: string; route?: RegExp }): this { if(!this.mimes.size) this.readMimes(); this.get(route, (req: any, res: any) => { try { const filename = req.url.pathname.replace(route, "") || "index.html"; const mime = this.mimes.get(filename.split(".").pop() || ""); const file = path.join(dir, filename); const stat = fs.statSync(file); if(req.headers.range) { const [startStr, endStr] = req.headers.range.replace(/bytes=/, "").split("-"); const start = parseInt(startStr, 10); const end = endStr ? parseInt(endStr, 10) : stat.size - 1; res.writeHead(206, { "Content-Range": `bytes ${start}-${end}/${stat.size}`, "Accept-Ranges": "bytes", "Content-Length": end - start + 1, "Content-Type": mime, }); fs.createReadStream(file, { start, end }).pipe(res); } else { res.writeHead(200, { "Content-Length": stat.size, "Content-Type": mime, }); fs.createReadStream(file).pipe(res); } } catch(err: any) { console.error(err); res.reply({ code: 404, body: "404 - File not found" }); } }); return this; } }