├── .babelrc ├── .eslintrc.js ├── .gitignore ├── .storybook ├── addons.js ├── config.js └── storybook.css ├── .travis.yml ├── LICENSE ├── README.md ├── appveyor.yml ├── config ├── custom-environment-variables.json ├── default.js ├── production.js └── test.js ├── cypress.json ├── docs └── react-head-start-logo.jpg ├── logs └── logs.md ├── package-lock.json ├── package.json ├── src ├── client │ ├── App.jsx │ ├── App.scss │ ├── codeSplitMappingsAsync.js │ ├── codeSplitMappingsSync.js │ ├── components │ │ ├── common │ │ │ ├── ErrorMessage │ │ │ │ ├── ErrorMessage.js │ │ │ │ ├── ErrorMessage.stories.js │ │ │ │ ├── ErrorMessage.test.js │ │ │ │ └── _ErrorMessage.scss │ │ │ ├── Loading │ │ │ │ ├── Loading.js │ │ │ │ ├── Loading.stories.js │ │ │ │ ├── Loading.test.js │ │ │ │ └── _Loading.scss │ │ │ ├── MainFooter │ │ │ │ ├── MainFooter.jsx │ │ │ │ ├── MainFooter.stories.js │ │ │ │ └── _MainFooter.scss │ │ │ ├── MainHeader │ │ │ │ ├── MainHeader.jsx │ │ │ │ ├── MainHeader.stories.js │ │ │ │ └── _MainHeader.scss │ │ │ └── MainLayout │ │ │ │ ├── MainLayout.jsx │ │ │ │ ├── MainLayout.stories.js │ │ │ │ └── _MainLayout.scss │ │ └── index.js │ ├── global-scss │ │ ├── _common.scss │ │ ├── _reset.scss │ │ └── _vars.scss │ ├── index.js │ ├── index.scss │ ├── introduction.stories.js │ ├── pages │ │ ├── ErrorPage │ │ │ ├── ErrorPage.jsx │ │ │ ├── ErrorPage.stories.js │ │ │ ├── ErrorPage.test.js │ │ │ └── _ErrorPage.scss │ │ ├── HomePage │ │ │ ├── HomePage.jsx │ │ │ ├── HomePage.stories.js │ │ │ ├── HomePage.test.js │ │ │ └── _HomePage.scss │ │ └── UsersPage │ │ │ ├── UsersPage.jsx │ │ │ ├── UsersPage.stories.js │ │ │ ├── UsersPage.test.js │ │ │ └── _UsersPage.scss │ ├── reducers │ │ ├── index.js │ │ └── users.js │ ├── routes.js │ └── utils │ │ ├── api.js │ │ ├── clientConfig.js │ │ └── polyfills.js ├── server │ ├── index.js │ ├── routes │ │ ├── apiRoute.js │ │ ├── renderPageRoute.js │ │ └── renderPageRoute.test.js │ ├── utils │ │ ├── banner.js │ │ ├── logger.js │ │ ├── outgoingRequestLogger.js │ │ └── terminalColors.js │ └── views │ │ └── 500.html └── static │ ├── favicon.ico │ └── robots.txt └── test ├── e2e-test ├── fixtures │ └── example.json ├── plugins │ └── index.js ├── support │ ├── commands.js │ └── index.js └── tests │ └── simple_spec.js └── unit-test ├── jest.config.js ├── jest.polyfills.js └── jest.unit-test.init.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["env", "react"], 3 | "plugins": ["transform-class-properties", "syntax-dynamic-import"] 4 | } -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | //need babel-eslint to allow class static props 3 | "parser": "babel-eslint", 4 | "extends": [ "eslint:recommended", "plugin:react/recommended" ], 5 | "plugins": [ "react", "jest" ], 6 | "env": { 7 | "browser": true, 8 | "commonjs": true, 9 | "es6": true, 10 | "node": true, 11 | "jest/globals": true 12 | }, 13 | "globals":{ 14 | //unit tests 15 | "expect":true, 16 | "shallow":true, 17 | "render":true, 18 | "mount":true, 19 | "jest":true, 20 | "beforeAll":true, 21 | //e2e tests 22 | "cy":true, 23 | }, 24 | "parserOptions": { 25 | "ecmaFeatures": { 26 | "experimentalObjectRestSpread": true, 27 | "jsx": true 28 | }, 29 | "sourceType": "module" 30 | }, 31 | "rules": { 32 | //general 33 | "indent": [ "error", 2 ], 34 | "quotes": [ "error", "single" ], 35 | "semi": [ "error", "always" ], 36 | "no-console": ["error", { allow: ["warn", "error"] }], 37 | //react 38 | "react/react-in-jsx-scope": "off", 39 | //jest 40 | "jest/no-disabled-tests": "warn", 41 | "jest/no-focused-tests": "error", 42 | "jest/no-identical-title": "error", 43 | "jest/prefer-to-have-length": "warn", 44 | "jest/valid-expect": "error" 45 | } 46 | }; -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | logs/*.log 4 | coverage 5 | .cache 6 | config/local.js 7 | test/e2e-test/videos 8 | test/e2e-test/screenshots -------------------------------------------------------------------------------- /.storybook/addons.js: -------------------------------------------------------------------------------- 1 | import '@storybook/addon-actions/register'; 2 | import '@storybook/addon-links/register'; 3 | -------------------------------------------------------------------------------- /.storybook/config.js: -------------------------------------------------------------------------------- 1 | //main config for storybook 2 | 3 | import { configure } from '@storybook/react'; 4 | import { setOptions } from '@storybook/addon-options'; 5 | //note: we depend on our parcel build of sass (see the npm script "storybook" for the css build) 6 | import '../dist/bundles/index.css'; 7 | import 'github-markdown-css'; 8 | import './storybook.css' 9 | 10 | setOptions({ 11 | sortStoriesByKind: true 12 | }); 13 | 14 | // automatically import all files ending in *.stories.js 15 | const req = require.context('../src/client', true, /.stories.js$/); 16 | function loadStories() { 17 | //Note: sortStoriesByKind doesnt seem to work so force intro story first 18 | req('./introduction.stories.js'); 19 | 20 | req.keys() 21 | .forEach((filename) => req(filename)); 22 | } 23 | 24 | configure(loadStories, module); 25 | 26 | 27 | -------------------------------------------------------------------------------- /.storybook/storybook.css: -------------------------------------------------------------------------------- 1 | /* custom styles used in storybook */ 2 | 3 | .markdown-body{ 4 | margin: 0 auto; 5 | max-width: 1024px; 6 | padding: 20px; 7 | } -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '8' -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 gregtillbrook 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Screenshot showing output for console methods](/docs/react-head-start-logo.jpg?raw=true) 2 | 3 | A React App bootstrap with Server Side Rendering, Code Splitting, Hot Reloading, Redux and React Router. 4 | 5 | [![Linux CI Build][travis-image]][travis-url] 6 | [![Windows CI Build][appveyor-image]][appveyor-url] 7 | [![Known Vulnerabilities][snyk-image]][snyk-url] 8 | [![Dependency Up-to-dateness][david-image]][david-url] 9 | 10 | 11 | A rich performant react app can have a lot of fiddly parts once you start adding things like SSR(Server Side Rendering), Code Splitting & Hot loading. You also tend to want similar core setups in each new app you make. This bootstrap aims to capture that setup in a re-usable form and do it in a way that is as clean and intuitive as is (reasonably) possible. 12 | 13 | 14 | 15 | # Highlights 16 | 17 | ### 🚀 All the react goodness 18 | - [React v16](https://www.npmjs.com/package/react), [Redux](https://www.npmjs.com/package/redux), [React Router v4](https://www.npmjs.com/package/react-router) provide the core 19 | - ES2017+ standard modern javascript 20 | - Server is [express v4](https://www.npmjs.com/package/express) (running on Node 8 and up) 21 | 22 | ### 📦 Production ready 23 | - Production ready app bundled with Parcel i.e. no webpack. Its lightning quick & and zero configuration so no need to maintain a webpack (or grunt or gulp) config file 24 | - [Code splitting](https://parceljs.org/code_splitting.html) hooks to optimise downloaded bundle size 25 | - Server side rendering to provide best SEO and load performance 26 | - Logging (to file & console) with [winston](https://www.npmjs.com/package/winston) 27 | - Easy app config in different environments with [config](https://www.npmjs.com/package/config) 28 | 29 | ### 🐵 Dev 30 | - Instant updates in browser following code changes with Hot Reloading (aka HMR or [Hot Module Replacement](https://parceljs.org/hmr.html)) 31 | - Auto reloading of node upon server code changes thanks to [Nodemon](https://www.npmjs.com/package/nodemon) 32 | - Interactive development/debugging/testing of UI components with [Storybook](https://storybook.js.org/) 33 | - Basic SCSS styling in place to extend with what you need or replace with your css-in-js solution of choice 34 | - Sensible (opiniated) project structure for modular component dev. The code/styling/tests/etc for component are all in the same folder 35 | - Watch task to lint and unit test continuously on code changes 36 | 37 | ### ⛑ Code Quality 38 | - Syntax checking (aka linting) with [eslint](https://www.npmjs.com/package/eslint) 39 | - Unit testing with [jest](http://facebook.github.io/jest/) 40 | - End to end/integration testing with [cypress](https://www.cypress.io/) 41 | 42 | 43 | 44 | # Getting started 45 | 46 | ```console 47 | git clone git@github.com:gregtillbrook/react-head-start.git my-react-app 48 | cd my-react-app 49 | npm install 50 | npm run dev 51 | ``` 52 | 53 | 54 | # Key npm scripts 55 | The npm scripts may look confusing at first but most arent meant to be called directly but get called as part of other scripts. The key scripts to know are; 56 | 57 | ```npm run dev``` 58 | Standard dev command. Runs up the app in 'dev' mode with auto reloading on server code changes + hot reloading of client. 59 | 60 | ```npm run prod``` 61 | Builds app in production mode (minified & faster than dev mode) and serves prod app. If you need to build and serve indepentally (which you probably do) use ```npm run prod:build``` and ```npm run prod:serve```. Note: prod:build is broken down into several sub tasks (client build, server build, copy files to dist folder) for readability but you wont need to run those individually. 62 | 63 | ```npm test``` 64 | Runs linting, unit tests and end to end tests. This is what the CI build runs and is good practice to run before each commit. You can also individually run ```npm run lint``` ```npm run test:unit``` and ```npm run test:e2e```. 65 | 66 | ```npm run test:watch``` 67 | Runs a watch task that runs linting and unit tests each time the code changes. This is a useful one to run alongside ```npm run dev``` while developing. Note: the e2e tests are omitted from this by default because they tend to run a little slower in normal sized apps. 68 | 69 | ```npm run test:e2e:dev``` 70 | Use this when authoring e2e tests. ```npm run test:e2e``` runs the tests in headless mode and handles the build + running of the app which is great for test runs and CI but is not optimal for developing and debugging. To develope e2e tests 71 | - In a terminal window, start up the app with ```npm run prod``` 72 | - In a second terminal window, open the cypress UI with ```npm run test:e2e:dev``` 73 | 74 | ```npm run storybook``` 75 | For interactive development/debugging/testing of components. You can also use ```npm run storybook:build``` to generate a static storybook site 76 | 77 | ```npm run todo``` 78 | A little helper to console log a list of all TODO notes in the app. 79 | 80 | 81 | 82 | # Things to think about 83 | 84 | ### You may not need code splitting 85 | The value of code splitting depends a lot on the structure of your app. If you have a large app with many distinct but rich pages, code splitting could be incredibly valuable. However if your app is small or HAS to load the bulk of your code in the initial load, then code splitting may not be so useful. 86 | 87 | ### You may not need server side rendering 88 | Historically, server side rendering has been important for things like SEO (search engines would index static html only) and page load performance. But nowadays these things are less of an issue e.g. google now executes javascript when indexing a page (although other search engines still vary in js support). So again, your apps structure and requirements will dictate how valuable SSR is to you. 89 | 90 | ### This thing has a lot of crap I don't want 91 | React bootstraps tend to be opiniated by their nature. The trick is hitting the balance between adding stuff thats useful and bloating it with useless stuff that makes the starter app harder to understand. What do you think - is there something important missing? or have I included something that really shouldnt be here? 92 | 93 | ### Where's webpack? 94 | Webacks configurability is great for complex apps or those requiring very specific builds. If you can avoid having to write/maintain a webpack config then thats a win - so I preferr to start with Parcel or create-react-app and swap to full webpack only when needed. 95 | 96 | ### Lots of comments? 97 | Ive tried to comment this app quite heavily so it's easier to understand what's going on as you step through the code. You'll probably what to clear a lot of those comments out when you start using the app. Is there anything confusing that could use more documentation? 98 | 99 | 100 | 101 | # TODO 102 | Stuff not done yet that Im considering adding; 103 | - code coverage reporting (+ make sure this bootstrap starts from 100% coverage) 104 | - service worker for [Progressive Web App](https://developers.google.com/web/progressive-web-apps/) 105 | - [prettier](https://www.npmjs.com/package/prettier) 106 | - Internationalisation 107 | 108 | 109 | [travis-image]: https://img.shields.io/travis/gregtillbrook/react-head-start/master.svg?label=Linux%20CI%20Build 110 | [travis-url]: https://travis-ci.org/gregtillbrook/react-head-start 111 | [appveyor-image]: https://img.shields.io/appveyor/ci/gregtillbrook/react-head-start/master.svg?label=Windows%20CI%20Build 112 | [appveyor-url]: https://ci.appveyor.com/project/gregtillbrook/react-head-start 113 | [snyk-image]: https://snyk.io/test/github/gregtillbrook/react-head-start/badge.svg 114 | [snyk-url]: https://snyk.io/test/github/gregtillbrook/react-head-start 115 | [david-image]: https://david-dm.org/gregtillbrook/react-head-start.svg 116 | [david-url]: https://david-dm.org/gregtillbrook/react-head-start 117 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | environment: 2 | matrix: 3 | - nodejs_version: '8' 4 | install: 5 | - ps: Install-Product node $env:nodejs_version 6 | - set CI=true 7 | - npm install --global npm@latest 8 | - set PATH=%APPDATA%\npm;%PATH% 9 | - npm install 10 | matrix: 11 | fast_finish: true 12 | build: off 13 | shallow_clone: true 14 | test_script: 15 | - node --version 16 | - npm --version 17 | - npm test 18 | cache: 19 | - '%APPDATA%\npm-cache' -------------------------------------------------------------------------------- /config/custom-environment-variables.json: -------------------------------------------------------------------------------- 1 | { 2 | "port": "NODE_PORT", 3 | "host": "NODE_HOST" 4 | } 5 | -------------------------------------------------------------------------------- /config/default.js: -------------------------------------------------------------------------------- 1 | //put all app configuration here with a sensible default and then override per machine/environment/etc in other config files 2 | //more docs on how config files work = https://github.com/lorenwest/node-config/wiki/Configuration-Files 3 | //note: temporary local config (for local debugging/development) can be put into config/local.js (it's git ignored) 4 | module.exports = { 5 | host: undefined, 6 | port: 5000, 7 | 8 | //config inside here will be available in the client browser app 9 | clientConfig:{ 10 | //WARNING: dont put anything sensitive in here - it WILL be publicly visible in the client browser 11 | apiHost: 'http://localhost:5000', 12 | }, 13 | 14 | enableServerSideRender:true, 15 | 16 | logIncomingHttpRequests:true, 17 | logOutgoingHttpRequests:true, 18 | 19 | logging:{ 20 | consoleLogLevel:'debug', 21 | logFileLogLevel:'info', 22 | logFilePath:'./logs/server.log', 23 | maxLogFileSizeInMB:5, 24 | maxLogFileCount:5 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /config/production.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | //put production configuration here 3 | }; 4 | -------------------------------------------------------------------------------- /config/test.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | //put override config for jest unit tests here 3 | 4 | port: 5010, 5 | clientConfig:{ 6 | apiHost: 'http://localhost:5010', 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "fixturesFolder":"test/e2e-test/fixtures", 3 | "integrationFolder":"test/e2e-test/tests", 4 | "pluginsFile":"test/e2e-test/plugins/index.js", 5 | "screenshotsFolder":"test/e2e-test/screenshots", 6 | "supportFile":"test/e2e-test/support/index.js", 7 | "videosFolder":"test/e2e-test/videos" 8 | } -------------------------------------------------------------------------------- /docs/react-head-start-logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gregtillbrook/react-head-start/c0d0a8838ab32e30bf70025d8d05bde477f0f3cb/docs/react-head-start-logo.jpg -------------------------------------------------------------------------------- /logs/logs.md: -------------------------------------------------------------------------------- 1 | This is the default location for winston server logs (configurable in config files) -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-head-start", 3 | "version": "0.9.0", 4 | "description": "React App bootstrap with Server Side Rendering, Code Splitting, Hot Reloading, Redux and React Router", 5 | "author": "Greg Tillbrook", 6 | "engines": { 7 | "node": ">=8" 8 | }, 9 | "scripts": { 10 | "start": "npm run dev", 11 | "dev": "npm-run-all --parallel dev:build dev:serve", 12 | "prod": "npm run prod:build && npm run prod:serve", 13 | "clean": "rimraf dist", 14 | "dev:build": "npm run clean && cross-env NODE_ENV=development parcel watch src/client/index.js --out-dir dist/bundles", 15 | "dev:build:css": "npm run clean && cross-env NODE_ENV=development parcel watch src/client/index.scss --out-dir dist/bundles", 16 | "dev:serve": "cross-env NODE_ENV=development npx nodemon --exec babel-node --ext js,jsx --delay 0.5 --watch src --watch test src/server/", 17 | "prod:build": "npm run clean && npm run prod:copy-files && npm run prod:build-server && npm run prod:build-client ", 18 | "prod:build-client": "cross-env NODE_ENV=production parcel build src/client/index.js --public-url / --out-dir dist/bundles --detailed-report", 19 | "prod:build-server": "cd src/ && cross-env npx babel '**/*.{js,jsx}' --no-babelrc --presets react,node8 --plugins transform-class-properties,syntax-dynamic-import --out-dir '../dist' --ignore test.js", 20 | "prod:copy-files": "cross-env npx copyfiles -u 1 'src/static/**/*' dist", 21 | "prod:serve": "cross-env NODE_ENV=production node dist/server/", 22 | "test": "npm run lint && npm run test:unit && npm run test:e2e", 23 | "lint": "npx eslint --ext jsx,js src test", 24 | "test:unit": "npx jest --config test/unit-test/jest.config.js", 25 | "test:e2e": "npm run prod:build && cross-env NODE_ENV=test node-while -s dist/server/index.js -r \"node node_modules/cypress/bin/cypress run\"", 26 | "test:e2e:dev": "npx cypress open --env env=production", 27 | "test:watch": "npm-watch", 28 | "test:quick": "npm run lint && npm run test:unit", 29 | "todo": "npx fixme -i 'node_modules/**' -i '.git/**' -i 'dist/**' --skip note", 30 | "storybook": "npm-run-all --parallel dev:build:css storybook:serve", 31 | "storybook:serve": "wait-on ./dist/bundles/index.css && start-storybook -p 6006", 32 | "storybook:build": "build-storybook" 33 | }, 34 | "browser": { 35 | "./src/client/codeSplitMappingsSync.js": "./src/client/codeSplitMappingsAsync.js", 36 | "config": "./src/client/utils/clientConfig.js" 37 | }, 38 | "watch": { 39 | "test:quick": { 40 | "patterns": [ 41 | "src", 42 | "test" 43 | ], 44 | "extensions": "js,jsx", 45 | "quiet": false 46 | } 47 | }, 48 | "postcss": { 49 | "modules": false, 50 | "plugins": { 51 | "autoprefixer": { 52 | "browsers": [ 53 | ">1%", 54 | "last 4 versions", 55 | "Firefox ESR", 56 | "not ie < 9" 57 | ], 58 | "flexbox": "no-2009" 59 | } 60 | } 61 | }, 62 | "dependencies": { 63 | "caller-callsite": "^2.0.0", 64 | "compression": "^1.7.1", 65 | "config": "^1.28.1", 66 | "cookie-parser": "^1.4.3", 67 | "express": "^4.16.2", 68 | "morgan": "^1.9.0", 69 | "serve-favicon": "^2.4.5", 70 | "winston": "^2.3.1" 71 | }, 72 | "devDependencies": { 73 | "@storybook/addon-actions": "^3.3.12", 74 | "@storybook/addon-links": "^3.3.12", 75 | "@storybook/addon-notes": "^3.3.12", 76 | "@storybook/addon-options": "^3.3.12", 77 | "@storybook/addons": "^3.3.12", 78 | "@storybook/react": "^3.3.12", 79 | "autoprefixer": "^7.2.1", 80 | "babel-cli": "^6.26.0", 81 | "babel-core": "^6.26.0", 82 | "babel-eslint": "^8.0.3", 83 | "babel-jest": "^21.2.0", 84 | "babel-loader": "^7.1.2", 85 | "babel-plugin-syntax-dynamic-import": "^6.18.0", 86 | "babel-plugin-transform-class-properties": "^6.24.1", 87 | "babel-polyfill": "^6.26.0", 88 | "babel-preset-env": "^1.6.1", 89 | "babel-preset-node8": "^1.2.0", 90 | "babel-preset-react": "^6.24.1", 91 | "cheerio": "^1.0.0-rc.2", 92 | "copyfiles": "^1.2.0", 93 | "core-js": "^2.5.3", 94 | "cross-env": "^5.1.1", 95 | "cypress": "^1.4.0", 96 | "enzyme": "^3.2.0", 97 | "enzyme-adapter-react-16": "^1.1.0", 98 | "eslint": "^4.13.1", 99 | "eslint-plugin-jest": "^21.5.0", 100 | "eslint-plugin-react": "^7.5.1", 101 | "fixme": "^0.4.4", 102 | "github-markdown-css": "^2.10.0", 103 | "jest": "^21.2.1", 104 | "node-sass": "^4.7.2", 105 | "node-while": "^1.0.1", 106 | "nodemon": "^1.12.7", 107 | "npm-run-all": "^4.1.2", 108 | "npm-watch": "^0.3.0", 109 | "parcel-bundler": "^1.6.2", 110 | "parcel-plugin-bundle-visualiser": "^1.0.2", 111 | "prop-types": "^15.6.0", 112 | "react": "^16.2.0", 113 | "react-async-component": "^1.0.2", 114 | "react-dom": "^16.2.0", 115 | "react-helmet": "^5.2.0", 116 | "react-redux": "^5.0.6", 117 | "react-router-config": "^1.0.0-beta.4", 118 | "react-router-dom": "^4.2.2", 119 | "react-router-test-context": "^0.1.0", 120 | "redux": "^3.7.2", 121 | "redux-thunk": "^2.2.0", 122 | "regenerator-runtime": "^0.11.1", 123 | "rimraf": "^2.6.2", 124 | "storybook-router": "^0.3.2", 125 | "wait-on": "^2.1.0" 126 | }, 127 | "keywords": [ 128 | "react", 129 | "bootstrap", 130 | "head start", 131 | "redux", 132 | "react-router", 133 | "express", 134 | "ssr", 135 | "server side render", 136 | "code splitting", 137 | "hot loading", 138 | "boilerplate" 139 | ], 140 | "repository": "gregtillbrook/react-head-start", 141 | "license": "MIT" 142 | } 143 | -------------------------------------------------------------------------------- /src/client/App.jsx: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import { renderRoutes } from 'react-router-config'; 3 | import PropTypes from 'prop-types'; 4 | import {Helmet} from './components/'; 5 | 6 | 7 | export default class App extends Component { 8 | 9 | static propTypes = { 10 | route: PropTypes.object.isRequired 11 | } 12 | 13 | render() { 14 | return ( 15 |
16 | 17 | 18 | {/* Set site wide header info here and specific overrides in pages */} 19 | My site title 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | {renderRoutes(this.props.route.routes)} 28 | 29 |
30 | ); 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /src/client/App.scss: -------------------------------------------------------------------------------- 1 | .app-wrapper { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | text-align: center; 6 | margin: 2rem auto; 7 | } -------------------------------------------------------------------------------- /src/client/codeSplitMappingsAsync.js: -------------------------------------------------------------------------------- 1 | /* 2 | Code Splitting config for Asyncronous load (aka on client) 3 | 4 | This file is paired with codeSplitMappingsSync.js - they must have the same exports. 5 | This file is imported in the client bundle while codeSplitMappingsSync is imported on 6 | the server (see package.json "browser" mappings) - this is necessary for code splitting to work. 7 | The dynamic import(...) calls below are what define the seperate code splitting sub bundles. 8 | */ 9 | import { asyncComponent } from 'react-async-component'; 10 | import {Loading, ErrorMessage} from './components/'; 11 | 12 | 13 | function makeAsyncComponent(importFunc){ 14 | return asyncComponent({ 15 | resolve: importFunc, 16 | LoadingComponent: Loading, // Optional 17 | ErrorComponent: ErrorMessage // Optional 18 | }); 19 | } 20 | 21 | export const HomePage = makeAsyncComponent(() => import('./pages/HomePage/HomePage')); 22 | export const UsersPage = makeAsyncComponent(() => import('./pages/UsersPage/UsersPage')); 23 | export const ErrorPage = makeAsyncComponent(() => import('./pages/ErrorPage/ErrorPage')); 24 | -------------------------------------------------------------------------------- /src/client/codeSplitMappingsSync.js: -------------------------------------------------------------------------------- 1 | /* 2 | Code Splitting config for Syncronous load (aka on server) 3 | 4 | This file is paired with codeSplitMappingsAsync.js - they must have the same exports. 5 | This file is imported on the server while codeSplitMappingsAsync is imported in the client 6 | bundle (see package.json "browser" mappings) - this is necessary for code splitting to work. 7 | Unlike codeSplitMappingsAsync all imports on server are standard module imports (the server 8 | needs all code imported up front). 9 | */ 10 | export {default as HomePage} from './pages/HomePage/HomePage'; 11 | export {default as UsersPage} from './pages/UsersPage/UsersPage'; 12 | export {default as ErrorPage} from './pages/ErrorPage/ErrorPage'; -------------------------------------------------------------------------------- /src/client/components/common/ErrorMessage/ErrorMessage.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | 5 | export default class ErrorMessage extends Component { 6 | 7 | static propTypes = { 8 | error: PropTypes.object 9 | }; 10 | 11 | render() { 12 | const { message = 'Error' } = this.props.error || {}; 13 | 14 | return ( 15 |
16 | {message} 17 |
18 | ); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/client/components/common/ErrorMessage/ErrorMessage.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { storiesOf } from '@storybook/react'; 3 | 4 | import ErrorMessage from './ErrorMessage'; 5 | 6 | 7 | storiesOf('common/ErrorMessage', module) 8 | .add('default', () => ) 9 | .add('with message', () => ); -------------------------------------------------------------------------------- /src/client/components/common/ErrorMessage/ErrorMessage.test.js: -------------------------------------------------------------------------------- 1 | import ErrorMessage from './ErrorMessage'; 2 | 3 | 4 | describe('components/commmon/ErrorMessage', function() { 5 | 6 | it('should show default message', function() { 7 | const cmp = shallow(); 8 | expect(cmp.text()).toEqual('Error'); 9 | }); 10 | 11 | it('should show supplied error message', function() { 12 | const mockErr = new Error('mock error'); 13 | const cmp = shallow(); 14 | expect(cmp.text()).toEqual('mock error'); 15 | }); 16 | 17 | }); -------------------------------------------------------------------------------- /src/client/components/common/ErrorMessage/_ErrorMessage.scss: -------------------------------------------------------------------------------- 1 | .error-message{ 2 | text-align: center; 3 | color: $col-error; 4 | padding: 50px 10px; 5 | } -------------------------------------------------------------------------------- /src/client/components/common/Loading/Loading.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | 3 | 4 | export default class Loading extends Component { 5 | render() { 6 | return ( 7 |
8 | Loading... 9 |
10 | ); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/client/components/common/Loading/Loading.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { storiesOf } from '@storybook/react'; 3 | 4 | import Loading from './Loading'; 5 | 6 | 7 | storiesOf('common/Loading', module) 8 | .add('default', () => ); -------------------------------------------------------------------------------- /src/client/components/common/Loading/Loading.test.js: -------------------------------------------------------------------------------- 1 | import Loading from './Loading'; 2 | 3 | 4 | describe('components/common/ErrorMessage', function() { 5 | 6 | it('should show content', function() { 7 | const cmp = shallow(); 8 | expect(cmp.text()).toEqual('Loading...'); 9 | }); 10 | 11 | }); 12 | -------------------------------------------------------------------------------- /src/client/components/common/Loading/_Loading.scss: -------------------------------------------------------------------------------- 1 | .loading{ 2 | text-align: center; 3 | padding: 50px 10px; 4 | } -------------------------------------------------------------------------------- /src/client/components/common/MainFooter/MainFooter.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | 3 | 4 | export default class MainFooter extends Component { 5 | render() { 6 | return ( 7 |
8 |
9 | ); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/client/components/common/MainFooter/MainFooter.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { storiesOf } from '@storybook/react'; 3 | import StoryRouter from 'storybook-router'; 4 | 5 | import MainFooter from './MainFooter'; 6 | 7 | 8 | storiesOf('common/MainFooter', module) 9 | .addDecorator(StoryRouter()) 10 | .add('default', () => ()); -------------------------------------------------------------------------------- /src/client/components/common/MainFooter/_MainFooter.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gregtillbrook/react-head-start/c0d0a8838ab32e30bf70025d8d05bde477f0f3cb/src/client/components/common/MainFooter/_MainFooter.scss -------------------------------------------------------------------------------- /src/client/components/common/MainHeader/MainHeader.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | 4 | export default class MainHeader extends Component { 5 | render() { 6 | return ( 7 |
8 |

react-head-start

9 | 13 |
14 | ); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/client/components/common/MainHeader/MainHeader.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { storiesOf } from '@storybook/react'; 3 | import StoryRouter from 'storybook-router'; 4 | 5 | import MainHeader from './MainHeader'; 6 | 7 | 8 | storiesOf('common/MainHeader', module) 9 | .addDecorator(StoryRouter()) 10 | .add('default', () => ()); -------------------------------------------------------------------------------- /src/client/components/common/MainHeader/_MainHeader.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gregtillbrook/react-head-start/c0d0a8838ab32e30bf70025d8d05bde477f0f3cb/src/client/components/common/MainHeader/_MainHeader.scss -------------------------------------------------------------------------------- /src/client/components/common/MainLayout/MainLayout.jsx: -------------------------------------------------------------------------------- 1 | //Meant as top level layout for pages - i.e. sites tend to have the same (or a few) layout(s) 2 | //of header/navigation/footer around each pages content layouts provide a good way to compose 3 | //pages in a clear declarative way 4 | import React, { Component } from 'react'; 5 | import PropTypes from 'prop-types'; 6 | import MainHeader from '../MainHeader/MainHeader'; 7 | import MainFooter from '../MainFooter/MainFooter'; 8 | 9 | export default class MainLayout extends Component { 10 | 11 | static propTypes = { 12 | children: PropTypes.any 13 | }; 14 | 15 | render() { 16 | //React.Fragment so we can return an array of components (i.e. to avoid another wrapper div) 17 | return ( 18 | 19 | 20 |
21 | {this.props.children} 22 |
23 | 24 |
25 | ); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/client/components/common/MainLayout/MainLayout.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { storiesOf } from '@storybook/react'; 3 | import StoryRouter from 'storybook-router'; 4 | 5 | import MainLayout from './MainLayout'; 6 | 7 | 8 | storiesOf('common/MainLayout', module) 9 | .addDecorator(StoryRouter()) 10 | .add('default', () => (Mock body content)); -------------------------------------------------------------------------------- /src/client/components/common/MainLayout/_MainLayout.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gregtillbrook/react-head-start/c0d0a8838ab32e30bf70025d8d05bde477f0f3cb/src/client/components/common/MainLayout/_MainLayout.scss -------------------------------------------------------------------------------- /src/client/components/index.js: -------------------------------------------------------------------------------- 1 | //group common componets into a single module of exports so we have 2 | //less import boilerplate in pages and components 3 | //Note: be mindful of code splitting though, only import truly common components here 4 | //that are intended to be in the main bundle 5 | 6 | export {default as MainHeader} from './common/MainHeader/MainHeader'; 7 | export {default as MainFooter} from './common/MainFooter/MainFooter'; 8 | export {default as MainLayout} from './common/MainLayout/MainLayout'; 9 | export {default as ErrorMessage} from './common/ErrorMessage/ErrorMessage'; 10 | export {default as Loading} from './common/Loading/Loading'; 11 | 12 | 13 | // pass through common 3rd party components to also cut down on import boilerplate 14 | 15 | export {Helmet} from 'react-helmet'; 16 | -------------------------------------------------------------------------------- /src/client/global-scss/_common.scss: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: -apple-system, system-ui, BlinkMacSystemFont, 'Segoe UI', Roboto, 5 | 'Helvetica Neue', Arial, sans-serif; 6 | } 7 | -------------------------------------------------------------------------------- /src/client/global-scss/_reset.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gregtillbrook/react-head-start/c0d0a8838ab32e30bf70025d8d05bde477f0f3cb/src/client/global-scss/_reset.scss -------------------------------------------------------------------------------- /src/client/global-scss/_vars.scss: -------------------------------------------------------------------------------- 1 | 2 | 3 | //colours 4 | $col-error:#ff0000; -------------------------------------------------------------------------------- /src/client/index.js: -------------------------------------------------------------------------------- 1 | //this is the entry point for the client app bundle 2 | import './utils/polyfills'; 3 | import React, { Component } from 'react'; 4 | import PropTypes from 'prop-types'; 5 | import {hydrate} from 'react-dom'; 6 | import BrowserRouter from 'react-router-dom/BrowserRouter'; 7 | import { renderRoutes } from 'react-router-config'; 8 | import { createStore, applyMiddleware } from 'redux'; 9 | import { Provider } from 'react-redux'; 10 | import thunk from 'redux-thunk'; 11 | 12 | import routes from './routes'; 13 | import reducers from './reducers'; 14 | import 'config'; //see utils/clientConfig.js 15 | import './index.scss'; //Parcel will use this import to build the css bundle 16 | 17 | 18 | //The SSR(Sever Side Render) passes data to client via __INIT_DATA_FROM_SERVER_RENDER__ 19 | const initData = window.__INIT_DATA_FROM_SERVER_RENDER__; 20 | console.log('server render stats', initData.stats); //eslint-disable-line no-console 21 | 22 | const store = createStore( 23 | reducers, initData.initialState, applyMiddleware(thunk) 24 | ); 25 | 26 | export default class Index extends Component { 27 | 28 | static propTypes = { 29 | route: PropTypes.object 30 | } 31 | 32 | render() { 33 | return ( 34 | 35 | 36 | {renderRoutes(routes)} 37 | 38 | 39 | ); 40 | } 41 | } 42 | 43 | hydrate(, document.querySelector('#app')); -------------------------------------------------------------------------------- /src/client/index.scss: -------------------------------------------------------------------------------- 1 | //core 2 | @import "./global-scss/vars"; 3 | @import "./global-scss/reset"; 4 | @import "./global-scss/common"; 5 | 6 | //components 7 | @import "./components/common/ErrorMessage/ErrorMessage"; 8 | @import "./components/common/Loading/Loading"; 9 | @import "./components/common/MainHeader/MainHeader"; 10 | @import "./components/common/MainFooter/MainFooter"; 11 | @import "./components/common/MainLayout/MainLayout"; 12 | 13 | //pages 14 | @import "./pages/UsersPage/UsersPage"; 15 | @import "./pages/HomePage/HomePage"; 16 | @import "./pages/ErrorPage/ErrorPage"; 17 | -------------------------------------------------------------------------------- /src/client/introduction.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { storiesOf } from '@storybook/react'; 3 | 4 | import README from '../../README.md'; 5 | 6 | 7 | //simple helper to output mardown based docs (defined here instead of main app because it's 8 | //potentially dangerous if misused) 9 | //eslint-disable-next-line react/prop-types 10 | const Markup = ({content}) =>
; 11 | 12 | 13 | storiesOf('introduction', module) 14 | .add('ReadMe', () => ); -------------------------------------------------------------------------------- /src/client/pages/ErrorPage/ErrorPage.jsx: -------------------------------------------------------------------------------- 1 | //Simple error page 2 | import React, { Component } from 'react'; 3 | import { MainLayout, Helmet } from '../../components/'; 4 | 5 | 6 | export default class ErrorPage extends Component { 7 | render() { 8 | return ( 9 | 10 | 11 | My site: Error 12 | 13 | 14 |

Error: page not found

15 |
16 | ); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/client/pages/ErrorPage/ErrorPage.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { storiesOf } from '@storybook/react'; 3 | import StoryRouter from 'storybook-router'; 4 | 5 | import ErrorPage from './ErrorPage'; 6 | 7 | 8 | storiesOf('pages/ErrorPage', module) 9 | .addDecorator(StoryRouter()) 10 | .add('default', () => ); -------------------------------------------------------------------------------- /src/client/pages/ErrorPage/ErrorPage.test.js: -------------------------------------------------------------------------------- 1 | import ErrorPage from './ErrorPage'; 2 | import createRouterContext from 'react-router-test-context'; 3 | 4 | 5 | describe('pages/ErrorPage', function() { 6 | 7 | it('should render error message', function() { 8 | const page = shallow(, {context:createRouterContext()}); 9 | expect(page.find('h2').text()).toEqual('Error: page not found'); 10 | }); 11 | 12 | }); -------------------------------------------------------------------------------- /src/client/pages/ErrorPage/_ErrorPage.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gregtillbrook/react-head-start/c0d0a8838ab32e30bf70025d8d05bde477f0f3cb/src/client/pages/ErrorPage/_ErrorPage.scss -------------------------------------------------------------------------------- /src/client/pages/HomePage/HomePage.jsx: -------------------------------------------------------------------------------- 1 | //An example of a basic page with no redux binding (see UserPage for a redux example) 2 | import React, { Component } from 'react'; 3 | import {MainLayout, Helmet} from '../../components/'; 4 | 5 | 6 | export default class HomePage extends Component { 7 | render() { 8 | return ( 9 | 10 | 11 | My site: Home page 12 | 13 | 14 |

Home Page

15 |
16 | ); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/client/pages/HomePage/HomePage.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { storiesOf } from '@storybook/react'; 3 | import StoryRouter from 'storybook-router'; 4 | 5 | import HomePage from './HomePage'; 6 | 7 | 8 | storiesOf('pages/HomePage', module) 9 | .addDecorator(StoryRouter()) 10 | .add('default', () => ); -------------------------------------------------------------------------------- /src/client/pages/HomePage/HomePage.test.js: -------------------------------------------------------------------------------- 1 | import HomePage from './HomePage'; 2 | import createRouterContext from 'react-router-test-context'; 3 | 4 | 5 | describe('pages/Home', function() { 6 | 7 | it('should render content', function() { 8 | const page = shallow(, {context:createRouterContext()}); 9 | expect(page.find('h2').text()).toEqual('Home Page'); 10 | }); 11 | 12 | }); -------------------------------------------------------------------------------- /src/client/pages/HomePage/_HomePage.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gregtillbrook/react-head-start/c0d0a8838ab32e30bf70025d8d05bde477f0f3cb/src/client/pages/HomePage/_HomePage.scss -------------------------------------------------------------------------------- /src/client/pages/UsersPage/UsersPage.jsx: -------------------------------------------------------------------------------- 1 | //A slightly more complex page showing redux binding. Note the 'fetchData' static method 2 | //that is used during SSR to call all the necessary actions to create the page 3 | import React, { Component } from 'react'; 4 | import PropTypes from 'prop-types'; 5 | import { connect } from 'react-redux'; 6 | 7 | import { MainLayout, Helmet } from '../../components/'; 8 | import { fetchUsers } from '../../reducers/users'; 9 | 10 | 11 | export class UsersPage extends Component { 12 | 13 | static propTypes = { 14 | fetchUsers: PropTypes.func.isRequired, 15 | users: PropTypes.array 16 | }; 17 | 18 | //This is used during SSR - see src/server/routes/renderPageRoute.js (fetchDataAndInitReduxStore) 19 | //As you add pages to your app you just need to make sure fetchData calls the necessary actions to 20 | //load data the page needs and you shouldnt need to delve into server code very often. It is good 21 | //to understand what the server code is doing during SSR though. 22 | static fetchData(store) { 23 | return store.dispatch(fetchUsers()); 24 | } 25 | 26 | componentDidMount() { 27 | this.props.fetchUsers(); 28 | } 29 | 30 | render() { 31 | const users = this.props.users || []; 32 | 33 | const liTags = users.map(user=>{ 34 | return ( 35 |
  • {user.name}
  • 36 | ); 37 | }); 38 | 39 | return ( 40 | 41 | 42 | My site: Users page 43 | 44 | 45 |

    Users Page

    46 | 47 |
      48 | {liTags} 49 |
    50 |
    51 | ); 52 | } 53 | } 54 | 55 | export default connect( 56 | state => ({ 57 | users: state.users.items 58 | }), 59 | { 60 | fetchUsers 61 | } 62 | )(UsersPage); -------------------------------------------------------------------------------- /src/client/pages/UsersPage/UsersPage.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { storiesOf } from '@storybook/react'; 3 | import StoryRouter from 'storybook-router'; 4 | 5 | //Note: we're imported the non 'connect' wrapped version of this component 6 | import { UsersPage } from './UsersPage'; 7 | 8 | 9 | storiesOf('pages/UsersPage', module) 10 | .addDecorator(StoryRouter()) 11 | .add('no users', () => {}} />) 12 | .add('1 user', () => {}} users={[{name:'Mock user'}]} />); -------------------------------------------------------------------------------- /src/client/pages/UsersPage/UsersPage.test.js: -------------------------------------------------------------------------------- 1 | import {UsersPage} from './UsersPage'; 2 | import createRouterContext from 'react-router-test-context'; 3 | 4 | 5 | describe('pages/UsersPage', function() { 6 | 7 | it('should render content', function() { 8 | const mockAction = ()=>{}; 9 | const page = shallow(, {context:createRouterContext()}); 10 | expect(page.find('h2').text()).toEqual('Users Page'); 11 | }); 12 | 13 | }); 14 | -------------------------------------------------------------------------------- /src/client/pages/UsersPage/_UsersPage.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gregtillbrook/react-head-start/c0d0a8838ab32e30bf70025d8d05bde477f0f3cb/src/client/pages/UsersPage/_UsersPage.scss -------------------------------------------------------------------------------- /src/client/reducers/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import users from './users'; 3 | 4 | export default combineReducers({ 5 | users 6 | }); -------------------------------------------------------------------------------- /src/client/reducers/users.js: -------------------------------------------------------------------------------- 1 | import api from '../utils/api'; 2 | 3 | 4 | export const USERS_LOADED = 'USERS_LOADED'; 5 | 6 | 7 | export const fetchUsers = () => async (dispatch) => { 8 | const data = await api.fetchUsers(); 9 | dispatch({ 10 | type: USERS_LOADED, 11 | items: data.users 12 | }); 13 | }; 14 | 15 | 16 | const initialState = { 17 | items: [] 18 | }; 19 | export default function reducer(state = initialState, action) { 20 | switch (action.type) { 21 | 22 | case USERS_LOADED: 23 | return Object.assign({}, state, { items: action.items }); 24 | 25 | default: 26 | return state; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/client/routes.js: -------------------------------------------------------------------------------- 1 | //Note: although react-router 4 allows for dynamic/distributed routing, we need 2 | //to use a single static route config for code-splitting to work 3 | import App from './App'; 4 | import {HomePage, UsersPage, ErrorPage} from './codeSplitMappingsSync'; 5 | 6 | const routes = [ 7 | { 8 | component: App, 9 | routes: [ 10 | { path: '/', exact: true, component: HomePage, layout:'fred' }, 11 | { path: '/users', component: UsersPage, layout:'fred2' }, 12 | { path: '*', component: ErrorPage, layout:'fred2' } 13 | ] 14 | } 15 | ]; 16 | 17 | export default routes; -------------------------------------------------------------------------------- /src/client/utils/api.js: -------------------------------------------------------------------------------- 1 | //iso fetch so we use fetch during SSR 2 | import 'isomorphic-fetch'; 3 | import config from 'config'; 4 | 5 | export default { 6 | 7 | fetchUsers: ()=>{ 8 | //client requests to api on same host dont require the host when fetching from browser 9 | //but do when fetching from server (i.e. when this is called during SSR) 10 | return fetch(config.clientConfig.apiHost + '/api/users') 11 | .then(res => { 12 | return res.json(); 13 | }); 14 | } 15 | 16 | }; -------------------------------------------------------------------------------- /src/client/utils/clientConfig.js: -------------------------------------------------------------------------------- 1 | //This module replaces 'config' in the client and exposes the clientConfig part of the server config 2 | //see package.json "browser" mappings 3 | 4 | //This module initialises itself at import time to reduce the risk of other client code 5 | //accessing config before it's ready 6 | const initData = window.__INIT_DATA_FROM_SERVER_RENDER__ || {clientConfig:{}}; 7 | console.log('config', initData.clientConfig); //eslint-disable-line no-console 8 | 9 | const config = { 10 | //note: structure follows that of config object on server side - except we only have 11 | //access 'clientConfig' part of the config here 12 | clientConfig: initData.clientConfig 13 | }; 14 | export default config; 15 | -------------------------------------------------------------------------------- /src/client/utils/polyfills.js: -------------------------------------------------------------------------------- 1 | //When polyfilling es6+ features you have 2 choices 2 | //1) Import all of babel-polyfill (easier but larger) 3 | // import 'babel-polyfill'; 4 | 5 | //2) Import the regenerator runtime + specific polyfills from core-js 6 | //see https://github.com/zloirock/core-js#commonjs 7 | import 'regenerator-runtime/runtime'; 8 | //add core-js polyfills as necessary. e.g. import 'core-js/es6/date' -------------------------------------------------------------------------------- /src/server/index.js: -------------------------------------------------------------------------------- 1 | //this is the entry point for the server app 2 | import express from 'express'; 3 | import favicon from 'serve-favicon'; 4 | import cookieParser from 'cookie-parser'; 5 | import compression from 'compression'; 6 | import path from 'path'; 7 | import config from 'config'; 8 | import morgan from 'morgan'; 9 | 10 | import renderPageRoute from './routes/renderPageRoute'; 11 | import {getUsers} from './routes/apiRoute'; 12 | import banner from './utils/banner'; 13 | import getLog from './utils/logger'; 14 | 15 | const app = express(); 16 | 17 | //logging 18 | const log = getLog(); 19 | if(config.logIncomingHttpRequests){ 20 | const incomingLog = getLog('INCOMING'); 21 | app.use(morgan('short', { stream: { write: message => incomingLog.info(message.trim()) }})); 22 | } 23 | 24 | //dont reveal whats running server 25 | app.disable('x-powered-by'); //TODO: helmet 26 | 27 | app.use(cookieParser()); 28 | app.use(compression()); // GZip compress responses 29 | 30 | //static files 31 | app.use(favicon(path.join(__dirname, '../static/favicon.ico'))); 32 | app.use('/', express.static(path.join(__dirname, '../static'))); 33 | //bundles are mapped like this so dev and prod builds both work (as dev uses src/static while prod uses dist/static) 34 | app.use('/bundles', express.static(path.join(__dirname, '../../dist/bundles'))); 35 | 36 | //API 37 | app.get('/api/users', getUsers); 38 | 39 | //all page rendering 40 | //Note: handles page routing and 404/500 error pages where necessary 41 | app.get('*', renderPageRoute); 42 | 43 | 44 | const server = app.listen(config.port, function () { 45 | banner(); 46 | log.info(`Server started on port ${server.address().port} in ${app.get('env')} mode`); 47 | //Its very useful to output init config to console at startup but we deliberately dont dump it to 48 | //log files incase it contains sensetive info (like keys for services etc) 49 | console.log(config);//eslint-disable-line no-console 50 | //'ready' is a hook used by the e2e (integration) tests (see node-while) 51 | server.emit('ready'); 52 | }); 53 | 54 | //export server instance so we can hook into it in e2e tests etc 55 | export default server; -------------------------------------------------------------------------------- /src/server/routes/apiRoute.js: -------------------------------------------------------------------------------- 1 | 2 | export function getUsers(req, res){ 3 | //Mock response - this is where you'd fetch data from database/external source etc 4 | res.json({ 5 | users:[ 6 | { name:'Bruce Banner' }, 7 | { name:'Tony Stark' }, 8 | { name:'Bruce Wayne' }, 9 | ] 10 | }); 11 | } -------------------------------------------------------------------------------- /src/server/routes/renderPageRoute.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { renderToString } from 'react-dom/server'; 3 | import StaticRouter from 'react-router-dom/StaticRouter'; 4 | import { matchRoutes, renderRoutes } from 'react-router-config'; 5 | import { Provider } from 'react-redux'; 6 | import { createStore, applyMiddleware } from 'redux'; 7 | import thunk from 'redux-thunk'; 8 | import config from 'config'; 9 | import {Helmet} from 'react-helmet'; 10 | import getLog from '../utils/logger'; 11 | 12 | import routes from '../../client/routes'; 13 | import reducers from '../../client/reducers'; 14 | 15 | 16 | const log = getLog(); 17 | 18 | export default async (req, res) => { 19 | try { 20 | let content, store, helmet, 21 | clientData = { 22 | clientConfig: config.clientConfig, 23 | initialState: {}, 24 | renderStats: {} 25 | }, 26 | dataFetchTime = 0, 27 | renderTime = 0; 28 | 29 | if(config.enableServerSideRender){ 30 | const dataFetchStart = Date.now(); 31 | store = await fetchDataAndInitReduxStore(req.url); 32 | clientData.initialState = store.getState(); 33 | dataFetchTime = Date.now() - dataFetchStart; 34 | 35 | const renderStart = Date.now(); 36 | content = renderReactAppContent(store, req.url); 37 | helmet = Helmet.renderStatic(); 38 | renderTime = Date.now() - renderStart; 39 | } 40 | 41 | clientData.stats = { 42 | 'ssr data fetch time':dataFetchTime, 43 | 'ssr render time':renderTime, 44 | 'total ssr time':dataFetchTime+renderTime 45 | }; 46 | 47 | res.setHeader('Content-Type', 'text/html'); 48 | res.send(renderHTML(content, clientData, helmet)); 49 | 50 | } catch (error) { 51 | log.error(error.stack); 52 | //TODO: set path relative to server folder so it works in prod (i.e. from dist/server) 53 | res.status(500).sendFile('src/server/views/500.html', {root: process.cwd() }); 54 | } 55 | }; 56 | 57 | async function fetchDataAndInitReduxStore(url){ 58 | const store = createStore(reducers, applyMiddleware(thunk)); 59 | 60 | const branch = matchRoutes(routes, url); 61 | //look for the 'fetchData' static method in the page to be rendered 62 | const promises = branch.map(({route}) => { 63 | let fetchData = route.component.fetchData; 64 | return fetchData instanceof Function ? fetchData(store) : Promise.resolve(null); 65 | }); 66 | await Promise.all(promises); 67 | 68 | return store; 69 | } 70 | 71 | function renderReactAppContent(store, url){ 72 | let context = {}; 73 | return renderToString( 74 | 75 | 76 | {renderRoutes(routes)} 77 | 78 | 79 | ); 80 | } 81 | 82 | function renderHTML(content, clientData, helmet){ 83 | return ` 84 | 85 | 86 | 87 | ${helmet.title.toString()} 88 | ${helmet.meta.toString()} 89 | ${helmet.link.toString()} 90 | 91 | 92 | 93 | 94 | 95 |
    ${content}
    96 | 97 | 100 | 101 | 102 | 103 | `; 104 | } -------------------------------------------------------------------------------- /src/server/routes/renderPageRoute.test.js: -------------------------------------------------------------------------------- 1 | import cheerio from 'cheerio'; 2 | import renderPageRoute from './renderPageRoute'; 3 | import {Helmet} from 'react-helmet'; 4 | 5 | describe('server/routes/renderPageRoute', function() { 6 | 7 | beforeAll(() => { 8 | //to stop Helmet error where it thinks server calls are made during browser render 9 | Helmet.canUseDOM = false; 10 | }); 11 | 12 | it('should render home page', async function() { 13 | const mockReq = {url: '/'}, mockRes = {send: jest.fn(), setHeader:jest.fn()}; 14 | 15 | await renderPageRoute(mockReq, mockRes); 16 | 17 | const $ = getResponseAsDom(mockRes); 18 | expect($('h2').text()).toEqual('Home Page'); 19 | }); 20 | 21 | it('should render error page', async function() { 22 | const mockReq = {url: '/asdadasd'}, mockRes = {send: jest.fn(), setHeader:jest.fn()}; 23 | 24 | await renderPageRoute(mockReq, mockRes); 25 | 26 | const $ = getResponseAsDom(mockRes); 27 | expect($('h2').text()).toEqual('Error: page not found'); 28 | }); 29 | 30 | //convert returned markup into a cheerio dom so we can easily inspect it 31 | function getResponseAsDom(res){ 32 | expect(res.send.mock.calls).toHaveLength(1); 33 | const renderedHTML = res.send.mock.calls[0][0]; 34 | return cheerio.load(renderedHTML); 35 | } 36 | 37 | }); -------------------------------------------------------------------------------- /src/server/utils/banner.js: -------------------------------------------------------------------------------- 1 | import colors from './terminalColors'; 2 | 3 | //NOTE: customise banner with e.g. http://patorjk.com/software/taag/#p=display&f=Calvin%20S&t=react-head-start 4 | export default () => { 5 | //NOTE: deliberately using console and not logger here so banner doesnt clutter server logs 6 | //eslint-disable-next-line no-console 7 | console.log(`${colors.cyan} 8 | ┬─┐┌─┐┌─┐┌─┐┌┬┐ ┬ ┬┌─┐┌─┐┌┬┐ ┌─┐┌┬┐┌─┐┬─┐┌┬┐ 9 | ├┬┘├┤ ├─┤│ │───├─┤├┤ ├─┤ ││───└─┐ │ ├─┤├┬┘ │ 10 | ┴└─└─┘┴ ┴└─┘ ┴ ┴ ┴└─┘┴ ┴─┴┘ └─┘ ┴ ┴ ┴┴└─ ┴ 11 | ${colors.reset}`); 12 | }; 13 | 14 | -------------------------------------------------------------------------------- /src/server/utils/logger.js: -------------------------------------------------------------------------------- 1 | /* 2 | Setup for winston logger. Default logger setup is to console and to file. Common 3 | config (like desired log level, or log file location) is already controlled by config 4 | files (see config/default.js) so they can be easily configured per environment. 5 | */ 6 | import path from 'path'; 7 | import winston, {config as winstonConfig} from 'winston'; 8 | import callerCallsite from 'caller-callsite'; 9 | import config from 'config'; 10 | import util from 'util'; 11 | 12 | import colors from './terminalColors'; 13 | 14 | 15 | const BYTES_PER_MEGABYTE = 1024*1024; 16 | 17 | winston.addColors({ 18 | 'debug':'grey', 19 | 'info':'white', 20 | 'warn':'yellow', 21 | 'error':'red' 22 | }); 23 | 24 | function formatLogMessage(options, context = ''){ 25 | const time = (new Date()).toISOString(); 26 | const logLevel = winstonConfig.colorize(options.level, options.level.toUpperCase().padEnd(5)); 27 | const meta = isEmptyObjectOrUndefined(options.meta) ? '' : util.format(options.meta); 28 | return `${colors.dim}${time}${colors.reset} ${logLevel} ${colors.dim}[${context}]${colors.reset} ${options.message} ${meta}`; 29 | } 30 | 31 | export default function getLog(context) { 32 | if(!context){ 33 | const filePath = callerCallsite().getFileName(); 34 | context = path.basename(filePath); 35 | } 36 | 37 | const logger = new winston.Logger({ 38 | transports: [ 39 | 40 | new (winston.transports.Console)({ 41 | level: config.logging.consoleLogLevel, 42 | handleExceptions: true, 43 | json: false, 44 | colorize: true, 45 | formatter: (options)=>{ 46 | return formatLogMessage(options, context); 47 | } 48 | }), 49 | 50 | new (winston.transports.File)({ 51 | level: config.logging.logFileLogLevel, 52 | filename: config.logging.logFilePath, 53 | handleExceptions: true, 54 | json: false, 55 | maxsize: config.logging.maxLogFileSizeInMB * BYTES_PER_MEGABYTE, 56 | maxFiles: config.logging.maxLogFileCount, 57 | colorize: false, 58 | formatter: (options)=>{ 59 | return formatLogMessage(options, context); 60 | } 61 | }) 62 | 63 | ], 64 | exitOnError: false 65 | }); 66 | 67 | return logger; 68 | } 69 | 70 | function isEmptyObjectOrUndefined(obj){ 71 | return !obj || (Object.keys(obj).length === 0 && obj.constructor === Object); 72 | } -------------------------------------------------------------------------------- /src/server/utils/outgoingRequestLogger.js: -------------------------------------------------------------------------------- 1 | /* 2 | Allows logging of all OUTGOING http requests from the node instance 3 | Can be really useful when debugging 4 | */ 5 | import config from 'config'; 6 | import globalLog from 'global-request-logger'; 7 | import getLog from './logger'; 8 | 9 | 10 | const log = getLog('OUTGOING'); 11 | 12 | function logNetworkData(request, response){ 13 | let url = request.protocol + '//' + request.hostname + request.path + (request.query ? '?'+ request.query : ''); 14 | if(response.statusCode >= 400){ 15 | log.error(request.method, url, response.statusCode); 16 | log.error('Request=', request); 17 | log.error('Response=', response); 18 | }else{ 19 | log.info(request.method, url, response.statusCode); 20 | } 21 | } 22 | 23 | if(config.logOutgoingHttpRequests){ 24 | globalLog.initialize(); 25 | globalLog.on('success', logNetworkData); 26 | globalLog.on('error', logNetworkData); 27 | } 28 | -------------------------------------------------------------------------------- /src/server/utils/terminalColors.js: -------------------------------------------------------------------------------- 1 | //ANSI codes for coloring terminal output 2 | //https://coderwall.com/p/yphywg/printing-colorful-text-in-terminal-when-run-node-js-script 3 | export default { 4 | //text colour 5 | black: '\x1b[30m', 6 | red: '\x1b[31m', 7 | green: '\x1b[32m', 8 | yellow: '\x1b[33m', 9 | blue: '\x1b[34m', 10 | magenta: '\x1b[35m', 11 | cyan: '\x1b[36m', 12 | white: '\x1b[37m', 13 | 14 | //background color 15 | bgBlack: '\x1b[40m', 16 | bgRed: '\x1b[41m', 17 | bgGreen: '\x1b[42m', 18 | bgYellow: '\x1b[43m', 19 | bgBlue: '\x1b[44m', 20 | bgMagenta: '\x1b[45m', 21 | bgCyan: '\x1b[46m', 22 | bgWhite: '\x1b[47m', 23 | 24 | //other styles 25 | reset: '\x1b[0m', 26 | bright: '\x1b[1m', 27 | dim: '\x1b[2m', 28 | underscore: '\x1b[4m', 29 | blink: '\x1b[5m', 30 | reverse: '\x1b[7m', 31 | hidden: '\x1b[8m', 32 | }; -------------------------------------------------------------------------------- /src/server/views/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Server Error 6 | 7 | 8 | 500: Server Error 9 | 10 | -------------------------------------------------------------------------------- /src/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gregtillbrook/react-head-start/c0d0a8838ab32e30bf70025d8d05bde477f0f3cb/src/static/favicon.ico -------------------------------------------------------------------------------- /src/static/robots.txt: -------------------------------------------------------------------------------- 1 | robotsUser-agent: * 2 | Disallow: /api/ 3 | -------------------------------------------------------------------------------- /test/e2e-test/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } -------------------------------------------------------------------------------- /test/e2e-test/plugins/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example plugins/index.js can be used to load plugins 3 | // 4 | // You can change the location of this file or turn off loading 5 | // the plugins file with the 'pluginsFile' configuration option. 6 | // 7 | // You can read more here: 8 | // https://on.cypress.io/plugins-guide 9 | // *********************************************************** 10 | 11 | // This function is called when a project is opened or re-opened (e.g. due to 12 | // the project's config changing) 13 | 14 | module.exports = (/*on, config*/) => { 15 | // `on` is used to hook into various events Cypress emits 16 | // `config` is the resolved Cypress config 17 | }; 18 | -------------------------------------------------------------------------------- /test/e2e-test/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add("login", (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This is will overwrite an existing command -- 25 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) 26 | -------------------------------------------------------------------------------- /test/e2e-test/support/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands'; 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /test/e2e-test/tests/simple_spec.js: -------------------------------------------------------------------------------- 1 | /* global Cypress */ 2 | //This is a extreme simple example of a cypress e2e test - it barely scratches the surface 3 | //See the docs at https://docs.cypress.io/guides/overview/why-cypress.html# 4 | // import config from 'config'; 5 | 6 | //'port' should match the values set in the produciton and test config files 7 | //This is because the 'npm run test:e2e' uses a different port so that e2e tests can be run in 8 | //parrallel with your dev server etc 9 | //TODO: figure out a nicer way to get the port from config 10 | const port = Cypress.env().env === 'production' ? '5000' : '5010'; 11 | 12 | describe('simple smoke test', function() { 13 | 14 | it('navigate to user page and confirm users have loaded', function() { 15 | 16 | //home page 17 | cy.visit(`http://localhost:${port}`); 18 | cy.contains('Users').click(); 19 | 20 | //users page 21 | cy.contains('Bruce Banner'); 22 | }); 23 | 24 | }); -------------------------------------------------------------------------------- /test/unit-test/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | rootDir: '../../', 3 | setupTestFrameworkScriptFile: './test/unit-test/jest.unit-test.init.js', 4 | testPathIgnorePatterns:[ 5 | '/config/', //skip the test.js config file 6 | '/node_modules/' 7 | ] 8 | }; -------------------------------------------------------------------------------- /test/unit-test/jest.polyfills.js: -------------------------------------------------------------------------------- 1 | global.requestAnimationFrame = (cb) => { 2 | setTimeout(cb, 0); 3 | }; -------------------------------------------------------------------------------- /test/unit-test/jest.unit-test.init.js: -------------------------------------------------------------------------------- 1 | //common test init run before ever unit test file 2 | 3 | import './jest.polyfills.js'; //polyfills must be imported before other imports to suppress jest warnings 4 | import { configure, shallow, render, mount } from 'enzyme'; 5 | import Adapter from 'enzyme-adapter-react-16'; 6 | import React from 'react'; 7 | // import toJson from 'enzyme-to-json'; 8 | 9 | //React Enzyme adapter 10 | configure({ adapter: new Adapter() }); 11 | 12 | //expose common functions used in tests 13 | //yes, globals are bad, but; 14 | // a) this is only in our tests, not production code 15 | // b) these would be imported in every test anyway making them implicit globals 16 | global.React = React; 17 | global.shallow = shallow; 18 | global.render = render; 19 | global.mount = mount; 20 | --------------------------------------------------------------------------------