208 lines
		
	
	
		
			6.9 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			208 lines
		
	
	
		
			6.9 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
import http, { IncomingMessage, ServerResponse } from "node:http";
 | 
						|
import { URL } from "node:url";
 | 
						|
import querystring from "node:querystring";
 | 
						|
 | 
						|
import Router from "./router.js";
 | 
						|
import Tpl from "./template.js";
 | 
						|
 | 
						|
import { Request, Response, Handler } from "./types.js";
 | 
						|
 | 
						|
export { Router, Tpl };
 | 
						|
 | 
						|
export default class Flummpress {
 | 
						|
  private server?: http.Server;
 | 
						|
  router: Router;
 | 
						|
  tpl: Tpl;
 | 
						|
  middleware: Handler[];
 | 
						|
 | 
						|
  constructor() {
 | 
						|
    this.router = new Router();
 | 
						|
    this.tpl = new Tpl();
 | 
						|
    this.middleware = [];
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * 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(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;
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * 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<void>`.
 | 
						|
   * @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<void>} 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;
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Starts the HTTP server and begins listening for incoming requests.
 | 
						|
   * @param {...any} args - Arguments passed to `http.Server.listen`.
 | 
						|
   * @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 {
 | 
						|
        await this.processPipeline(this.middleware, req, res);
 | 
						|
  
 | 
						|
        const route = this.router.getRoute(req.url.pathname, req.method!);
 | 
						|
  
 | 
						|
        if(route) {
 | 
						|
          const handler = route.methods[req.method?.toLowerCase()!];
 | 
						|
  
 | 
						|
          req.params = req.url.pathname.match(new RegExp(route.path))?.groups || {};
 | 
						|
          req.post = await this.readBody(req);
 | 
						|
          await this.processPipeline(handler, req, res);
 | 
						|
        }
 | 
						|
        else {
 | 
						|
          res.writeHead(404).end("404 - Not Found");
 | 
						|
        }
 | 
						|
      }
 | 
						|
      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;
 | 
						|
    req.url = {
 | 
						|
      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);
 | 
						|
      });
 | 
						|
    }
 | 
						|
    return req;
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Reads and parses the body of an incoming HTTP request.
 | 
						|
   * @param {Request} req - The HTTP request object.
 | 
						|
   * @returns {Promise<Record<string, string>>} - A promise that resolves to the parsed body data.
 | 
						|
   */
 | 
						|
  private async readBody(req: Request): Promise<Record<string, string>> {
 | 
						|
    return new Promise((resolve, reject) => {
 | 
						|
      let body: string = "";
 | 
						|
 | 
						|
      req.on("data", (chunk: string) => {
 | 
						|
        body += chunk;
 | 
						|
      });
 | 
						|
 | 
						|
      req.on("end", () => {
 | 
						|
        try {
 | 
						|
          resolve(
 | 
						|
            req.headers["content-type"] === "application/json"
 | 
						|
              ? JSON.parse(body)
 | 
						|
              : querystring.parse(body)
 | 
						|
          );
 | 
						|
        }
 | 
						|
        catch(err: any) {
 | 
						|
          reject(err);
 | 
						|
        }
 | 
						|
      });
 | 
						|
 | 
						|
      req.on("error", reject);
 | 
						|
    });
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * 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;
 | 
						|
 | 
						|
    res.reply = ({ code = 200, type = "text/html", body }) => {
 | 
						|
      res.writeHead(code, { "Content-Type": `${type}; charset=utf-8` });
 | 
						|
      res.end(body);
 | 
						|
    };
 | 
						|
 | 
						|
    res.json = (body, code = 200) => {
 | 
						|
      res.reply({ code, type: "application/json", body: JSON.stringify(body) });
 | 
						|
    };
 | 
						|
 | 
						|
    res.html = (body, code = 200) => {
 | 
						|
      res.reply({ code, type: "text/html", body });
 | 
						|
    };
 | 
						|
 | 
						|
    res.redirect = (target, code = 302) => {
 | 
						|
      res.writeHead(code, { Location: target });
 | 
						|
      res.end();
 | 
						|
    };
 | 
						|
 | 
						|
    return res;
 | 
						|
  }
 | 
						|
}
 |