bogart-edge
Version:
Fast JSGI web framework taking inspiration from Sinatra
828 lines (706 loc) • 21.9 kB
JavaScript
var
path = require('path'),
Q = require('./q'),
util = require('./util'),
Router = require('./router').Router,
middleware = require('./middleware'),
when = Q.when,
merge = require('./util').merge,
fs = require('fs'),
view = require('./view'),
inherits = require('util').inherits,
_ = require('underscore'),
EventEmitter = require('events').EventEmitter,
Injector = require('bogart-injector');
exports.version = [0,7,14];
exports.util = require('./util');
exports.q = Q;
/**
* Wraps a Node.JS style asynchronous function `function(err, result) {}`
* to return a `Promise`.
*
* @param {Function} nodeAsyncFn A node style async function expecting a callback as its last parameter.
* @param {Object} context Optional, if provided nodeAsyncFn is run with `this` being `context`.
*
* @returns {Function} A function that returns a promise.
*/
exports.promisify = Q.promisify;
exports.middleware = middleware;
exports.batteries = middleware.batteries;
/**
* Creates a request object given a router and a jsgi request.
* This function is primarily intended to be used internally by bogart; however, it could be
* used by a third party library to compose bogart routers with its own handling mechanisms.
*
* @type Request
*/
exports.request = require('./request');
/**
* Creates a @see ViewEngine
*
* Example:
* bogart.viewEngine("mustache", require("path").join(__dirname, "/templates"))
*
* @param {String} engineName the name of the engine, available: ["mustache", "haml"]
* @param {String} viewsPath Path where the views are located. Defaults to /views
* @member bogart
*/
exports.viewEngine = view.viewEngine;
/**
* A JSGI Application
*
*/
exports.app = function(injector) {
return new App(injector);
};
function App(injector) {
var self = this;
EventEmitter.call(this);
this.started = false;
this.middleware = [];
this.injector = injector || new Injector();
this.injector.value('injector', this.injector);
this._settings = {};
this._usedImplicitRouter = false;
this._router = exports.router(this.injector);
['get','post','put','del'].forEach(function (method) {
self[method] = function () {
this._usedImplicitRouter = true;
self._router[method].apply(self._router, arguments);
};
});
}
inherits(App, EventEmitter);
/**
* Creates a router associated with this App.
* The router is given an Injector that is a
* child of the App's injector.
* The router is automatically added to the
* app, do not call App#use with the result
* of this function.
*/
App.prototype.router = function () {
var router = exports.router(this.injector.createChild());
this.use(router);
return router;
};
App.prototype.use = function(middleware) {
if (this.started) {
throw new Error('Application has already been started. Bogart application may only be configured before it is started.');
}
this.emit('beforeAddMiddleware', this, middleware);
this.middleware.push(middleware);
this.emit('afterAddMiddleware', this, middleware);
};
App.prototype.resource = function (resourceConstructor) {
this.middleware.push(function (injector, req, next) {
var resource = injector.invoke(resourceConstructor);
var locals = {
next: next,
req: req,
};
return injector.invoke(resource.router, resource.router, locals);
});
};
App.prototype.start = function(port, host, jsgiOpts) {
var self = this
, first
, args = Array.prototype.slice.call(arguments);
jsgiOpts = args.shift();
if (typeof jsgiOpts === 'object') {
port = jsgiOpts.port || 8080;
host = jsgiOpts.host || '127.0.0.1';
} else {
port = jsgiOpts;
host = args.shift();
jsgiOpts = args.shift() || {};
}
if (host === undefined) {
host = port;
}
// Add the apps implicit router.
if (this._usedImplicitRouter) {
this.use(this._router);
}
this.emit('beforeStart', this);
var server = exports.start(this.listen.bind(this), require('./util').merge({
port: port || 8080,
host: host || '127.0.0.1'
}, jsgiOpts));
this.started = true;
this.emit('afterStart', this, server);
return server;
};
App.prototype.listen = function (req) {
var reqInjector = this.injector.createChild()
, middleware = this.middleware.concat();
if (middleware.length === 0) {
throw new Error('App#listen found no middleware. Register middleware using App#use');
}
reqInjector
.value('injector', reqInjector)
.value('req', req);
if (middleware.length === 1) {
reqInjector.value('next', null).value('nextApp', null);
return reqInjector.invoke(middleware[0]);
}
var callback = middleware.reverse().reduce(function (a, b, index) {
return function () {
if (index === 1) {
a = (function (fn) {
return function () {
reqInjector.value('next', null).value('nextApp', null);
return reqInjector.invoke(fn, fn);
};
})(a);
}
reqInjector.value('next', a).value('nextApp', a);
return reqInjector.invoke(b, b);
}
});
return callback();
};
App.prototype.setting = function (key, val) {
if (arguments.length === 1) {
return this._settings[key];
} else {
var oldValue = this._settings[key];
this._settings[key] = val;
this.emit('settingChange', key, val, oldValue);
return this;
}
};
/**
* Configuration manager. The function is variadic. The last argument must be a callback. The callback
* will be executed if the BOGART_ENV environment variable matches one of the environments provided. If
* no environments are provided, the function was called with one argument, then the environments default to
* 'all'. The special string 'all' may also be passed to explicitly run the callback in all environments.
*
* Examples:
*
* var app = bogart.app();
*
* bogart.config(function() {
* // Executed in all environments
* app.use(bogart.batteries);
* });
*
* bogart.config('development', function() {
* // Executed only when BOGART_ENV is set to 'development'
* app.use(requestLogger);
* });
*
* bogart.config('staging', 'production', function() {
* // Executed in staging or production
* app.use(redisSession);
* });
*
* @returns undefined
*/
exports.config = function(/* environment1, environment2, ..., environmentN, callback */) {
var args = Array.prototype.slice.call(arguments)
, callback = args.pop()
, environments = 'all';
if (args.length > 0) {
environments = args;
}
if (environments === 'all' || (environments.indexOf(process.env.BOGART_ENV || 'development') !== -1)) {
callback();
}
};
/**
* Creates a bogart router. A router is responsible for routing requests to appropriate handlers.
*
* Example:
*
* var router = bogart.router();
* router.get('/', function() { return bogart.html('Hello World'); });
*
*/
exports.router = Router;
/**
* Starts a server
*
* @param {Function} jsgiApp JSGI application to run
* @param {Object} options Options hash. Supports 'port' property which allows specification of port for server.
* Port defaults to 8080. More options are planned for the future.
*/
exports.start = function(jsgiApp, options) {
return require("jsgi").start(jsgiApp, options);
};
function jsgiResponse(body, opts, defaultHeaders) {
defaultHeaders = defaultHeaders || {};
opts = opts || {};
opts.status = opts.status || 200;
opts.headers = opts.headers || {};
if (!Array.isArray(body) && !(typeof body.forEach === 'function')) {
body = [ body ];
}
return {
status: opts.status,
body: body,
headers: merge(defaultHeaders, opts.headers)
};
}
/**
* Text response. Bogart helper method to create a JSGI response.
* Returns a default JSGI response with body containing the specified text, a status of 200,
* and headers. The headers included are "content-type" of "text" and "content-length" set
* appropriately based upon the length of 'txt' parameter.
*
* @param {String} txt Text for the body of the response.
*/
exports.text = function(txt, opts) {
txt = txt || '';
return jsgiResponse(txt, opts, {
'content-type': 'text',
'content-length': Buffer.byteLength(txt, 'utf-8')
});
};
/**
* HTML response. Bogart helper method to create a JSGI response.
* Returns a default JSGI response with body containing the specified html, a status of 200,
* and headers. The headers included are "content-type" of "text/html" and "content-length" set
* appropriately based upon the length of the 'html' parameter.
*
* @param {String} html HTML for the body of the response
* @param {Object} opts Options to override JSGI response defaults. For example, passing { status: 404 } would
* cause the resulting JSGI response's status to be 404.
*
* @returns JSGI Response
* @type Object
*/
exports.html = function(html, opts) {
html = html || '';
return jsgiResponse(html, opts, {
'content-type': 'text/html',
'content-length': Buffer.byteLength(html, 'utf-8')
});
};
/**
* Bogart helper function to create a JSGI response.
* Returns a default JSGI response with body containing the specified object represented as JSON, a status of 200,
* and headers. The headers included are "content-type" of "application/json" and "content-length" set
* appropriately based upon the length of the JSON representation of @paramref(obj)
*
* var res = bogart.json({ a: 1});
*
* Response will look like this:
*
* {
* status: 200,
* headers: { "content-type": "application/json", "content-length": 5 },
* body: [ "{a:1}" ]
* }
*
* @param {Object} obj Object to be represented as JSON
* @param {Object} opts Options to override JSGI response defaults. For example, passing {status: 404 } would
* cause the resulting JSGI response's status to be 404.
*/
exports.json = function(obj, opts) {
opts = opts || {};
opts.headers = opts.headers || {}
var str = JSON.stringify(obj);
var headers = _.extend(
opts.headers,
{
"content-type": "application/json",
"content-length": Buffer.byteLength(str, 'utf-8')
}
);
return {
status: opts.status || 200,
body: [str],
headers: headers
};
};
/**
* Bogart helper function to create a JSGI response.
* Returns a default JSGI response with body containing the specified object represented as JSON, a status of 200,
* and headers that facilitate CORS. The headers included are :
* - "content-type" of "application/json"
* - "content-length" set appropriately based upon the length of the JSON representation of @paramref(obj)
* - "Access-Control-Allow-Origin" of "*" (this is not the most secure option, you should specify origins to allow if possible)
* - "Access-Control-Allow-Methods" of "GET,PUT,POST,DELETE" to cover all the usual HTTP methods
* - "Access-Control-Allow-Headers" of "x-requested-with,*" to play nice with jQuery's AJAX method (which specifically requires that you allow the "x-requested-with" header), and anything else ("*")
*
* @param {Object} body Object to be represented as JSON
* @param {Object} opts Options to override JSGI response defaults. For example, passing {status: 404 } would
* cause the resulting JSGI response's status to be 404.
*/
exports.cors = function(body, opts) {
opts = opts || {};
var str = JSON.stringify(body);
return jsgiResponse(str, opts, {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(str, 'utf-8'),
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET,PUT,POST,DELETE',
'Access-Control-Allow-Headers': 'x-requested-with,*'
});
};
exports.error = function(msg, opts) {
opts = opts || {};
msg = msg || "Server Error";
return {
status: opts.status || 500,
body: [msg],
headers: { "content-type": "text/html", "content-length": Buffer.byteLength(msg, 'utf-8') }
};
};
/**
* Bogart helper function to create a JSGI response.
* Returns a default JSGI response the redirects to the url provided by the 'url' parameter.
*
* var resp = bogart.redirect("http://google.com");
* sys.inspect(resp)
*
* Assuming node-style sys.inspect, evalutes to:
* {
* status: 302,
* headers: { "location": "http://google.com" },
* body: []
* }
*
*
* @param {String} url URL to which the JSGI response will redirect
* @returns JSGI response for a 302 redirect
* @type Object
*/
exports.redirect = function(url, opts) {
var resp = {
status: 302,
headers: { "location": url },
body: []
};
if (opts) {
if (opts.headers) {
resp.headers = merge(resp.headers, opts.headers);
delete opts.headers;
}
merge(resp, opts);
}
return resp;
};
/**
* Bogart helper function to create a JSGI response.
* Returns a default JSGI response the redirects to the url provided by the 'url' parameter.
*
* var resp = bogart.permanentRedirect("http://google.com");
* sys.inspect(resp)
*
* Assuming node-style sys.inspect, evalutes to:
* {
* status: 301,
* headers: { "location": "http://google.com" },
* body: []
* }
*
*
* @param {String} url URL to which the JSGI response will redirect
* @returns JSGI response for a permanent (301) redirect
* @type Object
*/
exports.permanentRedirect = function(url, opts){
var resp = {
status:301,
headers: {"location": url},
body: []
};
if (opts) {
if (opts.headers) {
resp.headers = merge(resp.headers, opts.headers);
delete opts.headers;
}
merge(resp, opts);
}
return resp;
};
/**
* Bogart helper function to create a JSGI response.
* Returns a default JSGI response with a status of 304 (not modified).
*
* var resp = bogart.notModified();
*
* JSGI Response:
*
* {
* status: 304,
* body: []
* }
*
* @returns JSGI response for a not modified response (304).
* @type Object
*/
exports.notModified = function(opts){
return merge({
status: 304,
body:[]
}, opts);
};
var ResponseBuilder = exports.ResponseBuilder = function(viewEngine) {
var Stream = require('stream')
, responseBuilder = Object.create(Q.defer(), { then: { get: function() { return this.promise.then.bind(this.promise); } } })
, forEachCallback
, forEachDeferred = Q.defer()
, response = { headers: {}, status: 200, body: {} }
, waiting = []
, ended = false
, resolved = false;
var send = function(data) {
if (typeof data === 'string') {
forEachCallback(data);
} else if (typeof data.forEach === 'function') {
if (Buffer.isBuffer(data)) {
forEachCallback(data.toString());
} else {
data.forEach(forEachCallback);
}
} else {
forEachCallback(data);
}
};
responseBuilder.writable = true;
responseBuilder.on = function(event, callback) {
};
responseBuilder.emit = function(event) {
};
responseBuilder.removeListener = function(event, callback) {
};
/**
* Send response data
*
* Examples:
*
* res.send('Hello World');
* res.send([ 'Hello', 'World' ]);
* res.send(new Buffer('Hello World'));
*
* @param {String | ForEachable | Buffer} data Data to send
* @api public
*/
responseBuilder.send = responseBuilder.write = function(data) {
if (!resolved) {
responseBuilder.resolve(response);
resolved = true;
}
if (typeof forEachCallback === 'function') {
send(data);
} else {
waiting.push(data);
}
};
/**
* Render a `view` to the response stream.
*
* Example:
*
* res.render('index.html', { locals: { title: 'Hello World' } });
*
* @param {String} view The view to render
* @param {Object} opts Options for the ViewEngine.
*/
responseBuilder.render = function(view, opts) {
if (!viewEngine) {
throw new Error("No viewEngine specified");
}
viewEngine.render(view, opts).then(function(content) {
responseBuilder.send(content);
responseBuilder.end();
})
};
/**
* End the response.
*/
responseBuilder.end = function() {
forEachDeferred.resolve();
};
responseBuilder.headers = function(headers) {
response.headers = headers;
};
responseBuilder.setHeader = function(k,v) {
response.headers = response.headers || {};
response.headers[k] = v;
}
responseBuilder.status = function(status) {
if (status !== undefined) {
if (isNaN(status)) {
throw new Error('status must be a number');
}
response.status = status;
return responseBuilder;
}
return response.status;
};
Object.defineProperty(responseBuilder, 'statusCode', {
get: function() {
return response.status;
},
set: function(value) {
responseBuilder.status(value);
}
});
response.body.forEach = function(callback) {
forEachCallback = callback;
if (waiting.length > 0) {
waiting.forEach(send);
waiting = [];
}
return forEachDeferred.promise;
};
return responseBuilder;
};
/**
* Retrieve a ResponseBuilder to build a JSGI response imperatively.
*
* var viewEngine = bogart.viewEngine('mustache');
* app.get('/', function(req) {
* var resp = bogart.res(viewEngine);
*
* doSomethingAsync(function(err, str) {
* if (err) {
* resp.status(500);
* resp.send('Error: '+err.reason);
* } else {
* resp.send(str);
* }
* resp.end();
* });
*
* return resp;
* });
*
* @param {ViewEngine} viewEngine The ViewEngine to be used by response helpers for rendering views.
* @returns {ResponseBuilder} An object with methods to help build a response.
*/
exports.res = function(viewEngine) {
return new ResponseBuilder(viewEngine);
};
exports.response = function(viewEngine) {
console.log('bogart.response() called, bogart.res() is now the preferred, wrist-friendly, ' +
'version of this method. bogart.response() will be removed in the future.');
return bogart.res(viewEngine);
};
function pipeStream(stream, opts) {
var response = exports.res();
opts = opts || {};
if (!stream.readable) {
throw "Streams passed to pipe must be readable streams."
}
if (opts.status) {
response.status(opts.status);
}
if (opts.headers) {
response.headers(opts.headers);
}
stream.on('readable', function () {
var chunk;
while (null !== (chunk = stream.read())) {
response.send(chunk);
}
});
stream.on('end', function() {
response.end();
});
stream.on('error', function(err) {
response.reject(err);
});
return response.promise;
};
/**
* Pipe a response to a JSGI stream
*
* @param {ReadableStream} stream A readable stream.
* @returns {Promise} A promise for a JSGI stream.
*/
exports.pipe = function(stream, opts) {
var deferred = Q.defer();
if (typeof stream.forEach === 'function') {
deferred.resolve(merge({}, opts, { body: stream }));
return deferred.promise;
} else {
return pipeStream(stream, opts);
}
};
/**
* Get MIME type for a file extension
*
* @param {String} ext File extension
* @returns {String} MIME type of file extension.
*/
exports.mimeType = function(ext) {
var dotIndex = ext.lastIndexOf('.');
if (dotIndex > 0) {
ext = ext.substring(dotIndex);
}
return require("./mimetypes").mimeType(ext);
};
/**
* Creates a JSGI response that streams a file
*
* @param {String} filePath The path to the file to be streamed
* @param {Object} opts JSGI options
*
* @returns {Promise} A promise for a JSGI response
*/
exports.file = function(filePath, opts) {
opts = opts || {};
opts.headers = opts.headers || {};
opts.headers['Content-Type'] = opts.headers['Content-Type'] || exports.mimeType(path.extname(filePath));
return exports.pipe(fs.createReadStream(filePath), opts);
};
exports.proxy = function(url) {
return exports.pipe(require('request')(url));
};
/**
* Helper function to determine the main directory of the application
* @returns {String} Directory of the script that was executed
*/
exports.maindir = function() {
if (typeof require.main === 'undefined') {
return __dirname;
}
return path.dirname(require.main.filename).replace("file://","");
};
/**
* An empty function. Useful for authors of APIs with optional callbacks.
*/
exports.noop = function(){};
/**
* Returns whether a request is a get.
*
* @param {Request} req
* @returns {Boolean}
*/
exports.isGet = exports.util.isGet;
/**
* Returns whether a request is a post.
*
* @param {Request} req
* @returns {Boolean}
*/
exports.isPost = exports.util.isPost;
/**
* Returns whether a request is a put.
*
* @param {Request} req
* @returns {Boolean}
*/
exports.isPut = exports.util.isPut;
/**
* Returns whether a request is a delete.
*
* @param {Request} req
* @returns {Boolean}
*/
exports.isDel = exports.isDelete = exports.util.isDel;
/**
* Pipes data from source to dest.
*
* @param {ForEachable | ReadableStream} src Source of data
* @param {WriteableStream} dest Write data from src to dest
*
* @returns {Promise} A promise that will be resolved when the pumping is completed
*/
exports.pump = require('./stream').pump;
view.setting('template directory', path.join(exports.maindir(), 'views'));