Last active
April 18, 2016 16:19
-
-
Save dbismut/7af512a597fdb897f4958178f0856e77 to your computer and use it in GitHub Desktop.
ReactRouterSSR
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// server/index.jsx | |
import React from 'react'; | |
/* don't pay attention to the 3 lines below */ | |
import { Provider } from 'react-redux'; | |
import { syncHistoryWithStore } from 'react-router-redux'; | |
import { configureStore } from 'common/client/redux/store/'; | |
import { | |
match as ReactRouterMatch, | |
RouterContext, | |
createMemoryHistory | |
} from 'react-router'; | |
import SsrContext from './ssr_context'; | |
import patchSubscribeData from './ssr_data'; | |
import ReactDOMServer from 'react-dom/server'; | |
import ReactHelmet from 'react-helmet'; | |
import cookieParser from 'cookie-parser'; | |
import Cheerio from 'cheerio'; | |
function IsAppUrl(req) { | |
var url = req.url; | |
if(url === '/favicon.ico' || url === '/robots.txt') { | |
return false; | |
} | |
if(url === '/app.manifest') { | |
return false; | |
} | |
// Avoid serving app HTML for declared routes such as /sockjs/. | |
if(RoutePolicy.classify(url)) { | |
return false; | |
} | |
return true; | |
} | |
let webpackStats; | |
const ReactRouterSSR = {}; | |
export default ReactRouterSSR; | |
// creating some EnvironmentVariables that will be used later on | |
ReactRouterSSR.ssrContext = new Meteor.EnvironmentVariable(); | |
ReactRouterSSR.inSubscription = new Meteor.EnvironmentVariable(); // <-- needed in ssr_data.js | |
ReactRouterSSR.LoadWebpackStats = function(stats) { | |
webpackStats = stats; | |
}; | |
ReactRouterSSR.Run = function(routes, clientOptions, serverOptions) { | |
// this line just patches Subscribe and find mechanisms | |
patchSubscribeData(ReactRouterSSR); | |
if (!clientOptions) { | |
clientOptions = {}; | |
} | |
if (!serverOptions) { | |
serverOptions = {}; | |
} | |
if (!serverOptions.webpackStats) { | |
serverOptions.webpackStats = webpackStats; | |
} | |
Meteor.bindEnvironment(function() { | |
WebApp.rawConnectHandlers.use(cookieParser()); | |
WebApp.connectHandlers.use(Meteor.bindEnvironment(function(req, res, next) { | |
if (!IsAppUrl(req)) { | |
next(); | |
return; | |
} | |
global.__CHUNK_COLLECTOR__ = []; | |
var loginToken = req.cookies['meteor_login_token']; | |
var headers = req.headers; | |
var context = new FastRender._Context(loginToken, { headers }); | |
FastRender.frContext.withValue(context, function() { | |
// we don't need to patch Meteor.userId, this should be done by FastRender | |
console.log('rendering for', Meteor.userId()); | |
// this hasn't much to do with the issue with the current code | |
// here the reduxStore is created according to react-router-redux 4.0.0 | |
const memoryHistory = createMemoryHistory(req.url); | |
const reduxStore = configureStore(memoryHistory); | |
const history = syncHistoryWithStore(memoryHistory, reduxStore); | |
ReactRouterMatch({ history, routes, location: req.url }, Meteor.bindEnvironment((err, redirectLocation, renderProps) => { | |
if (err) { | |
res.writeHead(500); | |
res.write(err.messages); | |
res.end(); | |
} else if (redirectLocation) { | |
res.writeHead(302, { Location: redirectLocation.pathname + redirectLocation.search }); | |
res.end(); | |
} else if (renderProps) { | |
// notice we don't need the context anymore | |
// we can just get it with FastRender.frContext.get() | |
sendSSRHtml(clientOptions, serverOptions, req, res, next, renderProps, reduxStore); | |
} else { | |
res.writeHead(404); | |
res.write('Not found'); | |
res.end(); | |
} | |
})); | |
console.log('finished rendering for', Meteor.userId()); | |
}); | |
})); | |
})(); | |
}; | |
function sendSSRHtml(clientOptions, serverOptions, req, res, next, renderProps, reduxStore) { | |
const { css, html, head } = generateSSRData(serverOptions, req, res, renderProps, reduxStore); | |
res.write = patchResWrite(clientOptions, serverOptions, res.write, css, html, head); | |
next(); | |
} | |
function patchResWrite(clientOptions, serverOptions, originalWrite, css, html, head) { | |
return function(data) { | |
if(typeof data === 'string' && data.indexOf('<!DOCTYPE html>') === 0) { | |
if (!serverOptions.dontMoveScripts) { | |
data = moveScripts(data); | |
} | |
if (css) { | |
data = data.replace('</head>', '<style id="' + (clientOptions.styleCollectorId || 'css-style-collector-data') + '">' + css + '</style></head>'); | |
} | |
if (head) { | |
data = data.replace('<head>', | |
'<head>' + head.title + head.base + head.meta + head.link + head.script | |
); | |
} | |
let rootElementAttributes = ''; | |
const attributes = clientOptions.rootElementAttributes instanceof Array ? clientOptions.rootElementAttributes : []; | |
if(attributes[0] instanceof Array) { | |
for(var i = 0; i < attributes.length; i++) { | |
rootElementAttributes = rootElementAttributes + ' ' + attributes[i][0] + '="' + attributes[i][1] + '"'; | |
} | |
} else if (attributes.length > 0){ | |
rootElementAttributes = ' ' + attributes[0] + '="' + attributes[1] + '"'; | |
} | |
data = data.replace('<body>', '<body><' + (clientOptions.rootElementType || 'div') + ' id="' + (clientOptions.rootElement || 'react-app') + '"' + rootElementAttributes + '>' + html + '</' + (clientOptions.rootElementType || 'div') + '>'); | |
if (typeof serverOptions.webpackStats !== 'undefined') { | |
data = addAssetsChunks(serverOptions, data); | |
} | |
} | |
originalWrite.call(this, data); | |
}; | |
} | |
function addAssetsChunks(serverOptions, data) { | |
const chunkNames = serverOptions.webpackStats.assetsByChunkName; | |
const publicPath = serverOptions.webpackStats.publicPath; | |
if (typeof chunkNames.common !== 'undefined') { | |
var chunkSrc = (typeof chunkNames.common === 'string')? | |
chunkNames.common : | |
chunkNames.common[0]; | |
data = data.replace('<head>', '<head><script type="text/javascript" src="' + publicPath + chunkSrc + '"></script>'); | |
} | |
for (var i = 0; i < global.__CHUNK_COLLECTOR__.length; ++i) { | |
if (typeof chunkNames[global.__CHUNK_COLLECTOR__[i]] !== 'undefined') { | |
chunkSrc = (typeof chunkNames[global.__CHUNK_COLLECTOR__[i]] === 'string')? | |
chunkNames[global.__CHUNK_COLLECTOR__[i]] : | |
chunkNames[global.__CHUNK_COLLECTOR__[i]][0]; | |
data = data.replace('</head>', '<script type="text/javascript" src="' + publicPath + chunkSrc + '"></script></head>'); | |
} | |
} | |
return data; | |
} | |
function generateSSRData(serverOptions, req, res, renderProps, reduxStore) { | |
let html, css, head; | |
// we're stealing all the code from FlowRouter SSR | |
// https://github.com/kadirahq/flow-router/blob/ssr/server/route.js#L61 | |
// from my understanding, this ensure that the data we generate from SSR | |
// is only accessible within the Fiber from the client request | |
// i.e. each request should have its own ssrContext | |
const ssrContext = new SsrContext(); | |
ReactRouterSSR.ssrContext.withValue(ssrContext, () => { | |
try { | |
const frData = InjectData.getData(res, 'fast-render-data'); | |
if (frData) { | |
ssrContext.addData(frData.collectionData); | |
} | |
if (serverOptions.preRender) { | |
serverOptions.preRender(req, res); | |
} | |
// Uncomment these two lines if you want to easily trigger | |
// multiple client requests from different browsers at the same time | |
// console.log('sarted sleeping'); | |
// Meteor._sleepForMs(5000); | |
// console.log('ended sleeping'); | |
global.__STYLE_COLLECTOR_MODULES__ = []; | |
global.__STYLE_COLLECTOR__ = ''; | |
renderProps = { | |
...renderProps, | |
...serverOptions.props | |
}; | |
// since I know that I'm using redux I don't need the if (serverOptions.createReduxStore) | |
fetchComponentData(renderProps, reduxStore); | |
const app = ( | |
<Provider store={ reduxStore }> | |
<RouterContext {...renderProps} /> | |
</Provider> | |
); | |
html = ReactDOMServer.renderToString(app); | |
InjectData.pushData(res, 'redux-initial-state', JSON.stringify(reduxStore.getState())); | |
head = ReactHelmet.rewind(); | |
css = global.__STYLE_COLLECTOR__; | |
if (serverOptions.postRender) { | |
serverOptions.postRender(req, res); | |
} | |
// I'm pretty sure this could be avoided in a more elegant way? | |
const context = FastRender.frContext.get(); | |
const data = context.getData(); | |
InjectData.pushData(res, 'fast-render-data', data); | |
} | |
catch(err) { | |
console.error(new Date(), 'error while server-rendering', err.stack); | |
} | |
}); | |
return { html, css, head }; | |
} | |
function fetchComponentData(renderProps, reduxStore) { | |
const componentsWithFetch = renderProps.components | |
.filter(component => !!component) | |
.filter(component => component.fetchData); | |
if (!componentsWithFetch.length) { | |
return; | |
} | |
if (!Package.promise) { | |
console.error("react-router-ssr: Support for fetchData() static methods on route components requires the 'promise' package."); | |
return; | |
} | |
const promises = componentsWithFetch | |
.map(component => component.fetchData(reduxStore.getState, reduxStore.dispatch, renderProps)); | |
Promise.awaitAll(promises); | |
} | |
function moveScripts(data) { | |
const $ = Cheerio.load(data, { | |
decodeEntities: false | |
}); | |
const heads = $('head script'); | |
$('body').append(heads); | |
$('head').html($('head').html().replace(/(^[ \t]*\n)/gm, '')); | |
return $.html(); | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// server/ssr_context.js | |
// stolen from https://github.com/kadirahq/flow-router/blob/ssr/server/ssr_context.js | |
import deepMerge from 'deepmerge'; | |
export default class SsrContext { | |
constructor() { | |
this._collections = {}; | |
} | |
getCollection(collName) { | |
let collection = this._collections[collName]; | |
if (!collection) { | |
const minimongo = Package.minimongo; | |
collection = this._collections[collName] = new minimongo.LocalCollection(); | |
} | |
return collection; | |
} | |
addSubscription(name, params) { | |
const fastRenderContext = FastRender.frContext.get(); | |
if (!fastRenderContext) { | |
throw new Error( | |
`Cannot add a subscription: ${name} without FastRender Context` | |
); | |
} | |
const args = [name].concat(params); | |
const data = fastRenderContext.subscribe(...args); | |
this.addData(data); | |
} | |
addData(data) { | |
_.each(data, (collDataCollection, collectionName) => { | |
const collection = this.getCollection(collectionName); | |
collDataCollection.forEach((collData) => { | |
collData.forEach((item) => { | |
const existingDoc = collection.findOne(item._id); | |
if (existingDoc) { | |
const newDoc = deepMerge(existingDoc, item); | |
delete newDoc._id; | |
collection.update(item._id, newDoc); | |
} else { | |
collection.insert(item); | |
} | |
}); | |
}); | |
}); | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// server/ssr_data.js | |
// stolen from https://github.com/kadirahq/flow-router/blob/ssr/server/ssr_data.js | |
export default function patchSubscribeData (ReactRouterSSR) { | |
const originalSubscribe = Meteor.subscribe; | |
Meteor.subscribe = function(pubName) { | |
const params = Array.prototype.slice.call(arguments, 1); | |
const ssrContext = ReactRouterSSR.ssrContext.get(); | |
if (ssrContext) { | |
ReactRouterSSR.inSubscription.withValue(true, () => { | |
ssrContext.addSubscription(pubName, params); | |
}); | |
} | |
if (originalSubscribe) { | |
originalSubscribe.apply(this, arguments); | |
} | |
return { | |
ready: () => true | |
}; | |
}; | |
const Mongo = Package.mongo.Mongo; | |
const originalFind = Mongo.Collection.prototype.find; | |
Mongo.Collection.prototype.find = function(selector, options) { | |
selector = selector || {}; | |
const ssrContext = ReactRouterSSR.ssrContext.get(); | |
if (ssrContext && !ReactRouterSSR.inSubscription.get()) { | |
const collName = this._name; | |
// this line is added just to make sure this works CollectionFS | |
if (typeof this._transform === 'function') { | |
options.transform = this._transform; | |
} | |
const collection = ssrContext.getCollection(collName); | |
const cursor = collection.find(selector, options); | |
return cursor; | |
} | |
return originalFind.call(this, selector, options); | |
}; | |
// We must implement this. Otherwise, it'll call the origin prototype's | |
// find method | |
Mongo.Collection.prototype.findOne = function(selector, options) { | |
options = options || {}; | |
options.limit = 1; | |
return this.find(selector, options).fetch()[0]; | |
}; | |
const originalAutorun = Tracker.autorun; | |
Tracker.autorun = (fn) => { | |
// if autorun is in the ssrContext, we need fake and run the callback | |
// in the same eventloop | |
if (ReactRouterSSR.ssrContext.get()) { | |
const c = { firstRun: true, stop: () => {} }; | |
fn(c); | |
return c; | |
} | |
return originalAutorun.call(Tracker, fn); | |
}; | |
// By default, Meteor[call,apply] also inherit SsrContext | |
// So, they can't access the full MongoDB dataset because of that | |
// Then, we need to remove the SsrContext within Method calls | |
['call', 'apply'].forEach((methodName) => { | |
const original = Meteor[methodName]; | |
Meteor[methodName] = (...args) => { | |
const response = ReactRouterSSR.ssrContext.withValue(null, () => { | |
return original.apply(this, args); | |
}); | |
return response; | |
}; | |
}); | |
// This is not available in the server. But to make it work with SSR | |
// We need to have it. | |
Meteor.loggingIn = () => { | |
return false; | |
}; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment