Skip to content

Instantly share code, notes, and snippets.

@dbismut
Last active April 18, 2016 16:19
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save dbismut/7af512a597fdb897f4958178f0856e77 to your computer and use it in GitHub Desktop.
Save dbismut/7af512a597fdb897f4958178f0856e77 to your computer and use it in GitHub Desktop.
ReactRouterSSR
// 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();
}
// 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);
}
});
});
});
}
}
// 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