diff --git a/src/index.ts b/src/index.ts index 7d39b48..c37e740 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,9 +6,9 @@ import querystring from "node:querystring"; import Router from "./router.js"; import Tpl from "./template.js"; -export { Router, Tpl, Request }; +export { Router, Tpl, Request, Response, Middleware }; -type Middleware = (req: IncomingMessage, res: ServerResponse, next: () => void) => void; +type Middleware = (req: Request, res: Response, next: () => void) => void; interface Request extends IncomingMessage { parsedUrl: { @@ -22,16 +22,27 @@ interface Request extends IncomingMessage { post?: Record; }; +interface Response extends ServerResponse { + reply: (options: { code?: number; type?: string; body: string }) => void; + json: (body: string, code?: number) => void; + html: (body: string, code?: number) => void; + redirect: (target: string, code?: number) => void; +} + export default class Flummpress { private server?: http.Server; + private errorHandler: Middleware; router: Router; tpl: Tpl; - middleware: Set; + middleware: Middleware[]; constructor() { this.router = new Router(); this.tpl = new Tpl(); - this.middleware = new Set(); + this.middleware = []; + this.errorHandler = async (req: Request, res: Response) => { + res.writeHead(500).end("500 - Internal Server Error"); + }; } /** @@ -39,15 +50,18 @@ export default class Flummpress { * @param {any} obj - An instance of Router, Tpl, or a middleware function. * @returns {this} - The current instance for chaining. */ - use(obj: Router | Tpl): this { + use(obj: Router | Tpl | Middleware): this { if(obj instanceof Router) this.router.use(obj); else if(obj instanceof Tpl) this.tpl = obj; - else { - if(!this.middleware.has(obj)) - this.middleware.add(obj); - } + else + this.middleware.push(obj); + return this; + } + + setErrorHandler(handler: Middleware): this { + this.errorHandler = handler; return this; } @@ -57,169 +71,132 @@ export default class Flummpress { * @returns {this} - The current instance for chaining. */ listen(...args: any[]): this { - this.server = http.createServer(async (request: IncomingMessage, res: ServerResponse) => { - const req = request as Request; - const t_start = process.hrtime(); + this.server = http + .createServer(async (request: IncomingMessage, response: ServerResponse) => { + const req = request as Request; + const res = this.createResponse(req, response); + const start = process.hrtime(); - // URL and query parsing - const _url = new URL(req.url!.replace(/(?!^.)(\/+)?$/, ''), "http://localhost"); - req.parsedUrl = { - pathname: _url.pathname, - split: _url.pathname.split("/").slice(1), - searchParams: _url.searchParams, - qs: Object.fromEntries(Object.entries(querystring.parse(_url.search.substring(1))).filter(([_, v]) => typeof v === 'string')) as Record, - }; + try { + this.parseRequest(req); - // extract cookies - req['cookies'] = {}; - if(req.headers.cookie) { - req.headers.cookie.split("; ").forEach((c: string) => { - const parts = c.split('='); - req.cookies[parts.shift()?.trim() ?? ""] = decodeURI(parts.join('=')); - }); - } + for(const mw of this.middleware) { + await mw(req, res, () => {}); + } - // run middleware - await Promise.all([...this.middleware].map(m => m(req, res, () => {}))); + const route = this.router.getRoute(req.parsedUrl.pathname, req.method!); - // route and method handling - const method = req.method === 'HEAD' ? 'get' : req.method?.toLowerCase(); - const route = this.router.getRoute(req.parsedUrl.pathname, req.method === 'HEAD' ? 'GET' : req.method!); + if(route) { + const [pathPattern, methods] = route; + const method = req.method?.toLowerCase(); + const handler = methods[method!]; + const middleware = methods[`${method}mw`]; - if(route) { - // route found - const cb = route[1][method!]; - const middleware = route[1][`${method}mw`]; + if(middleware) { + for(const mw of middleware) { + await mw(req, res, () => {}); + } + } - req['params'] = req.parsedUrl.pathname.match(new RegExp(route[0]))?.groups; - req['post'] = await this.readBody(req); + if(handler) { + req.params = req.parsedUrl.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"); + } + } + catch(err: any) { + this.errorHandler(req, res, () => {}); + } - const result = await this.processMiddleware(middleware, req, this.createResponse(req, res)); - if(result) - cb(req, res); - } - else { - // route not found - res.writeHead(404).end(`404 - ${req.method} ${req.parsedUrl.pathname}`); - } - - console.log([ - `[${new Date().toLocaleTimeString()}]`, - `${(process.hrtime(t_start)[1] / 1e6).toFixed(2)}ms`, - `${req.method} ${res.statusCode}`, - req.parsedUrl.pathname - ].map(e => e.toString().padEnd(15)).join("")); - }).listen(...args); + console.log([ + `[${new Date().toISOString()}]`, + `${(process.hrtime(start)[1] / 1e6).toFixed(2)}ms`, + `${req.method} ${res.statusCode}`, + req.parsedUrl.pathname, + ].join(" | ")); + }).listen(...args); return this; } + private parseRequest(req: Request): void { + const url = new URL(req.url!.replace(/(?!^.)(\/+)?$/, ""), "http://localhost"); + req.parsedUrl = { + pathname: url.pathname, + split: url.pathname.split("/").slice(1), + searchParams: url.searchParams, + qs: Object.fromEntries(url.searchParams.entries()), + }; + req.cookies = {}; + if(req.headers.cookie) { + req.headers.cookie.split("; ").forEach((cookie) => { + const [key, value] = cookie.split("="); + req.cookies[key] = decodeURIComponent(value); + }); + } + } + /** * Reads and parses the body of an incoming HTTP request. * @param {IncomingMessage} req - The HTTP request object. * @returns {Promise} - A promise that resolves to the parsed body data. */ - readBody(req: IncomingMessage): Promise> { + private async readBody(req: IncomingMessage): Promise> { return new Promise((resolve, reject) => { - let data = ""; + let body = ""; - req.on("data", (chunk: string) => data += chunk); + req.on("data", (chunk) => { + body += chunk; + }); req.on("end", () => { - if(req.headers["content-type"] === "application/json") { - try { - resolve(JSON.parse(data) as Record); - } - catch(err: any) { - reject(err); - } - } - else { - const parsedData = querystring.parse(data); - const decodedData = Object.fromEntries( - Object.entries(parsedData).map(([key, value]) => { - try { - return [key, decodeURIComponent(value as string)]; - } - catch(err: any) { - return [key, value]; - } - }) + try { + resolve( + req.headers["content-type"] === "application/json" + ? JSON.parse(body) + : querystring.parse(body) ); - resolve(decodedData as Record); + } + catch(err: any) { + reject(err); } }); - req.on("error", (err: Error) => reject(err)); + req.on("error", reject); }); } - /** - * Enhances the HTTP response object with additional methods for convenience. - * @param {Request} req - The HTTP request object. - * @param {ServerResponse} res - The HTTP response object. - * @returns {ServerResponse & { - * reply: Function, json: Function, html: Function, redirect: Function - * }} - The enhanced response object. - */ - createResponse(req: Request, res: ServerResponse): ServerResponse & { - reply: (options: { code?: number; type?: string; body: string }) => ServerResponse; - json: (body: string, code?: number) => ServerResponse; - html: (body: string, code?: number) => ServerResponse; - redirect: (target: string, code?: number) => void; - } { - const enhancedRes = Object.assign(res, { - reply: ({ code = 200, type = "text/html", body }: { code?: number; type?: string; body: string }) => { - res.writeHead(code, { - "content-type": `${type}; charset=UTF-8`, - "content-length": Buffer.byteLength(body, "utf-8"), - }); - if(req.method === "HEAD") - body = null as unknown as string; - res.end(body); - return res; - }, - json: (body: string, code = 200) => { - if(typeof body === "object") - body = JSON.stringify(body); - return enhancedRes.reply({ code, body, type: "application/json" }); - }, - html: (body: string, code = 200) => enhancedRes.reply({ code, body, type: "text/html" }), - redirect: (target: string, code = 307) => { - res.writeHead(code, { - "Cache-Control": "no-cache, public", - "Location": target, - }); - res.end(); - } - }); + private createResponse( + req: Request, + response: ServerResponse + ): Response { + const res = response as Response; - return enhancedRes; - } + res.reply = ({ code = 200, type = "text/html", body }) => { + res.writeHead(code, { "Content-Type": `${type}; charset=utf-8` }); + res.end(body); + }; - /** - * Processes middleware functions for an incoming request. - * @param {Middleware} middleware - The middleware function to process. - * @param {IncomingMessage} req - The HTTP request object. - * @param {ServerResponse} res - The HTTP response object. - * @returns {Promise} - Resolves to true if middleware processing is successful. - */ - private async processMiddleware( - middleware: Middleware[], - req: IncomingMessage, - res: ServerResponse - ): Promise { - if(!middleware || middleware.length === 0) - return true; + res.json = (body, code = 200) => { + res.reply({ code, type: "application/json", body: JSON.stringify(body) }); + }; - for(const fn of middleware) { - const proceed = await new Promise(resolve => { - fn(req, res, () => resolve(true)); - }); + res.html = (body, code = 200) => { + res.reply({ code, type: "text/html", body }); + }; - if(!proceed) - return false; - } - return true; + res.redirect = (target, code = 302) => { + res.writeHead(code, { Location: target }); + res.end(); + }; + + return res; } }