Comparing version 0.1.21 to 0.2.0
@@ -29,3 +29,2 @@ // Initializes the framework | ||
httpPort: 80, | ||
hostname: 'localhost', | ||
connectionQueue: undefined, | ||
@@ -43,2 +42,3 @@ logs: { | ||
depth: 2, | ||
enableCache: false, | ||
jade: false | ||
@@ -105,2 +105,7 @@ } | ||
global.CTZN = { | ||
cache: { | ||
app: {}, | ||
route: {}, | ||
controller: {} | ||
}, | ||
config: config, | ||
@@ -120,5 +125,3 @@ on: on, | ||
config: config, | ||
controllers: patterns.controllers, | ||
models: patterns.models, | ||
views: patterns.views, | ||
start: server.start, | ||
@@ -129,2 +132,5 @@ session: session, | ||
// TODO: Loop over helper exports so this list doesn't have to be maintained manually | ||
cache: helpers.cache, | ||
retrieve: helpers.retrieve, | ||
kill: helpers.kill, | ||
copy: helpers.copy, | ||
@@ -154,3 +160,3 @@ extend: helpers.extend, | ||
files = fs.readdirSync(configDirectory); | ||
} catch ( e ) { | ||
} catch ( err ) { | ||
console.log('No valid configuration files found. Loading default config.'); | ||
@@ -161,23 +167,19 @@ return config; | ||
files.forEach( function (file, index, array) { | ||
var citizenConfig, | ||
citizenRegex = new RegExp(/^citizen[A-Za-z0-9_-]*\.json$/), | ||
appRegex = new RegExp(/^[A-Za-z0-9_-]*\.json$/); | ||
var parsedConfig, | ||
configRegex = new RegExp(/^[A-Za-z0-9_-]*\.json$/); | ||
if ( citizenRegex.test(file) ) { | ||
citizenConfig = JSON.parse(fs.readFileSync(path.join(configDirectory, '/', file))); | ||
if ( citizenConfig.hostname && citizenConfig.hostname === os.hostname() ) { | ||
config.citizen = citizenConfig; | ||
console.log('citizen configuration loaded based on hostname [' + citizenConfig.hostname + ']: ' + configDirectory + '/' + file); | ||
if ( configRegex.test(file) ) { | ||
parsedConfig = JSON.parse(fs.readFileSync(path.join(configDirectory, '/', file))); | ||
if ( parsedConfig.hostname && parsedConfig.hostname === os.hostname() ) { | ||
config = parsedConfig; | ||
console.log('app configuration loaded based on hostname [' + parsedConfig.hostname + ']: ' + configDirectory + '/' + file); | ||
} | ||
} else if ( appRegex.test(file) ) { | ||
config[path.basename(file, '.json')] = JSON.parse(fs.readFileSync(path.join(configDirectory, '/', file))); | ||
console.log('App configuration loaded from file: ' + configDirectory + '/' + file); | ||
} | ||
}); | ||
if ( !config.citizen ) { | ||
if ( !config.hostname ) { | ||
try { | ||
config.citizen = JSON.parse(fs.readFileSync(path.join(configDirectory, '/citizen.json'))); | ||
config = JSON.parse(fs.readFileSync(path.join(configDirectory, '/citizen.json'))); | ||
console.log('citizen configuration loaded from file: ' + configDirectory + '/citizen.json'); | ||
} catch ( e ) { | ||
} catch ( err ) { | ||
// No big deal, citizen will start under the default configuration | ||
@@ -184,0 +186,0 @@ } |
@@ -5,5 +5,10 @@ // core framework functions that might also be of use in the app | ||
var events = require('events'); | ||
var events = require('events'), | ||
fs = require('fs'); | ||
module.exports = { | ||
cache: cache, | ||
exists: exists, | ||
retrieve: retrieve, | ||
clear: clear, | ||
copy: copy, | ||
@@ -16,2 +21,259 @@ extend: extend, | ||
function cache(options) { | ||
var timer, | ||
key = options.key || options.file || options.controller || options.route || '', | ||
value; | ||
options.overwrite = options.overwrite || false; | ||
options.lifespan = options.lifespan || 'application'; | ||
options.encoding = options.encoding || 'utf-8'; | ||
options.synchronous = options.synchronous || false; | ||
options.directives = options.directives || {}; | ||
if ( key.length === 0 ) { | ||
throw { | ||
thrownBy: 'helpers.cache()', | ||
message: 'You need to specify an absolute file path, a route name, or custom key name when saving objects to the cache.' | ||
}; | ||
} | ||
if ( ( options.key && !options.file && !options.value ) || ( options.value && !options.key ) ) { | ||
throw { | ||
thrownBy: 'helpers.cache()', | ||
message: 'When using a custom key, you have to specify both a key name and value.' | ||
}; | ||
} | ||
if ( options.controller && ( !options.context || !options.viewName || !options.view ) ) { | ||
throw { | ||
thrownBy: 'helpers.cache()', | ||
message: 'When caching a controller, you must specify the context, view name, and rendered view contents.' | ||
}; | ||
} | ||
if ( options.lifespan !== 'application' && !isNumeric(options.lifespan) ) { | ||
throw { | ||
thrownBy: 'helpers.cache()', | ||
message: 'Cache lifespan needs to be specified in milliseconds.' | ||
}; | ||
} | ||
if ( options.value ) { | ||
if ( !CTZN.cache.app[key] || ( CTZN.cache.app[key] && options.overwrite ) ) { | ||
if ( options.lifespan !== 'application' ) { | ||
timer = setTimeout( function () { | ||
clear(key); | ||
}, options.lifespan); | ||
} | ||
CTZN.cache.app[key] = { | ||
key: key, | ||
// Create a copy of the content object so the cache isn't a pointer to the original | ||
value: copy(options.value), | ||
timer: timer | ||
}; | ||
} else { | ||
throw { | ||
thrownBy: 'helpers.cache()', | ||
message: 'An cache using the specified key [\'' + options.key + '\'] already exists. If your intention is to overwrite the existing cache, you have to pass the overwrite flag explicitly.' | ||
}; | ||
} | ||
} else if ( options.file ) { | ||
if ( !CTZN.cache.app[key] || ( CTZN.cache.app[key] && options.overwrite ) ) { | ||
if ( options.lifespan !== 'application' ) { | ||
timer = setTimeout( function () { | ||
clear(key); | ||
}, options.lifespan); | ||
} | ||
if ( options.synchronous ) { | ||
value = fs.readFileSync(options.file, { encoding: options.encoding }); | ||
if ( options.parseJSON ) { | ||
value = JSON.parse(value); | ||
} | ||
CTZN.cache.app[key] = { | ||
file: options.file, | ||
value: value, | ||
timer: timer | ||
}; | ||
} else { | ||
fs.readFile(options.file, { encoding: options.encoding }, function (err, data) { | ||
if ( err ) { | ||
throw { | ||
thrownBy: 'helpers.cache()', | ||
message: 'There was an error when attempting to read the specified file (' + key + ').' | ||
}; | ||
} else { | ||
if ( options.parseJSON ) { | ||
value = JSON.parse(data); | ||
} else { | ||
value = data; | ||
} | ||
CTZN.cache.app[key] = { | ||
file: options.file, | ||
value: value, | ||
timer: timer | ||
}; | ||
} | ||
}); | ||
} | ||
} else { | ||
throw { | ||
thrownBy: 'helpers.cache()', | ||
message: 'A cache containing the specified file [\'' + options.file + '\'] already exists. If your intention is to overwrite the existing cache, you have to pass the overwrite flag explicitly.' | ||
}; | ||
} | ||
} else if ( options.controller ) { | ||
if ( options.route ) { | ||
key = options.controller + '-' + options.viewName + '-' + options.route; | ||
} else { | ||
key = options.controller + '-' + options.viewName; | ||
} | ||
if ( !CTZN.cache.controller[key] || ( CTZN.cache.controller[key] && options.overwrite ) ) { | ||
if ( options.lifespan !== 'application' ) { | ||
timer = setTimeout( function () { | ||
clear(key, 'controller'); | ||
}, options.lifespan); | ||
} | ||
CTZN.cache.controller[key] = { | ||
controller: options.controller, | ||
route: options.route || '', | ||
context: options.context, | ||
viewName: options.viewName, | ||
view: options.view, | ||
timer: timer, | ||
lifespan: options.lifespan, | ||
resetOnAccess: options.resetOnAccess | ||
}; | ||
} else { | ||
throw { | ||
thrownBy: 'helpers.cache()', | ||
message: 'A cache containing the specified route/controller/view combination already exists. If your intention is to overwrite the existing cache, you have to pass the overwrite flag explicitly.\n route: ' + options.route + '\n controller: ' + options.controller + '\n view: ' + options.viewName | ||
}; | ||
} | ||
} else if ( options.route ) { | ||
if ( !CTZN.cache.route[key] || ( CTZN.cache.route[key] && options.overwrite ) ) { | ||
if ( options.lifespan !== 'application' ) { | ||
timer = setTimeout( function () { | ||
clear(key, 'route'); | ||
}, options.lifespan); | ||
} | ||
CTZN.cache.route[key] = { | ||
route: key, | ||
contentType: options.contentType, | ||
view: options.view, | ||
timer: timer | ||
}; | ||
} else { | ||
throw { | ||
thrownBy: 'helpers.cache()', | ||
message: 'A cache containing the specified route [\'' + options.route + '\'] already exists. If your intention is to overwrite the existing cache, you have to pass the overwrite flag explicitly.' | ||
}; | ||
} | ||
} | ||
} | ||
function exists(key, namespace) { | ||
namespace = namespace || 'app'; | ||
switch ( namespace ) { | ||
case 'app': | ||
if ( CTZN.cache.app[key] ) { | ||
return true; | ||
} | ||
break; | ||
case 'controller': | ||
if ( CTZN.cache.controller[key] ) { | ||
return true; | ||
} | ||
break; | ||
case 'route': | ||
if ( CTZN.cache.route[key] ) { | ||
return true; | ||
} | ||
break; | ||
default: | ||
return false; | ||
} | ||
} | ||
function retrieve(key, namespace) { | ||
namespace = namespace || 'app'; | ||
switch ( namespace ) { | ||
case 'app': | ||
if ( CTZN.cache.app[key] ) { | ||
if ( CTZN.cache.app[key].timer && CTZN.cache.app[key].resetOnAccess ) { | ||
clearTimeout(CTZN.cache.app[key].timer); | ||
CTZN.cache.app[key].timer = setTimeout( function () { | ||
clear(key, 'controller'); | ||
console.log('cache timed out: ' + key); | ||
}, CTZN.cache.app[key].lifespan); | ||
} | ||
return CTZN.cache.app[key].value; | ||
} | ||
break; | ||
case 'controller': | ||
if ( CTZN.cache.controller[key] ) { | ||
if ( CTZN.cache.controller[key].timer && CTZN.cache.controller[key].resetOnAccess ) { | ||
clearTimeout(CTZN.cache.controller[key].timer); | ||
CTZN.cache.controller[key].timer = setTimeout( function () { | ||
clear(key, 'controller'); | ||
}, CTZN.cache.controller[key].lifespan); | ||
} | ||
return CTZN.cache.controller[key]; | ||
} | ||
break; | ||
case 'route': | ||
if ( CTZN.cache.route[key] ) { | ||
if ( CTZN.cache.route[key].timer && CTZN.cache.route[key].resetOnAccess ) { | ||
clearTimeout(CTZN.cache.route[key].timer); | ||
CTZN.cache.route[key].timer = setTimeout( function () { | ||
clear(key, 'controller'); | ||
}, CTZN.cache.route[key].lifespan); | ||
} | ||
return CTZN.cache.route[key]; | ||
} | ||
break; | ||
default: | ||
return false; | ||
} | ||
} | ||
function clear(key, namespace) { | ||
namespace = namespace || 'app'; | ||
switch ( namespace ) { | ||
case 'app': | ||
if ( CTZN.cache.app[key] ) { | ||
if ( CTZN.cache.app[key].timer ) { | ||
clearTimeout(CTZN.cache.app[key].timer); | ||
} | ||
CTZN.cache.app[key] = undefined; | ||
} | ||
break; | ||
case 'controller': | ||
if ( CTZN.cache.controller[key] ) { | ||
if ( CTZN.cache.controller[key].timer ) { | ||
clearTimeout(CTZN.cache.controller[key].timer); | ||
} | ||
CTZN.cache.controller[key] = undefined; | ||
} | ||
break; | ||
case 'route': | ||
if ( CTZN.cache.route[key] ) { | ||
if ( CTZN.cache.route[key].timer ) { | ||
clearTimeout(CTZN.cache.route[key].timer); | ||
} | ||
CTZN.cache.route[key] = undefined; | ||
} | ||
break; | ||
} | ||
} | ||
// The copy() and getValue() functions were inspired by (meaning mostly stolen from) | ||
@@ -60,3 +322,3 @@ // Andrée Hanson: | ||
} else { | ||
object = 'undefined'; | ||
object = undefined; | ||
} | ||
@@ -102,5 +364,10 @@ | ||
if ( allReady && typeof callback === 'function' ) { | ||
if ( allReady ) { | ||
output.listen.success = true; | ||
callback(output); | ||
if ( typeof callback === 'function' ) { | ||
callback(output); | ||
} else { | ||
return output; | ||
} | ||
} | ||
@@ -131,2 +398,3 @@ }; | ||
} | ||
emitter = undefined; | ||
throw { | ||
@@ -133,0 +401,0 @@ thrownBy: 'helpers.listen()', |
@@ -6,2 +6,3 @@ // server | ||
var domain = require('domain'), | ||
events = require('events'), | ||
fs = require('fs'), | ||
@@ -15,3 +16,4 @@ http = require('http'), | ||
router = require('./router'), | ||
session = require('./session'); | ||
session = require('./session'), | ||
server = new events.EventEmitter(); | ||
@@ -22,33 +24,9 @@ module.exports = { | ||
function start() { | ||
listen({ | ||
applicationStart: function (emitter) { | ||
CTZN.on.application.start(emitter); | ||
} | ||
}, function (output) { | ||
if ( CTZN.appOn.application && CTZN.appOn.application.start ) { | ||
listen({ | ||
applicationStart: function (emitter) { | ||
CTZN.appOn.application.start(output.applicationStart, emitter); | ||
} | ||
}, function (output) { | ||
createServer(); | ||
}); | ||
} else { | ||
createServer(); | ||
} | ||
}); | ||
server.emit('applicationStart'); | ||
} | ||
function createServer() { | ||
http.createServer( function (request, response) { | ||
var context = { | ||
cookie: {}, | ||
session: {}, | ||
redirect: {} | ||
}, | ||
var context = {}, | ||
params = { | ||
@@ -89,6 +67,6 @@ request: request, | ||
requestDomain.on('error', function (e) { | ||
requestDomain.on('error', function (err) { | ||
listen({ | ||
applicationError: function (emitter) { | ||
CTZN.on.application.error(e, params, context, emitter); | ||
CTZN.on.application.error(err, params, context, emitter); | ||
} | ||
@@ -100,10 +78,10 @@ }, function (output) { | ||
applicationError: function (emitter) { | ||
CTZN.appOn.application.error(e, params, applicationError, emitter); | ||
CTZN.appOn.application.error(err, params, applicationError, emitter); | ||
} | ||
}, function (output) { | ||
applicationError = helpers.extend(applicationError, output.applicationError); | ||
error(e, request, response, params, applicationError); | ||
server.emit('error', err, request, response); | ||
}); | ||
} else { | ||
error(e, request, response, params, applicationError); | ||
server.emit('error', err, request, response); | ||
} | ||
@@ -119,5 +97,5 @@ }); | ||
if ( CTZN.config.citizen.sessions ) { | ||
sessionStart(params, context); | ||
server.emit('sessionStart', params, context); | ||
} else { | ||
requestStart(params, context); | ||
server.emit('requestStart', params, context); | ||
} | ||
@@ -148,5 +126,92 @@ } else { | ||
function setSession(params, context) { | ||
if ( CTZN.config.citizen.sessions && context.session && ( !params.request.headers.origin || ( params.request.headers.origin && params.request.headers.origin.search(params.request.headers.host) ) ) && Object.getOwnPropertyNames(context.session).length > 0 ) { | ||
if ( context.session.expires && context.session.expires === 'now' ) { | ||
session.end(params.session.id); | ||
context.cookie = helpers.extend(context.cookie, { ctznSessionID: { expires: 'now' }}); | ||
server.emit('sessionEnd', params, context); | ||
params.session = {}; | ||
} else { | ||
CTZN.sessions[params.session.id] = helpers.extend(CTZN.sessions[params.session.id], context.session); | ||
params.session = helpers.copy(CTZN.sessions[params.session.id]); | ||
} | ||
} | ||
} | ||
function setCookie(params, context) { | ||
var cookie = buildCookie(context.cookie); | ||
if ( cookie.length ) { | ||
params.response.setHeader('Set-Cookie', cookie); | ||
} | ||
} | ||
function sessionStart(params, context) { | ||
server.on('applicationStart', function () { | ||
listen({ | ||
applicationStart: function (emitter) { | ||
CTZN.on.application.start(emitter); | ||
} | ||
}, function (output) { | ||
if ( CTZN.appOn.application && CTZN.appOn.application.start ) { | ||
listen({ | ||
applicationStart: function (emitter) { | ||
CTZN.appOn.application.start(output.applicationStart, emitter); | ||
} | ||
}, function (output) { | ||
createServer(); | ||
}); | ||
} else { | ||
createServer(); | ||
} | ||
}); | ||
}); | ||
server.on('error', function (err, request, response) { | ||
var statusCode = err.statusCode || 500; | ||
switch ( CTZN.config.citizen.mode ) { | ||
case 'production': | ||
// TODO: Need friendly client error messaging for production mode. | ||
if ( err.thrownBy ) { | ||
console.log('Error thrown by ' + err.thrownBy + ': ' + err.message); | ||
if ( !err.staticAsset ) { | ||
console.log(util.inspect(err.domain)); | ||
} | ||
} else { | ||
console.log(util.inspect(err)); | ||
} | ||
if ( !response.headersSent ) { | ||
response.statusCode = statusCode; | ||
if ( err.stack ) { | ||
response.write(err.stack); | ||
} else { | ||
response.write(util.inspect(err)); | ||
} | ||
response.end(); | ||
} | ||
break; | ||
case 'development': | ||
case 'debug': | ||
if ( err.thrownBy ) { | ||
console.log('Error thrown by ' + err.thrownBy + ': ' + err.message); | ||
if ( !err.staticAsset ) { | ||
console.log(util.inspect(err.domain)); | ||
} | ||
} else { | ||
console.log(util.inspect(err)); | ||
console.trace(); | ||
} | ||
if ( !response.headersSent ) { | ||
response.statusCode = statusCode; | ||
if ( err.stack ) { | ||
response.write(err.stack); | ||
} else { | ||
response.write(util.inspect(err)); | ||
} | ||
response.end(); | ||
} | ||
break; | ||
} | ||
}); | ||
server.on('sessionStart', function (params, context) { | ||
var sessionID = 0; | ||
@@ -158,5 +223,8 @@ | ||
params.session = helpers.copy(CTZN.sessions[params.cookie.ctznSessionID]); | ||
requestStart(params, context); | ||
server.emit('requestStart', params, context); | ||
} else { | ||
sessionID = session.create(); | ||
if ( !context.cookie ) { | ||
context.cookie = {}; | ||
} | ||
context.cookie.ctznSessionID = { | ||
@@ -180,6 +248,6 @@ value: sessionID | ||
sessionStart = helpers.extend(sessionStart, output.sessionStart); | ||
requestStart(params, sessionStart); | ||
server.emit('requestStart', params, sessionStart); | ||
}); | ||
} else { | ||
requestStart(params, sessionStart); | ||
server.emit('requestStart', params, sessionStart); | ||
} | ||
@@ -189,35 +257,7 @@ }); | ||
} else { | ||
requestStart(params, context); | ||
server.emit('requestStart', params, context); | ||
} | ||
} | ||
}); | ||
function setSession(params, context) { | ||
if ( CTZN.config.citizen.sessions && ( !params.request.headers.origin || ( params.request.headers.origin && params.request.headers.origin.search(params.request.headers.host) ) ) && Object.getOwnPropertyNames(context.session).length > 0 ) { | ||
if ( context.session.expires && context.session.expires === 'now' ) { | ||
session.end(params.session.id); | ||
context.cookie = helpers.extend(context.cookie, { ctznSessionID: { expires: 'now' }}); | ||
sessionEnd(params, context); | ||
params.session = {}; | ||
} else { | ||
CTZN.sessions[params.session.id] = helpers.extend(CTZN.sessions[params.session.id], context.session); | ||
params.session = helpers.copy(CTZN.sessions[params.session.id]); | ||
} | ||
context.session = {}; | ||
} | ||
} | ||
function setCookie(params, context) { | ||
var cookie = buildCookie(context.cookie); | ||
if ( cookie.length ) { | ||
params.response.setHeader('Set-Cookie', cookie); | ||
} | ||
} | ||
function requestStart(params, context) { | ||
server.on('requestStart', function (params, context) { | ||
listen({ | ||
@@ -244,3 +284,3 @@ requestStart: function (emitter) { | ||
}); | ||
} | ||
}); | ||
@@ -256,7 +296,7 @@ | ||
// If a previous event in the request context requested a redirect, do it immediately rather than firing the controller. | ||
if ( context.redirect.url ) { | ||
if ( context.redirect && context.redirect.url ) { | ||
params.response.writeHead(context.redirect.statusCode || 302, { | ||
'Location': context.redirect.url | ||
}); | ||
params.response.end(responseEnd(params, context)); | ||
params.response.end(server.emit('responseEnd', params, context)); | ||
} else if ( controller ) { | ||
@@ -272,3 +312,3 @@ // If the Origin header exists and it's not the host, check if it's allowed. If so, | ||
respond = false; | ||
params.response.end(responseEnd(params, context)); | ||
params.response.end(server.emit('responseEnd', params, context)); | ||
} else { | ||
@@ -282,7 +322,7 @@ for ( var property in controller.access ) { | ||
respond = false; | ||
params.response.end(responseEnd(params, context)); | ||
params.response.end(server.emit('responseEnd', params, context)); | ||
} | ||
} else { | ||
respond = false; | ||
params.response.end(responseEnd(params, context)); | ||
params.response.end(server.emit('responseEnd', params, context)); | ||
} | ||
@@ -310,6 +350,6 @@ } | ||
setSession(params, requestEnd); | ||
responseStart(controller, params, requestEnd); | ||
server.emit('responseStart', controller, params, requestEnd); | ||
}); | ||
} else { | ||
responseStart(controller, params, requestEnd); | ||
server.emit('responseStart', controller, params, requestEnd); | ||
} | ||
@@ -325,3 +365,3 @@ }); | ||
// TODO: call onRequestEnd() method here, use listener, and on completion, call respond() | ||
responseStart(controller, params, context); | ||
server.emit('responseStart', controller, params, context); | ||
}); | ||
@@ -336,3 +376,3 @@ break; | ||
// TODO: call onRequestEnd() method here, use listener, and on completion, call respond() | ||
responseStart(controller, params, context); | ||
server.emit('responseStart', controller, params, context); | ||
}); | ||
@@ -342,3 +382,3 @@ break; | ||
// TODO: call onRequestEnd() method here, use listener, and on completion, call respond() | ||
responseStart(controller, params, context); | ||
server.emit('responseStart', controller, params, context); | ||
break; | ||
@@ -361,5 +401,3 @@ // Just send the response headers for HEAD and OPTIONS | ||
function responseStart(controller, params, context) { | ||
server.on('responseStart', function (controller, params, context) { | ||
listen({ | ||
@@ -371,3 +409,2 @@ responseStart: function (emitter) { | ||
var responseStart = helpers.extend(context, output.responseStart); | ||
setSession(params, responseStart); | ||
if ( CTZN.appOn.response && CTZN.appOn.response.start ) { | ||
@@ -381,9 +418,28 @@ listen({ | ||
setSession(params, responseStart); | ||
fireController(controller, params, responseStart); | ||
// if ( CTZN.cache.route[params.route.pathName] ) { | ||
if ( helpers.exists(params.route.pathName, 'route') ) { | ||
setCookie(params, responseStart); | ||
params.response.setHeader('Content-Type', CTZN.cache.route[params.route.pathName].contentType); | ||
params.response.write(CTZN.cache.route[params.route.pathName].view); | ||
params.response.end(); | ||
server.emit('responseEnd', params, context); | ||
} else { | ||
fireController(controller, params, responseStart); | ||
} | ||
}); | ||
} else { | ||
fireController(controller, params, responseStart); | ||
setSession(params, responseStart); | ||
// if ( CTZN.cache.route[params.route.pathName] ) { | ||
if ( helpers.exists(params.route.pathName, 'route') ) { | ||
setCookie(params, responseStart); | ||
params.response.setHeader('Content-Type', CTZN.cache.route[params.route.pathName].contentType); | ||
params.response.write(CTZN.cache.route[params.route.pathName].view); | ||
params.response.end(); | ||
server.emit('responseEnd', params, context); | ||
} else { | ||
fireController(controller, params, responseStart); | ||
} | ||
} | ||
}); | ||
} | ||
}); | ||
@@ -395,4 +451,4 @@ | ||
responseDomain.on('error', function (e) { | ||
error(e, params.request, params.response, params, context); | ||
responseDomain.on('error', function (err) { | ||
server.emit('error', err, params.request, params.response); | ||
}); | ||
@@ -405,5 +461,17 @@ | ||
responseDomain.run( function () { | ||
var cachedController, | ||
controllerName = context.handoffControllerName || params.route.controller, | ||
view = context.view || params.route.view, | ||
cacheKeyController = controllerName + '-' + view + '-' + params.route.pathName, | ||
cacheKeyGlobal = controllerName + '-' + view; | ||
listen({ | ||
pattern: function (emitter) { | ||
controller.handler(params, context, emitter); | ||
// if ( CTZN.cache.controller[cacheKeyController] || CTZN.cache.controller[cacheKeyGlobal] ) { | ||
if ( helpers.exists(cacheKeyController, 'controller') || helpers.exists(cacheKeyGlobal, 'controller') ) { | ||
cachedController = helpers.retrieve(cacheKeyController, 'controller') || helpers.retrieve(cacheKeyGlobal, 'controller'); | ||
emitter.emit('ready', cachedController.context); | ||
} else { | ||
controller.handler(params, context, emitter); | ||
} | ||
} | ||
@@ -416,5 +484,9 @@ }, function (output) { | ||
requestContext.handoffControllerName = undefined; | ||
params.route.view = requestContext.view || params.route.view; | ||
params.route.renderedView = requestContext.view || params.route.renderedView; | ||
requestContext.includesToRender = helpers.extend(requestContext.includesToRender, include); | ||
if ( requestContext.view ) { | ||
params.route.chain[params.route.chain.length-1].view = params.route.view; | ||
} | ||
@@ -425,3 +497,3 @@ if ( output.listen.success ) { | ||
if ( requestContext.redirect.url ) { | ||
if ( requestContext.redirect && requestContext.redirect.url ) { | ||
setCookie(params, requestContext); | ||
@@ -431,3 +503,12 @@ params.response.writeHead(requestContext.redirect.statusCode || 302, { | ||
}); | ||
params.response.end(responseEnd(params, requestContext)); | ||
params.response.end(); | ||
server.emit('responseEnd', params, requestContext); | ||
cacheController({ | ||
controller: controllerName, | ||
route: params.route.pathName, | ||
context: requestContext, | ||
format: params.route.format, | ||
viewName: params.route.renderedView, | ||
params: params | ||
}); | ||
} else { | ||
@@ -437,16 +518,53 @@ includeProperties = Object.getOwnPropertyNames(include); | ||
includeProperties.forEach( function (item, index, array) { | ||
includeGroup[item] = function (emitter) { | ||
CTZN.patterns.controllers[include[item].controller].handler(params, requestContext, emitter); | ||
}; | ||
var controllerName = include[item].controller, | ||
view = include[item].view || include[item].controller, | ||
cacheKeyController = controllerName + '-' + view + '-' + params.route.pathName, | ||
cacheKeyGlobal = controllerName + '-' + view; | ||
if ( !CTZN.cache.controller[cacheKeyController] && !CTZN.cache.controller[cacheKeyGlobal] ) { | ||
includeGroup[item] = function (emitter) { | ||
CTZN.patterns.controllers[include[item].controller].handler(params, requestContext, emitter); | ||
}; | ||
} | ||
}); | ||
delete requestContext.include; | ||
listen(includeGroup, function (output) { | ||
includeProperties.forEach( function (item, index, array) { | ||
// Includes can use all directives except handoff, so we delete that before extending the request context with the include's context | ||
delete output[item].handoff; | ||
requestContext = helpers.extend(requestContext, output[item]); | ||
requestContext.includesToRender[item].context = helpers.copy(requestContext); | ||
setSession(params, requestContext); | ||
requestContext.include = undefined; | ||
if ( Object.getOwnPropertyNames(includeGroup).length > 0 ) { | ||
listen(includeGroup, function (output) { | ||
includeProperties.forEach( function (item, index, array) { | ||
var requestContextCache = requestContext.cache || false; | ||
// Includes can use all directives except handoff, so we delete that before extending the request context with the include's context | ||
if ( output[item] ) { | ||
output[item].handoff = undefined; | ||
requestContext = helpers.extend(requestContext, output[item]); | ||
} | ||
requestContext.includesToRender[item].context = helpers.copy(requestContext); | ||
requestContext.cache = requestContextCache; | ||
setSession(params, requestContext); | ||
}); | ||
cacheController({ | ||
controller: controllerName, | ||
route: params.route.pathName, | ||
context: requestContext, | ||
format: params.route.format, | ||
viewName: params.route.renderedView, | ||
params: params | ||
}); | ||
if ( requestContext.handoff && params.url.type !== 'ajax' ) { | ||
handoff(params, requestContext); | ||
} else { | ||
setCookie(params, requestContext); | ||
respond(params, requestContext); | ||
} | ||
}); | ||
if ( requestContext.handoff ) { | ||
} else { | ||
cacheController({ | ||
controller: controllerName, | ||
route: params.route.pathName, | ||
context: requestContext, | ||
format: params.route.format, | ||
viewName: params.route.renderedView, | ||
params: params | ||
}); | ||
if ( requestContext.handoff && params.url.type !== 'ajax' ) { | ||
handoff(params, requestContext); | ||
@@ -457,5 +575,13 @@ } else { | ||
} | ||
} | ||
} else { | ||
cacheController({ | ||
controller: controllerName, | ||
route: params.route.pathName, | ||
context: requestContext, | ||
format: params.route.format, | ||
viewName: params.route.renderedView, | ||
params: params | ||
}); | ||
} else { | ||
if ( requestContext.handoff ) { | ||
if ( requestContext.handoff && params.url.type !== 'ajax' ) { | ||
handoff(params, requestContext); | ||
@@ -481,2 +607,88 @@ } else { | ||
function cacheController(options) { | ||
var cacheContext, | ||
cacheScope, | ||
cacheLifespan, | ||
cacheReset; | ||
if ( options.context.cache ) { | ||
if ( ( options.context.cache.scope === 'controller' && !CTZN.cache.controller[options.controller + '-' + options.viewName + '-' + options.route] ) || ( options.context.cache.scope === 'global' && !CTZN.cache.controller[options.controller + '-' + options.viewName] ) ) { | ||
cacheContext = helpers.copy(options.context); | ||
cacheScope = cacheContext.cache.scope; | ||
cacheLifespan = cacheContext.cache.lifespan || 'application'; | ||
cacheReset = cacheContext.cache.resetOnAccess || false; | ||
if ( Object.getOwnPropertyNames(options.params.url).length > 0 && cacheContext.cache.urlParams ) { | ||
Object.getOwnPropertyNames(options.params.url).forEach ( function ( item, index, array) { | ||
if ( cacheContext.cache.urlParams.indexOf(item) < 0 ) { | ||
throw { | ||
thrownBy: 'server.cacheController()', | ||
message: 'Invalid cache URL. The URL parameter [' + item + '] isn\'t permitted in a cached URL.' | ||
}; | ||
} | ||
}); | ||
} | ||
// Cache only those directives specified by the cache.directives array | ||
if ( cacheContext.cache.directives ) { | ||
if ( cacheContext.cache.directives.indexOf('cookie') < 0 ) { | ||
cacheContext.cookie = undefined; | ||
} | ||
if ( cacheContext.cache.directives.indexOf('session') < 0 ) { | ||
cacheContext.session = undefined; | ||
} | ||
if ( cacheContext.cache.directives.indexOf('redirect') < 0 ) { | ||
cacheContext.redirect = undefined; | ||
} | ||
if ( cacheContext.cache.directives.indexOf('view') < 0 ) { | ||
cacheContext.view = undefined; | ||
} | ||
if ( cacheContext.cache.directives.indexOf('handoff') < 0 ) { | ||
cacheContext.handoff = undefined; | ||
} | ||
if ( cacheContext.cache.directives.indexOf('include') < 0 ) { | ||
cacheContext.include = undefined; | ||
} | ||
} else { | ||
cacheContext.include = undefined; | ||
cacheContext.view = undefined; | ||
cacheContext.handoff = undefined; | ||
cacheContext.redirect = undefined; | ||
cacheContext.session = undefined; | ||
cacheContext.cookie = undefined; | ||
} | ||
cacheContext.cache = undefined; | ||
cacheContext.includesToRender = undefined; | ||
cacheContext.domain = undefined; | ||
switch ( cacheScope ) { | ||
case 'controller': | ||
helpers.cache({ | ||
controller: options.controller, | ||
route: options.route, | ||
context: cacheContext, | ||
viewName: options.viewName, | ||
view: options.view || renderView(options.controller, options.viewName, options.format, helpers.extend(cacheContext.content, options.params)), | ||
lifespan: cacheLifespan, | ||
resetOnAccess: cacheReset | ||
}); | ||
break; | ||
case 'global': | ||
helpers.cache({ | ||
controller: options.controller, | ||
context: cacheContext, | ||
viewName: options.viewName, | ||
view: options.view || renderView(options.controller, options.viewName, options.format, helpers.extend(cacheContext.content, options.params)), | ||
lifespan: cacheLifespan, | ||
resetOnAccess: cacheReset | ||
}); | ||
break; | ||
} | ||
} | ||
} | ||
} | ||
function handoff(params, requestContext) { | ||
@@ -497,7 +709,8 @@ var thisHandoff = helpers.copy(requestContext.handoff), | ||
if ( thisHandoff.includeView ) { | ||
if ( thisHandoff.includeThisView ) { | ||
requestContext.includesToRender[lastLink.controller] = { | ||
controller: lastLink.controller, | ||
view: thisHandoff.includeView, | ||
context: helpers.copy(requestContext) | ||
view: params.route.view, | ||
context: helpers.copy(requestContext), | ||
cache: thisHandoff.cache || false | ||
}; | ||
@@ -507,4 +720,9 @@ } | ||
handoffParams = helpers.extend(params, { route: { chain: params.route.chain.concat([{ controller: thisHandoff.controller, view: renderedView }]), renderer: renderer, renderedView: renderedView }}); | ||
delete requestContext.handoff; | ||
delete requestContext.view; | ||
requestContext.handoff = undefined; | ||
requestContext.view = undefined; | ||
requestContext.session = {}; | ||
if ( requestContext.cache && ( requestContext.cache.scope === 'controller' || requestContext.cache.scope === 'global' ) ) { | ||
requestContext.cache = undefined; | ||
} | ||
requestContext.handoffControllerName = thisHandoff.controller; | ||
fireController(handoffController, handoffParams, requestContext); | ||
@@ -517,3 +735,4 @@ } | ||
var contentType, | ||
viewContext; | ||
viewContext, | ||
view; | ||
@@ -526,7 +745,31 @@ switch ( params.route.format ) { | ||
Object.getOwnPropertyNames(requestContext.includesToRender).forEach( function (item, index, array) { | ||
var includeView = requestContext.includesToRender[item].view || requestContext.includesToRender[item].controller, | ||
includeViewContext = helpers.extend(requestContext.includesToRender[item].context.content, params); | ||
var includeController = requestContext.includesToRender[item].controller, | ||
includeView = requestContext.includesToRender[item].view || requestContext.includesToRender[item].controller, | ||
includeViewContext, | ||
cachedController, | ||
cacheKeyController = includeController + '-' + includeView + '-' + params.route.pathName, | ||
cacheKeyGlobal = includeController + '-' + includeView; | ||
includeViewContext.include = helpers.extend(includeViewContext.include, viewContext.include); | ||
viewContext.include[item] = renderView(requestContext.includesToRender[item].controller, includeView, 'html', includeViewContext); | ||
// if ( CTZN.cache.controller[cacheKeyController] ) { | ||
// viewContext.include[item] = CTZN.cache.controller[cacheKeyController].view; | ||
// } else if ( CTZN.cache.controller[cacheKeyGlobal] ) { | ||
// viewContext.include[item] = CTZN.cache.controller[cacheKeyGlobal].view; | ||
if ( helpers.exists(cacheKeyController, 'controller') || helpers.exists(cacheKeyGlobal, 'controller') ) { | ||
cachedController = helpers.retrieve(cacheKeyController, 'controller') || helpers.retrieve(cacheKeyGlobal, 'controller'); | ||
viewContext.include[item] = cachedController.view; | ||
} else { | ||
includeViewContext = helpers.extend(requestContext.includesToRender[item].context.content, params); | ||
includeViewContext.include = helpers.extend(includeViewContext.include, viewContext.include); | ||
viewContext.include[item] = renderView(requestContext.includesToRender[item].controller, includeView, 'html', includeViewContext); | ||
cacheController({ | ||
controller: includeController, | ||
route: params.route.pathName, | ||
context: requestContext.includesToRender[item].context, | ||
format: params.route.format, | ||
viewName: includeView, | ||
view: viewContext.include[item], | ||
params: params | ||
}); | ||
} | ||
}); | ||
@@ -553,5 +796,17 @@ // If debugging is enabled, append the debug output to viewContext | ||
view = renderView(params.route.renderer, params.route.renderedView, params.route.format, viewContext); | ||
params.response.setHeader('Content-Type', contentType); | ||
params.response.write(renderView(params.route.renderer, params.route.renderedView, params.route.format, viewContext)); | ||
params.response.end(responseEnd(params, requestContext)); | ||
params.response.write(view); | ||
params.response.end(); | ||
server.emit('responseEnd', params, requestContext); | ||
if ( requestContext.cache && requestContext.cache.scope === 'route' ) { | ||
helpers.cache({ | ||
route: params.route.pathName, | ||
contentType: contentType, | ||
view: view | ||
}); | ||
} | ||
} | ||
@@ -650,3 +905,3 @@ | ||
function responseEnd(params, context) { | ||
server.on('responseEnd', function (params, context) { | ||
listen({ | ||
@@ -668,7 +923,7 @@ responseEnd: function (emitter) { | ||
}); | ||
} | ||
}); | ||
function sessionEnd(params, context) { | ||
server.on('sessionEnd', function (params, context) { | ||
listen({ | ||
@@ -690,3 +945,3 @@ sessionEnd: function (emitter) { | ||
}); | ||
} | ||
}); | ||
@@ -713,48 +968,3 @@ | ||
function error(e, request, response) { | ||
var statusCode = e.statusCode || 500; | ||
switch ( CTZN.config.citizen.mode ) { | ||
case 'production': | ||
// TODO: Need friendly client error messaging for production mode. | ||
if ( e.thrownBy ) { | ||
console.log('Error thrown by ' + e.thrownBy + ': ' + e.message); | ||
if ( !e.staticAsset ) { | ||
console.log(util.inspect(e.domain)); | ||
} | ||
} else { | ||
console.log(util.inspect(e)); | ||
} | ||
if ( !response.headersSent ) { | ||
response.statusCode = statusCode; | ||
if ( e.stack ) { | ||
response.write(e.stack); | ||
} else { | ||
response.write(util.inspect(e)); | ||
} | ||
response.end(); | ||
} | ||
break; | ||
case 'development': | ||
case 'debug': | ||
if ( e.thrownBy ) { | ||
console.log('Error thrown by ' + e.thrownBy + ': ' + e.message); | ||
if ( !e.staticAsset ) { | ||
console.log(util.inspect(e.domain)); | ||
} | ||
} else { | ||
console.log(util.inspect(e)); | ||
} | ||
if ( !response.headersSent ) { | ||
response.statusCode = statusCode; | ||
if ( e.stack ) { | ||
response.write(e.stack); | ||
} else { | ||
response.write(util.inspect(e)); | ||
} | ||
response.end(); | ||
} | ||
break; | ||
} | ||
} | ||
@@ -761,0 +971,0 @@ |
@@ -35,3 +35,3 @@ // session management | ||
if ( CTZN.sessions[sessionID] ) { | ||
delete CTZN.sessions[sessionID]; | ||
CTZN.sessions[sessionID] = undefined; | ||
} | ||
@@ -38,0 +38,0 @@ } |
{ | ||
"name": "citizen", | ||
"version": "0.1.21", | ||
"version": "0.2.0", | ||
"description": "An event-driven MVC framework for Node.js web applications.", | ||
@@ -14,2 +14,3 @@ "author": { | ||
}, | ||
"license": "MIT", | ||
"main": "index", | ||
@@ -16,0 +17,0 @@ "scripts": { |
513
README.md
@@ -7,2 +7,4 @@ # citizen | ||
**Version 0.2.0 contains many breaking changes and new features.** For example, configuration files are parsed differently and I've added caching capabilities. If you've built an app based on citizen, you'll want to read this documentation thoroughly before upgrading. | ||
citizen is in beta. Your comments, criticisms, and requests are appreciated. | ||
@@ -31,6 +33,6 @@ | ||
models/ | ||
index.js // Optional | ||
index.js // Optional | ||
views/ | ||
index/ | ||
index.jade // You can use Jade (.jade), Handlebars (.hbs), or HTML files | ||
index.jade // You can use Jade (.jade), Handlebars (.hbs), or HTML files | ||
start.js | ||
@@ -44,5 +46,5 @@ web/ | ||
config/ | ||
citizen-local.json | ||
citizen-production.json | ||
db.json | ||
local.json | ||
qa.json | ||
production.json | ||
logs/ | ||
@@ -91,40 +93,38 @@ on/ | ||
<tr> | ||
<th><code>app.controllers</code></th> | ||
<th> | ||
<code>app.start()</code> | ||
</th> | ||
<td> | ||
Contains controllers from your supplied patterns, which you can use instead of <code>require</code> | ||
Starts the web server | ||
</td> | ||
</tr> | ||
<tr> | ||
<th><code>app.models</code></th> | ||
<th> | ||
<code>app.cache()</code><br /> | ||
<code>app.exists()</code><br /> | ||
<code>app.retrieve()</code><br /> | ||
<code>app.clear()</code><br /> | ||
<code>app.listen()</code><br /> | ||
<code>app.copy()</code><br /> | ||
<code>app.extend()</code><br /> | ||
<code>app.isNumeric()</code><br /> | ||
<code>app.dashes()</code><br /> | ||
</th> | ||
<td> | ||
Contains models from your supplied patterns, which you can use instead of <code>require</code> | ||
<a href="#helpers">Helpers</a> used internally by citizen, exposed publicly since you might find them useful | ||
</td> | ||
</tr> | ||
<tr> | ||
<th><code>app.views</code></th> | ||
<th> | ||
<code>app.models</code> | ||
</th> | ||
<td> | ||
Contains views (both raw and compiled) from your supplied patterns | ||
Contains models from your supplied patterns, which you can use instead of <code>require</code>. Controllers and views aren't exposed this way because you don't need to access them directly. | ||
</td> | ||
</tr> | ||
<tr> | ||
<th><code>app.start()</code></th> | ||
<td> | ||
Starts the web server | ||
</td> | ||
</tr> | ||
<tr> | ||
<th> | ||
<code>app.listen()</code> | ||
<code>app.copy()</code> | ||
<code>app.extend()</code> | ||
<code>app.isNumeric()</code> | ||
<code>app.dashes()</code> | ||
<code>app.handlebars</code> | ||
</th> | ||
<td> | ||
<a href="#helpers">Helpers</a> used internally by citizen that you might find useful in your own app | ||
</td> | ||
</tr> | ||
<tr> | ||
<th><code>app.handlebars</code></th> | ||
<td> | ||
A pointer to the citizen Handlebars global, allowing you full access to Handlebars methods such as <code>app.handlebars.registerHelper()</code> | ||
@@ -134,3 +134,5 @@ </td> | ||
<tr> | ||
<th><code>app.jade</code></th> | ||
<th> | ||
<code>app.jade</code> | ||
</th> | ||
<td> | ||
@@ -141,3 +143,5 @@ A pointer to the citizen Jade global | ||
<tr> | ||
<th><code>app.config</code></th> | ||
<th> | ||
<code>app.config</code> | ||
</th> | ||
<td> | ||
@@ -148,5 +152,7 @@ The configuration settings you supplied at startup | ||
<tr> | ||
<th><code>CTZN</code></th> | ||
<th> | ||
<code>CTZN</code> | ||
</th> | ||
<td> | ||
The global namespace used by citizen for session storage, among other things. You should not access or modify this namespace directly; anything you might need in your application will be exposed by the server to your controllers through local scopes. | ||
The global namespace used by citizen for internal objects, user sessions, and cache. You should not access or modify this namespace directly; anything you might need in your application will be exposed by the server to your controllers through local scopes. | ||
</td> | ||
@@ -160,47 +166,66 @@ </tr> | ||
citizen tries to follow convention over configuration whenever possible, but some things are best handled by a config file. | ||
citizen follows convention over configuration, but some things are best handled by a config file. | ||
The `config` directory is optional and contains configuration files for both citizen and your app in JSON format. You can have multiple citizen configuration files within this directory, allowing different configurations based on environment. citizen retrieves its configuration file from this directory based on the following logic: | ||
The `config` directory is optional and contains configuration files that drive both citizen and your app in JSON format. You can have multiple citizen configuration files within this directory, allowing different configurations based on environment. citizen retrieves its configuration file from this directory based on the following logic: | ||
1. citizen parses each JSON file whose name starts with "citizen" looking for a `hostname` key that matches the machine's hostname. If it finds one, it loads that configuration. | ||
1. citizen parses each JSON file looking for a `hostname` key that matches the machine's hostname. If it finds one, it loads that configuration. | ||
2. If it can't find a matching hostname key, it looks for a file named citizen.json and loads that configuration. | ||
3. If it can't find citizen.json, it runs under its default configuration. | ||
3. If it can't find citizen.json or you don't have a `config` directory, it runs under its default configuration. | ||
citizen also parses any other files it finds in this directory and stores the resulting configuration within `app.config`. Using the file structure above, you'd end up with `app.config.citizen` and `app.config.db`. | ||
The following represents citizen's default configuration, which is extended by your citizen configuration file: | ||
{ | ||
"mode": "production", | ||
"directories": { | ||
"app": "[absolute path to start.js]", | ||
"logs": "[directories.app]/logs", | ||
"on": "[directories.app]/on", | ||
"controllers": "[directories.app]/patterns/controllers", | ||
"models": "[directories.app]/patterns/models", | ||
"views": "[directories.app]/patterns/views", | ||
"web": "[directories.app]../web" | ||
"citizen": { | ||
"mode": "production", | ||
"directories": { | ||
"app": "[absolute path to start.js]", | ||
"logs": "[directories.app]/logs", | ||
"on": "[directories.app]/on", | ||
"controllers": "[directories.app]/patterns/controllers", | ||
"models": "[directories.app]/patterns/models", | ||
"views": "[directories.app]/patterns/views", | ||
"web": "[directories.app]../web" | ||
}, | ||
"urlPaths": { | ||
"app": "", | ||
"fileNotFound": "/404.html" | ||
}, | ||
"httpPort": 80, | ||
"hostname": "localhost", // Hostname for accepting requests | ||
"connectionQueue": undefined, | ||
"logs": { | ||
"console": true, | ||
"file": false | ||
}, | ||
"sessions": false, | ||
"sessionTimeout": 1200000, // In ms (20 minutes) | ||
"requestTimeout": 30000, // In ms (30 seconds) | ||
"mimetypes": [parsed from internal config], | ||
"debug": { | ||
"output": "console", | ||
"depth": 2, | ||
"jade": false | ||
} | ||
} | ||
} | ||
These settings are exposed publicly via `app.config.citizen`. | ||
If you want to add a database configuration for your local dev environment, you could do it like this: | ||
{ | ||
"hostname": "My-MacBook-Pro", | ||
"citizen": { | ||
// your custom citizen config | ||
}, | ||
"urlPaths": { | ||
"app": "", | ||
"fileNotFound": "/404.html" | ||
}, | ||
"httpPort": 80, | ||
"hostname": "localhost", | ||
"connectionQueue": undefined, | ||
"logs": { | ||
"console": true, | ||
"file": false | ||
}, | ||
"sessions": false, | ||
"sessionTimeout": 1200000, // 20 minutes | ||
"requestTimeout": 30000, // 30 seconds | ||
"mimetypes": [parsed from internal config], | ||
"debug": { | ||
"output": "console", | ||
"depth": 2, | ||
"jade": false | ||
"db": { | ||
"server": "127.0.0.1", | ||
"username": "dbuser", | ||
"password": "dbpassword" | ||
} | ||
} | ||
This config file would extend the default configuration when running on your local machine. The database settings would be accessible within your app via `app.config.db`. | ||
`urlPaths.app` is the path name in your app's web address. If your app's URL is: | ||
@@ -210,3 +235,3 @@ | ||
`urlPaths.app` should be "/to/my-app". This is necessary for the router to work. | ||
`urlPaths.app` should be "/to/my-app". This is necessary for citizen's router to work. | ||
@@ -225,3 +250,3 @@ | ||
Requesting that URL will cause the `index` controller to fire, because the index controller is the default. The following URL will also cause the index controller to fire: | ||
Requesting that URL would cause the `index` controller to fire, because the index controller is the default. The following URL would also cause the index controller to fire: | ||
@@ -275,3 +300,5 @@ http://www.cleverna.me/index | ||
exports.handler = handler; | ||
module.exports = { | ||
handler: handler | ||
}; | ||
@@ -352,3 +379,5 @@ function handler(params, context, emitter) { | ||
exports.handler = handler; | ||
module.exports = { | ||
handler: handler | ||
}; | ||
@@ -372,3 +401,3 @@ function handler(params, context, emitter) { | ||
To make a controller private--inaccessible via HTTP, but accessible within your app--add a plus sign (`+`) to the beginning of the file name: | ||
To make a controller private—inaccessible via HTTP, but accessible within your app—add a plus sign (`+`) to the beginning of the file name: | ||
@@ -378,4 +407,4 @@ app/ | ||
controllers/ | ||
+_header.js // Partial, only accessible internally | ||
_head.js // Partial, accessible via www.cleverna.me/_head | ||
+_header.js // Partial, only accessible internally | ||
article.js // Accessible via www.cleverna.me/article | ||
@@ -389,3 +418,3 @@ | ||
Here's a simple static model for the article pattern: | ||
Here's a simple static model for the article pattern (just an example, because storing content this way is awful): | ||
@@ -492,3 +521,3 @@ // article model | ||
In addition to the view content, the controller's `ready` emitter can also pass directives to render alternate views, set cookies and session variables, initiate redirects, call and render includes, and hand off the request to another controller for further processing. | ||
In addition to the view content, the controller's `ready` emitter can also pass directives to render alternate views, set cookies and session variables, initiate redirects, call and render includes, cache views (or entire routes), and hand off the request to another controller for further processing. | ||
@@ -502,3 +531,5 @@ | ||
exports.handler = handler; | ||
module.exports = { | ||
handler: handler | ||
}; | ||
@@ -522,3 +553,3 @@ function handler(params, context, emitter) { | ||
A complete cookie object's default values: | ||
Here's an example of a complete cookie object's default values: | ||
@@ -531,3 +562,3 @@ cookie.foo = { | ||
// 'session' - expires at the end of the browser session (default) | ||
// [time in milliseconds] - length of time, added to current time | ||
// [time in milliseconds] - added to current time for a specific expiration date | ||
expires: 'session', | ||
@@ -543,3 +574,5 @@ path: '/', | ||
exports.handler = handler; | ||
module.exports = { | ||
handler: handler | ||
}; | ||
@@ -553,2 +586,3 @@ function handler(params, context, emitter) { | ||
}), | ||
// If a directive is an empty object, that's fine. citizen just ignores it. | ||
cookie = {}; | ||
@@ -600,17 +634,16 @@ | ||
If sessions are enabled, citizen creates an object called `CTZN.sessions` to store session information. Don't access this object directly; use `params.session` in your controller or simply `session` within views. These local scopes reference the current user's session without having to pass a session ID. | ||
If sessions are enabled, you can access session variables via `params.session` in your controller or simply `session` within views. These local scopes reference the current user's session without having to pass a session ID. | ||
By default, the session has four properties: `id`, `started`, `expires`, and `timer`. The session ID is also sent to the client as a cookie called `ctznSessionID`. | ||
By default, a session has four properties: `id`, `started`, `expires`, and `timer`. The session ID is also sent to the client as a cookie called `ctznSessionID`. | ||
Setting session variables is pretty much the same as setting cookie variables: | ||
session.username = 'Danny'; | ||
session.nickname = 'Doc'; | ||
emitter.emit('ready', { | ||
content: content, | ||
session: session | ||
session: { | ||
username: 'Danny', | ||
nickname: 'Doc' | ||
} | ||
}); | ||
Sessions expire based on the `sessionTimeout` config property, which represents the length of a session in milliseconds. The default is 20 minutes. The `timer` is reset with each request. When the `timer` runs out, the session is deleted. Any client requests after that time will generate a new session ID and send a new session ID cookie to the client. Remember that the browser's session is separate from the server's session, so any cookies you've set with an expiration of `session` are untouched if the user's session expires on the server. You need to clear those cookies manually at the start of the next server session if you don't want them hanging around. | ||
Sessions expire based on the `sessionTimeout` config property, which represents the length of a session in milliseconds. The default is 1200000 (20 minutes). The `timer` is reset with each request from the user. When the `timer` runs out, the session is deleted. Any client requests after that time will generate a new session ID and send a new session ID cookie to the client. Remember that the browser's session is separate from the server's session, so any cookies you've set with an expiration of `session` are untouched if the user's session expires on the server. You need to clear those cookies manually at the start of the next server session if you don't want them hanging around. | ||
@@ -633,10 +666,7 @@ To forcibly clear and expire the current user's session: | ||
redirect = { | ||
statusCode: 301, | ||
url: 'http://redirect.com' | ||
}; | ||
emitter.emit('ready', { | ||
content: content, | ||
redirect: redirect | ||
redirect: { | ||
statusCode: 301, | ||
url: 'http://redirect.com' | ||
} | ||
}); | ||
@@ -700,3 +730,5 @@ | ||
exports.handler = handler; | ||
module.exports = { | ||
handler: handler | ||
}; | ||
@@ -714,3 +746,5 @@ function handler(params, context, emitter) { | ||
emitter.emit('ready', { | ||
content: content, | ||
content: { | ||
article: article | ||
}, | ||
include: { | ||
@@ -747,3 +781,3 @@ _head: { | ||
**A pattern meant to be used as an include can be accessed via URL just like any other controller.** You could request the `_head` controller like so: | ||
**A pattern meant to be used as an include can be accessed via HTTP just like any other controller.** You could request the `_head` controller like so: | ||
@@ -768,6 +802,8 @@ http://cleverna.me/_head | ||
exports.handler = handler; | ||
module.exports = { | ||
handler: handler | ||
}; | ||
function handler(params, context, emitter) { | ||
var metaData, | ||
var metaData = {}, | ||
// If the article URL param exists, use that. Otherwise, assume _head is | ||
@@ -790,3 +826,3 @@ // being used as an include and use the requested route. | ||
**Reminder:** To make a controller private--inaccessible via HTTP, but accessible within your app--add a plus sign (`+`) to the beginning of the file name: | ||
**Reminder:** To make a controller private—inaccessible via HTTP, but accessible within your app—add a plus sign (`+`) to the beginning of the file name: | ||
@@ -796,4 +832,4 @@ app/ | ||
controllers/ | ||
+_header.js // Only accessible internally | ||
_head.js // Accessible via www.cleverna.me/_head | ||
+_header.js // Only accessible internally | ||
article.js // Accessible via www.cleverna.me/article | ||
@@ -806,3 +842,3 @@ | ||
* **Do you only need to share a chunk of markup across different views?** Use a standard Handlebars partial, Jade template, or HTML document. The syntax is easy and you don't have to create a full MVC pattern like you would with a citizen include. | ||
* **Do you need to loop over a chunk of markup to render a data set?** The server processes citizen includes and returns them as fully-rendered HTML, not compiled templates. You can't loop over them and inject data like you can with Handlebars partials or Jade includes. | ||
* **Do you need to loop over a chunk of markup to render a data set?** The server processes citizen includes and returns them as fully-rendered HTML (or JSON), not compiled templates. You can't loop over them and inject data like you can with Handlebars partials or Jade includes. | ||
* **Do you need to retrieve additional content that isn't in the parent view's context?** A citizen include can do anything that a standard MVC pattern can do except set the [handoff](#controller-handoff) directive. If you want to retrieve additional data and add it to the view context or set cookies and session variables, a citizen include is the way to go. | ||
@@ -820,3 +856,5 @@ * **Do you need the ability to render different includes based on business logic?** citizen includes can have multiple views because they're full MVC patterns. Using a citizen include, you can place logic in the include's controller and request different views based on that logic. Using Handlebars partials or Jade includes would require registering multiple partials and putting the logic in the view template. | ||
exports.handler = handler; | ||
module.exports = { | ||
handler: handler | ||
}; | ||
@@ -839,6 +877,6 @@ function handler(params, context, emitter) { | ||
// Rendering the requested controller's view is optional. | ||
// Using includeView tells citizen to render the article.jade view and | ||
// store it in the include scope. If you don't specify the includeView, | ||
// Using includeThisView tells citizen to render the article.jade view and | ||
// store it in the include scope. If you don't specify includeThisView, | ||
// the article controller's view won't be rendered. | ||
includeView: 'article' | ||
includeThisView: true | ||
}, | ||
@@ -855,3 +893,3 @@ | ||
When you use the `handoff` directive and specify the `includeView` like we did above, the originally requested view (article.jade in this case) is rendered as an include whose name matches its controller: | ||
When you use the `handoff` directive and specify `includeThisView` like we did above, the originally requested view (article.jade in this case) is rendered as an include whose name matches its controller: | ||
@@ -868,3 +906,5 @@ // article.jade, which is stored in the include scope as include.article | ||
exports.handler = handler; | ||
module.exports = { | ||
handler: handler | ||
}; | ||
@@ -886,4 +926,5 @@ function handler(params, context, emitter) { | ||
emitter.emit('ready', { | ||
// No need to specify `content` here because citizen keeps track of the | ||
// article pattern's request context throughout the handoff process | ||
// No need to specify previous directives, such as content, here because | ||
// citizen keeps track of the article pattern's request context throughout | ||
// the handoff process. | ||
include: { | ||
@@ -923,3 +964,3 @@ _head: { | ||
You can use `handoff` to chain requests across as many controllers as you want, with each controller's directives added to the request context and each controller's view optionally added to the include scope. The initially requested controller's name and all following handoff controllers' names are stored in the `route` object as an array called `route.chain`. You can loop over this object to render all the included views: | ||
You can use `handoff` to chain requests across as many controllers as you want, with each controller's directives added to the request context and each controller's view optionally added to the include scope. The initially requested controller's name and all following handoff controllers' names, along with their view names, are stored in the `route` object as an array called `route.chain`. You can loop over this object to render all the included views: | ||
@@ -936,6 +977,179 @@ // layout.jade | ||
each val in route.chain | ||
!= include[val] | ||
!= include[val.controller] | ||
### Cache | ||
In many cases, a requested route or controller will generate the same view every time based on the same input parameters, so it doesn't make sense to run the controller and render the view from scratch for each request. citizen provides flexible caching capabilities to speed up your server side rendering via the `cache` directive. | ||
Here's an example `cache` directive (more details after the code sample): | ||
emitter.emit('ready', { | ||
cache: { | ||
// Required. Valid values are 'controller', 'route', and 'global' | ||
scope: 'route', | ||
// Optional. List of valid URL parameters that protects against | ||
// accidental caching of malformed URLs. | ||
urlParams: ['article', 'page'], | ||
// Optional. List of directives to cache with the controller. | ||
directives: ['handoff', 'cookie'], | ||
// Optional. Life of cached item in milliseconds. Default is the life of | ||
// the application (no expiration). | ||
lifespan: 600000 | ||
// Reset the cached item's expiration timer whenever the item is | ||
// accessed, keeping it in the cache until traffic subsides. | ||
resetOnAccess: true | ||
}); | ||
#### cache.scope | ||
The `scope` property determines how the controller and its resulting view are cached. | ||
<table> | ||
<thead> | ||
<tr> | ||
<th colspan="2"> | ||
Values for <code>cache.scope</code> | ||
</th> | ||
</tr> | ||
</thead> | ||
<tr> | ||
<th> | ||
route | ||
</th> | ||
<td> | ||
<p> | ||
A cache scope of "route" caches the entire rendered view for a given route. If a route's view doesn't vary across requests, use this scope to render it once when it's first requested and then serve it from the cache for every following request. | ||
</p> | ||
</td> | ||
</tr> | ||
<tr> | ||
<th> | ||
controller | ||
</th> | ||
<td> | ||
<p> | ||
Setting cache scope to "controller" caches an instance of the controller and the resulting view for every unique route that calls the controller. If the following URLs are requested and the article controller's cache scope is set to "controller", each URL will get its own unique cached instance of the article controller: | ||
</p> | ||
<ul> | ||
<li> | ||
<code>http://cleverna.me/article/My-Clever-Article</code> | ||
</li> | ||
<li> | ||
<code>http://cleverna.me/article/My-Clever-Article/page/2</code> | ||
</li> | ||
<li> | ||
<code>http://cleverna.me/article/Another-Article</code> | ||
</li> | ||
</ul> | ||
<p> | ||
This may sound similar to the "route" scope, but a good use of the "controller" scope is when you're calling multiple controllers using citizen includes or the handoff directive and you want to cache each of those controllers based on the route, but not cache the final rendered view (like the previous layout controller example). | ||
</p> | ||
</td> | ||
</tr> | ||
<tr> | ||
<th> | ||
global | ||
</th> | ||
<td> | ||
A cache scope of "global" caches a single instance of a given controller and uses it everywhere, regardless of context or the requested route. If you have a controller whose output and rendering won't change across requests regardless of the context or route, "global" is a good option. | ||
</td> | ||
</tr> | ||
</table> | ||
#### cache.urlParams | ||
The `urlParams` property helps protect against invalid cache items (or worse: an attack meant to flood your server's resources by overloading the cache). If we used the example above in our article controller, the following URLs would be cached because the "article" and "page" URL paramters are permitted: | ||
http://cleverna.me/article | ||
http://cleverna.me/article/My-Article-Title | ||
http://cleverna.me/article/My-Article-Title/page/2 | ||
These URLs wouldn't be cached, which is a good thing because it wouldn't take long for an attacker's script to loop over a URL and flood the cache: | ||
// "dosattack" isn't a valid URL parameter | ||
http://cleverna.me/article/My-Article-Title/dosattack/1 | ||
http://cleverna.me/article/My-Article-Title/dosattack/2 | ||
// "page" is valid, but "dosattack" isn't, so it's not cached | ||
http://cleverna.me/article/My-Article-Title/page/2/dosattack/3 | ||
The server will throw an error when an invalid URL is requested with a cache directive. Additionally, any URL that results in an error won't be cached, whether it's valid or not. | ||
#### cache.directives | ||
By default, any directives you specify in a cached controller aren't cached; they're implemented the first time the controller is called and then ignored after that. This is to prevent accidental storage of private data in the cache through session or cookie directives. | ||
If you want directives to persist within the cache, include them in the `directives` property as an array: | ||
emitter.emit('ready', { | ||
handoff: { | ||
controller: 'layout', | ||
includeThisView: true | ||
}, | ||
cookie: { | ||
myCookie: { | ||
value: 'howdy' | ||
} | ||
}, | ||
myCustomDirective: { | ||
doSomething: true | ||
}, | ||
cache: { | ||
scope: 'controller', | ||
// Cache handoff and myCustomDirective so that if this controller is | ||
// called from the cache, it hands off to the layout controller and acts | ||
// upon myCustomDirective every time. The cookie directive will only be | ||
// acted upon the first time the controller is called, however. | ||
directives: ['handoff', 'myCustomDirective'] | ||
} | ||
}); | ||
#### Cache Limitations and Warnings | ||
Controllers that use the `include` directive can't use global or controller cache scopes due to the way citizen renders includes, but it's on the roadmap for a future release. In the meantime, you can get around it by using the cache directive within the included controllers. The route scope works fine. | ||
If you use the handoff directive to call a series of controllers and any one of those controllers sets the cache directive with the route scope, it takes priority over any cache settings in the following controllers. This is because the route scope caches the entire controller chain as a single cache object. | ||
citizen's cache is a RAM cache, so be careful with your caching strategy. You could very quickly find yourself out of RAM. Use the lifespan option so URLs that aren't receiving a ton of traffic naturally fall out of the cache and free up resources for frequently accessed pages. | ||
Cache defensively. Place logic in your controllers that combines the urlParams validation with some simple checks so invalid URLs don't result in junk pages clogging your cache: | ||
// article controller | ||
module.exports = { | ||
handler: handler | ||
}; | ||
function handler(params, context, emitter) { | ||
var article = app.model.article.getArticle(params.url.article, params.url.page); | ||
// If the article exists, cache the result. citizen will compare the | ||
// existing URL parameters against the urlParams list you provide. If | ||
// there's a mismatch, citizen won't cache the result. | ||
if ( article.title ) { | ||
emitter.emit('ready', { | ||
content: { | ||
article: article | ||
}, | ||
cache: { | ||
scope: 'controller', | ||
urlParams: ['article', 'page'] | ||
} | ||
}); | ||
// Throw a 404 if the article doesn't exist. Nothing will be cached. | ||
} else { | ||
throw { | ||
statusCode: 404 | ||
}; | ||
} | ||
} | ||
# Application Events and the Context Argument | ||
@@ -945,3 +1159,3 @@ | ||
To take advantage of these events, include a directory called "on" in your app with the any or all of follwowing modules and exports: | ||
To take advantage of these events, include a directory called "on" in your app with any or all of follwowing modules and exports: | ||
@@ -955,3 +1169,3 @@ app/ | ||
`request.start()`, `request.end()`, and `response.start()` are called before your controller is fired, so the output from those events is passed from each one to the next, and ultimately to your controller via the `context` argument. Exactly what they output--content, citizen directives, custom directives--is up to you. | ||
`request.start()`, `request.end()`, and `response.start()` are called before your controller is fired, so the output from those events is passed from each one to the next, and ultimately to your controller via the `context` argument. Exactly what they output—content, citizen directives, custom directives—is up to you. | ||
@@ -991,4 +1205,4 @@ All files and exports are optional. citizen only calls them if they exist. For example, you could have only a request.js module that exports `start()`. | ||
access: { | ||
// citizen expects header names in lowercase (per the spec, HTTP headers are | ||
// case-insensitive) | ||
// citizen expects header names in lowercase (per the spec, HTTP headers | ||
// are case-insensitive) | ||
'access-control-allow-origin': 'http://www.foreignhost.com', | ||
@@ -1011,2 +1225,67 @@ 'access-control-expose-headers': 'X-My-Custom-Header, X-Another-Custom-Header', | ||
### cache(options) | ||
You can store any object in citizen's cache. | ||
// Cache a string for the life of the application. | ||
app.cache({ | ||
key: 'welcomeMessage', | ||
value: 'Welcome to my site.' | ||
}); | ||
// Cache a string for the life of the application, and overwrite the | ||
// existing key. The overwrite property is required any time you want to | ||
// write to an existing key. This prevents accidental overwrites. | ||
app.cache({ | ||
key: 'welcomeMessage', | ||
value: 'Welcome to our site.', | ||
overwrite: true | ||
}); | ||
// Cache a file buffer using the file path as the key. This is a wrapper for | ||
// fs.readFile and fs.readFileSync paired with citizen's cache function. | ||
// Optionally, tell citizen to perform a synchronous file read operation and | ||
// use an encoding different from the default (UTF-8). | ||
app.cache({ | ||
file: '/path/to/articles.txt', | ||
synchronous: true, | ||
encoding: 'CP-1252' | ||
}); | ||
// Cache a file with a custom key. Optionally, parse the JSON and store the | ||
// parsed object in the cache instead of the raw buffer. Expire the cache | ||
// after 60000 ms (60 seconds), but reset the timer whenever the key is | ||
// retrieved. | ||
app.cache({ | ||
file: '/path/to/articles.json', | ||
key: 'articles', | ||
parseJSON: true, | ||
lifespan: 60000, | ||
resetOnAccess: true | ||
}); | ||
### exists(key) | ||
This is a way to check for the existence of a given key in the cache without resetting the cache timer on that key. | ||
app.exists('welcomeMessage') // true | ||
app.exists('/path/to/articles.txt') // true | ||
app.exists('articles') // true | ||
### retrieve(key) | ||
Retrieve a cached object using the key name. If `resetOnAccess` was true when the item was cached, using retrieve() will reset the cache clock and extend the life of the cached item. | ||
app.retrieve('welcomeMessage') | ||
app.retrieve('/path/to/articles.txt') | ||
app.retrieve('articles') | ||
### clear(key) | ||
Clear a cache object using the key name. | ||
app.clear('welcomeMessage') | ||
app.clear('/path/to/articles.txt') | ||
app.clear('articles') | ||
### listen({ functions }, callback) | ||
@@ -1022,3 +1301,5 @@ | ||
exports.handler = handler; | ||
module.exports = { | ||
handler: handler | ||
}; | ||
@@ -1025,0 +1306,0 @@ function handler(params, context, emitter) { |
125297
1850
1394
6