flummpress/src/router.ts

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;
}
}