├── example ├── public │ ├── .gitkeep │ ├── robots.txt │ └── crossdomain.xml ├── vendor │ └── .gitkeep ├── app │ ├── helpers │ │ └── .gitkeep │ ├── models │ │ └── .gitkeep │ ├── routes │ │ ├── .gitkeep │ │ ├── index.js │ │ ├── missing.js │ │ ├── redirect.js │ │ └── dynamic.js │ ├── styles │ │ ├── .gitkeep │ │ └── app.css │ ├── views │ │ └── .gitkeep │ ├── components │ │ └── .gitkeep │ ├── controllers │ │ ├── .gitkeep │ │ ├── missing.js │ │ ├── dynamic.js │ │ ├── redirect.js │ │ └── crash.js │ ├── templates │ │ ├── .gitkeep │ │ ├── components │ │ │ └── .gitkeep │ │ ├── application.hbs │ │ ├── missing.hbs │ │ ├── crash.hbs │ │ ├── dynamic.hbs │ │ ├── redirect.hbs │ │ └── index.hbs │ ├── router.js │ ├── app.js │ ├── initializers │ │ ├── meta.js │ │ └── ember-prerender.js │ ├── mixins │ │ ├── ember-prerender.js │ │ └── meta.js │ └── index.html ├── tests │ ├── unit │ │ └── .gitkeep │ ├── helpers │ │ ├── resolver.js │ │ └── start-app.js │ ├── test-helper.js │ ├── index.html │ └── .jshintrc ├── .bowerrc ├── testem.json ├── .travis.yml ├── ember-prerender-config.js ├── .ember-cli ├── .gitignore ├── bower.json ├── .editorconfig ├── .jshintrc ├── Brocfile.js ├── package.json ├── config │ └── environment.js └── README.md ├── index.js ├── .gitignore ├── lib ├── index.js ├── plugins │ ├── prettyPrintHtml.js │ ├── removeScriptTags.js │ ├── transparent.js │ ├── inMemoryHtmlCache.js │ ├── minifyHtml.js │ ├── prepareEmail.js │ ├── httpHeaders.js │ ├── s3HtmlCache.js │ └── mongoHtmlCache.js ├── logger.js ├── engines │ ├── webdriver.js │ ├── phantom.js │ └── jsdom.js ├── server.js └── renderer.js ├── bin └── ember-prerender ├── LICENSE ├── server.js ├── package.json └── README.md /example/public/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/vendor/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/app/helpers/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/app/models/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/app/routes/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/app/styles/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/app/views/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/tests/unit/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/app/components/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/app/controllers/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/app/templates/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/app/templates/components/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib'); 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | node_modules/ 3 | .DS_Store 4 | .idea/ 5 | -------------------------------------------------------------------------------- /example/public/robots.txt: -------------------------------------------------------------------------------- 1 | # robotstxt.org/ 2 | 3 | User-agent: * 4 | -------------------------------------------------------------------------------- /example/.bowerrc: -------------------------------------------------------------------------------- 1 | { 2 | "directory": "bower_components", 3 | "analytics": false 4 | } 5 | -------------------------------------------------------------------------------- /example/app/templates/application.hbs: -------------------------------------------------------------------------------- 1 |

Welcome to Ember.js

2 | 3 | {{outlet}} 4 | -------------------------------------------------------------------------------- /example/app/templates/missing.hbs: -------------------------------------------------------------------------------- 1 |

404 Not Found

2 | 3 |

No documents matched the specified URL

4 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | var PrerenderServer = require('./server'); 2 | 3 | exports = module.exports = function(config) { 4 | return new PrerenderServer(config); 5 | }; 6 | -------------------------------------------------------------------------------- /example/app/templates/crash.hbs: -------------------------------------------------------------------------------- 1 |

Fatal Error

2 | 3 |

4 | This page will cause a fatal Javascript error, which should 5 | cause ember-prerender to restart its rendering engine. 6 |

7 | -------------------------------------------------------------------------------- /example/app/routes/index.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | import Meta from '../mixins/meta'; 3 | import EmberPrerender from '../mixins/ember-prerender'; 4 | 5 | export default Ember.Route.extend(Meta, EmberPrerender, {}); 6 | -------------------------------------------------------------------------------- /example/app/routes/missing.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | import Meta from '../mixins/meta'; 3 | import EmberPrerender from '../mixins/ember-prerender'; 4 | 5 | export default Ember.Route.extend(Meta, EmberPrerender, {}); 6 | -------------------------------------------------------------------------------- /example/testem.json: -------------------------------------------------------------------------------- 1 | { 2 | "framework": "qunit", 3 | "test_page": "tests/index.html", 4 | "launch_in_ci": [ 5 | "PhantomJS" 6 | ], 7 | "launch_in_dev": [ 8 | "PhantomJS", 9 | "Chrome" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /example/app/controllers/missing.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | 3 | export default Ember.Controller.extend({ 4 | metaFields: function() { 5 | return { 6 | title: "Missing", 7 | statusCode: 404 8 | }; 9 | } 10 | }); 11 | -------------------------------------------------------------------------------- /example/.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | language: node_js 3 | 4 | sudo: false 5 | 6 | cache: 7 | directories: 8 | - node_modules 9 | 10 | install: 11 | - npm install -g bower 12 | - npm install 13 | - bower install 14 | 15 | script: 16 | - npm test 17 | -------------------------------------------------------------------------------- /example/ember-prerender-config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | engine: 'phantom', 3 | //engine: 'jsdom', 4 | //engine: 'webdriver', 5 | appUrl: 'http://localhost:4200/', 6 | plugins: [ 7 | 'removeScriptTags', 8 | 'httpHeaders' 9 | ] 10 | }; 11 | -------------------------------------------------------------------------------- /example/app/templates/dynamic.hbs: -------------------------------------------------------------------------------- 1 |
2 | {{#each content.items}} 3 | 4 | {{/each}} 5 |
6 | -------------------------------------------------------------------------------- /lib/plugins/prettyPrintHtml.js: -------------------------------------------------------------------------------- 1 | var pretty = require('pretty'); 2 | 3 | module.exports = { 4 | beforeSend: function(req, res, page, next) { 5 | if (!page.html) { 6 | return next(); 7 | } 8 | page.html = pretty(page.html); 9 | next(); 10 | } 11 | }; 12 | -------------------------------------------------------------------------------- /example/app/controllers/dynamic.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | 3 | export default Ember.Controller.extend({ 4 | metaFields: function() { 5 | return { 6 | title: "Flickr Feed", 7 | description: "Page generated using Ember.js and ember-prerender" 8 | }; 9 | } 10 | }); 11 | -------------------------------------------------------------------------------- /example/app/controllers/redirect.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | 3 | export default Ember.Controller.extend({ 4 | metaFields: function() { 5 | return { 6 | title: "Redirecting...", 7 | statusCode: 301, 8 | header: "Location: http://github.com/" 9 | }; 10 | } 11 | }); 12 | -------------------------------------------------------------------------------- /example/app/controllers/crash.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | 3 | export default Ember.Controller.extend({ 4 | init: function() { 5 | console.log("Throwing error in 1 second..."); 6 | 7 | Ember.run.later(function() { 8 | throw new Error("An error occurred"); 9 | }, 1000); 10 | } 11 | }); 12 | -------------------------------------------------------------------------------- /example/tests/helpers/resolver.js: -------------------------------------------------------------------------------- 1 | import Resolver from 'ember/resolver'; 2 | import config from '../../config/environment'; 3 | 4 | var resolver = Resolver.create(); 5 | 6 | resolver.namespace = { 7 | modulePrefix: config.modulePrefix, 8 | podModulePrefix: config.podModulePrefix 9 | }; 10 | 11 | export default resolver; 12 | -------------------------------------------------------------------------------- /example/.ember-cli: -------------------------------------------------------------------------------- 1 | { 2 | /** 3 | Ember CLI sends analytics information by default. The data is completely 4 | anonymous, but there are times when you might want to disable this behavior. 5 | 6 | Setting `disableAnalytics` to true will prevent any data from being sent. 7 | */ 8 | "disableAnalytics": false 9 | } 10 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | 7 | # dependencies 8 | /node_modules 9 | /bower_components/* 10 | 11 | # misc 12 | /.sass-cache 13 | /connect.lock 14 | /coverage/* 15 | /libpeerconnection.log 16 | npm-debug.log 17 | testem.log 18 | -------------------------------------------------------------------------------- /example/app/router.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | import config from './config/environment'; 3 | 4 | var Router = Ember.Router.extend({ 5 | location: config.locationType 6 | }); 7 | 8 | Router.map(function() { 9 | this.resource('dynamic'); 10 | this.route('crash'); 11 | this.route('redirect'); 12 | this.route('missing', { path: '/*path' }); 13 | }); 14 | 15 | export default Router; 16 | -------------------------------------------------------------------------------- /example/app/templates/redirect.hbs: -------------------------------------------------------------------------------- 1 |

Redirecting...

2 | 3 |

4 | When using ember-prerender, this page should redirect you 5 | to Github.com using a 301 redirect. 6 |

7 | 8 |

9 | In addition to setting the prerender-specific meta tag for 10 | redirects, you can use Javascript or a meta refresh tag to forward 11 | desktop browsers to the desired URL. 12 |

13 | -------------------------------------------------------------------------------- /example/app/routes/redirect.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | import Meta from '../mixins/meta'; 3 | import EmberPrerender from '../mixins/ember-prerender'; 4 | 5 | export default Ember.Route.extend(Meta, EmberPrerender, { 6 | enter: function() { 7 | if (!window.isPrerender) { 8 | setTimeout(function() { 9 | window.location.replace("http://github.com/"); 10 | }, 5000); 11 | } 12 | } 13 | }); 14 | -------------------------------------------------------------------------------- /example/app/styles/app.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | margin: 20px; 3 | font-family: sans-serif; 4 | } 5 | 6 | .clear:before, 7 | .clear:after { 8 | content: " "; 9 | display: table; 10 | } 11 | .clear:after { 12 | clear: both; 13 | } 14 | 15 | .clear > .floated { 16 | float: left; 17 | height: 240px; 18 | width: 240px; 19 | background-repeat: no-repeat; 20 | background-size: cover; 21 | background-position: center center; 22 | display: block; 23 | } 24 | -------------------------------------------------------------------------------- /example/app/templates/index.hbs: -------------------------------------------------------------------------------- 1 |

Waiting for remote JSON

2 | 3 |

{{#link-to 'dynamic'}}Flickr feed{{/link-to}}

4 | 5 |

Recovery from a fatal error

6 | 7 |

{{#link-to 'crash'}}Crash example{{/link-to}}

8 | 9 |

Issuing 301 redirects

10 | 11 |

{{#link-to 'redirect'}}Redirect to Github{{/link-to}}

12 | 13 |

Issuing a 404 header on missing routes

14 | 15 |

Missing page

16 | 17 | -------------------------------------------------------------------------------- /example/app/app.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | import Resolver from 'ember/resolver'; 3 | import loadInitializers from 'ember/load-initializers'; 4 | import config from './config/environment'; 5 | 6 | Ember.MODEL_FACTORY_INJECTIONS = true; 7 | 8 | var App = Ember.Application.extend({ 9 | modulePrefix: config.modulePrefix, 10 | podModulePrefix: config.podModulePrefix, 11 | Resolver: Resolver 12 | }); 13 | 14 | loadInitializers(App, config.modulePrefix); 15 | 16 | export default App; 17 | -------------------------------------------------------------------------------- /example/app/initializers/meta.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | import ENV from 'example/config/environment'; 3 | 4 | export default { 5 | name: 'meta', 6 | initialize: function() { 7 | ENV.APP.DEFAULT_PAGE_TITLE = document.title; 8 | 9 | Ember.$('head').append( 10 | Ember.$(' 20 | 21 | 22 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /lib/plugins/minifyHtml.js: -------------------------------------------------------------------------------- 1 | var Minimize = require('minimize'); 2 | var minimize = new Minimize({ 3 | empty: false, // KEEP empty attributes 4 | cdata: true, // KEEP CDATA from scripts 5 | comments: false, // KEEP comments 6 | ssi: false, // KEEP Server Side Includes 7 | conditionals: true, // KEEP conditional internet explorer comments 8 | spare: false, // KEEP redundant attributes 9 | quotes: true, // KEEP arbitrary quotes 10 | loose: false // KEEP one whitespace 11 | }); 12 | 13 | module.exports = { 14 | beforeSend: function(req, res, page, next) { 15 | if (!page.html) { 16 | return next(); 17 | } 18 | 19 | minimize.parse(page.html, function(error, minified) { 20 | if (!error) { 21 | page.html = minified; 22 | } 23 | next(); 24 | }); 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "0.0.0", 4 | "private": true, 5 | "directories": { 6 | "doc": "doc", 7 | "test": "tests" 8 | }, 9 | "scripts": { 10 | "start": "ember server", 11 | "build": "ember build", 12 | "test": "ember test" 13 | }, 14 | "repository": "https://github.com/stefanpenner/ember-cli", 15 | "engines": { 16 | "node": ">= 0.10.0" 17 | }, 18 | "author": "", 19 | "license": "MIT", 20 | "devDependencies": { 21 | "body-parser": "^1.2.0", 22 | "broccoli-asset-rev": "0.1.1", 23 | "broccoli-ember-hbs-template-compiler": "^1.6.1", 24 | "ember-cli": "0.0.46", 25 | "ember-cli-ic-ajax": "0.1.1", 26 | "ember-cli-inject-live-reload": "^1.0.2", 27 | "ember-cli-qunit": "0.1.0", 28 | "ember-data": "1.0.0-beta.10", 29 | "express": "^4.8.5", 30 | "glob": "^4.0.5" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /lib/plugins/prepareEmail.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | beforeSend: function(req, res, page, next) { 3 | if (page.url.indexOf('/email/') === 0) { 4 | var subjectRe = /(.*?)<\/title>/; 5 | var subject = page.html.match(subjectRe); 6 | 7 | var bodyRe = /<section id="email-message">(.*?)<\/section>/; 8 | var body = page.html.match(bodyRe); 9 | if (subject && subject.length > 1 && body && body.length > 1) { 10 | page.html = "<!doctype html>\n" + 11 | "<html lang=\"en\">\n" + 12 | "<head>\n" + 13 | " <meta charset=\"utf-8\">\n" + 14 | " <title>" + subject[1] + "\n" + 15 | " \n" + 16 | "\n" + 17 | "\n" + 18 | body[1] + "\n" + 19 | "\n" + 20 | ""; 21 | } else { 22 | page.statusCode = 500; 23 | page.html = "500 Internal Server Error"; 24 | } 25 | } 26 | 27 | next(); 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013-2014 ZipfWorks Inc 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /lib/plugins/httpHeaders.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | beforeSend: function(req, res, page, next) { 3 | if (page.html) { 4 | var headerMatch = //g; 5 | var head = page.html.split('', 1).pop(); 6 | var match; 7 | 8 | while ((match = headerMatch.exec(head))) { 9 | switch (match[1]) { 10 | case 'status-code': 11 | page.statusCode = parseInt(match[2], 10); 12 | if (page.statusCode == 301 || page.statusCode == 302) { 13 | page.html = 'Moved'; 14 | } 15 | break; 16 | case 'header': 17 | var pos = match[2].indexOf(': '); 18 | if (pos > 0) { 19 | var headerName = match[2].slice(0, pos); 20 | var headerValue = match[2].slice(pos+2); 21 | if (headerName === 'Location') { 22 | headerValue = decodeURIComponent(headerValue); 23 | } 24 | res.setHeader(headerName, headerValue); 25 | } 26 | break; 27 | } 28 | } 29 | } 30 | 31 | next(); 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /example/app/initializers/ember-prerender.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | 3 | export default { 4 | name: 'ember-prerender', 5 | initialize: function(container) { 6 | if (document.createEvent) { 7 | window.prerenderReadyEvent = document.createEvent('Event'); 8 | window.prerenderReadyEvent.initEvent('XContentReady', false, false); 9 | window.prerenderTransitionEvent = document.createEvent('Event'); 10 | window.prerenderTransitionEvent.initEvent('XPushState', false, false); 11 | } 12 | 13 | window.prerenderReady = function() { 14 | if (window.prerenderReadyEvent) { 15 | console.debug('PRERENDER READY'); 16 | document.dispatchEvent(window.prerenderReadyEvent); 17 | } 18 | }; 19 | 20 | document.addEventListener('XPushState', function(event) { 21 | var router = container.lookup('router:main'); 22 | Ember.run(function() { 23 | router.replaceWith(event.url).then(function(route) { 24 | if (route.handlerInfos) { 25 | // The requested route was already loaded 26 | window.prerenderReady(); 27 | } 28 | }); 29 | }); 30 | }, false); 31 | } 32 | }; 33 | 34 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | var prerender = require('./lib'); 2 | var _ = require('lodash'); 3 | 4 | // Default configuration values: 5 | var config = { 6 | port: 3000, 7 | processNum: 0, 8 | engine: "phantom", 9 | contentReadyDelay: 0, 10 | initializeTimeout: 25000, 11 | renderTimeout: 15000, 12 | maxRequestsPerRenderer: 100, 13 | exitAfterMaxRequests: false, 14 | gracefulExit: true, 15 | maxQueueSize: 50, 16 | appUrl: "http://localhost:4200/", 17 | serveFiles: true, 18 | serveFilesLog: true, 19 | filesMatch: /\.(?:css|js|jpg|png|gif|ico|svg|woff|woff2|ttf|swf|map)(?:\?|$)/i, 20 | ignoreAssets: /google-analytics\.com|fonts\.googleapis\.com|typekit\.com|platform\.twitter\.com|connect\.facebook\.net|apis\.google\.com|\.css(?:\?|$)/, 21 | logging: { 22 | level: "debug", 23 | timestamp: true, 24 | format: true 25 | }, 26 | plugins: [ 27 | "removeScriptTags", 28 | "httpHeaders" 29 | ] 30 | }; 31 | 32 | if (process.env.CONFIG) { 33 | var userConfig = require(process.env.CONFIG); 34 | _.merge(config, userConfig); 35 | } 36 | 37 | if (process.env.PROCESS_NUM) { 38 | config.processNum = parseInt(process.env.PROCESS_NUM, 10); 39 | } 40 | 41 | var server = prerender(config); 42 | server.start(); 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ember-prerender", 3 | "summary": "Static HTML rendering for Ember.js", 4 | "description": "Render static HTML from your Ember.js web apps on the server for SEO and other purposes.", 5 | "version": "2.5.5", 6 | "author": "Brian Stanback", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/zipfworks/ember-prerender" 10 | }, 11 | "dependencies": { 12 | "cache-manager": "^0.19.0", 13 | "cli-color": ">= 0.3 < 0.4", 14 | "lodash": ">= 2.4 < 2.5", 15 | "minimize": "^1.3.3", 16 | "mongodb": "^2.0.25", 17 | "phantom": "^0.7.2", 18 | "phantomjs": ">= 1.9 < 1.10", 19 | "pretty": ">= 0.1 < 0.2", 20 | "request": ">= 2.0 < 3.0" 21 | }, 22 | "optionalDependencies": { 23 | "aws-sdk": ">= 2.0 < 3.0", 24 | "chromedriver": "^2.10.0-1", 25 | "jsdom": "1.0.2", 26 | "selenium-webdriver": "^2.43.5" 27 | }, 28 | "bin": { 29 | "ember-prerender": "bin/ember-prerender" 30 | }, 31 | "scripts": { 32 | "start": "node server.js" 33 | }, 34 | "bugs": { 35 | "url": "https://github.com/zipfworks/ember-prerender/issues" 36 | }, 37 | "homepage": "https://github.com/zipfworks/ember-prerender", 38 | "main": "index.js", 39 | "license": "MIT" 40 | } 41 | -------------------------------------------------------------------------------- /example/config/environment.js: -------------------------------------------------------------------------------- 1 | /* jshint node: true */ 2 | 3 | module.exports = function(environment) { 4 | var ENV = { 5 | modulePrefix: 'example', 6 | environment: environment, 7 | baseURL: '/', 8 | locationType: 'auto', 9 | EmberENV: { 10 | FEATURES: { 11 | // Here you can enable experimental features on an ember canary build 12 | // e.g. 'with-controller': true 13 | } 14 | }, 15 | 16 | APP: { 17 | // Here you can pass flags/options to your application instance 18 | // when it is created 19 | } 20 | }; 21 | 22 | if (environment === 'development') { 23 | // ENV.APP.LOG_RESOLVER = true; 24 | ENV.APP.LOG_ACTIVE_GENERATION = true; 25 | // ENV.APP.LOG_TRANSITIONS = true; 26 | // ENV.APP.LOG_TRANSITIONS_INTERNAL = true; 27 | ENV.APP.LOG_VIEW_LOOKUPS = true; 28 | } 29 | 30 | if (environment === 'test') { 31 | // Testem prefers this... 32 | ENV.baseURL = '/'; 33 | ENV.locationType = 'auto'; 34 | 35 | // keep test console output quieter 36 | ENV.APP.LOG_ACTIVE_GENERATION = false; 37 | ENV.APP.LOG_VIEW_LOOKUPS = false; 38 | 39 | ENV.APP.rootElement = '#ember-testing'; 40 | } 41 | 42 | if (environment === 'production') { 43 | 44 | } 45 | 46 | return ENV; 47 | }; 48 | -------------------------------------------------------------------------------- /example/tests/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Example Tests 7 | 8 | 9 | 10 | {{BASE_TAG}} 11 | 12 | 13 | 14 | 15 | 31 | 32 | 33 |
34 |
35 | 36 | 39 | 40 | 41 | 42 | 43 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /example/app/mixins/meta.js: -------------------------------------------------------------------------------- 1 | import Ember from 'ember'; 2 | import ENV from 'example/config/environment'; 3 | 4 | export default Ember.Mixin.create({ 5 | actions: { 6 | didTransition: function() { 7 | var fields = {}; 8 | var currentHandlerInfos = this.router.get('router.currentHandlerInfos'); 9 | for (var i = 0; i < currentHandlerInfos.length; i++) { 10 | var controller = this.controllerFor(currentHandlerInfos[i].name); 11 | if (controller.metaFields) { 12 | Ember.$.extend(fields, controller.metaFields()); 13 | } 14 | } 15 | this._addMetaTags(fields); 16 | this._super(); 17 | } 18 | }, 19 | 20 | _addMetaTags: function(fields) { 21 | var tags = []; 22 | 23 | if (fields.description) { 24 | tags.push({ 25 | name: 'description', 26 | content: fields.description 27 | }); 28 | } 29 | 30 | if (fields.statusCode) { 31 | tags.push({ 32 | property: 'prerender:status-code', 33 | content: fields.statusCode 34 | }); 35 | } 36 | 37 | if (fields.header) { 38 | tags.push({ 39 | property: 'prerender:header', 40 | content: fields.header 41 | }); 42 | } 43 | 44 | document.title = fields.title || ENV.APP.DEFAULT_PAGE_TITLE; 45 | 46 | Ember.$('#meta-start').nextUntil('#meta-end').remove(); 47 | for (var i = 0; i < tags.length; i++) { 48 | Ember.$('#meta-start').after(Ember.$('', tags[i])); 49 | } 50 | } 51 | }); 52 | 53 | -------------------------------------------------------------------------------- /example/tests/.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "predef": [ 3 | "document", 4 | "window", 5 | "location", 6 | "setTimeout", 7 | "$", 8 | "-Promise", 9 | "QUnit", 10 | "define", 11 | "console", 12 | "equal", 13 | "notEqual", 14 | "notStrictEqual", 15 | "test", 16 | "asyncTest", 17 | "testBoth", 18 | "testWithDefault", 19 | "raises", 20 | "throws", 21 | "deepEqual", 22 | "start", 23 | "stop", 24 | "ok", 25 | "strictEqual", 26 | "module", 27 | "moduleFor", 28 | "moduleForComponent", 29 | "moduleForModel", 30 | "process", 31 | "expect", 32 | "visit", 33 | "exists", 34 | "fillIn", 35 | "click", 36 | "keyEvent", 37 | "triggerEvent", 38 | "find", 39 | "findWithAssert", 40 | "wait", 41 | "DS", 42 | "keyEvent", 43 | "isolatedContainer", 44 | "startApp", 45 | "andThen", 46 | "currentURL", 47 | "currentPath", 48 | "currentRouteName" 49 | ], 50 | "node": false, 51 | "browser": false, 52 | "boss": true, 53 | "curly": false, 54 | "debug": false, 55 | "devel": false, 56 | "eqeqeq": true, 57 | "evil": true, 58 | "forin": false, 59 | "immed": false, 60 | "laxbreak": false, 61 | "newcap": true, 62 | "noarg": true, 63 | "noempty": false, 64 | "nonew": false, 65 | "nomen": false, 66 | "onevar": false, 67 | "plusplus": false, 68 | "regexp": false, 69 | "undef": true, 70 | "sub": true, 71 | "strict": false, 72 | "white": false, 73 | "eqnull": true, 74 | "esnext": true 75 | } 76 | -------------------------------------------------------------------------------- /lib/plugins/s3HtmlCache.js: -------------------------------------------------------------------------------- 1 | var cache_manager = require('cache-manager'); 2 | var s3 = new (require('aws-sdk')).S3({params:{Bucket: process.env.S3_BUCKET_NAME}}); 3 | 4 | var cacheTTL = process.env.CACHE_TTL || 14400; 5 | 6 | var s3_cache = { 7 | get: function(key, callback) { 8 | if (process.env.S3_PREFIX_KEY) { 9 | key = process.env.S3_PREFIX_KEY + '/' + key; 10 | } 11 | 12 | s3.getObject({ 13 | Key: key 14 | }, callback); 15 | }, 16 | set: function(key, value, callback) { 17 | if (process.env.S3_PREFIX_KEY) { 18 | key = process.env.S3_PREFIX_KEY + '/' + key; 19 | } 20 | 21 | var request = s3.putObject({ 22 | Key: key, 23 | ContentType: 'text/html;charset=UTF-8', 24 | StorageClass: 'REDUCED_REDUNDANCY', 25 | Body: value 26 | }, callback); 27 | 28 | if (!callback) { 29 | request.send(); 30 | } 31 | } 32 | }; 33 | 34 | module.exports = { 35 | init: function() { 36 | this.cache = cache_manager.caching({ 37 | store: s3_cache, 38 | ttl: cacheTTL 39 | }); 40 | }, 41 | 42 | beforeRender: function(req, res, page, next) { 43 | if (req.headers['x-cache-invalidate']) { 44 | // Skip cache 45 | return next(); 46 | } 47 | this.cache.get(page.url, function(err, result) { 48 | if (!err && result) { 49 | page.statusCode = 200; 50 | page.html = result.Body; 51 | } 52 | next(); 53 | }); 54 | }, 55 | 56 | beforeSend: function(req, res, page, next) { 57 | this.cache.set(page.url, page.html); 58 | next(); 59 | } 60 | }; 61 | -------------------------------------------------------------------------------- /lib/logger.js: -------------------------------------------------------------------------------- 1 | var clc = require('cli-color'); 2 | var _ = require('lodash'); 3 | 4 | function PrerenderLogger(config, prefix) { 5 | this.config = config; 6 | this.prefix = prefix; 7 | 8 | this.levels = { 9 | debug: 0, 10 | renderer: 1, 11 | server: 2, 12 | error: 3 13 | }; 14 | 15 | this.formats = { 16 | debug: function(val) { 17 | return clc.blackBright(val); 18 | }, 19 | renderer: function(val) { 20 | return clc.blueBright(val); 21 | }, 22 | server: function(val) { 23 | return clc.cyan(val); 24 | }, 25 | error: function(val) { 26 | return clc.red(val); 27 | } 28 | }; 29 | } 30 | 31 | /* 32 | * Utility class for prerender logging 33 | */ 34 | PrerenderLogger.prototype.log = function(level) { 35 | var _this = this; 36 | 37 | if (this.levels[this.config.level] > this.levels[level]) { 38 | return; 39 | } 40 | 41 | var args = [].slice.call(arguments); 42 | args.shift(); 43 | 44 | args.unshift('[' + this.prefix + ']'); 45 | 46 | if (this.config.timestamp) { 47 | var timestamp = new Date().toISOString(); 48 | if (this.config.format) { 49 | timestamp = clc.inverse(timestamp); 50 | } 51 | args.unshift(timestamp); 52 | } 53 | 54 | if (this.config.format) { 55 | args = _.map(args, function(arg) { 56 | if (typeof arg !== 'string') { 57 | try { 58 | arg = JSON.stringify(arg); 59 | } catch (e) { 60 | } 61 | } 62 | return _this.formats[level](arg); 63 | }); 64 | } 65 | 66 | console.log.apply(this, args); 67 | }; 68 | 69 | module.exports = PrerenderLogger; 70 | -------------------------------------------------------------------------------- /lib/plugins/mongoHtmlCache.js: -------------------------------------------------------------------------------- 1 | var MongoClient = require('mongodb').MongoClient; 2 | 3 | var mongoURI = process.env.MONGO_URI || 'mongodb://localhost/ember-prerender'; 4 | var mongoCollection = process.env.MONGO_COLL || 'pages'; 5 | var cacheTTL = process.env.CACHE_TTL || 14400; 6 | 7 | var collection; 8 | 9 | MongoClient.connect(mongoURI, function(err, db) { 10 | if (db) { 11 | db.collection(mongoCollection, function(err, coll) { 12 | collection = coll; 13 | 14 | // Currently keeping records in mongo indefinitely and checking the date delta in beforeRender, 15 | // an alternative option is to have Mongo expire the record automatically 16 | //collection.ensureIndex({ createdOn: 1 }, { expireAfterSeconds: cacheTTL }); 17 | }); 18 | } 19 | }); 20 | 21 | module.exports = { 22 | beforeRender: function(req, res, page, next) { 23 | if (req.headers['x-cache-invalidate'] || !collection) { 24 | // Skip cache for POST/PUT requests or if no DB collection is available 25 | return next(); 26 | } 27 | collection.findOne({ url: page.url }, function(err, result) { 28 | if (!err && result && result.html) { 29 | if (((new Date() - result.ts) / 1000) <= cacheTTL) { 30 | page.statusCode = result.status || 200; 31 | if ((page.statusCode === 301 || page.statusCode === 302) && result.location) { 32 | res.setHeader('Location', result.location); 33 | } 34 | page.html = result.html; 35 | } 36 | } 37 | next(); 38 | }); 39 | }, 40 | 41 | beforeSend: function(req, res, page, next) { 42 | if (page.statusCode < 400) { 43 | var object = { 44 | url: page.url, 45 | status: page.statusCode, 46 | html: page.html, 47 | ts: new Date() 48 | }; 49 | if (page.statusCode === 301 || page.statusCode === 302) { 50 | var location = res.getHeader('Location'); 51 | if (location) { 52 | object.location = location; 53 | } 54 | } 55 | collection.update({ url: object.url }, object, { upsert: true }, function (err) { 56 | // Ignored 57 | }); 58 | } 59 | next(); 60 | } 61 | }; 62 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # Ember-Prerender Example Project # 2 | 3 | This is an example Ember.js project that demonstrates how 4 | ember-prerender works. 5 | 6 | ## How does it work? ## 7 | 8 | A custom initializer in **_app/initializers/ember-prerender.js_** registers custom XContentReady and XPushState events. It listens for an XPushState event with a 'url' parameter and transitions to that URL when the event is received. 9 | 10 | A mixin in **_app/mixins/ember-prerender.js_** gets added to the routes in **_app/routes/*_** which dispatches the XContentReady event when Ember.js has finished transitioning to the route and any promises have been resolved. Promises may be returned by the optional willComplete method in your controllers if there is something on the page that get loaded after the route's model(s). 11 | 12 | You'll also find an initializer and mixin used for updating the page title, meta tags, and prerender status code. Meta tag values are generated by the controllers in **_app/controllers/*_** and the properties from parent controllers are inhereted by their children. 13 | 14 | Other files to check out are **_app/router.js_** and **_app/templates/*_**. 15 | 16 | The rest of the project is from the default template project built with ember-cli by running `ember new App`. 17 | 18 | ## Getting Started ## 19 | 20 | To build, run, and test this project, please install ember-cli, bower, 21 | and ember-prerender: 22 | 23 | npm install -g ember-cli 24 | npm install -g bower 25 | npm install -g ember-prerender 26 | 27 | Once you've downloaded the dependencies, clone this repository and run the following: 28 | 29 | cd example 30 | npm install 31 | bower install 32 | 33 | ## Running ## 34 | 35 | First, view the project in your browser: 36 | 37 | ember server 38 | 39 | In your browser, open [http://localhost:4200](http://localhost:4200) to view the Javascript version of the site. 40 | 41 | To render the project with ember-prerender, keep "ember server" running 42 | and type: 43 | 44 | ember-prerender ember-prerender-config.js 45 | 46 | In your browser, open [http://localhost:3000](http://localhost:3000) to view the static html version of the site 47 | 48 | The example project includes three routes, an index at /, an AJAX page at /dynamic, and a 404 page at any other url (e.g. /foo). 49 | 50 | ## Caveats ## 51 | 52 | Ember-prerender expects your Ember app to be located at window.App. This could be a configuration option if there's demand for it. 53 | -------------------------------------------------------------------------------- /lib/engines/webdriver.js: -------------------------------------------------------------------------------- 1 | var webdriver = require('selenium-webdriver'); 2 | var chromedriver = require('chromedriver'); 3 | var path = require('path'); 4 | var _ = require('lodash'); 5 | 6 | function WebDriverEngine(config, logger) { 7 | this.config = config; 8 | this.logger = logger; 9 | } 10 | 11 | /* 12 | * Initialize the page 13 | */ 14 | WebDriverEngine.prototype.init = function(appUrl, initCallback, errorCallback, beforeInitCallback) { 15 | var _this = this; 16 | 17 | this.initializationCallback = initCallback; 18 | this.hasInitializationCallback = true; 19 | this.contentReadyTimer = null; 20 | 21 | process.env['PATH'] = path.dirname(chromedriver.path) + ':' + process.env['PATH']; 22 | 23 | this.driver = new webdriver.Builder().withCapabilities( 24 | webdriver.Capabilities.chrome() 25 | //webdriver.Capabilities.firefox() 26 | ).build(); 27 | 28 | beforeInitCallback(function() { 29 | _this.driver.get(appUrl); 30 | 31 | // TODO: Bind console messages, set viewport size, etc 32 | _this.driver.executeScript( 33 | "window.isPrerender = true;" + 34 | 35 | "window.XContentReadyFlag = false;" + 36 | "document.addEventListener('XContentReady', function() {" + 37 | "window.XContentReadyFlag = true;" + 38 | "}, false);" + 39 | 40 | "window.jsErrors = [];" + 41 | "window.onerror = function(errorMessage) {" + 42 | "window.jsErrors.push(errorMessage);" + 43 | "};" 44 | ); 45 | 46 | // Hack: the above event listener script that sets window.XContentReadyFlag is sometimes 47 | // executed after the page has already loaded 48 | var _maxTries = 5; 49 | var _checkInterval = 100; 50 | 51 | _this.readyTimer = setInterval(function() { 52 | return _this.driver.executeScript( 53 | "return {" + 54 | "ready: window.XContentReadyFlag," + 55 | "errors: window.jsErrors" + 56 | "};" 57 | ).then(function(result) { 58 | if (result.errors.length) { 59 | errorCallback("Phantom encountered an error: " + result.errors.join()); 60 | } else if (result.ready || (_this.hasInitializationCallback && _maxTries <= 0)) { 61 | _this.driver.executeScript("window.XContentReadyFlag = false;"); 62 | _.bind(_this.onPageReady, _this)(); 63 | } else if (_this.hasInitializationCallback) { 64 | _maxTries--; 65 | } 66 | }); 67 | }, _checkInterval); 68 | }); 69 | }; 70 | 71 | /* 72 | * Load a route 73 | */ 74 | WebDriverEngine.prototype.loadRoute = function(page, callback) { 75 | var _this = this; 76 | 77 | this.currentPage = page; 78 | this.pageCallback = callback; 79 | this.hasPageCallback = true; 80 | 81 | clearTimeout(this.contentReadyTimer); 82 | 83 | this.driver.executeScript( 84 | "window.XContentReadyFlag = false;" + 85 | "window.prerenderTransitionEvent.url = '" + page.url + "';" + 86 | "window.document.dispatchEvent(window.prerenderTransitionEvent);" 87 | ); 88 | }; 89 | 90 | /* 91 | * Callback handler for when a page finishes rendering 92 | */ 93 | WebDriverEngine.prototype.onPageReady = function() { 94 | var _this = this; 95 | 96 | if (this.hasInitializationCallback) { 97 | this.hasInitializationCallback = false; 98 | this.initializationCallback(); 99 | } else { 100 | this.contentReadyTimer = setTimeout(function() { 101 | _this.driver.getPageSource().then(function(html) { 102 | if (_this.hasPageCallback) { 103 | _this.hasPageCallback = false; 104 | _this.currentPage.statusCode = 200; 105 | _this.currentPage.html = html; 106 | _this.pageCallback(_this.currentPage); 107 | } 108 | }); 109 | }, this.config.contentReadyDelay); 110 | } 111 | }; 112 | 113 | /* 114 | * Destroy the webdriver process 115 | */ 116 | WebDriverEngine.prototype.shutdown = function() { 117 | clearInterval(this.readyTimer); 118 | clearTimeout(this.contentReadyTimer); 119 | 120 | if (this.driver) { 121 | this.driver.quit(); 122 | } 123 | }; 124 | 125 | module.exports = WebDriverEngine; 126 | -------------------------------------------------------------------------------- /lib/engines/phantom.js: -------------------------------------------------------------------------------- 1 | var _ = require('lodash'); 2 | var phantomjs = require('phantomjs'); 3 | var phantom = require('phantom'); 4 | 5 | function PhantomEngine(config, logger) { 6 | this.config = config; 7 | this.logger = logger; 8 | this.engineSettings = { 9 | binary: phantomjs.path, 10 | userAgent: 'Prerender', 11 | loadImages: false, 12 | localToRemoteUrlAccessEnabled: true, 13 | webSecurityEnabled: true 14 | }; 15 | } 16 | 17 | /* 18 | * Initialize the page 19 | */ 20 | PhantomEngine.prototype.init = function(appUrl, initCallback, errorCallback, beforeInitCallback) { 21 | var _this = this; 22 | 23 | this.initializationCallback = initCallback; 24 | this.hasInitializationCallback = true; 25 | this.contentReadyTimer = null; 26 | 27 | this.engineSettings.onExit = function(code, signal) { 28 | if (code !== 0) { 29 | errorCallback("Erroneous exit code: " + code, signal); 30 | } 31 | }; 32 | 33 | this.phantom = phantom.create("--load-images=false", "--ignore-ssl-errors=true", "--ssl-protocol=any", this.engineSettings, function(ph) { 34 | _this.phantom.ph = ph; 35 | _this.phantom.ph.createPage(function(phantomPage) { 36 | _this.phantom.page = phantomPage; 37 | 38 | _this.phantom.page.set('onConsoleMessage', function(msg) { 39 | _this.logger.log('debug', '>>>', msg); 40 | }); 41 | 42 | _this.phantom.page.set('onCallback', _.bind(_this.onPageReady, _this)); 43 | 44 | _this.phantom.page.set('onError', function(msg) { 45 | errorCallback("Phantom encountered an error: " + msg); 46 | }); 47 | 48 | // FIXME: Uncomment after resolving issue in phantomjs-node (https://github.com/sgentle/phantomjs-node/issues/203) 49 | //_this.phantom.page.set('onResourceRequested', function(requestData, networkRequest) { 50 | // if (_this.config.ignoreAssets.test(requestData.url)) { 51 | // _this.logger.log('error', "Ignored the following resource request:", requestData.url); 52 | // networkRequest.abort(); 53 | // } 54 | //}); 55 | _this.phantom.page.onResourceRequested(function(requestData, networkRequest) { 56 | if (/google-analytics\.com|fonts\.googleapis\.com|typekit\.com|platform\.twitter\.com|connect\.facebook\.net|apis\.google\.com|\.css(?:\?|$)/.test(requestData.url)) { 57 | networkRequest.abort(); 58 | } 59 | }, function(requestData) {}); 60 | 61 | _this.phantom.page.set('onResourceError', function(error) { 62 | if (error.url != '') { 63 | _this.logger.log('error', "Phantom encountered an error loading a resource:", error); 64 | } 65 | }); 66 | 67 | _this.phantom.page.set('viewportSize', { 68 | width: 1024, 69 | height: 768 70 | }); 71 | 72 | _this.phantom.page.set('onInitialized', function() { 73 | _this.phantom.page.evaluate(function() { 74 | window.isPrerender = true; 75 | document.addEventListener('XContentReady', function() { 76 | window.callPhantom(); 77 | }, false); 78 | }); 79 | }); 80 | 81 | beforeInitCallback(function() { 82 | _this.phantom.page.open(appUrl); 83 | }); 84 | }); 85 | }); 86 | }; 87 | 88 | /* 89 | * Load a route 90 | */ 91 | PhantomEngine.prototype.loadRoute = function(page, callback) { 92 | this.currentPage = page; 93 | this.pageCallback = callback; 94 | this.hasPageCallback = true; 95 | 96 | clearTimeout(this.contentReadyTimer); 97 | 98 | this.phantom.page.evaluate(function(url) { 99 | window.prerenderTransitionEvent.url = url; 100 | document.dispatchEvent(window.prerenderTransitionEvent); 101 | }, null, page.url); 102 | }; 103 | 104 | /* 105 | * Callback handler for when a page finishes rendering 106 | */ 107 | PhantomEngine.prototype.onPageReady = function() { 108 | var _this = this; 109 | 110 | if (this.hasInitializationCallback) { 111 | this.hasInitializationCallback = false; 112 | this.initializationCallback(); 113 | } else { 114 | this.contentReadyTimer = setTimeout(function() { 115 | _this.phantom.page.evaluate( 116 | function() { 117 | var html = document.documentElement.outerHTML; 118 | if (document.doctype) { 119 | html = "\n" + html; 120 | } 121 | return html; 122 | }, 123 | function (html) { 124 | if (_this.hasPageCallback) { 125 | _this.hasPageCallback = false; 126 | _this.currentPage.statusCode = 200; 127 | _this.currentPage.html = html; 128 | _this.pageCallback(_this.currentPage); 129 | } 130 | } 131 | ); 132 | }, this.config.contentReadyDelay); 133 | } 134 | }; 135 | 136 | /* 137 | * Destroy the phantom process 138 | */ 139 | PhantomEngine.prototype.shutdown = function() { 140 | clearTimeout(this.contentReadyTimer); 141 | if (this.phantom && this.phantom.ph) { 142 | this.phantom.ph.exit(); 143 | } 144 | }; 145 | 146 | module.exports = PhantomEngine; 147 | -------------------------------------------------------------------------------- /lib/engines/jsdom.js: -------------------------------------------------------------------------------- 1 | var jsdom = require('jsdom'); 2 | var _ = require('lodash'); 3 | var d = require('domain').create(); 4 | 5 | function JSDomEngine(config, logger) { 6 | this.config = config; 7 | this.logger = logger; 8 | this.config.engineSettings = { 9 | FetchExternalResources: ['script', 'iframe'], 10 | ProcessExternalResources: ['script', 'iframe'], 11 | SkipExternalResources: this.config.ignoreAssets, 12 | MutationEvents: '2.0', 13 | QuerySelector: false 14 | }; 15 | } 16 | 17 | /* 18 | * Initialize the page 19 | */ 20 | JSDomEngine.prototype.init = function(appUrl, initCallback, errorCallback, beforeInitCallback) { 21 | var _this = this; 22 | 23 | this.initializationCallback = initCallback; 24 | this.hasInitializationCallback = true; 25 | this.errorCallback = errorCallback; 26 | this.beforeInitCallback = beforeInitCallback; 27 | this.contentReadyTimer = null; 28 | 29 | d.on('error', function(error) { 30 | _this.logger.log('error', 'JSDOM encountered a fatal error:', error.message); 31 | process.exit(1); 32 | }); 33 | 34 | d.run(function() { 35 | try { 36 | _this.beforeInitCallback(function() { 37 | jsdom.env({ 38 | url: appUrl, 39 | features: _this.config.engineSettings, 40 | done: function(errors, window) { 41 | _this.window = window; 42 | _this.document = window.document; 43 | 44 | _this.document.addEventListener('XContentReady', _.bind(_this.onPageReady, _this)); 45 | _this.window = _this.document.parentWindow; 46 | _this.window.isPrerender = true; 47 | //_this.window.onerror = this.errorCallback; // Not implemented by JSDOM 48 | _this.window.resizeTo(1024, 768); 49 | _this.window.navigator.mimeTypes = []; // Not implememented by JSDOM 50 | _this.bindConsole(); 51 | } 52 | }); 53 | }); 54 | } catch (error) { 55 | _this.errorCallback(error.message); 56 | } 57 | }); 58 | }; 59 | 60 | /* 61 | * Load a route 62 | */ 63 | JSDomEngine.prototype.loadRoute = function(page, callback) { 64 | this.currentPage = page; 65 | this.pageCallback = callback; 66 | this.hasPageCallback = true; 67 | 68 | clearTimeout(this.contentReadyTimer); 69 | 70 | var _this = this; 71 | 72 | // XXX: JSDOM does not currently support push state so update window.location manually 73 | var urlParts = page.url.split('?'); 74 | this.window.location.href = this.config.appUrl.substr(0, this.config.appUrl.length - 1) + urlParts[0]; 75 | this.window.location.search = urlParts[1] || ''; 76 | 77 | d.run(function() { 78 | try { 79 | _this.window.prerenderTransitionEvent.url = page.url; 80 | _this.window.document.dispatchEvent(_this.window.prerenderTransitionEvent); 81 | } catch (error) { 82 | _this.logger.log('error', 'JSDOM encountered an error while loading the route:', error.message); 83 | } 84 | }); 85 | }; 86 | 87 | /* 88 | * Callback handler for when a page finishes loading 89 | */ 90 | JSDomEngine.prototype.onPageReady = function() { 91 | var _this = this; 92 | 93 | if (this.hasInitializationCallback) { 94 | this.hasInitializationCallback = false; 95 | this.initializationCallback(); 96 | } else { 97 | this.contentReadyTimer = setTimeout(function() { 98 | if (_this.hasPageCallback) { 99 | _this.hasPageCallback = false; 100 | var html = _this.window.document.documentElement.outerHTML; 101 | if (_this.window.document.doctype) { 102 | html = "\n" + html; 103 | } 104 | _this.currentPage.statusCode = 200; 105 | _this.currentPage.html = html; 106 | _this.pageCallback(_this.currentPage); 107 | } 108 | }, this.config.contentReadyDelay); 109 | } 110 | }; 111 | 112 | /* 113 | * Destroy the jsdom document 114 | */ 115 | JSDomEngine.prototype.shutdown = function() { 116 | clearTimeout(this.contentReadyTimer); 117 | this.window.close(); 118 | clearInterval(this.errorTimer); 119 | }; 120 | 121 | /* 122 | * Bind JSDom console logging output to PrerenderLogger debug log 123 | */ 124 | JSDomEngine.prototype.bindConsole = function() { 125 | var _this = this; 126 | 127 | var methods = ['log', 'debug', 'info', 'warn', 'error']; 128 | 129 | methods.forEach(function(method) { 130 | _this.window.console[method] = function() { 131 | var args = [].slice.call(arguments); 132 | args.unshift('debug', '>>>'); 133 | return _this.logger.log.apply(_this.logger, args); 134 | }; 135 | }); 136 | 137 | // Error messages are currently a special case 138 | this.errorTimer = setInterval(_.bind(this.logErrors, this), 2000); 139 | }; 140 | 141 | /* 142 | * Log script errors 143 | */ 144 | JSDomEngine.prototype.logErrors = function() { 145 | var _this = this; 146 | 147 | if (this.document.errors.length > 0) { 148 | this.document.errors.forEach(function(error) { 149 | if (error.message.indexOf('NOT IMPLEMENTED') === -1) { 150 | //console.log(error); 151 | _this.logger.log('error', error.message); 152 | } 153 | }); 154 | this.document.errors = []; 155 | } 156 | }; 157 | 158 | module.exports = JSDomEngine; 159 | -------------------------------------------------------------------------------- /lib/server.js: -------------------------------------------------------------------------------- 1 | var PrerenderLogger = require('./logger'); 2 | var PrerenderRenderer = require('./renderer'); 3 | var _ = require('lodash'); 4 | var http = require('http'); 5 | var url = require('url'); 6 | var request = require('request'); 7 | 8 | function PrerenderServer(config) { 9 | this.config = config; 10 | this.logger = new PrerenderLogger(this.config.logging, 'Server ' + this.config.processNum); 11 | this.renderer = new PrerenderRenderer( 12 | _.extend({ 13 | initializationCallback: _.bind(this.afterRendererInit, this), 14 | terminationCallback: _.bind(this.afterRendererTerminate, this) 15 | }, this.config) 16 | ); 17 | this.queue = []; 18 | } 19 | 20 | /* 21 | * Start the renderer and server 22 | */ 23 | PrerenderServer.prototype.start = function() { 24 | this.logger.log('server', "Server listening on port " + (this.config.port + this.config.processNum)); 25 | this.server = http.createServer(_.bind(this.onRequest, this)).listen((this.config.port + this.config.processNum)); 26 | 27 | this.logger.log('server', "Starting rendering engine"); 28 | this.renderer.startEngine(); 29 | }; 30 | 31 | /* 32 | * Check the queue after the renderer finishes initializing 33 | */ 34 | PrerenderServer.prototype.afterRendererInit = function() { 35 | this.processQueue(); 36 | }; 37 | 38 | /* 39 | * Stop accepting connections and exit the process when the renderer terminates 40 | */ 41 | PrerenderServer.prototype.afterRendererTerminate = function() { 42 | if (this.config.gracefulExit) { 43 | this.server.close(function() { 44 | process.exit(); 45 | }); 46 | } else { 47 | process.exit(); 48 | } 49 | } 50 | 51 | /* 52 | * Handle a server request 53 | */ 54 | PrerenderServer.prototype.onRequest = function(req, res) { 55 | var addr = req.headers['x-forwarded-for'] || req.connection.remoteAddress; 56 | var agent = req.headers['user-agent'] || "Unknown"; 57 | var user = addr + ' (' + agent + ')'; 58 | var reqUrl = this.parseURL(req.url); 59 | 60 | req.once('end', function() { 61 | // Close the socket once the response is sent to avoid keepalive issues 62 | req.connection.destroy(); 63 | }); 64 | 65 | if (req.method !== 'GET') { 66 | // Not a valid request method 67 | this.logger.log('error', user + " -> Received an unsupported " + req.method + " request: " + reqUrl); 68 | res.writeHead(405, {'Content-Type': 'text/html;charset=UTF-8'}); 69 | res.end("405 Method Not Allowed"); 70 | } else if (reqUrl.match(this.config.filesMatch)) { 71 | // Serve a static file 72 | if (this.config.serveFilesLog) { 73 | this.logger.log('server', user + " -> Serving file: " + reqUrl); 74 | } 75 | this.serveFile(req, res, reqUrl); 76 | } else { 77 | // Enqueue a rendering job 78 | this.logger.log('server', user + " -> Enqueueing route: " + reqUrl); 79 | this.enqueueJob(req, res, user, reqUrl); 80 | } 81 | }; 82 | 83 | /* 84 | * Enqueue a rendering job 85 | */ 86 | PrerenderServer.prototype.enqueueJob = function(req, res, user, reqUrl) { 87 | if (this.queue.length > this.config.maxQueueSize) { 88 | this.logger.log('error', user + " -> Request failed, queue reached the maximum configured size: " + reqUrl); 89 | res.writeHead(503, {'Content-Type': 'text/html;charset=UTF-8'}); 90 | res.end("503 Service Unvailable"); 91 | } else { 92 | this.queue.push({ 93 | req: req, 94 | res: res, 95 | queueTime: process.hrtime(), 96 | user: user, 97 | page: { 98 | url: reqUrl, 99 | statusCode: 500, // Default code in the event of a rendering error 100 | html: "500 Internal Server Error" 101 | }, 102 | callback: _.bind(this.sendPage, this) 103 | }); 104 | this.processQueue(); 105 | } 106 | }; 107 | 108 | /* 109 | * Process any jobs in the queue 110 | */ 111 | PrerenderServer.prototype.processQueue = function() { 112 | if (!this.renderer.busy && this.queue.length > 0) { 113 | var job = this.queue.shift(); 114 | job.startTime = process.hrtime(); 115 | this.logger.log('server', job.user + " -> Rendering route: " + job.page.url); 116 | this.renderer.renderPage(job); 117 | } 118 | }; 119 | 120 | /* 121 | * Send the rendered page 122 | */ 123 | PrerenderServer.prototype.sendPage = function(job) { 124 | var totalDuration = this.hrtimeToMs(process.hrtime(job.queueTime)); 125 | var renderDuration = this.hrtimeToMs(process.hrtime(job.startTime)); 126 | var queueDuration = parseInt(totalDuration - renderDuration, 10); 127 | 128 | this.logger.log('server', job.user + " -> Rendered page in " + totalDuration + "ms " + 129 | "(" + queueDuration + "ms in queue + " + renderDuration + "ms rendering) " + 130 | "with status code " + job.page.statusCode + ": " + job.page.url); 131 | 132 | job.res.setHeader('Content-Length', Buffer.byteLength(job.page.html, 'utf8')); 133 | job.res.writeHead(job.page.statusCode, {'Content-Type': 'text/html;charset=UTF-8'}); 134 | job.res.end(job.page.html); 135 | 136 | this.renderer.jobFinished(job); 137 | this.processQueue(); 138 | }; 139 | 140 | /* 141 | * Serve a static file 142 | */ 143 | PrerenderServer.prototype.serveFile = function(req, res, reqUrl) { 144 | if (this.config.serveFiles) { 145 | var url = this.config.appUrl + reqUrl.substr(1); 146 | request(url).pipe(res); 147 | } else { 148 | res.writeHead(500, {'Content-Type': 'text/html;charset=UTF-8'}); 149 | res.end("500 Internal Server Error"); 150 | } 151 | }; 152 | 153 | /* 154 | * Parse the full url into the path and query string 155 | */ 156 | PrerenderServer.prototype.parseURL = function(reqURL) { 157 | var parts = url.parse(reqURL, true); 158 | if (parts.query._escaped_fragment_) { 159 | parts.hash = '#!' + parts.query._escaped_fragment_; 160 | delete parts.query._escaped_fragment_; 161 | delete parts.search; 162 | } 163 | return url.format(parts); 164 | }; 165 | 166 | /* 167 | * Convert hrtime to milliseconds 168 | */ 169 | PrerenderServer.prototype.hrtimeToMs = function(hr) { 170 | return (hr[0] * 1000 + parseInt(hr[1] / 1000000, 10)); 171 | }; 172 | 173 | module.exports = PrerenderServer; 174 | -------------------------------------------------------------------------------- /lib/renderer.js: -------------------------------------------------------------------------------- 1 | var PrerenderLogger = require('./logger'); 2 | var _ = require('lodash'); 3 | 4 | function PrerenderRenderer(config) { 5 | this.config = config; 6 | this.logger = new PrerenderLogger(this.config.logging, 'Renderer ' + this.config.processNum); 7 | this.plugins = this.loadPlugins(); 8 | this.engine = this.loadEngine(); 9 | 10 | process.on('SIGUSR2', _.bind(function() { 11 | this.logger.log('renderer', "Received SIGUSR2 signal, restarting rendering engine"); 12 | this.restartEngine(); 13 | }, this)); 14 | } 15 | 16 | /* 17 | * Initialize the renderer and associated rendering engine (JSDOM, PhantomJS, WebDriverJs) 18 | */ 19 | PrerenderRenderer.prototype.startEngine = function() { 20 | var _this = this; 21 | 22 | this.logger.log('renderer', "Engine starting up (" + this.config.engine + ")"); 23 | this.numRequests = 0; 24 | this.startTime = process.hrtime(); 25 | this.initializeTimer = setTimeout(_.bind(this.onInitializeTimeout, this), this.config.initializeTimeout); 26 | this.busy = true; 27 | 28 | _this.engine.init( 29 | this.config.appUrl, 30 | _.bind(_this.afterEngineInit, _this), 31 | _.bind(_this.onEngineError, _this), 32 | _.bind(_this.beforeEngineInit, _this) 33 | ); 34 | }; 35 | 36 | /* 37 | * Shutdown the rendering engine 38 | */ 39 | PrerenderRenderer.prototype.stopEngine = function() { 40 | this.logger.log('renderer', "Engine shutting down"); 41 | 42 | clearTimeout(this.initializeTimer); 43 | clearTimeout(this.renderTimer); 44 | this.initializeTimer = null; 45 | this.renderTimer = null; 46 | 47 | if (this.job) { 48 | this.logger.log('error', "Engine stopped before rendering: " + this.job.page.url); 49 | this.terminateActiveJob(); 50 | } 51 | this.engine.shutdown(); 52 | }; 53 | 54 | /* 55 | * Restart the rendering engine 56 | */ 57 | PrerenderRenderer.prototype.restartEngine = function() { 58 | this.stopEngine(); 59 | this.startEngine(); 60 | }; 61 | 62 | PrerenderRenderer.prototype.terminateActiveJob = function() { 63 | if (this.job) { 64 | this.job.page.statusCode = 503; 65 | this.job.page.html = '503 Service Unavailable'; 66 | this.job.callback(this.job); 67 | } 68 | }; 69 | 70 | /* 71 | * Handle abnormal rendering engine exits 72 | */ 73 | PrerenderRenderer.prototype.onEngineError = function(msg, trace) { 74 | if (this.initializeTimer) { 75 | this.logger.log('error', "Restarting rendering engine in " + this.config.initializeTimeout + " seconds after it failed with error:", msg, trace); 76 | } else { 77 | this.logger.log('error', "Restarting rendering engine after it failed with error:", msg, trace); 78 | this.restartEngine(); 79 | } 80 | }; 81 | 82 | /* 83 | * Engine's page object created 84 | */ 85 | PrerenderRenderer.prototype.beforeEngineInit = function(callback) { 86 | this.pluginEvent('beforeEngineInit', [this, this.engine], callback); 87 | }; 88 | 89 | /* 90 | * Rendering engine initialization finished 91 | */ 92 | PrerenderRenderer.prototype.afterEngineInit = function() { 93 | clearTimeout(this.initializeTimer); 94 | this.initializeTimer = null; 95 | 96 | var duration = this.hrtimeToMs(process.hrtime(this.startTime)); 97 | this.logger.log('renderer', "Renderer initialized after " + duration + "ms"); 98 | this.busy = false; 99 | this.config.initializationCallback(); 100 | }; 101 | 102 | /* 103 | * Serve a page/route from the rendering engine 104 | */ 105 | PrerenderRenderer.prototype.renderPage = function(job) { 106 | var _this = this; 107 | 108 | this.busy = true; 109 | this.job = job; 110 | this.numRequests++; 111 | 112 | var req = this.job.req; 113 | var res = this.job.res; 114 | var page = this.job.page; 115 | 116 | this.pluginEvent('beforeRender', [req, res, page], function() { 117 | _this.job.req = req; 118 | _this.job.res = res; 119 | _this.job.page = page; 120 | 121 | if (_this.job.page.statusCode < 500) { 122 | _this.logger.log('renderer', "Skipped rendering, cached page returned by plugin: " + _this.job.page.url); 123 | _this.busy = false; 124 | _this.job.callback(_this.job); 125 | } else { 126 | _this.logger.log('renderer', "Rendering: " + _this.job.page.url); 127 | _this.renderTimer = setTimeout(_.bind(_this.onRenderTimeout, _this), _this.config.renderTimeout); 128 | _this.engine.loadRoute(_this.job.page, _.bind(_this.afterRender, _this)); 129 | } 130 | }); 131 | }; 132 | 133 | /* 134 | * Run post-processing plugins on the page object and notify master when page 135 | * rendering has completed 136 | */ 137 | PrerenderRenderer.prototype.afterRender = function(page) { 138 | var _this = this; 139 | 140 | clearTimeout(this.renderTimer); 141 | this.renderTimer = null; 142 | 143 | this.logger.log('renderer', "Rendering finished"); 144 | if (this.job) { 145 | var req = this.job.req; 146 | var res = this.job.res; 147 | 148 | this.pluginEvent('beforeSend', [req, res, page], function() { 149 | _this.job.req = req; 150 | _this.job.res = res; 151 | _this.job.page = page; 152 | 153 | if (_this.numRequests >= _this.config.maxRequestsPerRenderer) { 154 | if (_this.config.exitAfterMaxRequests) { 155 | _this.logger.log('error', "Rendering engine reached the maximum allowed number of requests, exiting process"); 156 | if (_this.config.gracefulExit) { 157 | _this.numRequests = 0; 158 | _this.busy = false; 159 | _this.job.callback(_this.job); 160 | } 161 | _this.config.terminationCallback(); 162 | } else { 163 | _this.logger.log('error', "Rendering engine reached the maximum allowed number of requests, restarting engine"); 164 | _.bind(_this.restartEngine, _this)(); 165 | } 166 | } else { 167 | _this.busy = false; 168 | _this.job.callback(_this.job); 169 | } 170 | }); 171 | } else { 172 | this.busy = false; 173 | } 174 | }; 175 | 176 | /* 177 | * Job finished and the response has been sent 178 | */ 179 | PrerenderRenderer.prototype.jobFinished = function(job) { 180 | this.job = null; 181 | this.pluginEvent('jobFinished', [job]); 182 | }; 183 | 184 | /* 185 | * Handle renderer initialization timeouts 186 | */ 187 | PrerenderRenderer.prototype.onInitializeTimeout = function() { 188 | this.logger.log('error', "Restarting renderer, timed out while initializing"); 189 | this.restartEngine(); 190 | }; 191 | 192 | /* 193 | * Handle rendering timeouts 194 | */ 195 | PrerenderRenderer.prototype.onRenderTimeout = function() { 196 | var _this = this; 197 | 198 | if (this.job) { 199 | this.logger.log('error', "Timed out while rendering: " + this.job.page.url); 200 | this.terminateActiveJob(); 201 | } 202 | 203 | this.pluginEvent('onRenderTimeout', [_this, this.job], function() { 204 | _this.busy = false; 205 | }); 206 | }; 207 | 208 | /* 209 | * Load and return the plugins 210 | */ 211 | PrerenderRenderer.prototype.loadPlugins = function() { 212 | var _this = this; 213 | var plugins = []; 214 | 215 | this.config.plugins.forEach(function(plugin) { 216 | if (typeof plugin === 'string') { 217 | plugin = require('./plugins/' + plugin); 218 | } 219 | 220 | plugins.push(plugin); 221 | 222 | if (typeof plugin.init === 'function') { 223 | plugin.init(_this); 224 | } 225 | }); 226 | return plugins; 227 | }; 228 | 229 | /* 230 | * Execute methodName on each plugin 231 | */ 232 | PrerenderRenderer.prototype.pluginEvent = function(methodName, args, callback) { 233 | var _this = this; 234 | var index = 0; 235 | 236 | callback = callback || function() {}; 237 | 238 | var next = function() { 239 | var layer = _this.plugins[index++]; 240 | if (!layer) { 241 | return callback(); 242 | } 243 | var method = layer[methodName]; 244 | if (method) { 245 | method.apply(layer, args); 246 | } else { 247 | next(); 248 | } 249 | }; 250 | 251 | args.push(next); 252 | next(); 253 | }; 254 | 255 | /* 256 | * Start the rendering engine 257 | */ 258 | PrerenderRenderer.prototype.loadEngine = function() { 259 | var PrerenderEngine; 260 | switch (this.config.engine) { 261 | case 'jsdom': 262 | PrerenderEngine = require('./engines/jsdom.js'); 263 | break; 264 | case 'phantom': 265 | PrerenderEngine = require('./engines/phantom.js'); 266 | break; 267 | case 'webdriver': 268 | PrerenderEngine = require('./engines/webdriver.js'); 269 | break; 270 | default: 271 | this.logger.log('error', "No engine was specified, valid options: jsdom, phantom, webdriver"); 272 | process.exit(2); 273 | } 274 | return new PrerenderEngine(this.config, this.logger); 275 | }; 276 | 277 | /* 278 | * Convert hrtime to milliseconds 279 | */ 280 | PrerenderRenderer.prototype.hrtimeToMs = function(hr) { 281 | return (hr[0] * 1000 + parseInt(hr[1] / 1000000, 10)); 282 | }; 283 | 284 | module.exports = PrerenderRenderer; 285 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Unmaintained: Ember Prerender # 2 | 3 | [![Code Climate](https://codeclimate.com/github/zipfworks/ember-prerender.png)](https://codeclimate.com/github/zipfworks/ember-prerender) 4 | [![Dependency Status](https://gemnasium.com/zipfworks/ember-prerender.svg)](https://gemnasium.com/zipfworks/ember-prerender) 5 | [![Stories in Ready](https://badge.waffle.io/zipfworks/ember-prerender.png?label=ready&title=Ready)](https://waffle.io/zipfworks/ember-prerender) 6 | 7 | This project allows web apps built with [Ember.js](http://emberjs.com/) (and other 8 | frameworks) to be executed on the server and rendered into static HTML. The main 9 | reason you'd want to use ember-prerender is to serve static HTML content to web 10 | crawlers and bots which aren't capable of executing Javascript. This is useful 11 | for SEO purposes, such as general indexing of page content, Facebook's Link Preview, 12 | Pinterest's Rich Pins, Twitter Cards, Google's Rich Snippets, and other structured 13 | data formats. 14 | 15 | The project makes use of [Node.js](http://nodejs.org/) and 16 | [JSDOM](https://github.com/tmpvar/jsdom), [PhantomJS](http://phantomjs.org/), 17 | or [WebDriverJS](https://code.google.com/p/selenium/wiki/WebDriverJs) 18 | based on your requirements and preference. Note: WebDriver support is still 19 | experimental and mostly useful for debugging. 20 | 21 | The concept and plugin code is based loosely off of 22 | the [Prerender Service](https://github.com/collectiveip/prerender) by Todd Hooper. 23 | Unlike the Prerender Service, the goal of ember-prerender is to reduce rendering times 24 | by utilizing a long-lived instance of an app instead of reloading it on every request. 25 | In addition, you have the flexibility of using JSDOM or WebDriverJs instead of PhantomJS. 26 | 27 | Although the current focus of this project is to support Ember apps, the code is 28 | completely decoupled from Ember.js and can be used with Angular, Backbone, Knockout, 29 | jQuery, etc. (assuming your app implements the XPushState and XContentReady events 30 | described in this README). In the future, this project may be more 31 | closely coupled with [HTMLBars](https://github.com/tildeio/htmlbars) / 32 | [Bound Templates](https://github.com/tildeio/bound-templates.js). 33 | 34 | ## Usage ## 35 | 36 | Install ember-prerender: 37 | 38 | From npm (https://www.npmjs.org/package/ember-prerender): 39 | 40 | $ sudo npm install -g ember-prerender 41 | 42 | Or, if you prefer to get it directly from github: 43 | 44 | $ sudo npm install -g zipfworks/ember-prerender 45 | 46 | Copy or edit the default configuration file (in /config/) to match your 47 | app's environment. 48 | 49 | Run the service with the path to your configuration file: 50 | 51 | $ ember-prerender config/default.js [optional process num] 52 | 53 | If you're invoking ember-prerender directly from the cloned repository, 54 | you can do this instead: 55 | 56 | $ export CONFIG="./your-app-config.js" 57 | $ export PROCESS_NUM=0 58 | $ node server.js 59 | 60 | Test the prerender service by visiting it in your browser at 61 | [http://localhost:3000](http://localhost:3000) (default). 62 | 63 | ## Configuration Options ## 64 | 65 | Configuration files should be in javascript module format, the following is an 66 | annotated version of a complete config file. 67 | 68 | ``` 69 | module.exports = { 70 | // The port that prerender runs on (Phantom will use additional ports) 71 | port: 3000, 72 | 73 | // Process number (starting from 0) which is added to the above port, used when running multiple instances 74 | processNum: 0, 75 | 76 | // Can be: jsdom, phantom, or webdriver 77 | engine: "phantom", 78 | 79 | // Milliseconds to wait after the page load but before getting the HTML 80 | contentReadyDelay: 0, 81 | 82 | // Maximum milliseconds to wait before the initial app load times out 83 | initializeTimeout: 25000, 84 | 85 | // Maximum milliseconds to wait before a render job times out 86 | renderTimeout: 15000, 87 | 88 | // Maximum number of requests a worker can handle before it's restarted 89 | maxRequestsPerRenderer: 200, 90 | 91 | // Whether to restart a renderer gracefully or exit the process after reaching maxRequestsPerRenderer 92 | // Note: Exiting the process and having something like supervisor automatically restart it can 93 | // help avoid memory leaks which seems to affect the JSDOM engine 94 | // This could be investigated with: https://github.com/lloyd/node-memwatch 95 | exitAfterMaxRequests: false, 96 | 97 | // If exitAfterMaxRequets is true, setting this to true will cause all 98 | // queued requests to be rendered before the process is terminated 99 | gracefulExit: true, 100 | 101 | // Maximum number of rendering requests to queue up before dropping new ones 102 | maxQueueSize: 1000, 103 | 104 | // Your app's default URL 105 | appUrl: "http://localhost/", 106 | 107 | // Serve static files 108 | serveFiles: true, 109 | 110 | // Log requests for static files 111 | serveFilesLog: true, 112 | 113 | // Regular expression of static file patterns 114 | filesMatch: /\.(?:css|js|jpg|png|gif|ico|svg|woff|woff2|ttf|swf|map)(?:\?|$)/, 115 | 116 | // Regular expression containing assets you don't want to download or process 117 | ignoreAssets: /google-analytics\.com|fonts\.googleapis\.com|typekit\.com|platform\.twitter\.com|connect\.facebook\.net|apis\.google\.com|\.css(?:\?|$)/, 118 | 119 | logging: { 120 | // Logging verbosity 121 | "level": "debug", 122 | 123 | // Add a timestamp to logs 124 | "timestamp": true, 125 | 126 | // Add color formatting to logs 127 | "format": true 128 | }, 129 | 130 | // Available plugins: 131 | plugins: [ 132 | "removeScriptTags", 133 | "httpHeaders", 134 | //"prepareEmail", 135 | //"prettyPrintHtml", 136 | //"minifyHtml", 137 | //"inMemoryHtmlCache", 138 | //"s3HtmlCache", 139 | //require('./your-own-plugin.js') 140 | ] 141 | } 142 | ``` 143 | 144 | ## Example Ember.js Project ## 145 | 146 | If you want to see ember-prerender in action, check out the example project at: 147 | [https://github.com/zipfworks/ember-prerender/tree/master/example](https://github.com/zipfworks/ember-prerender/tree/master/example) 148 | 149 | The example demonstrates the following use cases: 150 | 151 | * Rendering a page that loads an external resource 152 | * Returning 404 headers 153 | * Returning 301 redirects 154 | * Recovering from fatal Javascript errors 155 | * Updating the page title and meta tags for each route 156 | 157 | ## XPushState and XContentReady Events ## 158 | 159 | Your application must accept the XPushState event with a 'url' 160 | property on the event. After receiving the event, your app should 161 | transition to the route that matches the URL. After the route has loaded, 162 | your must emit the 163 | [XContentReady](https://github.com/n-fuse/the-XContentReady-Event/) event 164 | to let ember-prerender know that the page is ready. 165 | 166 | To find out more about implmenting the events, the best place to start is 167 | by looking at the initializers and mixins in the example project of this 168 | repository. 169 | 170 | ### Example Configuration (CoffeeScript) ### 171 | 172 | Add to: app/initialize.coffee 173 | ```CoffeeScript 174 | # Prerender event 175 | if document.createEvent 176 | window.prerenderReadyEvent = document.createEvent('Event') 177 | window.prerenderReadyEvent.initEvent('XContentReady', false, false) 178 | 179 | window.prerenderTransitionEvent = document.createEvent('Event') 180 | window.prerenderTransitionEvent.initEvent('XPushState', false, false) 181 | 182 | App.prerenderReady = -> 183 | console.log('PRERENDER READY') 184 | document.dispatchEvent(window.prerenderReadyEvent) 185 | 186 | document.addEventListener('XPushState', (event) -> 187 | router = App.__container__.lookup 'router:main' 188 | Ember.run -> 189 | router.replaceWith(event.url).then (route) -> 190 | if route.handlerInfos 191 | // The requested route was already loaded 192 | App.prerenderReady() 193 | , false) 194 | ``` 195 | 196 | In your routes (tested with Ember 1.4, 1.5, 1.6, and 1.7): 197 | ```CoffeeScript 198 | # Promise hook for when a page has loaded, can be overridden in subclasses 199 | willComplete: -> Em.RSVP.resolve() 200 | 201 | actions: 202 | didTransition: -> 203 | @_super() 204 | promises = [] 205 | for handler in @router.router.currentHandlerInfos 206 | if handler.handler.willComplete 207 | promises.push handler.handler.willComplete() 208 | Ember.RSVP.all(promises).then App.prerenderReady 209 | ``` 210 | Instead of adding this to each of your routes, you can extend Ember.Route to 211 | create a base route or use Ember.Route.reopen to change the default behavior. 212 | 213 | Depending on your app, you may need to postpone firing the XContentReady event 214 | by overriding willTransition. You can do so by returning a deferred promise 215 | and resolving it after the other parts of the page have loaded. 216 | 217 | To detect whether your app is being loaded in a browser or through prerender, 218 | you can check the window.isPrerender variable which is set to true by 219 | ember-prerender. 220 | 221 | ## Search Engine Support ## 222 | 223 | Google is now executing Javascript pages directly, however, you may wish 224 | to inform Google about ember-prerender's HTML snapshots by adding the 225 | "fragment" meta tag. For push state apps, the tag looks like this: 226 | 227 | `````` 228 | 229 | Please visit Google's [AJAX Crawling documentation]( 230 | https://developers.google.com/webmasters/ajax-crawling/docs/getting-started) 231 | for more information. 232 | 233 | Bing and Yandex also support the "fragment" meta tag. In addition, most 234 | of Google's bots support the tag, including Googlebot, Googlebot Mobile, 235 | AdsBot-Google and Googlebot-Image. 236 | 237 | ## Running ## 238 | 239 | You may manually start ember-prerender or preferably use 240 | [supervisord](http://supervisord.org/), forever, foreman, upstart, etc to 241 | start, stop, restart, and monitor ember-prerender. 242 | 243 | If your web application changes, you can send a SIGUSR2 signal to the 244 | master prerender process to cause the page to be reloaded. 245 | 246 | The following is an example supervisord configuration file which should be 247 | placed in /etc/supervisor/conf.d/: 248 | 249 | ``` 250 | [program:prerender-yourappname] 251 | command = ember-prerender /mnt/ebs1/www/yourappname/conf/prerender.js %(process_num)d 252 | directory = /mnt/ebs1/www/yourappname 253 | user = yourappname 254 | autostart = true 255 | autorestart = true 256 | stopasgroup = true 257 | stdout_logfile = /mnt/ebs1/www/yourappname/logs/prerender.log 258 | stderr_logfile = /mnt/ebs1/www/yourappname/logs/prerender.error.log 259 | process_name = %(program_name)s_%(process_num)02d 260 | numprocs = 1 261 | ``` 262 | 263 | ## Web Server Setup ## 264 | 265 | Once Ember Prerender is working with your project, you'll probably 266 | want to enable prerendering for certain user agents (e.g. web crawlers) 267 | while serving Javascript for compatible browsers. One way to do this 268 | is by setting up a reverse proxy, such as nginx, haproxy, 269 | apache, squid, etc. 270 | 271 | ### Nginx Reverse Proxy + Load Balancer Setup ### 272 | 273 | Example configuration (you can add additional instances to the upstream 274 | backend for load balancing): 275 | 276 | ```Nginx 277 | upstream prerender-yourappname-backend { 278 | #ip_hash; 279 | #least_conn; 280 | server localhost:3000; 281 | #server localhost:3001; 282 | #server localhost:3002; 283 | #server localhost:3003; 284 | } 285 | 286 | server { 287 | listen 80; 288 | listen [::]:80; 289 | server_name yourserver.com; 290 | 291 | root /path/to/your/htdocs; 292 | 293 | error_page 404 /404.html 294 | index index.html; 295 | 296 | location ~ /\. { 297 | deny all; 298 | } 299 | 300 | location / { 301 | try_files $uri @prerender; 302 | } 303 | 304 | location @prerender { 305 | proxy_set_header Host $host; 306 | proxy_set_header X-Real-IP $remote_addr; 307 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 308 | #proxy_intercept_errors on; 309 | proxy_next_upstream error timeout; 310 | 311 | set $prerender 0; 312 | if ($http_user_agent ~* "baiduspider|yeti|yodaobot|gigabot|ia_archiver|facebookexternalhit|twitterbot|pinterest|tumblr|bingpreview|shopwiki|duckduckbot|rogerbot|slackbot|linkedinbot|embedly|quora link preview|showyoubot|outbrain|developers\.google\.com/\+/") { 313 | set $prerender 1; 314 | } 315 | if ($args ~ "_escaped_fragment_=|prerender=1") { 316 | set $prerender 1; 317 | } 318 | if ($http_user_agent ~ "Prerender") { 319 | set $prerender 0; 320 | } 321 | 322 | if ($prerender = 1) { 323 | proxy_pass http://prerender-yourappname-backend; 324 | } 325 | if ($prerender = 0) { 326 | rewrite .* /index.html break; 327 | } 328 | } 329 | } 330 | ``` 331 | 332 | ## License ## 333 | 334 | The MIT License (MIT) 335 | 336 | Copyright (c) 2013-2014 ZipfWorks Inc 337 | 338 | Permission is hereby granted, free of charge, to any person obtaining a copy 339 | of this software and associated documentation files (the "Software"), to deal 340 | in the Software without restriction, including without limitation the rights 341 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 342 | copies of the Software, and to permit persons to whom the Software is 343 | furnished to do so, subject to the following conditions: 344 | 345 | The above copyright notice and this permission notice shall be included in 346 | all copies or substantial portions of the Software. 347 | 348 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 349 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 350 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 351 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 352 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 353 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 354 | THE SOFTWARE. 355 | --------------------------------------------------------------------------------