flummpress/src/router.ts

273 lines
8.6 KiB
TypeScript

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<RegExp | string, { [key: string]: RouteCallback | RouteCallback[] }>;
private tpl?: Tpl;
private mimes: Map<string, string>;
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<this> {
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;
}
}