273 lines
8.6 KiB
TypeScript
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;
|
|
}
|
|
}
|