├── .gitignore ├── LICENSE ├── README.md ├── examples ├── express.js ├── index.html ├── index.js ├── koa.js ├── mediumArticle20180926 │ ├── index.html │ └── index.js ├── package.json └── testmarkdown.md ├── index.js └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Simon Y. Blackwell 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anywhichway/fos/adb6951dd0a9644835bbe413a60a6db0528260e6/README.md -------------------------------------------------------------------------------- /examples/express.js: -------------------------------------------------------------------------------- 1 | const express = require('express'), 2 | fosify = require("../index.js").fosify, 3 | app = express(); 4 | 5 | app.locals.serverName = "Express FOS"; 6 | const api = { 7 | echo:arg => arg, 8 | upper:arg => arg.toUpperCase(), 9 | f:() => () => true, 10 | locals:(key) => key ? app.locals[key] : undefined 11 | // direct URL http://localhost:3000/fos/locals?arguments=["serverName"] 12 | }; 13 | fosify(app,api,{allow:"*",name:"F"}); 14 | app.use(express.static(__dirname + "/")); 15 | 16 | app.listen(3000, () => console.log("Express FOS listening on 3000")) -------------------------------------------------------------------------------- /examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 12 | 13 | 14 | FOS Example - open debugger to see results 15 | 16 | -------------------------------------------------------------------------------- /examples/index.js: -------------------------------------------------------------------------------- 1 | const md = require('markdown-it')({ 2 | html: true, 3 | linkify: true, 4 | typographer: true 5 | }); 6 | 7 | const FOS = require("../index.js"), 8 | server = new FOS( 9 | { 10 | echo: arg => arg, 11 | upper: arg => arg.toUpperCase(), 12 | f: () => () => true 13 | }, 14 | {allow: "*", log: true}, 15 | ); 16 | server.use((request, response, next) => { 17 | next(); 18 | }, (request, response, next) => { 19 | next("route"); 20 | }, (request, response, next) => { 21 | console.log("should not be here"); 22 | next(); 23 | }); 24 | server.use(/\/hello/g, async (request, response, next) => { 25 | console.log("RegExp", request.url); 26 | }); 27 | server.param("id", async (request, response, next, value) => { 28 | console.log(request.url, value); 29 | }); 30 | server.use("/hello/there/:id", (request, response, next) => { 31 | console.log(request.url, "to long"); 32 | next(); 33 | }); 34 | server.use("/hello/:id", async (request, response, next) => { 35 | response.end("hi!"); 36 | }); 37 | server.static("/", { 38 | mimeTypes: { 39 | md: { 40 | contentType: "text/html", 41 | transform: (content) => md.render(content.toString()) 42 | } 43 | } 44 | }); 45 | server.listen(3000); -------------------------------------------------------------------------------- /examples/koa.js: -------------------------------------------------------------------------------- 1 | const Koa = require('koa'), 2 | fosify = require("../index.js").fosify, 3 | app = new Koa(); 4 | 5 | app.locals = {serverName: "Koa FOS"}; 6 | const api = { 7 | echo:arg => arg, 8 | upper:arg => arg.toUpperCase(), 9 | f:() => () => true, 10 | locals:(key) => key ? app.locals[key] : undefined 11 | // direct URL http://localhost:3000/fos/locals?arguments=["serverName"] 12 | }; 13 | 14 | app.use(require('koa-static')(__dirname + "/")); 15 | fosify(app,api,{allow:"*",name:"F"}); 16 | app.listen(3000,() => console.log("Koa FOS server listening on 3000")); -------------------------------------------------------------------------------- /examples/mediumArticle20180926/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | Hello! 8 | -------------------------------------------------------------------------------- /examples/mediumArticle20180926/index.js: -------------------------------------------------------------------------------- 1 | const FOS = require("../../index.js"), 2 | cookieParser = require('cookie-parser'), 3 | app = new FOS({},{allow:"*"}); 4 | app.use(cookieParser()); 5 | app.route("/hi").get((request,response,next) => { response.end("Hi!"); next(); }); 6 | app.route("/").get(async (request) => { console.log("Cookies",JSON.stringify(request.cookies)); }); 7 | app.static("/"); 8 | app.listen(3000) -------------------------------------------------------------------------------- /examples/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fos-express example", 3 | "version": "v0.0.1", 4 | "description": "ExpressJS Function Oriented Server", 5 | "engines": { 6 | "node": "10.0.0" 7 | }, 8 | "license": "MIT", 9 | "scripts": {}, 10 | "repository": {}, 11 | "keywords": [], 12 | "author": "Simon Y. Blackwell (http://www.github.com/anywhichway)", 13 | "bugs": {}, 14 | "devDependencies": {}, 15 | "dependencies": { 16 | "koa-static": "^5.0.0" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /examples/testmarkdown.md: -------------------------------------------------------------------------------- 1 | You have been marked! -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | var createServer, 3 | URL; 4 | if (typeof (module) !== "undefined") { 5 | createServer = require("http").createServer; 6 | URL = require("url").URL; 7 | } else { // not yet supported/tested 8 | class ServerResponse { 9 | constructor() { 10 | this.promise = new Promise(resolve => this.resolve = resolve); 11 | this.promise.statusCode = 200; 12 | this.promise.statusMessage = ""; 13 | this.promise.headers = {}; 14 | this.promise.body = ""; 15 | Object.keys(ServerResponse.prototype).forEach(key => Object.defineProperty(this.promise, key, { 16 | enumerable: false, 17 | configurable: true, 18 | writable: true, 19 | value: ServerResponse.prototype[key] 20 | })) 21 | return this.promise; 22 | } 23 | 24 | addTrailers() { 25 | } // Adds HTTP trailing headers 26 | end(text = "") { 27 | this.body += text; 28 | this.finished = true; 29 | this.resolve(); 30 | } // Signals that the the server should consider that the response is complete 31 | // finished Returns true if the response is complete, otherwise false 32 | getHeader(key) { 33 | return this.headers[key]; 34 | } // Returns the value of the specified header 35 | // headersSent // Returns true if headers were sent, otherwise false 36 | removeHeader(key) { 37 | delete this.headers[key]; 38 | } // Removes the specified header 39 | // sendDate set to false if the Date header should not be sent in the response. Default true 40 | setHeader(key, value) { 41 | this.headers[key] = value; 42 | } // Sets the specified header 43 | setTimeout() { 44 | } // Sets the timeout value of the socket to the specified number of milliseconds 45 | // statusCode Sets the status code that will be sent to the client 46 | // statusMessage Sets the status message that will be sent to the client 47 | write(text) { 48 | this.headersSent = true; 49 | this.body += text; 50 | } // Sends text, or a text stream, to the client 51 | //writeContinue() {} // Sends a HTTP Continue message to the client 52 | writeHead() { 53 | this.headersSent = true; 54 | } // Sends status and response headers to the client 55 | } 56 | 57 | createServer = async handler => { 58 | self.addEventListener('fetch', async event => { 59 | const request = Object.assign({}, event.request), 60 | response = new ServerResponse(); 61 | request.headers = new Proxy(request.headers, {get: (target, property) => target.get(property)}); 62 | handler(request, response); 63 | await response; 64 | event.respondWith(new Response(response.body, { 65 | status: response.status, 66 | statusText: response.statusText || response.statusMessage, 67 | headers: response.headers 68 | })); 69 | }); 70 | } 71 | } 72 | 73 | function fromJSON(json, functions) { 74 | return JSON.parse(json, (_, value) => { 75 | if (value === '@NaN') return NaN; 76 | if (value === '@Infinity') return Infinity; 77 | if (value === '@-Infinity') return -Infinity; 78 | if (value === '@undefined') return undefined; 79 | if (typeof (value) === "string") { 80 | if (value.indexOf("Date@") === 0) return new Date(parseInt(value.substring(5))) 81 | if (/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/.test(value)) { 82 | return new Date(value); 83 | } 84 | if (functions && value.indexOf("Function@") === 0) return Function("return " + value.substring(9))(); 85 | } 86 | return value; 87 | }); 88 | } 89 | 90 | async function runCallback(cb, request, response, value) { 91 | if (typeof (cb) === "string") return true; // it is actually a path 92 | return new Promise(resolve => { 93 | const result = cb(request, response, resolve, value); 94 | if (result && typeof (result) === "object" && result instanceof Promise) { 95 | result.then(result => resolve(result)) 96 | } 97 | }); 98 | } 99 | 100 | function toJSON(value) { 101 | return JSON.stringify(value, (_, value) => { 102 | if (value !== value || value === Infinity || value === -Infinity || value === undefined || (typeof (value) === "number" && isNaN(value))) return `@${value}`; 103 | const type = typeof (value); 104 | if (type === "function") return "Function@" + value; 105 | if (value && type === "object" && value instanceof Date) return "Date@" + value.getTime(); 106 | return value; 107 | }); 108 | } 109 | 110 | function toScript(object, {server}, parent = "") { 111 | function fos(options) { 112 | fos._options = options; 113 | return fos; 114 | }; 115 | const handlers = "{" + Object.keys(object).reduce((accum, key, index, array) => { 116 | const value = object[key], 117 | type = typeof (value), 118 | proto = Object.getPrototypeOf(value); 119 | if (type === "function") { // headers available as fos._headers 120 | accum += `"${key}": (...args) => { 121 | return fetch("${server}/fos/${parent}${key}?arguments="+encodeURIComponent(toJSON(args)),fos._options) 122 | .then(response => response.text().then(text => { delete fos._options; if(response.ok) { return text; } throw new Error(response.status + " " + text); })) 123 | .then(text => fromJSON(text,true)); 124 | }` 125 | } else if (value && type === "object") { 126 | accum += `"${key}":` + toScript(value, {server}, `${parent}${key}.`); 127 | } 128 | if (index < array.length - 1) { 129 | accum += ","; 130 | } 131 | return accum; 132 | }, "") + "}"; 133 | return `(() => { 134 | ${toJSON}; 135 | ${fromJSON}; 136 | var fos = Object.assign(${fos},${handlers}); 137 | return fos; 138 | })()`; 139 | } 140 | 141 | const VERBS = ["all", "delete", "get", "head", "patch", "post", "put"]; 142 | 143 | class FOS { 144 | constructor(functions, {allow, name="FOS", before, after, done, middleware,log} = {}) { 145 | this.functions = functions; 146 | this.settings = {log}; 147 | this.routes = []; 148 | this.locals = {}; 149 | this.engines = {}; 150 | this.generators = {}; 151 | const handler = async (request, response, complete = Promise.resolve()) => { 152 | request.fos = this; 153 | response.locals = Object.assign({}, this.locals); 154 | const url = new URL(request.url, (request.secure ? "https://" : "http://") + request.headers.host); 155 | if (!request.subdomains) { 156 | request.subdomains = []; 157 | const parts = request.headers.host.split("."); 158 | if (parts.length > 2) { 159 | parts.pop(); 160 | parts.pop(); 161 | request.subdomains.push(parts.join(".")); 162 | } 163 | } 164 | request = new Proxy(request, { 165 | get: (target, property) => { 166 | if (url[property]) { 167 | return url[property]; 168 | } 169 | if (property === "path") { 170 | return url.pathname; 171 | } 172 | if (property === "query") { 173 | return new Proxy(url.searchParams, {get: (target, property) => typeof (target.get) === "function" ? target.get(property) : target[property]}); 174 | } 175 | if (property === "ip") { 176 | return (req.headers['x-forwarded-for'] || '').split(',').pop() || 177 | req.connection.remoteAddress || 178 | req.socket.remoteAddress || 179 | req.connection.socket.remoteAddress 180 | } 181 | return target[property]; 182 | } 183 | }); 184 | if (allow) { 185 | response.setHeader("Access-Control-Allow-Origin", allow); 186 | } 187 | if (before) { 188 | await before({request, response}); 189 | } 190 | if (!response.headersSent) { 191 | //console.log(request.pathname,request.query.arguments); 192 | let result; 193 | if (request.pathname === "/fos") { 194 | response.statusCode = 200; 195 | response.setHeader("Content-Type", "text/javascript"); 196 | response.end(`${name ? "const " + name + " = " : ""}${toScript(this.functions, {server: request.protocol + "//" + request.host})};`); 197 | return; 198 | } 199 | if (request.pathname.indexOf("/fos/") === 0) { 200 | response.statusCode = 200; 201 | const parts = request.pathname.substring(5).split("."); 202 | let node = this.functions, 203 | key; 204 | while ((key = parts.shift()) && (node = node[key])) { 205 | if (parts.length === 0) { 206 | try { 207 | //console.log(request.query.arguments,fromJSON(request.query["arguments"])) 208 | result = await node.apply({ 209 | request, 210 | response 211 | }, fromJSON(request.query["arguments"])); 212 | //console.log(result,node); 213 | if (after) { 214 | result = await after({result, request, response}); 215 | } 216 | if (!response.headersSent) { 217 | response.end(toJSON(result)); 218 | } else { 219 | response.end(); 220 | } 221 | } catch (err) { 222 | response.statusCode = 500; 223 | response.end(err.message); 224 | } 225 | if (done) { 226 | done({result, request, response}); 227 | } 228 | return; 229 | } 230 | } 231 | } else if (this.functions.request) { // request hander added 232 | await this.functions.request.call({request, response}, request.pathname); 233 | if (response.finished) return; 234 | } 235 | if (middleware) { 236 | complete(); 237 | } else { 238 | response.statusCode = 404; 239 | response.end("Not Found"); 240 | } 241 | } 242 | if (done) { 243 | done({result, request, response}); 244 | } 245 | }; 246 | if (middleware) { 247 | return handler; 248 | } 249 | this.server = createServer(handler); 250 | } 251 | 252 | disable(key) { 253 | this.set(key, false); 254 | } 255 | 256 | disabled(key) { 257 | return this.get(key) === false; 258 | } 259 | 260 | enable(key) { 261 | this.set(key, true); 262 | } 263 | 264 | enabled(key) { 265 | return this.get(key) === true; 266 | } 267 | 268 | engine(extension, renderFunction) { 269 | this.engines[extension] = renderFunction; 270 | } 271 | 272 | listen(port) { 273 | this.server.listen(port, err => { 274 | if (err) { 275 | console.log(err); 276 | } 277 | console.log(`A FOS is listening on ${port}`); 278 | }) 279 | } 280 | 281 | param(params, callback) { 282 | if (!this.params) { 283 | this.params = {}; 284 | } 285 | if (Array.isArray(params)) { 286 | params.slice().forEach(param => this.params[`:${param}`] = callback); 287 | } else { 288 | this.params[`:${params}`] = callback; 289 | } 290 | } 291 | 292 | set(name, value) { 293 | this.settings[name] = value; 294 | } 295 | 296 | route(path) { 297 | if (!this.functions.request) { 298 | this.functions.request = FOS.request; 299 | } 300 | const route = {path, all: [], delete: [], get: [], head: [], patch: [], post: [], put: []}; 301 | this.routes.push(route); 302 | const proxy = new Proxy(route, { 303 | get(target, property) { 304 | if (!VERBS.includes(property)) throw new Error(`${property} is not an HTTP verb or 'all'`); 305 | return (...callbacks) => { 306 | target[property] = callbacks; 307 | return proxy; 308 | } 309 | } 310 | }); 311 | return proxy; 312 | } 313 | 314 | static(path, {location = "", defaultFile = "index.html", mimeTypes = {}} = {}) { 315 | mimeTypes = {html: "text/html", js: "application/javascript", ...mimeTypes}; 316 | let fs, 317 | normalizePath, 318 | resolvePath; 319 | try { 320 | if (typeof (require) === "function") { 321 | fs = require("fs"); 322 | normalizePath = require("path").normalize; 323 | const process = require("process"), 324 | resolve = require("path").resolve; 325 | resolvePath = (...args) => resolve(process.cwd(), ...args); 326 | console.log(process.cwd()) 327 | } 328 | } catch (e) { 329 | ; 330 | } 331 | if (!fs || !resolvePath || !normalizePath) return; // no-op if not on server where require("fs") works 332 | this.route(path).get(async (request, response) => { 333 | if (response.headersSent) return; 334 | let url = request.pathname.substring(path.length); 335 | if (url.length === 0) url = defaultFile; 336 | else if (url[url.length - 1] === "/") url += defaultFile; 337 | const extension = url.split(".").pop(); 338 | return new Promise(resolve => { 339 | //console.log(normalizePath(location + "/" + url)); 340 | const path = resolvePath(normalizePath(location + "/" + url).slice(1)); 341 | if(this.settings.log) console.log(path); 342 | fs.readFile(path, async (err, data) => { 343 | if (err) { 344 | response.writeHead(404, {'Content-Type': 'text/plain'}); 345 | response.write("Not Found"); 346 | } else { 347 | const type = mimeTypes[extension]; 348 | if (type) { 349 | const { 350 | contentType = `application/${extension}`, 351 | transform = value => value 352 | } = typeof type === "string" ? {contentType: type} : type; 353 | response.writeHead(200, {'Content-Type': contentType}); 354 | response.write(await transform(data, request, response)); 355 | } else { 356 | response.writeHead(200); 357 | response.write(data); 358 | } 359 | } 360 | response.end(); 361 | resolve(); 362 | }); 363 | }); 364 | }); 365 | } 366 | 367 | use(pathOrCallback, ...callbacks) { 368 | if (typeof (pathOrCallback) === "function") { 369 | callbacks.unshift(pathOrCallback); 370 | pathOrCallback = () => true; 371 | } 372 | this.route(pathOrCallback).all(async (request, response, next) => { 373 | for (const cb of callbacks) { 374 | if ("route" === await runCallback(cb, request, response)) break; 375 | } 376 | next(); 377 | }); 378 | return this; 379 | } 380 | 381 | static fosify(app, functions, options) { 382 | options = Object.assign({}, options, {middleware: true}); 383 | const fos = new FOS(functions, options); 384 | if (app.context && app.callback) { // Koa app has these, Express does not 385 | app.use(async (ctx, next) => { 386 | await next(); 387 | if (ctx.request.path === "/fos" || /\/fos\/.*/.test(ctx.request.path)) { 388 | fos(ctx.request.req, ctx.response.res); 389 | } 390 | }) 391 | } else { 392 | app.route("/fos").get(fos); 393 | app.route(/\/fos\/.*/).get(fos); 394 | } 395 | } 396 | 397 | static async request(path) { 398 | const {request, response} = this, 399 | fos = request.fos, 400 | uparts = path.split("/"), 401 | params = Object.assign({}, fos.params); 402 | for (const route of fos.routes) { 403 | const path = route.path, 404 | type = typeof (path), 405 | pparts = type === "string" ? path.split("/") : []; 406 | let result; 407 | for (let i = 0; i < pparts.length && i < uparts.length; i++) { 408 | const ppart = pparts[i], 409 | upart = uparts[i]; 410 | if (params[ppart]) { 411 | result = await runCallback(params[ppart], request, response, uparts[i]); 412 | delete params[ppart]; 413 | if (result === "route") break; 414 | } else if (ppart && ppart !== upart) { 415 | //result="route"; 416 | break; 417 | } 418 | } 419 | if (result === "route" || pparts.length > uparts.length) continue; 420 | if ((type === "function" && path(request)) || (type === "object" && path instanceof RegExp && path.test(request.pathname)) || (type === "string" && path.indexOf(request.pathname.substring(0, path.length) === 0))) { 421 | for (const callback of route.all) { 422 | result = await runCallback(callback, request, response); 423 | if (result === "route") { 424 | result = null; 425 | break; 426 | } 427 | if (result === "done") { 428 | return; 429 | } 430 | } 431 | const verb = request.method.toLowerCase(); 432 | if (route[verb]) { 433 | for (const callback of route[verb]) { 434 | result = await runCallback(callback, request, response); 435 | if (result === "route") { 436 | result = null; 437 | break; 438 | } 439 | if (result === "done") { 440 | return; 441 | } 442 | } 443 | } 444 | } 445 | } 446 | } 447 | } 448 | 449 | VERBS.forEach(key => { 450 | FOS.prototype[key] = function (path, ...callbacks) { 451 | return this.route(path)[key](...callbacks); 452 | } 453 | }); 454 | FOS.prototype.get = function (pathOrKey, ...callbacks) { 455 | if (typeof (pathOrKey) === "string" && callbacks.length === 0) { 456 | return this.settings[pathOrKey]; 457 | } 458 | return this.route(pathOrKey).get(...callbacks); 459 | } 460 | 461 | module.exports = FOS; 462 | 463 | }).call(this); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fos", 3 | "version": "v0.0.10a", 4 | "description": "Function Oriented Server: The easy way to expose JavaScript APIs to clients as micro-services.", 5 | "engines": { 6 | "node": ">=10.0.0" 7 | }, 8 | "license": "MIT", 9 | "scripts": {}, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/anywhichway/fos.git" 13 | }, 14 | "keywords": [], 15 | "author": "Simon Y. Blackwell (http://www.github.com/anywhichway)", 16 | "bugs": { 17 | "url": "https://github.com/anywhichway/fos/issues" 18 | }, 19 | "homepage": "https://github.com/anywhichway/fos#readme", 20 | "devDependencies": { 21 | "cookie-parser": "^1.4.3", 22 | "markdown-it": "^13.0.2" 23 | } 24 | } 25 | --------------------------------------------------------------------------------