├── .gitignore ├── LICENSE ├── README.md ├── dist ├── extern.dev.js └── extern.min.js ├── extern.js ├── index.js ├── instructions └── fragment.json ├── package.json ├── react ├── error.js └── loading.js └── test ├── extern.browser.js ├── extern.test.js ├── fixtures ├── client.js ├── empty.js ├── format.json ├── missing.json └── what.css ├── index.js ├── static.js └── ui.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Go Daddy Operating Company, LLC 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a 4 | copy of this software and associated documentation files (the "Software"), 5 | to deal in the Software without restriction, including without limitation 6 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | and/or sell copies of the Software, and to permit persons to whom the 8 | Software is furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | DEALINGS IN THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > [!WARNING] 2 | > **This library is deprecated, retired and no longer undevelopment.** 3 | 4 | # external 5 | 6 | External is a dual purpose library. It ships with a client-side framework renders 7 | third party or external pages in the most optimal way as possible. This is done 8 | using various of techniques: 9 | 10 | - The payload is downloaded using a fully async streaming XHR request. This way 11 | we can continuously update and render our placeholder while data flows and 12 | therefor reducing the time to render. 13 | - All assets of the page are loaded async, this includes the CSS. 14 | - The received client code is wrapped before execution so client code can re-use 15 | our dependencies while keeping a sandboxed approach. 16 | - While the client was specifically written for the [BigPipe] framework it 17 | should work against any back-end as long as it returns the same data 18 | structure. 19 | - Templates are rendered using React so it's easy to compose and update. 20 | 21 | But we also ship with a server-side framework implementation for [BigPipe] which 22 | makes it possible to serve the client and automatically format all the output in 23 | the expected HTML structure. 24 | 25 | ## Table of Contents 26 | 27 | - [Installation](#installation) 28 | - [Building](#building) 29 | - [Serving](#serving) 30 | - [Listening](#listening) 31 | - [Extern](#extern) 32 | - [BigPipe](#bigpipe) 33 | - [Wire Format](#wire-format) 34 | - [License](#license) 35 | 36 | ## Installation 37 | 38 | The client-side component is composed from various of tiny modules and can be 39 | build using [Browserify]. It can be build-in to other browserify components by 40 | simply requiring the `external` module in your client-code. 41 | 42 | The server side part of this framework can be installed through npm: 43 | 44 | ``` 45 | npm install external 46 | ``` 47 | 48 | In addition to providing a browserify-able client-side script there is also a 49 | compiled version of this code which lives in the `dist` folder called 50 | `extern.js`. This pre-compiled library exposes it self using the `Extern` global 51 | and therefor does not introduced `require` statement as globals. In all the code 52 | examples in documentation we assume that you have an `Extern` global. If you use 53 | the `dist` build you can skip the following example: 54 | 55 | ```js 56 | var Extern = require('external'); 57 | ``` 58 | 59 | ### Building 60 | 61 | If you want to generate new stand alone bundles of the Extern library you can 62 | run our `prepublish` and `dev` scripts using the `npm run` command. These 63 | commands do assume that you've installed the `devDependencies` of this project. 64 | To generate a new production build, `dist/extern.min.js` run: 65 | 66 | ``` 67 | npm run prepublish 68 | ``` 69 | 70 | As this is a `prepublish` script, it means that every release to npm will have 71 | the `dist/extern.js` included. So if browserify isn't your think, you can just 72 | include the `extern/dist/extern.min.js` instead. 73 | 74 | To generate an un-minified build for development purposes you can run: 75 | 76 | ``` 77 | npm run dev 78 | ``` 79 | 80 | This will generate a new `dist/extern.dev.js` file. 81 | 82 | ### Serving 83 | 84 | Now that you know how to install it and what type of bundles there are you can 85 | decide how to serve the library. When this module is used as plugin in [BigPipe] 86 | it will automatically serve the browserify and plugin combined bundle from: 87 | 88 | ``` 89 | http(s)://domain.com/extern.js 90 | ``` 91 | 92 | We also mount our `dist` folder on the server so the static assets in this 93 | folder can also be served: 94 | 95 | ``` 96 | http(s)://domain.com/extern.min.js 97 | ``` 98 | 99 | Now that you've picked your build, and know how the files are served you can 100 | simply put the script tag in your page and your ready to display external 101 | pages/apps. 102 | 103 | ```html 104 | 105 | 108 | ``` 109 | 110 | ### Listening 111 | 112 | The easiest way to have `Extern` load your remote pages is by using the 113 | `Extern.listen` method in combination with the `rel="extern"` attributes on 114 | `` elements: 115 | 116 | ```html 117 | Remote 118 | ``` 119 | 120 | The `Extern.listen` method will gather all `` elements and search for a `rel` 121 | that is set to `extern` and uses the set `href` of the element as URL that needs 122 | to be remotely loaded. 123 | 124 | ```js 125 | Extern.listen(document.body, {}); 126 | ``` 127 | 128 | ## Extern 129 | 130 | The following options are supported: 131 | 132 | - **`timeout`** Timeout for dependency loading. If assets take longer we should 133 | render and error template instead. The timeout is in milliseconds. 134 | - **`document`** Reference to the `document` global can be useful if assets need 135 | to be loaded in iframes instead of the global document. 136 | - **`className`** If a link has this className we will automatically load it in 137 | the placeholder. This className will also automatically be add and removed 138 | once the link is clicked. Defaults to `extern-loads`. 139 | 140 | ```js 141 | var extern = new Extern('http://my.example.com/page', document.body, { 142 | timeout: 10000 143 | }); 144 | ``` 145 | 146 | ### Events 147 | 148 | The returned `extern` instance is actually an `EventEmitter3` instance so you 149 | can listen to the various of events that we're emitting: 150 | 151 | - **error** Emitted when something went so horribly wrong that we decided to 152 | show the error template. This event receives the actual `error` as argument. 153 | - **done** The streaming XHR is finished with loading. 154 | - **name:render** Called when a fragment is about to render in to the 155 | placeholder. The `name` part in the event should be name of the fragment you 156 | want to listen for. 157 | - **name:loaded** All the assets are loaded for the given placeholder name. 158 | 159 | ## Wrapping 160 | 161 | The client code for each fragments are loaded through an XHR connection. This 162 | way we can safely executed third party code by wrapping the execution in a 163 | `try/catch` statement. But not only does this allow us to wrap code, it also 164 | allows us to introduce variables in the function. The following variables are 165 | introduced as "globals": 166 | 167 | - `React`, This is the `react/addons` reference. 168 | - `require`, Reference to our `require` statement so you can re-use all the 169 | bundled things. 170 | 171 | ## API 172 | 173 | The following properties and methods are exposed on the Extern instance. 174 | 175 | #### Extern.listen 176 | 177 | **Exposed on the constructor** 178 | 179 | Scan the current document for all `` elements and attach click 180 | listeners to it so we can automatically update the supplied placeholder with the 181 | contents of the set URL. This method accepts one argument and that is the 182 | `placeholder` DOM element where all pages should loaded in 183 | 184 | ```js 185 | Extern.listen(document.body); 186 | ``` 187 | 188 | #### Extern.merge 189 | 190 | **Exposed on the constructor** 191 | 192 | Merge the object of the second argument in to the first argument. It returns the 193 | fully merged first argument. 194 | 195 | ```js 196 | var x = Extern.merge({ foo: 'foo' }, { bar: 'bar' }); 197 | ``` 198 | 199 | #### Extern.requests 200 | 201 | **Exposed on the constructor** 202 | 203 | A reference to the `requests` module that we're using for our XHR requests. 204 | 205 | ```js 206 | var requests = Extern.requests. 207 | ``` 208 | 209 | See [unshiftio/requests](https://github.com/unshiftio/requests) for more 210 | information. 211 | 212 | ## BigPipe 213 | 214 | This library ships with a custom [Fittings] framework implementation for 215 | [BigPipe] which allows us to control how everything is processed inside of 216 | [BigPipe]. Adding it to your BigPipe instance is just as simple as passing a 217 | custom `framework` option while creating a new instance: 218 | 219 | ```js 220 | 'use strict'; 221 | 222 | var BigPipe = require('bigpipe') 223 | , Extern = require('external'); 224 | 225 | var app = BigPipe.createServer({ 226 | framework: Extern, 227 | port: 8080 228 | }); 229 | ``` 230 | 231 | But the framework can also be set _after_ the construction using the `framework` 232 | method: 233 | 234 | ```js 235 | app.framework(Extern); 236 | ``` 237 | 238 | **Please do note that the current Fittings implentation is in the BigPipe master 239 | branch but will out in the release that follows 0.9** 240 | 241 | Once the fittings are installed on the application, it will start spitting out 242 | responses based on the specified [Wire Format](#wire-format) below. The 243 | processing instructions can be found in the [instructions](/instructions) folder 244 | in the root of this repository. But before fiddling with these files I would 245 | suggest giving the [README.md][Fittings] of Fittings a read so you know how the 246 | data formatting works. 247 | 248 | ## Wire Format 249 | 250 | In order to have the broadest support within this framework we came up with a 251 | dedicated wire-format in order to have the server-side and client-side 252 | components interact with each other. While this wire-format is mostly catered to 253 | the needs of an application that is build using the [BigPipe] framework it 254 | should be relatively easy to produce exactly the same output in different 255 | frameworks and programming languages. This wire format is also required in order 256 | to make streaming data as simple as possible as we can trigger buffer flushes 257 | based on this. 258 | 259 | The format that we're using is `\u1337` separated `JSON`. Every time we 260 | encounter the `\u1337` character on the client-side we assume it's the end of 261 | chunk that requires processing. The JSON payload that is send should contain the 262 | following properties: 263 | 264 | - **`_id`** A unique id for the payload that is flushed. 265 | - **`name`** Name of the payload that is flushed. This is used to track 266 | potential child->parent references throughout the flushed payload. 267 | - **`details`** An object that contains: 268 | - **`js`** Array with path names for the JavaScript files that need to be 269 | loaded on the page. We will automatically prepend the server address to 270 | these assets. 271 | - **`css`** Array with path names for the CSS files that need to be 272 | loaded on the page. We will automatically prepend the server address to 273 | these assets. 274 | - **`state`** Additional state that will be spread on the component when we 275 | render it. 276 | - **`template`** An initial HTML template that should be rendered in the given 277 | placeholder. 278 | 279 | ### CSS Assets 280 | 281 | In order to be able to load CSS assets fully async in every browser we need to 282 | know when the styles are applied. This is done by Extern client by adding a DOM 283 | element to the page that has an `id` attribute which contains `_` and the filename of 284 | the asset that is being downloaded (`#_yourfilename`). We therefor **require** 285 | that the CSS file contains CSS selector and sets the `height` property to 286 | `42px`. This allows us to poll the element for height changes to know when the 287 | CSS is fully loaded. So if we have a file called `1aFafa801jz09.css` it should 288 | have the following selector in the source: 289 | 290 | ```css 291 | #_1aFafa801jz09 { height: 42px } 292 | ``` 293 | 294 | ## License 295 | 296 | This project has been released under the MIT license, see [LICENSE]. 297 | 298 | [Fittings]: https://github.com/bigpipe/fittings 299 | [Browserify]: http://github.com/substack/node-browserify 300 | [BigPipe]: https://github.com/bigpipe/bigpipe 301 | -------------------------------------------------------------------------------- /extern.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var debug = require('diagnostics')('extern') 4 | , ReactIntl = require('react-intl') 5 | , Assets = require('async-asset') 6 | , React = require('react/addons') 7 | , Requests = require('requests') 8 | , Recovery = require('recovery') 9 | , destroy = require('demolish') 10 | , each = require('async-each') 11 | , URL = require('url-parse'); 12 | 13 | /** 14 | * Extern. 15 | * 16 | * Options: 17 | * 18 | * - `cdn` Base URL for the CDN, if non is provide it will use the URL requested 19 | * as cdn URL. 20 | * - `timeout` Maximum time that we're allowed to load a single asset. 21 | * - `manual` Don't `open` by default but do it manual. 22 | * - `document` Optional reference to the `document` global it should use. 23 | * 24 | * @param {String} url Address of the server we're connecting against. 25 | * @param {Element} container Container in which the React should be loaded. 26 | * @param {Object} options Optional configuration. 27 | * @api public 28 | */ 29 | function Extern(url, container, options) { 30 | if (!this) return new Extern(url, container, options); 31 | 32 | options = this.options = Extern.merge( 33 | Extern.merge({}, Extern.defaults), 34 | options || {} 35 | ); 36 | 37 | // 38 | // Setup our Exponential back off things. 39 | // 40 | Recovery.call(this, options.backoff || {}); 41 | 42 | this.buffer = ''; 43 | this.components = {}; 44 | this.url = new URL(url); 45 | this.container = container; 46 | this.cdn = new URL(options.cdn || url); 47 | this.assets = new Assets(container.parentNode, { 48 | document: options.document || global.document || {}, 49 | timeout: options.timeout, 50 | prefix: '_' 51 | }); 52 | 53 | // 54 | // Remove parts of the URL that should not be send to CDN's 55 | // 56 | this.cdn.set('query', ''); 57 | this.cdn.set('hash', ''); 58 | 59 | this.render(this.react.loading, { message: options.loading }); 60 | this.on('reconnect', this.open, this); 61 | 62 | if (!options.manual) this.reconnect(); 63 | } 64 | 65 | // 66 | // Extern is an EventEmitter so we can listen upon all the things. 67 | // 68 | Extern.prototype = new Recovery(); 69 | Extern.prototype.constructor = Extern; 70 | Extern.prototype.emits = require('emits'); 71 | 72 | /** 73 | * The default options. 74 | * 75 | * @type {Object} 76 | * @api public 77 | */ 78 | Extern.defaults = { 79 | timeout: 30000, // Timeout for assets downloading. 80 | manual: false // Manually start the request. 81 | }; 82 | 83 | /** 84 | * Open the streaming connection and download all the data's. 85 | * 86 | * @api public 87 | */ 88 | Extern.prototype.open = function open() { 89 | var extern = this; 90 | 91 | extern.stream = new Requests(extern.url.href, { 92 | streaming: true, 93 | method: 'GET', 94 | mode: 'cors' 95 | }); 96 | 97 | extern.stream 98 | .on('data', extern.parse.bind(extern)) 99 | .on('error', extern.emits('error')) 100 | .on('end', function done(err) { 101 | if (err) extern.render(extern.react.error); 102 | 103 | extern.reconnected(err); 104 | extern.emit('done'); 105 | }); 106 | 107 | debug('opening connection to %s', extern.url.href); 108 | return extern; 109 | }; 110 | 111 | /** 112 | * Render a given React component in our supplied container. 113 | * 114 | * @param {React} component The Component that needs to be rendered. 115 | * @param {Object} spread What ever needs to be spread upon the component. 116 | * @api public 117 | */ 118 | Extern.prototype.render = function render(component, spread) { 119 | try { 120 | return React.render( 121 | React.createElement(component, React.__spread(this.options.props || {}, spread || {})), 122 | this.container 123 | ); 124 | } catch (e) { 125 | this.emit('error', e); 126 | debug('failed to render React component in the container due to', e); 127 | return this.render(this.react.error); 128 | } 129 | }; 130 | 131 | /** 132 | * Parse incoming data. 133 | * 134 | * @param {String} data Received data stream from the XHR request. 135 | * @returns {Boolean} Extracted a fragment from the buffer. 136 | * @api private 137 | */ 138 | Extern.prototype.boundary = '\\u1337'; 139 | Extern.prototype.parse = function parse(data) { 140 | if (data) this.buffer += data; 141 | 142 | var i; 143 | 144 | if (!~(i = this.buffer.indexOf(this.boundary))) { 145 | debug('received %d of data, but did not contain our boundary yet.', data.length); 146 | return false; 147 | } 148 | 149 | // 150 | // Poor man's parser implementation. It's highly unlikely that we're receiving 151 | // multiple blobs of data in one go. 152 | // 153 | this.read(this.buffer.substr(0, i)); 154 | 155 | this.buffer = this.buffer.substr(i + this.boundary.length).trim(); 156 | this.parse(''); // Another parse call to see if we received multiple chunks. 157 | 158 | return true; 159 | }; 160 | 161 | /** 162 | * Transform the parsed data in to an actual template. 163 | * 164 | * @param {String} fragment The received fragment from the server. 165 | * @api public 166 | */ 167 | Extern.prototype.read = function read(fragment) { 168 | try { fragment = JSON.parse(fragment); } 169 | catch (e) { 170 | debug('failed to parse buffer fragment to a valid JSON structure', e); 171 | return this.emit('error', new Error('Failed to parse received JSON')); 172 | } 173 | 174 | var assets = [] 175 | , extern = this 176 | , name = fragment.name 177 | , cdn = new URL(this.cdn.toString()); 178 | 179 | // 180 | // Make sure that we've received our basic structure. 181 | // 182 | fragment.details = fragment.details || {}; 183 | 184 | extern 185 | .once(fragment.name +':loaded', function loaded(err) { 186 | // @TODO handle error 187 | if (err) debug('failed to load %s due to', fragment.name, err); 188 | 189 | if (extern.listeners(fragment.details.parent +':render').length) { 190 | debug('rendering %s as all is loaded', fragment.name); 191 | extern.emit(fragment.name +':render'); 192 | } 193 | }) 194 | .on(fragment.details.parent +':render', function render() { 195 | debug('parent %s has rendered so re-rendering child %s', fragment.details.parent, fragment.name); 196 | extern.emit(name +':render'); 197 | }) 198 | .on(fragment.name +':render', function render() { 199 | var component = (fragment.details.js || []).filter(function find(id) { 200 | return id in extern.components; 201 | }); 202 | 203 | // 204 | // No JavaScript file was found, so we cannot render the view as we require 205 | // a React component for this. 206 | // 207 | if (!component.length) { 208 | return debug('no React component to render for %s', fragment.name); 209 | } 210 | 211 | extern.render(extern.components[component[0]](), fragment.state); 212 | extern.emit(fragment.name +':rendered', fragment.state); 213 | }); 214 | 215 | if (fragment.details.css) Array.prototype.push.apply(assets, fragment.details.css); 216 | if (fragment.details.js) Array.prototype.push.apply(assets, fragment.details.js); 217 | 218 | /** 219 | * Create object that has CDN information. 220 | * 221 | * @param {String} pathname Asset file path. 222 | * @return {Object} details 223 | * @api private 224 | */ 225 | function map(pathname) { 226 | cdn.set('pathname', pathname); 227 | 228 | return { 229 | pathname: pathname, 230 | href: cdn.href 231 | }; 232 | } 233 | 234 | /** 235 | * Download the asset from the server. 236 | * 237 | * @param {Object} url Formatted URL. 238 | * @param {Function} fn Completion callback. 239 | * @api private 240 | */ 241 | function download(url, fn) { 242 | debug('downloading asset %s for %s', url.href, fragment.name); 243 | if (/\.js$/.test(url.pathname)) return extern.download(url, fn); 244 | 245 | extern.assets.add(url.href, fn); 246 | } 247 | 248 | // 249 | // Download any global dependencies before local assets. 250 | // 251 | return each((fragment.details.dependencies || []).map(map), download, function prepared(err) { 252 | if (err) return extern.emit('error', err); 253 | 254 | each(assets.map(map), download, extern.emits(fragment.name +':loaded')); 255 | }); 256 | }; 257 | 258 | /** 259 | * Download files from the specified remote server so they can be sandboxed 260 | * before evaluation. 261 | * 262 | * @param {Array} urls A list of URL's that should be downloaded from the server. 263 | * @param {Function} fn Completion callback that follows error first callback pattern. 264 | * @returns {Extern} 265 | * @api public 266 | */ 267 | Extern.prototype.download = function download(urls, fn) { 268 | var extern = this; 269 | 270 | urls = Array.isArray(urls) ? urls : [urls]; 271 | each(urls, function iteration(url, next) { 272 | var buffer = []; 273 | 274 | (new Requests(url.href, { 275 | timeout: extern.options.timeout, 276 | streaming: false, 277 | method: 'GET' 278 | })) 279 | .on('data', function concat(data) { 280 | buffer.push(data); 281 | }) 282 | .on('end', function end(err) { 283 | if (err) return next(err); 284 | 285 | extern.sandbox(url.pathname, buffer.join('')); 286 | 287 | // 288 | // Clean-up all references and data that was gathered. 289 | // 290 | this.removeAllListeners(); 291 | buffer.length = 0; 292 | 293 | next(); 294 | }); 295 | }, fn); 296 | 297 | return this; 298 | }; 299 | 300 | /** 301 | * Generate a sandboxed environment. 302 | * 303 | * @param {String} pathaname The path to the source file on the server. 304 | * @param {String} buffer The received source from the server. 305 | * @returns {Extern} 306 | * @api public 307 | */ 308 | Extern.prototype.sandbox = function sandbox(pathname, buffer) { 309 | if (pathname in this.components) return this; 310 | 311 | var extern = this 312 | , value 313 | , fn; 314 | 315 | // 316 | // We don't really need to do anything in the `catch` statement as the actual 317 | // error will be captured in the stored `conditional` function as it would 318 | // throw as the `fn` is not a function causing the error template to be used. 319 | // 320 | try { fn = new Function('React', 'require', buffer); } 321 | catch (e) { 322 | debug('failed to compile sandbox and create %s due to ', pathname, e); 323 | extern.emit('error', e); 324 | } 325 | 326 | /** 327 | * The component that needs to be rendered by the server. 328 | * 329 | * @returns {React.createClass} 330 | * @api private 331 | */ 332 | this.components[pathname] = function conditional() { 333 | if (value) return value; 334 | 335 | // 336 | // Capture the execution of the component. We assume that the given JS 337 | // client code returns the React component that eventually needs to be 338 | // rendered. 339 | // 340 | // If the execution failed, we will show our build-in error component 341 | // instead so something visual is still rendered. 342 | // 343 | try { value = fn(React, require) || extern.react.error; } 344 | catch (e) { 345 | debug('failed to execute component sandbox for %s due to ', pathname, e); 346 | extern.emit('error', e); 347 | value = extern.react.error; 348 | } 349 | 350 | return value; 351 | }; 352 | 353 | return this; 354 | }; 355 | 356 | /** 357 | * Custom React components which are rendered while we're loading assets. 358 | * 359 | * @type {Object} 360 | * @private 361 | */ 362 | Extern.prototype.react = { 363 | loading: require('./react/loading'), 364 | error: require('./react/error') 365 | }; 366 | 367 | /** 368 | * Completely destroy and null the said object. 369 | * 370 | * @returns {Boolean} First destruction 371 | * @api public 372 | */ 373 | Extern.prototype.destroy = destroy('buffer, components, url, container, assets, stream, options', { 374 | before: function () { 375 | debug('destroying extern instance %s', this.url.href); 376 | 377 | if (this.stream) { 378 | this.stream.destroy(); 379 | } 380 | } 381 | }); 382 | 383 | /** 384 | * Merge b with object a. 385 | * 386 | * @param {Object} a Target object that should receive props from b. 387 | * @param {Object} b Object that needs to be merged in to a. 388 | * @api public 389 | */ 390 | Extern.merge = function merge(a, b) { 391 | return Object.keys(b).reduce(function reduce(state, key) { 392 | state[key] = b[key]; 393 | return state; 394 | }, a); 395 | }; 396 | 397 | /** 398 | * A handy listener for automatically attaching to various of elements that 399 | * will render the said URL's in the given placeholder. 400 | * 401 | * @param {Element} placeholder The placeholder for the remote pages. 402 | * @param {Object} configuration Configuration of the Extern server. 403 | * @param {Object} events Additional event listeners. 404 | * @returns {Extern} 405 | * @api public 406 | */ 407 | Extern.listen = function listen(placeholder, configuration, events) { 408 | events = events || {}; 409 | configuration = configuration || {}; 410 | configuration.className = configuration.className || 'extern-loads'; 411 | 412 | var Instance = this 413 | , links = [] 414 | , extern; 415 | 416 | Array.prototype.slice.call( 417 | document.getElementsByTagName('a') 418 | ).forEach(function each(a) { 419 | if (a.rel !== 'extern' || !a.href) return; 420 | 421 | /** 422 | * Render the given URL in the placeholder. 423 | * 424 | * @param {Event} e DOM Event from when we clicked on things. 425 | * @api private 426 | */ 427 | function render(e) { 428 | if (e) e.preventDefault(); 429 | 430 | // 431 | // Destroy the previous instance so we can clean up memory. 432 | // 433 | if (extern) extern.destroy(); 434 | extern = new Instance(a.href, placeholder, configuration); 435 | 436 | for (var name in events) { 437 | extern.on(name, events[name]); 438 | } 439 | 440 | // 441 | // Add/Remove the custom className. 442 | // 443 | links.forEach(function clear(link) { 444 | if (!~link.className.indexOf(configuration.className)) return; 445 | link.className = link.className.replace(new RegExp('(?:^|\\s)'+ configuration.className +'(?!\\S)'), ''); 446 | }); 447 | 448 | a.className += ' '+ configuration.className; 449 | } 450 | 451 | a.addEventListener('click', render, false); 452 | links.push(a); 453 | 454 | // 455 | // If the given link has our special `active` className we should activate 456 | // the `extern` instance directly so it loads without having to click on the 457 | // URL. 458 | // 459 | if (~a.className.indexOf(configuration.className)) render(); 460 | }); 461 | 462 | return this; 463 | }; 464 | 465 | /** 466 | * Expose the Requests instance so outsiders can also use it. 467 | * 468 | * @type {Function} 469 | * @api public 470 | */ 471 | Extern.requests = Requests; 472 | 473 | // 474 | // Expose the Framework 475 | // 476 | module.exports = Extern; 477 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Fittings = require('fittings') 4 | , join = require('path').join 5 | , fs = require('fs'); 6 | 7 | /** 8 | * Read files out of our instructions directory. 9 | * 10 | * @param {String} file Filename that we should read. 11 | * @returns {String} 12 | * @api private 13 | */ 14 | function read(file) { 15 | return fs.readFileSync(join(__dirname, 'instructions', file), 'utf-8'); 16 | } 17 | 18 | /** 19 | * Create a new custom fittings instance so we can fully customize how 20 | * everything should be loaded for external files. 21 | * 22 | * @constructor 23 | * @api public 24 | */ 25 | Fittings.extend({ 26 | name: 'extern', 27 | fragment: read('fragment.json'), 28 | bootstrap: read('fragment.json'), 29 | library: require.resolve('./extern.js'), 30 | middleware: { 31 | standalone: require('serve-static')(join(__dirname, 'dist')) 32 | } 33 | }).on(module); 34 | -------------------------------------------------------------------------------- /instructions/fragment.json: -------------------------------------------------------------------------------- 1 | { 2 | "_id": "{fittings:id}", 3 | "name": "{fittings:name}", 4 | "details": {fittings~data}, 5 | "state": {fittings@state}, 6 | "template": {fittings~template} 7 | } 8 | \u1337 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "external", 3 | "version": "1.0.0", 4 | "description": "Fittings for BigPipe which allows you to use the BigPipe as a third party/external content provider.", 5 | "main": "index.js", 6 | "browser": "extern.js", 7 | "scripts": { 8 | "100%": "istanbul check-coverage --statements 100 --functions 100 --lines 100 --branches 100", 9 | "test": "node test/index.js", 10 | "node": "mocha test/extern.test.js", 11 | "watch": "mocha --watch test/extern.test.js", 12 | "coverage": "istanbul cover ./node_modules/.bin/_mocha -- test/extern.test.js", 13 | "compile": "mkdir -p dist && NODE_ENV=production browserify -s Extern -g envify -g uglifyify index.js -o dist/extern.min.js", 14 | "prepublish": "npm run compile", 15 | "dev": "mkdir -p dist && NODE_ENV=development browserify -s Extern -g envify index.js -o dist/extern.dev.js", 16 | "static": "node test/static.js" 17 | }, 18 | "keywords": [ 19 | "external", 20 | "fittings", 21 | "remote", 22 | "loading", 23 | "third", 24 | "party", 25 | "third-party", 26 | "bigpipe" 27 | ], 28 | "author": "Arnout Kazemier", 29 | "license": "MIT", 30 | "dependencies": { 31 | "async-asset": "0.0.x", 32 | "async-each": "0.1.x", 33 | "demolish": "1.0.x", 34 | "diagnostics": "1.0.x", 35 | "emits": "3.0.x", 36 | "fittings": "1.2.x", 37 | "intl": "1.0.x", 38 | "react": "0.13.x", 39 | "react-intl": "1.2.x", 40 | "recovery": "0.2.x", 41 | "requests": "0.1.x", 42 | "serve-static": "1.10.x", 43 | "url-parse": "1.4.x" 44 | }, 45 | "devDependencies": { 46 | "access-control": "0.0.x", 47 | "argh": "0.1.x", 48 | "assume": "1.2.x", 49 | "browserify": "10.2.x", 50 | "envify": "3.4.x", 51 | "istanbul": "0.3.x", 52 | "mocha": "2.2.x", 53 | "mochify": "2.10.x", 54 | "mochify-istanbul": "2.3.x", 55 | "node-jsx": "0.13.x", 56 | "pre-commit": "1.0.x", 57 | "uglifyify": "3.0.x" 58 | }, 59 | "testling": { 60 | "files": "test/*.browser.js", 61 | "harness": "mocha-bdd", 62 | "browsers": [ 63 | "ie/6..latest", 64 | "chrome/22..latest", 65 | "firefox/16..latest", 66 | "safari/latest", 67 | "opera/11.0..latest", 68 | "iphone/latest", 69 | "ipad/latest", 70 | "android-browser/latest" 71 | ] 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /react/error.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var React = require('react/addons') 4 | , ReactIntl = require('react-intl'); 5 | 6 | /** 7 | * A simple back-up view for when loading or rendering an actual component 8 | * failed. This way we give users some addition instructions instead of leaving 9 | * them to suffer with a blank page of death. 10 | * 11 | * @returns {React} 12 | * @api public 13 | */ 14 | module.exports = React.createClass({ 15 | mixins: [ReactIntl.IntlMixin], 16 | render: function render() { 17 | return React.createElement('div', { className: 'error' }, 18 | React.createElement('h2', null, 'Yikes!'), 19 | React.createElement('p', null, [ 20 | 'Something\'s gone wrong, and we\'re working feverishly to fix the issue.', 21 | 'Please wait a bit and try again.' 22 | ].join(' ')) 23 | ); 24 | } 25 | }); 26 | -------------------------------------------------------------------------------- /react/loading.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var React = require('react/addons') 4 | , ReactIntl = require('react-intl'); 5 | 6 | /** 7 | * A simple back-up view for when loading or rendering an actual component 8 | * failed. This way we give users some addition instructions instead of leaving 9 | * them to suffer with a blank page of death. 10 | * 11 | * @constructor 12 | * @type {React.Component} 13 | * @api public 14 | */ 15 | module.exports = React.createClass({ 16 | mixins: [ReactIntl.IntlMixin], 17 | getDefaultProps: function getDefaultProps() { 18 | return { 19 | message: 'Loading, please wait.', 20 | container: { 21 | backgroundColor: '#FFF', 22 | marginBottom: '12px', 23 | padding: '25px 5%', 24 | textAlign: 'center' 25 | }, 26 | spinner: { 27 | display: 'inline-block', 28 | verticalAlign: 'middle', 29 | marginRight: '5px', 30 | width: '16px', 31 | height: '16px' 32 | } 33 | }; 34 | }, 35 | render: function render() { 36 | return React.createElement('div', { style: this.props.container }, 37 | React.createElement('i', { style: this.props.spinner }), 38 | this.props.message 39 | ); 40 | } 41 | }); 42 | -------------------------------------------------------------------------------- /test/extern.browser.js: -------------------------------------------------------------------------------- 1 | describe('extern', function () { 2 | 'use strict'; 3 | 4 | if (!global.Intl) global.Intl = require('intl'); 5 | 6 | var Requests = require('requests') 7 | , Extern = require('../extern') 8 | , assume = require('assume') 9 | , React = require('react') 10 | , ui = require('./ui') 11 | , placeholder 12 | , extern; 13 | 14 | // 15 | // Add all the things to a custom DOM element so we do not accidentally override 16 | // assets or other things that are loaded in the DOM. 17 | // 18 | placeholder = document.createElement('div'); 19 | placeholder.id = 'placeholder'; 20 | document.body.appendChild(placeholder); 21 | 22 | beforeEach(function () { 23 | placeholder.innerHTML = ''; 24 | 25 | extern = new Extern('http://localhost/wut', placeholder, { 26 | props: { custom: 'props', are: 'available' }, 27 | timeout: 2000, 28 | manual: true 29 | }); 30 | }); 31 | 32 | afterEach(function () { 33 | extern.destroy(); 34 | }); 35 | 36 | it('is exported as a function', function () { 37 | assume(Extern).is.a('function'); 38 | }); 39 | 40 | it('renders an initial loading screen', function () { 41 | assume(placeholder.innerHTML).includes('Loading'); 42 | 43 | extern = new Extern('http://localhost/wut', placeholder, { 44 | loading: 'Custom totally unrelated message', 45 | manual: true 46 | }); 47 | 48 | assume(placeholder.innerHTML).includes('totally unrelated'); 49 | }); 50 | 51 | it('exposes the `.requests` library', function () { 52 | assume(Extern.requests).equals(Requests); 53 | }); 54 | 55 | it('removes querystring/hash for CDN urls', function () { 56 | extern.destroy(); 57 | extern = new Extern('http://localhost/wut', placeholder, { 58 | cdn: 'https://thisisadifferenturl.com/?querystring=removed#hashtagyolo', 59 | timeout: 2000, 60 | manual: true 61 | }); 62 | 63 | assume(extern.cdn.href).equals('https://thisisadifferenturl.com/'); 64 | 65 | extern.destroy(); 66 | extern = new Extern('http://localhost/wut?q=s#swag', placeholder, { 67 | timeout: 2000, 68 | manual: true 69 | }); 70 | 71 | assume(extern.cdn.href).equals('http://localhost/wut'); 72 | }); 73 | 74 | describe('#open', function () { 75 | it('renders an error template when things fail', function (next) { 76 | extern.open(); 77 | 78 | extern.once('done', function () { 79 | assume(placeholder.innerHTML).includes('error'); 80 | next(); 81 | }); 82 | }); 83 | 84 | it('requests the given source', function (next) { 85 | extern.destroy(); 86 | 87 | extern = new Extern('http://localhost:8080/fixtures/format.json', placeholder, { 88 | timeout: 1000 89 | }); 90 | 91 | extern.once('error', next); 92 | extern.once('fixture:rendered', function () { 93 | assume(placeholder.innerHTML).includes('client.js'); 94 | 95 | next(); 96 | }); 97 | }); 98 | }); 99 | 100 | describe('#render', function () { 101 | it('emits `error` when it fails to render the component', function (next) { 102 | extern.once('error', function (err) { 103 | assume(err).is.instanceOf(Error); 104 | next(); 105 | }); 106 | 107 | extern.render({ not: 'a real component' }); 108 | }); 109 | 110 | it('renders the error template', function () { 111 | placeholder.innerHTML = ''; 112 | extern.render({ non: 'extistent' }); 113 | 114 | assume(placeholder.innerHTML).matches(/error/i); 115 | }); 116 | 117 | it('applies the given object as spread data', function () { 118 | var Fixture = React.createClass({ 119 | render: function () { 120 | return React.createElement('div', null, this.props.foo); 121 | } 122 | }); 123 | 124 | extern.render(Fixture, { foo: 'bar-lal' }); 125 | assume(placeholder.innerHTML).matches(/bar-lal/i); 126 | }); 127 | 128 | it('merges the supplied props options with the supplied spread', function () { 129 | var Fixture = React.createClass({ 130 | render: function () { 131 | assume(this.props.are).equals('merged'); 132 | assume(this.props.foo).equals('bar-lal'); 133 | assume(this.props.custom).equals('props'); 134 | 135 | return React.createElement('div', null, this.props.foo); 136 | } 137 | }); 138 | 139 | extern.render(Fixture, { foo: 'bar-lal', are: 'merged' }); 140 | assume(placeholder.innerHTML).matches(/bar-lal/i); 141 | }); 142 | }); 143 | 144 | describe('#parse', function () { 145 | it('adds the supplied data to the buffer if no boundary is found', function () { 146 | assume(extern.buffer).equals(''); 147 | 148 | assume(extern.parse('foo')).is.false(); 149 | assume(extern.buffer).equals('foo'); 150 | }); 151 | 152 | it('it continues attempting to read until buffer is full', function () { 153 | extern.parse('foo'); 154 | extern.parse('bar'); 155 | 156 | assume(extern.buffer).equals('foobar'); 157 | assume(extern.parse(extern.boundary)).is.true(); 158 | 159 | assume(extern.buffer).equals(''); 160 | }); 161 | 162 | it('trims away and extra whitespace', function () { 163 | assume(extern.parse('foo, bar'+ extern.boundary + '\n\r\n')).is.true(); 164 | assume(extern.buffer).equals(''); 165 | }); 166 | 167 | it('can parse multiple chunks of data', function () { 168 | extern.parse('foo bar'+ extern.boundary +'moo boo'); 169 | assume(extern.buffer).equals('moo boo'); 170 | 171 | extern.parse(extern.boundary +'moo'+ extern.boundary); 172 | assume(extern.buffer).equals(''); 173 | }); 174 | 175 | it('passes the chunk in to the #read method'); 176 | }); 177 | 178 | describe('#read', function () { 179 | it('emits an error when it fails to parse JSON', function (next) { 180 | extern.once('error', function (err) { 181 | assume(err.message).contains('JSON'); 182 | next(); 183 | }); 184 | 185 | extern.read('{foo'); 186 | }); 187 | 188 | it('emits `:loaded` if there are no dependencies to load', function (next) { 189 | extern.once('foo:loaded', function () { 190 | next(); 191 | }); 192 | 193 | extern.read(JSON.stringify({ 194 | name: 'foo' 195 | })); 196 | }); 197 | 198 | it('displays an error view when there is no component to render', function (next) { 199 | extern.destroy(); 200 | 201 | extern = new Extern('http://localhost:8080/fixtures/missing.json', placeholder, { 202 | timeout: 1000 203 | }); 204 | 205 | extern.once('error', next); 206 | extern.once('fixture:rendered', function () { 207 | assume(placeholder.innerHTML).includes('error'); 208 | 209 | next(); 210 | }); 211 | }); 212 | }); 213 | 214 | describe('.listen', function () { 215 | it('has a .listen method', function () { 216 | assume(Extern.listen).is.a('function'); 217 | }); 218 | 219 | it('returns the Extern', function () { 220 | assume(Extern.listen(placeholder)).equals(Extern); 221 | }); 222 | 223 | it('attaches it self to links & downloads the said URL', function (next) { 224 | var a = document.createElement('a'); 225 | 226 | a.href = 'http://localhost:8080/fixtures/format.json'; 227 | a.rel = 'extern'; 228 | 229 | document.body.appendChild(a); 230 | Extern.listen(placeholder, {}, { 231 | 'error': next, 232 | 'fixture:rendered': function () { 233 | assume(placeholder.innerHTML).contains('fixture client.js'); 234 | 235 | next(); 236 | } 237 | }); 238 | 239 | ui.mouse(a, 'click'); 240 | }); 241 | 242 | it('initializes all the things automatically using classNames', function (next) { 243 | var a = document.createElement('a'); 244 | 245 | a.href = 'http://localhost:8080/fixtures/format.json'; 246 | a.className = 'foo bar banana'; 247 | a.rel = 'extern'; 248 | 249 | document.body.appendChild(a); 250 | Extern.listen(placeholder, { className: 'bar' }, { 251 | 'error': next, 252 | 'fixture:rendered': function () { 253 | assume(placeholder.innerHTML).contains('fixture client.js'); 254 | 255 | next(); 256 | } 257 | }); 258 | }); 259 | }); 260 | 261 | describe('.merge', function () { 262 | it('merges the object in the first arg', function () { 263 | var obj = {}; 264 | 265 | Extern.merge(obj, { foo: 'bar' }); 266 | assume(obj.foo).equals('bar'); 267 | }); 268 | 269 | it('returns the supplied object', function () { 270 | var obj = {}; 271 | 272 | assume(Extern.merge(obj, { foo: 'bar' })).deep.equals(obj); 273 | assume(obj.foo).equals('bar'); 274 | }); 275 | }); 276 | }); 277 | -------------------------------------------------------------------------------- /test/extern.test.js: -------------------------------------------------------------------------------- 1 | describe('extern', function () { 2 | 'use strict'; 3 | 4 | var assume = require('assume') 5 | , Fittings = require('../'); 6 | 7 | describe('bigpipe integration', function () { 8 | it('has a custom name', function () { 9 | assume(Fittings.prototype.name).equals('extern'); 10 | }); 11 | 12 | it('exported as function', function () { 13 | assume(Fittings).is.a('function'); 14 | }); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /test/fixtures/client.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | return React.createClass({ 4 | render: function render() { 5 | return React.createElement('strong', null, 'fixture client.js'); 6 | } 7 | }); 8 | -------------------------------------------------------------------------------- /test/fixtures/empty.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/godaddy/external/d64ee13f9d4d39b2eb51ff3866e4e41f6d941e17/test/fixtures/empty.js -------------------------------------------------------------------------------- /test/fixtures/format.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fixture", 3 | "details": { 4 | "js": ["/fixtures/client.js"], 5 | "css": ["/fixtures/what.css"] 6 | }, 7 | "template": "" 8 | } 9 | \u1337 10 | -------------------------------------------------------------------------------- /test/fixtures/missing.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fixture", 3 | "details": { 4 | "js": ["/fixtures/empty.js"], 5 | "css": ["/fixtures/what.css"] 6 | }, 7 | "template": "" 8 | } 9 | \u1337 10 | -------------------------------------------------------------------------------- /test/fixtures/what.css: -------------------------------------------------------------------------------- 1 | #placeholder { 2 | height: 10px 3 | } 4 | 5 | #_what { height: 42px } 6 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var path = require('path') 4 | , Mocha = require('mocha') 5 | , argv = require('argh').argv 6 | , mochify = require('mochify'); 7 | 8 | argv.reporter = argv.reporter || 'spec'; 9 | argv.ui = argv.ui || 'bdd'; 10 | 11 | /** 12 | * Poor mans kill switch. Kills all active hooks. 13 | * 14 | * @api private 15 | */ 16 | function kill() { 17 | require('async-each')(kill.hooks, function each(fn, next) { 18 | fn(next); 19 | }, function done(err) { 20 | if (err) return process.exit(1); 21 | 22 | process.exit(0); 23 | }); 24 | } 25 | 26 | /** 27 | * All the hooks that need destruction. 28 | * 29 | * @type {Array} 30 | * @private 31 | */ 32 | kill.hooks = []; 33 | 34 | // 35 | // This is the magical test runner that setup's all the things and runs various 36 | // of test suites until something starts failing. 37 | // 38 | (function runner(steps) { 39 | if (!steps.length) return kill(), runner; 40 | 41 | var step = steps.shift(); 42 | 43 | step(function unregister(fn) { 44 | kill.hooks.push(fn); 45 | }, function register(err) { 46 | if (err) throw err; 47 | 48 | runner(steps); 49 | }); 50 | 51 | return runner; 52 | })([ 53 | // 54 | // Run the normal node tests. 55 | // 56 | function creamy(kill, next) { 57 | var mocha = new Mocha(); 58 | 59 | mocha.reporter(argv.reporter); 60 | mocha.ui(argv.ui); 61 | 62 | // 63 | // The next bulk of logic is required to correctly glob and lookup all the 64 | // files required for testing. 65 | // 66 | mocha.files = [ 67 | './test/*.test.js' 68 | ].map(function lookup(glob) { 69 | return Mocha.utils.lookupFiles(glob, ['js']); 70 | }).reduce(function flatten(arr, what) { 71 | Array.prototype.push.apply(arr, what); 72 | return arr; 73 | }, []).map(function resolve(file) { 74 | return path.resolve(file); 75 | }); 76 | 77 | // 78 | // Run the mocha test suite inside this node process with a custom callback 79 | // so we don't accidentally exit the process and forget to run the test of the 80 | // tests. 81 | // 82 | mocha.run(function ran(err) { 83 | if (err) err = new Error('Something failed in the mocha test suite'); 84 | next(err); 85 | }); 86 | }, 87 | 88 | // 89 | // Start-up a small static file server so we can download files and fixtures 90 | // inside our PhantomJS test. 91 | // 92 | require('./static'), 93 | 94 | // 95 | // Run the PhantomJS tests now that we have a small static server setup. 96 | // 97 | function phantomjs(kill, next) { 98 | mochify('./test/*.browser.js', { 99 | reporter: argv.reporter, 100 | cover: argv.cover, 101 | ui: argv.ui 102 | }) 103 | .bundle(next); 104 | } 105 | ]); 106 | -------------------------------------------------------------------------------- /test/static.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var fs = require('fs') 4 | , url = require('url') 5 | , path = require('path') 6 | , http = require('http') 7 | , access = require('access-control')(); 8 | 9 | /** 10 | * Simple static server to serve test files. 11 | * 12 | * @param {Function} kill Kill the server. 13 | * @param {Function} next Continue 14 | * @api private 15 | */ 16 | module.exports = function staticserver(kill, next) { 17 | var server = http.createServer(function serve(req, res) { 18 | access(req, res, function () { 19 | var file = path.join(__dirname, url.parse(req.url).pathname); 20 | 21 | if (!fs.existsSync(file)) { 22 | res.statusCode = 404; 23 | 24 | return res.end('nope'); 25 | } 26 | 27 | res.statusCode = 200; 28 | fs.createReadStream(file).pipe(res); 29 | }); 30 | }); 31 | 32 | kill(function close(next) { 33 | server.close(next); 34 | }); 35 | 36 | server.listen(8080, next); 37 | }; 38 | 39 | if (require.main === module) module.exports(function () { 40 | // 41 | // Ignore me, I'm the kill function of the test runner. 42 | // 43 | }, function () { 44 | console.log('Running static server on localhost:8080'); 45 | }); 46 | -------------------------------------------------------------------------------- /test/ui.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Emit mouse events on DOM elements without UI interaction. 5 | * 6 | * @param {DOM} node DOM node 7 | * @param {String} type Mouse event type we should trigger. 8 | * @api public 9 | */ 10 | exports.mouse = function mouse(node, type) { 11 | var evt; 12 | 13 | if (node.dispatchEvent) { 14 | if ('function' === typeof MouseEvent) { 15 | evt = new MouseEvent(type, { 16 | bubbles: true, 17 | cancelable: true 18 | }); 19 | } else { 20 | // PhantomJS (wat!) 21 | evt = document.createEvent('MouseEvent'); 22 | evt.initEvent(type, true, true); 23 | } 24 | 25 | return node.dispatchEvent(evt); 26 | } else { 27 | // IE 8 28 | evt = document.createEventObject('MouseEvent'); 29 | return node.fireEvent('on'+ type, evt); 30 | } 31 | }; 32 | 33 | /** 34 | * Emit keyboard events on DOM elements without UI interaction. 35 | * 36 | * @param {DOM} node DOM node 37 | * @param {String} type Mouse event type we should trigger. 38 | * @api public 39 | */ 40 | exports.keyboard = function keyboard(node, type, code) { 41 | var evt; 42 | 43 | if (node.dispatchEvent) { 44 | if ('function' === typeof KeyboardEvent) { 45 | // Chrome, Safari, Firefox 46 | evt = new KeyboardEvent(type, { 47 | bubbles: true, 48 | cancelable: true 49 | }); 50 | } else { 51 | // PhantomJS (wat!) 52 | evt = document.createEvent('KeyboardEvent'); 53 | evt.initEvent(type, true, true); 54 | } 55 | 56 | evt.keyCode = code; 57 | return node.dispatchEvent(evt); 58 | } else { 59 | // IE 8 60 | evt = document.createEventObject('KeyboardEvent'); 61 | evt.keyCode = code; 62 | return node.fireEvent('on'+ type, evt); 63 | } 64 | }; 65 | --------------------------------------------------------------------------------