├── .gitignore ├── .jshintrc ├── README.md ├── common.js ├── package.json ├── public ├── assets │ └── images │ │ ├── github-retina.png │ │ ├── github.png │ │ ├── twitter-retina.png │ │ └── twitter.png └── favicon.ico ├── server ├── app.js ├── bin │ └── www ├── common │ └── helpers.js ├── routes.js └── views │ ├── error.ejs │ ├── layout.ejs │ ├── markup.ejs │ └── partials │ └── analytics.ejs ├── src ├── bootstrap.jsx ├── components │ ├── About.jsx │ ├── App.jsx │ ├── Footer.jsx │ ├── Github.jsx │ ├── GithubUser.jsx │ ├── Header.jsx │ ├── Home.jsx │ ├── common │ │ ├── DisplayInfosPanel.jsx │ │ ├── DisplayStars.jsx │ │ ├── Panel.jsx │ │ ├── Spinner.jsx │ │ ├── Tr.jsx │ │ └── TwitterButton.jsx │ ├── github │ │ ├── IntroBox.jsx │ │ ├── ProfileBox.jsx │ │ ├── ProfileList.jsx │ │ └── SearchBox.jsx │ └── githubUser │ │ ├── Profile.jsx │ │ ├── Repos.jsx │ │ └── ReposPaginator.jsx ├── routes.jsx ├── services │ ├── github.js │ ├── httpService.js │ ├── httpService │ │ ├── config │ │ │ └── environment │ │ │ │ ├── config.build.js │ │ │ │ ├── config.dev.js │ │ │ │ └── config.mock.js │ │ ├── http.js │ │ ├── http.mocks │ │ │ ├── followers.json │ │ │ ├── repos.json │ │ │ ├── user.json │ │ │ └── users.search.json │ │ └── http.stub.js │ └── localStorageWrapper.js └── style │ ├── footer.scss │ ├── header.scss │ ├── main.scss │ └── spinner.scss ├── webpack.config.js └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .idea 3 | build 4 | *.log -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "debug": true, 3 | "devel": true, 4 | "unused": false 5 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | react-es6-isomorphic 2 | ==================== 3 | 4 | ![image](http://dev.topheman.com/wp-content/uploads/2015/04/logo-reactjs.png) 5 | 6 | *UPDATE :* Facebook released [react@0.14.0](https://facebook.github.io/react/blog/2015/10/07/react-v0.14.html), so I upgraded [topheman/react-es6](https://github.com/topheman/react-es6) and this project with the latest version (also upgraded react-router - which was the most of the work). 7 | 8 | This project is the isomorphic version of [topheman/react-es6](https://github.com/topheman/react-es6), which contains all the front-end part (UI in React ...) and was coded so that it could be executed in both client and server side (shared components, routes, services ...) 9 | 10 | The backend is based on [topheman/topheman-apis-proxy](https://github.com/topheman/topheman-apis-proxy) which provides the github API. 11 | 12 | Techs involved : 13 | 14 | * React (client and server side rendering) 15 | * ES6 (using Webpack for bundling) 16 | * ExpressJS as node server framework 17 | 18 | If you want to go further, read the [blog post](http://dev.topheman.com/react-es6-isomorphic/) about this project. 19 | 20 | ### Setup 21 | 22 | #### Install 23 | 24 | ```shell 25 | git clone https://github.com/topheman/react-es6-isomorphic.git 26 | cd react-es6-isomorphic 27 | npm install 28 | ``` 29 | 30 | You'll have to install the [topheman-apis-proxy](https://github.com/topheman/topheman-apis-proxy) backend, follow the [installation steps](https://github.com/topheman/topheman-apis-proxy#installation) README section. 31 | 32 | #### Run 33 | 34 | A set of npm tasks are configured in the package.json to help you. 35 | 36 | I'll try to automate even more, but with that, you'll be able to have your API server (topheman-apis-proxy), your expressJS server AND the webpack dev server managing client side assets (js, css) with sourceMaps. 37 | 38 | Those npm task are based on the env var `NODE_ENV` which accepts `PROD`/`MOCK`/`DEV`, but this var is abstracted by the tasks. 39 | 40 | ##### Dev mode 41 | 42 | Open three terminal tabs : two in the react-es6-isomorphic project folder, one in the topheman-apis-proxy project folder. 43 | 44 | * Tab 1 react-es6-isomorphic : `npm run webpack-dev` (will launch the webpack dev server, serving the front assets in live reloading) 45 | * Tab 2 react-es6-isomorphic : `npm run server-dev` (will launch the express server on port 9000, monitored by nodemon which will restart the server on pretty much any file changes - since there is server side rendering) 46 | * Tab 3 topheman-apis-proxy : `grunt serve` (see more in the [run in local](https://github.com/topheman/topheman-apis-proxy#run-in-local) README section) 47 | 48 | ##### Mock mode 49 | 50 | You can run the project offline (without topheman-apis-proxy), a set of requests are mocked (not all of them, still wip), it will also be usefull for unit tests in continuous integration : 51 | 52 | * Tab 1 react-es6-isomorphic : `npm run webpack-mock` (will launch webpack dev server, serving the front assets in live reloading) 53 | * Tab 2 react-es6-isomorphic : `npm run server-mock` (will launch the express server on port 9000, monitored by nodemon) 54 | 55 | ##### Prod mode 56 | 57 | `npm run server-prod` will build the assets via webpack in production mode and then launch the express server in production mode. 58 | 59 | Use it to check if the project behaves OK in production mode before delivering (note that my production server of topheman-apis-proxy won't accept xhr because of cors constraints in this case). 60 | 61 | ##### Finally 62 | 63 | Open [http://localhost:9000](http://localhost:9000/) 64 | 65 | 66 | #### Build 67 | 68 | The client-side code is written in ES6 with modules that work as well on the client as on the server (like React, superagent ...). 69 | 70 | * client-side : a bundle is made with webpack and accessible either via the webpack dev server, or written inside `/build` folder 71 | * server-side : `require('babel/register')` lets me use those es6 modules without any transpiling step (babel does it itself on the fly, I don't have to worry about it) 72 | 73 | I also made a set of npm tasks for the build step, that creates a bundle inside the `/build` folder (including js, sass to css, images ...) 74 | 75 | `npm run webpack-build` will build the assets to `/build` (with sourcemaps and all), you can `npm start` after if you want, to check the project without webpack involved. 76 | 77 | This task is triggered at `npm postinstall`, since it's based on the `NODE_ENV` variable, it will create the bundle in the correct mode. 78 | 79 | ### Deploy 80 | 81 | I deploy on heroku, if you don't, you shouldn't have to tweak a lot my routine. 82 | 83 | Assuming you've logged in to heroku on the command line and have setup your heroku remote repo : 84 | 85 | * Since I make the client-side bundle (js, css builds) at deploy time, the hosting platform will also need the devDependencies (such as webpack) : `heroku config:set NPM_CONFIG_PRODUCTION=false` 86 | * Configure your app in production mode : `heroku config:set NODE_ENV=PROD` 87 | * Then : `git push heroku master` 88 | 89 | A tricks I discovered : if you're on a branch and want to deploy to heroku (which ignores anything which is not the master branch) : `git push -f heroku name-of-your-branch:master` 90 | 91 | ### Contributing 92 | 93 | As I stated before, [topheman/react-es6](https://github.com/topheman/react-es6), is the original project, where I'll continue to implement the client-side features - which I will merge into [topheman/react-es6-isomorphic](https://github.com/topheman/react-es6-isomorphic), which is [only about server-side rendering](#contributing). 94 | 95 | So, any **client-side related pull-requests** should be done against [topheman/react-es6](https://github.com/topheman/react-es6). 96 | 97 | **This repo is only about server-side rendering.** I won't accept pull-requests about client-side features. 98 | 99 | ### FAQ 100 | 101 | This project is still a work in progress, I'm documenting as I'm moving forward. 102 | 103 | ### License 104 | 105 | This software is distributed under an MIT licence. 106 | 107 | Copyright 2015 © Christophe Rosset 108 | 109 | > Permission is hereby granted, free of charge, to any person obtaining a copy of this software 110 | > and associated documentation files (the "Software"), to deal in the Software without 111 | > restriction, including without limitation the rights to use, copy, modify, merge, publish, 112 | > distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the 113 | > Software is furnished to do so, subject to the following conditions: 114 | > The above copyright notice and this permission notice shall be included in all copies or 115 | > substantial portions of the Software. 116 | > The Software is provided "as is", without warranty of any kind, express or implied, including 117 | > but not limited to the warranties of merchantability, fitness for a particular purpose and 118 | > noninfringement. In no event shall the authors or copyright holders be liable for any claim, 119 | > damages or other liability, whether in an action of contract, tort or otherwise, arising from, 120 | > out of or in connection with the software or the use or other dealings in the Software. -------------------------------------------------------------------------------- /common.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function projectIsGitManaged(){ 4 | var fs = require('fs'); 5 | var path = require('path'); 6 | try { 7 | // Query the entry 8 | var stats = fs.lstatSync(path.join(__dirname,'.git')); 9 | 10 | // Is it a directory? 11 | if (stats.isDirectory()) { 12 | return true; 13 | } 14 | return false; 15 | } 16 | catch (e) { 17 | return false; 18 | } 19 | } 20 | 21 | /** 22 | * Return the git revision if the project is git managed 23 | * Also returns the git revision for a project just shipped to heroku at compile time 24 | * If no git involved, returns null 25 | * @param {String} mode 'short'|'long' 26 | * @returns {String|null} 27 | */ 28 | function getGitRevision(mode){ 29 | var gitRev = require('git-rev-sync'); 30 | mode = mode || 'long'; 31 | if(['short','long'].indexOf(mode) === -1){ 32 | throw new Error("Only accepts 'short' or 'long' as argument"); 33 | } 34 | if(projectIsGitManaged()){ 35 | return gitRev[mode](); 36 | } 37 | //case we are on heroku, process.env.SOURCE_VERSION contains the hash of the git revision (only at compile time) 38 | else if(process.env.SOURCE_VERSION){ 39 | if(mode === 'short'){ 40 | return process.env.SOURCE_VERSION.slice(0,7); 41 | } 42 | return process.env.SOURCE_VERSION 43 | } 44 | return; 45 | } 46 | 47 | function getInfos(){ 48 | var moment = require('moment'); 49 | var pkg = require('./package.json'); 50 | var infos = { 51 | pkg: pkg, 52 | today: moment(new Date()).format('DD/MM/YYYY'), 53 | year: new Date().toISOString().substr(0, 4), 54 | gitRevisionShort: getGitRevision('short'), 55 | gitRevisionLong: getGitRevision('long'), 56 | urlToCommit: undefined 57 | }; 58 | infos.urlToCommit = infos.gitRevisionLong ? _getUrlToCommit(pkg, infos.gitRevisionLong) : undefined; 59 | return infos; 60 | } 61 | 62 | /** 63 | * Called in default mode by webpack (will format it correctly in comments) 64 | * Called in formatted mode by gulp (for html comments) 65 | * @param {String} mode default/formatted 66 | * @param {Object} overrideInfos pass an object to override the properties to render 67 | * @returns {String} 68 | */ 69 | function getBanner(mode, overrideInfos){ 70 | overrideInfos = overrideInfos || {}; 71 | var _ = require('lodash'); 72 | var infos = _.extend(getInfos(), overrideInfos); 73 | var compiled = _.template([ 74 | '<%= pkg.name %>', 75 | '', 76 | '<%= pkg.description %>', 77 | '', 78 | '@version v<%= pkg.version %> - <%= today %>', 79 | '<% if(gitRevisionShort) { %>@revision #<%= gitRevisionShort %><% if (urlToCommit) { %> - <%= urlToCommit %><% } %><% } %>', 80 | '@author <%= (pkg.author && pkg.author.name) ? pkg.author.name : pkg.author %>', 81 | '@copyright <%= year %>(c) <%= (pkg.author && pkg.author.name) ? pkg.author.name : pkg.author %>', 82 | '@license <%= pkg.license %>', 83 | '' 84 | ].join(mode === 'formatted' ? '\n * ' : '\n')); 85 | return compiled(infos); 86 | } 87 | 88 | function getBannerHtml(overrideInfos){ 89 | overrideInfos = overrideInfos || {}; 90 | return '\n'; 91 | } 92 | 93 | function _getUrlToCommit(pkg, gitRevisionLong){ 94 | var urlToCommit; 95 | //retrieve and reformat repo url from package.json 96 | if (typeof(pkg.repository) === 'string') { 97 | urlToCommit = pkg.repository; 98 | } 99 | else if (typeof(pkg.repository.url) === 'string') { 100 | urlToCommit = pkg.repository.url; 101 | } 102 | //check that there is a git repo specified in package.json & it is a github one 103 | if (urlToCommit && /^https:\/\/github.com/.test(urlToCommit)) { 104 | urlToCommit = urlToCommit.replace(/.git$/, '/tree/' + gitRevisionLong);//remove the .git at the end 105 | } 106 | return urlToCommit; 107 | } 108 | 109 | module.exports.getInfos = getInfos; 110 | module.exports.getBanner = getBanner; 111 | module.exports.getBannerHtml = getBannerHtml; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-es6-isomorphic", 3 | "version": "2.2.0", 4 | "description": "A simple app to try React and ES6, using topheman-apis-proxy as data api backend NOW with isomorphism", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "node ./server/bin/www", 8 | "nodemon": "node_modules/.bin/nodemon -e js,jsx,json,ejs ./server/bin/www", 9 | "test": "echo \"Error: no test specified\" && exit 1", 10 | "server-dev": "DEBUG=http,errors npm run nodemon", 11 | "server-mock": "NODE_ENV=MOCK npm run server-dev", 12 | "server-prod": "echo 'Building assets before launching server' && NODE_ENV=PROD npm run webpack-build && NODE_ENV=PROD DEBUG=http,errors npm start", 13 | "webpack-dev": "./node_modules/.bin/webpack-dev-server --progress --colors", 14 | "webpack-mock": "NODE_ENV=MOCK npm run webpack-dev", 15 | "webpack-build": "./node_modules/.bin/webpack --progress", 16 | "postinstall": "npm run webpack-build" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "https://github.com/topheman/react-es6-isomorphic.git" 21 | }, 22 | "keywords": [ 23 | "react", 24 | "ES6", 25 | "isomorphism", 26 | "server-side rendering", 27 | "express" 28 | ], 29 | "author": "Christophe Rosset", 30 | "license": "MIT", 31 | "bugs": { 32 | "url": "https://github.com/topheman/react-es6-isomorphic/issues" 33 | }, 34 | "homepage": "https://github.com/topheman/react-es6-isomorphic", 35 | "engines": { 36 | "node": "^6.11.1" 37 | }, 38 | "devDependencies": { 39 | "babel-core": "^5.8.25", 40 | "babel-loader": "^5.3.2", 41 | "css-loader": "^0.19.0", 42 | "del": "^2.0.2", 43 | "extract-text-webpack-plugin": "^0.8.2", 44 | "file-loader": "^0.8.4", 45 | "git-rev-sync": "^1.4.0", 46 | "json-loader": "^0.5.3", 47 | "lodash": "^3.10.1", 48 | "moment": "^2.10.6", 49 | "node-sass": "^3.3.3", 50 | "nodemon": "^1.3.7", 51 | "react-hot-loader": "^1.3.0", 52 | "run-sequence": "^1.1.4", 53 | "sass-loader": "^3.0.0", 54 | "style-loader": "^0.12.4", 55 | "url-loader": "^0.5.6", 56 | "webpack": "^1.12.2", 57 | "webpack-dev-server": "^1.12.0" 58 | }, 59 | "dependencies": { 60 | "bootstrap-sass": "^3.3.5", 61 | "history": "^1.12.4", 62 | "babel": "^5.1.10", 63 | "body-parser": "~1.12.0", 64 | "cookie-parser": "~1.3.4", 65 | "debug": "~2.1.1", 66 | "ejs": "^2.3.1", 67 | "express": "~4.12.2", 68 | "express-ejs-layouts": "^1.1.0", 69 | "lscache": "^1.0.5", 70 | "react": "^0.14.0", 71 | "react-addons-update": "^0.14.0", 72 | "react-dom": "^0.14.0", 73 | "react-router": "^1.0.0-rc3", 74 | "superagent": "^1.1.0", 75 | "morgan": "~1.5.1", 76 | "serve-favicon": "~2.2.0" 77 | }, 78 | "private": true 79 | } 80 | -------------------------------------------------------------------------------- /public/assets/images/github-retina.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/topheman/react-es6-isomorphic/e8cd9c9ad6bb962f025bd6dc69c9817e11efd574/public/assets/images/github-retina.png -------------------------------------------------------------------------------- /public/assets/images/github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/topheman/react-es6-isomorphic/e8cd9c9ad6bb962f025bd6dc69c9817e11efd574/public/assets/images/github.png -------------------------------------------------------------------------------- /public/assets/images/twitter-retina.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/topheman/react-es6-isomorphic/e8cd9c9ad6bb962f025bd6dc69c9817e11efd574/public/assets/images/twitter-retina.png -------------------------------------------------------------------------------- /public/assets/images/twitter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/topheman/react-es6-isomorphic/e8cd9c9ad6bb962f025bd6dc69c9817e11efd574/public/assets/images/twitter.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/topheman/react-es6-isomorphic/e8cd9c9ad6bb962f025bd6dc69c9817e11efd574/public/favicon.ico -------------------------------------------------------------------------------- /server/app.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | require('babel/register');//to be able to mix and match ES5 CommonJS / ES6 modules / jsx 4 | var express = require('express'); 5 | var path = require('path'); 6 | var favicon = require('serve-favicon'); 7 | var logger = require('morgan'); 8 | var cookieParser = require('cookie-parser'); 9 | var bodyParser = require('body-parser'); 10 | var helpers = require('./common/helpers'); 11 | 12 | var routes = require('./routes'); 13 | 14 | var app = express(); 15 | var expressLayouts = require('express-ejs-layouts'); 16 | 17 | // view engine setup 18 | app.set('views', path.join(__dirname, 'views')); 19 | app.set('view engine', 'ejs'); 20 | 21 | // set the layout part 22 | app.set('layout', 'layout'); 23 | app.use(expressLayouts); 24 | 25 | // uncomment after placing your favicon in /public 26 | app.use(favicon(path.join(__dirname ,'../public/favicon.ico'))); 27 | app.use(logger('dev')); 28 | app.use(bodyParser.json()); 29 | app.use(bodyParser.urlencoded({ extended: false })); 30 | app.use(cookieParser()); 31 | 32 | // available vars in all templates 33 | app.locals.hash = ''; 34 | app.locals.bannerHtml = require('../common').getBannerHtml( app.get('env') === 'PROD' ? require('../build/bannerInfos.json') : {}); 35 | 36 | //@note don't really need to serve /public since every assets are bundle with webpack 37 | //app.use(express.static(path.join(__dirname, 'public'))); //keeping it if any assets come out 38 | if (app.get('env') === 'PROD') { 39 | app.use(express.static(path.join(__dirname, '../build'))); 40 | try{ 41 | app.locals.hash = '-' + require('../build/stats.json').hash; 42 | console.log('Using hash:', app.locals.hash); 43 | } 44 | catch(e){ 45 | throw new Error("Couldn't retrieve hash from /build/stats.json, please rebuild", e); 46 | } 47 | } 48 | 49 | // catch 404 and forward to error handler (must be before routes declaration which catches all) 50 | app.use('/:url(assets)/*',function(req, res, next) { 51 | var err = new Error('Not Found'); 52 | err.status = 404; 53 | next(err); 54 | }); 55 | 56 | app.use('/', routes); 57 | 58 | // error handlers 59 | 60 | // development error handler 61 | // will print stacktrace 62 | if (app.get('env') !== 'PROD') { 63 | app.use(function(err, req, res, next) { 64 | res.status(err.status || 500); 65 | console.log(err, err.message); 66 | res.render(path.join(__dirname,'views/error.ejs'), { 67 | layout: 'layout', 68 | message: err.message, 69 | status: err.status || 500, 70 | error: err, 71 | env: req.app.get('env') 72 | }); 73 | }); 74 | } 75 | 76 | // production error handler 77 | // no stacktraces leaked to user 78 | app.use(function(err, req, res, next) { 79 | res.status(err.status || 500); 80 | res.render(path.join(__dirname,'views/error.ejs'), { 81 | layout: 'layout', 82 | message: err.message, 83 | status: err.status || 500, 84 | error: {}, 85 | env: req.app.get('env') 86 | }); 87 | }); 88 | 89 | /** httpService init - also runs client side with the same code, both in ES6 */ 90 | var httpServiceConfiguration = helpers.getHttpServiceConfiguration(app.get('env')); 91 | var httpService = require('../src/services/httpService').getInstance(httpServiceConfiguration); 92 | 93 | module.exports = app; 94 | -------------------------------------------------------------------------------- /server/bin/www: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * Module dependencies. 5 | */ 6 | 7 | var app = require('../app'); 8 | var debug = require('debug')('http'); 9 | var http = require('http'); 10 | 11 | var env = process.env.NODE_ENV ? process.env.NODE_ENV.toLowerCase() : 'dev'; 12 | if(env === 'prod'){ 13 | console.log('PRODUCTION mode'); 14 | } 15 | else if(env === 'test'){ 16 | console.log('TEST mode'); 17 | } 18 | else if(env === 'mock'){ 19 | console.log('MOCK mode'); 20 | } 21 | else{ 22 | console.log('DEVELOPMENT mode'); 23 | } 24 | 25 | /** 26 | * Get port from environment and store in Express. 27 | */ 28 | 29 | var port = normalizePort(process.env.PORT || '9000'); 30 | app.set('port', port); 31 | 32 | /** 33 | * Create HTTP server. 34 | */ 35 | 36 | var server = http.createServer(app); 37 | 38 | /** 39 | * Listen on provided port, on all network interfaces. 40 | */ 41 | 42 | server.listen(port); 43 | server.on('error', onError); 44 | server.on('listening', onListening); 45 | 46 | /** 47 | * Normalize a port into a number, string, or false. 48 | */ 49 | 50 | function normalizePort(val) { 51 | var port = parseInt(val, 10); 52 | 53 | if (isNaN(port)) { 54 | // named pipe 55 | return val; 56 | } 57 | 58 | if (port >= 0) { 59 | // port number 60 | return port; 61 | } 62 | 63 | return false; 64 | } 65 | 66 | /** 67 | * Event listener for HTTP server "error" event. 68 | */ 69 | 70 | function onError(error) { 71 | if (error.syscall !== 'listen') { 72 | throw error; 73 | } 74 | 75 | var bind = typeof port === 'string' 76 | ? 'Pipe ' + port 77 | : 'Port ' + port; 78 | 79 | // handle specific listen errors with friendly messages 80 | switch (error.code) { 81 | case 'EACCES': 82 | console.error(bind + ' requires elevated privileges'); 83 | process.exit(1); 84 | break; 85 | case 'EADDRINUSE': 86 | console.error(bind + ' is already in use'); 87 | process.exit(1); 88 | break; 89 | default: 90 | throw error; 91 | } 92 | } 93 | 94 | /** 95 | * Event listener for HTTP server "listening" event. 96 | */ 97 | 98 | function onListening() { 99 | var addr = server.address(); 100 | var bind = typeof addr === 'string' 101 | ? 'pipe ' + addr 102 | : 'port ' + addr.port; 103 | debug('Listening on ' + bind); 104 | } 105 | -------------------------------------------------------------------------------- /server/common/helpers.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | /** 5 | * 6 | * @param env from process.env or app.get('env') 7 | * @returns {Object} the Configuration that will let possible to init the httpService that runs client-side 8 | * to also run server-side 9 | */ 10 | getHttpServiceConfiguration: function(env){ 11 | var suffix; 12 | switch(env){ 13 | case 'development': 14 | case 'DEV': 15 | suffix = 'dev'; 16 | break; 17 | case 'PROD': 18 | suffix = 'build'; 19 | break; 20 | case 'MOCK': 21 | suffix = 'mock'; 22 | break; 23 | } 24 | return require('../../src/services/httpService/config/environment/config.'+suffix+'.js'); 25 | } 26 | }; -------------------------------------------------------------------------------- /server/routes.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var express = require('express'); 4 | var router = express.Router(); 5 | var path = require('path'); 6 | var debug = require('debug')('http'); 7 | 8 | var React = require('react'); 9 | var ReactDOMServer = require('react-dom/server'); 10 | var ReactRouter = require('react-router'); 11 | var RoutingContext = ReactRouter.RoutingContext; 12 | 13 | var clientSideRoutes = require('../src/routes.jsx'); 14 | 15 | var github = require('../src/services/github'); 16 | 17 | /** 18 | * Helper that makes async request when needed to feed data to the router 19 | * for later rendering by react components 20 | * 21 | * @param {Object} req 22 | * @param {Object} renderProps (from react-router) 23 | * @param {Function} cb containing the application to serialize and pass in the params of the router 24 | */ 25 | var fetchDataByRoute = function(req, renderProps, cb){ 26 | if(req.url.indexOf('/github/user/') > -1){ 27 | var state = { 28 | data: {} 29 | }; 30 | Promise.all([ 31 | github.getUser(renderProps.params.username), 32 | github.getUserRepos(renderProps.params.username,{ 33 | page: 1, 34 | sort: "updated", 35 | per_page: 15 36 | }) 37 | ]).then(results => { 38 | state.username = renderProps.params.username; 39 | state.data.profile = results[0]; 40 | state.data.repositories = results[1]; 41 | cb(state); 42 | },() => { 43 | delete state.data; 44 | cb(state); 45 | }); 46 | } 47 | else{ 48 | cb(state); 49 | } 50 | }; 51 | 52 | /** 53 | * The server side-rendering works but there are caveats when rendering from dynamic data 54 | * using the new version of react-router. 55 | * Main reason: it doesn't seem to pass mutated routeInfos (renderProps). 56 | * This bug is a good oportunity to setup some flux-like implementation, 57 | * in order to have clearer management of state. 58 | */ 59 | router.get('/*', function(req, res, next) { 60 | ReactRouter.match({routes: clientSideRoutes, location: req.url}, function(err, redirectLocation, renderProps) { 61 | if(err){ 62 | debug('[ReactRouter][Error]', req.url, err.message); 63 | return next(err); 64 | } 65 | else if(redirectLocation){ 66 | debug('[ReactRouter][Redirection]', req.url, redirectLocation.pathname, redirectLocation.search); 67 | return res.redirect(302, redirectLocation.pathname + redirectLocation.search); 68 | } 69 | else if(renderProps){ 70 | debug('[ReactRouter][Found]', req.url); 71 | fetchDataByRoute(req, renderProps, function(appState){ 72 | //pass the state of the app via the params, so that the router dispatches to the props of the right component 73 | Object.assign(renderProps.params, appState); 74 | var markup = ReactDOMServer.renderToString(); 75 | //serialize the state of the app so that the client can catch up 76 | var serializedState = JSON.stringify(appState); 77 | return res.render(path.resolve(__dirname, 'views/markup.ejs'), { 78 | layout: 'layout', 79 | markup: markup, 80 | serializedState: serializedState, 81 | env: req.app.get('env') 82 | }); 83 | }); 84 | } 85 | else{ 86 | //@todo correctly render errors 87 | console.log('[ReactRouter][Not Found]', req.url); 88 | var error404 = new Error('Not Found'); 89 | error404.status = 404; 90 | next(error404); 91 | } 92 | }); 93 | }) 94 | 95 | /* if no route matched, redirect home */ 96 | router.get('/*', function(req, res, next) { 97 | res.redirect('/'); 98 | }); 99 | 100 | module.exports = router; -------------------------------------------------------------------------------- /server/views/error.ejs: -------------------------------------------------------------------------------- 1 |

ERROR <%= status %> : <%= message %>

2 | 3 | <% if (status != 404 && error.stack) { %> 4 |
<%= error.stack %>
5 | <% } %> -------------------------------------------------------------------------------- /server/views/layout.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Topheman - react-es6-isomorphic 6 | <% if(env === 'PROD') { %> 7 | 8 | <% } else { %> 9 | 10 | <% } %> 11 | 12 | 13 | 14 | <% include ./partials/analytics %> 15 | <% if(typeof markup !== 'undefined') { %> 16 |
<%- markup %>
17 | <% } %> 18 | <% if(typeof serializedState !== 'undefined') { %> 19 | 22 | <% } %> 23 | <% if(typeof error === 'undefined') { %> 24 | <% if(env === 'PROD') { %> 25 | 26 | <% } else { %> 27 | 28 | 29 | <% } %> 30 | <% } %> 31 | 32 | <%- bannerHtml %> -------------------------------------------------------------------------------- /server/views/markup.ejs: -------------------------------------------------------------------------------- 1 | <%- markup %> -------------------------------------------------------------------------------- /server/views/partials/analytics.ejs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/bootstrap.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { Router } from 'react-router'; 4 | import createBrowserHistory from 'history/lib/createBrowserHistory' 5 | import routes from './routes.jsx'; 6 | 7 | //init httpService config 8 | import httpServiceConfiguration from 'httpServiceConfiguration'; 9 | import httpService from './services/httpService.js'; 10 | httpService.getInstance(httpServiceConfiguration);//will keep config in singleton 11 | //this way, instead of using resolve.alias of webpack (and having the require of module messed up by webpack when they'll be executed server-side) 12 | //I use dependency injection, in the one place that won't be executed in node : the client side bootstrap 13 | 14 | const history = createBrowserHistory(); 15 | ReactDOM.render({routes}, document.getElementById('app-container')) 16 | -------------------------------------------------------------------------------- /src/components/About.jsx: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import React from 'react'; 4 | 5 | const About = () => ( 6 |
7 |

Update: You may now use "universal" instead of isomorphic (see this post).

8 |

9 | The difference with regular SPA is that any page you access directly will be initially server-side rendered. 10 | You can check that by accessing directly a page and displaying its source.
11 | Even if server-side rendered, the SPA keeps working as usual (front-end router, template rendering ...). 12 |

13 |

14 | But ... why bother about that ...? 15 |

16 |

I won't go into specifics but here are two main upsides :

17 | 21 |

22 | TLDR; from stackoverflow 23 |

24 |
25 | Isomorphic web sites can be run on both the server and in the browser. They grant code reuse, seo, and page load speed boosts while still having an interface written in JS. node.js is most often used for the server javascript-engine. 26 |
27 |

Learn more about that and this project on my blog post.

28 |
29 | ); 30 | 31 | export default About; -------------------------------------------------------------------------------- /src/components/App.jsx: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import React from 'react'; 4 | //import Router from 'react-router'; 5 | //var RouteHandler = Router.RouteHandler; 6 | 7 | import Header from './Header.jsx'; 8 | import Footer from './Footer.jsx'; 9 | 10 | const App = ({children}) => ( 11 |
12 |
13 |
14 | {children} 15 |
16 |
17 |
18 | ); 19 | 20 | export default App; -------------------------------------------------------------------------------- /src/components/Footer.jsx: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import React from 'react'; 4 | 5 | import TwitterButton from './common/TwitterButton.jsx'; 6 | 7 | const Footer = () => ( 8 | 14 | ); 15 | 16 | export default Footer; -------------------------------------------------------------------------------- /src/components/Github.jsx: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import React from 'react'; 4 | 5 | import IntroBox from './github/IntroBox.jsx'; 6 | import SearchBox from './github/SearchBox.jsx'; 7 | 8 | const Github = () => ( 9 |
10 | 11 | 12 |
13 | ); 14 | 15 | export default Github; -------------------------------------------------------------------------------- /src/components/GithubUser.jsx: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import React from 'react'; 4 | import update from 'react-addons-update'; 5 | 6 | import github from '../services/github.js'; 7 | import Spinner from './common/Spinner.jsx'; 8 | import Profile from './githubUser/Profile.jsx'; 9 | import Repos from './githubUser/Repos.jsx'; 10 | 11 | const ORIGINAL_REPOS_PER_PAGE = 15; 12 | 13 | export default class GithubUser extends React.Component { 14 | constructor(props){ 15 | 16 | super(props); 17 | 18 | //init context bindings - due to diff between React.createClass and ES6 class 19 | this._getInitialState = this._getInitialState.bind(this); 20 | this.reposGotoPage = this.reposGotoPage.bind(this); 21 | this.init = this.init.bind(this); 22 | this.componentWillMount = this.componentWillMount.bind(this); 23 | 24 | //init state 25 | this.state = this._getInitialState(); 26 | 27 | //init state client-side - when the page was already rendered server-side, to share the state (passed serialized) 28 | if(typeof __DATA__ !== 'undefined' && __DATA__ !== null){ 29 | this.state = __DATA__.data; 30 | __DATA__ = null; 31 | } 32 | 33 | //init state server-side - to render based on data previously retrieved and passed through the router params 34 | if(props.params.data){ 35 | this.state.profile = props.params.data.profile; 36 | this.state.profile.pristineLogin = props.params.username; 37 | this.state.repositories = props.params.data.repositories; 38 | this.state.repositories.pristineLogin = props.params.username; 39 | } 40 | 41 | } 42 | 43 | /** 44 | * If the component's state not yet initialized with data (from server-side rendering), 45 | * will trigger an xhr, a state change and a re-render 46 | * 47 | * Should never be triggered on server-side rendering (even if componentDidMount only fires on client, 48 | * this one fires before the first render - see http://facebook.github.io/react/docs/component-specs.html#lifecycle-methods ) 49 | */ 50 | componentWillMount(){ 51 | if(!this.state.profile.data) { 52 | this.init(this.state.profile.pristineLogin); 53 | } 54 | } 55 | _getInitialState(){ 56 | return{ 57 | profile: { 58 | pristineLogin: this.props.params.username 59 | }, 60 | repositories: { 61 | pristineLogin: this.props.params.username 62 | } 63 | }; 64 | } 65 | init(userName){ 66 | //init the state as fetching 67 | var newState = update(this.state,{ 68 | profile:{ 69 | fetching: {$set: true} 70 | }, 71 | repositories:{ 72 | fetching: {$set: true} 73 | } 74 | }); 75 | this.setState(newState); 76 | //client-side fetching of the profile via xhr based on username 77 | github.getUser(userName) 78 | .then((result) => { 79 | this.setState({ 80 | profile: { 81 | data: result.data, 82 | fetching: false 83 | } 84 | }); 85 | }) 86 | .catch((error) => { 87 | this.setState({ 88 | profile: { 89 | error : error.humanMessage, 90 | fetching: false 91 | } 92 | }); 93 | }); 94 | //client-side fetching of the repositories via xhr based on the username 95 | github.getUserRepos(userName,{ 96 | page: 1, 97 | sort: "updated", 98 | per_page: ORIGINAL_REPOS_PER_PAGE 99 | }) 100 | .then((result) => { 101 | this.setState({ 102 | repositories: { 103 | pristineLogin: userName,//pass again (since it was erased) 104 | data: result.data, 105 | infos: result.infos, 106 | fetching: false 107 | } 108 | }); 109 | }) 110 | .catch((error) => { 111 | this.setState({ 112 | repositories: { 113 | error : error.humanMessage, 114 | fetching: false 115 | } 116 | }); 117 | }); 118 | } 119 | reposGotoPage(pageNum){ 120 | //client-side fetching of the repositories via xhr based on the username 121 | var newState = update(this.state,{ 122 | repositories:{ 123 | fetching: {$set: true} 124 | } 125 | }); 126 | this.setState(newState); 127 | github.getUserRepos(this.state.repositories.pristineLogin,{ 128 | page: pageNum, 129 | sort: "updated", 130 | per_page: this.state.repositories.infos.per_page 131 | }) 132 | .then((result) => { 133 | this.setState({ 134 | repositories: { 135 | pristineLogin: this.state.repositories.pristineLogin,//pass again (since it was erased) 136 | data: result.data, 137 | infos: result.infos, 138 | fetching: false 139 | } 140 | }); 141 | }) 142 | .catch((error) => { 143 | this.setState({ 144 | repositories: { 145 | error : error.humanMessage, 146 | fetching: false 147 | } 148 | }); 149 | }); 150 | } 151 | reposChangePerPage(perPage){ 152 | 153 | } 154 | render(){ 155 | var profile = this.state.profile; 156 | var repositories = this.state.repositories; 157 | return ( 158 |
159 | 160 | 161 |
162 | ); 163 | } 164 | } -------------------------------------------------------------------------------- /src/components/Header.jsx: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import React from 'react'; 4 | import { IndexLink, Link } from 'react-router'; 5 | 6 | export default class Header extends React.Component { 7 | constructor(props){ 8 | 9 | super(props); 10 | 11 | //init context bindings - due to diff between React.createClass and ES6 class 12 | this._getInitialState = this._getInitialState.bind(this); 13 | this.handleClick = this.handleClick.bind(this); 14 | 15 | //init state 16 | this.state = this._getInitialState(); 17 | 18 | } 19 | _getInitialState(){ 20 | return {collapsed: true}; 21 | } 22 | handleClick(e){ 23 | var collapsed = this.state.collapsed; 24 | this.setState({collapsed:!collapsed}); 25 | } 26 | render() { 27 | 28 | var collapsedMenuClassName = "collapse navbar-collapse" + (this.state.collapsed === true ? "" : " in"); 29 | 30 | return ( 31 |
32 | 55 | 56 | 70 |
71 | ); 72 | } 73 | } -------------------------------------------------------------------------------- /src/components/Home.jsx: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import React from 'react'; 4 | import { Link } from 'react-router'; 5 | 6 | const Home = () => ( 7 |
8 |

This project is the isomorphic version of topheman/react-es6. It's based on React and Express and relies on topheman-apis-proxy for the backend (providing the github API).

9 |

It's running on react v0.14.0 - read the blog post about the upgrade.

10 |

Please check out the github repo or read original blog post for further informations.

11 |

TL;DR : click on the button to try it !

12 |

TRY the DAMN thing !

13 |

Isomorphic, you say ? You may now use the term "universal" ... Take a quick look at the about page if you don't see the difference with the front-only version.

14 |
15 | ); 16 | 17 | export default Home; 18 | -------------------------------------------------------------------------------- /src/components/common/DisplayInfosPanel.jsx: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import React from 'react'; 4 | 5 | import Panel from '../common/Panel.jsx'; 6 | import Spinner from '../common/Spinner.jsx'; 7 | 8 | const DisplayInfosPanel = ({infos, originalTitle}) => { 9 | var fetching = infos ? infos.fetching : false; 10 | if(infos && infos.error){ 11 | var error = infos.error; 12 | return ( 13 | 14 |
15 |
16 |
17 | {error} 18 |
19 |
20 |
21 |
22 | ); 23 | } 24 | else{ 25 | //initial case before xhr 26 | //better speed loading perception if username already present 27 | return( 28 | 29 |
30 |
31 | 32 |
33 |
34 |
35 | ); 36 | } 37 | }; 38 | 39 | export default DisplayInfosPanel; -------------------------------------------------------------------------------- /src/components/common/DisplayStars.jsx: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import React from 'react'; 4 | 5 | const DisplayStars = ({number}) => { 6 | if(number > 0){ 7 | return( 8 | 9 | {number} 10 | 11 | ) 12 | } 13 | else{ 14 | return