226 lines
7.2 KiB
TypeScript
226 lines
7.2 KiB
TypeScript
import http, { IncomingMessage, ServerResponse } from "node:http";
|
|
import { URL } from "node:url";
|
|
import { Buffer } from "node:buffer";
|
|
import querystring from "node:querystring";
|
|
|
|
import Router from "./router.js";
|
|
import Tpl from "./template.js";
|
|
|
|
export { Router, Tpl, Request };
|
|
|
|
type Middleware = (req: IncomingMessage, res: ServerResponse, next: () => void) => void;
|
|
|
|
interface Request extends IncomingMessage {
|
|
parsedUrl: {
|
|
pathname: string;
|
|
split: string[];
|
|
searchParams: URLSearchParams;
|
|
qs: Record<string, string>;
|
|
};
|
|
cookies: Record<string, string>;
|
|
params?: Record<string, string>;
|
|
post?: Record<string, string>;
|
|
};
|
|
|
|
export default class Flummpress {
|
|
private server?: http.Server;
|
|
router: Router;
|
|
tpl: Tpl;
|
|
middleware: Set<Middleware>;
|
|
|
|
constructor() {
|
|
this.router = new Router();
|
|
this.tpl = new Tpl();
|
|
this.middleware = new Set();
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
use(obj: Router | Tpl): 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);
|
|
}
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* 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, res: ServerResponse) => {
|
|
const req = request as Request;
|
|
const t_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<string, string>,
|
|
};
|
|
|
|
// 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('='));
|
|
});
|
|
}
|
|
|
|
// run middleware
|
|
await Promise.all([...this.middleware].map(m => m(req, res, () => {})));
|
|
|
|
// 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) {
|
|
// route found
|
|
const cb = route[1][method!];
|
|
const middleware = route[1][`${method}mw`];
|
|
|
|
req['params'] = req.parsedUrl.pathname.match(new RegExp(route[0]))?.groups;
|
|
req['post'] = await this.readBody(req);
|
|
|
|
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);
|
|
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Reads and parses the body of an incoming HTTP request.
|
|
* @param {IncomingMessage} req - The HTTP request object.
|
|
* @returns {Promise<any>} - A promise that resolves to the parsed body data.
|
|
*/
|
|
readBody(req: IncomingMessage): Promise<Record<string, string>> {
|
|
return new Promise((resolve, reject) => {
|
|
let data = "";
|
|
|
|
req.on("data", (chunk: string) => data += chunk);
|
|
|
|
req.on("end", () => {
|
|
if(req.headers["content-type"] === "application/json") {
|
|
try {
|
|
resolve(JSON.parse(data) as Record<string, string>);
|
|
}
|
|
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];
|
|
}
|
|
})
|
|
);
|
|
resolve(decodedData as Record<string, string>);
|
|
}
|
|
});
|
|
|
|
req.on("error", (err: Error) => reject(err));
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
}
|
|
});
|
|
|
|
return enhancedRes;
|
|
}
|
|
|
|
/**
|
|
* 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<boolean>} - Resolves to true if middleware processing is successful.
|
|
*/
|
|
private async processMiddleware(
|
|
middleware: Middleware[],
|
|
req: IncomingMessage,
|
|
res: ServerResponse
|
|
): Promise<boolean> {
|
|
if(!middleware || middleware.length === 0)
|
|
return true;
|
|
|
|
for(const fn of middleware) {
|
|
const proceed = await new Promise<boolean>(resolve => {
|
|
fn(req, res, () => resolve(true));
|
|
});
|
|
|
|
if(!proceed)
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
}
|