├── .babelrc ├── .gitignore ├── gulp ├── index.js └── tasks │ ├── babel.js │ ├── images.js │ ├── nodemon.js │ ├── scripts.js │ ├── styles.js │ └── symlink.js ├── gulpfile.js ├── package.json └── src ├── app ├── bundles.js ├── components │ └── App.jsx ├── constants.js ├── errors │ ├── AuthorizationError.js │ └── index.js ├── history.js ├── reducers │ ├── index.js │ └── session.js ├── store.js └── utils │ ├── callApi.js │ ├── createApiCaller.js │ ├── createApiMiddleware.js │ ├── createCustomStore.js │ └── renderFullPage.js ├── client ├── index.jsx ├── routes.js └── styles │ └── index.scss └── server ├── index.jsx └── routes.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ "es2015", "react", "stage-1" ] 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | -------------------------------------------------------------------------------- /gulp/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const globby = require('globby'); 4 | const gulp = require('gulp'); 5 | const gutil = require('gulp-util'); 6 | 7 | globby.sync(__dirname + '/tasks/*.js').forEach(task => require(task)); 8 | 9 | const defaultTasks = [ 'babel', 'images', 'scripts', 'styles', 'symlink' ]; 10 | if (gutil.env.dev) { 11 | defaultTasks.push('nodemon'); 12 | } 13 | 14 | gulp.task('default', defaultTasks); 15 | -------------------------------------------------------------------------------- /gulp/tasks/babel.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const gulp = require('gulp'); 4 | const gutil = require('gulp-util'); 5 | const c = gutil.colors; 6 | 7 | gulp.task('babel', () => { 8 | const babel = require('gulp-babel'); 9 | const del = require('del'); 10 | const src = [ 11 | 'src/**/*.{js,jsx}', 12 | '!src/client/*.{js,jsx}' 13 | ]; 14 | 15 | const run = (e, path) => { 16 | let runSrc = src; 17 | 18 | // If this function was triggered by a watch we only need 19 | // to transform the changed files. If a file was deleted we 20 | // need to delete the corresponding transformed file 21 | if (e) { 22 | runSrc = e.path.replace(process.cwd() + '/', ''); 23 | 24 | if (e.type === 'deleted') { 25 | return del(runSrc.replace(/^src/, 'dist')); 26 | } 27 | 28 | gutil.log(`${c.cyan('babel')}: ${c.yellow(runSrc)} ${e.type}, converting`); 29 | } else { 30 | gutil.log(`${c.cyan('babel')}: converting`); 31 | } 32 | 33 | return gulp.src(runSrc, { base: 'src' }) 34 | .pipe(babel()) 35 | .pipe(gulp.dest('dist')); 36 | } 37 | 38 | if (gutil.env.dev) { 39 | gutil.log(`${c.cyan('babel')}: watching`); 40 | gulp.watch(src, run); 41 | } 42 | 43 | return run(); 44 | }); 45 | -------------------------------------------------------------------------------- /gulp/tasks/images.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const gulp = require('gulp'); 4 | const gutil = require('gulp-util'); 5 | const c = gutil.colors; 6 | 7 | /** 8 | * Pull images through imagemin 9 | */ 10 | gulp.task('images', function() { 11 | const src = [ 12 | 'src/client/images/*.{gif,jpg,png,svg}', 13 | 'src/client/images/**/*.{gif,jpg,png,svg}', 14 | 'src/modules/*/images/*.{gif,jpg,png,svg}', 15 | 'src/modules/*/images/**/*.{gif,jpg,png,svg}' 16 | ]; 17 | 18 | if (gutil.env.dev) { 19 | gutil.log(`${c.cyan('images')}: watching`); 20 | gulp.watch(src, e => run(src, e)); 21 | } 22 | 23 | return run(src); 24 | }); 25 | 26 | function run(src, e) { 27 | if (e) { 28 | src = e.path.replace(`${process.cwd()}/`, ''); 29 | gutil.log(`${c.cyan('images')}: ${c.yellow(src)} ${e.type}, minifying`); 30 | } else { 31 | gutil.log(`${c.cyan('images')}: minifying`); 32 | } 33 | 34 | return gulp.src(src, { base: 'src' }) 35 | .pipe(require('gulp-rename')(path => { 36 | const m = path.dirname.match(/^modules\/([^\/]+)/); 37 | path.dirname = m ? 38 | path.dirname.replace(`${m[0]}/images`, m[1]) : 39 | path.dirname.replace('client/images', 'app'); 40 | })) 41 | .pipe(require('gulp-imagemin')()) 42 | .pipe(gulp.dest('dist/public/img')); 43 | }; 44 | -------------------------------------------------------------------------------- /gulp/tasks/nodemon.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const gulp = require('gulp'); 4 | const gutil = require('gulp-util'); 5 | const c = gutil.colors; 6 | 7 | /** 8 | * Development server 9 | */ 10 | gulp.task('nodemon', [ 'babel' ], (cb) => { 11 | const nodemon = require('nodemon'); 12 | 13 | nodemon({ 14 | script: './dist/server/index.js', 15 | watch: './dist' 16 | }) 17 | .once('start', cb) 18 | .on('start', () => { 19 | gutil.log(`${c.cyan('nodemon')}: started`); 20 | }) 21 | .on('restart', (files) => { 22 | gutil.log(`${c.cyan('nodemon')}: ${c.yellow(files[0].replace(__dirname + '/', ''))} changed - restarting`); 23 | }) 24 | }); 25 | 26 | -------------------------------------------------------------------------------- /gulp/tasks/scripts.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const gulp = require('gulp'); 4 | const gutil = require('gulp-util'); 5 | const c = gutil.colors; 6 | 7 | /** 8 | * Bundle scripts for browser usage 9 | */ 10 | gulp.task('scripts', () => { 11 | const browserify = require('browserify'); 12 | 13 | const bundleOpts = { 14 | debug: true, 15 | extensions: [ '.jsx' ], 16 | transform: [ require('babelify') ] 17 | }; 18 | 19 | // app bundler 20 | let appBundler = browserify('src/client', bundleOpts); 21 | 22 | // expose app modules 23 | exposeDir(appBundler, './src/app', 'app'); 24 | 25 | // this will hold all bundlers and their outputs 26 | let bundlers = [ appBundler ]; 27 | const outputs = [ 'app.js' ]; 28 | 29 | // create bundlers for each module, excluding modules found in the app bundler 30 | // these don't have entry points since the files are just `require()`d 31 | const inputs = require('globby').sync('./src/modules/*'); 32 | let moduleBundler, moduleName; 33 | inputs.forEach(input => { 34 | moduleName = input.match(/\/([^\/]+)$/)[1]; 35 | 36 | moduleBundler = browserify(bundleOpts).external(appBundler); 37 | exposeDir(moduleBundler, input, `app/${moduleName}`); 38 | 39 | bundlers.push(moduleBundler); 40 | outputs.push(`${moduleName}.js`); 41 | }); 42 | 43 | // wrap the app bundle with watchify in dev mode 44 | if (gutil.env.dev) { 45 | gutil.log(`${c.cyan('scripts')}: watching`); 46 | 47 | bundlers = bundlers.map((bundler, index) => { 48 | bundler = require('watchify')(bundler); 49 | bundler.on('update', files => { 50 | run(bundler, outputs[index], files); 51 | }); 52 | return bundler; 53 | }); 54 | } 55 | 56 | return require('merge-stream')( 57 | bundlers.map((bundle, index) => run(bundle, outputs[index])) 58 | ); 59 | }); 60 | 61 | /** 62 | * Recursively expose a directory on given bundler 63 | * 64 | * @param {Object} bundler 65 | * @param {String} dir 66 | * @param {String} namespace 67 | */ 68 | function exposeDir(bundler, dir, namespace) { 69 | const fs = require('fs'); 70 | 71 | fs.readdirSync(dir).forEach(file => { 72 | if (/^\./.test(file)) return; 73 | 74 | if (fs.statSync(`${dir}/${file}`).isDirectory()) { 75 | return exposeDir(bundler, `${dir}/${file}`, `${namespace}/${file}`); 76 | } 77 | 78 | const m = file.match(/^(.*)\.jsx?$/); 79 | if (!m) return; 80 | 81 | const exposeAs = m[1] === 'index' ? namespace : `${namespace}/${m[1]}`; 82 | bundler.require(`${dir}/${file}`, { expose: exposeAs }); 83 | }); 84 | } 85 | 86 | /** 87 | * Bundle given bundler 88 | * 89 | * @param {Object} bundler 90 | * @param {String} bundleName 91 | * @param {Array} files 92 | */ 93 | function run(bundler, bundleName, files) { 94 | const sourcemaps = require('gulp-sourcemaps'); 95 | 96 | if (files) { 97 | gutil.log(`${c.cyan('scripts')}: ${c.yellow(files[0].replace(process.cwd(), '.'))} changed - bundling ${c.yellow(bundleName)}`); 98 | } else { 99 | gutil.log(`${c.cyan('scripts')}: bundling ${c.yellow(bundleName)}`); 100 | } 101 | 102 | return bundler.bundle() 103 | .pipe(require('vinyl-source-stream')(bundleName)) 104 | .pipe(require('vinyl-buffer')()) 105 | .pipe(sourcemaps.init({ loadMaps: true })) 106 | .pipe(gutil.env.dev ? gutil.noop() : require('gulp-uglify')()) 107 | .pipe(sourcemaps.write('.')) 108 | .pipe(gulp.dest('dist/public/js')); 109 | } 110 | -------------------------------------------------------------------------------- /gulp/tasks/styles.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const gulp = require('gulp'); 4 | const gutil = require('gulp-util'); 5 | const c = gutil.colors; 6 | 7 | gulp.task('styles', () => { 8 | const src = [ 9 | 'src/client/styles/index.scss', 10 | 'src/modules/*/styles/index.scss' 11 | ]; 12 | 13 | if (gutil.env.dev) { 14 | gutil.log(`${c.cyan('styles')}: watching`); 15 | 16 | require('globby').sync(src).forEach(src => { 17 | gulp.watch([ 18 | src.replace(/([^\/]+)\.scss$/, '*.scss'), 19 | src.replace(/([^\/]+)\.scss$/, '**/*.scss') 20 | ], e => run(src, e)); 21 | }); 22 | } 23 | 24 | return run(src); 25 | }); 26 | 27 | function run(src, e) { 28 | const sourcemaps = require('gulp-sourcemaps'); 29 | 30 | if (e) { 31 | gutil.log(`${c.cyan('styles')}: ${c.yellow(e.path.replace(process.cwd(), '.'))} ${e.type}, processing`); 32 | } else { 33 | gutil.log(`${c.cyan('styles')}: processing`); 34 | } 35 | 36 | return gulp.src(src, { base: 'src' }) 37 | .pipe(sourcemaps.init()) 38 | .pipe(require('gulp-sass')()) 39 | .pipe(require('gulp-autoprefixer')({ 40 | browsers: [ 'last 2 versions' ] 41 | })) 42 | .pipe(gutil.env.dev ? gutil.noop() : require('gulp-cssnano')({ 43 | zindex: false 44 | })) 45 | .pipe(require('gulp-rename')(path => { 46 | const m = path.dirname.match(/^modules\/([^\/]+)/); 47 | path.basename = m ? m[1] : 'app'; 48 | path.dirname = ''; 49 | })) 50 | .pipe(sourcemaps.write('.')) 51 | .pipe(gulp.dest('dist/public/css')); 52 | }; 53 | -------------------------------------------------------------------------------- /gulp/tasks/symlink.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const gulp = require('gulp'); 4 | 5 | /** 6 | * Symlink app into node_modules 7 | */ 8 | gulp.task('symlink', [ 'babel' ], () => { 9 | const symlink = require('gulp-symlink'); 10 | 11 | const appStream = gulp.src('dist/app') 12 | .pipe(symlink('node_modules/app', { force: true })); 13 | 14 | const modStream = gulp.src('dist/modules/*') 15 | .pipe(symlink(file => ( 16 | new symlink.File({ 17 | path: `dist/app/${file.relative}`, 18 | cwd: process.cwd() 19 | }) 20 | ), { force: true })); 21 | 22 | return require('merge-stream')(appStream, modStream); 23 | }); 24 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | require('./gulp'); 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-redux-boilerplate", 3 | "version": "0.0.0", 4 | "description": "", 5 | "author": "chielkunkels", 6 | "license": "MIT", 7 | "homepage": "https://github.com/chielkunkels/react-redux-boilerplate#readme", 8 | "bugs": { 9 | "url": "https://github.com/chielkunkels/react-redux-boilerplate/issues" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/chielkunkels/react-redux-boilerplate.git" 14 | }, 15 | "keywords": [ 16 | "react", 17 | "redux", 18 | "react-router" 19 | ], 20 | "dependencies": { 21 | "babel-preset-stage-1": "^6.3.13", 22 | "compression": "^1.6.0", 23 | "cookie-parser": "^1.4.1", 24 | "express": "^4.13.3", 25 | "fg-loadcss": "^0.2.4", 26 | "fg-loadjs": "^0.2.3", 27 | "history": "^1.17.0", 28 | "isomorphic-fetch": "^2.2.1", 29 | "normalize.css": "^3.0.3", 30 | "react": "^0.14.6", 31 | "react-dom": "^0.14.6", 32 | "react-redux": "^4.0.6", 33 | "react-router": "^1.0.3", 34 | "redux": "^3.0.5", 35 | "serve-static": "^1.10.0" 36 | }, 37 | "devDependencies": { 38 | "babel-preset-es2015": "^6.3.13", 39 | "babel-preset-react": "^6.3.13", 40 | "babelify": "^7.2.0", 41 | "browserify": "^13.0.0", 42 | "del": "^2.2.0", 43 | "globby": "^4.0.0", 44 | "gulp": "^3.9.0", 45 | "gulp-autoprefixer": "^3.1.0", 46 | "gulp-babel": "^6.1.1", 47 | "gulp-cssnano": "^2.1.0", 48 | "gulp-imagemin": "^2.4.0", 49 | "gulp-rename": "^1.2.2", 50 | "gulp-sass": "^2.1.1", 51 | "gulp-sourcemaps": "^1.6.0", 52 | "gulp-symlink": "^2.1.3", 53 | "gulp-uglify": "^1.5.1", 54 | "gulp-util": "^3.0.7", 55 | "merge-stream": "^1.0.0", 56 | "nodemon": "^1.8.1", 57 | "vinyl-buffer": "^1.0.0", 58 | "vinyl-source-stream": "^1.1.0", 59 | "watchify": "^3.7.0" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/app/bundles.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const bundles = {}; 4 | 5 | export default bundles; 6 | -------------------------------------------------------------------------------- /src/app/components/App.jsx: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import React from 'react'; 4 | import { connect } from 'react-redux'; 5 | 6 | function App(props) { 7 | return ( 8 |
9 | Hello {props.currentUser ? props.currentUser.firstname : 'world'}. 10 |
11 | ); 12 | } 13 | 14 | export default connect(state => ({ 15 | currentUser: state.session.user 16 | }))(App); 17 | -------------------------------------------------------------------------------- /src/app/constants.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | export const SET_SESSION_TOKEN = 'SET_SESSION_TOKEN'; 4 | export const SET_SESSION_USER = 'SET_SESSION_USER'; 5 | -------------------------------------------------------------------------------- /src/app/errors/AuthorizationError.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | export default function AuthorizationError(message) { 4 | this.name = 'AuthorizationError'; 5 | this.message = message; 6 | this.status = 401; 7 | } 8 | 9 | AuthorizationError.prototype.toString = function() { 10 | return this.message; 11 | }; 12 | -------------------------------------------------------------------------------- /src/app/errors/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | export Authorization from 'app/errors/AuthorizationError'; 4 | -------------------------------------------------------------------------------- /src/app/history.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import createBrowserHistory from 'history/lib/createBrowserHistory'; 4 | 5 | export default createBrowserHistory(); 6 | -------------------------------------------------------------------------------- /src/app/reducers/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { session } from 'app/reducers/session'; 4 | import { combineReducers } from 'redux'; 5 | 6 | let reducers = { session }; 7 | 8 | export function addReducers(newReducers) { 9 | reducers = { ...reducers, ...newReducers }; 10 | } 11 | 12 | export default function createReducer() { 13 | return combineReducers(reducers); 14 | } 15 | -------------------------------------------------------------------------------- /src/app/reducers/session.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import * as c from 'app/constants'; 4 | 5 | const initialState = { 6 | token: null, 7 | user: null 8 | }; 9 | 10 | /** 11 | * Session reducer 12 | * 13 | * @param {Object} state 14 | * @param {Object} action 15 | * 16 | * @return {Object} 17 | */ 18 | export function session(state = initialState, action) { 19 | switch (action.type) { 20 | case c.SET_SESSION_TOKEN: 21 | return { ...state, 22 | token: action.token 23 | }; 24 | 25 | case c.SET_SESSION_USER: 26 | return { ...state, 27 | user: action.user 28 | }; 29 | 30 | default: 31 | return state; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/app/store.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import history from 'app/history'; 4 | import createReducer from 'app/reducers'; 5 | import createApiCaller from 'app/utils/createApiCaller'; 6 | import createApiMiddleware from 'app/utils/createApiMiddleware'; 7 | import createCustomStore from 'app/utils/createCustomStore'; 8 | 9 | const apiToken = window.__initialState.session.token; 10 | 11 | const store = createCustomStore( 12 | createReducer(), 13 | [ createApiMiddleware(createApiCaller(apiToken), history) ], 14 | window.__initialState 15 | ); 16 | 17 | export default store; 18 | -------------------------------------------------------------------------------- /src/app/utils/callApi.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | require('isomorphic-fetch'); 4 | 5 | import * as errors from 'app/errors'; 6 | 7 | const API_URL = typeof window !== 'undefined' ? 8 | window.__config.API_URL : 9 | process.env.API_URL; 10 | 11 | /** 12 | * Call given api endpoint with given options 13 | * 14 | * This is a lightweight wrapper around fetch that sets content-type and 15 | * accept headers if they've not been defined yet and does some standard 16 | * error handling that should always be implemented. 17 | * 18 | * @param {String} endpoint 19 | * @param {Object} options 20 | * 21 | * @return {Promise} 22 | */ 23 | export default function callApi(endpoint, options) { 24 | const url = API_URL + endpoint; 25 | 26 | options = options || {}; 27 | options.headers = options.headers || {}; 28 | 29 | if (!options.headers.accept) { 30 | options.headers.accept = 'application/json'; 31 | } 32 | 33 | if (!options.headers['content-type']) { 34 | options.headers['content-type'] = 'application/json'; 35 | } 36 | 37 | return fetch(url, options) 38 | .then(res => { 39 | if (res.status >= 200 && res.status < 300) { 40 | return res.json(); 41 | } else if (res.status === 401) { 42 | return res.json().then(json => ( 43 | Promise.reject(new errors.Authorization(json.error.message)) 44 | )); 45 | } 46 | }); 47 | } 48 | -------------------------------------------------------------------------------- /src/app/utils/createApiCaller.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import callApi from 'app/utils/callApi'; 4 | 5 | /** 6 | * Create an api calling function with given token 7 | * 8 | * If a token is passed into this function, the api calls will receive 9 | * an Authorization header; if not, the calls will be unauthorized. 10 | * 11 | * @param {String} token 12 | * 13 | * @return {Function} 14 | */ 15 | export default function createApiCaller(token) { 16 | if (!token) return callApi; 17 | 18 | /** 19 | * Wrap callApi to mix the Authorization header with the token into 20 | * the request's options. Takes the same arguments as `callApi` 21 | * 22 | * @param {String} endpoint 23 | * @param {Object} options 24 | */ 25 | return function callApiWithToken(endpoint, options) { 26 | if (!options) options = {}; 27 | 28 | const headers = options.headers || {}; 29 | 30 | return callApi(endpoint, { ...options, 31 | headers: { ...headers, 32 | Authorization: `Bearer ${token}` 33 | } 34 | }); 35 | }; 36 | } 37 | -------------------------------------------------------------------------------- /src/app/utils/createApiMiddleware.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | export default function createApiMiddleware(callApiWithToken, history) { 4 | return function apiMiddleware({ dispatch, getState }) { 5 | return next => action => 6 | typeof action === 'function' ? 7 | action(dispatch, getState, callApiWithToken, history) : 8 | next(action); 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /src/app/utils/createCustomStore.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import { applyMiddleware, 4 | createStore } from 'redux'; 5 | 6 | /** 7 | * Create a store with given reducer, middleware and initial state 8 | * 9 | * @param {Function} reducer 10 | * @param {Function[]} middleware 11 | * @param {Object} initialState 12 | */ 13 | export default function createCustomStore(reducer, middleware, initialState) { 14 | return applyMiddleware(...middleware)(createStore)(reducer, initialState); 15 | } 16 | -------------------------------------------------------------------------------- /src/app/utils/renderFullPage.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Render given html string into full page markup 5 | * 6 | * @param {String} html 7 | * @param {Object} initialState 8 | * @param {String[]} styleSheets 9 | * 10 | * @return {String} 11 | */ 12 | export default function renderFullPage(html, initialState, styleSheets) { 13 | styleSheets = styleSheets.map(sheet => ( 14 | `` 15 | )); 16 | 17 | const config = { 18 | API_URL: process.env.API_URL 19 | }; 20 | 21 | const analytics = !process.env.GOOGLE_ANALYTICS ? '' : ``; 29 | 30 | return ( 31 | ` 32 | 33 | 34 | 35 | 36 | 37 | React + Redux boilerplate 38 | 39 | ${styleSheets} 40 | 41 | 42 |
${html}
43 | 44 | 45 | 46 | ${analytics} 47 | 48 | ` 49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /src/client/index.jsx: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import routes from './routes'; 4 | import App from 'app/components/App'; 5 | import history from 'app/history'; 6 | import React from 'react'; 7 | import ReactDOM from 'react-dom'; 8 | import { match, Router } from 'react-router'; 9 | import { Provider } from 'react-redux'; 10 | 11 | history.listen(location => { 12 | match({ routes, location: location.pathname }, (err, redirectLocation, renderProps) => { 13 | ReactDOM.render(( 14 | 15 | 16 | 17 | ), document.getElementById('app-root')); 18 | }); 19 | })(); 20 | -------------------------------------------------------------------------------- /src/client/routes.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import bundles from 'app/bundles'; 4 | import App from 'app/components/App'; 5 | import createReducer, { 6 | addReducers } from 'app/reducers'; 7 | import loadCSS from 'fg-loadcss'; 8 | import loadJS from 'fg-loadjs'; 9 | 10 | const routes = [ 11 | { 12 | getChildRoutes: getChildRoutes 13 | }, 14 | { 15 | path: '*', 16 | component: App 17 | } 18 | ]; 19 | 20 | const loaded = []; 21 | 22 | function getChildRoutes(location, cb) { 23 | let bundle = location.pathname.replace(/^[\/\s]+/, '').split('/')[0]; 24 | if (!bundle || !bundles[bundle]) return cb(); 25 | 26 | if (bundles[bundle].partOf) { 27 | bundle = bundles[bundle].partOf; 28 | } 29 | 30 | if (loaded.indexOf(bundle) !== -1) { 31 | return cb(null, require(`app/${bundle}/routes`).default); 32 | } 33 | 34 | if (bundles[bundle].styles) { 35 | const sheets = document.styleSheets; 36 | let found = false; 37 | 38 | for (let i = 0; i < sheets.length; i++) { 39 | if (!sheets[i].href) continue; 40 | 41 | if (new RegExp(`\/css\/${bundle}\.css`).test(sheets[i].href)) { 42 | found = true; 43 | break; 44 | } 45 | } 46 | 47 | if (!found) { 48 | loadCSS(`/css/${bundle}.css`); 49 | } 50 | } 51 | 52 | loadJS(`/js/${bundle}.js`, function() { 53 | loaded.push(bundle); 54 | 55 | if (bundles[bundle].reducers) { 56 | addReducers(require(`app/${bundle}/reducers`)); 57 | require('app/store').default.replaceReducer(createReducer()); 58 | } 59 | cb(null, require(`app/${bundle}/routes`).default); 60 | }); 61 | } 62 | 63 | export default routes; 64 | -------------------------------------------------------------------------------- /src/client/styles/index.scss: -------------------------------------------------------------------------------- 1 | @charset 'utf-8'; 2 | 3 | @import '../../../node_modules/normalize.css/normalize'; 4 | -------------------------------------------------------------------------------- /src/server/index.jsx: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import routes from './routes'; 4 | import bundles from 'app/bundles'; 5 | import App from 'app/components/App'; 6 | import * as sessionReducers from 'app/reducers/session'; 7 | import createApiCaller from 'app/utils/createApiCaller'; 8 | import createApiMiddleware from 'app/utils/createApiMiddleware'; 9 | import createCustomStore from 'app/utils/createCustomStore'; 10 | import renderFullPage from 'app/utils/renderFullPage'; 11 | import express from 'express'; 12 | import createMemoryHistory from 'history/lib/createMemoryHistory'; 13 | import React from 'react'; 14 | import { renderToString } from 'react-dom/server'; 15 | import { Provider } from 'react-redux'; 16 | import { match, 17 | RoutingContext } from 'react-router'; 18 | import { combineReducers } from 'redux'; 19 | 20 | const app = express(); 21 | app.use(require('compression')()); 22 | app.use(require('serve-static')(__dirname + '/../public')); 23 | app.use(require('cookie-parser')()); 24 | 25 | app.get('*', (req, res, next) => { 26 | let bundle = req.path.replace(/^[\/\s]+/, '').split('/')[0]; 27 | if (bundles[bundle] && bundles[bundle].partOf) { 28 | bundle = bundles[bundle].partOf; 29 | } 30 | 31 | let reducers = { ...sessionReducers }; 32 | 33 | const styleSheets = []; 34 | if (bundles[bundle]) { 35 | if (bundles[bundle].styles) { 36 | styleSheets.push(`/css/${bundle}.css`); 37 | } 38 | 39 | if (bundles[bundle].reducers) { 40 | reducers = { ...reducers, ...require(`app/${bundle}/reducers`) }; 41 | } 42 | } 43 | 44 | const history = createMemoryHistory(); 45 | 46 | const store = createCustomStore( 47 | combineReducers(reducers), 48 | [ createApiMiddleware(createApiCaller(req.cookies.api_token), history) ] 49 | ); 50 | 51 | match({ routes, location: req.url, history }, (err, redirectLocation, renderProps) => { 52 | if (err) { 53 | return next(err); 54 | } 55 | 56 | if (redirectLocation) { 57 | return res.redirect(302, redirectLocation.pathname + redirectLocation.search); 58 | } 59 | 60 | const render = () => ( 61 | res.send(renderFullPage(renderToString( 62 | 63 | 64 | 65 | ), store.getState(), styleSheets)) 66 | ); 67 | 68 | const promises = renderProps.components 69 | .filter(component => component && !!component.fetchData) 70 | .map(component => component.fetchData(renderProps, store.dispatch)); 71 | 72 | if (!promises.length) { 73 | return render(); 74 | } 75 | 76 | Promise.all(promises) 77 | .then(render) 78 | .catch(next); 79 | }); 80 | }); 81 | 82 | const port = process.env.PORT || 4589; 83 | app.listen(port, () => { 84 | console.log(`Server listening on port ${port}`); 85 | }); 86 | -------------------------------------------------------------------------------- /src/server/routes.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import App from 'app/components/App'; 4 | import globby from 'globby'; 5 | 6 | const moduleRoutes = globby.sync(`${__dirname}/../modules/*/routes.js`) 7 | .map(routesFile => require(routesFile).default); 8 | 9 | const routes = moduleRoutes.map(routes => ({ childRoutes: routes })); 10 | 11 | routes.push({ 12 | path: '*', 13 | component: App 14 | }); 15 | 16 | export default routes; 17 | --------------------------------------------------------------------------------