seeden/maglev

View on GitHub
src/app.js

Summary

Maintainability
D
2 days
Test Coverage
import express from 'express';
import debug from 'debug';
import http from 'http';
import isArray from 'lodash/isArray';

import expressDomainMiddleware from 'express-domain-middleware';
import compression from 'compression';
import serveFavicon from 'serve-favicon';
import serveStatic from 'serve-static';
import cookieParser from 'cookie-parser';
import session from 'express-session';
import bodyParser from 'body-parser';
import methodOverride from 'method-override';
import responseTime from 'response-time';
import timeout from 'connect-timeout';
import morgan from 'morgan';
import cors from 'cors';
import lessMiddleware from 'less-middleware';

import request from 'express/lib/request';
import consolidate from 'consolidate';
import flash from 'connect-flash';
import robots from 'robots.txt';
import MiddlewareType from './constants/MiddlewareType';

import * as fileController from './controllers/file';
import * as pageController from './controllers/page';

const log = debug('maglev:app');

function connectionToUnique(conn) {
  return `${conn.remoteAddress}:${conn.remotePort}`;
}


export default class App {
  constructor(server, options = {}) {
    if (!options.root) {
      throw new Error('Root is undefined');
    }

    log(`App root: ${options.root}`);

    this._server = server;
    this._options = options;
    this._expressApp = express();
    this._httpServer = null;
    this._activeConnections = {};

    // prepare basic
    this._prepareErrorHandler();
    this._prepareCompression();
    this._prepareLog();
    this._prepareEngine();
    this._prepareHtml();

    this._prepareMiddleware(MiddlewareType.BEFORE_STATIC);

    // prepare static
    this._prepareStatic();

    // prepare middlewares
    this._prepareVars();
    this._prepareSession();
    this._prepareSecure();
    this._prepareMiddleware(MiddlewareType.BEFORE_ROUTER);
    this._prepareRouter();
    this._prepareMiddleware(MiddlewareType.AFTER_ROUTER);
  }

  get options() {
    return this._options;
  }

  get activeConnections() {
    return this._activeConnections;
  }

  get server() {
    return this._server;
  }

  get httpServer() {
    return this._httpServer;
  }

  get expressApp() {
    return this._expressApp;
  }

  listen(port, host, callback) {
    if (this._httpServer) {
      return callback(new Error('You need to close http server first'));
    }

    this._httpServer = http
      .createServer(this.expressApp)
      .listen(port, host, callback);

    this.handleConnectionEvents();

    return this;
  }

  handleConnectionEvents() {
    // TODO UNHANDLE
    const { activeConnections, httpServer } = this;

    httpServer.on('connection', function onConnectionCallback(connection) {
      const key = connectionToUnique(connection);
      activeConnections[key] = {
        connection,
        requests: 0,
      };

      connection.once('close', function onCloseCallback() {
        if (activeConnections[key]) {
          delete activeConnections[key];
        }
      });
    });

    httpServer.on('request', function onRequestCallback(request, response) {
      const key = connectionToUnique(request.connection);

      const settings = activeConnections[key];
      if (!settings) {
        return;
      }

      settings.requests++;

      response.once('finish', function onFinishCallback() {
        const settings = activeConnections[key];
        if (!settings) {
          return;
        }

        settings.requests--;
      });
    });
  }

  _destroyUnusedConnections() {
    const { activeConnections } = this;

    // remove unused connections
    Object.keys(activeConnections).forEach(function destroyConnection(key) {
      const settings = activeConnections[key];
      if (settings.requests) {
        return;
      }

      settings.connection.destroy();
      delete activeConnections[key];
    });
  }

  close(callback) {
    const { activeConnections, httpServer, options } = this;

    if (!httpServer) {
      return callback(new Error('You need to listen first'));
    }

    log('Closing http server');
    httpServer.close((err) => {
      if (err) {
        return callback(err);
      }

      this._httpServer = null;

      // check current state of the connections
      if (!Object.keys(activeConnections).length) {
        log('There is no idle connections');
        return callback();
      }

      log(`Starting idle connection timeout ${options.socket.idleTimeout}`);
      setTimeout(() => {
        Object.keys(activeConnections).forEach((key) => {
          const settings = activeConnections[key];
          if (!settings) {
            return;
          }

          log(`Destroying connection: ${key}`);
          settings.connection.destroy();
        });

        log('All connections destroyed');
        callback();
      }, options.socket.idleTimeout);
    });

    // destroy connections without requests
    this._destroyUnusedConnections();

    return this;
  }

  _prepareErrorHandler() {
    const app = this.expressApp;

    app.use(expressDomainMiddleware);
  }

  _prepareCompression() {
    const app = this.expressApp;
    const options = this.options;

    if (!options.compression) {
      return;
    }

    app.use(compression(options.compression));
  }

  _prepareLog() {
    const app = this.expressApp;
    const options = this.options;

    if (!options.log) {
      return;
    }

    app.set('showStackError', true);

    if (!options.morgan) {
      return;
    }
    app.use(morgan(options.morgan.format, options.morgan.options));
  }

  _prepareEngine() {
    const app = this.expressApp;
    const options = this.options;

    app.locals.pretty = true;
    app.locals.cache = 'memory';
    app.enable('jsonp callback');

    app.engine('html', consolidate[options.view.engine]);

    app.set('view engine', 'html');
    app.set('views', `${options.root}/views`);
  }

  _prepareHtml() {
    const app = this.expressApp;
    const options = this.options;

    if (!options.powered) {
      app.disable('x-powered-by');
    }

    if (options.responseTime) {
      app.use(responseTime(options.responseTime));
    }

    if (options.cors) {
      app.use(cors(options.cors));
    }

    if (options.request.timeout) {
      app.use(timeout(options.request.timeout));
    }

    if (options.cookieParser) {
      app.use(cookieParser(options.cookieParser.secret, options.cookieParser.options));
    }

    if (options.bodyParser) {
      for (let index = 0; index < options.bodyParser.length; index++) {
        const bp = options.bodyParser[index];
        app.use(bodyParser[bp.parse](bp.options));
      }
    }

    if (options.methodOverride) {
      app.use(methodOverride(options.methodOverride.getter, options.methodOverride.options));
    }
  }

  _prepareVars() {
    const app = this.expressApp;
    const server = this.server;
    const options = this.options;

    // add access to req from template
    app.use(function setTemplateVariables(req, res, next) {
      res.locals._req = req;
      res.locals._production = process.env.NODE_ENV === 'production';
      res.locals._build = options.server.build;

      next();
    });

    // add access to req from template
    app.use(function setBasicVariables(req, res, next) {
      req.objects = {};
      req.server = server;
      req.models = server.models;

      next();
    });
  }

  _prepareSession() {
    const app = this.expressApp;
    const options = this.options;

    if (!options.session) {
      return;
    }

    // use session middleware
    const sessionMiddleware = session(options.session);
    app.use(sessionMiddleware);

    if (!options.sessionRecovery) {
      return;
    }

    // session recovery
    app.use(function sessionRecovery(req, res, next) {
      let tries = options.sessionRecovery.tries;

      function lookupSession(error) {
        if (error) {
          return next(error);
        }

        if (typeof req.session !== 'undefined') {
          return next();
        }

        tries -= 1;

        if (tries < 0) {
          return next(new Error('Session is undefined'));
        }

        sessionMiddleware(req, res, lookupSession);
      }

      lookupSession();
    });
  }

  _prepareSecure() {
    const app = this.expressApp;
    const server = this.server;
    const options = this.options;

    app.use(server.secure.passport.initialize());

    if (options.session) {
      app.use(server.secure.passport.session());
    }
  }

  _prepareStatic() {
    const app = this.expressApp;
    const options = this.options;

    if (options.flash) {
      app.use(flash());
    }

    try {
      if (options.favicon) {
        log(`FavIcon root: ${options.favicon.root}`);
        app.use(serveFavicon(options.favicon.root, options.favicon.options));
      }

      if (options.robots) {
        log(`Robots root: ${options.robots.root}`);
        app.use(robots(options.robots.root));
      }
    } catch (err) {
      if (err.code !== 'ENOENT') {
        throw err;
      }

      log(err.message);
    }

    if (options.css) {
      log(`CSS root: ${options.css.root}`);
      app.use(options.css.path, lessMiddleware(options.css.root, options.css.options));
    }

    if (options.static) {
      if (!options.static.path || !options.static.root) {
        throw new Error('Static path or root is undefined');
      }

      log(`Static root: ${options.static.root}`);
      app.use(options.static.path, serveStatic(options.static.root, options.static.options));
    }
  }

  _prepareRouter() {
    const app = this.expressApp;
    const options = this.options;
    const server = this.server;

    // use server router
    app.use(server.router.expressRouter);

    // delete uploaded files
    app.use(fileController.clearAfterError); // error must be first
    app.use(fileController.clear);

    // at the end add 500 and 404
    app.use(options.page.notFound || pageController.notFound);
    app.use(options.page.error || pageController.error);
  }

  _prepareMiddleware(type) {
    const app = this.expressApp;
    const options = this.options;
    const middlewares = options.middleware;

    if (!middlewares || !middlewares[type]) {
      return;
    }

    const middleware = middlewares[type];

    if (typeof middleware === 'function') {
      app.use(middleware);
    } else if (isArray(middleware)) {
      middleware.forEach((fn) => {
        app.use(fn);
      });
    }
  }
}

function prepareRequest(req) {
  req.__defineGetter__('httpHost', function getHttpHost() {
    const trustProxy = this.app.get('trust proxy');
    const host = trustProxy && this.get('X-Forwarded-Host');

    return host || this.get('Host');
  });

  req.__defineGetter__('port', function getPort() {
    const host = this.httpHost;
    if (!host) {
      return null;
    }

    const parts = host.split(':');
    return (parts.length === 2) ? parseInt(parts[1], 10) : 80;
  });

  req.__defineGetter__('protocolHost', function getProtocolHost() {
    return `${this.protocol}://${this.httpHost}`;
  });
}

prepareRequest(request);