Home Identifier Source Repository

src/Server.js

/**
 * @module Server.js
 * @author Joe Groseclose <@benderTheCrime>
 * @date 8/16/2015
 */

// System Modules
import repl from                        'repl';
import http from                        'http';
import https from                       'https';
import {Client} from                    'fb-watchman';
import {cyan} from                      'chalk';
import {default as $Injector} from      'angie-injector';
import $LogProvider from                'angie-log';

// Angie Modules
import {config} from                    './Config';
import app from                         './Angie';
import $Request from                    './services/$Request';
import $Response, {
    ErrorResponse,
    $CustomResponse
} from                                  './services/$Response';

const RESPONSE_HEADER_MESSAGES = $Injector.get('RESPONSE_HEADER_MESSAGES'),
    CLIENT = new Client(),
    SUB = {
        expression: [
            'anyof', [ 'match', '*.js' ], [ 'match', '*.es6' ] ],
        fields: []
    };
let webserver,
    shell;

/**
 * @desc $$watch is an Angie implementation of Facebook Watchman for NodeJS.
 *
 * It is mentioned in the README that to use the `watch` command, it is
 * necessary to install the Watchman binaries.
 *
 * This is the function that fires when the `angie watch` command is issued. It
 * will subscribe to the target project folder by default. If passed the "devmode"
 * argument, the script will subscribe to the Angie source files.
 *
 * On change to, addition of, or removal of files from the subscribed to
 * directory, the $$watch function will restart the $$server/$$shell depending
 * on the issued action.
 * @since 0.3.2
 * @param {Array} [param=[]] args An array of CLI arguments piped into the
 * function
 * @access private
 */
function $$watch(args = []) {
    const PORT = $$port(args),
        ACTION = args[0] || 'watch',
        WATCH_DIR = /--?devmode/i.test(args) ? __dirname : process.cwd();

    // Check to see whether or not the config specifies the app as `development`
    // and we are creating a web server. If so, pose the user with a warning:
    if (config.development !== true && ACTION === 'watch') {
        $LogProvider.warn(
            `It is not necessary or wise to issue the ${cyan('watch')} ` +
            'command in production'
        );
    }

    return new Promise(function(resolve) {

        // Verify that the user actually has Facebook Watchman installed
        CLIENT.capabilityCheck({}, function (e, r) {
            if (e) {
                throw new Error(e);
            }
            resolve(r);
        });
    }).then(function() {
        return new Promise(function(resolve) {
            CLIENT.command([ `watch-project`, WATCH_DIR ], function (e, r) {
                if (e) {
                    throw new Error(e);
                }
                if ('warning' in r) {
                    $LogProvider.warn(r.warning);
                }
                resolve(r);
            });
        }).then(function(r) {
            $LogProvider.info(`Watch initiated on ${cyan(r.watch)}`);

            return new Promise(function(resolve) {
                CLIENT.command([
                    'subscribe',
                    r.watch,
                    `ANGIE_WATCH`,
                    SUB
                ], function (e, r) {
                    if (e) {
                        throw new Error(e);
                    }
                    resolve(r);
                });
            }).then(function() {
                CLIENT.on('subscription', function (r) {
                    if (r.subscription === 'ANGIE_WATCH') {

                        // Stop any existing webserver
                        if (webserver) {
                            webserver.close();
                        }

                        // Call the passed command to restart the watched
                        // process
                        (args[0] && args[0] === 'watch' ? $$server : $$shell)(
                            [ PORT ]
                        );
                    }
                });
            });
        });
    }).catch(function(e) {
        throw new Error(e);
    });
}

/**
 * @desc $$shell uses the NodeJS REPL to open an Angie-based shell
 *
 * Before load, the $$shell command will load all file dependencies of the
 * target application. All application components will be available in the
 * shell.
 *
 * The $$shell function is only called as a byproduct of the `angie watch`
 * command and will reload all application files on save, on creation, or on
 * removal in the directory specified to the $$watch function.
 * @since 0.3.2
 * @access private
 */
function $$shell() {
    const SHELL_PROMPT = 'angie > ';

    if (shell) {
        process.stdout.write('\n');
    }

    return app.$$load().then(function() {
        process.stdin.setEncoding('utf8');

        // Start a REPL after loading project files
        if (!shell) {
            shell = repl.start({
                prompt: SHELL_PROMPT,
                input: process.stdin,
                output: process.stdout
            });
        } else {
            process.stdout.write(SHELL_PROMPT);
        }
    });
}

/**
 * @desc $$server uses the NodeJS http/https to open an Angie-based web server
 *
 * Before load, the $$server command will load all file dependencies of the
 * target application. All application components will be available to the
 * application running behind the server.
 *
 * The $$server function can be called as a byproduct of the `angie watch`
 * command in which case it will reload all application files on save,
 * on creation, or on removal in the directory specified to the $$watch function.
 * It can also be called independently of the Facebook Watchman application by
 * issuing the `angie server` or `angie s` commands from the CLI.
 * @since 0.3.2
 * @param {Array} [param=[]] args An array of CLI arguments piped into the
 * function
 * @access private
 */
function $$server(args = []) {
    const PORT = $$port(args);

    // Load necessary app components
    app.$$load().then(function() {

        // Start a webserver, use http/https based on port
        webserver = (PORT === 443 ? https : http).createServer(function(req, res) {
            let $request = new $Request(req),
                response = new $Response(res).response,
                requestTimeout;

            // Instantiate the request, get the data
            $request.$$data().then(function() {

                // Add Angie components for the request and response objects
                app.service('$request', $request).service('$response', response);

                // Set a request error timeout so that we ensure every request
                // resolves to something
                requestTimeout = setTimeout(
                    forceEnd.bind(null, $request.path, response),
                    config.hasOwnProperty('responseErrorTimeout') ?
                        config.responseErrorTimeout : 5000
                );

            // Route the request
            }).then(() => $request.$$route()).then(function() {
                let code = response.statusCode,
                    log = 'error';

                // Clear the request error because now we are guaranteed some
                // sort of response
                clearTimeout(requestTimeout);

                // Provide log information based on the application response
                if (code < 400) {
                    log = 'info';
                } else if (code < 500) {
                    log = 'warn';
                }

                $LogProvider[ log ](
                    req.method,
                    $request.path,
                    response._header || ''
                );

                // Call this inside route block to make sure that we only
                // return once
                end(response);
            }).catch(function(e) {
                new ErrorResponse(e).head().writeSync();
                $LogProvider.error(
                    req.method,
                    $request.path,
                    response._header || ''
                );

                // Call this inside route block to make sure that we only
                // return once
                end(response);
            });
        }).listen(PORT);

        // Info
        $LogProvider.info(`Serving on port ${PORT}`);

        function end(response) {

            // End the response
            response.end();

            // After we have finished with the response, we can tear down
            // request/response specific components
            app.$$tearDown('$request', '$response');
        }

        // Force an ended response with a timeout
        function forceEnd(path, response) {

            // Send a custom response for gateway timeout
            new $CustomResponse().head(504, null, {
                'Content-Type': 'text/html'
            }).writeSync(`<h1>${RESPONSE_HEADER_MESSAGES[ 504 ]}</h1>`);

            // Log something
            $LogProvider.error(path, response._header);

            // End the response
            end(response);
        }
    });
}

function $$port(args) {
    return /\--?usessl/i.test(args) ? 443 : !isNaN(+args[1]) ? +args[1] : 3000;
}

export {
    $$watch,
    $$server,

    // Exposed for testing purposes
    $$shell
};