From f5ef406bf765d8fe0f5788d13da7842fc8b080b2 Mon Sep 17 00:00:00 2001 From: xavcz Date: Wed, 16 Nov 2016 16:17:12 +0100 Subject: [PATCH 1/3] got something ~working with a hacks on reactrouter:react-router-ssr --- .meteor/versions | 2 +- package.json | 1 + packages/meteor-react-router-ssr | 1 + packages/nova-apollo/lib/client.js | 49 +++++++++-------- packages/nova-apollo/lib/export.js | 4 +- .../lib/users/UsersEditForm.jsx | 1 + .../lib/users/UsersProfileCheck.jsx | 4 +- packages/nova-base-routes/lib/routes.jsx | 54 +++++++++---------- packages/nova-base-routes/lib/store.js | 13 ++--- 9 files changed, 64 insertions(+), 65 deletions(-) create mode 160000 packages/meteor-react-router-ssr diff --git a/.meteor/versions b/.meteor/versions index 18cf08088..1bce775ce 100644 --- a/.meteor/versions +++ b/.meteor/versions @@ -105,7 +105,7 @@ rate-limit@1.0.6 react-meteor-data@0.2.9 reactive-dict@1.1.8 reactive-var@1.0.11 -reactrouter:react-router-ssr@3.1.5 +reactrouter:react-router-ssr@3.1.6 reload@1.1.11 retry@1.0.9 routepolicy@1.0.12 diff --git a/package.json b/package.json index 5affa4007..72d0d1ae0 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "immutability-helper": "^2.0.0", "intl": "^1.2.4", "intl-locales-supported": "^1.0.0", + "isomorphic-fetch": "^2.2.1", "juice": "^1.11.0", "mailchimp": "^1.1.6", "marked": "^0.3.5", diff --git a/packages/meteor-react-router-ssr b/packages/meteor-react-router-ssr new file mode 160000 index 000000000..98bc33ecb --- /dev/null +++ b/packages/meteor-react-router-ssr @@ -0,0 +1 @@ +Subproject commit 98bc33ecb370f6fbd43faafc01ff4a042180e87a diff --git a/packages/nova-apollo/lib/client.js b/packages/nova-apollo/lib/client.js index 837f74dfe..04b14ddfb 100644 --- a/packages/nova-apollo/lib/client.js +++ b/packages/nova-apollo/lib/client.js @@ -4,16 +4,17 @@ // ------- // start of main-client from apollostack/meteor-integration -import ApolloClient, { createBatchingNetworkInterface } from 'apollo-client'; +import ApolloClient, { createNetworkInterface } from 'apollo-client'; import { Accounts } from 'meteor/accounts-base'; import { _ } from 'meteor/underscore'; +import 'isomorphic-fetch'; +import Cookie from 'react-cookie'; import Telescope from 'meteor/nova:lib'; const defaultNetworkInterfaceConfig = { path: '/graphql', options: {}, - useMeteorAccounts: true }; export const createMeteorNetworkInterface = (givenConfig) => { @@ -27,38 +28,40 @@ export const createMeteorNetworkInterface = (givenConfig) => { // For SSR const url = Meteor.absoluteUrl(path); - const networkInterface = createBatchingNetworkInterface({ + const networkInterface = createNetworkInterface({ uri: url, - batchInterval: 10 + opts: { + credentials: 'same-origin', + } }); - if (config.useMeteorAccounts) { - networkInterface.use([{ - applyMiddleware(request, next) { - const currentUserToken = Accounts._storedLoginToken(); - - if (!currentUserToken) { - next(); - return; - } - - if (!request.options.headers) { - request.options.headers = new Headers(); - } - - request.options.headers.Authorization = currentUserToken; + networkInterface.use([{ + applyMiddleware(request, next) { + console.log('from router token', config.cookieLoginToken); + console.log('from accounts token', Meteor.isClient && Accounts._storedLoginToken()); + const currentUserToken = config.cookieLoginToken ? config.cookieLoginToken : Meteor.isClient ? Accounts._storedLoginToken() : null; + if (!currentUserToken) { next(); - }, - }]); - } + return; + } + + if (!request.options.headers) { + request.options.headers = new Headers(); + } + + request.options.headers.Authorization = currentUserToken; + + next(); + }, + }]); return networkInterface; }; export const meteorClientConfig = (networkInterfaceConfig) => { return { - ssrMode: true, + ssrMode: Meteor.isServer, networkInterface: createMeteorNetworkInterface(networkInterfaceConfig), // Default to using Mongo _id, must use _id for queries. diff --git a/packages/nova-apollo/lib/export.js b/packages/nova-apollo/lib/export.js index b09806310..f94943b98 100644 --- a/packages/nova-apollo/lib/export.js +++ b/packages/nova-apollo/lib/export.js @@ -2,7 +2,7 @@ import Telescope from 'meteor/nova:lib'; import { makeExecutableSchema } from 'graphql-tools'; -import { client } from './client.js'; +import { meteorClientConfig, client } from './client.js'; import { createApolloServer } from './server.js'; import typeDefs from './schema'; @@ -16,4 +16,4 @@ createApolloServer({ schema, }); -export { client }; \ No newline at end of file +export { meteorClientConfig, client }; \ No newline at end of file diff --git a/packages/nova-base-components/lib/users/UsersEditForm.jsx b/packages/nova-base-components/lib/users/UsersEditForm.jsx index 747bcfdb2..8d09a5dca 100644 --- a/packages/nova-base-components/lib/users/UsersEditForm.jsx +++ b/packages/nova-base-components/lib/users/UsersEditForm.jsx @@ -18,6 +18,7 @@ const UsersEditForm = (props, context) => { } else { const user = props.data.user; + return ( { collection={ Users } document={ currentUser } mutationName="usersEdit" - resultQuery={Users.fragments.full} + fragment={Users.fragments.full} + noRemoveMutation={true} successCallback={ (user) => Telescope.callbacks.runAsync("users.profileCompleted.async", user) } fields={ requiredFields } /> @@ -40,6 +41,7 @@ const UsersProfileCheckModal = ({show, router, currentUser}, context) => { }; const UsersProfileCheck = ({currentUser}, context) => { + debugger return currentUser ? : null; }; diff --git a/packages/nova-base-routes/lib/routes.jsx b/packages/nova-base-routes/lib/routes.jsx index 472e83dde..f61e67cd8 100644 --- a/packages/nova-base-routes/lib/routes.jsx +++ b/packages/nova-base-routes/lib/routes.jsx @@ -7,13 +7,14 @@ import Events from "meteor/nova:events"; import Helmet from 'react-helmet'; import Cookie from 'react-cookie'; import ReactDOM from 'react-dom'; - +import ApolloClient from 'apollo-client'; import { ApolloProvider } from 'react-apollo'; -import { client } from 'meteor/nova:apollo'; +import {getDataFromTree} from "react-apollo/server"; +import { meteorClientConfig } from 'meteor/nova:apollo'; import { configureStore } from "./store.js"; -Meteor.startup(() => { +Meteor.startup(function initNovaRoutesAndApollo() { /* Routes definition @@ -46,27 +47,21 @@ Meteor.startup(() => { let history; let initialState; let store; + let client; // Use history hook to get a reference to the history object const historyHook = newHistory => history = newHistory; - // Pass the state of the store as the object to be dehydrated server side - const dehydrateHook = () => { - // console.log('store get state', store.getState()); - return store.getState(); - } - - // Take the rehydrated state and use it as the initial state client side - const rehydrateHook = state => { - // console.log('rehydrated state', state); - initialState = state - }; - const clientOptions = { historyHook, - rehydrateHook, - wrapperHook(app) { - store = configureStore(initialState, history); + rehydrateHook: state => { + console.log('rehydrated state', state); + initialState = state + }, + wrapperHook(app, loginToken) { + console.log('wrapper hook initial state', initialState); + client = new ApolloClient(meteorClientConfig({cookieLoginToken: loginToken})); + store = configureStore(client, initialState, history); return {app} }, props: { @@ -79,23 +74,22 @@ Meteor.startup(() => { }; const serverOptions = { + historyHook, htmlHook: (html) => { const head = Helmet.rewind(); return html.replace('', ''+ head.title + head.meta + head.link); }, - preRender: (req, res) => { - Cookie.plugToRequest(req, res); + preRender: (req, res, app) => { + //Cookie.plugToRequest(req, res); + //console.log('preRender hook', app); + // console.log(req.cookies); + return Promise.await(getDataFromTree(app)); }, - historyHook, - dehydrateHook, - // see https://github.com/thereactivestack/meteor-react-router-ssr/blob/9762f12c5d5512c5cfee8663a29428f7e4c141f8/lib/server.jsx#L241-L257 - // note: can't get it working well - // fetchDataHook: (components) => { - // console.log(components[0]); // = Apollo(AppContainer) - // // console.log('this is where ssr & apollo should interact -> fetch data') - // return [components[0].fetchData({} /* should be props .. how to get them?*/, {client})]; - // }, - fetchDataHook: (components) => components, + dehydrateHook: () => { + console.log(client.store.getState()); + return client.store.getState(); + }, + // fetchDataHook: (components) => components, }; ReactRouterSSR.Run(AppRoutes, clientOptions, serverOptions); diff --git a/packages/nova-base-routes/lib/store.js b/packages/nova-base-routes/lib/store.js index 0cb44b092..0156b7da9 100644 --- a/packages/nova-base-routes/lib/store.js +++ b/packages/nova-base-routes/lib/store.js @@ -1,20 +1,17 @@ import { createStore, combineReducers, applyMiddleware, compose } from 'redux'; -import ApolloClient from 'apollo-client'; -import { routerMiddleware } from 'react-router-redux' +// import { routerMiddleware } from 'react-router-redux' import Telescope from 'meteor/nova:lib'; -import { client } from 'meteor/nova:apollo'; +// import { client } from 'meteor/nova:apollo'; -const rootReducer = combineReducers({...Telescope.reducers, apollo: client.reducer()}); - -const configureStore = (initialState = {}, history) => createStore( +const configureStore = (client, initialState = {}, history) => createStore( // reducers - rootReducer, + combineReducers({...Telescope.reducers, apollo: client.reducer()}), //initial state initialState, // middlewares compose( - applyMiddleware(client.middleware(), routerMiddleware(history)), + applyMiddleware(client.middleware()/*, routerMiddleware(history)*/), typeof window !== "undefined" && window.devToolsExtension ? window.devToolsExtension() : f => f ), ); From 9891551567bc3f4312b6f692cffac125a3f5833c Mon Sep 17 00:00:00 2001 From: xavcz Date: Wed, 16 Nov 2016 16:26:53 +0100 Subject: [PATCH 2/3] remove queries and mutations on state dehydration, hide debug logs --- packages/nova-apollo/lib/client.js | 4 ++-- .../lib/users/UsersProfileCheck.jsx | 3 ++- packages/nova-base-routes/lib/routes.jsx | 12 +++++++++--- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/nova-apollo/lib/client.js b/packages/nova-apollo/lib/client.js index 04b14ddfb..242ca8300 100644 --- a/packages/nova-apollo/lib/client.js +++ b/packages/nova-apollo/lib/client.js @@ -37,8 +37,8 @@ export const createMeteorNetworkInterface = (givenConfig) => { networkInterface.use([{ applyMiddleware(request, next) { - console.log('from router token', config.cookieLoginToken); - console.log('from accounts token', Meteor.isClient && Accounts._storedLoginToken()); + // console.log('from router token', config.cookieLoginToken); + // console.log('from accounts token', Meteor.isClient && Accounts._storedLoginToken()); const currentUserToken = config.cookieLoginToken ? config.cookieLoginToken : Meteor.isClient ? Accounts._storedLoginToken() : null; if (!currentUserToken) { diff --git a/packages/nova-base-components/lib/users/UsersProfileCheck.jsx b/packages/nova-base-components/lib/users/UsersProfileCheck.jsx index 9f6f47a92..0662d4d39 100644 --- a/packages/nova-base-components/lib/users/UsersProfileCheck.jsx +++ b/packages/nova-base-components/lib/users/UsersProfileCheck.jsx @@ -41,7 +41,8 @@ const UsersProfileCheckModal = ({show, router, currentUser}, context) => { }; const UsersProfileCheck = ({currentUser}, context) => { - debugger + // console.log('current user', currentUser); + // console.log('profile completed', !Users.hasCompletedProfile(currentUser)); return currentUser ? : null; }; diff --git a/packages/nova-base-routes/lib/routes.jsx b/packages/nova-base-routes/lib/routes.jsx index f61e67cd8..92330f76a 100644 --- a/packages/nova-base-routes/lib/routes.jsx +++ b/packages/nova-base-routes/lib/routes.jsx @@ -55,11 +55,11 @@ Meteor.startup(function initNovaRoutesAndApollo() { const clientOptions = { historyHook, rehydrateHook: state => { - console.log('rehydrated state', state); + // console.log('rehydrated state', state); initialState = state }, wrapperHook(app, loginToken) { - console.log('wrapper hook initial state', initialState); + // console.log('wrapper hook initial state', initialState); client = new ApolloClient(meteorClientConfig({cookieLoginToken: loginToken})); store = configureStore(client, initialState, history); return {app} @@ -86,7 +86,13 @@ Meteor.startup(function initNovaRoutesAndApollo() { return Promise.await(getDataFromTree(app)); }, dehydrateHook: () => { - console.log(client.store.getState()); + // console.log(client.store.getState()); + const state = client.store.getState(); + + // https://github.com/apollostack/apollo-client/issues/845 + delete state.apollo.queries; + delete state.apollo.mutations; + return client.store.getState(); }, // fetchDataHook: (components) => components, From d1389e732f7ec2f9f69e2fb96bc05b53374ef239 Mon Sep 17 00:00:00 2001 From: xavcz Date: Wed, 16 Nov 2016 16:34:37 +0100 Subject: [PATCH 3/3] messy but working integration, will need some refactoring for react-apollo@0.6.0 + apollo-client@0.5.3 anyway --- .gitignore | 1 + .meteor/versions | 2 +- packages/meteor-react-router-ssr | 1 - packages/nova-apollo/lib/client.js | 4 +- packages/nova-base-routes/lib/routes.jsx | 5 + packages/nova-router/README.md | 204 ++++++++++++++ packages/nova-router/lib/client.jsx | 78 ++++++ packages/nova-router/lib/react-router-ssr.js | 12 + packages/nova-router/lib/server.jsx | 271 +++++++++++++++++++ packages/nova-router/lib/ssr_context.js | 51 ++++ packages/nova-router/lib/ssr_data.js | 89 ++++++ packages/nova-router/package.js | 42 +++ packages/nova-router/package.json | 36 +++ 13 files changed, 791 insertions(+), 5 deletions(-) delete mode 160000 packages/meteor-react-router-ssr create mode 100644 packages/nova-router/README.md create mode 100644 packages/nova-router/lib/client.jsx create mode 100644 packages/nova-router/lib/react-router-ssr.js create mode 100644 packages/nova-router/lib/server.jsx create mode 100644 packages/nova-router/lib/ssr_context.js create mode 100644 packages/nova-router/lib/ssr_data.js create mode 100644 packages/nova-router/package.js create mode 100644 packages/nova-router/package.json diff --git a/.gitignore b/.gitignore index fd580bef7..45f745955 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,4 @@ bundle.tar.gz jsdoc-conf.json jsdoc.json +packages/nova-router/.npm diff --git a/.meteor/versions b/.meteor/versions index 1bce775ce..0ac559f67 100644 --- a/.meteor/versions +++ b/.meteor/versions @@ -105,7 +105,7 @@ rate-limit@1.0.6 react-meteor-data@0.2.9 reactive-dict@1.1.8 reactive-var@1.0.11 -reactrouter:react-router-ssr@3.1.6 +reactrouter:react-router-ssr@3.1.6-nova-patch reload@1.1.11 retry@1.0.9 routepolicy@1.0.12 diff --git a/packages/meteor-react-router-ssr b/packages/meteor-react-router-ssr deleted file mode 160000 index 98bc33ecb..000000000 --- a/packages/meteor-react-router-ssr +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 98bc33ecb370f6fbd43faafc01ff4a042180e87a diff --git a/packages/nova-apollo/lib/client.js b/packages/nova-apollo/lib/client.js index 242ca8300..d0acd55b7 100644 --- a/packages/nova-apollo/lib/client.js +++ b/packages/nova-apollo/lib/client.js @@ -77,6 +77,4 @@ export const meteorClientConfig = (networkInterfaceConfig) => { // end of main-client from apollostack/meteor-integration // -------- -export const client = new ApolloClient(meteorClientConfig()); - -Telescope.graphQL.client = client; +// export const client = new ApolloClient(meteorClientConfig()); \ No newline at end of file diff --git a/packages/nova-base-routes/lib/routes.jsx b/packages/nova-base-routes/lib/routes.jsx index 92330f76a..b93b9b5ef 100644 --- a/packages/nova-base-routes/lib/routes.jsx +++ b/packages/nova-base-routes/lib/routes.jsx @@ -61,7 +61,12 @@ Meteor.startup(function initNovaRoutesAndApollo() { wrapperHook(app, loginToken) { // console.log('wrapper hook initial state', initialState); client = new ApolloClient(meteorClientConfig({cookieLoginToken: loginToken})); + store = configureStore(client, initialState, history); + + // *IRONY MODE: let's mess even more with the data flow. closure? never heard about that. I do like global vars* + Telescope.graphQL.client = client; + return {app} }, props: { diff --git a/packages/nova-router/README.md b/packages/nova-router/README.md new file mode 100644 index 000000000..963b77c18 --- /dev/null +++ b/packages/nova-router/README.md @@ -0,0 +1,204 @@ +Server-side rendering for react-router and react-meteor-data rehydratating Meteor subscriptions + +It has a protection against leaking your data. Only subscribed data will be available just the way it would be on the client. + +What about your SEO? Just `npm install react-helmet` and hook it with `htmlHook(html): string` (see the example below). + +## Install +`meteor add reactrouter:react-router-ssr` + +## Usage +### `ReactRouterSSR.Run(routes, [clientOptions], [serverOptions])` +The `routes` argument takes the routes you want react-router to use (you don't have to call `ReactDOM.render()` yourself)
+Read the [react-router documentation](https://github.com/rackt/react-router/tree/master/docs) for more informations. + +#### routes +Your main `` node of your application.
+**Notice that there is no `` element, ReactRouterSSR takes care of creating it on the client and server with the correct parameters** + +#### clientOptions (optional) +- `historyHook`: [function(history) : newHistory] - Hook something into history client side. +- `props` [object]: The additional arguments you would like to give to the `` component on the client. +- `wrapperHook` [function(App) : Component]: You can wrap the react-router element with your own providers. +- `rehydrateHook` [function(data)]: Receive the rehydrated object that was dehydrated during server side rendering. +- `rootElement` [string]: The root element ID your React application is mounted with (defaults to `react-app`) +- `rootElementType` [string]: Set the root element type (defaults to `div`) +- `rootElementAttributes`[array]: Set the root element attributes as an array of tag-value pairs. I.e. `[['class', sidebar main], ['style', 'background-color: white']]` + +#### serverOptions (optional) +- `props` [object]: The additional arguments you would like to give to the `` component on the server. +- `htmlHook` [function(html) : newHtml]: Prepare the HTML before sending it to the client +- `historyHook` [function(history): newHistory]: Hook something on the history server side. +- `dehydrateHook` [function() : data]: Supply data that should be dehydrated and sent to client. +- `fetchDataHook` [function(components) : Array]: Trigger the fetchData on your components that have it +- `preRender` [function(req, res)]: Executed just before the renderToString +- `postRender` [function(req, res)]: Executed just after the renderToString +- `dontMoveScripts` [bool]: Keep the script inside the head tag instead of moving it at the end of the body +- `disableSSR` [bool]: Disable server-side rendering, in case the application depends on code which doesn't work on the server. +- `loadingScreen` [string]: An HTML string to display while the page renders, in case the `disableSSR` option is set to true. + +### Scripts +Unless you disabled it, the scripts yo have in the header will be moved down at the end of the body tag. + +To keep a particuliar code in the head, you can add the `data-dont-move` attribute like this: + +```html + +``` + +## Simple Example +```javascript +import React, { Component } from 'react'; +import ReactMixin from 'react-mixin'; +import { IndexRoute, Route } from 'react-router'; +import { ReactRouterSSR } from 'meteor/reactrouter:react-router-ssr'; + +AppRoutes = ( + + + + + {/* ... */} + +); + +@ReactMixin(ReactMeteorData) +export default class HomePage extends Component + getMeteorData() { + Meteor.subscribe('profile'); + + return { + profile: Profile.findOne({ user: Meteor.userId() }) + }; + }, + + render() { + return
Hi {profile.name}
; + } +}); + +ReactRouterSSR.Run(AppRoutes); +``` + +## Complex Example +```javascript +import { IndexRoute, Route } from 'react-router'; +import ReactHelmet from 'react-helmet'; +import ReactCookie from 'react-cookie'; + +AppRoutes = ( + + + + + {/* ... */} + +); + +ReactRouterSSR.Run(AppRoutes, { + props: { + onUpdate() { + // Notify the page has been changed to Google Analytics + ga('send', 'pageview'); + }, + } +}, { + htmlHook(html) { + const head = ReactHelmet.rewind(); + return html.replace('', '' + head.title + head.base + head.meta + head.link + head.script); + }, + preRender: function(req, res) { + ReactCookie.plugToRequest(req, res); + } +}); + +if (Meteor.isClient) { + // Load Google Analytics + (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){ + (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o), + m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m) + })(window,document,'script','//www.google-analytics.com/analytics.js','ga'); + + ga('create', 'UA-XXXXXXXX-X', 'auto'); + ga('send', 'pageview'); +} +``` + +## Example with Redux + +ReactRouterSSR supports applications that use Redux, using the `rehydrateHook` and `dehydrateHook` options in clientOptions and serverOptions respectively. + +```javascript +import React from 'react'; +import { Provider } from 'react-redux'; + +import routes from './routes'; +import configureStore from './store'; + +// Data that is populated by hooks during startup +let history; +let store; +let initialState; + +// Use history hook to get a reference to the history object +const historyHook = newHistory => history = newHistory; + +// Pass the state of the store as the object to be dehydrated server side +const dehydrateHook = () => store.getState(); + +// Take the rehydrated state and use it as the initial state client side +const rehydrateHook = state => initialState = state; + +// Create a redux store and pass into the redux Provider wrapper +const wrapperHook = app => { + store = configureStore(initialState, history); + return {app}; +} + +const clientOptions = { historyHook, rehydrateHook, wrapperHook }; +const serverOptions = { historyHook, dehydrateHook }; + +ReactRouterSSR.Run(routes, clientOptions, serverOptions); +``` + +### Client-side data rehydration +ReactRouterSSR provides hooks to make use of client-side data rehydration: + +- On server side, once rendering is done, the data returned from dehydrateHook is serialized (using `JSON.stringify()`) and sent to the client as part of the generated HTML. +- On the client side, that serialized data is rehydrated and passed to the client via rehydrateHook. + +#### Data serialization +The `JSON.stringify()` serialization means that, if your data holds "rich" domain objects with methods attached though prototypes or ES6 classes (for example documents fetched from Mongo collections with an associated transform, or [ImmutableJS](https://facebook.github.io/immutable-js) structures...), the client receives them downcasted to Plain Old Javascript Objects (without prototypes or methods) in the 'data'. + +It is then the responsibility of the client code to "upcast" them back to the expected domain objects. In the case of redux it is recommended to handle that in each of the relevant reducers, by taking advantage of the fact that redux's `createStore()` dispatches an internal action with the 'initialState' it has been passed (which, in our case, is the unserialized state coming from the server rendering.) + +For example: + +- for a reducer that stores a document read from a collection that has a transform attached : +```js +function myReducer(state = {}, action) { + // If needed, upcast the raw state passed by the server SSR. + if (typeof state.expectedHelper === 'undefined') { // Or some other check for MyDomainClass ? + state = transform(state); // Where transform is the same transform you assigned to your collection + } + // Then the usual action matching : + switch (action.type) { + ... return state; + } +} +``` +- for a reducer that stores ImmutableJS structures, [redux-immutablejs](https://github.com/indexiatech/redux-immutablejs)'s createReducer() helper accepts an optional 'constructor' argument that does exactly that (defaults to `Immutable.fromJS()`). + +### Server-side pre-render data fetching (optional) +On the server-side, ReactRouterSSR implements the "fetchData" mechanism mentioned at the bottom of [the Redux doc on Server-Side Rendering](http://rackt.org/redux/docs/recipes/ServerRendering.html): + +The route components (e.g. `App`, `HomePage`, `LoginPage`... in the example above) can optionally specify a static fetchData() method to pre-populate the store with external data before rendering happens. +That fetchData() method, if present, will be automatically called for the components of the matched route (e.g. on `App` and `HomePage` for the url `'/'` in the example above). + +The fetchData() method receives: + +- the store's `getState` function, +- the store's `dispatch` function, +- the routing props for the resolved route (notably including `location` and `params`) + +and can dispatch async actions for external data fetching, returning the corresponding Promise. Rendering is then deferred until all Promises are resolved. diff --git a/packages/nova-router/lib/client.jsx b/packages/nova-router/lib/client.jsx new file mode 100644 index 000000000..88cd0a214 --- /dev/null +++ b/packages/nova-router/lib/client.jsx @@ -0,0 +1,78 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Router, browserHistory } from 'react-router'; +import Cookie from 'react-cookie'; + +const ReactRouterSSR = { + Run(routes, clientOptions) { + if (!clientOptions) { + clientOptions = {}; + } + + let history = browserHistory; + + if(typeof clientOptions.historyHook === 'function') { + history = clientOptions.historyHook(history); + } + + Meteor.startup(function() { + const rootElementName = clientOptions.rootElement || 'react-app'; + const rootElementType = clientOptions.rootElementType || 'div'; + const attributes = clientOptions.rootElementAttributes instanceof Array ? clientOptions.rootElementAttributes : []; + let rootElement = document.getElementById(rootElementName); + + // In case the root element doesn't exist, let's create it + if (!rootElement) { + rootElement = document.createElement(rootElementType); + rootElement.id = rootElementName; + + // check if a 2-dimensional array was passed... if not, be nice and handle it anyway + if(attributes[0] instanceof Array) { + // set attributes + for(var i = 0; i < attributes.length; i++) { + rootElement.setAttribute(attributes[i][0], attributes[i][1]); + } + } else if (attributes.length > 0){ + rootElement.setAttribute(attributes[0], attributes[1]); + } + + document.body.appendChild(rootElement); + } + + // Rehydrate data client side, if desired. + if(typeof clientOptions.rehydrateHook === 'function') { + InjectData.getData('dehydrated-initial-data', data => { + const rehydratedData = data ? JSON.parse(data) : undefined; + clientOptions.rehydrateHook(rehydratedData); + }); + } + + let app = ( + + ); + + if (typeof clientOptions.wrapperHook === 'function') { + const loginToken = Cookie.load('meteor_login_token') || localStorage['Meteor.loginToken']; + app = clientOptions.wrapperHook(app, loginToken); + } + + if (typeof clientOptions.renderHook === 'function') { + clientOptions.renderHook(app, rootElement); + } else { + ReactDOM.render(app, rootElement); + } + + let collectorEl = document.getElementById(clientOptions.styleCollectorId || 'css-style-collector-data') + + if (collectorEl) { + collectorEl.parentNode.removeChild(collectorEl); + } + }); + } +}; + +export { ReactRouterSSR }; +export default ReactRouterSSR; diff --git a/packages/nova-router/lib/react-router-ssr.js b/packages/nova-router/lib/react-router-ssr.js new file mode 100644 index 000000000..d3edeaa4f --- /dev/null +++ b/packages/nova-router/lib/react-router-ssr.js @@ -0,0 +1,12 @@ +import { checkNpmVersions } from 'meteor/tmeasday:check-npm-versions'; +checkNpmVersions({ + 'react': '15.x', + 'react-dom': '15.x', + 'react-router': '3.x' +}, 'reactrouter:react-router-ssr'); + +if (Meteor.isClient) { + ReactRouterSSR = require('./client.jsx').default; +} else { + ReactRouterSSR = require('./server.jsx').default; +} diff --git a/packages/nova-router/lib/server.jsx b/packages/nova-router/lib/server.jsx new file mode 100644 index 000000000..ff5e13262 --- /dev/null +++ b/packages/nova-router/lib/server.jsx @@ -0,0 +1,271 @@ +import React from 'react'; + +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 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() { + let history = createMemoryHistory(req.url); + + if (typeof serverOptions.historyHook === 'function') { + history = serverOptions.historyHook(history); + } + + 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) { + sendSSRHtml(clientOptions, serverOptions, req, res, next, renderProps); + } else { + res.writeHead(404); + res.write('Not found'); + res.end(); + } + })); + }); + })); + })(); +}; + +function sendSSRHtml(clientOptions, serverOptions, req, res, next, renderProps) { + const { css, html } = generateSSRData(clientOptions, serverOptions, req, res, renderProps); + res.write = patchResWrite(clientOptions, serverOptions, res.write, css, html); + + next(); +} + +function patchResWrite(clientOptions, serverOptions, originalWrite, css, html) { + return function(data) { + if(typeof data === 'string' && data.indexOf('') === 0) { + if (!serverOptions.dontMoveScripts) { + data = moveScripts(data); + } + + if (css) { + data = data.replace('', ''); + } + + if (typeof serverOptions.htmlHook === 'function') { + data = serverOptions.htmlHook(data); + } + + 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('', '<' + (clientOptions.rootElementType || 'div') + ' id="' + (clientOptions.rootElement || 'react-app') + '"' + rootElementAttributes + '>' + html + ''); + + 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('', ''); + } + + 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('', ''); + } + } + + return data; +} + +function generateSSRData(clientOptions, serverOptions, req, res, renderProps) { + let html, css; + + // we're stealing all the code from FlowRouter SSR + // https://github.com/kadirahq/flow-router/blob/ssr/server/route.js#L61 + const ssrContext = new SsrContext(); + + ReactRouterSSR.ssrContext.withValue(ssrContext, () => { + try { + const frData = InjectData.getData(res, 'fast-render-data'); + if (frData) { + ssrContext.addData(frData.collectionData); + } + + // 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 + }; + + // fetchComponentData(serverOptions, renderProps); + let app = ; + + if (typeof clientOptions.wrapperHook === 'function') { + const loginToken = req.cookies['meteor_login_token']; + app = clientOptions.wrapperHook(app, loginToken); + } + + if (serverOptions.preRender) { + serverOptions.preRender(req, res, app); + } + + if (!serverOptions.disableSSR){ + html = ReactDOMServer.renderToString(app); + } else if (serverOptions.loadingScreen){ + html = serverOptions.loadingScreen; + } + + css = global.__STYLE_COLLECTOR__; + + if (typeof serverOptions.dehydrateHook === 'function') { + const data = serverOptions.dehydrateHook(); + InjectData.pushData(res, 'dehydrated-initial-data', JSON.stringify(data)); + } + + 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 }; +} + +function fetchComponentData(serverOptions, renderProps) { + 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 = serverOptions.fetchDataHook(componentsWithFetch); + 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(); +} diff --git a/packages/nova-router/lib/ssr_context.js b/packages/nova-router/lib/ssr_context.js new file mode 100644 index 000000000..a6c85745d --- /dev/null +++ b/packages/nova-router/lib/ssr_context.js @@ -0,0 +1,51 @@ +// 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); + } + }); + }); + }); + } +} diff --git a/packages/nova-router/lib/ssr_data.js b/packages/nova-router/lib/ssr_data.js new file mode 100644 index 000000000..3a7ea83df --- /dev/null +++ b/packages/nova-router/lib/ssr_data.js @@ -0,0 +1,89 @@ +// 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; + }; +} diff --git a/packages/nova-router/package.js b/packages/nova-router/package.js new file mode 100644 index 000000000..7c759dd3a --- /dev/null +++ b/packages/nova-router/package.js @@ -0,0 +1,42 @@ +Package.describe({ + name: 'reactrouter:react-router-ssr', + version: '3.1.6-nova-patch', + summary: 'Server-side rendering for react-router and react-meteor-data rehydratating Meteor subscriptions', + git: 'https://github.com/thereactivestack/meteor-react-router-ssr.git', + documentation: 'README.md' +}); + +Npm.depends({ + 'cookie-parser': '1.4.1', + 'cheerio': '0.20.0', + 'deepmerge': '0.2.10' +}); + +Package.onUse(function(api) { + api.versionsFrom('1.3'); + api.use([ + 'ecmascript', + 'tracker', + 'minimongo@1.0.0', + 'meteorhacks:fast-render@2.16.0', + 'meteorhacks:inject-data@2.0.1-nova-patch', + 'tmeasday:check-npm-versions@0.2.0' + ]); + + api.use([ + 'underscore@1.0.3', + 'webapp@1.2.0', + 'mongo@1.0.0', + 'routepolicy@1.0.5', + 'url@1.0.9' + ], 'server'); + + api.use([ + 'autopublish@1.0.0', + 'tmeasday:publish-counts@0.7.0', + 'promise@0.8.8' + ], 'server', {weak: true}) + + api.export('ReactRouterSSR'); + api.mainModule('lib/react-router-ssr.js'); +}); diff --git a/packages/nova-router/package.json b/packages/nova-router/package.json new file mode 100644 index 000000000..78745f0bd --- /dev/null +++ b/packages/nova-router/package.json @@ -0,0 +1,36 @@ +{ + "name": "react-router-ssr", + "version": "3.0.0", + "description": "Server-side rendering for react-router and react-meteor-data rehydratating Meteor subscriptions", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/thereactivestack/meteor-react-router-ssr.git" + }, + "keywords": [ + "meteor", + "react", + "reactrouter", + "react-router", + "ssr", + "server-rendering" + ], + "author": "Benoit Tremblay ", + "license": "MIT", + "bugs": { + "url": "https://github.com/thereactivestack/meteor-react-router-ssr/issues" + }, + "homepage": "https://github.com/thereactivestack/meteor-react-router-ssr#readme", + "dependencies": { + "cheerio": "^0.20.0", + "cookie-parser": "^1.4.1", + "promise": "^7.1.1", + "react": "^15.3.0", + "react-dom": "^15.3.0", + "react-helmet": "^3.1.0", + "react-router": "^2.0.1", + "underscore": "^1.8.3" + } +}