From 8554cdb3969f811d3398a687a212752429223aa7 Mon Sep 17 00:00:00 2001 From: Flummi Date: Sat, 15 Mar 2025 13:26:20 +0000 Subject: [PATCH] Dateien nach "src" hochladen --- src/index.ts | 225 +++++++++++++++++++++++++++++++++++++++ src/router.ts | 272 ++++++++++++++++++++++++++++++++++++++++++++++++ src/template.ts | 189 +++++++++++++++++++++++++++++++++ 3 files changed, 686 insertions(+) create mode 100644 src/index.ts create mode 100644 src/router.ts create mode 100644 src/template.ts diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..4536abc --- /dev/null +++ b/src/index.ts @@ -0,0 +1,225 @@ +import http, { IncomingMessage, ServerResponse } from "http"; +import { URL } from "url"; +import { Buffer } from "buffer"; +import querystring from "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; + }; + cookies: Record; + params?: Record; + post?: Record; +}; + +export default class Flummpress { + private server?: http.Server; + router: Router; + tpl: Tpl; + middleware: Set; + + 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, + }; + + // 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} - A promise that resolves to the parsed body data. + */ + readBody(req: IncomingMessage): Promise> { + 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); + } + 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); + } + }); + + 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} - 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; + + for(const fn of middleware) { + const proceed = await new Promise(resolve => { + fn(req, res, () => resolve(true)); + }); + + if(!proceed) + return false; + } + return true; + } +} diff --git a/src/router.ts b/src/router.ts new file mode 100644 index 0000000..0b4dd47 --- /dev/null +++ b/src/router.ts @@ -0,0 +1,272 @@ +import fs from "fs"; +import path from "path"; +import Tpl from "./template.js"; + +import { Request } from "./index.js"; +import { ServerResponse } from "http"; + +type RouteCallback = (req: Request, res: ServerResponse) => void; + +export default class Router { + private routes: Map; + private tpl?: Tpl; + private mimes: Map; + + constructor() { + this.routes = new Map(); + 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 { + 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; + } + + /** + * Groups multiple routes under a shared base path. + * @param path - Base path for the route group. + * @param cb - Callback to define the grouped routes. + * @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), + }; + 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)); + return this; + } + + /** + * 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) { + this.routes = new Map([...this.routes, ...obj.routes]); + this.sortRoutes(); + } + if(obj instanceof Tpl) { + this.tpl = obj; + } + } + + /** + * 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. + */ + get(path: string | RegExp, cb: RouteCallback, middleware?: RouteCallback): this { + this.registerRoute(path, cb, "get", middleware); + 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. + */ + post(path: string | RegExp, cb: RouteCallback, middleware?: RouteCallback): this { + this.registerRoute(path, cb, "post", middleware); + 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. + */ + head(path: string | RegExp, cb: RouteCallback, middleware?: RouteCallback): this { + this.registerRoute(path, cb, "head", middleware); + 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. + */ + put(path: string | RegExp, cb: RouteCallback, middleware?: RouteCallback): this { + this.registerRoute(path, cb, "put", middleware); + 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. + */ + delete(path: string | RegExp, cb: RouteCallback, middleware?: RouteCallback): this { + this.registerRoute(path, cb, "delete", middleware); + 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. + */ + patch(path: string | RegExp, cb: RouteCallback, middleware?: RouteCallback): this { + this.registerRoute(path, cb, "patch", middleware); + 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. + */ + private registerRoute( + path: string | RegExp, + cb: RouteCallback, + method: string, + middleware: RouteCallback | RouteCallback[] = [] + ): this { + const middlewareArray = 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`]: middlewareArray, + }); + + 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 { + method = method.toLowerCase(); + return [...this.routes.entries()].find(r => { + return (typeof r[0] === "string" ? r[0] === path : r[0].exec?.(path)) && r[1][method]; + }); + } + + /** + * Sorts the routes by their keys in reverse order. + * @returns The Router instance for chaining. + */ + private sortRoutes(): this { + this.routes = new Map([...this.routes.entries()].sort().reverse()); + 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: any, res: any) => { + 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; + } +} diff --git a/src/template.ts b/src/template.ts new file mode 100644 index 0000000..9a4c49d --- /dev/null +++ b/src/template.ts @@ -0,0 +1,189 @@ +import fs from "fs"; +import path from "path"; + +export default class Template { + private views: string; + private globals: Record; + private cache: boolean; + private templates: Map; + private debug: boolean; + + constructor() { + this.views = "./views"; + this.globals = {}; + this.debug = false; + this.cache = true; + this.templates = new Map(); + } + + /** + * Enables or disables debug mode. + * @param debug - If true, enables debug mode. + */ + setDebug(debug: boolean): void { + this.debug = debug; + } + + /** + * Sets the directory for template files and preloads all templates. + * @param views - The directory path for template files. + */ + setViews(views: string): void { + this.views = views; + this.readdir(views); + } + + /** + * Sets global variables to be used in all templates. + * @param globals - An object containing global variables. + */ + setGlobals(globals: Record): void { + this.globals = globals; + } + + /** + * Enables or disables the template caching mechanism. + * @param cache - If true, enables caching. + */ + setCache(cache: boolean): void { + this.cache = cache; + } + + /** + * Recursively reads the specified directory and loads all templates into memory. + * @param dir - The directory to read. + * @param root - The root directory for relative paths. + */ + private readdir(dir: string, root: string = dir): void { + for(const d of fs.readdirSync(`${path.resolve()}/${dir}`)) { + if(d.endsWith(".html")) { + const file = path.parse(`${dir.replace(this.views, "")}/${d}`); + const t_dir = file.dir.substring(1); + const t_file = file.name; + this.getTemplate(!t_dir ? t_file : `${t_dir}/${t_file}`); + } + else { + this.readdir(`${dir}/${d}`, root); + } + } + } + + /** + * Retrieves a template from the cache or loads it from the file system if not cached. + * @param tpl - The name of the template. + * @returns The template code as a string. + */ + private getTemplate(tpl: string): string { + let template: { code: string; cached: Date }; + let cache = false; + if(this.cache && this.templates.has(tpl)) { + template = this.templates.get(tpl)!; + cache = true; + } + else { + template = { + code: fs.readFileSync(`${this.views}/${tpl}.html`, "utf-8"), + cached: new Date(), + }; + this.templates.set(tpl, template); + } + return [ + template.code, + this.debug ? `` : "", + ].join(""); + } + + /** + * Renders a template with the provided data and local variables. + * @param file - The name of the template file (without extension). + * @param data - Data object to inject into the template. + * @param locals - Local variables to be used within the template. + * @returns The rendered HTML string. + */ + render(file: string, data: Record = {}, locals: Record = {}): string { + data = { ...data, ...locals, ...this.globals }; + try { + const code = + 'with(_data){const __html = [];' + + '__html.push(`' + + this.getTemplate(file) + .replace(/[\t]/g, " ") + .split("`") + .join("\\`") + .replace(/{{--(.*?)--}}/g, "") + .replace(/{{(.+?)}}/g, "`, $1, `") + .replace(/{!!(.+?)!!}/g, "`, this.escape($1), `") + .replace(/@js/g, "`);") + .replace(/@endjs/g, ";__html.push(`") + .replace(/@mtime\((.*?)\)/g, "`);__html.push(this.getMtime('$1'));__html.push(`") + .replace(/@include\((.*?)\)/g, (_, inc) => this.render(inc, data)) + .replace(/@for\((.*?)\)$/gm, "`); for($1) { __html.push(`") + .replace(/@endfor/g, "`); } __html.push(`") + .replace(/@each\((.*?) as (.*?)\)/g, "`); this.forEach($1, ($2, key) => { __html.push(`") + .replace(/@endeach/g, "`); }); __html.push(`") + .replace(/@elseif\((.*?)\)(\)?)/g, "`); } else if ($1$2) { __html.push(`") + .replace(/@if\((.*?)\)(\)?)/g, "`); if ($1$2) { __html.push(`") + .replace(/@else/g, "`); } else { __html.push(`") + .replace(/@endif/g, "`); } __html.push(`") + + "`); return __html.join('').replace(/\\n\\s*\\n/g, '\\n'); }"; + + return new Function("_data", code).bind({ + escape: this.escape, + forEach: this.forEach, + getMtime: this.getMtime, + })(data); + } + catch(err: any) { + console.log(file, (err as Error).message); + return this.debug ? `${(err as Error).message} in ${file}` : ""; + } + } + + /** + * Escapes a string for safe usage in HTML. + * @param str - The string to escape. + * @returns The escaped string. + */ + escape(str: string): string { + return (str + "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'") + .replace(/{/g, "{") + .replace(/}/g, "}"); + } + + /** + * Iterates over an object or array and applies a callback function. + * @param o - The object or array to iterate over. + * @param f - The callback function. + */ + forEach(o: any, f: (value: any, key: string | number) => void): void { + if(Array.isArray(o)) { + o.forEach(f); + } + else if(typeof o === "object") { + Object.keys(o).forEach((k) => f.call(null, o[k], k)); + } + else { + throw new Error(`${o} is not an iterable object`); + } + } + + /** + * Retrieves the last modification time of a file. + * @param file - The file path to check. + * @returns The last modification time in milliseconds. + */ + getMtime(file: string): number { + try { + return +`${fs.statSync(path.normalize(process.cwd() + file)).mtimeMs}`.split(".")[0]; + } + catch(err: any) { + console.log(err); + return 0; + } + } +}