syntheticore/declaire

View on GitHub
src/serverApplication.js

Summary

Maintainability
D
1 day
Test Coverage
require('http').globalAgent.maxSockets = Infinity;
var fs = require('fs');
var path = require('path');

var mongo = require('mongodb');
var express = require('express');

var compression = require('compression')
var morgan = require('morgan');
var bodyParser = require('body-parser');
var lusca = require('lusca');
var favicon = require('serve-favicon');
var errorHandler = require('errorhandler');
var stylus = require('stylus');
var nib = require('nib');
var browserify = require('browserify');
var session = require('express-session');
var contextService = require('request-context');
var MongoStore = require('connect-mongo')(session);

var _ = require('./utils.js');
var Parser = require('./parser.js');
var Evaluator = require('./evaluator.js');
var StreamInterface = require('./serverStreamInterface.js');
var Model = require('./model.js');
var ViewModel = require('./viewModel.js');
var Collection = require('./collection.js');
var Query = require('./query.js');
var DataInterface = require('./serverDataInterface.js');
var REST = require('./REST.js');
var Auth = require('./serverAuth.js');

var oneDay = 86400000;


var ServerApplication = function(options) {
  // Create express app
  var expressApp = express();
  
  var environment = expressApp.get('env');
  
  // Default options
  options = _.merge({
    mongoUrl: process.env.MONGODB_URI || process.env.MONGOHQ_URL || process.env.MONGOLAB_URI || 'mongodb://127.0.0.1:27017/declaire',
    viewsFolder: './src/views/',
    npmPublic: ['/public']
  }, options);
  if(options.mongoDevUrl && environment == 'development') {
    options.mongoUrl = options.mongoDevUrl;
  }
  
  // Logging
  expressApp.use(morgan('combined'));

  // Fix Safari caching bug
  expressApp.use(require('jumanji'));

  // Gzip compression
  expressApp.use(compression());

  // Mongo based sessions
  if(!process.env.SESSION_SECRET) console.warn("Warning: No SESSION_SECRET environment variable set!")
  expressApp.use(session({
    secret: process.env.SESSION_SECRET || 'devSecret',
    store: new MongoStore({url: options.mongoUrl}),
    saveUninitialized: false,
    resave: false,
    cookie: {
      secure: (environment == 'production'),
      maxAge: oneDay
    }
  }));
  
  // Prevent common vulnerabilities and attacks
  expressApp.use(lusca({
    // Cross site request forgery
    // csrf: true,
    // Allow only images from other domains
    csp: {
      policy: {
        // 'default-src': "'self'",
        // 'img-src': '*'
      }
    },
    // Prevent clickjacking
    xframe: 'SAMEORIGIN',
    // Enforce HTTPS
    hsts: {maxAge: 31536000, includeSubDomains: true, preload: true},
    // Prevent cross site scripting
    xssProtection: true
  }));
  
  // Parse form data
  expressApp.use(bodyParser.json({limit: '100mb'}));
  expressApp.use(bodyParser.urlencoded({limit: '100mb', extended: true}));
  
  // Compile Stylus stylesheets
  expressApp.use(stylus.middleware({
    src: './',
    dest: './public',
    compress: true,
    compile: function compile(str, path) {
      return stylus(str)
        .set('filename', path)
        .set('compress', true)
        .use(require('nib')())
        .import('nib');
    }
  }));
  
  // Serve public files
  expressApp.use(express.static('./public', {maxAge: oneDay}));
  _.each(options.npmPublic, function(folder) {
    expressApp.use('/' + _.last(folder.split('/')), express.static('./node_modules/' + folder, {maxAge: oneDay}));
  });
  
  // Serve favicon
  try { expressApp.use(favicon('./public/favicon.png')) } catch(e) {
    console.error("Warning: Your application provides no icon!")
  }
  
  //XXX Remove this
  expressApp.get('/', function(req, res) {
    res.redirect('/pages/index');
  });

  // Package and minify JavaScript
  // Exchanges require calls to this very module with the client side implementation
  var bundle;
  var prepareBundle = function(cb) {
    if(bundle) return cb(bundle);
    process.stdout.write("Preparing Bundle...");
    // Use executed application as main script on the client as well
    var appPath = process.argv[1];
    var b = new browserify({debug: true});
    b.add(appPath);
    b.ignore('newrelic');
    b.ignore('express');
    b.ignore('connect');
    b.ignore('request');
    b.ignore('canvas');
    b.ignore('socket.io');
    _.each(options.npmIgnore, function(ignore) {
      b.ignore(ignore);
    });
    // Exchange require calls
    b.transform(require('aliasify'), {
      aliases: {
        "declaire": "./node_modules/declaire/src/clientAPI.js"
      },
      verbose: false
    });
    if(environment == 'production') {
      b.plugin(require('minifyify'), {output: 'public/bundle.js.map', map: 'bundle.js.map'});
    }
    b.bundle(function(err, buf) {
      if(err) throw err;
      bundle = buf;
      cb(bundle);
    });
  };

  expressApp.get('/bundle.js', function(req, res) {
    res.setHeader('Content-Type', 'application/javascript');
    prepareBundle(function(bundle) {
      res.send(bundle);
    });
  });

  // Let New Relic measure uptime
  expressApp.get('/ping', function(req, res) { res.send('pong'); });

  // Inject bootstrapping script and bundle reference into head tag
  var injectScripts = function(layout) {
    var bootstrap = fs.readFileSync('./node_modules/declaire/src/bootstrap.js', 'utf8');
    _.each(layout.children, function(node) {
      if(node.tag == 'head') {
        // Inject bootstrapping script
        var scriptTag = {
          type: 'HTMLTag',
          tag: 'script',
          children: [{
            type: 'Text',
            content: bootstrap
          }]
        };
        // node.children.push(scriptTag);
        // Inject bundle
        node.children.push({
          type: 'HTMLTag',
          tag: 'script',
          attributes: {
            src: {type: 'static', value: '/bundle.js'},
            async: {type: 'static', value: 'async'}
          },
          children: []
        });
        return false;
      }
    });
  };

  var walkSync = function(dir, cb) {
    if(dir[dir.length - 1] != '/') dir += '/';
    var files = fs.readdirSync(dir);
    files.forEach(function(file) {
      if(fs.statSync(dir + file).isDirectory()) {
        walkSync(dir + file + '/', cb);
      } else {
        cb(dir + file);
      }
    });
  };

  // Load and parse all templates in the views folder
  var parseTrees;
  var parseTemplates = function() {
    parseTrees = {};
    walkSync(options.viewsFolder, function(file) {
      if(path.extname(file) == '.dcl'){
        var fn = path.normalize(file);
        var name = file.replace(options.viewsFolder, '');
        console.log("Parsing " + name);
        try {
          var node = Parser.parseTemplate(fs.readFileSync(fn, 'utf8'));
          parseTrees[name] = node;
        } catch(e) {
          console.error( "Parse error: " + e.message + "\n  at " + fn + ":" + e.lineNum);
          throw e;
        }
      }
    });
    var layout = parseTrees['layout.dcl'];
    if(!layout) {
      console.error("ERROR: The main layout at " + path.normalize(options.viewsFolder + '/layout.dcl') + " could not be found!");
      process.exit(1);
    }
    injectScripts(layout);
  };
  parseTemplates();

  // Make parse trees available to client-side evaluator
  expressApp.get('/templates.json', function(req, res) {
    res.send(JSON.stringify(parseTrees));
  });

  // Save view model declarations for lookup in templates
  var viewModels = {};

  // Generic main model
  var MainModel = Model('_main', {_page: '/'});

  // Build an evaluator for the given URL
  var buildEvaluator = function(url) {
    // Create main model instance
    var mainModel = MainModel.create({_page: url});
    mainModel.set('_page', url);
    app.mainModel = mainModel;
    // Build evaluator for main layout
    var topNode = parseTrees['layout.dcl'];
    var evaluator = Evaluator(topNode, viewModels, parseTrees, StreamInterface(), mainModel);
    return evaluator;
  };

  // Make the logged in user accessible during template evaluation
  expressApp.use(contextService.middleware('declaire'));
  expressApp.use(function (req, res, next) {
    contextService.set('declaire:user', req.session.user);
    next();
  });

  // Render layout for the requested page
  expressApp.get('/pages/*', function(req, res) {
    res.setHeader('Content-Type', 'text/html');
    if(environment == 'development') parseTemplates();
    var evaluator = buildEvaluator(req.url);
    // Stream chunks of rendered html
    evaluator.render().render(function(chunk) {
      res.write(chunk.data);
      res.flush();
      if(chunk.eof) {
        res.end();
      }
    });
  });

  // Show stack traces in development
  if(environment == 'development') {
    expressApp.use(errorHandler({dumpExceptions: true, showStack: true}));
  }

  var app = {
    // Export main model
    mainModel: MainModel.create(),
    
    // Register models and view models for use with this application
    use: function(model) {
      model.app = this;
      if(model.klass == 'ViewModel') {
        viewModels[model.name] = model;
      } else if(model.klass == 'Model') {
        var dataface = DataInterface(this, model);
        model.dataInterface = dataface;
        REST(model.name, expressApp, dataface).serveResource();
      }
      return model;
    },

    // Proxy model constructor
    Model: function(name, reference) {
      return this.use(Model(name, reference));
    },

    // Proxy view model constructor
    ViewModel: function(name, reference, constructor, postCb) {
      return this.use(ViewModel(name, reference, constructor, postCb));
    },

    // Proxy query constructor
    Query: function(modelOrCollection, query, options) {
      return this.use(Query(modelOrCollection, query, options));
    },

    // Connect to backend services, prepare bundle, call back
    init: function(cb) {
      var self = this;
      // Connect to database
      process.stdout.write("Connecting to database...");
      mongo.MongoClient.connect(options.mongoUrl, function(err, dbs) {
        if(err) throw err;
        console.log("done");
        self.db = dbs;
        // Authentication
        Auth.serve(expressApp, self.db);
        // Mongo PubSub
        process.stdout.write("Preparing PubSub...");
        self.pubSub = require('./serverPublisher.js')(expressApp, self.db).init(function() {
          console.log("done");
          // start must be called by application code to listen for requests
          var start = function(cb) {
            var port = options.port || process.env.PORT || 3000;
            var server = expressApp.listen(port, function () {
              console.log("Listening on port " + port);
              cb && cb(server);
            });
          };
          // Make sure we have a cached bundle ready before listening for requests
          prepareBundle(function() {
            console.log("done");
            cb(start, expressApp, self.db)
          });
        });
      });
    }
  };
  return app;
};


module.exports = ServerApplication;