seeden/maglev

View on GitHub
src/server.js

Summary

Maintainability
A
1 hr
Test Coverage
import fs from 'fs';
import RBAC from 'rbac';
import extend from 'node.extend';
import defaultOptions from './options';
import Router from './router';
import App from './app';
import Secure from './secure';
import Models from './models';
import debug from 'debug';
import domain from 'domain';
import { EventEmitter } from 'events';
import ok from 'okay';
import { each } from 'async';

const log = debug('maglev:server');
const portOffset = parseInt(process.env.NODE_APP_INSTANCE || 0, 10);

function walk(path, fileCallback, callback) {
  fs.readdir(path, ok(callback, (files) => {
    each(files, (file, cb) => {
      const newPath = path + '/' + file;
      fs.stat(newPath, ok(cb, (stat) => {
        if (stat.isFile()) {
          if (/(.*)\.(js$|coffee$)/.test(file)) {
            const model = require(newPath);
            return fileCallback(model, newPath, file, cb);
          }
        } else if (stat.isDirectory()) {
          return walk(newPath, fileCallback, cb);
        }

        cb();
      }));
    }, callback);
  }));
}

export default class Server extends EventEmitter {
  constructor(options, callback) {
    super();

    if (!callback) {
      throw new Error('Please use callback for server');
    }

    options = extend(true, {}, defaultOptions, options);

    if (!options.db) {
      throw new Error('Db is not defined');
    }

    this._options = options;
    this._db = options.db;

    this.catchErrors(() => {
      this.init(options, callback);
    });
  }

  handleError(err) {
    log(err);

    this.emit('err', err);

    this.closeGracefully();
  }

  catchErrors(callback) {
    const dom = domain.create();

    dom.id = 'ServerDomain';
    dom.on('error', (err) => this.handleError(err));

    try {
      dom.run(callback);
    } catch (err) {
      process.nextTick(() => this.handleError(err));
    }
  }

  init(options, callback) {
    // catch system termination
    const signals = ['SIGINT', 'SIGTERM', 'SIGQUIT'];
    signals.forEach((signal) => {
      process.on(signal, () => this.closeGracefully());
    });

    // catch PM2 termination
    process.on('message', (msg) => {
      if (msg === 'shutdown') {
        this.closeGracefully();
      }
    });

    this._rbac = new RBAC(options.rbac.options, ok(callback, () => {
      this._router = new Router(options.router); // router is used in app
      this._models = new Models(this, options.models); // models is used in secure
      this._secure = new Secure(this);

      this._app = new App(this, options);

      this._loadRoutes(ok(callback, () => {
        this._loadModels(ok(callback, () => {
          callback(null, this);
        }));
      }));
    }));
  }

  notifyPM2Online() {
    if (!process.send) {
      return;
    }

    // after callback
    process.nextTick(function notify() {
      process.send('online');
    });
  }

  get options() {
    return this._options;
  }

  get rbac() {
    return this._rbac;
  }

  get db() {
    return this._db;
  }

  get secure() {
    return this._secure;
  }

  get app() {
    return this._app;
  }

  get router() {
    return this._router;
  }

  get models() {
    return this._models;
  }

  listen(callback) {
    if (!callback) {
      throw new Error('Callback is undefined');
    }

    if (this._listening) {
      callback(new Error('Server is already listening'));
      return this;
    }

    this._listening = true;

    const options = this.options;
    this.app.listen(options.server.port + portOffset, options.server.host, ok(callback, () => {
      log(`Server is listening on port: ${this.app.httpServer.address().port}`);

      this.notifyPM2Online();

      callback(null, this);
    }));

    return this;
  }

  close(callback) {
    if (!callback) {
      throw new Error('Callback is undefined');
    }

    if (!this._listening) {
      callback(new Error('Server is not listening'));
      return this;
    }

    this._listening = false;

    this.app.close(callback);

    return this;
  }

  closeGracefully() {
    log('Received kill signal (SIGTERM), shutting down gracefully.');
    if (!this._listening) {
      log('Ended without any problem');
      process.exit(0);
      return;
    }

    let termTimeoutID = null;

    this.close(function closeCallback(err) {
      if (termTimeoutID) {
        clearTimeout(termTimeoutID);
        termTimeoutID = null;
      }

      if (err) {
        log(err.message);
        process.exit(1);
        return;
      }

      log('Ended without any problem');
      process.exit(0);
    });

    const options = this.options;
    termTimeoutID = setTimeout(function timeoutCallback() {
      termTimeoutID = null;
      log('Could not close connections in time, forcefully shutting down');
      process.exit(1);
    }, options.shutdown.timeout);
  }

  _loadModels(callback) {
    const models = this._models;
    const path = this.options.root + '/models';

    walk(path, (model, modelPath, file, cb) => {
      try {
        log(`Loading model: ${modelPath}`);
        models.register(model);
        cb();
      } catch (err) {
        log(`Problem with model: ${modelPath}`);
        cb(err);
      }
    }, ok(callback, () => {
      // preload all models
      models.preload(callback);
    }));
  }

  _loadRoutes(callback) {
    const router = this.router;
    const path = this.options.root + '/routes';

    walk(path, (route, routePath, file, cb) => {
      try {
        log(`Loading route: ${routePath}`);
        const routeFn = route.default ? route.default : route;
        routeFn(router);
        cb();
      } catch (err) {
        log(`Problem with route: ${routePath}`);
        cb(err);
      }
    }, callback);
  }
}