UNPKG

pipe.js

Version:

The client-side library which is used in BigPipe to orchestrate the pagelets.

666 lines (574 loc) 18.3 kB
/*globals */ 'use strict'; var EventEmitter = require('eventemitter3') , collection = require('./collection') , AsyncAsset = require('async-asset') , Fortress = require('fortress') , async = require('./async') , val = require('parsifal') , undefined , sandbox; // // Async Asset loader. // var assets = new AsyncAsset(); /** * Representation of a single pagelet. * * @constructor * @param {Pipe} pipe The pipe. * @api public */ function Pagelet(pipe) { EventEmitter.call(this); this.orchestrate = pipe.orchestrate; this.stream = pipe.stream; this.pipe = pipe; // // Create one single Fortress instance that orchestrates all iframe based client // code. This sandbox variable should never be exposed to the outside world in // order to prevent leaking. // this.sandbox = sandbox = sandbox || new Fortress(); } // // Inherit from EventEmitter. // Pagelet.prototype = new EventEmitter(); Pagelet.prototype.constructor = Pagelet; /** * Configure the Pagelet. * * @param {String} name The given name of the pagelet. * @param {Object} data The data of the pagelet. * @param {Array} roots HTML root elements search for targets. * @api private */ Pagelet.prototype.configure = function configure(name, data, roots) { var pipe = this.pipe , pagelet = this; // // Pagelet identification. // this.id = data.id; // ID of the pagelet. this.name = name; // Name of the pagelet. this.css = collection.array(data.css); // CSS for the Page. this.js = collection.array(data.js); // Dependencies for the page. this.run = data.run; // Pagelet client code. this.rpc = data.rpc; // Pagelet RPC methods. this.data = data.data; // All the template data. this.mode = data.mode; // Fragment rendering mode. this.streaming = !!data.streaming; // Are we streaming POST/GET. this.container = this.sandbox.create(); // Create an application sandbox. this.timeout = data.timeout || 25 * 1000; // Resource loading timeout. this.hash = data.hash; // Hash of the template. // // This pagelet was actually part of a parent pagelet, so set a reference to // the parent pagelet that was loaded. // var parent = this.parent = data.parent ? pipe.get(data.parent) : undefined; // // Locate all the placeholders for this given pagelet. // this.placeholders = this.$('data-pagelet', name, roots); // // The pagelet as we've been given the remove flag. // if (data.remove) { return this.destroy(true); } // // Attach event listeners for FORM posts so we can intercept those. // this.listen(); // // Create a real-time Substream over which we can communicate over without. // this.substream = this.stream.substream(this.name); this.substream.on('data', function data(packet) { pagelet.processor(packet); }); // // Register the pagelet with the BigPipe server as an indication that we've // been fully loaded and ready for action. // this.orchestrate.write({ type: 'pagelet', name: name, id: this.id }); // // Generate the RPC methods that we're given by the server. We will make the // assumption that: // // - A callback function is always given as last argument. // - The function should return it self in order to chain. // - The function given supports and uses error first callback styles. // - Does not override the build-in prototypes of the Pagelet. // collection.each(this.rpc, function rpc(method) { var counter = 0; // // Never override build-in methods as this WILL affect the way a Pagelet is // working. // if (method in Pagelet.prototype) return; pagelet[method] = function rpcfactory() { var args = Array.prototype.slice.call(arguments, 0) , id = method +'#'+ (++counter); pagelet.once('rpc:'+ id, args.pop()); pagelet.substream.write({ method: method, type: 'rpc', args: args, id: id }); return pagelet; }; }); // // Should be called before we create `rpc` hooks. // this.broadcast('configured', data); async.each(this.css.concat(this.js), function download(url, next) { assets.add(url, next); }, function done(err) { if (err) return pagelet.broadcast('error', err); pagelet.broadcast('loaded'); pagelet.render(pagelet.parse()); // // All resources are loaded, but we have a parent element. When the parent // element renders it will most likely also nuke our placeholder references // preventing us from rendering updates again. // if (parent) parent.on('render', function render() { pagelet.placeholders = pagelet.$('data-pagelet', pagelet.name, parent.placeholders); pagelet.render(pagelet.data || pagelet.parse()); }); pagelet.initialize(); }, { context: this.pipe, timeout: this.timeout }); }; /** * Get the template for a given type. We currently only support `client` and * `error` as types. * * @param {String} type Template type * @returns {Function} * @api private */ Pagelet.prototype.template = function template(type) { type = type || 'client'; return this.pipe.templates[this.hash[type]]; }; /** * Get a pagelet loaded on the page. If we have * * @param {String} name Name of the pagelet we need. * @returns {Pagelet|Undefined} */ Pagelet.prototype.pagelet = function pagelet(name) { return this.pipe.get(name, this.name); }; /** * Intercept form posts and stream them over our substream instead to prevent * full page reload. * * @returns {Pagelet} * @api private */ Pagelet.prototype.listen = function listen() { var pagelet = this; /** * Handles the actual form submission. * * @param {Event} evt The submit event. * @api private */ function submission(evt) { evt = evt || window.event; var form = evt.target || evt.srcElement; // // In previous versions we had and `evt.preventDefault()` so we could make // changes to the form and re-submit it. But there's a big problem with that // and that is that in FireFox it loses the reference to the button that // triggered the submit. If causes buttons that had a name and value: // // ```html // <button name="key" value="value" type="submit">submit</button> // ``` // // To be missing from the POST or GET. We managed to go around it by not // simply preventing the default action. If this still does not not work we // need to transform the form URLs once the pagelets are loaded. // if ( ('getAttribute' in form && form.getAttribute('data-pagelet-async') === 'false') || !pagelet.streaming ) { var action = form.getAttribute('action'); return form.setAttribute('action', [ action, ~action.indexOf('?') ? '&' : '?', '_pagelet=', pagelet.name ].join('')); } // // As we're submitting the form over our real-time connection and gather the // data our self we can safely prevent default. // evt.preventDefault(); pagelet.submit(form); } collection.each(this.placeholders, function each(root) { root.addEventListener('submit', submission, false); }); // // When the pagelet is removed we want to remove our listeners again. To // prevent memory leaks as well possible duplicate listeners when a pagelet is // loaded in the same placeholder (in case of a full reload). // return this.once('destroy', function destroy() { collection.each(pagelet.placeholders, function each(root) { root.removeEventListener('submit', submission, false); }); }); }; /** * Submit the contents of a <form> to the server. * * @param {FormElement} form Form that needs to be submitted. * @returns {Object} The data that is ported to the server. * @api public */ Pagelet.prototype.submit = function submit(form) { var active = document.activeElement , elements = form.elements , data = {} , element , i; if (active && active.name) { data[active.name] = active.value; } else { active = false; } for (i = 0; i < elements.length; i++) { element = elements[i]; // // Story time children! Once upon a time there was a developer, this // developer created a form with a lot of submit buttons. The developer // knew that when a user clicked on one of those buttons the value="" and // name="" attributes would get send to the server so he could see which // button people had clicked. He implemented this and all was good. Until // someone captured the `submit` event in the browser which didn't have // a reference to the clicked element. This someone found out that the // `document.activeElement` pointed to the last clicked element and used // that to restore the same functionality and the day was saved again. // // There are valuable lessons to be learned here. Submit buttons are the // suck. PERIOD. // if ( element.name && !(element.name in data) && element.disabled === false && /^(?:input|select|textarea|keygen)/i.test(element.nodeName) && !/^(?:submit|button|image|reset|file)$/i.test(element.type) && (element.checked || !/^(?:checkbox|radio)$/i.test(element.type)) ) data[element.name] = val(element); } // // Now that we have a JSON object, we can just send it over our real-time // connection and wait for a page refresh. // this.substream.write({ type: (form.method || 'GET').toLowerCase(), body: data }); return data; }; /** * Get the pagelet contents once again. * * @returns {Pagelet} * @api public */ Pagelet.prototype.get = function get() { this.substream.write({ type: 'get' }); return this; }; /** * Process the incoming messages from our SubStream. * * @param {Object} packet The decoded message. * @returns {Boolean} * @api private */ Pagelet.prototype.processor = function processor(packet) { if ('object' !== typeof packet) return false; switch (packet.type) { case 'rpc': EventEmitter.prototype.emit.apply(this, [ 'rpc:'+ packet.id ].concat(packet.args || [])); break; case 'event': if (packet.args && packet.args.length) { EventEmitter.prototype.emit.apply(this, packet.args); } break; case 'fragment': this.render(packet.frag.view); break; case 'err': var err = new Error(packet.err.message || 'RPC error'); if (packet.err.stack) err.stack = packet.err.stack; this.render(err); break; case 'redirect': window.location.href = packet.url; break; default: return false; } return true; }; /** * The Pagelet's resource has all been loaded. * * @api private */ Pagelet.prototype.initialize = function initialise() { this.broadcast('initialize'); // // Only load the client code in a sandbox when it exists. There no point in // spinning up a sandbox if it does nothing // if (!this.code) return; this.sandbox(this.prepare(this.code)); }; /** * Emit events on the server side Pagelet instance. * * @param {String} event */ Pagelet.prototype.emit = function emit(event) { this.substream.write({ args: Array.prototype.slice.call(arguments, 0), type: 'emit' }); return true; }; /** * Broadcast an event that will be emitted on the pagelet and the page. * * @param {String} event The name of the event we should emit * @returns {Pagelet} * @api public */ Pagelet.prototype.broadcast = function broadcast(event) { EventEmitter.prototype.emit.apply(this, arguments); var name = this.name +':'+ event; if (this.parent) { name = this.parent.name +':'+ name; } this.pipe.emit.apply(this.pipe, [ name, this ].concat(Array.prototype.slice.call(arguments, 1))); return this; }; /** * Find the element based on the attribute and value. * * @param {String} attribute The name of the attribute we're searching. * @param {String} value The value that the attribute should equal to. * @param {Array} root Optional array of root elements. * @returns {Array} A list of HTML elements that match. * @api public */ Pagelet.prototype.$ = function $(attribute, value, roots) { var elements = []; collection.each(roots || [document], function each(root) { if ('querySelectorAll' in root) return Array.prototype.push.apply( elements, root.querySelectorAll('['+ attribute +'="'+ value +'"]') ); // // No querySelectorAll support, so we're going to do a full DOM scan in // order to search for attributes. // for (var all = root.getElementsByTagName('*'), i = 0, l = all.length; i < l; i++) { if (value === all[i].getAttribute(attribute)) { elements.push(all[i]); } } }); return elements; }; /** * Invoke the correct render method for the pagelet. * * @param {String|Object} html The HTML or data that needs to be rendered. * @returns {Boolean} Successfully rendered a pagelet. * @api public */ Pagelet.prototype.render = function render(html) { if (!this.placeholders.length) return false; var mode = this.mode in this ? this[this.mode] : this.html , template = this.template('client'); // // We have been given an object instead of pure HTML so we are going to make // the assumption that this is data for the client side template and render // that our selfs. If no HTML is supplied we're going to use the data that has // been send to the client // if ( 'function' === collection.type(template) && ( 'object' === collection.type(html) || undefined === html && 'object' === collection.type(this.data) || html instanceof Error )) { try { if (html instanceof Error) throw html; // So it's captured an processed as error html = template(collection.copy(html || {}, this.data || {})); } catch (e) { html = this.template('error')(collection.copy(html || {}, this.data || {}, { reason: 'Failed to render: '+ this.name, message: e.message, stack: e.stack, error: e })); } } collection.each(this.placeholders, function each(root) { mode.call(this, root, html); }, this); this.pipe.rendered.push(this.name); this.broadcast('render', html); return true; }; /** * Render the fragment as HTML (default). * * @param {Element} root Container. * @param {String} content Fragment content. * @api public */ Pagelet.prototype.html = function html(root, content) { this.createElements(root, content); }; /** * Render the fragment as SVG. * * @param {Element} root Container. * @param {String} content Fragment content. * @api public */ Pagelet.prototype.svg = function svg(root, content) { this.createElements(root, content); }; /** * Get the element namespaceURI description based on mode. * * @param {String} mode Mode the pagelet will be rendered in. * @return {String} Element namespace. */ Pagelet.prototype.getElementNS = function getElementNS(mode) { mode = mode.toLowerCase(); switch(mode) { case 'svg': return 'http://www.w3.org/2000/svg'; default: return 'http://www.w3.org/1999/xhtml'; } }; /** * Create elements by namespace and via a document fragment. * * @param {Element} root Container. * @param {String} content Fragment content. * @api private */ Pagelet.prototype.createElements = function createElements(root, content) { var fragment = document.createDocumentFragment() , div = document.createElementNS(this.getElementNS(this.mode), 'div') , borked = this.pipe.IEV < 7; // // Clean out old HTML before we append our new HTML or we will get duplicate // DOM. Or there might have been a loading placeholder in place that needs // to be removed. // while (root.firstChild) { root.removeChild(root.firstChild); } if (borked) root.appendChild(div); div.innerHTML = content; while (div.firstChild) { fragment.appendChild(div.firstChild); } root.appendChild(fragment); if (borked) root.removeChild(div); }; /** * Parse the included template from the comment node so it can be injected in to * the page as initial rendered view. * * @returns {String} View. * @api private */ Pagelet.prototype.parse = function parse() { var node = this.$('data-pagelet-fragment', this.name)[0] , comment; // // The firstChild of the fragment should have been a HTML comment, this is to // prevent the browser from rendering and parsing the template. // if (!node.firstChild || node.firstChild.nodeType !== 8) return; comment = node.firstChild.nodeValue; return comment .substring(1, comment.length -1) .replace(/\\([\s\S]|$)/g, '$1'); }; /** * Destroy the pagelet and clean up all references so it can be re-used again in * the future. * * @TODO unload CSS. * @TODO unload JavaScript. * * @param {Boolean} remove Remove the placeholder as well. * @api public */ Pagelet.prototype.destroy = function destroy(remove) { var pagelet = this; // // Execute any extra destroy hooks. This needs to be done before we remove any // elements or destroy anything as there might people subscribed to these // events. // this.broadcast('destroy'); // // Remove all the HTML from the placeholders. // if (this.placeholders) collection.each(this.placeholders, function remove(root) { if (remove && root.parentNode) root.parentNode.removeChild(root); else while (root.firstChild) root.removeChild(root.firstChild); }); // // Remove the added RPC handlers, make sure we don't delete prototypes. // if (this.rpc && this.rpc.length) collection.each(this.rpc, function nuke(method) { if (method in Pagelet.prototype) return; delete pagelet[method]; }); // // Remove the sandboxing. // if (this.container) sandbox.kill(this.container.id); this.placeholders = this.container = null; // // Announce the destruction and remove it. // if (this.substream) this.substream.end(); // // Everything has been cleaned up, release it to our Freelist Pagelet pool. // this.pipe.free(this); return this; }; // // Expose the module. // module.exports = Pagelet;