import fs from "node:fs"; import path from "node:path"; import { IncomingMessage, ServerResponse } from "node:http"; import Tpl from "./template.js"; export interface Request extends Omit { url: { pathname: string; split: string[]; searchParams: URLSearchParams; qs: Record; }; cookies?: Record; params?: Record; post?: Record; } export interface Response extends ServerResponse { reply: (options: { code?: number; type?: string; body: string }) => void; json: (body: string, code?: number) => void; html: (body: string, code?: number) => void; redirect: (target: string, code?: number) => void; } export type Handler = (req: Request, res: Response, next?: () => void) => void | Promise; export default class Router { private routes: Array<{ path: string | RegExp; methods: { [method: string]: Handler[] } }> = []; private tpl?: Tpl; private mimes: Map; constructor() { 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; } /** * Registers a new route group with common base path and middleware. * @param basePath - The base path or RegExp. * @param callback - Callback to define routes within the group. * @returns The Router instance for chaining. */ group(basePath: string | RegExp, callback: (methods: any) => void): this { const self = this; const methods = { get(path: string | RegExp, ...handlers: Handler[]) { const fullPath = self.combinePaths(basePath, path); return self.registerRoute(fullPath, "get", handlers); }, post(path: string | RegExp, ...handlers: Handler[]) { const fullPath = self.combinePaths(basePath, path); return self.registerRoute(fullPath, "post", handlers); }, put(path: string | RegExp, ...handlers: Handler[]) { const fullPath = self.combinePaths(basePath, path); return self.registerRoute(fullPath, "put", handlers); }, delete(path: string | RegExp, ...handlers: Handler[]) { const fullPath = self.combinePaths(basePath, path); return self.registerRoute(fullPath, "delete", handlers); }, patch(path: string | RegExp, ...handlers: Handler[]) { const fullPath = self.combinePaths(basePath, path); return self.registerRoute(fullPath, "patch", handlers); } }; callback(methods); return this; } /** * Combines a base path and a sub path into a single path. * @param basePath - The base path or RegExp. * @param subPath - The sub path or RegExp. * @returns The combined path as a string or RegExp. */ private combinePaths(basePath: string | RegExp, subPath: string | RegExp): string | RegExp { if(typeof basePath === "string" && typeof subPath === "string") return `${basePath.replace(/\/$/, "")}/${subPath.replace(/^\//, "")}`; if(basePath instanceof RegExp && typeof subPath === "string") return new RegExp(`${basePath.source}${subPath.replace(/^\//, "")}`); if(typeof basePath === "string" && subPath instanceof RegExp) return new RegExp(`${basePath.replace(/\/$/, "")}${subPath.source}`); if(basePath instanceof RegExp && subPath instanceof RegExp) return new RegExp(`${basePath.source}${subPath.source}`); throw new TypeError("Invalid path types. Both basePath and subPath must be either string or RegExp."); } /** * 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) { if(!Array.isArray(obj.routes)) throw new TypeError("Routes must be an array."); this.routes = [ ...this.routes, ...obj.routes ]; this.sortRoutes(); } if(obj instanceof Tpl) { this.tpl = obj; } } /** * Registers a route for HTTP GET requests. * @param {string | RegExp} path - The URL path or pattern for the route. * @param {Handler[]} callback - An array of middleware or handler functions to execute for this route. * @returns {this} The current instance for method chaining. */ get(path: string | RegExp, ...callback: Handler[]): this { this.registerRoute(path, "get", callback); return this; } /** * Registers a route for HTTP POST requests. * @param {string | RegExp} path - The URL path or pattern for the route. * @param {Handler[]} callback - An array of middleware or handler functions to execute for this route. * @returns {this} The current instance for method chaining. */ post(path: string | RegExp, ...callback: Handler[]): this { this.registerRoute(path, "post", callback); return this; } /** * Registers a route for HTTP HEAD requests. * @param {string | RegExp} path - The URL path or pattern for the route. * @param {Handler[]} callback - An array of middleware or handler functions to execute for this route. * @returns {this} The current instance for method chaining. */ head(path: string | RegExp, ...callback: Handler[]): this { this.registerRoute(path, "head", callback); return this; } /** * Registers a route for HTTP PUT requests. * @param {string | RegExp} path - The URL path or pattern for the route. * @param {Handler[]} callback - An array of middleware or handler functions to execute for this route. * @returns {this} The current instance for method chaining. */ put(path: string | RegExp, ...callback: Handler[]): this { this.registerRoute(path, "put", callback); return this; } /** * Registers a route for HTTP DELETE requests. * @param {string | RegExp} path - The URL path or pattern for the route. * @param {Handler[]} callback - An array of middleware or handler functions to execute for this route. * @returns {this} The current instance for method chaining. */ delete(path: string | RegExp, ...callback: Handler[]): this { this.registerRoute(path, "delete", callback); return this; } /** * Registers a route for HTTP PATCH requests. * @param {string | RegExp} path - The URL path or pattern for the route. * @param {Handler[]} callback - An array of middleware or handler functions to execute for this route. * @returns {this} The current instance for method chaining. */ patch(path: string | RegExp, ...callback: Handler[]): this { this.registerRoute(path, "patch", callback); return this; } /** * Registers a route with a specified path, HTTP method, and handler(s). * If the route already exists, the provided handler(s) will be appended * to the existing method's handlers. * * @private * @param {string|RegExp} path - The path of the route, which can be a string or a RegExp. * @param {string} method - The HTTP method for the route (e.g., "GET", "POST"). * @param {Handler[]} handler - An array of handler functions to be associated with the route and method. * @returns {this} Returns the current instance to allow method chaining. */ private registerRoute( path: string | RegExp, method: string, handler: Handler[] ): this { const route = this.routes.find(route => typeof route.path === "string" ? route.path === path : route.path instanceof RegExp && path instanceof RegExp && route.path.toString() === path.toString() ); if(route) { route.methods[method] = [ ...(route.methods[method] || []), ...handler ]; } else { this.routes.push({ path, methods: { [method]: handler } }); } 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 { return this.routes .find(r => typeof r.path === "string" ? r.path === path : r.path.exec?.(path) && r.methods[method.toLowerCase()] ); } /** * Sorts the routes by their keys in reverse order. * @returns The Router instance for chaining. */ private sortRoutes(): this { this.routes.sort((a, b) => { const aLength = typeof a.path === "string" ? a.path.length : a.path.source.length; const bLength = typeof b.path === "string" ? b.path.length : b.path.source.length; if(typeof a.path === "string" && typeof b.path === "string") return bLength - aLength; if(typeof a.path === "string") return -1; if(typeof b.path === "string") return 1; return bLength - aLength; }); 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: Request, res: Response, next?: () => void) => { 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; } }