├── .babelrc ├── .eslintignore ├── .eslintrc ├── .gitignore ├── README.md ├── app ├── actions │ └── todos.js ├── components │ ├── about.jsx │ ├── app.jsx │ ├── not-found.jsx │ └── todos │ │ ├── create.jsx │ │ ├── finished-todo.jsx │ │ ├── index.jsx │ │ └── pending-todo.jsx ├── images │ └── bg.png ├── index.js ├── routes.jsx ├── store │ └── index.js └── styles │ ├── about.css │ ├── global.css │ ├── navbar.css │ └── todos.css ├── circle.yml ├── constants.js ├── core ├── config.js ├── connect.js ├── fetch-data.js ├── hydrate.js ├── provide-insert-css.js └── universal-render.js ├── package.json ├── run ├── dev.js └── utils │ ├── start-render-server.js │ ├── start-webpack-server.js │ └── write-stats.js ├── server ├── koa.js └── server-html.jsx ├── spec ├── app.spec.js ├── core │ ├── connect.spec.js │ ├── fetch-data.spec.js │ ├── hydrate.spec.js │ └── provide-insert-css.spec.js └── helpers │ ├── mount.js │ └── setup-browser-env.js └── webpack ├── client.config.babel.js ├── server.config.babel.js ├── shared.config.js └── test.config.babel.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["save"], 3 | "plugins": [ 4 | "typecheck", 5 | "react-hot-loader/babel", 6 | "lodash" 7 | ], 8 | "env": { 9 | "test": { 10 | "plugins": [ 11 | [ "webpack-loaders", { "config": "${CONFIG}" } ], 12 | [ "resolver", { "resolveDirs": [ "./" ] } ] 13 | ] 14 | }, 15 | "production": { 16 | "plugins": [ 17 | "react-remove-prop-types", 18 | "transform-react-constant-elements", 19 | "transform-react-inline-elements", 20 | "transform-react-pure-class-to-function" 21 | ] 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | server/build.js 3 | node_modules/ 4 | coverage/ 5 | .nyc_output/ 6 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": "eslint-config-airbnb", 4 | "plugins": ["react", "jsx-a11y"], 5 | "env": { 6 | "browser": true, 7 | "node": true, 8 | "es6": true, 9 | "mocha": true 10 | }, 11 | "globals": { 12 | "chai": true, 13 | "sinon": true, 14 | "expect": true 15 | }, 16 | "rules": { 17 | "react/jsx-curly-spacing": [2, "always"], 18 | "react/jsx-no-duplicate-props": 2, 19 | "react/jsx-no-undef": 2, 20 | "react/jsx-quotes": 0, 21 | "react/jsx-uses-react": 2, 22 | "react/jsx-uses-vars": 2, 23 | "react/jsx-closing-bracket-location": [2, "after-props" ], 24 | "react/no-did-mount-set-state": 2, 25 | "react/no-did-update-set-state": 2, 26 | "react/no-multi-comp": 2, 27 | "react/no-unknown-property": 2, 28 | "react/prop-types": 2, 29 | "react/react-in-jsx-scope": 2, 30 | "react/require-extension": 2, 31 | "react/self-closing-comp": 2, 32 | "react/wrap-multilines": 2, 33 | "react/sort-comp": 0, 34 | "react/jsx-no-bind": 0, 35 | "react/jsx-boolean-value": [2, "always"], 36 | 37 | "quotes": [2, "single", "avoid-escape"], 38 | "jsx-quotes": [2, "prefer-single"], 39 | "comma-dangle": [2, "never"], 40 | "indent": [2, 2], 41 | "object-curly-spacing": [2, "always"], 42 | "array-bracket-spacing": [2, "always"], 43 | "no-undef": 2, 44 | "no-underscore-dangle": 0, 45 | "func-names": 0, 46 | "no-else-return": 0, 47 | "no-console": 1, 48 | "no-throw-literal": 0, 49 | "id-length": 0, 50 | "no-unused-expressions": 0, 51 | "no-param-reassign": 0, 52 | "max-len": [1, 100], 53 | "camelcase": 0, 54 | "space-before-function-paren": [2, "never"], 55 | "consistent-return": 0, 56 | "semi": [2, "never"], 57 | "no-confusing-arrow": 0, 58 | "global-require": 0, 59 | "import/no-unresolved": 0 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/webpack-stats.json 2 | coverage 3 | node_modules 4 | npm-debug.log 5 | dist 6 | .tmp 7 | .DS_Store 8 | .sass-cache 9 | .env 10 | .c9 11 | .nyc_output 12 | server/build.js 13 | coverage.lcov 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ⚛ futureRX 2 | 3 | > 🚀 The futuristic stack to create universal React applications with MobX as state manager. With supporting async data fetching out of the box on server side rendering. 4 | 5 | [![dependencies](https://david-dm.org/iam4x/futureRX.svg)](https://david-dm.org/iam4x/futureRX#info=dependencies&view=table) 6 | [![devDependencies](https://david-dm.org/iam4x/futureRX/dev-status.svg)](https://david-dm.org/iam4x/futureRX#info=devDependencies&view=table) 7 | [![CircleCI](https://img.shields.io/circleci/project/iam4x/futureRX.svg)](https://circleci.com/gh/iam4x/futureRX) 8 | [![codecov](https://img.shields.io/codecov/c/github/iam4x/futureRX.svg)](https://codecov.io/gh/iam4x/futureRX) 9 | 10 | 11 | WARNING: This is a proof of concept running on bleeding edge libraries, it has not been running in production yet. If you do [contact me](http://twitter.com/iam4x). 12 | 13 | This project aim frontend applications which are using an external API to work with data. This boilerplate does not include an API with it to fetch/persist data. 14 | 15 | ## Libraries used 16 | 17 | > **nodejs ^6.2** / **npm ^3.9** 18 | 19 | * #### react 20 | * [mobx](https://github.com/mobxjs/mobx) 21 | * [mobx-store](https://github.com/AriaFallah/mobx-store) 22 | * [react ^15](https://facebook.github.io/react/) 23 | * [react-router ^2.0.0](https://github.com/rackt/react-router) 24 | * [react-hot-loader ^3.0.0-beta](https://github.com/gaearon/react-hot-loader) 25 | * [why-did-you-update](https://github.com/garbles/why-did-you-update) 26 | 27 | * #### css styles 28 | * [css-modules](https://github.com/css-modules/css-modules) 🌟 29 | * [postcss ^5.0.21](https://github.com/postcss/postcss) 30 | * [postcss-import ^8.1.2](https://github.com/postcss/postcss-import) 31 | * [postcss-url ^5.1.2](https://github.com/postcss/postcss-url) 32 | * [autoprefixer ^6.3.6](https://github.com/postcss/autoprefixer) 33 | * [precss ^1.4.0](https://github.com/jonathantneal/precss) 34 | * [isomorphic-style-loader ^1.0.0](https://github.com/kriasoft/isomorphic-style-loader) 35 | * _Supports also global CSS for real world usage with:_ 36 | * [extract-text-webpack-plugin ^1.0.1](https://github.com/webpack/extract-text-webpack-plugin) 37 | * [style-loader ^0.13](https://github.com/webpack/style-loader) 38 | 39 | * #### server side rendering 40 | * [koa ^2.0.0](http://koajs.com/) 41 | 42 | * #### testing tools 43 | * [ava ^0.15.2](https://github.com/sindresorhus/ava) 44 | * [jsdom ^9.4.0](https://github.com/tmpvar/jsdom) 45 | * [enzyme ^2.3.0](https://github.com/airbnb/enzyme) 46 | * [chai-enzyme ^0.5.0](https://github.com/producthunt/chai-enzyme) 47 | * [faux-jax ^5.0.1](https://github.com/algolia/faux-jax) 48 | * [sinon ^1.17.4](https://github.com/sinonjs/sinon) 49 | * [sinon-chai ^2.8.0](https://github.com/domenic/sinon-chai) 50 | * [nyc ^7.0.0-alpha.5](https://github.com/bcoe/nyc) 51 | 52 | * #### dev/building tools 53 | * [webpack ^2.1.0-beta.15](http://webpack.github.io/) 54 | * [webpack-hot-middleware](https://github.com/glenjamin/webpack-hot-middleware) 55 | * [webpack-dev-middleware](https://github.com/webpack/webpack-dev-middleware) 56 | * [babeljs ^6.9](https://babeljs.io/) 57 | * [flowtype (via babel-plugin-typecheck)](https://github.com/codemix/babel-plugin-typecheck) 58 | * [browser-sync](https://www.browsersync.io) 59 | 60 | ## Setup 61 | 62 | * `$ git clone -o upstream https://github.com/iam4x/futureRX.git [myApp]` 63 | * `$ cd [myApp] && npm install` 64 | 65 | ## Run 66 | 67 | * ### dev 68 | * `$ npm run dev` OR 69 | * `$ PORT=xxxx npm run dev` 70 | * (Append `?debugRender` to your URL to enable `why-did-you-update`) 71 | 72 | * ### test 73 | * `$ npm test` OR 74 | * `$ npm test -- --watch` 75 | 76 | * ### build 77 | * `$ NODE_ENV=production npm run build` 78 | * `$ NODE_ENV=production node dist/server` 79 | 80 | ## Update 81 | 82 | * `$ git fetch upstream` 83 | * `$ git merge upstream/master` 84 | * `$ npm install` 85 | 86 | ### TODO 87 | 88 | * [x] allow override of listening ports via config ([7e9ae4a](https://github.com/iam4x/futureRX/commit/7e9ae4ac73fdd562fed5d39dda9325b4541217af)) 89 | * [x] styles with css-modules and global styles ([#1](https://github.com/iam4x/futureRX/pull/1)) 90 | * [x] test with ava and airbnb-enzyme ([#2](https://github.com/iam4x/futureRX/pull/2)) 91 | * [x] webpack build config 92 | * [ ] production server optimization 93 | * [ ] refactor server code 94 | 95 | ### Learn more 96 | 97 | * [Official ReactJS website](http://facebook.github.io/react/) 98 | * [Official ReactJS wiki](https://github.com/facebook/react/wiki) 99 | * [Learn ES6](https://babeljs.io/docs/learn-es6/) 100 | * [ES6 Features](https://github.com/lukehoban/es6features#readme) 101 | -------------------------------------------------------------------------------- /app/actions/todos.js: -------------------------------------------------------------------------------- 1 | import { times, random } from 'lodash' 2 | import debug from 'debug' 3 | 4 | export const load = (store) => new Promise(resolve => { 5 | debug('dev')('start load todos') 6 | 7 | setTimeout(() => { 8 | store('todos').replace( 9 | times( 10 | random(9) + 1, 11 | (id) => ({ id, title: `todo #${id}`, finished: !!random(1) }) 12 | ) 13 | ) 14 | 15 | resolve() 16 | }, 300) 17 | 18 | debug('dev')('end load todos') 19 | }) 20 | -------------------------------------------------------------------------------- /app/components/about.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import withStyles from 'isomorphic-style-loader/lib/withStyles' 3 | import { markdown } from 'markdown' 4 | 5 | import styles from 'app/styles/about.css' 6 | import readme from 'README.md' 7 | 8 | const about = () => 9 |
12 | 13 | export default withStyles(styles)(about) 14 | -------------------------------------------------------------------------------- /app/components/app.jsx: -------------------------------------------------------------------------------- 1 | /* eslint max-len: 0 */ 2 | 3 | import React from 'react' 4 | import withStyles from 'isomorphic-style-loader/lib/withStyles' 5 | import { Link } from 'react-router' 6 | 7 | import styles from 'app/styles/navbar.css' 8 | 9 | type Props = { 10 | children: any 11 | } 12 | 13 | const App = ({ children }: Props) => 14 |
15 | 42 | 43 | 44 |
{ children }
45 |
46 | 47 | export default withStyles(styles)(App) 48 | -------------------------------------------------------------------------------- /app/components/not-found.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Link } from 'react-router' 3 | 4 | const NotFound = () => ( 5 |
6 |

Page not found

7 | Back to home 8 |
9 | ) 10 | 11 | export default NotFound 12 | -------------------------------------------------------------------------------- /app/components/todos/create.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { observable } from 'mobx' 3 | 4 | import connect from 'core/connect' 5 | 6 | @connect('todos') 7 | class CreateTodo extends Component { 8 | 9 | props: { 10 | todos: { 11 | push: Function 12 | } 13 | } 14 | 15 | @observable title = '' 16 | 17 | handleChange = ({ target: { value } }) => { 18 | this.title = value 19 | } 20 | 21 | handleSubmit = (e) => { 22 | e.preventDefault() 23 | 24 | const { title } = this 25 | const { todos } = this.props 26 | 27 | if (title && title.trim()) { 28 | // fire `onSubmit` with new Todo 29 | todos.push({ title, id: Math.random(), finished: false }) 30 | 31 | // reset title to empty string for next todo 32 | this.title = '' 33 | } 34 | } 35 | 36 | render() { 37 | const { title } = this 38 | 39 | return ( 40 |
43 | 49 |
50 | ) 51 | } 52 | 53 | } 54 | 55 | export default CreateTodo 56 | -------------------------------------------------------------------------------- /app/components/todos/finished-todo.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import connect from 'core/connect' 3 | 4 | import styles from 'app/styles/todos.css' 5 | 6 | type Props = { 7 | todo: { 8 | id: number, 9 | title: string, 10 | finished: boolean 11 | }, 12 | onRemove: Function 13 | } 14 | 15 | const FinishedTodo = ({ todo, onRemove }: Props) => 16 |
  • 17 | { todo.title } 18 | 19 |
    22 | 23 |
    24 |
  • 25 | 26 | export default connect()(FinishedTodo) 27 | -------------------------------------------------------------------------------- /app/components/todos/index.jsx: -------------------------------------------------------------------------------- 1 | import { filter } from 'lodash' 2 | import pluralize from 'pluralize' 3 | 4 | import React from 'react' 5 | import withStyles from 'isomorphic-style-loader/lib/withStyles' 6 | 7 | import connect from 'core/connect' 8 | import { load } from 'app/actions/todos' 9 | 10 | import FinishedTodo from './finished-todo' 11 | import PendingTodo from './pending-todo' 12 | import CreateTodo from './create' 13 | 14 | import styles from 'app/styles/todos.css' 15 | 16 | type Props = { 17 | todos: { 18 | map: Function 19 | } 20 | } 21 | 22 | const handleMarkAllDone = (todos) => () => 23 | todos.replace(todos.map(t => ({ ...t, finished: true }))) 24 | 25 | const Todos = ({ todos }: Props) => { 26 | const pendingTodos = filter(todos, { finished: false }) 27 | const finishedTodos = filter(todos, { finished: true }) 28 | 29 | return ( 30 |
    31 | { /* unfinished todos */ } 32 |
    33 |
    34 |

    Todos

    35 | 36 | 37 | 38 |
    39 |
    42 | Mark all done 43 |
    44 |
    45 | 46 |
      47 | { pendingTodos.map(todo => 48 | ) } 51 |
    52 | 53 |
    54 | { pendingTodos.length ? 55 | 56 | { pendingTodos.length } 57 | { pluralize('todo', pendingTodos.length) } remaining 58 | : 59 | Congrats it's all done! } 60 |
    61 |
    62 |
    63 | 64 | { /* finished todos */ } 65 |
    66 |
    67 |

    Already done

    68 | 69 |
      70 | { finishedTodos.map(todo => 71 | todos.remove(todo) } />) } 75 |
    76 |
    77 |
    78 |
    79 | ) 80 | } 81 | 82 | 83 | export default Object.assign( 84 | withStyles(styles)(connect('todos')(Todos)), 85 | { fetchData: load } 86 | ) 87 | -------------------------------------------------------------------------------- /app/components/todos/pending-todo.jsx: -------------------------------------------------------------------------------- 1 | import { memoize } from 'lodash' 2 | 3 | import React from 'react' 4 | import connect from 'core/connect' 5 | 6 | import styles from 'app/styles/todos.css' 7 | 8 | type Props = { 9 | todo: { 10 | id: number, 11 | title: string, 12 | finished: boolean 13 | } 14 | } 15 | 16 | const handleClick = memoize((todo) => () => { todo.finished = true }) 17 | 18 | const PendingTodo = ({ todo }: Props) => 19 |
  • 22 | 26 | 27 | { todo.title } 28 |
  • 29 | 30 | export default connect()(PendingTodo) 31 | -------------------------------------------------------------------------------- /app/images/bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iam4x/futureRX/b1dd1b65a031ceb79acce2150225786c8f7ff294/app/images/bg.png -------------------------------------------------------------------------------- /app/index.js: -------------------------------------------------------------------------------- 1 | // global defined styles 2 | import 'app/styles/global.css' 3 | 4 | import { after } from 'lodash' 5 | import { browserHistory, match } from 'react-router' 6 | 7 | import routes from 'app/routes' 8 | 9 | import createStore from 'app/store' 10 | 11 | import renderApp from 'core/universal-render' 12 | import fetchData from 'core/fetch-data' 13 | 14 | const store = createStore() 15 | renderApp({ store }) 16 | 17 | // we have to define the `browserHistory` listener here since we need to 18 | // dispose the listener on hot module reload 19 | const unlisten = browserHistory.listen(after(2, ({ pathname }) => 20 | match({ routes, location: pathname }, (error, redirect, props) => 21 | props ? fetchData(store, props) : undefined 22 | ) 23 | )) 24 | 25 | if (module.hot) { 26 | module.hot.dispose(unlisten) 27 | module.hot.accept(renderApp) 28 | } 29 | -------------------------------------------------------------------------------- /app/routes.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Route } from 'react-router' 3 | 4 | export default ( 5 | 6 | 7 | 8 | 9 | 10 | ) 11 | -------------------------------------------------------------------------------- /app/store/index.js: -------------------------------------------------------------------------------- 1 | import MobxStore from 'mobx-store' 2 | 3 | const defaultState = { 4 | todos: [] 5 | } 6 | 7 | export default (state = defaultState) => new MobxStore(state) 8 | -------------------------------------------------------------------------------- /app/styles/about.css: -------------------------------------------------------------------------------- 1 | .aboutContainer { 2 | background-color: #FFF; 3 | padding: 20px; 4 | } 5 | -------------------------------------------------------------------------------- /app/styles/global.css: -------------------------------------------------------------------------------- 1 | /* PUT YOUR GLOBAL SCOPE CSS HERE, it should only be external CSS */ 2 | @import 'normalize.css/normalize'; 3 | @import 'bootstrap/dist/css/bootstrap'; 4 | 5 | html, 6 | body { 7 | min-height: 100%; 8 | width: 100%; 9 | } 10 | 11 | body { 12 | background: #EEE; 13 | padding-top: 70px; 14 | padding-bottom: 20px; 15 | } 16 | -------------------------------------------------------------------------------- /app/styles/navbar.css: -------------------------------------------------------------------------------- 1 | .github { 2 | padding-top: 12px; 3 | 4 | iframe:first-child { 5 | margin-right: 15px; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /app/styles/todos.css: -------------------------------------------------------------------------------- 1 | .todosContainer { 2 | background-image: url('../images/bg.png'); 3 | margin-top: 20px; 4 | padding: 20px 20px 50px 20px; 5 | position: relative; 6 | width: 100%; 7 | } 8 | 9 | .todosList { 10 | border-top: 1px solid #EAEAEA; 11 | padding-top: 20px; 12 | padding-left: 0; 13 | list-style-type: none; 14 | vertical-align: middle; 15 | 16 | li { 17 | border-bottom: 1px solid #EAEAEA; 18 | padding-bottom: 5px; 19 | padding-top: 5px; 20 | width: 100%; 21 | 22 | &:last-child { 23 | border-bottom: none; 24 | } 25 | 26 | input[type='checkbox'] { 27 | margin-right: 10px; 28 | } 29 | } 30 | } 31 | 32 | .count { 33 | background-color: #cff3cf; 34 | bottom: 0; 35 | left: 0; 36 | padding: 5px 20px; 37 | position: absolute; 38 | width: 100%; 39 | } 40 | 41 | .pendingTodo { 42 | cursor: pointer; 43 | } 44 | 45 | .finishedTodo { 46 | cursor: default; 47 | text-decoration: line-through; 48 | } 49 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | node: 3 | version: 6.3.0 4 | dependencies: 5 | pre: 6 | - npm set progress=false 7 | test: 8 | post: 9 | - npm run coverage 10 | -------------------------------------------------------------------------------- /constants.js: -------------------------------------------------------------------------------- 1 | const { NODE_ENV = 'development' } = process.env 2 | 3 | // WEBPACK BUILD CONSTANTS 4 | export const JS_REGEX = /\.js$|\.jsx$|\.es6$|\.babel$/ 5 | export const EXCLUDE_REGEX = /node_modules/ 6 | 7 | export const AUTOPREFIXER_BROWSERS = [ 8 | 'Android 2.3', 9 | 'Android >= 4', 10 | 'Chrome >= 35', 11 | 'Firefox >= 31', 12 | 'Explorer >= 9', 13 | 'iOS >= 7', 14 | 'Opera >= 12', 15 | 'Safari >= 7.1' 16 | ] 17 | 18 | export const DEV = NODE_ENV === 'development' 19 | -------------------------------------------------------------------------------- /core/config.js: -------------------------------------------------------------------------------- 1 | const { PORT, NODE_ENV } = process.env 2 | 3 | const APP_CONFIG = { 4 | shared: { 5 | 6 | }, 7 | 8 | development: { 9 | PORT: parseInt(PORT, 10) || 3000 10 | }, 11 | 12 | production: { 13 | PORT: parseInt(PORT, 10) || 3010 14 | } 15 | } 16 | 17 | // default to development config 18 | export default { 19 | ...APP_CONFIG.shared, 20 | ...(APP_CONFIG[NODE_ENV] || APP_CONFIG.development) 21 | } 22 | -------------------------------------------------------------------------------- /core/connect.js: -------------------------------------------------------------------------------- 1 | import { isFunction, isString, isArray } from 'lodash' 2 | import { observer } from 'mobx-react' 3 | import { inject } from 'react-tunnel' 4 | 5 | const nativeObjectMethods = Object.getOwnPropertyNames(Function) 6 | 7 | // borrowed from `react-redux/connect` 8 | // https://github.com/rackt/react-redux/blob/master/src/components/connect.js#L17 9 | const getDisplayName = ({ displayName, name }) => displayName || name || 'Component' 10 | 11 | export default (params) => (Component) => { 12 | // * connect('foo' || [ 'foo', 'bar' ]) -> provide storeKey data into props 13 | // 14 | // in 'foo' out ({ foo: store('foo') }) 15 | // in ['foo', 'bar'] out ({ foo: store('foo'), bar: store('bar') }) 16 | const injectStoresIntoProps = inject(({ store }) => { 17 | if (isString(params)) { 18 | return ({ [params]: store(params) }) 19 | } 20 | 21 | if (isArray(params)) { 22 | return params.reduce((result, storeKey) => 23 | ({ ...result, [storeKey]: store(storeKey) }), {}) 24 | } 25 | 26 | return ({ store }) 27 | }) 28 | 29 | // * observe decorated component by `mobx-react::observer` 30 | // * connect props from context onto decorated component 31 | const FinalComponent = injectStoresIntoProps(observer(Component)) 32 | 33 | // copy missing static methods from `Component` to `FinalComponent` 34 | Object.getOwnPropertyNames(Component).forEach(method => { 35 | const isNotNativeMethod = !nativeObjectMethods.includes(method) 36 | const isNotAlreadyDefined = !isFunction(FinalComponent[method]) 37 | const methodIsFunction = isFunction(Component[method]) 38 | 39 | if (isNotNativeMethod && methodIsFunction && isNotAlreadyDefined) { 40 | FinalComponent[method] = Component[method] 41 | } 42 | }) 43 | 44 | // set `connect(Component)` as displayName 45 | FinalComponent.displayName = `connect(${getDisplayName(Component)})` 46 | 47 | return FinalComponent 48 | } 49 | -------------------------------------------------------------------------------- /core/fetch-data.js: -------------------------------------------------------------------------------- 1 | import { isFunction } from 'lodash' 2 | 3 | export default async (store, { components, params, location: { query } }) => 4 | await Promise.all( 5 | components 6 | .filter(c => isFunction(c.fetchData)) 7 | .map(c => c.fetchData(store, params, query)) 8 | ) 9 | -------------------------------------------------------------------------------- /core/hydrate.js: -------------------------------------------------------------------------------- 1 | import { each } from 'lodash' 2 | import jsonStringifySafe from 'json-stringify-safe' 3 | 4 | export const dehydrate = (store) => jsonStringifySafe(store.contents()) 5 | 6 | export const rehydrate = (store) => each( 7 | window.__appState__, 8 | (data, storeKey) => store.set(storeKey, data) 9 | ) 10 | -------------------------------------------------------------------------------- /core/provide-insert-css.js: -------------------------------------------------------------------------------- 1 | import { Component, PropTypes } from 'react' 2 | 3 | class ProvideInsertCss extends Component { 4 | 5 | props: { 6 | children: any; 7 | insertCss: Function; 8 | }; 9 | 10 | static childContextTypes = { 11 | insertCss: PropTypes.func.isRequired 12 | } 13 | 14 | getChildContext() { 15 | const { insertCss } = this.props 16 | return { insertCss } 17 | } 18 | 19 | render() { 20 | const { children } = this.props 21 | return children 22 | } 23 | 24 | } 25 | 26 | export default ProvideInsertCss 27 | -------------------------------------------------------------------------------- /core/universal-render.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import DevTools from 'mobx-react-devtools' 3 | import { render } from 'react-dom' 4 | import { renderToString } from 'react-dom/server' 5 | import { Provider } from 'react-tunnel' 6 | import { Router, browserHistory, RouterContext, match } from 'react-router' 7 | 8 | import routes from 'app/routes' 9 | 10 | import fetchData from './fetch-data' 11 | import ProvideInsertCss from './provide-insert-css' 12 | import { rehydrate, dehydrate } from './hydrate' 13 | 14 | const { NODE_ENV, BROWSER } = process.env 15 | 16 | export default async ({ store, location, assets } = {}) => { 17 | const css = [] 18 | const insertCss = BROWSER ? 19 | (styles) => styles._insertCss() : 20 | (styles) => css.push(styles._getCss()) 21 | 22 | if (BROWSER) { 23 | rehydrate(store) 24 | 25 | // track potentially unnecessary re-renders 26 | // if we find `debugRender` in the query string 27 | if (NODE_ENV === 'development' && 28 | window.location.search.includes('debugRender')) { 29 | const { whyDidYouUpdate } = require('why-did-you-update') 30 | whyDidYouUpdate(React) 31 | } 32 | 33 | const App = ( 34 | 35 | { () => 36 | 37 | 40 | } 41 | 42 | ) 43 | 44 | const container = document.getElementById('app--container') 45 | 46 | if (NODE_ENV !== 'development') return render(App, container) 47 | 48 | // special render in development: 49 | // * enable react-hot-loader 50 | // * enable mobx-react-devtools 51 | const { AppContainer } = require('react-hot-loader') 52 | const Dev = ( 53 |
    54 | { App } 55 | 56 |
    57 | ) 58 | 59 | render(Dev, container) 60 | } else { 61 | // server side rendering 62 | const ServerHTML = require('server/server-html') 63 | 64 | // promisify `match` from `react-router` 65 | const asyncMatch = () => new Promise((resolve) => 66 | match({ routes, location }, (...args) => resolve(args)) 67 | ) 68 | 69 | const [ routerError, redirect, renderProps ] = await asyncMatch(location, routes) 70 | if (routerError || redirect) throw ({ error: routerError, redirect }) 71 | 72 | await fetchData(store, renderProps) 73 | const appState = dehydrate(store) 74 | 75 | const body = renderToString( 76 | 77 | { () => 78 | 79 | 80 | } 81 | 82 | ) 83 | 84 | return renderToString( 85 | 91 | ) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "futureRX", 3 | "version": "1.0.0", 4 | "description": "🚀 The futuristic stack to create universal React applications with MobX as state manager. With supporting async data fetching out of the box on server side rendering.", 5 | "main": "index.js", 6 | "scripts": { 7 | "lint": "eslint . --ext .jsx,.js", 8 | "dev": "node run/dev", 9 | "ava": "CONFIG=$(pwd)/webpack/test.config.babel.js BABEL_DISABLE_CACHE=1 NODE_ENV=test ava spec/**/*.spec.js", 10 | "test": "nyc npm run ava", 11 | "coverage": "nyc report --reporter=text-lcov > coverage.lcov && codecov -t 8bba035a-a292-4daa-b6d4-68acc439495f", 12 | "build:client": "BABEL_ENV=production webpack --config ./webpack/client.config.babel.js", 13 | "build:server": "BABEL_ENV=production webpack --config ./webpack/server.config.babel.js", 14 | "build": "npm run build:server && npm run build:client" 15 | }, 16 | "author": "iam4x", 17 | "license": "ISC", 18 | "ava": { 19 | "babel": "inherit", 20 | "failFast": true, 21 | "verbose": true, 22 | "require": [ 23 | "./spec/helpers/setup-browser-env", 24 | "babel-register", 25 | "babel-polyfill" 26 | ] 27 | }, 28 | "nyc": { 29 | "extension": [ 30 | ".jsx", 31 | ".js" 32 | ], 33 | "exclude": [ 34 | "spec/", 35 | "webpack/", 36 | "node_modules/", 37 | "dist/" 38 | ] 39 | }, 40 | "dependencies": { 41 | "autoprefixer": "^6.3.6", 42 | "axios": "^0.13.0", 43 | "babel-core": "^6.9.1", 44 | "babel-eslint": "^6.0.4", 45 | "babel-loader": "^6.2.4", 46 | "babel-plugin-lodash": "^3.2.0", 47 | "babel-plugin-react-remove-prop-types": "^2.0.2", 48 | "babel-plugin-transform-react-constant-elements": "^6.9.1", 49 | "babel-plugin-transform-react-inline-elements": "^6.8.0", 50 | "babel-plugin-transform-react-pure-class-to-function": "^1.0.1", 51 | "babel-plugin-typecheck": "^3.9.0", 52 | "babel-polyfill": "^6.9.1", 53 | "babel-preset-save": "^1.1.5", 54 | "bootstrap": "^3.3.6", 55 | "css-loader": "^0.23.1", 56 | "debug": "^2.2.0", 57 | "eslint": "^3.1.1", 58 | "eslint-config-airbnb": "^9.0.1", 59 | "eslint-loader": "^1.3.0", 60 | "eslint-plugin-import": "^1.8.1", 61 | "eslint-plugin-jsx-a11y": "^2.0.1", 62 | "eslint-plugin-react": "^5.1.1", 63 | "extract-text-webpack-plugin": "^1.0.1", 64 | "file-loader": "^0.9.0", 65 | "image-webpack-loader": "^1.8.0", 66 | "isomorphic-style-loader": "^1.0.0", 67 | "json-loader": "^0.5.4", 68 | "json-stringify-safe": "^5.0.1", 69 | "koa": "^2.0.0", 70 | "koa-compress": "^1.0.9", 71 | "koa-html-minifier": "^1.0.1", 72 | "koa-mount": "^1.3.0", 73 | "koa-proxy": "^0.6.0", 74 | "koa-static-cache": "^3.1.7", 75 | "lodash": "^4.13.1", 76 | "markdown": "^0.5.0", 77 | "mobx": "^2.3.6", 78 | "mobx-react": "^3.3.1", 79 | "mobx-store": "^3.3.1", 80 | "normalize.css": "^4.2.0", 81 | "pluralize": "^3.0.0", 82 | "postcss": "^5.1.0", 83 | "postcss-import": "8.1.0", 84 | "postcss-loader": "^0.9.1", 85 | "postcss-url": "^5.1.2", 86 | "precss": "^1.4.0", 87 | "raw-loader": "^0.5.1", 88 | "react": "^15.1.0", 89 | "react-dom": "^15.1.0", 90 | "react-hot-loader": "^3.0.0-beta.2", 91 | "react-router": "^2.4.1", 92 | "react-tunnel": "^0.1.0", 93 | "style-loader": "^0.13.1", 94 | "webpack": "^2.1.0-beta.15", 95 | "webpack-node-externals": "^1.2.0" 96 | }, 97 | "devDependencies": { 98 | "ava": "^0.15.2", 99 | "babel-plugin-resolver": "0.0.7", 100 | "babel-plugin-webpack-loaders": "^0.7.0", 101 | "browser-sync": "^2.13.0", 102 | "browser-sync-webpack-plugin": "^1.0.3", 103 | "chai": "^3.5.0", 104 | "chai-enzyme": "^0.5.0", 105 | "codecov": "^1.0.1", 106 | "enzyme": "^2.3.0", 107 | "faux-jax": "^5.0.1", 108 | "jsdom": "^9.4.0", 109 | "koa-webpack-dev-middleware": "^1.2.1", 110 | "koa-webpack-hot-middleware": "^1.0.3", 111 | "mobx-react-devtools": "^4.2.0", 112 | "node-watch": "^0.4.0", 113 | "nyc": "^7.0.0-alpha.5", 114 | "open": "0.0.5", 115 | "sinon": "^1.17.4", 116 | "sinon-chai": "^2.8.0", 117 | "source-map-support": "^0.4.1", 118 | "webpack-dev-middleware": "^1.6.1", 119 | "webpack-hot-middleware": "^2.10.0", 120 | "why-did-you-update": "0.0.8" 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /run/dev.js: -------------------------------------------------------------------------------- 1 | process.env.BABEL_ENV = 'browser' 2 | process.env.NODE_ENV = 'development' 3 | 4 | require('babel-register') 5 | require('babel-polyfill') 6 | 7 | const debug = require('debug') 8 | 9 | const startRenderServer = require('./utils/start-render-server') 10 | const startWebpackServer = require('./utils/start-webpack-server') 11 | 12 | debug.enable('dev,koa') 13 | 14 | startWebpackServer(startRenderServer) 15 | -------------------------------------------------------------------------------- /run/utils/start-render-server.js: -------------------------------------------------------------------------------- 1 | import { resolve } from 'path' 2 | import { exec, fork } from 'child_process' 3 | 4 | import debug from 'debug' 5 | import open from 'open' 6 | 7 | import browserSync from 'browser-sync' 8 | import watch from 'node-watch' 9 | 10 | import { PORT } from '../../core/config' 11 | 12 | let server = null 13 | let serverReload = false 14 | let firstServerStart = true 15 | 16 | const asyncExec = (cmd, opts) => new Promise(r => 17 | exec(cmd, opts, (e) => { if (e) { throw e } else { r() } }).stdout.pipe(process.stdout)) 18 | 19 | const startServer = async () => { 20 | const env = { ...process.env, DEBUG: 'dev,koa', BABEL_ENV: 'server' } 21 | 22 | // compile first the server code, 23 | // it will be re-compiled at every server restart 24 | const webpackServerConfig = resolve(__dirname, '../../webpack/server.config.babel.js') 25 | await asyncExec(`webpack --config ${webpackServerConfig}`, { env, encoding: 'utf8' }) 26 | 27 | debug('dev')('compiled server code updated') 28 | 29 | // define `restartServer` function 30 | const restartServer = () => { 31 | if (server) { 32 | debug('dev')('restarting koa application') 33 | serverReload = true 34 | server.kill('SIGTERM') 35 | server = null 36 | startServer() 37 | } 38 | } 39 | 40 | server = fork(resolve(__dirname, '../../dist/server.js'), { env }) 41 | 42 | server.once('message', (message) => { 43 | if (message.match(/^online$/)) { 44 | // tell `browserSync` to reload the browser, 45 | // server code has restarted 46 | if (serverReload) { 47 | serverReload = false 48 | browserSync.get('bs-webpack-plugin').reload() 49 | } 50 | 51 | // open browser after server started 52 | if (firstServerStart) { 53 | firstServerStart = false 54 | open(`http://localhost:${PORT + 2}`) 55 | 56 | // watch for `rs` into console to restart server 57 | process.stdin.setEncoding('utf8') 58 | process.stdin.on('data', (data) => { 59 | const parsedData = `${data}`.trim().toLowerCase() 60 | if (parsedData === 'rs') restartServer() 61 | }) 62 | 63 | // watch for `./server` changes and auto-reload server & browser 64 | watch(resolve(__dirname, '../../server'), () => restartServer()) 65 | } 66 | } 67 | }) 68 | } 69 | 70 | process.on('exit', () => server ? server.kill('SIGTERM') : undefined) 71 | 72 | export default startServer 73 | -------------------------------------------------------------------------------- /run/utils/start-webpack-server.js: -------------------------------------------------------------------------------- 1 | import debug from 'debug' 2 | 3 | import Koa from 'koa' 4 | import convert from 'koa-convert' 5 | import webpack from 'webpack' 6 | 7 | import clientConfig from '../../webpack/client.config.babel' 8 | import { PORT } from '../../core/config' 9 | 10 | export default (afterBundle) => { 11 | const webpackServer = new Koa() 12 | const webpackClientConfig = clientConfig(afterBundle) 13 | const compiler = webpack(webpackClientConfig) 14 | 15 | const config = { 16 | port: PORT + 1, 17 | options: { 18 | publicPath: `http://localhost:${PORT + 1}/assets/`, 19 | hot: true, 20 | stats: { 21 | assets: true, 22 | colors: true, 23 | version: false, 24 | hash: false, 25 | timings: true, 26 | chunks: false, 27 | chunkModules: false 28 | } 29 | } 30 | } 31 | 32 | webpackServer.use(convert(require('koa-webpack-dev-middleware')(compiler, config.options))) 33 | webpackServer.use(convert(require('koa-webpack-hot-middleware')(compiler))) 34 | 35 | webpackServer.listen(config.port, '0.0.0.0', () => 36 | debug('dev')('`webpack-dev-server` listening on port %s', config.port)) 37 | } 38 | -------------------------------------------------------------------------------- /run/utils/write-stats.js: -------------------------------------------------------------------------------- 1 | // borrowed from https://github.com/gpbl/isomorphic500/blob/master/webpack%2Futils%2Fwrite-stats.js 2 | import fs from 'fs' 3 | import debug from 'debug' 4 | import { extname, resolve } from 'path' 5 | 6 | const filepath = resolve(__dirname, '../../dist/webpack-stats.json') 7 | 8 | function writeStats(stats) { 9 | const publicPath = this.options.output.publicPath 10 | const json = stats.toJson() 11 | 12 | // get chunks by name and extensions 13 | const getChunks = function(name, ext = /.js$/) { 14 | let chunks = json.assetsByChunkName[name] 15 | 16 | // a chunk could be a string or an array, so make sure it is an array 17 | if (!(Array.isArray(chunks))) { 18 | chunks = [ chunks ] 19 | } 20 | 21 | return chunks 22 | .filter(chunk => ext.test(extname(chunk))) // filter by extension 23 | .map(chunk => `${publicPath}${chunk}`) // add public path to it 24 | } 25 | 26 | const script = getChunks('app', /js/) 27 | const style = getChunks('app', /css/) 28 | 29 | // Find compiled images in modules 30 | // it will be used to map original filename to the compiled one 31 | // for server side rendering 32 | const imagesRegex = /\.(jpe?g|png|gif|svg)$/ 33 | const images = json.modules 34 | .filter(module => imagesRegex.test(module.name)) 35 | .map(image => 36 | ({ 37 | original: image.name, 38 | compiled: `${publicPath}${image.assets[0]}` 39 | }) 40 | ) 41 | 42 | const content = { script, style, images } 43 | 44 | fs.writeFileSync(filepath, JSON.stringify(content)) 45 | debug('dev')('`webpack-stats.json` updated') 46 | } 47 | 48 | export default function() { this.plugin('done', writeStats) } 49 | -------------------------------------------------------------------------------- /server/koa.js: -------------------------------------------------------------------------------- 1 | import debug from 'debug' 2 | import { resolve } from 'path' 3 | 4 | import Koa from 'koa' 5 | import convert from 'koa-convert' 6 | import mount from 'koa-mount' 7 | import staticCache from 'koa-static-cache' 8 | 9 | import createStore from 'app/store' 10 | import render from 'core/universal-render' 11 | import { PORT } from 'core/config' 12 | 13 | const { NODE_ENV } = process.env 14 | 15 | const app = new Koa() 16 | 17 | debug.enable('koa') 18 | 19 | // production middlewares 20 | if (NODE_ENV !== 'development') { 21 | app.use(convert(require('koa-compress')())) 22 | app.use(convert(require('koa-html-minifier')({ 23 | caseSensitive: true, 24 | collapseWhitespace: true, 25 | collapseBooleanAttributes: true, 26 | collapseInlineTagWhitespace: true, 27 | decodeEntities: true, 28 | minifyCSS: true, 29 | minifyJS: true, 30 | minifyURLs: true, 31 | removeAttributeQuotes: true, 32 | removeEmptyAttributes: true, 33 | removeRedundantAttributes: true, 34 | removeScriptTypeAttributes: true, 35 | removeStyleLinkTypeAttributes: true, 36 | useShortDoctype: true 37 | }))) 38 | } 39 | 40 | // Proxy asset folder to webpack development server in development mode 41 | if (NODE_ENV === 'development') { 42 | const proxy = require('koa-proxy')({ 43 | host: `http://0.0.0.0:${PORT + 1}`, 44 | map: (filePath) => `assets/${filePath}` 45 | }) 46 | app.use(convert(mount('/assets', proxy))) 47 | } else { 48 | const cacheOpts = { maxAge: 86400000, gzip: true } 49 | app.use(convert(mount('/assets', staticCache(resolve('./dist'), cacheOpts)))) 50 | } 51 | 52 | app.use(async (ctx) => { 53 | try { 54 | ctx.status = 200 55 | ctx.body = await render({ 56 | assets: require('./webpack-stats.json'), 57 | store: createStore(), 58 | location: ctx.request.url 59 | }) 60 | 61 | // Don't cache assets name on dev 62 | if (NODE_ENV === 'development') { 63 | delete require.cache[require.resolve('./webpack-stats.json')] 64 | } 65 | } catch (err) { 66 | // Render 500 error page from server 67 | const { error, redirect } = err 68 | if (error) throw error 69 | // Handle component `onEnter` transition 70 | if (redirect) { 71 | const { pathname, search } = redirect 72 | ctx.redirect(pathname + search) 73 | } else { 74 | throw err 75 | } 76 | } 77 | }) 78 | 79 | app.listen(PORT) 80 | 81 | // Tell parent process koa-server is started 82 | if (process.send) process.send('online') 83 | debug('koa')('`koa-render-server` started on port %s', PORT) 84 | -------------------------------------------------------------------------------- /server/server-html.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | type Props = { 4 | assets: {}; 5 | locale: string; 6 | body: string; 7 | appState: string; 8 | css: string; 9 | }; 10 | 11 | const ServerHTML = ({ assets, locale, body, appState, css }: Props) => ( 12 | 13 | 14 | 15 | 16 | 17 | { /* Global bundled styles */ } 18 | { assets.style.map((href, idx) => 19 | ) } 20 | 21 | { /* Critical rendered styles */ } 22 |