Skip to content

Instantly share code, notes, and snippets.

@eriktrom
Created December 16, 2015 19:05
Show Gist options
  • Save eriktrom/f3a0569c26453316ef9c to your computer and use it in GitHub Desktop.
Save eriktrom/f3a0569c26453316ef9c to your computer and use it in GitHub Desktop.
ember-fastboot-render
import Promise from 'bluebird';
import SimpleDOM from 'simple-dom';
import Wreck from 'wreck';
import Redis from 'redis';
import Contextify from 'contextify';
Promise.promisifyAll(Wreck);
Promise.promisifyAll(Redis.RedisClient.prototype);
export default {
visit(options={}) {
if (!options.url) throw Error('Must pass url option');
let { url, revision } = options;
let bootOptions = {
isBrowser: false,
document: this.doc,
rootElement: this.doc.body,
autoRun: false
};
return this._createEmberApplication(revision)
.then(app => app.visit(url, bootOptions))
.then(instance => {
let outerHtmlBody = this.htmlSerializer.serialize(instance.rootElement);
let innerHtmlBody = outerHtmlBody.slice(6, outerHtmlBody.length-7); // strip duplicate <body></body>
instance = void 0; // TODO: destroy instance, although garbage collection seems to handle this pretty well
return {
// url: instance.getUrl(), // TODO: for redirects?
// title: 'Hello World', // TODO
body: innerHtmlBody
};
});
},
replaceHtml(options={}) {
let { doc, revision } = options;
if (!doc) throw Error("'doc' option must be given");
if (!doc.body) throw Error("'doc.body' must be given");
if (!revision)
return this._htmlFile().then(html => html.replace("<!-- FASTBOOT_BUILD_BODY -->", doc.body)); // refactor
throw Error('HTML replacement failed');
},
/*
Private
*/
_createEmberApplication(revision) {
/*
When no revision is passed as a query param, we init/run the application
once and memoize it. This is what the world sees, so we want it fast/low memory
*/
if (!revision) {
if (this._emberApplication) return Promise.resolve(this._emberApplication);
return Promise.props({
vendorFile: this._vendorFile(),
appFile: this._appFile(),
sandbox: Promise.resolve(this.sandbox)
}).then(deps => {
deps.sandbox.run(deps.vendorFile);
deps.sandbox.run(deps.appFile);
let app = deps.sandbox.require('hhs-client/app').default;
let config = deps.sandbox.require('hhs-client/config/environment').default;
config.APP.autoboot = false;
// create + memoize app
// TODO: make a hapi method to put this into redis LRU cache (reduce mem footprint)
this._emberApplication = app.create(config.APP);
return this._emberApplication;
});
}
/*
Upon activation of a revision we will restart pm2, busting the memoized
current revision above.
When we want to test a revision we DO NOT memoize, we simply build a new
ember application for the revision and dereference the built application when
the request is finished. This is slower by ~500ms and takes ~100mb of extra
memory, but its just for our in house checks that before activating a revision
the site passes a manual spot check, so all is good, no real traffic will
hit this code path
*/
// if (revision) {
// return Promise.props({
// vendorFile: this._fileForRevision({ revision, fileKey: 'vendorjs' }),
// appFile: this._fileForRevision({ revision, fileKey: 'appjs' }),
// sandbox: this.sandbox
// }).then(deps => {
// deps.sandbox.run(deps.vendorFile);
// deps.sandbox.run(deps.appFile);
// let app = deps.sandbox.require('hhs-client/app').default;
// let config = deps.sandbox.require('hhs-client/config/environment').default;
// config.APP.autoboot = false;
// return app.create(config.APP);
// });
// }
throw Error('Ember application failed to initialize and run');
},
// private
/*
Return the hhs-client.js file. This file is DIFFERENT from what ember build outputs
in that it does not require certain modules (by excluding them in treeForApp hook).
TODO: The only real difference then that ember-cli-fastboot-build **does**
is output a different hhs-client.js file, which can exclude modules from
being required from vendor.js. Consider if this 'double build' system really
warrants this - we could simply wrap the same build output in !isBrowser and
be done with it. We will still need to upload all files to s3 and redis,
but that's taken care of by ember-cli-deploy-fastboot.
During development we return the default revision, which is the result of the
the last fastboot build
During production we return the 'activated' revision
*/
_appFile() {
if (this.isProd) return this._appjsForProd();
else return this._appjsForDev();
},
_appjsForDev() {
return this.redisClient.getAsync('hhs-client:appjs:default');
},
_appjsForProd() {
return this.redisClient.getAsync('hhs-client:appjs:current')
.then(this._cacheActiveRevision.bind(this))
.then(activeRevision => this._appjsForProdActiveRevision(activeRevision));
},
_appjsForProdActiveRevision(activeRevision) {
return this.redisClient.getAsync(`hhs-client:appjs:${activeRevision}`);
},
/*
Return the vendor.js file. This file is exactly what ember build outputs
TODO: refactor ember-cli-build-fastboot to not duplicate the work of the normal
build
During development we return the default revision, which is the result of the
the last fastboot build
During production we return the 'activated' revision
*/
_vendorFile() {
if (this.isProd) return this._vendorjsForProd();
else return this._vendorjsForDev();
},
_vendorjsForDev() {
return this.redisClient.getAsync('hhs-client:vendorjs:default');
},
_vendorjsForProd() {
return this.redisClient.getAsync('hhs-client:vendorjs:current')
.then(this._cacheActiveRevision.bind(this))
.then(activeRevision => this._vendorjsForProdActiveRevision(activeRevision));
},
_vendorjsForProdActiveRevision(activeRevision) {
return this.redisClient.getAsync(`hhs-client:vendorjs:${activeRevision}`);
},
/*
Return the FastBoot HTML file
During development we return the default revision, which is the result of the
the last fastboot build
During production we return the 'activated' revision
*/
_htmlFile() {
if (this.isProd) return this._htmlForProd();
else return this._htmlForDev();
},
_htmlForDev() {
return this.redisClient.getAsync('hhs-client:index:default');
},
_htmlForProd() {
return this.redisClient.getAsync('hhs-client:index:current-content');
},
_cacheActiveRevision(activeRevision) {
if (this.activeRevision) {
return this.activeRevision;
} else {
this.activeRevision = activeRevision;
return activeRevision;
}
},
/*
Return a file for a revision. Does not try to cache current revision
and thus its a dynamic function. This will only run when a query param
of ?revision=abcd is passed to the url of the Ember App.
If the provided revision does not exist, the returned page will show
a list of all the revisions available.
*/
// _fileForRevision(options={}) {
// let { revision, fileKey } = options;
// if (!revision) throw Error('Must pass a revision option');
// if (!fileKey) throw Error('Must pass a fileKey option');
// return this.redisClient.getAsync(`hhs-client:${fileKey}:${revision}`)
// .then(content => {
// if (!content) return this._showRevisionList();
// else return content;
// });
// },
// _showRevisionList() {
// return this.redisClient.zrevrangeAsync('hhs-client:index:revisions', 0, -1)
// .then(revisionList => {
// return `
// <h1>Bad Revision Number Given as Query Param. Try Again.</h1>
// ${revisionList}
// `;
// });
// },
/*
Memoized Getters
*/
get redisClient() {
if (this._redisClient) return this._redisClient;
this._redisClient = Redis.createClient();
return this._redisClient;
},
get htmlSerializer() {
if (this._htmlSerializer) return this._htmlSerializer;
this._htmlSerializer = new SimpleDOM.HTMLSerializer(SimpleDOM.voidMap);
return this._htmlSerializer;
},
/*
Live Getters
*/
get isProd() {
if (process.env.NODE_ENV === 'production') return true;
else return false;
},
// memoizing this will cache the response, for all url's, fyi
get doc() {
return new SimpleDOM.Document();
},
// can we memoize this?
get sandbox() {
let fqdn = 'http://127.0.0.1';
let localOrigin = this.isProd ? fqdn : `${fqdn}:8000`;
// build sandbox
let sandbox = {
console, // hackz - use vm.global* for fix
setTimeout, // hackz - use vm.global* for fix
clearTimeout, // hackz - use vm.global* for fix
module: { exports: {} }, // make jQuery think we're in node
matchMedia: true, // hackz - wrap liquid-fire app.import('matchMedia') for fix
Wreck,
localOrigin // hackz: use server.info.uri, but need this to be in a plugin
};
sandbox.window = sandbox;
sandbox.window.self = sandbox; // remove me after refactor
Contextify(sandbox); // die soon bitch
return sandbox;
}
};
@eriktrom
Copy link
Author

Route Handler:

import EmberApp from '../../ember-app';

export default {

  index: {
    description: 'Fastboot html pages',
    tags: ['web'],
    handler(request, reply) {
      let revision = request.query.revision;
      let url = request.path;

      return EmberApp.visit({ url, revision })
        .then(doc => reply(EmberApp.replaceHtml({ doc, revision })));
    }
  }

};

Note, its not clean, but when it becomes a hapi plugin, it will be very powerful b/c other plugins, like auth/session support and catbox-redis will make it fast as all heck

@eriktrom
Copy link
Author

error happens on line 24/25 - not sure yet - but application boots, just first app instance used to throw, now they all throw

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment