diff --git a/.gitignore b/.gitignore index 16acd49..d5f19d8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,2 @@ node_modules -dist package-lock.json diff --git a/dist/index.js b/dist/index.js new file mode 100644 index 0000000..af3e77a --- /dev/null +++ b/dist/index.js @@ -0,0 +1,133 @@ +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +import http from "node:http"; +import { URL } from "node:url"; +import querystring from "node:querystring"; +import Router from "./router.js"; +import Tpl from "./template.js"; +export { Router, Tpl }; +export default class Flummpress { + constructor() { + this.router = new Router(); + this.tpl = new Tpl(); + this.middleware = []; + } + use(plugin) { + 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; + } + processPipeline(handlers, req, res) { + return __awaiter(this, void 0, void 0, function* () { + for (const handler of handlers) { + if (typeof handler !== "function") + throw new TypeError(`Handler is not a function: ${handler}`); + let nextCalled = false; + yield handler(req, res, () => nextCalled = true); + if (!nextCalled || res.writableEnded) + return; + } + }); + } + listen(...args) { + this.server = http.createServer((request, response) => __awaiter(this, void 0, void 0, function* () { + var _a, _b; + const req = this.parseRequest(request); + const res = this.createResponse(response); + const start = process.hrtime(); + try { + yield 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[(_a = req.method) === null || _a === void 0 ? void 0 : _a.toLowerCase()]; + req.params = ((_b = req.url.pathname.match(new RegExp(pathPattern))) === null || _b === void 0 ? void 0 : _b.groups) || {}; + req.post = yield this.readBody(req); + yield this.processPipeline(handler, req, res); + } + else { + res.writeHead(404).end("404 - Not Found"); + } + } + catch (err) { + 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; + } + parseRequest(request) { + const url = new URL(request.url.replace(/(?!^.)(\/+)?$/, ""), "http://localhost"); + const req = 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; + } + readBody(req) { + return __awaiter(this, void 0, void 0, function* () { + return new Promise((resolve, reject) => { + let body = ""; + req.on("data", (chunk) => { + body += chunk; + }); + req.on("end", () => { + try { + resolve(req.headers["content-type"] === "application/json" + ? JSON.parse(body) + : querystring.parse(body)); + } + catch (err) { + reject(err); + } + }); + req.on("error", reject); + }); + }); + } + createResponse(response) { + const res = 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; + } +} diff --git a/dist/router.js b/dist/router.js new file mode 100644 index 0000000..d02ff58 --- /dev/null +++ b/dist/router.js @@ -0,0 +1,169 @@ +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +import fs from "node:fs"; +import path from "node:path"; +import Tpl from "./template.js"; +export default class Router { + constructor() { + this.routes = new Map(); + this.mimes = new Map(); + } + importRoutesFromPath(p_1) { + return __awaiter(this, arguments, void 0, function* (p, tpl = false) { + const dirEntries = yield 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 = (yield import(`${path.resolve()}/${p}/${tmp.name}`)).default; + this.use(routeModule(this, tpl)); + } + else if (tmp.isDirectory()) { + yield this.importRoutesFromPath(p + '/' + tmp.name); + } + } + return this; + }); + } + group(basePath, callback) { + const self = this; + const methods = { + get(path, ...handlers) { + const fullPath = self.combinePaths(basePath, path); + return self.registerRoute(fullPath, "get", handlers); + }, + post(path, ...handlers) { + const fullPath = self.combinePaths(basePath, path); + return self.registerRoute(fullPath, "post", handlers); + }, + put(path, ...handlers) { + const fullPath = self.combinePaths(basePath, path); + return self.registerRoute(fullPath, "put", handlers); + }, + delete(path, ...handlers) { + const fullPath = self.combinePaths(basePath, path); + return self.registerRoute(fullPath, "delete", handlers); + }, + patch(path, ...handlers) { + const fullPath = self.combinePaths(basePath, path); + return self.registerRoute(fullPath, "patch", handlers); + } + }; + callback(methods); + return this; + } + combinePaths(basePath, subPath) { + 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."); + } + use(obj) { + if (obj instanceof Router) { + this.routes = new Map([...this.routes, ...obj.routes]); + this.sortRoutes(); + } + if (obj instanceof Tpl) { + this.tpl = obj; + } + } + get(path, cb) { + this.registerRoute(path, "get", cb); + return this; + } + post(path, cb) { + this.registerRoute(path, "post", cb); + return this; + } + head(path, cb) { + this.registerRoute(path, "head", cb); + return this; + } + put(path, cb) { + this.registerRoute(path, "put", cb); + return this; + } + delete(path, cb) { + this.registerRoute(path, "delete", cb); + return this; + } + patch(path, cb) { + this.registerRoute(path, "patch", cb); + return this; + } + registerRoute(path, method, handlers) { + if (!this.routes.has(path)) { + this.routes.set(path, {}); + } + this.routes.get(path)[method] = handlers.flat(); + console.log("route set:", method.toUpperCase(), path); + this.sortRoutes(); + return this; + } + getRoute(path, method) { + method = method.toLowerCase(); + return [...this.routes.entries()].find(r => { + var _a, _b; + return (typeof r[0] === "string" ? r[0] === path : (_b = (_a = r[0]).exec) === null || _b === void 0 ? void 0 : _b.call(_a, path)) && r[1][method]; + }); + } + sortRoutes() { + this.routes = new Map([...this.routes.entries()].sort().reverse()); + return this; + } + readMimes(file = "/etc/mime.types") { + fs.readFileSync(file, "utf-8") + .split("\n") + .filter(line => !line.startsWith("#") && line) + .forEach(line => { + const [mimeType, extensions] = line.split(/\s+/); + extensions === null || extensions === void 0 ? void 0 : extensions.split(" ").forEach(ext => this.mimes.set(ext, mimeType)); + }); + } + static({ dir = path.resolve() + "/public", route = /^\/public/ }) { + if (!this.mimes.size) + this.readMimes(); + this.get(route, [(req, res) => { + 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) { + console.error(err); + res.reply({ code: 404, body: "404 - File not found" }); + } + }]); + return this; + } +} diff --git a/dist/template.js b/dist/template.js new file mode 100644 index 0000000..0f2a632 --- /dev/null +++ b/dist/template.js @@ -0,0 +1,122 @@ +import fs from "node:fs"; +import path from "node:path"; +export default class Template { + constructor() { + this.views = "./views"; + this.globals = {}; + this.debug = false; + this.cache = true; + this.templates = new Map(); + } + setDebug(debug) { + this.debug = debug; + } + setViews(views) { + this.views = views; + this.readdir(views); + } + setGlobals(globals) { + this.globals = globals; + } + setCache(cache) { + this.cache = cache; + } + readdir(dir, root = dir) { + 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); + } + } + } + getTemplate(tpl) { + let template; + 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(""); + } + render(file, data = {}, locals = {}) { + data = Object.assign(Object.assign(Object.assign({}, 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) { + console.log(file, err.message); + return this.debug ? `${err.message} in ${file}` : ""; + } + } + escape(str) { + return (str + "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'") + .replace(/{/g, "{") + .replace(/}/g, "}"); + } + forEach(o, f) { + 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`); + } + } + getMtime(file) { + try { + return +`${fs.statSync(path.normalize(process.cwd() + file)).mtimeMs}`.split(".")[0]; + } + catch (err) { + console.log(err); + return 0; + } + } +} diff --git a/package.json b/package.json index 7d23b2d..5b9f8cd 100644 --- a/package.json +++ b/package.json @@ -2,10 +2,9 @@ "name": "flummpress", "version": "3.0.0", "description": "Express für arme", - "main": "index.mjs", + "main": "./dist/index.js", "scripts": { "build": "tsc", - "start": "node ./dist/index.js", "doc": "typedoc --out docs src" }, "keywords": [], diff --git a/src/test.mjs b/src/test.mjs new file mode 100644 index 0000000..390f697 --- /dev/null +++ b/src/test.mjs @@ -0,0 +1,36 @@ +import flummpress from '../dist/index.js'; + +process.on('unhandledRejection', err => { + console.error(err); + throw err; +}); + + +const app = new flummpress(); + +const loggedin = async (req, res, next) => { + console.log("Logged in"); + await next(); +}; + +app.router.group(/^\/api\/v2\/admin\/(?\d+)\/tags/, group => { + group.get(/$/, + (req, res, next) => { // middleware davor + console.log("Logged in"); + next(); + }, + (req, res, next) => { // eigentlicher request + res.reply({ + body: JSON.stringify(req.params) + }); + next(); + }, + (req, res) => { // middleware danach + console.log("Logged out"); + } + ); +}); + +app.listen(3000); + +console.log(app.router);