From a595aea282eb54e1056524d09d8c15a28b6c52d3 Mon Sep 17 00:00:00 2001 From: Flummi Date: Sun, 6 Jun 2021 15:57:56 +0200 Subject: [PATCH] refactored... --- README.md | 82 ++++++++--------- package.json | 2 +- src/index.mjs | 224 +++++++++++++++++++++++++++++++++++++---------- src/router.mjs | 126 ++++++++++++++------------ src/template.mjs | 56 ++++++++++++ src/views.mjs | 22 ----- 6 files changed, 344 insertions(+), 168 deletions(-) create mode 100644 src/template.mjs delete mode 100644 src/views.mjs diff --git a/README.md b/README.md index f9e55bc..ced875d 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,42 @@ -# flummpress - -## Usage Example -```javascript -import path from "path"; -import flummpress, { router, views } from "flummpress"; - -(async () => { - const port = 8080; - (await new flummpress()) - .listen(port) - .on("listening", () => { - console.log(`flummpress is listening on port ${port}`); - - // new route GET - router.get(/^\/$/, (req, res) => { - res.reply({ - body: "hello world!" - }); - }); - - // new route POST - router.post(/^\/$/, async (req, res) => { - const postdata = await req.post; - console.log(postdata); - res.reply({ - body: "hello post!" - }); - }); - - // public folder - router.static({ - dir: path.resolve() + "/public", - route: /^\/public/ - }); - }); -})(); -``` - -## documentation - +# flummpress + +## Usage Example +```javascript +import path from "path"; +import flummpress, { router, views } from "flummpress"; + +(async () => { + const port = 8080; + (await new flummpress()) + .listen(port) + .on("listening", () => { + console.log(`flummpress is listening on port ${port}`); + + // new route GET + router.get(/^\/$/, (req, res) => { + res.reply({ + body: "hello world!" + }); + }); + + // new route POST + router.post(/^\/$/, async (req, res) => { + const postdata = await req.post; + console.log(postdata); + res.reply({ + body: "hello post!" + }); + }); + + // public folder + router.static({ + dir: path.resolve() + "/public", + route: /^\/public/ + }); + }); +})(); +``` + +## documentation + coming soon \ No newline at end of file diff --git a/package.json b/package.json index a6bee32..43d351a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flummpress", - "version": "1.0.2", + "version": "2.0.0", "description": "Express für arme", "main": "src/index.mjs", "repository": { diff --git a/src/index.mjs b/src/index.mjs index 275b43a..06fbd27 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -1,46 +1,178 @@ -import http from "http"; -import url from "url"; -import querystring from "querystring"; - -import router from "./router.mjs"; -import views from "./views.mjs"; - -export default class flummpress { - constructor() { - this.router = new router(); - this.views = new views(); - } - - use(item) { - switch(item.type) { - case "route": - item.data.forEach((v, k) => this.router.routes.set(k, v)); - break; - case "view": - item.data.forEach((v, k) => this.views.set(k, v)); - break; - } - } - - listen(...args) { - this.router.routes.forEach((v, k) => console.log("route set", v.method, k)); - return http.createServer(async (req, res, r) => { - req.url = url.parse(req.url.replace(/(?!^.)(\/+)?%/, '')); - req.url.qs = querystring.parse(req.url.query); - - req.post = new Promise((resolve, _, data = "") => req - .on("data", d => void (data += d)) - .on("end", () => void resolve(Object.fromEntries(Object.entries(querystring.parse(data)).map(([k, v]) => [k, decodeURIComponent(v)]))))); - - res.reply = ({ - code = 200, - type = "text/html", - body - }) => res.writeHead(code, { "Content-Type": `${type}; charset=utf-8` }).end(body); - - !(r = this.router.routes.getRegex(req.url.pathname, req.method)) ? res.writeHead(404).end(`404 - ${req.url.pathname}`) : await r(req, res); - console.log(`[${(new Date()).toLocaleTimeString()}] ${res.statusCode} ${req.method}\t${req.url.pathname}`); - }).listen(...args); - } -}; -export { router, views }; +import path from "path"; +import fs from "fs"; +import http from "http"; +import { URL } from "url"; +import querystring from "querystring"; + +import Router from "./router.mjs"; +import Tpl from "./template.mjs"; + +export { Router, Tpl }; + +export default class flummpress { + #mimes; + #server; + constructor() { + this.router = new Router(); + this.tpl = new Tpl(); + + return this; + }; + + use(obj) { + if(obj instanceof Router) { + this.router.routes = new Map([ ...this.router.routes, ...obj.routes ]); + this.router.sortRoutes(); + } + else if(obj instanceof Tpl) { + this.tpl = obj; + } + return this; + }; + + static({ dir = path.resolve() + "/public", route = /^\/public/ }) { + if(!this.#mimes) { + this.#mimes = new Map(); + (fs.readFileSync("./mime.types", "utf-8")) + .split("\n") + .filter(e => !e.startsWith("#") && e) + .map(e => e.split(/\s{2,}/)) + .filter(e => e.length > 1) + .forEach(m => m[1].split(" ").forEach(ext => this.#mimes.set(ext, m[0]))); + } + + this.router.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); + let stat; + try { + stat = fs.statSync(file); + } catch { + res.reply({ + code: 404, + body: "404 - file not found." + }); + return this; + } + + if(!mime.startsWith("video") && !mime.startsWith("audio")) { + res.reply({ + type: this.#mimes.get(filename.split(".").pop()).toLowerCase(), + body: fs.readFileSync(path.join(dir, filename)) + }); + return this; + } + + if(req.headers.range) { + const parts = req.headers.range.replace(/bytes=/, "").split("-"); + const start = parseInt(parts[0], 10); + const end = parts[1] ? parseInt(parts[1], 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, + }); + const stream = fs.createReadStream(file, { start: start, end: end }) + .on("open", () => stream.pipe(res)) + .on("error", err => res.end(err)); + } + else { + res.writeHead(200, { + "Content-Length": stat.size, + "Content-Type": mime, + }); + fs.createReadStream(file).pipe(res); + } + } catch(err) { + console.error(err); + res.reply({ + code: 500, + body: "500 - f0ck hat keinen b0ck" + }); + } + }); + return this; + }; + + listen(...args) { + this.#server = http.createServer(async (req, res) => { + const t_start = process.hrtime(); + + const _url = new URL(req.url.replace(/(?!^.)(\/+)?$/, ''), "relative:///"); + req.url = { + pathname: _url.pathname, + split: _url.pathname.split("/").slice(1), + searchParams: _url.searchParams, + qs: {...querystring.parse(_url.search.substring(1))} // legacy + }; + + const method = req.method.toLowerCase(); + const route = this.router.getRoute(req.url.pathname, req.method); + + if(route) { // 200 + const cb = route[1][method]; + const middleware = route[1][`${method}mw`]; + req.params = req.url.pathname.match(new RegExp(route[0]))?.groups; + req.post = await this.readBody(req); + + const result = await this.processMiddleware(middleware, req, this.createResponse(res)); + if(result) + cb(req, res); + } + else { // 404 + res + .writeHead(404) + .end(`404 - ${req.method} ${req.url.pathname}`); + } + + console.log([ + `[${(new Date()).toLocaleTimeString()}]`, + `${(process.hrtime(t_start)[1] / 1e6).toFixed(2)}ms`, + `${req.method} ${res.statusCode}`, + req.url.pathname + ].map(e => e.toString().padEnd(15)).join("")); + + }).listen(...args); + return this; + }; + + readBody(req) { + return new Promise((resolve, _, data = "") => req + .on("data", d => void (data += d)) + .on("end", () => { + if(req.headers['content-type'] === "application/json") { + try { + return void resolve(JSON.parse(data)); + } catch(err) {} + } + void resolve(Object.fromEntries(Object.entries(querystring.parse(data)).map(([k, v]) => { + try { + return [k, decodeURIComponent(v)]; + } catch(err) { + return [k, v]; + } + }))); + })); + }; + + createResponse(res) { + res.reply = ({ + code = 200, + type = "text/html", + body + }) => res.writeHead(code, { "Content-Type": `${type}; charset=utf-8` }).end(body); + res.json = msg => res.reply({ type: "application/json", body: msg }); + res.html = msg => res.reply({ type: "text/html", body: msg }); + return res; + }; + + processMiddleware(middleware, req, res) { + if(!middleware) + return new Promise(resolve => resolve(true)); + return new Promise(resolve => middleware(req, res, () => resolve(true))); + }; + +}; diff --git a/src/router.mjs b/src/router.mjs index 4dc642b..ef0f41a 100644 --- a/src/router.mjs +++ b/src/router.mjs @@ -1,58 +1,68 @@ -import { promises as fs } from "fs"; -import path from "path"; - -export default class Router { - #mimes; - constructor() { - this.routes = new Map(); - } - async loadRoutes(path) { - await Promise.all( - (await fs.readdir(path)) - .filter(f => f.endsWith(".mjs")) - .map(async route => (await import(`${path}/${route}`)).default(this)) - ); - } - route(method, args) { - //console.log("route set", method, args[0]); - return { - type: "route", - data: this.routes.set(args[0], { method: method, f: args[1] }) - }; - } - get() { - return this.route("GET", arguments); - } - post() { - return this.route("POST", arguments); - } - async static({ dir = path.resolve() + "/public", route = /^\/public/ }) { - if(!this.#mimes) { - this.#mimes = new Map(); - (await fs.readFile("/etc/mime.types", "utf-8")) - .split("\n") - .filter(e => !e.startsWith("#") && e) - .map(e => e.split(/\s{2,}/)) - .filter(e => e.length > 1) - .forEach(m => m[1].split(" ").forEach(ext => this.#mimes.set(ext, m[0]))); - } - - this.get(route, async (req, res) => { - try { - return res.reply({ - type: this.#mimes.get(req.url.path.split(".").pop()).toLowerCase(), - body: await fs.readFile(path.join(dir, req.url.path.replace(route, "")), "utf-8") - }); - } catch { - return res.reply({ - code: 404, - body: "404 - file not found" - }); - } - }); - } -}; - -Map.prototype.getRegex = function(path, method) { - return [...this.entries()].filter(r => r[0].exec(path) && r[1].method.includes(method))[0]?.[1].f; -}; +export default class Router { + constructor() { + this.routes = new Map(); + return this; + }; + + group(path, cb) { + const methods = { + get: this.get.bind(this), + post: this.post.bind(this), + }; + const target = { + path: new RegExp(path), + }; + const handler = { + get: (opt, method) => (p, ...args) => methods[method]( + new RegExp([ opt.path, new RegExp(p === "/" ? "$": p) ] + .map(regex => regex.source) + .join("") + .replace(/(\\\/){1,}/g, "/")), + ...args, + ) + }; + cb(new Proxy(target, handler)); + return this; + }; + + get(path, ...args) { + if(args.length === 1) + this.registerRoute(path, args[0], "get"); + else + this.registerRoute(path, args[1], "get", args[0]); + return this; + }; + + post(path, ...args) { + if(args.length === 1) + this.registerRoute(path, args[0], "post"); + else + this.registerRoute(path, args[1], "post", args[0]); + return this; + }; + + registerRoute(path, cb, method, middleware) { + if(!this.routes.has(path)) + this.routes.set(path, {}); + this.routes.set(path, { + ...this.routes.get(path), + [method]: cb, + [method + "mw"]: middleware, + }); + console.log("route set:", method.toUpperCase(), path); + this.sortRoutes(); + return this; + }; + + getRoute(path, method) { + method = method.toLowerCase(); + return [...this.routes.entries()].filter(r => { + return (r[0] === path || r[0].exec?.(path)) && r[1].hasOwnProperty(method); + })[0]; + }; + + sortRoutes() { + this.routes = new Map([...this.routes.entries()].sort().reverse()); + return this; + }; +}; diff --git a/src/template.mjs b/src/template.mjs new file mode 100644 index 0000000..9fd2fb3 --- /dev/null +++ b/src/template.mjs @@ -0,0 +1,56 @@ +import fs from "fs"; +import path from "path"; + +export default class { + constructor() { + this.views = path.resolve() + "/views"; + }; + getTemplate(tpl) { + return fs.readFileSync(`${this.views}/${tpl}.html`, "utf-8"); + } + render(tpl, data = {}) { + try { + const code = 'with(_data){const __html = [];' + + '__html.push(\`' + + tpl.replace(/[\t]/g, ' ') + .replace(/'(?=[^\{]*}})/, '\t') + .split('\`').join('\\\`') + .split('\t').join('\`') + .replace(/{{=(.+?)}}/g, '\`,$1,\`') + .replace(/{{-(.+?)}}/g, '\`,this.escape($1),\`') + .replace(/{{include (.*?)}}/g, (_, inc) => this.render(this.getTemplate(inc), data)) + .replace(/{{each (?.*) as (?.*)}}/g, (_, key, val) => `\`);this.forEach(${key},(${val},key)=>{__html.push(\``) + .replace(/{{\/each}}/g, "\`);});__html.push(`") + .split('{{').join('\`);') + .split('}}').join('__html.push(\`') + + '\`);return __html.join(\'\').replace(/\\n\\s*\\n/g, "\\n");}'; + + return (new Function("_data", code)).bind({ + escape: this.escape, + forEach: this.forEach + })(data); + } catch(err) { + console.log(err); + return err.message; + } + }; + 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 a iterable object`); + }; +}; diff --git a/src/views.mjs b/src/views.mjs deleted file mode 100644 index c5c2724..0000000 --- a/src/views.mjs +++ /dev/null @@ -1,22 +0,0 @@ -import { promises as fs } from "fs"; - -export default class ViewController { - #views; - constructor() { - this.#views = new Map(); - } - get(name) { - return this.#views.get(name) || false; - } - set(name, view) { - return { - type: "view", - data: this.#views.set(name, view) - }; - } - async loadViews(path) { - return await Promise.all((await fs.readdir(path)) - .filter(view => view.endsWith(".html")) - .map(async view => this.set(view.slice(0, -5), await fs.readFile(`${path}/${view}`)))); - } -};