UNPKG

chimera-framework

Version:

Language agnostic framework for stand-alone and distributed computing

506 lines (473 loc) 18.9 kB
'use strict' require('cache-require-paths') module.exports = { createApp, createServer, createWebSocket, isRouteMatch, getRouteMatches, getParametersAsObject } const nsync = require('neo-async') const compression = require('compression') const staticCache = require('express-static-cache') const stringify = require('json-stringify-safe') const express = require('express') const favicon = require('serve-favicon') const cookieParser = require('cookie-parser') const bodyParser = require('body-parser') const session = require('express-session') const fileUpload = require('express-fileupload') const engines = require('consolidate') const url = require('url') const http = require('http') const socketIo = require('socket.io') const core = require('./core.js') const util = require('./util.js') const COLOR_FG_YELLOW = '\x1b[33m' const COLOR_RESET = '\x1b[0m' const VERBOSE = 1 const VERY_VERBOSE = 2 const DEFAULT_WEB_CONFIG = { mongoUrl: '', verbose: 0, staticPath: process.cwd() + '/public', faviconPath: process.cwd() + '/public/favicon.ico', viewPath: process.cwd() + '/views', viewEngines: { ejs: 'ejs', pug: 'pug' }, defaultTemplate: null, errorTemplate: null, sessionSecret: 'dbbd821f0f40735ca5c2e03ad93bc79b', sessionMaxAge: 600000, sessionSaveUnitialized: true, sessionResave: true, startupHook: null, // Input: STATE. output: state beforeRequestHook: null, // Input: STATE. output: state afterRequestHook: null, // Input: STATE. output: state routes: {}, vars: {} } const DEFAULT_REQUEST = { 'query': {}, 'body': {}, 'baseUrl': {}, 'cookies': {}, 'session': {}, 'files': {}, 'params': [], 'hostname': '', 'method': 'get', 'protocol': 'http', 'url': '' } const DEFAULT_RESPONSE = { 'data': '', 'view': '', 'session': {}, 'cookies': {}, 'status': 200, 'errorMessage': '' } const FORBIDDEN_RESPONSE_KEY = ['domain', '_events', '_eventsCount', '_maxListeners', 'output', 'outputEncodings', 'outputCallbacks', 'outputSize', 'writable', '_last', 'upgrading', 'chunkedEncoding', 'shouldKeepAlive', 'useChunkedEncodingByDefault', 'sendDate', '_removedHeader', '_contentLength', '_hasBody', '_trailer', 'finished', '_headerSent', 'socket', 'connection', '_header', '_headers', '_headerNames', '_onPendingData', 'req', 'locals', '_startAt', '_startTime', 'writeHead', '__onFinished', 'end', 'app', 'status', 'links', 'send', 'json', 'jsonp', 'sendStatus', 'sendFile', 'sendfile', 'download', 'type', 'contentType', 'format', 'attachment', 'append', 'header', 'set', 'get', 'clearCookie', 'cookie', 'location', 'redirect', 'vary', 'render', '_finish', 'statusCode', 'statusMessage', 'assignSocket', 'detachSocket', 'writeContinue', '_implicitHeader', 'writeHeader', 'setTimeout', 'destroy', '_send', '_writeRaw', '_buffer', '_storeHeader', 'setHeader', 'getHeader', 'removeHeader', '_renderHeaders', 'headersSent', 'write', 'addTrailers', '_flush', '_flushOutput', 'flushHeaders', 'flush', 'pipe', 'setMaxListeners', 'getMaxListeners', 'emit', 'addListener', 'on', 'prependListener', 'once', 'prependOnceListener', 'removeListener', 'removeAllListeners', 'listeners', 'listenerCount', 'eventNames'] const FORBIDDEN_REQUEST_KEY = ['_readableState', 'readable', 'domain', '_events', '_eventsCount', '_maxListeners', 'socket', 'connection', 'httpVersionMajor', 'httpVersionMinor', 'httpVersion', 'complete', 'headers', 'rawHeaders', 'trailers', 'rawTrailers', 'upgrade', 'url', 'method', 'statusCode', 'statusMessage', 'client', '_consuming', '_dumped', 'next', 'baseUrl', 'originalUrl', '_parsedUrl', 'params', 'query', 'res', '_startAt', '_startTime', '_remoteAddress', 'body', 'secret', 'cookies', 'signedCookies', '_parsedOriginalUrl', 'sessionStore', 'sessionID', 'session', 'route', 'app', 'header', 'get', 'accepts', 'acceptsEncodings', 'acceptsEncoding', 'acceptsCharsets', 'acceptsCharset', 'acceptsLanguages', 'acceptsLanguage', 'range', 'param', 'is', 'protocol', 'secure', 'ip', 'ips', 'subdomains', 'path', 'hostname', 'host', 'fresh', 'stale', 'xhr', 'setTimeout', 'read', '_read', 'destroy', '_addHeaderLines', '_addHeaderLine', '_dump', 'push', 'unshift', 'isPaused', 'setEncoding', 'pipe', 'unpipe', 'on', 'addListener', 'resume', 'pause', 'wrap', 'setMaxListeners', 'getMaxListeners', 'emit', 'prependListener', 'once', 'prependOnceListener', 'removeListener', 'removeAllListeners', 'listeners', 'listenerCount', 'eventNames'] function getEscapedRouteKey (route) { // hyphen should be translated literally let str = route.replace(/-/g, '\\-') // dots should be translated literally str = str.replace(/\./g, '\\.') return str } function getRoutePattern (route) { // object (including regex pattern) should not be processed if (typeof route === 'string') { let str = getEscapedRouteKey(route) // translate into regex str = str.replace(/:[a-zA-Z_][a-zA-Z0-9_]*/g, '([a-zA-Z0-9_]*)') str = '^' + str + '$' const regex = new RegExp(str) return regex } return route } function getParameterNames (route) { if (typeof route === 'string') { route = getEscapedRouteKey(route) } let matches = route.match(/:([a-zA-Z_][a-zA-Z0-9_]*)/g) if (matches === null) { matches = [] } for (let i = 0; i < matches.length; i++) { matches[i] = matches[i].replace(':', '') } return matches } function isRouteMatch (route, urlPath) { return !!getRouteMatches(route, urlPath) } function getRouteMatches (route, urlPath) { const routePattern = getRoutePattern(route) return urlPath.match(routePattern) || urlPath.replace(/(.*)\/$/, '$1').match(routePattern) } function getParametersAsObject (route, urlPath) { const routeMatches = getRouteMatches(route, urlPath) const parameterNames = getParameterNames(route) let parameters = {} for (let i = 0; i < parameterNames.length; i++) { const parameterName = parameterNames[i] parameters[parameterName] = routeMatches[i + 1] } return parameters } function getAppWithMiddleware (app, config) { app.use(compression()) try { if (!util.isNullOrUndefined(config.staticPath)) { app.use('/', express.static(config.staticPath)) app.use(staticCache(config.staticPath, { maxAge: 'staticMaxAge' in config ? config.staticMaxAge : 365 * 24 * 60 * 60 })) } if (!util.isNullOrUndefined(config.faviconPath)) { app.use(favicon(config.faviconPath)) } } catch (error) { console.error(error) } try { if (!util.isNullOrUndefined(config.viewPath)) { app.set('view cache', true) app.set('views', config.viewPath) } } catch (error) { console.error(error) } app.use(bodyParser.json()) app.use(bodyParser.urlencoded({ extended: true })) app.use(cookieParser()) app.use(fileUpload()) // use view engines const extensions = Object.keys(config.viewEngines) for (let i = 0; i < extensions.length; i++) { const extension = extensions[i] const engine = engines[config.viewEngines[extension]] app.engine(extension, engine) } app.use(session({ 'secret': config.sessionSecret, 'resave': config.sessionResave, 'saveUninitialized': config.sessionSaveUnitialized, 'cookie': {'maxAge': config.sessionMaxAge} })) return app } function getPacked (originalObject, defaultObject, forbiddenKey) { let packed = {} const defaultKeys = Object.keys(defaultObject) for (let i = 0; i < defaultKeys.length; i++) { const key = defaultKeys[i] packed[key] = defaultObject[key] } const originalKeys = Object.keys(originalObject) for (let i = 0; i < originalKeys.length; i++) { const key = originalKeys[i] if (key in defaultObject || forbiddenKey.indexOf(key) < 0) { packed[key] = originalObject[key] } } return packed } function getWebState (config, request, response) { const webStateConfig = util.getPatchedObject(DEFAULT_WEB_CONFIG, config) return { 'config': webStateConfig, 'request': getPacked(request, DEFAULT_REQUEST, FORBIDDEN_REQUEST_KEY), 'response': getPacked(response, DEFAULT_RESPONSE, FORBIDDEN_RESPONSE_KEY) } } function createResponder (app, webConfig) { return (request, response) => { // initiate state and newState processor let STATE = getWebState(webConfig, request, response) // express's response status is a chainable function, and we want to use simple number instead STATE.response.status = 200 // process nsync.series([ createHookProcessor('startupHook'), routeProcessor, // set STATE.matchedRoute if there is a matching route, delete STATE.config.routes createHookProcessor('beforeRequestHook'), createChainProcessor(), createHookProcessor('afterRequestHook') ], (error) => { if (error) { console.error(error) } finalProcess(app, request, response) }) function logMessage (message, verbosity) { const configVerbosity = 'config' in STATE ? STATE.config.verbose : webConfig.verbose if (verbosity > configVerbosity) { return null } const isoDate = (new Date()).toISOString() return console.error(COLOR_FG_YELLOW + '[' + isoDate + '] ' + message + COLOR_RESET) } function logState (description, verbosity) { const configVerbosity = 'config' in STATE ? STATE.config.verbose : webConfig.verbose if (verbosity > configVerbosity) { return null } logMessage(description + ' STATE :\n' + util.getInspectedObject(STATE), verbosity) } function findRouteObj (routeObjList, urlPath, method) { for (let i = 0; i < routeObjList.length; i++) { const routeObj = routeObjList[i] const routeMethod = routeObj.method const route = routeObj.route if ((routeMethod === 'all' || routeMethod === method) && isRouteMatch(route, urlPath)) { return routeObj } } return null } function routeProcessor (callback) { const routeObjList = STATE.config.routes const urlPath = url.parse(STATE.request.url).pathname const method = STATE.request.method.toLowerCase() logMessage('PROCESS ROUTE `' + urlPath + '`', VERBOSE) const routeObj = findRouteObj(routeObjList, urlPath, method) STATE.matchedRoute = routeObj if (util.isNullOrUndefined(routeObj)) { logMessage('CHAIN FOR `' + urlPath + '` DOES NOT EXIST', VERBOSE) } else { logMessage('FIND `' + routeObj.chain + '` FOR `' + urlPath + '`', VERBOSE) } delete STATE.config.routes callback() } function createHookProcessor (hookKey) { const config = STATE.config return function (callback) { const hook = config[hookKey] if (util.isRealObject(hook)) { // hook is object. Use it to patch current state const newState = hook logMessage('BEFORE ' + hookKey, VERBOSE) logState('BEFORE ' + hookKey, VERY_VERBOSE) STATE = util.getPatchedObject(STATE, newState) logMessage('AFTER ' + hookKey, VERBOSE) logState('AFTER ' + hookKey, VERY_VERBOSE) return callback() } else if (util.isString(hook)) { // hook is string, assume it as chain const chain = hook logMessage('BEFORE ' + hookKey, VERBOSE) logState('BEFORE ' + hookKey, VERY_VERBOSE) return core.executeChain(chain, [STATE], config.vars, function (error, newState) { if (!error) { STATE = util.getPatchedObject(STATE, newState) } else { STATE = util.getPatchedObject(STATE, newState) STATE.response.status = 500 STATE.response.errorMessage = 'Internal Server Error' STATE.response.error = error console.error(error) } logMessage('AFTER ' + hookKey, VERBOSE) logState('AFTER ' + hookKey, VERY_VERBOSE) return callback() }) } else { // hook is neither object nor string. Pass on the current state return callback() } } } function createChainProcessor () { const config = STATE.config return function (callback) { if (STATE.response.status >= 400) { return callback() } const routeObj = STATE.matchedRoute if (routeObj) { const urlPath = url.parse(STATE.request.url).pathname const route = routeObj.route if (routeObj.view) { // inject view if it is defined by the route STATE.response.view = routeObj.view } const chain = routeObj.chain STATE.request.params = getParametersAsObject(route, urlPath) logMessage('EXECUTE `' + chain + '`', VERBOSE) try { return core.executeChain(chain, [STATE], config.vars, function (error, newResponse) { if (!error) { if (!util.isRealObject(newResponse)) { newResponse = {'data': String(newResponse)} } STATE.response = util.getPatchedObject(STATE.response, newResponse) } else { STATE.response = util.getPatchedObject(STATE.response, newResponse) STATE.response.status = 500 STATE.response.errorMessage = 'Internal Server Error' STATE.response.error = error console.error(error) } logMessage('EXECUTION COMPLETE', VERBOSE) logState(chain, VERY_VERBOSE) return callback() }) } catch (error) { STATE.response.status = 500 STATE.response.errorMessage = 'Internal Server Error' STATE.response.error = error return callback() } } STATE.response.status = 404 STATE.response.errorMessage = 'Not Found' logMessage('NOT FOUND', VERBOSE) return callback() } } function buildCookieResponse (response) { const cookies = STATE.response.cookies const keys = Object.keys(cookies) for (let i = 0; i < keys.length; i++) { const key = keys[i] if (key !== 'sessionid') { response.cookie(key, STATE.response.cookies[key]) } } return response } function buildSessionRequest (request) { const session = STATE.response.session const keys = Object.keys(session) for (let i = 0; i < keys.length; i++) { const key = keys[i] if (key !== 'cookie') { request.session[key] = STATE.response.session[key] } } return request } function finalProcess (app, request, response) { response = buildCookieResponse(response) request = buildSessionRequest(request, response) // emit to all clients if ('server' in app && 'webSocket' in app.server && '_emit' in STATE.response) { try { const io = app.server.webSocket const emit = STATE.response._emit const rooms = 'rooms' in emit && util.isArray(emit.rooms) ? emit.rooms : [] const event = 'event' in emit && util.isString(emit.event) ? emit.event : '' const args = 'args' in emit && util.isArray(emit.args) ? emit.args : [] // prepare sockets let sockets = io.sockets for (let i = 0; i < rooms.length; i++) { sockets = sockets.to(rooms[i]) } sockets.emit(event, ...args) } catch (error) { console.error(error) } } // render response request.session.save(function (error) { if (error) { console.error(error) } return renderResponse(app, response) }) } function renderResponse (app, response) { const viewExists = (!util.isNullOrUndefined(STATE.response.view)) && (STATE.response.view !== '') response.set('X-XSS-Protection', 0) // standard render if (STATE.response.status < 400 && viewExists) { return response.render(STATE.response.view, STATE.response.data) } // JSON, without view if (!viewExists && ((util.isRealObject(STATE.response.data) || util.isArray(STATE.response.data)))) { return response.send(stringify(STATE.response.data)) } // error if (STATE.response.status >= 400) { response.status(STATE.response.status || 500) response.locals.message = STATE.response.errorMessage response.locals.status = STATE.response.status if (app.get('env') === 'development') { if (STATE.response.error) { response.locals.error = STATE.response.error } else { let err = new Error(STATE.response.errorMessage) err.status = STATE.response.status response.locals.error = err } } else { response.locals.error = {} } if (!util.isNullOrUndefined(STATE.config.errorTemplate)) { return response.render(STATE.config.errorTemplate) } return response.send(String(STATE.response.errorMessage)) } // JSON if (util.isRealObject(STATE.response.data) || util.isArray(STATE.response.data)) { return response.send(stringify(STATE.response.data)) } // null or undefined if (util.isNullOrUndefined(STATE.response.data)) { return response.send('') } // everything else return response.send(String(STATE.response.data)) } } } function createApp (webConfig, ...middlewares) { webConfig = util.getPatchedObject(DEFAULT_WEB_CONFIG, webConfig) // create app with default middlewares const app = getAppWithMiddleware(express(), webConfig) // add custom middlewares to the app for (let i = 0; i < middlewares.length; i++) { const middleware = middlewares[i] if (util.isFunction(middleware)) { app.use(middleware) } else if (util.isRealObject(middleware)) { const routes = Object.keys(middleware) for (let i = 0; i < routes.length; i++) { const route = routes[i] app.use(route, middleware[route]) } } } // add responder to the app const responder = createResponder(app, webConfig) app.all('/*', responder) app.use(function (req, res) { res.status(404).send('No responder found') }) // add socketHandler if ('server' in app && 'webSocket' in app.server && 'socketHandler' in webConfig && util.isFunction(webConfig.socketHandler)) { app.server.webSocket.on('connection', (socket) => { webConfig.socketHandler(socket) }) } return app } function createServer (app) { const server = http.Server(app) app.server = server return server } function createWebSocket (server) { const webSocket = socketIo(server) server.webSocket = webSocket return webSocket }