diff --git a/src/index.ts b/src/index.ts index 1023f32..8880d5f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,47 +2,74 @@ import http, { IncomingMessage, ServerResponse } from "node:http"; import { URL } from "node:url"; import querystring from "node:querystring"; -import Router from "./router"; -import Tpl from "./template"; +import Router from "./router.js"; +import Tpl from "./template.js"; -import { Request, Response, Middleware } from "./types"; +import { Request, Response, Handler } from "./types.js"; export { Router, Tpl }; export default class Flummpress { private server?: http.Server; - private errorHandler: Middleware; router: Router; tpl: Tpl; - middleware: Middleware[]; + middleware: Handler[]; constructor() { this.router = new Router(); this.tpl = new Tpl(); this.middleware = []; - this.errorHandler = async (req: Request, res: Response) => { - res.writeHead(500).end("500 - Internal Server Error"); - }; } /** - * Adds middleware, routes, or template systems to the server. - * @param {any} obj - An instance of Router, Tpl, or a middleware function. - * @returns {this} - The current instance for chaining. + * Adds a plugin to the application, which can be a Router instance, Tpl instance, + * or a middleware handler function. The method determines the type of the plugin + * and performs the appropriate action. + * + * - If the plugin is an instance of `Router`, it is added to the application's router. + * - If the plugin is an instance of `Tpl`, it sets the application's template engine. + * - If the plugin is a middleware function, it is added to the middleware stack. + * + * @param {Router | Tpl | Handler} plugin - The plugin to add, which can be a `Router` instance, + * a `Tpl` instance, or a middleware handler function. + * @returns {this} The current instance for method chaining. */ - use(obj: Router | Tpl | Middleware): this { - if(obj instanceof Router) - this.router.use(obj); - else if(obj instanceof Tpl) - this.tpl = obj; - else - this.middleware.push(obj); + use(plugin: Router | Tpl | Handler): this { + if(plugin instanceof Router) + this.router.use(plugin); + else if(plugin instanceof Tpl) + this.tpl = plugin; + else if(typeof plugin === "function") + this.middleware.push(plugin); return this; } - setErrorHandler(handler: Middleware): this { - this.errorHandler = handler; - return this; + /** + * Processes a series of handlers in a pipeline by invoking each handler asynchronously + * in sequence with the provided request and response objects. + * + * Each handler is responsible for calling the `next` function to signal that + * the pipeline should continue to the next handler. If `next` is not called + * by a handler or the response is ended, the pipeline execution stops. + * + * @private + * @param {Handler[]} handlers - An array of handler functions to process. Each function + * should have the signature `(req: Request, res: Response, next: Function) => Promise`. + * @param {Request} req - The HTTP request object. + * @param {Response} res - The HTTP response object. + * @throws {TypeError} If any handler in the array is not a function. + * @returns {Promise} Resolves when all handlers have been executed or the pipeline is terminated early. + */ + private async processPipeline(handlers: Handler[], req: Request, res: Response) { + for(const handler of handlers) { + if(typeof handler !== "function") + throw new TypeError(`Handler is not a function: ${handler}`); + + let nextCalled = false; + await handler(req, res, () => nextCalled = true); + if(!nextCalled || res.writableEnded) + return; + } } /** @@ -51,57 +78,55 @@ export default class Flummpress { * @returns {this} - The current instance for chaining. */ listen(...args: any[]): this { - this.server = http - .createServer(async (request: IncomingMessage, response: ServerResponse) => { - const req: Request = this.parseRequest(request); - const res: Response = this.createResponse(response); - const start = process.hrtime(); - - try { - for(const mw of this.middleware) { - await mw(req, res, () => {}); - } - - const route = this.router.getRoute(req.url.pathname, req.method!); - - if(route) { - const [pathPattern, methods] = route; - const method = req.method?.toLowerCase(); - const handler = methods[method!]; - const middleware = methods[`${method}mw`]; - - for(const mw of middleware) { - await mw(req, res, () => {}); - } - - if(handler) { - req.params = req.url.pathname.match(new RegExp(pathPattern))?.groups || {}; - req.post = await this.readBody(req); - handler(req, res); - } - else { - res.writeHead(405).end("405 - Method Not Allowed"); - } - } - else { - res.writeHead(404).end("404 - Not Found"); - } + this.server = http.createServer(async (request: IncomingMessage, response: ServerResponse) => { + const req: Request = this.parseRequest(request); + const res: Response = this.createResponse(response); + const start = process.hrtime(); + + try { + await this.processPipeline(this.middleware, req, res); + + const route = this.router.getRoute(req.url.pathname, req.method!); + + if(route) { + const [pathPattern, methods] = route; + const handler = methods[req.method?.toLowerCase()!]; + + req.params = req.url.pathname.match(new RegExp(pathPattern))?.groups || {}; + req.post = await this.readBody(req); + await this.processPipeline(handler, req, res); } - catch(err: any) { - this.errorHandler(req, res, () => {}); + else { + res.writeHead(404).end("404 - Not Found"); } - - console.log([ - `[${new Date().toISOString()}]`, - `${(process.hrtime(start)[1] / 1e6).toFixed(2)}ms`, - `${req.method} ${res.statusCode}`, - req.url.pathname, - ].join(" | ")); - }).listen(...args); - + } + catch(err: any) { + console.error(err); + res.writeHead(500).end("500 - Internal Server Error"); + } + + console.log([ + `[${new Date().toISOString()}]`, + `${(process.hrtime(start)[1] / 1e6).toFixed(2)}ms`, + `${req.method} ${res.statusCode}`, + req.url.pathname, + ].join(" | ")); + }).listen(...args); + return this; } + /** + * Parses an incoming HTTP request and converts it into a custom Request object. + * + * This method extracts information from the incoming request, such as the URL, + * query string parameters, and cookies, and structures them in the returned Request object. + * Any malformed or extra trailing slashes in the URL are sanitized. + * + * @private + * @param {IncomingMessage} request - The incoming HTTP request to parse. + * @returns {Request} A structured Request object with parsed properties such as `url` and `cookies`. + */ private parseRequest(request: IncomingMessage): Request { const url = new URL(request.url!.replace(/(?!^.)(\/+)?$/, ""), "http://localhost"); const req = request as unknown as Request; @@ -125,7 +150,7 @@ export default class Flummpress { /** * Reads and parses the body of an incoming HTTP request. * @param {Request} req - The HTTP request object. - * @returns {Promise} - A promise that resolves to the parsed body data. + * @returns {Promise>} - A promise that resolves to the parsed body data. */ private async readBody(req: Request): Promise> { return new Promise((resolve, reject) => { @@ -152,6 +177,11 @@ export default class Flummpress { }); } + /** + * Creates a custom Response object with additional utility methods. + * @param {ServerResponse} response - The original HTTP response object. + * @returns {Response} - A structured Response object with utility methods such as `json` and `html`. + */ private createResponse(response: ServerResponse): Response { const res: Response = response as Response; diff --git a/src/router.ts b/src/router.ts index 4dcebc1..5abbe16 100644 --- a/src/router.ts +++ b/src/router.ts @@ -1,11 +1,11 @@ import fs from "node:fs"; import path from "node:path"; -import Tpl from "./template"; +import Tpl from "./template.js"; -import { Request, Middleware, RouteCallback } from "./types"; +import { Request, Response, Handler } from "./types.js"; export default class Router { - private routes: Map; + private routes: Map; private tpl?: Tpl; private mimes: Map; @@ -35,36 +35,60 @@ export default class Router { } /** - * Groups multiple routes under a shared base path. - * @param path - Base path for the route group. - * @param cb - Callback to define the grouped routes. + * 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(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), + 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); + } }; - 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)); + + 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. @@ -81,104 +105,87 @@ export default class Router { } /** - * 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. + * Registers a route for HTTP GET requests. + * @param {string | RegExp} path - The URL path or pattern for the route. + * @param {Handler[]} cb - An array of middleware or handler functions to execute for this route. + * @returns {this} The current instance for method chaining. */ - get(path: string | RegExp, cb: RouteCallback, middleware?: RouteCallback): this { - this.registerRoute(path, cb, "get", middleware); + get(path: string | RegExp, cb: Handler[]): this { + this.registerRoute(path, "get", cb); 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. + * Registers a route for HTTP POST requests. + * @param {string | RegExp} path - The URL path or pattern for the route. + * @param {Handler[]} cb - An array of middleware or handler functions to execute for this route. + * @returns {this} The current instance for method chaining. */ - post(path: string | RegExp, cb: RouteCallback, middleware?: RouteCallback): this { - this.registerRoute(path, cb, "post", middleware); + post(path: string | RegExp, cb: Handler[]): this { + this.registerRoute(path, "post", cb); 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. + * Registers a route for HTTP HEAD requests. + * @param {string | RegExp} path - The URL path or pattern for the route. + * @param {Handler[]} cb - An array of middleware or handler functions to execute for this route. + * @returns {this} The current instance for method chaining. */ - head(path: string | RegExp, cb: RouteCallback, middleware?: RouteCallback): this { - this.registerRoute(path, cb, "head", middleware); + head(path: string | RegExp, cb: Handler[]): this { + this.registerRoute(path, "head", cb); 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. + * Registers a route for HTTP PUT requests. + * @param {string | RegExp} path - The URL path or pattern for the route. + * @param {Handler[]} cb - An array of middleware or handler functions to execute for this route. + * @returns {this} The current instance for method chaining. */ - put(path: string | RegExp, cb: RouteCallback, middleware?: RouteCallback): this { - this.registerRoute(path, cb, "put", middleware); + put(path: string | RegExp, cb: Handler[]): this { + this.registerRoute(path, "put", cb); 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. + * Registers a route for HTTP DELETE requests. + * @param {string | RegExp} path - The URL path or pattern for the route. + * @param {Handler[]} cb - An array of middleware or handler functions to execute for this route. + * @returns {this} The current instance for method chaining. */ - delete(path: string | RegExp, cb: RouteCallback, middleware?: RouteCallback): this { - this.registerRoute(path, cb, "delete", middleware); + delete(path: string | RegExp, cb: Handler[]): this { + this.registerRoute(path, "delete", cb); 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. + * Registers a route for HTTP PATCH requests. + * @param {string | RegExp} path - The URL path or pattern for the route. + * @param {Handler[]} cb - An array of middleware or handler functions to execute for this route. + * @returns {this} The current instance for method chaining. */ - patch(path: string | RegExp, cb: RouteCallback, middleware?: RouteCallback): this { - this.registerRoute(path, cb, "patch", middleware); + patch(path: string | RegExp, cb: Handler[]): this { + this.registerRoute(path, "patch", cb); 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. + * Registers a route for HTTP OPTIONS requests. + * @param {string | RegExp} path - The URL path or pattern for the route. + * @param {Handler[]} cb - An array of middleware or handler functions to execute + * @returns {this} The current instance for method chaining. */ private registerRoute( path: string | RegExp, - cb: RouteCallback, method: string, - middleware: Middleware | Middleware[] = [] + handlers: Handler[] ): this { - if(!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`]: middleware as RouteCallback[], - }); + this.routes.get(path)![method] = handlers.flat(); console.log("route set:", method.toUpperCase(), path); this.sortRoutes(); @@ -228,11 +235,14 @@ export default class Router { * @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 { + static({ + dir = path.resolve() + "/public", + route = /^\/public/ + }: { dir?: string; route?: RegExp }): this { if(!this.mimes.size) this.readMimes(); - this.get(route, (req: Request, res: any) => { + this.get(route, [(req: Request, res: Response) => { try { const filename = req.url.pathname.replace(route, "") || "index.html"; const mime = this.mimes.get(filename.split(".").pop() || ""); @@ -264,7 +274,7 @@ export default class Router { console.error(err); res.reply({ code: 404, body: "404 - File not found" }); } - }); + }]); return this; } diff --git a/src/types.d.ts b/src/types.d.ts index d13c17e..38baae7 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -19,5 +19,4 @@ export interface Response extends ServerResponse { redirect: (target: string, code?: number) => void; } -export type Middleware = (req: Request, res: Response, next: () => void) => void; -export type RouteCallback = (req: Request, res: Response) => void; +export type Handler = (req: Request, res: Response, next?: () => void) => void | Promise;