├── .babelrc ├── .editorconfig ├── .eslintrc ├── .gitignore ├── README.md ├── index.js ├── package.json ├── server ├── index.js └── render.js ├── src ├── actions │ ├── AppActions.js │ ├── ForumActions.js │ ├── PostActions.js │ └── apiUtils │ │ ├── ForumAPIs.js │ │ └── PostAPIs.js ├── client.js ├── components │ ├── HtmlDocument.js │ └── common │ │ ├── CircularProgress.js │ │ ├── Header.js │ │ └── PostList.js ├── constants.js ├── decorators │ ├── createEnterTransitionHook.js │ ├── index.js │ └── propSliceWillChange.js ├── middleware │ └── logger.js ├── reducers │ ├── AppReducer.js │ ├── ForumReducer.js │ ├── PostReducer.js │ └── index.js ├── routes │ ├── index.js │ └── views │ │ ├── App.js │ │ ├── ForumListPageView.js │ │ ├── ForumPostPageView.js │ │ ├── HomePageView.js │ │ ├── PostPageView.js │ │ └── SearchPostPageView.js ├── server.js ├── styles │ ├── common.styl │ ├── common │ │ ├── Header.styl │ │ └── PostList.styl │ └── views │ │ ├── App.styl │ │ ├── ForumListPageView.styl │ │ ├── ForumPostPageView.styl │ │ ├── HomePageView.styl │ │ ├── PostPageView.styl │ │ └── SearchPostPageView.styl └── utils │ ├── createReducer.js │ └── shouldImmutableComponentUpdate.js ├── webpack.config.js └── webpack ├── config.js ├── dev.config.js ├── index.js ├── prod.config.js └── utils ├── fake-data-provider.js ├── mkdirs.js ├── notify-stats.js └── write-stats.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "stage": 0 3 | } 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | 11 | # Matches multiple files with brace expansion notation 12 | # Set default charset 13 | [*.{js,py}] 14 | charset = utf-8 15 | 16 | # 4 space indentation 17 | [*.py] 18 | indent_style = space 19 | indent_size = 4 20 | 21 | # Tab indentation (no size specified) 22 | [Makefile] 23 | indent_style = tab 24 | 25 | # Indentation override for all JS under lib directory 26 | [*.js] 27 | indent_style = space 28 | indent_size = 2 29 | 30 | # Matches the exact files either package.json or .travis.yml 31 | [{package.json,.travis.yml}] 32 | indent_style = space 33 | indent_size = 2 34 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "env": { 4 | "browser": true, 5 | "node": true, 6 | }, 7 | "ecmaFeatures": { 8 | "arrowFunctions": true, 9 | "classes": true, 10 | "modules": true, 11 | "jsx": true 12 | }, 13 | "plugins": [ 14 | "react" 15 | ], 16 | "rules": { 17 | // Possible Errors 18 | "comma-dangle": [2, "always-multiline"], 19 | // Best Practices 20 | "consistent-return": [0], 21 | "yoda": [2, "always"], 22 | // Variables 23 | "no-use-before-define": [2, "nofunc"], 24 | // Stylistic Issues 25 | "camelcase": [0], 26 | "no-underscore-dangle": [0], 27 | }, 28 | } 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by http://www.gitignore.io 2 | 3 | ### Editor 4 | *.swp 5 | 6 | ### Node ### 7 | # Logs 8 | logs 9 | *.log 10 | 11 | # Runtime data 12 | pids 13 | *.pid 14 | *.seed 15 | 16 | # OSX 17 | .DS_Store 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | 25 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 26 | .grunt 27 | 28 | # Compiled binary addons (http://nodejs.org/api/addons.html) 29 | build/Release 30 | 31 | # Dependency directory 32 | # Commenting this out is preferred by some people, see 33 | # https://npmjs.org/doc/faq.html#Should-I-check-my-node_modules-folder-into-git 34 | node_modules 35 | 36 | # Users Environment Variables 37 | .lock-wscript 38 | 39 | ### Project ### 40 | public/build/ 41 | server/build/ 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Redux-Universal 2 | 3 | 4 | ## Features 5 | 6 | - [Universal JavaScript] 7 | - [React-Router] 8 | - Reactive Programming 9 | - Webpack Server Bundling 10 | - Async Data 11 | - [Immutable] Data Structure 12 | - An of course, integration with [Redux] 13 | 14 | 15 | ## Medium Post 16 | 17 | I'd like to share some ideas found in the repo, here you go: 18 | 19 | * [Redux-Universal](https://medium.com/@tomchentw/redux-universal-576fb9475b5b) 20 | 21 | 22 | ## Usage 23 | 24 | ```sh 25 | npm install 26 | ``` 27 | 28 | ### Development 29 | 30 | ```sh 31 | npm run dev 32 | ``` 33 | 34 | It will start: 35 | 36 | - [express] server at port 3000 37 | - [webpack-dev-server] at port 8080 38 | 39 | Please visit [http://localhost:3000/] to get the page. 40 | 41 | ### Production 42 | 43 | Into two steps, first is build webpack assets: 44 | 45 | ```sh 46 | npm run build 47 | ``` 48 | 49 | Then, start [express] server: 50 | 51 | ```sh 52 | npm start 53 | ``` 54 | 55 | 56 | ## Credits 57 | 58 | - [@tommy351]: Original creator of this repo 59 | - [@emmenko]: for decorator idea in [redux-react-router-async-example] 60 | 61 | 62 | [Universal JavaScript]: https://medium.com/@mjackson/universal-javascript-4761051b7ae9 63 | [React-Router]: https://github.com/rackt/react-router 64 | [Immutable]: http://facebook.github.io/immutable-js/ 65 | [Redux]: https://github.com/gaearon/redux 66 | [express]: http://expressjs.com/ 67 | [webpack-dev-server]: http://webpack.github.io/docs/webpack-dev-server.html 68 | [@tommy351]: https://github.com/tommy351 69 | [@emmenko]: https://github.com/emmenko 70 | [redux-react-router-async-example]: https://github.com/emmenko/redux-react-router-async-example 71 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | require("babel-core/register"); 2 | 3 | require("./server"); 4 | 5 | if ("production" !== process.env.NODE_ENV) { 6 | // In development, serve the static files from the webpack dev server. 7 | require("./webpack"); 8 | } 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-universal", 3 | "version": "1.0.0", 4 | "description": "Redux for universal app makes awesome", 5 | "main": "index.js", 6 | "scripts": { 7 | "clean": "rm -rf public/build server/build", 8 | "dev": "NODE_ENV=development nodev --ignore server/build --ignore public/build --ignore src index.js", 9 | "build": "NODE_ENV=production webpack --stats --progress", 10 | "start": "NODE_ENV=production node index", 11 | "eslint": "eslint . --ext .js,.jsx", 12 | "test": "npm run eslint" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/tomchentw/redux-universal.git" 17 | }, 18 | "keywords": [ 19 | "react", 20 | "redux", 21 | "universal" 22 | ], 23 | "author": { 24 | "name": "tomchentw", 25 | "email": "developer@tomchentw.com", 26 | "url": "https://github.com/tomchentw" 27 | }, 28 | "license": "MIT", 29 | "bugs": { 30 | "url": "https://github.com/tomchentw/redux-universal/issues" 31 | }, 32 | "homepage": "https://github.com/tomchentw/redux-universal#readme", 33 | "devDependencies": { 34 | "autoprefixer-core": "^5.2.1", 35 | "babel-core": "<=5.6.20 || >5.8.12", 36 | "babel-eslint": "^3.1.1", 37 | "babel-loader": "^5.1.0", 38 | "css-loader": "^0.12.1", 39 | "cssnano": "^1.2.0", 40 | "eslint": "^0.21.0", 41 | "eslint-loader": "^0.11.2", 42 | "eslint-plugin-react": "^2.6.3", 43 | "extract-text-webpack-plugin": "^0.8.0", 44 | "json-loader": "^0.5.2", 45 | "nib": "^1.1.0", 46 | "node-libs-browser": "^0.5.2", 47 | "nodev": "^0.8.7", 48 | "null-loader": "^0.1.1", 49 | "postcss-loader": "^0.4.3", 50 | "react-hot-loader": "^1.2.7", 51 | "style-loader": "^0.12.2", 52 | "stylus-loader": "^1.2.1", 53 | "webpack": "^1.9.5", 54 | "webpack-dev-server": "^1.8.2" 55 | }, 56 | "dependencies": { 57 | "babel-core": "^5.8.14", 58 | "body-parser": "^1.12.4", 59 | "classnames": "^2.1.1", 60 | "express": "^4.12.4", 61 | "faker": "^3.0.0", 62 | "immutable": "^3.7.2", 63 | "isomorphic-fetch": "^2.1.1", 64 | "keymirror": "^0.1.1", 65 | "lodash": "^3.8.0", 66 | "normalize.css": "^3.0.3", 67 | "object-path": "^0.9.2", 68 | "react": "^0.13.3", 69 | "react-immutable-render-mixin": "^0.8.1", 70 | "react-redux": "^0.2.2", 71 | "react-router": "1.0.0-beta3", 72 | "redux": "^1.0.0-rc", 73 | "redux-thunk": "^0.1.0", 74 | "require-uncached": "^1.0.2", 75 | "serialize-javascript": "^1.0.0", 76 | "serve-static": "^1.10.0", 77 | "thenify": "^3.1.0", 78 | "warning": "^2.0.0" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | import {join as joinPath} from "path"; 2 | import express from "express"; 3 | import bodyParser from "body-parser"; 4 | import serveStatic from "serve-static"; 5 | 6 | const server = express(); 7 | const IS_PRODUCTION = "production" === process.env.NODE_ENV; 8 | 9 | server.use(bodyParser.json()); 10 | 11 | if (IS_PRODUCTION) { 12 | // On production, use the public directory for static files 13 | // This directory is created by webpack on build time. 14 | server.use(serveStatic(joinPath(__dirname, "../public"))); 15 | } 16 | 17 | // Render the app server-side and send it as response. 18 | server.get("/*", require("./render")); 19 | 20 | // Catch server error 21 | server.use((err, req, res, next) => { 22 | console.error("Error on request %s %s", req.method, req.url); 23 | console.error(err.stack); 24 | res.status(500).send("Server error"); 25 | }); 26 | 27 | server.listen(3000, () => { 28 | console.log("Server listening on port 3000"); 29 | }); 30 | -------------------------------------------------------------------------------- /server/render.js: -------------------------------------------------------------------------------- 1 | import requireUncached from "require-uncached"; 2 | 3 | const possibleUncachedRequire = ("production" !== process.env.NODE_ENV ? requireUncached : require); 4 | 5 | export default function(req, res, next) { 6 | const webpackStats = possibleUncachedRequire("../public/build/webpack-stats.json"); 7 | const {createHtmlResponse} = possibleUncachedRequire("./build/main"); 8 | 9 | return createHtmlResponse({ 10 | webpackStats, 11 | request: { 12 | path: req.path, 13 | query: req.query, 14 | }, 15 | }) 16 | .then(({status, pathname, body}) => { 17 | if (302 === status) { 18 | res.redirect(pathname); 19 | } else { 20 | res.status(status).send(body); 21 | } 22 | }, next); 23 | } 24 | -------------------------------------------------------------------------------- /src/actions/AppActions.js: -------------------------------------------------------------------------------- 1 | import {ActionTypes} from "../constants"; 2 | 3 | export function setTitle(title, appendSitename = true) { 4 | return { 5 | type: ActionTypes.SET_TITLE, 6 | title, 7 | appendSitename, 8 | }; 9 | } 10 | 11 | export function setStatus(status) { 12 | return { 13 | type: ActionTypes.SET_STATUS, 14 | status, 15 | }; 16 | } 17 | 18 | export function toggleLoginModal(active) { 19 | return { 20 | type: ActionTypes.TOGGLE_LOGIN_MODAL, 21 | active, 22 | }; 23 | } 24 | -------------------------------------------------------------------------------- /src/actions/ForumActions.js: -------------------------------------------------------------------------------- 1 | import {ActionTypes} from "../constants"; 2 | import {fetchForumList} from "./apiUtils/ForumAPIs"; 3 | 4 | export function getForumList() { 5 | // This is an asynchronous action and we need to dispatch again when respond 6 | // comes in. So we return a factory function to accept a dispatch function. 7 | // This is handled by redux-thunk 8 | return dispatch => { 9 | return fetchForumList() 10 | .then(({data}) => { 11 | dispatch({ 12 | type: ActionTypes.UPDATE_FORUM_LIST_SUCCESS, 13 | list: data, 14 | }); 15 | 16 | return data; 17 | }); 18 | }; 19 | } 20 | -------------------------------------------------------------------------------- /src/actions/PostActions.js: -------------------------------------------------------------------------------- 1 | import {ActionTypes} from "../constants"; 2 | import {fetchPostList, fetchPostListBySearchTerm, fetchPost} from "./apiUtils/PostAPIs"; 3 | 4 | export function getPostList(forumId) { 5 | return dispatch => { 6 | dispatch({ 7 | type: ActionTypes.UPDATE_POST_LIST_START, 8 | forumId, 9 | }); 10 | 11 | return fetchPostList(forumId) 12 | .then(({data}) => { 13 | dispatch({ 14 | type: ActionTypes.UPDATE_POST_LIST_SUCCESS, 15 | forumId, 16 | list: data, 17 | }); 18 | 19 | return data; 20 | }); 21 | }; 22 | } 23 | 24 | export function getPostListBySearchTerm(searchTerm) { 25 | return dispatch => { 26 | dispatch({ 27 | type: ActionTypes.UPDATE_POST_SEARCH_LIST_START, 28 | searchTerm, 29 | }); 30 | 31 | return fetchPostListBySearchTerm(searchTerm) 32 | .then(({data}) => { 33 | dispatch({ 34 | type: ActionTypes.UPDATE_POST_SEARCH_LIST_SUCCESS, 35 | searchTerm, 36 | list: data, 37 | }); 38 | 39 | return data; 40 | }); 41 | }; 42 | } 43 | 44 | export function getPost(postId) { 45 | return dispatch => { 46 | dispatch({ 47 | type: ActionTypes.UPDATE_POST_START, 48 | postId, 49 | }); 50 | 51 | return fetchPost(postId) 52 | .then(({data}) => { 53 | dispatch({ 54 | type: ActionTypes.UPDATE_POST_SUCCESS, 55 | item: data, 56 | }); 57 | 58 | return data; 59 | }); 60 | }; 61 | } 62 | -------------------------------------------------------------------------------- /src/actions/apiUtils/ForumAPIs.js: -------------------------------------------------------------------------------- 1 | // import fetch from "isomorphic-fetch"; 2 | 3 | export function fetchForumList() { 4 | // Normally you should just return fetch("/api/forums/") ... 5 | return new Promise((resolve) => { 6 | setTimeout(() => { 7 | resolve({ 8 | data: global.fakeForumList, 9 | }); 10 | }, 800); 11 | }); 12 | } 13 | -------------------------------------------------------------------------------- /src/actions/apiUtils/PostAPIs.js: -------------------------------------------------------------------------------- 1 | // import fetch from "isomorphic-fetch"; 2 | import faker from "faker"; 3 | 4 | export function fetchPostList(forumId) { 5 | // Normally you should just return fetch(`/api/posts/`) ... 6 | return new Promise((resolve) => { 7 | setTimeout(() => { 8 | resolve({ 9 | data: global.fakePostList, 10 | }); 11 | }, 800); 12 | }); 13 | } 14 | 15 | export function fetchPostListBySearchTerm(term) { 16 | // Normally you should just return fetch(`/api/searchPosts/`) ... 17 | return new Promise((resolve) => { 18 | setTimeout(() => { 19 | const fakeSearchList = global.fakePostSearchList.filter(() => 0.33 > Math.random()); 20 | 21 | resolve({ 22 | data: fakeSearchList, 23 | }); 24 | }, 800); 25 | }); 26 | } 27 | 28 | export function fetchPost(postId) { 29 | // Normally you should just return fetch(`/api/posts/:postId`) ... 30 | return new Promise((resolve) => { 31 | setTimeout(() => { 32 | resolve({ 33 | data: { 34 | ...([...global.fakePostList, ...global.fakePostSearchList].filter(it => postId === it.id)[0]), 35 | content: faker.lorem.paragraphs(), 36 | }, 37 | }); 38 | }, 800); 39 | }); 40 | } 41 | -------------------------------------------------------------------------------- /src/client.js: -------------------------------------------------------------------------------- 1 | require("babel-core/polyfill"); 2 | 3 | import { createStore, combineReducers, applyMiddleware } from "redux"; 4 | import thunkMiddleware from "redux-thunk"; 5 | import React from "react"; 6 | import {Provider} from "react-redux"; 7 | import {Router} from "react-router"; 8 | import {history} from "react-router/lib/BrowserHistory"; 9 | 10 | import {ActionTypes} from "./constants"; 11 | import * as reducers from "./reducers"; 12 | import loggerMiddleware from "./middleware/logger"; 13 | 14 | const initialState = window.$STATE; 15 | 16 | const reducer = combineReducers(reducers); 17 | const finalCreateStore = applyMiddleware( 18 | thunkMiddleware, 19 | loggerMiddleware 20 | )(createStore); 21 | const store = finalCreateStore(reducer, initialState); 22 | 23 | store.dispatch({ type: ActionTypes.REHYDRATE }); 24 | 25 | const childrenRoutes = require("./routes")(store); 26 | 27 | const JAVASCRIPT_IS_ENABLED = true; // Change this to false to see how it works? 28 | 29 | if (JAVASCRIPT_IS_ENABLED) { 30 | React.render(( 31 | 32 | {() => } 33 | 34 | ), document.getElementById("root")); 35 | } 36 | -------------------------------------------------------------------------------- /src/components/HtmlDocument.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from "react"; 2 | import serialize from "serialize-javascript"; 3 | 4 | export default class HtmlDocument extends React.Component { 5 | 6 | static propTypes = { 7 | state: PropTypes.object.isRequired, 8 | markup: PropTypes.string.isRequired, 9 | webpackStats: PropTypes.object.isRequired, 10 | } 11 | 12 | render() { 13 | const {state, markup, webpackStats} = this.props; 14 | const dehydratedState = "window.$STATE=" + serialize(state); 15 | 16 | const style = [].concat( 17 | webpackStats.main.css 18 | ); 19 | 20 | const script = [].concat( 21 | webpackStats.vendor.js, 22 | webpackStats.main.js 23 | ); 24 | 25 | return ( 26 | 27 | 28 | 29 | {state.AppReducer.fullTitle} 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | {style.map((href, key) => )} 40 | 41 | 42 |
43 |