ares-ide
Version:
A browser-based code editor and UI designer for Enyo 2 projects
799 lines (697 loc) • 23.8 kB
JavaScript
/* jshint node:true */
/*global setImmediate*/
/**
* ARES IDE server
*/
var fs = require("fs"),
path = require("path"),
createDomain = require('domain').create,
express = require("express"),
npmlog = require('npmlog'),
nopt = require('nopt'),
util = require('util'),
spawn = require('child_process').spawn,
querystring = require("querystring"),
versionTools = require('./hermes/lib/version-tools'),
http = require('http'),
HttpError = require("./hermes/lib/httpError");
// __dirname is not defined by node-webkit
var myDir = typeof(__dirname) !== 'undefined' ? __dirname : path.resolve('') ;
/**********************************************************************/
var knownOpts = {
"help": Boolean,
"runtest": Boolean,
"browser": Boolean,
"bundled-browser": Boolean,
"port": Number,
"host": String,
"timeout": Number,
"listen_all": Boolean,
"config": path,
"level": ['silly', 'verbose', 'info', 'http', 'warn', 'error'],
"log": Boolean,
"version": Boolean
};
var shortHands = {
"h": ["--help"],
"T": ["--runtest"],
"b": ["--browser"],
"B": ["--bundled-browser"],
"p": ["--port"],
"H": ["--host"],
"t": ["--timeout"],
"a": ["--listen_all"],
"c": ["--config"],
"l": ["--level"],
"L": ["--log"],
"V": ["--version"]
};
var argv = nopt(knownOpts, shortHands, process.argv, 2 /*drop 'node' & 'ide.js'*/);
argv.config = argv.config || path.join(myDir, "ide.json");
argv.host = argv.host || "127.0.0.1";
argv.port = argv.port || 9009;
argv.timeout = argv.timeout || (4*60*1000); //default: 4 minutes.
if (process.env['ARES_BUNDLE_BROWSER'] && !argv['bundled-browser']) {
delete process.env['ARES_BUNDLE_BROWSER'];
}
if (argv.help) {
console.log("\n" +
"Ares IDE, a front-end designer/editor web applications.\n" +
"\n" +
"Usage: 'node ./ide.js' [OPTIONS]\n" +
"\n" +
"OPTIONS:\n" +
" -h, --help help message [boolean]\n" +
" -T, --runtest Run the non-regression test suite [boolean]\n" +
" -b, --browser Open the default browser on the Ares URL [boolean]\n" +
" -B, --bundled-browser Open the included browser on the Ares URL [boolean]\n" +
" -p, --port b port (o) local IP port of the express server (default: 9009, 0: dynamic) [default: '9009']\n" +
" -H, --host b host to bind the express server onto [default: '127.0.0.1']\n" +
" -t, --timeout b milliseconds of inactivity before a server socket is presumed to have timed out [default: '240000']\n" +
" -a, --listen_all b When set, listen to all adresses. By default, listen to the address specified with -H [boolean]\n" +
" -c, --config b IDE configuration file [default: './ide.json']\n" +
" -l, --level b IDE debug level ('silly', 'verbose', 'info', 'http', 'warn', 'error') [default: 'http']\n" +
" -L, --log b Log IDE debug to ./ide.log [boolean]\n");
process.exit(0);
}
/**********************************************************************/
var log = npmlog;
log.heading = 'ares';
log.level = 'http';
log.level = argv.level || 'http';
if (argv.log) {
log.stream = fs.createWriteStream('ide.log');
}
versionTools.setLogger(log);
if (argv.version) {
versionTools.showVersionAndExit();
}
versionTools.checkNodeVersion(); // Exit in case of error
function m() {
var arg, msg = '';
for (var argi = 0; argi < arguments.length; argi++) {
arg = arguments[argi];
if (typeof arg === 'object') {
msg += util.inspect(arg);
} else {
msg += arg;
}
msg += ' ';
}
return msg;
}
log.info('main', m("Arguments:", argv));
/**********************************************************************/
// Exit path
process.on('uncaughtException', function (err) {
log.error('main', err.stack);
process.exit(1);
});
process.on('exit', onExit);
process.on('SIGINT', onExit);
// Load IDE configuration & start per-project file servers
var ide = {};
var subProcesses = [];
var platformVars = [
{regex: /@NODE@/, value: process.argv[0]},
{regex: /@CWD@/, value: process.cwd()},
{regex: /@INSTALLDIR@/, value: myDir},
{regex: /@HOME@/, value: process.env[(process.platform == 'win32') ? 'USERPROFILE' : 'HOME']}
];
var platformOpen = {
win32: [ "cmd" , '/c', 'start' ],
darwin:[ "open" ],
linux: [ "xdg-open" ]
};
var configPath, tester;
var configStats;
var aresAboutData;
var serviceMap = {};
if (argv.runtest) {
tester = require('./test/tester/main.js');
configPath = path.resolve(myDir, "ide-test.json");
} else{
configPath = argv.config;
}
function checkFile(inFile) {
var fileStats;
if (!fs.existsSync(inFile)) {
throw new Error("Did not find: '"+inFile+"': ");
}
fileStats = fs.lstatSync(inFile);
if (!fileStats.isFile()) {
throw new Error("Not a file: '"+inFile+"': ");
}
return fileStats;
}
function loadMainConfig(configFile) {
configStats = checkFile(configFile);
log.verbose('loadMainConfig()', "Loading ARES configuration from '" + configFile + "'...");
var configContent = fs.readFileSync(configFile, 'utf8');
try {
ide.res = JSON.parse(configContent);
} catch(e) {
throw "Improper JSON: "+configContent;
}
if (!ide.res.services || !ide.res.services[0]) {
throw new Error("Corrupted '"+configFile+"': no storage services defined");
}
}
function loadPackageConfig() {
var packagePath = path.resolve(myDir, "package.json");
checkFile(packagePath);
var packageContentJSON = fs.readFileSync(packagePath, 'utf8');
try {
var packageContent = JSON.parse(packageContentJSON);
aresAboutData = {
"version": packageContent.version,
"bugReportURL": packageContent.bugs.url,
"license": packageContent.license,
"projectHomePage": packageContent.homepage
};
} catch(e) {
throw new Error("Improper JSON: " + packagePath);
}
}
function getObjectType(object) {
return util.isArray(object) ? "array" : typeof object;
}
function mergePluginConfig(service, newdata, configFile) {
log.verbose('mergePluginConfig()', "Merging service '" + (service.name || service.id) + "' to ARES configuration");
try {
for(var key in newdata) {
var srcType = getObjectType(service[key]);
var dstType = getObjectType(newdata[key]);
if (srcType === 'undefined') {
service[key] = newdata[key];
} else if (srcType !== dstType) {
throw "Incompatible elements '" + key + "'. Unable to merge " + configFile;
} else if (srcType === 'array') {
for(var idx = 0; idx < newdata[key].length; idx++) {
service[key].push(newdata[key][idx]);
}
} else if (srcType === 'object') {
for(var subkey in newdata[key]) {
log.verbose('mergePluginConfig()', "Adding or replacing " + subkey + " in " + key);
service[key][subkey] = newdata[key][subkey];
}
} else {
service[key] = newdata[key];
}
}
log.verbose('mergePluginConfig()', "Merged service: " + JSON.stringify(service, null, 2));
} catch(err) {
log.warn('mergePluginConfig()', err);
throw new Error("Unable to merge '" + configFile + "'");
}
}
function appendPluginConfig(configFile) {
log.verbose('appendPluginConfig()', "Loading ARES plugin configuration from '"+configFile+"'...");
var pluginDir = path.dirname(configFile);
log.verbose('appendPluginConfig()', 'pluginDir:', pluginDir);
var pluginData, configContent;
try {
configContent = fs.readFileSync(configFile, 'utf8');
pluginData = JSON.parse(configContent);
} catch(e) {
throw new Error("Unable to load or JSON-parse '" + configFile + "' (" + e.toString() + ")");
}
// The service in the plugin configuration file that is both
// active and has a defined 'type` property is the main
// plugin.
var pluginService = pluginData.services.filter(function(service) {
return service.active && service.type;
})[0];
var pluginUrl = '/res/plugins/' + pluginService.id;
log.verbose('appendPluginConfig()', 'pluginUrl:', pluginUrl);
pluginService.pluginDir = pluginDir;
pluginService.pluginUrl = pluginUrl;
pluginData.services.forEach(function(service) {
// Apply regexp to all properties
substVars(service, [
{regex: /@PLUGINDIR@/, value: pluginDir},
{regex: /@PLUGINURL@/, value: pluginUrl}
]);
if (serviceMap[service.id]) {
mergePluginConfig(serviceMap[service.id], service, configFile);
} else {
log.verbose('appendPluginConfig()', "Adding new service '" + service.name + "' to ARES configuration");
ide.res.services.push(service);
serviceMap[service.id] = service;
}
});
}
function loadPluginConfigFiles() {
var modDir, nPlugins = 0;
// Build a service map to merge the plugin services later on
ide.res.services.forEach(function(entry) {
serviceMap[entry.id] = entry;
});
// After 'npm install', 'ares-ide' & its potential plugins are
// located into the same folder named 'node_modules', so we
// first try this runtime configuration. If not found, we
// revert to the development one (look for plugins into under
// the 'ares-project/node_modules').
modDir = path.resolve(myDir, "..");
if (path.basename(modDir) !== 'node_modules') {
modDir = path.join(myDir, 'node_modules');
}
log.info('loadPluginConfigFiles()', "loading plugins from '" + modDir + "'");
// Find and load the plugins configuration, sorted in folder
// names lexicographical order.
var directories = fs.readdirSync(modDir).sort();
directories.forEach(function(directory) {
var filename = path.join(modDir, directory, 'ide-plugin.json');
if (fs.existsSync(filename)) {
nPlugins++;
log.info('loadPluginConfigFiles()', "loading '" + directory + "/ide-plugin.json'");
appendPluginConfig(filename);
}
});
log.info('loadPluginConfigFiles()', "loaded " + nPlugins + " plugins");
}
/**
* load proxy from environment in each service (only if proxy is
* missing from original config)
*/
function loadProxyFromEnv() {
ide.res.services.forEach(function(s){
if (! s.proxyUrl) {
s.proxyUrl = ide.res.globalProxyUrl || process.env.https_proxy || process.env.http_proxy;
}
});
}
loadMainConfig(configPath);
loadPluginConfigFiles();
loadPackageConfig();
loadProxyFromEnv();
// File age/date is the UTC configuration file last modification date
ide.res.timestamp = configStats.atime.getTime();
log.verbose('main', ide.res);
function handleMessage(service) {
return function(msg) {
if (msg.protocol && msg.host && msg.port && msg.origin && msg.pathname) {
service.dest = msg;
log.info(service.id , m("will proxy to service.dest:", service.dest));
service.origin = 'http://' + argv.host + ':' + argv.port;
service.pathname = '/res/services/' + service.id;
if (service.origin.match(/^https:/)) {
log.http(service.id, "connect to <"+service.origin+"> to accept SSL certificate");
}
var options = {
host: service.dest.host,
port: service.dest.port,
path: '/config',
method: 'POST',
headers: {
'content-type': 'application/json'
}
};
log.http(service.id, "POST /config");
var creq = http.request(options, function(cres) {
log.http(service.id, "POST /config", cres.statusCode);
}).on('error', function(e) {
throw e;
});
log.verbose(service.id, "config:", service);
creq.write(JSON.stringify({config: service}, null, 2));
creq.end();
} else {
log.error(service.id, "Error updating service URL");
}
};
}
function serviceOut(service) {
var line = "";
return function(data){
line += data.toString();
if (line[line.length-1] === '\n') {
log.http(service.id, line);
line = "";
}
};
}
function serviceErr(service) {
var line = "";
return function(data){
line += data.toString();
if (line[line.length-1] === '\n') {
log.warn(service.id, line);
line = "";
}
};
}
function handleServiceExit(service) {
return function(code, signal) {
if (signal) {
log.warn(service.id, "killed (signal="+signal+")");
} else {
log.warn(service.id, "abnormal exit (code="+code+")");
if (service.respawn) {
log.warn(service.id, "respawning...");
startService(service);
} else {
log.error('handleServiceExit()', "*** Exiting...");
process.exit(code);
}
}
};
}
function substVars(data, vars) {
var s;
for(var key in data) {
var pType = getObjectType(data[key]);
if (pType === 'string') {
s = data[key];
s = substitute(s, vars);
data[key] = s;
} else if (pType === 'array') {
substVars(data[key], vars);
} else if (pType === 'object') {
substVars(data[key], vars);
}
// else - Nothing to do (no substitution on non-string
// properties)
}
}
function substitute(s, vars) {
vars.forEach(function(subst){
s = s.replace(subst.regex,subst.value);
});
return s;
}
function startService(service) {
// substitute platform variables
substVars(service, platformVars);
// prepare command
var command = service.command;
var params = [];
var options = {
stdio: ['ignore', 'pipe', 'pipe', 'ipc']
};
service.params.forEach(function(inParam){
params.push(inParam);
});
if (service.verbose) {
params.push('-v');
}
// run the command
log.info(service.id, "executing '"+command+" "+params.join(" ")+"'");
var subProcess = spawn(command, params, options);
subProcess.stderr.on('data', serviceErr(service));
subProcess.stdout.on('data', serviceOut(service));
/*
subProcess.stderr.pipe(process.stderr);
subProcess.stdout.pipe(process.stdout);
*/
subProcess.on('exit', handleServiceExit(service));
subProcess.on('message', handleMessage(service));
subProcesses.push(subProcess);
}
var unproxyfiableHeaders = [
"Access-Control-Allow-Methods",
"Access-Control-Allow-Headers",
"Access-Control-Allow-Origin",
"Access-Control-Expose-Headers"
];
function proxyServices(req, res, next) {
log.verbose('proxyServices()', m("req.params:", req.params, ", req.query:", req.query));
var query = {},
id = req.params.serviceId,
service = ide.res.services.filter(function(service) {
return service.id === id;
})[0];
if (!service) {
setImmediate(next, new HttpError('No such service: ' + id, 403));
return;
}
for (var key in req.query) {
if (key && req.query[key]) {
query[key] = req.query[key];
}
}
var options = {
// host to forward to
host: service.dest.host,
// port to forward to
port: service.dest.port,
// path to forward to
path: service.dest.pathname +
(req.params[0] ? '/' + encodeURI(req.params[0]) : '') +
'?' + querystring.stringify(query),
// request method
method: req.method,
// headers to send
headers: req.headers
};
log.verbose('proxyServices()', m("options:", options));
// ENYO-3634: if we proxyfy a CORS request, it's not CORS anymore between ARES and proxyfied service
// so we remove CORS request headers (note that OPTIONS requests should never make it to here!)
if (options.headers && options.headers.origin) {
delete options.headers.origin;
}
var creq = http.request(options, function(cres) {
// transmit every header verbatim, but cookies
log.verbose('proxyServices()', m("cres.headers:", cres.headers));
for (var key in cres.headers) {
var val = cres.headers[key];
if (key.toLowerCase() === 'set-cookie') {
// re-write cookies
var cookies = parseSetCookie(val);
cookies.forEach(translateCookie.bind(this, service, res));
} else {
// ENYO-3634 / CORS is the sole responsibility of the ARES server when services are proxified
// therefore should the service have fixed CORS headers, they are ignored.
if (unproxyfiableHeaders.indexOf(key) === -1) {
res.header(key, val);
}
}
}
res.writeHead(cres.statusCode);
cres.pipe(res);
}).on('error', function(e) {
log.error('proxyServices()', "options:", options);
next(e);
});
req.pipe(creq);
}
function translateCookie(service, res, cookie) {
cookie.options.domain = ide.res.domain || '127.0.0.1';
var oldPath = cookie.options.path;
cookie.options.path = service.pathname + (oldPath ? oldPath : '');
log.silly('translateCookie()', m("cookie.path:", oldPath, "->", cookie.options.path));
log.silly('translateCookie()', m("set-cookie:", cookie));
res.cookie(cookie.name, cookie.value, cookie.options);
}
function parseSetCookie(cookies) {
var outCookies = [];
if (!Array.isArray(cookies)) {
cookies = [ cookies ];
}
cookies.forEach(function(cookie) {
var outCookie = {};
var tokens = cookie.split(/; */);
log.silly("parseSetCookie()", m("tokens:", tokens));
var namevalStr = tokens.splice(0, 1)[0];
if (typeof namevalStr === 'string') {
var nameval = namevalStr.split('=');
outCookie.name = nameval[0];
outCookie.value = nameval[1];
outCookie.options = {};
tokens.forEach(function(token) {
var opt = token.split('=');
outCookie.options[opt[0]] = opt[1] || true;
});
if (typeof outCookie.options.expires === 'string') {
outCookie.options.expires = new Date(outCookie.options.expires);
}
log.silly("parseSetCookie()", m("outCookie:", outCookie));
outCookies.push(outCookie);
} else {
log.silly("parseSetCookie()", m("Invalid Set-Cookie header:", namevalStr));
}
});
log.silly("parseSetCookie()", m("outCookies:", outCookies));
return outCookies;
}
function onExit() {
if (subProcesses.length > 0) {
log.info('onExit()', 'Terminating sub-processes...');
subProcesses.forEach(function(subproc) {
process.kill(subproc.pid, 'SIGINT');
});
subProcesses = [];
log.info('onExit()', 'Exiting...');
}
}
ide.res.services.filter(function(service){
return service.active;
}).forEach(function(service){
if (service.command) {
startService(service);
}
});
// Start the ide server
var enyojsRoot = path.resolve(myDir,".");
var app = express(),
server = http.createServer(app);
/**
* Ares server timeout is defined as the maximum value of services timeout attributes.
* @param {integer} inTimeout default value of the server timeout.
*/
function defineServerTimeout(inTimeout) {
var timeout = inTimeout;
for (var key in ide.res.services) {
if (ide.res.services[key].timeout !== undefined &&
ide.res.services[key].timeout > inTimeout) {
timeout = ide.res.services[key].timeout;
}
}
log.verbose("Timeout between main server and its children is set to : ", timeout ," (ms)");
server.setTimeout(timeout);
}
defineServerTimeout(argv.timeout);
// over-write CORS headers using the configuration if
// any, otherwise be paranoid.
var corsHeaders,
allowedMethods = ['GET', 'PUT', 'POST', 'DELETE'],
exposedHeaders = ['x-content-type'],
allowedHeaders = ['Content-Type','Authorization','Cache-Control','X-HTTP-Method-Override'];
function setCorsHeaders(req, res, next) {
var cors = ide.res.cors || {};
var origins = (Array.isArray(cors.origins) && cors.origins.length > 0 && cors.origins);
// one time setup - allowed methods and headers don't change per request
if (!corsHeaders) {
var methods = Array.isArray(cors.methods) && cors.methods,
headers = Object.keys(ide.res.headers || {});
corsHeaders = {};
corsHeaders['Access-Control-Allow-Methods'] = allowedMethods.concat(methods).join(',');
corsHeaders['Access-Control-Allow-Headers'] = allowedHeaders.concat(headers).join(',');
corsHeaders['Access-Control-Expose-Headers'] = exposedHeaders.concat(headers).join(',');
corsHeaders['Access-Control-Max-Age'] = '86400';
corsHeaders['Access-Control-Allow-Credentials'] = 'true';
log.info("setCorsHeaders()", "CORS will use:", corsHeaders);
}
// request time: is this a CORS request? [CLUE: if yes, there's an origin]
if (req.headers && req.headers.origin) {
if (origins.indexOf("*") !== -1) {
corsHeaders['Access-Control-Allow-Origin'] = "*";
} else {
if (origins.indexOf(req.headers.origin) !== -1) {
corsHeaders['Access-Control-Allow-Origin'] = req.headers.origin;
} else {
corsHeaders['Access-Control-Allow-Origin'] = "";
}
}
for (var h in corsHeaders) {
log.silly("setCorsHeaders()", h, ":", corsHeaders[h]);
// Lowercase HTTP headers, work-around an iPhone bug
res.header(h.toLowerCase(), corsHeaders[h]);
}
}
if ('OPTIONS' === req.method) {
res.status(200).end();
} else {
setImmediate(next);
}
}
function setUserHeaders(req, res, next) {
var headers = ide.res.headers || {};
for (var k in headers) {
var v = headers[k];
log.silly('setUserHeaders()', "adding:", k, ":", v);
res.header(k, v);
}
setImmediate(next);
}
app.configure(function(){
/*
* Error Handling - Wrap exceptions in delayed handlers
*/
app.use(function _useDomain(req, res, next) {
var domain = createDomain();
domain.on('error', function(err) {
next(err);
domain.dispose();
});
domain.enter();
setImmediate(next);
});
app.use(setCorsHeaders);
app.use(setUserHeaders);
app.use(express.favicon(myDir + '/ares/assets/images/ares_48x48.ico'));
app.use('/ide', express.static(enyojsRoot));
app.use('/test', express.static(path.join(enyojsRoot, '/test')));
app.use(express.logger('dev'));
app.get('/', function(req, res, next) {
log.http('main', "GET /");
res.redirect('/ide/ares/');
});
app.get('/res/timestamp', function(req, res, next) {
res.status(200).json({timestamp: ide.res.timestamp});
});
app.get('/res/services', function(req, res, next) {
log.verbose('main', m("GET /res/services:", ide.res.services));
res.status(200).json({services: ide.res.services});
});
app.get('/res/aboutares', function(req, res, next) {
res.status(200).json({aboutAres: aresAboutData});
});
app.all('/res/services/:serviceId/*', proxyServices);
app.all('/res/services/:serviceId', proxyServices);
// access to static files provided by the plugins
ide.res.services.forEach(function(service) {
if (service.pluginUrl && service.pluginDir) {
log.verbose('app.configure()', service.pluginUrl + ' -> ' + service.pluginDir);
app.use(service.pluginUrl, express.static(service.pluginDir));
}
});
if (tester) {
app.post('/res/tester', tester.setup);
app['delete']('/res/tester', tester.cleanup);
}
/**
* Global error handler (last plumbed middleware)
* @private
*/
function errorHandler(err, req, res, next){
log.error('errorHandler()', err.stack);
res.status(500).send(err.toString());
}
// express-3.x: middleware with arity === 4 is
// detected as the error handler
app.use(errorHandler.bind(this));
log.verbose('app.configure()', "done");
});
// Run non-regression test suite
var page = "index.html";
if (argv.runtest) {
page = "test.html";
}
var origin, url;
server.listen(argv.port, argv.listen_all ? null : argv.host, null /*backlog*/, function () {
var tcpAddr = server.address(),
info;
origin = "http://" + (argv.host || "127.0.0.1") + ":" + tcpAddr.port;
url = origin + "/ide/ares/" + page;
if (argv.browser) {
// Open default browser
info = platformOpen[process.platform] ;
spawn(info[0], info.slice(1).concat([url]));
} else if (argv['bundled-browser']) {
// Open bundled browser
var bundledBrowser = process.env['ARES_BUNDLE_BROWSER'];
info = platformOpen[process.platform];
if (bundledBrowser) {
if (process.platform === 'win32') {
info.splice(2, 1); // delete 'start' command
}
info = info.concat([bundledBrowser, '--args']);
}
spawn(info[0], info.slice(1).concat([url]));
} else {
log.http('main', "Ares now running at <" + url + ">");
}
});
log.info('main', "Press CTRL + C to shutdown");