336 lines
12 KiB
TypeScript
336 lines
12 KiB
TypeScript
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<IncomingMessage, 'url'> {
|
|
url: {
|
|
pathname: string;
|
|
split: string[];
|
|
searchParams: URLSearchParams;
|
|
qs: Record<string, string>;
|
|
};
|
|
cookies?: Record<string, string>;
|
|
params?: Record<string, string>;
|
|
post?: Record<string, string>;
|
|
}
|
|
|
|
export interface Response extends ServerResponse {
|
|
reply: (options: { code?: number; type?: string; body: string }) => void;
|
|
status: (code: number) => Response;
|
|
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<void>;
|
|
|
|
export default class Router {
|
|
private routes: Array<{ path: string | RegExp; methods: { [method: string]: Handler[] } }> = [];
|
|
private tpl?: Tpl;
|
|
private mimes: Map<string, string>;
|
|
|
|
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<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;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|