UNPKG

mojito

Version:

Mojito provides an architecture, components and tools for developers to build complex web applications faster.

631 lines (547 loc) 23.4 kB
/* * Copyright (c) 2011-2013, Yahoo! Inc. All rights reserved. * Copyrights licensed under the New BSD License. * See the accompanying LICENSE file for terms. */ /*jslint anon:true, nomen:true*/ /*global YUI, setTimeout, clearTimeout*/ /** * The Action Context is a key part of the Mojito framework. The <em>ac</em>, * for short, gives you access to the frameworks features from within a * controller function. The ac is an abstraction that allows you to execute * mojit actions within either a server or client context. * @module ActionContext */ YUI.add('mojito-action-context', function(Y, NAME) { 'use strict'; /** * This dispatch function is called one time per Mojito execution. It * creates a contextualized Y instance for all further internal dispatches * to use. It also creates the ActionContext for the mojit. * * The command has three main parts: the "instance", the "context", and the * "params". * <pre> * command: { * instance: ...see below... * context: ...see below... * params: ...see below... * } * </pre> * * The "instance" is a partial instance with details of the mojit instance. * See `ServerStore.expandInstance()` for details of the structure and which * fields are required. * * The "context" is the request context. It is built by the * "contextualizer" middleware. * * The "params" is a structured set of parameters to pass to the mojit. * <pre> * params: { * route: {}, * url: {}, * body: {}, * file: {}, * ... * } * </pre> * * <pre> * adapter: { * flush: function(data, meta){}, * done: function(data, meta){}, * error: function(err){} * } * </pre> * @method dispatch * @param {map} command the "command" describing how to dispatch the mojit. * See above. * @param {object} adapter the output adapter to pass to the mojit. See * above. * @deprecated Use 'ac._dispatch()' instead. See https://github.com/yahoo/mojito/blob/develop/DEPRECATIONS.md * for details. */ /** * This _dispatch function is called one time per Mojito execution. It * creates a contextualized Y instance for all further internal dispatches * to use. It also creates the ActionContext for the mojit. * * The command has three main parts: the "instance", the "context", and the * "params". * <pre> * command: { * instance: ...see below... * context: ...see below... * params: ...see below... * } * </pre> * * The "instance" is a partial instance with details of the mojit instance. * See `ServerStore.expandInstance()` for details of the structure and which * fields are required. * * The "context" is the request context. It is built by the * "contextualizer" middleware. * * The "params" is a structured set of parameters to pass to the mojit. * <pre> * params: { * route: {}, * url: {}, * body: {}, * file: {}, * ... * } * </pre> * * <pre> * adapter: { * flush: function(data, meta){}, * done: function(data, meta){}, * error: function(err){} * } * </pre> * @method _dispatch * @param {map} command the "command" describing how to dispatch the mojit. * See above. * @param {object} adapter the output adapter to pass to the mojit. See * above. */ var CHARSET = 'charset=utf-8', // the functions this core addon is going to attach to the // ActionContext flush, done, error, // serializer container serializer, CACHE = { renderers: {} }; function sanitizeChildren(children) { if (!Y.Lang.isObject(children)) { return children; } Y.Object.each(children, function(v, k) { // We don't want child params to be included within a mojit's // configuration, because it can leak implemenation details out to // other execution environments. For example, the client runtime // does not need to have the parameters of the mojits that were used // to construct the initial client DOM. children[k].params = undefined; }); return children; } function sanitizeConfigCopy(cfg) { var copy; if (!Y.Lang.isObject(cfg)) { return cfg; } copy = Y.mojito.util.copy(cfg); copy.children = sanitizeChildren(copy.children); return copy; } function attachChildViewIdsToMetaChildren(children, binders) { if (!children) { return; } Y.Object.each(binders, function(binderData, viewId) { Y.Object.each(children, function(childData) { if (binderData.instanceId === childData.instanceId) { childData.viewId = viewId; } }); }); } serializer = { /* * @method json * @private * @param {object} data * @param {object} meta * @return {string} */ json: function(data, meta) { meta.http.headers['content-type'] = ['application/json; ' + CHARSET]; try { return Y.JSON.stringify(data); } catch (err) { throw new Error('Expected JSON data, but there was a parse error' + ' on the string: \"' + data); } }, /* * @method xml * @private * @param {object} data * @param {object} meta * @return {string} */ xml: function(data, meta) { // A dirty XML function I found on the interwebs function simpleXml(js, wraptag) { if (js instanceof Object) { return simpleXml(Y.Object.keys(js).map(function(key) { return simpleXml(js[key], key); }).join('\n'), wraptag); } return ((wraptag) ? '<' + wraptag + '>' : '') + js + ((wraptag) ? '</' + wraptag + '>' : '' ); } meta.http.headers['content-type'] = ['application/xml; ' + CHARSET]; if (Y.Lang.isObject) { try { return simpleXml(data, 'xml'); } catch (err) { throw new Error('Expected XML data, but there was a parse' + ' error on the string: \"' + err.message); } } return ''; } }; /** * Mixes all the Action Context addons into the Action Context * @private * @method attachActionContextAddons * @param {Array} addons The action context addons. * @param {object} command The command object. * @param {object} adapter The output adapter. * @param {Y.mojito.ActionContext} ac The action context. * @param {ResourceStore} store the resource store */ function attachActionContextAddons(addons, command, adapter, ac, store) { var i, addon, addonName, acAddons = command.instance.acAddons || []; // HookSystem::StartBlock Y.mojito.hooks.hook('attachActionContext', adapter.hook, 'start', command); // HookSystem::EndBlock for (i = 0; i < acAddons.length; i += 1) { addonName = acAddons[i]; if (addons[addonName]) { addon = new addons[addonName](command, adapter, ac); if (addon.namespace) { ac[addon.namespace] = addon; // TODO: this is a big hack to pass the store reference // into the addon without changing the signature of ctor, // instead we should pass an object with all the stuff that // an addon will need as part of the ctor. if (Y.Lang.isFunction(addon.setStore)) { addon.setStore(store); } } } else { Y.log('[' + addonName + '] addon was not found for mojit ' + command.instance.type, 'warn', NAME); } } // HookSystem::StartBlock Y.mojito.hooks.hook('attachActionContext', adapter.hook, 'end', command); // HookSystem::EndBlock } /** * The main point of entry for all mojits into Mojito. The Action Context is * passed to every mojit action during execution, either on the client or * server. This object is the API into Mojito, can can have many plugins * attached the provide extra functionality. * @class ActionContext */ function ActionContext(opts) { var controller = opts.controller, command = opts.command, store = opts.store, actionFunction, error, my = this; // HookSystem::StartBlock Y.mojito.hooks.hook('actionContext', opts.adapter.hook, 'start', my, opts); // HookSystem::EndBlock // It's possible to setup a route that calls "foo.", which means that // the default action in the instance should be used instead. if (!command.action) { command.action = command.instance.action || 'index'; } this.action = command.action; this.type = command.instance.type; this.context = command.context; this.dispatcher = opts.dispatcher; actionFunction = this.action; // These should not be on the ac object this.command = command; this.instance = command.instance; this._adapter = opts.adapter; // Create a function which will properly delegate to the dispatcher to // perform the actual processing. this._dispatch = function(command, adapter) { return my.dispatcher.dispatch(command, adapter); }; attachActionContextAddons(Y.mojito.addons.ac, command, opts.adapter, this, store); // Check if the controller has the requested action if (!Y.Lang.isFunction(controller[actionFunction])) { // If the action is not found try the '__call' function if (Y.Lang.isFunction(controller.__call)) { actionFunction = '__call'; } else { // If there is still no joy then die error = new Error('Action "' + this.action + '" not defined by the controller named "' + this.instance.controller + '" of the "' + this.type + '" mojit.'); error.code = 404; throw error; } } // HookSystem::StartBlock Y.mojito.hooks.hook('actionContext', opts.adapter.hook, 'end1', my, opts); // HookSystem::EndBlock // Reap the request/ac process within the timeout. If ac.done or // ac.error is invoked by user code prior to the time limit this // timer will be cleared. if (this._adapter.page.staticAppConfig.actionTimeout) { this._timer = setTimeout(function() { var err, msg = 'Killing potential zombie context for Mojit type: ' + my.instance.type + ', controller: ' + my.instance.controller + ', action: ' + actionFunction; // Clear the timer reference so our invocation of error() // doesn't try to clear it. my._timer = null; // Create an HTTP Timeout error with controller/action info. err = new Error(msg); err.code = 408; my.error(err); my.done = function() { Y.log('ac.done() called after timeout. results lost', 'warn', NAME); }; }, this._adapter.page.staticAppConfig.actionTimeout); } controller[actionFunction](this); // HookSystem::StartBlock Y.mojito.hooks.hook('actionContext', opts.adapter.hook, 'end2', my, opts); // HookSystem::EndBlock } ActionContext.prototype = { /** * Returns data in the request and allows you to carry on execution. * @method flush * @param {object|string} data The data you want return by the request. * @param {object} meta Any meta-data required to service the request. */ flush: function(data, meta) { return this.done(data, meta, true); }, /** * Returns data and closes the request. * @method done * @param {object|string} data The data you want return by the request. * @param {object} meta Any meta-data required to service the request. */ done: function(data, meta, more) { // If we have an active timer clear it immediately. if (this._timer) { clearTimeout(this._timer); this._timer = null; } var callbackFunc = more ? 'flush' : 'done', instance = this.command.instance, config = instance.config || {}, context = this.command.context || {}, adapter = this._adapter, page = adapter.page || {}, action = this.command.action, mojitView, renderer = null, contentType, instanceData; // TODO: optimize this. it is not needed if there is no binder // nor view name, but it is needed in 90ish % of the cases if (instance.data && instance.data.toJSON) { instanceData = instance.data.toJSON(); } // HookSystem::StartBlock Y.mojito.hooks.hook('actionContextDone', adapter.hook, 'start', this); // HookSystem::EndBlock if (Y.Lang.isString(meta)) { // If the meta string is a serializer set it if (serializer[meta]) { meta = { serialize: meta }; } else {// Otherwise we think it is a template name meta = { view: {name: meta} }; } } meta = meta || {}; meta.assets = meta.assets || {}; meta.assets.bottom = meta.assets.bottom || {}; meta.assets.bottom.js = meta.assets.bottom.js || []; meta.http = meta.http || {}; meta.http.code = meta.http.code || 200; meta.http.headers = meta.http.headers || {}; meta.view = meta.view || {}; // Check to see we need to serialize the data if (meta.serialize && serializer[meta.serialize]) { // Warning: this metod can change the "meta" object data = serializer[meta.serialize].apply(this, [data, meta]); // Once we are done, invalidate the "serialize" option so others don't // use it by mistake meta.serialize = undefined; } // We want to know the view name, id, and binder used later so make sure // "meta" is up-to-date meta.view.name = meta.view.name || action; // TODO: Use a different binder meta.view.binder = meta.view.binder || meta.view.name; mojitView = instance.views[meta.view.name]; if (!meta.view.id) { meta.view.id = Y.guid(); //DEBUGGING: meta.view.id += '-viewId-' + // this.command.instance.type + '-' + this.command.action; } // If we are given "meta.view['content-path']" use it over what we got // from "instance.views" if (mojitView && meta.view['content-path']) { mojitView['content-path'] = meta.view['content-path']; } // If we are given "meta.view['engine']" use it over what we got from // "instance.views" if (mojitView && meta.view.engine) { mojitView.engine = meta.view.engine; } if (mojitView && mojitView.assets) { meta.assets = Y.mojito.util.metaMerge(meta.assets, mojitView.assets); } meta.assets = Y.mojito.util.metaMerge(meta.assets, config.assets || {}); // Here we ask each "thing" attached to the AC if it wants to add view // "meta" Y.Object.each(this, function(item) { if (item && Y.Lang.isFunction(item.mergeMetaInto)) { item.mergeMetaInto(meta); } }); contentType = meta.http.headers['content-type']; attachChildViewIdsToMetaChildren(meta.children, meta.binders); if (!meta.binders) { meta.binders = {}; } // Don't clobber an existing meta.binders[meta.view.id] entry if (!meta.binders[meta.view.id]) { // do not add binder meta if there is not binder available if (meta.view.binder && instance.binders && instance.binders[meta.view.binder]) { meta.binders[meta.view.id] = { base: instance.base, name: instance.binders[meta.view.binder], // YUI Module name of the Binder action: action, type: instance.type, viewId: meta.view.id, instanceId: instance.instanceId, // We don't use the actual config's children object, because // that might not have been what was actually dispatched. We get // the actual children config that was dispatched through the // meta object. children: sanitizeChildren(meta.children || config.children), // adding instance data model into the binder map to rehydrated in the client data: instanceData }; if (instance.proxied) { // direct support for proxied children meta.binders[meta.view.id].proxied = sanitizeChildren({proxied: instance.proxied}).proxied; } } } /* * Here we provide an easy way to return a string * data == 'a string of chars' */ if (Y.Lang.isString(data)) { // if the user didn't provided a content type, we'll make it plain // text if (!contentType) { meta.http.headers['content-type'] = ['text/plain; ' + CHARSET]; } //Y.log('pushing to native adapter', 'info', NAME); adapter[callbackFunc](data, meta); // HookSystem::StartBlock Y.mojito.hooks.hook('actionContextDone', adapter.hook, 'end1', this); // HookSystem::EndBlock return; } // there may not be a view if this is running on the client if (mojitView) { data = data || {}; // default null data to empty view template if (!contentType) { meta.http.headers['content-type'] = ['text/html; ' + CHARSET]; } // Merge instance.data + data, no need to do recursive merge. // The properties from later objects will overwrite those in // earlier objects. data = Y.merge( instanceData || {}, data ); // some provisions for the view data.mojit_guid = instance.instanceId; data.mojit_view_id = meta.view.id; data.mojit_assets = instance.assetsRoot; // page.data will be accessible in views thru `page` // but only if the mojit is not overruling it by setting it // thru ac.data.set('page', {}) or by ac.done({ page: {} }) // TODO: optimize this or it is going to happen // for every mojit instance in the page with a binder if (!data.page && page.data && page.data.toJSON) { data.page = page.data.toJSON(); } // Use engine to compile template view // Y.log('Rendering "' + meta.view.name + '" view for "' + // (instance.id || '@' + instance.type) + '"', 'info', NAME); // TODO: we might want to use a view renderer factory // that can provide caching capabilities for better performance // instead of creating objects over and over again per mojit instance renderer = new Y.mojito.ViewRenderer(mojitView.engine, page.staticAppConfig.viewEngine); renderer.render(data, instance, mojitView, adapter, meta, more); } else { if (Y.Lang.isObject(data)) { throw new Error("Missing view template: '" + meta.view.name + "'"); } adapter[callbackFunc](data, meta); } // HookSystem::StartBlock Y.mojito.hooks.hook('actionContextDone', adapter.hook, 'end2', this); // HookSystem::EndBlock }, /** * Programatically report an error to Mojito, which will handle it * gracefully. * @method error * @param {Error} err A normal JavaScript Error object is expected, but you * may add a "code" property to the error if you want the framework to * report a certain HTTP status code for the error. For example, if the * status code is 404, Mojito will generate a 404 page. Additionally you * might provide a reasonPhrase property, to override the default human * readable description for this status code with one specific to your * application. For example for the status code 404 you could provide * "This does not exist in my app". */ error: function(err) { // If we have an active timer clear it immediately. if (this._timer) { clearTimeout(this._timer); this._timer = null; } this._adapter.error(err); } }; Y.namespace('mojito').ActionContext = ActionContext; }, '0.1.0', {requires: [ 'mojito', 'json-stringify', 'event-custom-base', 'mojito-view-renderer', 'mojito-util', 'mojito-hooks' ]});