├── .babelrc ├── .dockerignore ├── .eslintrc.js ├── .flowconfig ├── .gitignore ├── .modernizrrc ├── .stylelintrc ├── .tern-project ├── .travis.yml ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── README.md ├── __mocks__ └── fileMock.js ├── app ├── app.jsx ├── client │ └── loadExternalLibs.js ├── routes.jsx ├── server.js ├── server │ ├── application │ │ ├── apis │ │ │ ├── __tests__ │ │ │ │ └── todos.js │ │ │ ├── index.js │ │ │ └── todos.js │ │ ├── controllers │ │ │ ├── __tests__ │ │ │ │ └── application.js │ │ │ ├── application.js │ │ │ └── index.js │ │ └── templates │ │ │ ├── application │ │ │ └── index.marko │ │ │ ├── helpers │ │ │ ├── javascript-include-tag │ │ │ │ └── template.marko │ │ │ ├── prerender-data │ │ │ │ └── template.marko │ │ │ ├── render-javascript-assets-tag │ │ │ │ └── template.marko │ │ │ ├── stylesheet-link-tag │ │ │ │ └── template.marko │ │ │ ├── webpack-bundle-tag │ │ │ │ └── template.marko │ │ │ └── webpack-css-bundle-tag │ │ │ │ └── template.marko │ │ │ └── layouts │ │ │ ├── application.marko │ │ │ └── error.marko │ ├── domain │ │ ├── models │ │ │ └── Todo.js │ │ └── repositories │ │ │ └── TodoDAO.js │ └── infrastructure │ │ ├── app.js │ │ ├── contexts │ │ ├── index.js │ │ ├── prerender.jsx │ │ └── render.js │ │ ├── database.js │ │ ├── middlewares │ │ ├── error.js │ │ └── index.js │ │ └── settings.js └── share │ ├── __tests__ │ ├── __snapshots__ │ │ └── createStore.js.snap │ └── createStore.js │ ├── components │ ├── app │ │ ├── __tests__ │ │ │ ├── __snapshots__ │ │ │ │ └── index.jsx.snap │ │ │ └── index.jsx │ │ └── index.jsx │ ├── helmet │ │ ├── __tests__ │ │ │ ├── __snapshots__ │ │ │ │ └── index.jsx.snap │ │ │ ├── index.jsx │ │ │ └── logicBundle.js │ │ ├── index.jsx │ │ ├── logicBundle.js │ │ └── types.js │ ├── routing │ │ └── logicBundle.js │ ├── static-page │ │ ├── __tests__ │ │ │ ├── __snapshots__ │ │ │ │ └── index.jsx.snap │ │ │ └── index.jsx │ │ └── index.jsx │ └── todos │ │ ├── TodosAdd.jsx │ │ ├── TodosBody.jsx │ │ ├── TodosBodyStyle.scss │ │ ├── TodosFooter.jsx │ │ ├── TodosHeader.jsx │ │ ├── __tests__ │ │ ├── TodosAdd.jsx │ │ ├── TodosBody.jsx │ │ ├── TodosFooter.jsx │ │ ├── TodosHeader.jsx │ │ ├── __snapshots__ │ │ │ └── index.jsx.snap │ │ ├── index.jsx │ │ └── logicBundle.js │ │ ├── index.jsx │ │ ├── logicBundle.js │ │ └── types.js │ ├── createReducer.js │ ├── createStore.js │ └── helpers │ ├── __tests__ │ ├── __snapshots__ │ │ ├── createMockingComponent.jsx.snap │ │ └── createRedialHooks.jsx.snap │ ├── createMockingComponent.jsx │ ├── createRedialHooks.jsx │ ├── fetchData.js │ ├── globalizeSelectors.js │ └── injectReducers.js │ ├── createMockingComponent.jsx │ ├── createRedialHooks.js │ ├── fetch.js │ ├── fetchData.js │ ├── globalizeSelectors.js │ └── injectReducers.js ├── config ├── index.js ├── path-helper.js └── webpack │ ├── client │ ├── development.js │ └── production.js │ ├── default-config.js │ ├── server │ ├── development.js │ └── production.js │ └── universal-webpack-settings.js ├── docker-compose.yml ├── flow ├── css-modules.js.flow └── webpack-assets.js.flow ├── marko.json ├── nodemon.json ├── package.json ├── postcss.config.js ├── prod-server.js ├── public ├── 404.html ├── 422.html ├── 500.html ├── favicon.ico ├── manifest.json └── robots.txt ├── redux-structure.png ├── scripts ├── backend-build.sh ├── backend-watch.sh ├── build.sh ├── clean.js ├── compile-templates.sh ├── debug.sh ├── dev.sh ├── env │ ├── backend.sh │ ├── debug.sh │ ├── development.sh │ ├── frontend.sh │ ├── production.sh │ └── test ├── frontend-build.sh ├── frontend-watch.sh ├── preset-js.js ├── start.sh └── watch.sh ├── shim.js ├── test-setup.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["./scripts/preset-js", "react", "stage-0"], 3 | "plugins": [ 4 | "transform-export-extensions", 5 | "dynamic-import-node" 6 | ], 7 | "env": { 8 | "development": { 9 | "plugins": [ 10 | "react-hot-loader/babel" 11 | ] 12 | }, 13 | "production": { 14 | "plugins": [ 15 | "transform-react-constant-elements", 16 | "transform-react-inline-elements", 17 | "transform-react-remove-prop-types" 18 | ] 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .tern-port 3 | build/ 4 | node_modules/ 5 | public/assets/ 6 | public/sw.js 7 | tmp/ 8 | coverage 9 | *.marko.js 10 | docker-compose.yml 11 | test-setup.js 12 | shim.js 13 | redux-structure.png 14 | flow-typed/ 15 | __mocks__/ 16 | CHANGELOG.md 17 | Dockerfile 18 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ["airbnb", "prettier", "prettier/flowtype", "prettier/react"], 3 | parser: "babel-eslint", 4 | plugins: ["jest", "flowtype"], 5 | env: { 6 | "jest/globals": true, 7 | browser: true, 8 | node: true 9 | }, 10 | rules: { 11 | quotes: [2, "double"], 12 | "comma-dangle": 0, 13 | "no-param-reassign": 0, 14 | "global-require": 0, 15 | "no-underscore-dangle": 0, 16 | "arrow-parens": 0, 17 | "jsx-a11y/href-no-hash": "off", 18 | "import/no-extraneous-dependencies": 0, 19 | "import/no-named-as-default": 0, 20 | "import/prefer-default-export": 0, 21 | "react/require-extension": 0, 22 | "react/prop-types": 0, 23 | "flowtype/define-flow-type": 1, 24 | "flowtype/space-after-type-colon": [1, "always"], 25 | "flowtype/space-before-type-colon": [1, "never"], 26 | "flowtype/type-id-match": [1, "^([A-Z][a-z0-9]+)+Type$"], 27 | "flowtype/use-flow-type": 1 28 | }, 29 | settings: { 30 | "import/resolver": { 31 | webpack: { 32 | config: { 33 | resolve: { 34 | extensions: [".js", ".jsx"], 35 | modules: ["app", "node_modules"] 36 | } 37 | } 38 | } 39 | } 40 | } 41 | }; 42 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | .*/build/.* 3 | .*/node_modules/fbjs/.* 4 | .*/node_modules/nock/.* 5 | .*/node_modules/stylelint/.* 6 | .*/node_modules/babel-plugin-transform-react-remove-prop-types/.* 7 | 8 | [include] 9 | 10 | [libs] 11 | 12 | [options] 13 | module.name_mapper='.*\.\(css\|sass\|scss\|less\)$' -> '/flow/css-modules.js.flow' 14 | module.name_mapper='.*\.\(svg\|png\|jpg\|jpeg\|gif\)$' -> '/flow/webpack-assets.js.flow' 15 | module.name_mapper='^client\/\(.*\)$' -> '/app/client/\1' 16 | module.name_mapper='^server\/\(.*\)$' -> '/app/server/\1' 17 | module.name_mapper='^share\/\(.*\)$' -> '/app/share/\1' 18 | 19 | esproposal.class_static_fields=enable 20 | esproposal.class_instance_fields=enable 21 | suppress_comment= \\(.\\|\n\\)*\\$FlowFixMe -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | .tern-port 4 | build/ 5 | node_modules/ 6 | public/assets/ 7 | public/sw.js 8 | public/sw.js.gz 9 | __generated__/ 10 | tmp/ 11 | coverage 12 | *.marko.js 13 | 14 | # IntelliJ project files 15 | *.iml 16 | out 17 | gen 18 | 19 | # Node template 20 | # Logs 21 | logs 22 | *.log 23 | npm-debug.log* 24 | 25 | # Runtime data 26 | pids 27 | *.pid 28 | *.seed 29 | 30 | # Directory for instrumented libs generated by jscoverage/JSCover 31 | lib-cov 32 | 33 | # Coverage directory used by tools like istanbul 34 | 35 | # nyc test coverage 36 | .nyc_output 37 | 38 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 39 | .grunt 40 | 41 | # node-waf configuration 42 | .lock-wscript 43 | 44 | # Compiled binary addons (http://nodejs.org/api/addons.html) 45 | build/Release 46 | 47 | # Dependency directories 48 | node_modules 49 | jspm_packages 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional REPL history 55 | .node_repl_history 56 | -------------------------------------------------------------------------------- /.modernizrrc: -------------------------------------------------------------------------------- 1 | { 2 | "minify": true, 3 | "options": [ 4 | "setClasses" 5 | ], 6 | "feature-detects": [] 7 | } -------------------------------------------------------------------------------- /.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "stylelint-config-standard", 4 | "stylelint-config-css-modules" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.tern-project: -------------------------------------------------------------------------------- 1 | { 2 | "libs": [ "ecma6"], 3 | "plugins": { 4 | "node": {}, 5 | "complete_strings": {} 6 | } 7 | } -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 8 4 | before_install: 5 | - npm install -g codecov 6 | script: 7 | - npm test -- --coverage 8 | - codecov 9 | after_success: 10 | - bash <(curl -s https://codecov.io/bash) 11 | cache: yarn 12 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 4.3.0 2 | - Fix bugs 3 | - Update run scripts 4 | 5 | ## 4.2.0 6 | - Update dependencies 7 | - Migrate tests to Jest 8 | 9 | ## 4.1.0 10 | - Add Babili and Prepack 11 | - Restructure babel-preset for current node env 12 | 13 | ## 4.0.2 14 | - Refactor the whole application with DDD 15 | - Update eslint style 16 | - Replace react-async-component with react-loadable 17 | 18 | ## 3.3.0 19 | - Add preload for next route 20 | - Update redial webhook 21 | 22 | ## 3.1.0 23 | - Replace gulp with npm scripts 24 | - Replace react-proxy-loader with react-async-component 25 | - Update dependencies 26 | 27 | ## 3.0.1 28 | - Update eslint with v3 29 | 30 | ## 3.0.0 31 | - Add BundleAnalyzerPlugin plugin 32 | - Optimize common chunks 33 | 34 | ## 2.18.3 35 | - Remove binding regenerator-runtime in global 36 | - Update dependencies 37 | 38 | ## 2.15.0 39 | - Update react-router 40 | - Update clientFetchData to manually call the routeOnChange handler 41 | 42 | ## 2.14.4 43 | - Use [yarn](https://github.com/yarnpkg/yarn) 44 | 45 | ## 2.14.2 46 | - Update Dockerfile and pm2 script 47 | 48 | ## 2.14.0 49 | - Remove why-did-you-update 50 | - Add react-hot-loader-3 - Known problem: [react-router/issues/2704](https://github.com/ReactTraining/react-router/issues/2704) 51 | - Update webpack-isomorphic-tools new api 52 | - Drop support for node 4 53 | 54 | ## 2.13.0 55 | - Update koa-csrf 56 | - Remove flow libs 57 | 58 | ## 2.12.2 59 | - Add CHANGELOG.md 60 | 61 | ## 2.12.1 62 | - Add .node-inspectorrc 63 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:8 as builder 2 | 3 | ARG SECRET_KEY=secret 4 | 5 | WORKDIR /opt/application 6 | 7 | COPY package.json yarn.lock ./ 8 | RUN yarn install 9 | COPY . . 10 | RUN npm run build 11 | 12 | 13 | FROM node:8 14 | 15 | ARG SECRET_KEY=secret 16 | 17 | WORKDIR /opt/application 18 | 19 | RUN wget -O /usr/local/bin/dumb-init https://github.com/Yelp/dumb-init/releases/download/v1.2.1/dumb-init_1.2.1_amd64 20 | RUN chmod +x /usr/local/bin/dumb-init 21 | COPY package.json yarn.lock ./ 22 | RUN yarn install --production 23 | COPY . . 24 | COPY --from=builder /opt/application/public/assets ./public/assets 25 | COPY --from=builder /opt/application/public/sw.js ./public/sw.js 26 | COPY --from=builder /opt/application/build ./build 27 | 28 | ENV NODE_ENV=production \ 29 | SECRET_KEY=${SECRET_KEY} 30 | 31 | CMD ["dumb-init", "npm", "start"] 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Dan Abramov 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React and Koa boilerplate (deprecated. New project available at https://github.com/hung-phan/micro-nextjs) 2 | [![build status](https://secure.travis-ci.org/hung-phan/koa-react-isomorphic.svg)](http://travis-ci.org/hung-phan/koa-react-isomorphic/) 3 | [![codecov](https://codecov.io/gh/hung-phan/koa-react-isomorphic/branch/master/graph/badge.svg)](https://codecov.io/gh/hung-phan/koa-react-isomorphic) 4 | [![Dependency Status](https://david-dm.org/hung-phan/koa-react-isomorphic.svg)](https://david-dm.org/hung-phan/koa-react-isomorphic) 5 | [![devDependency Status](https://david-dm.org/hung-phan/koa-react-isomorphic/dev-status.svg)](https://david-dm.org/hung-phan/koa-react-isomorphic#info=devDependencies) 6 | 7 | The idea of this repository is to implement all new concepts and libraries which work great for React.js. 8 | 9 | * [Koa.js](https://github.com/koajs/koa) 10 | * [Webpack](https://github.com/webpack/webpack) 11 | * [Babel](https://babeljs.io/) 12 | * [Flowtype](http://flowtype.org/) 13 | * [Marko](http://markojs.com/) 14 | * [Bootstrap](http://getbootstrap.com/css/) and [FontAwesome](https://fortawesome.github.io/Font-Awesome/) 15 | * [Redux](https://github.com/rackt/redux) 16 | * [Relay](https://facebook.github.io/relay/) 17 | * [Immutablejs](https://facebook.github.io/immutable-js/) 18 | * [ServiceWorker and AppCache](http://www.html5rocks.com/en/tutorials/service-worker/introduction/) 19 | * [PostCSS](https://github.com/postcss/postcss), [CSSNext](http://cssnext.io/), [CSSNano](http://cssnano.co/) 20 | * [Jest](https://facebook.github.io/jest), [Nock](https://github.com/pgte/nock) 21 | 22 | ## Requirement 23 | - Install [redux-devtools-extension](https://github.com/zalmoxisus/redux-devtools-extension) to have better experience when developing. 24 | - Install [yarn](https://github.com/yarnpkg/yarn) 25 | 26 | ## Features 27 | * Immutablejs: Available on [features/immutablejs](https://github.com/hung-phan/koa-react-isomorphic/tree/features/immutable-js) 28 | * Relay: Available on [features/relay](https://github.com/hung-phan/koa-react-isomorphic/tree/features/relay) 29 | 30 | ## Explanation 31 | 32 | ### Templates 33 | Templates are written in marko.js with predefined template helpers. To see its usage, please refer to `layout/application.marko`. 34 | 35 | ### Server side rendering 36 | I use [webpack-isomorphic-tools](https://github.com/halt-hammerzeit/webpack-isomorphic-tools) to support loading assets in 37 | the server side. You can see the configuration file under `config` folder. 38 | 39 | #### Fetch data 40 | - For redux, data is fetched using redial hooks on the server side. 41 | 42 | Takes a look at `templates/todos`, I will have sth like: 43 | 44 | ```javascript 45 | createRedialEnhancer({ 46 | [FETCH_DATA_HOOK]: ({ store }) => store.dispatch(fetchTodos()), 47 | [UPDATE_HEADER_HOOK]: ({ store }) => store.dispatch(updateLink([ 48 | // window.javascriptAssets will be injected to do preload link for optimizing route 49 | { rel: 'preload', href: window.javascriptAssets['static-page'], as: 'script' }, 50 | ])), 51 | }) 52 | ``` 53 | 54 | - For relay, data is fetched using isomorphic-relay-router on the server side. 55 | 56 | ### Default require for node 57 | The default `require` node statement has been modified by webpack-isomorphic-tools, so I remap it with `nodeRequire` 58 | under `global`. For example, you can use it like as below: 59 | 60 | ```javascript 61 | const { ROOT, PUBLIC } = global.nodeRequire('./config/path-helper'); 62 | ``` 63 | 64 | Note: `nodeRequire` will resolve the path from project root directory. 65 | 66 | 67 | #### Preload assets via redial 68 | To be able to support for asynchronous chunks loading using ``, I returned the javascript 69 | assets map for all the routes to the client via `window.javascriptAssets`. 70 | 71 | You can use this to inject assets for the next page to improve performance. This is what I am trying to achieve 72 | [preload-webpack-plugin](https://github.com/GoogleChrome/preload-webpack-plugin). 73 | 74 | This will map the hook with the current component and trigger it (Note: This will only be applied to root component). 75 | 76 | ### Async react components 77 | [react-loadable](https://github.com/thejameskyle/react-loadable) 78 | 79 | ### Idea to structure redux application 80 | For now, the best way is to place all logic in the same place with components to make it less painful when scaling the application. 81 | Current structure is the combination of ideas from [organizing-redux](http://jaysoo.ca/2016/02/28/organizing-redux-application/) and 82 | [ducks-modular-redux](https://github.com/erikras/ducks-modular-redux). Briefly, I will have our reducer, action-types, and actions 83 | in the same place with featured components. 84 | 85 | ![alt text](https://raw.githubusercontent.com/hung-phan/koa-react-isomorphic/master/redux-structure.png "redux structure") 86 | 87 | #### Localize selectors 88 | Some great ideas from [scoped-selectors-for-redux-modules](http://www.datchley.name/scoped-selectors-for-redux-modules/). 89 | You can create a localized scope for selector using `globalizeSelectors`. 90 | 91 | 92 | ```javascript 93 | export const mountPoint = 'todos'; 94 | 95 | export const selectors = globalizeSelectors({ 96 | getTodos: identity, // it will also work with reselect library 97 | }, mountPoint); 98 | ``` 99 | 100 | Then in main reducer, you can have sth like this, which helps reduce the coupling with React view 101 | 102 | ```javascript 103 | /* @flow */ 104 | import { combineReducers } from 'redux'; 105 | import todosReducer, { mountPoint as todosMountPoint } from './components/todos/logicBundle'; 106 | import routingReducer, { mountPoint as routingMountPoint } from './components/routing/logicBundle'; 107 | import helmetReducer, { mountPoint as helmetMountPoint } from './components/helmet/logicBundle'; 108 | 109 | export default combineReducers({ 110 | [todosMountPoint]: todosReducer, 111 | [routingMountPoint]: routingReducer, 112 | [helmetMountPoint]: helmetReducer, 113 | }); 114 | ``` 115 | 116 | Sample for logicBundle: 117 | 118 | ```javascript 119 | export const mountPoint = "todos"; 120 | 121 | export const selectors = globalizeSelectors( 122 | { 123 | getTodos: identity 124 | }, 125 | mountPoint 126 | ); 127 | 128 | export const ADD_TODO = "todos/ADD_TODO"; 129 | export const REMOVE_TODO = "todos/REMOVE_TODO"; 130 | export const COMPLETE_TODO = "todos/COMPLETE_TODO"; 131 | export const SET_TODOS = "todos/SET_TODOS"; 132 | 133 | export const addTodo: AddTodoActionType = createAction(ADD_TODO); 134 | export const removeTodo: RemoveTodoActionType = createAction(REMOVE_TODO); 135 | export const completeTodo: CompleteTodoActionType = createAction(COMPLETE_TODO); 136 | export const setTodos: SetTodosActionType = createAction(SET_TODOS); 137 | export const fetchTodos = () => 138 | (dispatch: Function): Promise => 139 | fetch(getUrl("/api/v1/todos")) 140 | .then(res => res.json()) 141 | .then((res: TodoType[]) => dispatch(setTodos(res))); 142 | 143 | export default handleActions( 144 | { 145 | [ADD_TODO]: (state, { payload: text }) => update(state, { 146 | $push: [{ text, complete: false }] 147 | }), 148 | [REMOVE_TODO]: (state, { payload: index }) => update(state, { 149 | $splice: [[index, 1]] 150 | }), 151 | [COMPLETE_TODO]: (state, { payload: index }) => update(state, { 152 | $splice: [ 153 | [index, 1], 154 | [index, 0, { ...state[index], complete: !state[index].complete }] 155 | ] 156 | }), 157 | [SET_TODOS]: (state, { payload: todos }) => todos 158 | }, 159 | [] 160 | ); 161 | ``` 162 | 163 | ## Upcoming 164 | * Phusion Passenger server with Nginx 165 | 166 | ## Development 167 | 168 | ```bash 169 | $ git clone git@github.com:hung-phan/koa-react-isomorphic.git 170 | $ cd koa-react-isomorphic 171 | $ yarn install 172 | ``` 173 | 174 | ### Hot reload 175 | 176 | ```bash 177 | $ yarn run watch 178 | $ yarn run dev 179 | ``` 180 | 181 | ### With server rendering - encourage for testing only 182 | 183 | ```bash 184 | $ SERVER_RENDERING=true yarn run watch 185 | $ yarn run dev 186 | ``` 187 | 188 | ### Enable flowtype in development 189 | ```bash 190 | $ yarn run flow-watch 191 | $ yarn run flow-stop # to terminate the server 192 | ``` 193 | 194 | You need to add annotation to the file to enable flowtype (`// @flow`) 195 | 196 | 197 | ## Test 198 | 199 | ```bash 200 | $ yarn test 201 | ``` 202 | 203 | ## Debug 204 | ```bash 205 | $ yarn run watch 206 | $ yarn run debug 207 | ``` 208 | 209 | ## Production 210 | 211 | ### Start production server 212 | 213 | ```bash 214 | $ yarn run build 215 | $ SECRET_KEY=your_env_key yarn start 216 | ``` 217 | 218 | ### Docker container 219 | 220 | ```bash 221 | $ docker-compose build 222 | $ docker-compose up 223 | ``` 224 | 225 | Access `http://localhost:3000` to see the application 226 | 227 | ## QA 228 | 229 | Feel free to open an issue on the repo. 230 | -------------------------------------------------------------------------------- /__mocks__/fileMock.js: -------------------------------------------------------------------------------- 1 | module.exports = 'test-file-stub'; 2 | -------------------------------------------------------------------------------- /app/app.jsx: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | /* global process */ 3 | import React from "react"; 4 | import ReactDOM from "react-dom"; 5 | import { match, Router } from "react-router"; 6 | import App from "./share/components/app"; 7 | import createStore from "./share/createStore"; 8 | import { clientFetchData } from "./share/helpers/fetchData"; 9 | import { getHistory } from "./routes"; 10 | import "./client/loadExternalLibs"; 11 | 12 | const appDOM = document.getElementById("app"); 13 | 14 | if (!appDOM) { 15 | throw new Error("Cannot initialise app"); 16 | } 17 | 18 | const store = createStore(window.prerenderData); 19 | const history = getHistory(store); 20 | let { getRoutes } = require("./routes"); 21 | 22 | function initialize() { 23 | const routes = getRoutes(history, store); 24 | 25 | clientFetchData(history, routes, store); 26 | 27 | if (process.env.SERVER_RENDERING) { 28 | match({ history, routes }, (error, redirectLocation, renderProps) => { 29 | // $FlowFixMe 30 | ReactDOM.hydrate( 31 | } />, 32 | appDOM 33 | ); 34 | }); 35 | } else { 36 | ReactDOM.render(, appDOM); 37 | } 38 | } 39 | 40 | // $FlowFixMe 41 | if (process.env.NODE_ENV === "development" && module.hot) { 42 | module.hot.accept("./routes", () => { 43 | ({ getRoutes } = require("./routes")); 44 | initialize(); 45 | }); 46 | } 47 | 48 | initialize(); 49 | 50 | if (process.env.NODE_ENV === "production") { 51 | const runtime = require("offline-plugin/runtime"); 52 | 53 | runtime.install({ 54 | onUpdating: () => { 55 | console.log("SW Event:", "onUpdating"); // eslint-disable-line 56 | }, 57 | onUpdateReady: () => { 58 | console.log("SW Event:", "onUpdateReady"); // eslint-disable-line 59 | // Tells to new SW to take control immediately 60 | runtime.applyUpdate(); 61 | }, 62 | onUpdated: () => { 63 | console.log("SW Event:", "onUpdated"); // eslint-disable-line 64 | // Reload the webpage to load into the new version 65 | window.location.reload(); 66 | }, 67 | 68 | onUpdateFailed: () => { 69 | console.log("SW Event:", "onUpdateFailed"); // eslint-disable-line 70 | } 71 | }); 72 | } 73 | -------------------------------------------------------------------------------- /app/client/loadExternalLibs.js: -------------------------------------------------------------------------------- 1 | import "bootstrap/scss/bootstrap.scss"; 2 | import "font-awesome/less/font-awesome.less"; 3 | -------------------------------------------------------------------------------- /app/routes.jsx: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import React from "react"; 3 | import { syncHistoryWithStore } from "react-router-redux"; 4 | import { 5 | browserHistory, 6 | createMemoryHistory, 7 | Route, 8 | Router 9 | } from "react-router"; 10 | import injectReducers from "./share/helpers/injectReducers"; 11 | 12 | const getClientHistory = (store: Object): Object => 13 | syncHistoryWithStore(browserHistory, store); 14 | 15 | const getServerHistory = (store: Object, url: string): Object => 16 | syncHistoryWithStore(createMemoryHistory(url), store); 17 | 18 | export const getHistory = (...args: any[]) => 19 | process.env.RUNTIME_ENV === "client" 20 | ? getClientHistory(...args) 21 | : getServerHistory(...args); 22 | 23 | export const getRoutes = ( 24 | history: Object, 25 | store: Object, 26 | options: Object = {} 27 | ): Object => ( 28 | 29 | { 32 | const [ 33 | { default: TodosComponent }, 34 | { mountPoint: todosMountPoint, default: todosReducer } 35 | ] = await Promise.all([ 36 | import(/* webpackChunkName: "todos-page", webpackPreload: true */ "./share/components/todos"), 37 | import(/* webpackChunkName: "todos-page", webpackPreload: true */ "./share/components/todos/logicBundle") 38 | ]); 39 | 40 | injectReducers(store, { [todosMountPoint]: todosReducer }); 41 | cb(null, TodosComponent); 42 | }} 43 | /> 44 | { 47 | const { 48 | default: StaticPageComponent 49 | } = await import(/* webpackChunkName: "static-page", webpackPrefetch: true */ "./share/components/static-page"); 50 | 51 | cb(null, StaticPageComponent); 52 | }} 53 | /> 54 | 55 | ); 56 | -------------------------------------------------------------------------------- /app/server.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | /* global process */ 3 | import http from "http"; 4 | import app from "./server/infrastructure/app"; 5 | 6 | const PORT = process.env.PORT || 3000; 7 | 8 | export default () => { 9 | http.createServer(app.callback()).listen(PORT, error => { 10 | if (error) { 11 | throw error; 12 | } 13 | 14 | console.info(`Server listening on port ${PORT}`); // eslint-disable-line no-console 15 | }); 16 | }; 17 | -------------------------------------------------------------------------------- /app/server/application/apis/__tests__/todos.js: -------------------------------------------------------------------------------- 1 | import supertest from "supertest"; 2 | import app from "../../../infrastructure/app"; 3 | 4 | describe("API: v1/todos", () => { 5 | it("should return json todos when calling GET request", () => { 6 | return supertest(app.listen()) 7 | .get("/api/v1/todos") 8 | .set("Accept", "application/json") 9 | .expect("Content-Type", /json/) 10 | .expect(200); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /app/server/application/apis/index.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import todos from "./todos"; 3 | 4 | export default (router: Object) => { 5 | todos(router); 6 | }; 7 | -------------------------------------------------------------------------------- /app/server/application/apis/todos.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import { all } from "../../domain/repositories/TodoDAO"; 3 | 4 | export default (router: Object) => { 5 | router.get("/api/v1/todos", async (ctx: Object) => { 6 | ctx.body = await all(); 7 | }); 8 | }; 9 | -------------------------------------------------------------------------------- /app/server/application/controllers/__tests__/application.js: -------------------------------------------------------------------------------- 1 | import supertest from "supertest"; 2 | import app from "../../../infrastructure/app"; 3 | 4 | describe("Controller: application", () => { 5 | it("should render single page", async () => { 6 | const result = await supertest(app.listen()) 7 | .get("/") 8 | .set("Accept", "text/html") 9 | .expect(200); 10 | 11 | expect(result.text).toBeDefined(); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /app/server/application/controllers/application.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | export default (router: Object) => { 3 | router.get("*", async (ctx: Object) => { 4 | ctx.body = await ctx.prerender("application/index.marko"); 5 | }); 6 | }; 7 | -------------------------------------------------------------------------------- /app/server/application/controllers/index.js: -------------------------------------------------------------------------------- 1 | import application from "./application"; 2 | 3 | export default (router: Object) => { 4 | application(router); 5 | }; 6 | -------------------------------------------------------------------------------- /app/server/application/templates/application/index.marko: -------------------------------------------------------------------------------- 1 | 2 | <@mainContent> 3 |
$!{input.prerenderComponent}
4 | 5 | 6 | -------------------------------------------------------------------------------- /app/server/application/templates/helpers/javascript-include-tag/template.marko: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/server/application/templates/helpers/prerender-data/template.marko: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | -------------------------------------------------------------------------------- /app/server/application/templates/helpers/render-javascript-assets-tag/template.marko: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /app/server/application/templates/helpers/stylesheet-link-tag/template.marko: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/server/application/templates/helpers/webpack-bundle-tag/template.marko: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/server/application/templates/helpers/webpack-css-bundle-tag/template.marko: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/server/application/templates/layouts/application.marko: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Application 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /app/server/application/templates/layouts/error.marko: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Error - ${input.status} 5 | 6 | 7 | 32 | 33 | 34 |
35 |

Error

36 |

Looks like something broke!

37 |

Settings:

38 |
39 |                 $!{JSON.stringify(input.settings, null, 2)}
40 |             
41 |

Context:

42 |
43 |                 $!{JSON.stringify(input.ctx, null, 2)}
44 |             
45 |
46 | 47 | 48 | -------------------------------------------------------------------------------- /app/server/domain/models/Todo.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | export default class Todo { 3 | id: string; 4 | text: string; 5 | complete: boolean; 6 | 7 | constructor({ 8 | id, 9 | text, 10 | complete 11 | }: { 12 | id: string, 13 | text: string, 14 | complete: boolean 15 | }) { 16 | this.id = id; 17 | this.text = text; 18 | this.complete = complete; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /app/server/domain/repositories/TodoDAO.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import DataLoader from "dataloader"; 3 | import database from "../../infrastructure/database"; 4 | import Todo from "../models/Todo"; 5 | 6 | type RawTodoType = { 7 | id: string, 8 | text: string, 9 | complete: boolean 10 | }; 11 | 12 | const dataloader = new DataLoader( 13 | // $FlowFixMe 14 | (ids: string[]): Promise> => { 15 | const set: Set = new Set(ids); 16 | 17 | return Promise.resolve( 18 | database.todos 19 | .filter(({ id }: RawTodoType) => set.has(id)) 20 | .map(opts => new Todo(opts)) 21 | ); 22 | } 23 | ); 24 | 25 | export const count = (): number => database.todos.length; 26 | 27 | export const all = (): Promise => 28 | Promise.resolve(database.todos.map(opts => new Todo(opts))); 29 | 30 | export const getById = (id: string): Promise => dataloader.load(id); 31 | 32 | export const getByIds = (ids: string[]): Promise => 33 | dataloader.loadMany(ids); 34 | -------------------------------------------------------------------------------- /app/server/infrastructure/app.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import Koa from "koa"; 3 | import createContext from "./contexts"; 4 | import { 5 | apiLayer, 6 | assetsLayer, 7 | errorLayer, 8 | initialLayer, 9 | loggingLayer, 10 | renderLayer, 11 | securityLayer 12 | } from "./middlewares"; 13 | import apis from "../application/apis"; 14 | import controllers from "../application/controllers"; 15 | 16 | const app = new Koa(); 17 | 18 | createContext(app); 19 | loggingLayer(app); 20 | errorLayer(app); 21 | assetsLayer(app); 22 | securityLayer(app); 23 | initialLayer(app); 24 | apiLayer(app, apis); 25 | renderLayer(app, controllers); 26 | 27 | export default app; 28 | -------------------------------------------------------------------------------- /app/server/infrastructure/contexts/index.js: -------------------------------------------------------------------------------- 1 | import render from "./render"; 2 | import prerender from "./prerender"; 3 | 4 | export default function wrapContext(app: Object) { 5 | Object.assign(app.context, { render, prerender }); 6 | } 7 | -------------------------------------------------------------------------------- /app/server/infrastructure/contexts/prerender.jsx: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | /* global process */ 3 | import React from "react"; 4 | import Helmet from "react-helmet"; 5 | import { renderToString } from "react-dom/server"; 6 | import { match, RouterContext } from "react-router"; 7 | import App from "../../../share/components/app/index"; 8 | import createStore from "../../../share/createStore"; 9 | import { serverFetchData } from "../../../share/helpers/fetchData"; 10 | 11 | let routesModule = require("../../../routes"); 12 | 13 | // $FlowFixMe 14 | if (process.env.NODE_ENV === "development" && module.hot) { 15 | module.hot.accept("../../../routes", () => { 16 | routesModule = require("../../../routes"); 17 | }); 18 | } 19 | 20 | export default function( 21 | template: string, 22 | parameters: Object = {}, 23 | initialState: Object = {} 24 | ): Promise { 25 | if (!process.env.SERVER_RENDERING) { 26 | return this.render(template, parameters); 27 | } 28 | 29 | return new Promise((resolve, reject) => { 30 | const store = createStore(initialState); 31 | const history = routesModule.getHistory(store, this.req.url); 32 | const routes = routesModule.getRoutes(history, store); 33 | 34 | match({ routes, history }, (error, redirectLocation, renderProps) => { 35 | if (error) { 36 | resolve(this.throw(500, error.message)); 37 | } else if (redirectLocation) { 38 | resolve( 39 | this.redirect(redirectLocation.pathname + redirectLocation.search) 40 | ); 41 | } else if (renderProps) { 42 | serverFetchData(renderProps, store).then(() => { 43 | try { 44 | const currentRoutes = ; 45 | const prerenderComponent = renderToString( 46 | 47 | ); 48 | const prerenderData = store.getState(); 49 | 50 | // prevent memory leak 51 | Helmet.rewind(); 52 | 53 | resolve( 54 | this.render(template, { 55 | ...parameters, 56 | prerenderComponent, 57 | prerenderData 58 | }) 59 | ); 60 | } catch (e) { 61 | reject(e); 62 | } 63 | }); 64 | } else { 65 | resolve(this.throw(404)); 66 | } 67 | }); 68 | }); 69 | } 70 | -------------------------------------------------------------------------------- /app/server/infrastructure/contexts/render.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | /* global process */ 3 | import path from "path"; 4 | import marko from "marko"; 5 | import settings from "../settings"; 6 | 7 | export default function( 8 | template: string, 9 | parameters: Object = {} 10 | ): Promise { 11 | this.type = "text/html"; 12 | 13 | const templatePath = path.join( 14 | settings.path.ROOT, 15 | `${settings.path.TEMPLATES_DIR}/${template}` 16 | ); 17 | const currentTemplate = 18 | process.env.NODE_ENV === "production" 19 | ? global.nodeRequire(`${templatePath}.js`) 20 | : marko.load(templatePath); 21 | 22 | return currentTemplate.stream({ 23 | ...settings, 24 | ...parameters, 25 | csrf: this.csrf 26 | }); 27 | } 28 | -------------------------------------------------------------------------------- /app/server/infrastructure/database.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import _ from "lodash"; 3 | import faker from "faker"; 4 | 5 | export default { 6 | todos: _.range(10).map(() => ({ 7 | id: faker.random.uuid(), 8 | text: faker.lorem.sentence(), 9 | complete: _.sample([true, false]) 10 | })) 11 | }; 12 | -------------------------------------------------------------------------------- /app/server/infrastructure/middlewares/error.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | /* global process */ 3 | import settings from "../settings"; 4 | 5 | export default async (ctx: Object, next: Function) => { 6 | try { 7 | await next(); 8 | 9 | if (ctx.response.status === 404 && !ctx.response.body) { 10 | ctx.throw(404); 11 | } 12 | } catch (err) { 13 | ctx.status = err.status || 500; 14 | 15 | // application 16 | ctx.app.emit("error", err, ctx); 17 | 18 | // accepted types 19 | switch (ctx.accepts("html", "text", "json")) { 20 | case "text": 21 | ctx.type = "text/plain"; 22 | if (process.env.NODE_ENV === "development" || err.expose) { 23 | ctx.body = err.message; 24 | } else { 25 | throw err; 26 | } 27 | break; 28 | 29 | case "json": 30 | ctx.type = "application/json"; 31 | if (process.env.NODE_ENV === "development" || err.expose) { 32 | ctx.body = { 33 | error: err.message 34 | }; 35 | } else { 36 | ctx.body = { 37 | error: ctx.status 38 | }; 39 | } 40 | break; 41 | 42 | case "html": 43 | ctx.type = "text/html"; 44 | if (process.env.NODE_ENV === "development" || process.env.DEBUG) { 45 | ctx.body = await ctx.render("layouts/error.marko", { settings, ctx }); 46 | } else if ([404, 422].includes(ctx.status)) { 47 | ctx.redirect(`/${ctx.status}.html`); 48 | } else { 49 | ctx.redirect("/500.html"); 50 | } 51 | break; 52 | default: 53 | throw err; 54 | } 55 | } 56 | }; 57 | -------------------------------------------------------------------------------- /app/server/infrastructure/middlewares/index.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | /* global process */ 3 | import cors from "koa-cors"; 4 | import logger from "koa-logger"; 5 | import bodyParser from "koa-bodyparser"; 6 | import htmlMinifier from "koa-html-minifier2"; 7 | import router from "koa-router"; 8 | import conditionalGet from "koa-conditional-get"; 9 | import etag from "koa-etag"; 10 | import CSRF from "koa-csrf"; 11 | import convert from "koa-convert"; 12 | import session from "koa-session"; 13 | import compress from "koa-compress"; 14 | import helmet from "koa-helmet"; 15 | import settings from "../settings"; 16 | import error from "./error"; 17 | 18 | export const loggingLayer = (app: Object) => { 19 | if (process.env.NODE_ENV === "development") { 20 | app.use(logger()); // https://github.com/koajs/logger 21 | } 22 | }; 23 | 24 | export const initialLayer = (app: Object) => 25 | app 26 | .use(bodyParser()) 27 | .use(conditionalGet()) 28 | .use(etag()); // https://github.com/koajs/bodyparser // https://github.com/koajs/conditional-get // https://github.com/koajs/etag 29 | 30 | export const apiLayer = (app: Object, apiRoutes: Function) => { 31 | const newRouter = router(); 32 | 33 | newRouter.use(convert(cors())); // https://github.com/koajs/cors 34 | 35 | apiRoutes(newRouter); 36 | 37 | app.use(newRouter.routes()).use(newRouter.allowedMethods()); 38 | 39 | return newRouter; 40 | }; 41 | 42 | export const assetsLayer = (app: Object) => { 43 | if (!process.env.SERVER_STATIC_ASSETS) { 44 | const staticAssets = require("koa-static"); 45 | 46 | app.use( 47 | staticAssets(settings.path.PUBLIC, { gzip: true, maxage: 31536000 }) 48 | ); // https://github.com/koajs/static 49 | } 50 | }; 51 | 52 | export const securityLayer = (app: Object) => { 53 | app.keys = [process.env.SECRET_KEY]; 54 | 55 | const csrf = new CSRF(); 56 | 57 | app 58 | .use(session({ maxAge: 86400000 }, app)) // https://github.com/koajs/session 59 | .use((ctx, next) => { 60 | // don't check csrf for request coming from the server 61 | if (ctx.get("x-app-secret") === process.env.SECRET_KEY) { 62 | return next(); 63 | } 64 | 65 | return csrf(ctx, next); 66 | }) // https://github.com/koajs/csrf 67 | .use(helmet()); // https://github.com/venables/koa-helmet 68 | }; 69 | 70 | export const renderLayer = (app: Object, templateRoutes: Function) => { 71 | const newRouter = router(); 72 | 73 | newRouter 74 | .use( 75 | htmlMinifier({ 76 | collapseWhitespace: true, 77 | removeComments: true, 78 | preserveLineBreaks: false, 79 | removeEmptyAttributes: false, 80 | removeIgnored: true 81 | }) 82 | ) // https://github.com/kangax/html-minifier 83 | .use(compress()); // https://github.com/koajs/compress 84 | 85 | templateRoutes(newRouter); 86 | 87 | app.use(newRouter.routes()).use(newRouter.allowedMethods()); 88 | 89 | return newRouter; 90 | }; 91 | 92 | export const errorLayer = (app: Object) => app.use(error); 93 | -------------------------------------------------------------------------------- /app/server/infrastructure/settings.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | /* global process */ 3 | const { ROOT, PUBLIC } = require("../../../config/path-helper"); 4 | 5 | // default settings 6 | const settings = { 7 | path: { 8 | ROOT, 9 | PUBLIC, 10 | TEMPLATES_DIR: "app/server/application/templates" 11 | }, 12 | env: { 13 | NODE_ENV: process.env.NODE_ENV 14 | }, 15 | assetManifest: {} 16 | }; 17 | 18 | // ignore assets build for test 19 | if (process.env.NODE_ENV === "test") { 20 | settings.assetManifest = { 21 | javascript: {}, 22 | styles: {} 23 | }; 24 | } else { 25 | settings.assetManifest = global.nodeRequire("./public/assets/webpack-chunks.json"); 26 | } 27 | 28 | export default settings; 29 | -------------------------------------------------------------------------------- /app/share/__tests__/__snapshots__/createStore.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`createStore default state 1`] = ` 4 | Object { 5 | "helmet": Object { 6 | "link": Array [], 7 | "title": "Koa React Isomorphic", 8 | }, 9 | "routing": Object { 10 | "locationBeforeTransitions": null, 11 | }, 12 | } 13 | `; 14 | -------------------------------------------------------------------------------- /app/share/__tests__/createStore.js: -------------------------------------------------------------------------------- 1 | import createStore from "../createStore"; 2 | 3 | test("createStore default state", () => { 4 | expect(createStore().getState()).toMatchSnapshot(); 5 | }); 6 | -------------------------------------------------------------------------------- /app/share/components/app/__tests__/__snapshots__/index.jsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`App in development mode 1`] = ` 4 | 5 | 21 |
22 | 23 | New Routes 24 |
25 |
26 |
27 | `; 28 | 29 | exports[`App in production mode 1`] = ` 30 | 46 |
47 | 48 | New Routes 49 |
50 |
51 | `; 52 | -------------------------------------------------------------------------------- /app/share/components/app/__tests__/index.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { shallow } from "enzyme"; 3 | import createStore from "../../../createStore"; 4 | import App from ".."; 5 | 6 | test("App in development mode", () => { 7 | const NODE_ENV = process.env.NODE_ENV; 8 | process.env.NODE_ENV = "development"; 9 | 10 | expect( 11 | shallow() 12 | ).toMatchSnapshot(); 13 | 14 | process.env.NODE_ENV = NODE_ENV; 15 | }); 16 | 17 | test("App in production mode", () => { 18 | expect( 19 | shallow() 20 | ).toMatchSnapshot(); 21 | }); 22 | -------------------------------------------------------------------------------- /app/share/components/app/index.jsx: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | /* global process */ 3 | import React from "react"; 4 | import { Provider } from "react-redux"; 5 | import Helmet from "../helmet"; 6 | 7 | export default ({ store, routes }: { store: Object, routes: Object }) => { 8 | let Component = ( 9 | 10 |
11 | 12 | {routes} 13 |
14 |
15 | ); 16 | 17 | if (process.env.NODE_ENV === "development") { 18 | const { AppContainer } = require("react-hot-loader"); 19 | 20 | Component = {Component}; 21 | } 22 | 23 | return Component; 24 | }; 25 | -------------------------------------------------------------------------------- /app/share/components/helmet/__tests__/__snapshots__/index.jsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Helmet 1`] = ` 4 | 19 |
20 | 21 | 30 | 36 | 42 | 48 | 49 | 50 | 51 | 52 |
53 |
54 | `; 55 | -------------------------------------------------------------------------------- /app/share/components/helmet/__tests__/index.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { mount } from "enzyme"; 3 | import { Provider } from "react-redux"; 4 | import createStore from "../../../createStore"; 5 | import Helmet from ".."; 6 | 7 | test("Helmet", () => { 8 | expect( 9 | mount( 10 | 11 |
12 | 13 |
14 |
15 | ) 16 | ).toMatchSnapshot(); 17 | }); 18 | -------------------------------------------------------------------------------- /app/share/components/helmet/__tests__/logicBundle.js: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | import faker from "faker"; 3 | import reducer, { updateLink, updateTitle } from "../logicBundle"; 4 | 5 | describe("Module: Helmet", () => { 6 | describe("Reducer", () => { 7 | it("should update title when calls 'updateTitle' action", () => { 8 | const newTitle = faker.random.uuid(); 9 | 10 | expect( 11 | reducer( 12 | { title: "Koa React Isomorphic", link: [] }, 13 | updateTitle(newTitle) 14 | ) 15 | ).toEqual({ title: newTitle, link: [] }); 16 | }); 17 | 18 | it("should update link when calls 'updateLink' action", () => { 19 | const newLink = _.range(10).map(() => faker.random.uuid()); 20 | 21 | expect( 22 | reducer( 23 | { title: "Koa React Isomorphic", link: [] }, 24 | updateLink(newLink) 25 | ) 26 | ).toEqual({ title: "Koa React Isomorphic", link: newLink }); 27 | }); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /app/share/components/helmet/index.jsx: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import React from "react"; 3 | import { compose } from "redux"; 4 | import { connect } from "react-redux"; 5 | import ReactHelmet from "react-helmet"; 6 | import { selectors } from "./logicBundle"; 7 | 8 | export const Helmet = ({ helmet }: { helmet: Object }) => ( 9 | 10 | ); 11 | 12 | export default compose( 13 | connect(state => ({ 14 | helmet: selectors.getHelmet(state) 15 | })) 16 | )(Helmet); 17 | -------------------------------------------------------------------------------- /app/share/components/helmet/logicBundle.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import identity from "lodash/identity"; 3 | import update from "immutability-helper"; 4 | import { createAction, handleActions } from "redux-actions"; 5 | import globalizeSelectors from "../../helpers/globalizeSelectors"; 6 | import type { UpdateLinkType, UpdateTitleType } from "./types"; 7 | 8 | export const mountPoint = "helmet"; 9 | 10 | export const selectors = globalizeSelectors( 11 | { 12 | getHelmet: identity 13 | }, 14 | mountPoint 15 | ); 16 | 17 | export const UPDATE_TITLE = "helmet/UPDATE_TITLE"; 18 | export const UPDATE_LINK = "helmet/UPDATE_LINK"; 19 | 20 | export const updateTitle: UpdateTitleType = createAction(UPDATE_TITLE); 21 | export const updateLink: UpdateLinkType = createAction(UPDATE_LINK); 22 | 23 | export default handleActions( 24 | { 25 | [UPDATE_TITLE]: (state, { payload: title }) => 26 | update(state, { title: { $set: title } }), 27 | [UPDATE_LINK]: (state, { payload: link }) => 28 | update(state, { link: { $set: link } }) 29 | }, 30 | { 31 | title: "Koa React Isomorphic", 32 | link: [] 33 | } 34 | ); 35 | -------------------------------------------------------------------------------- /app/share/components/helmet/types.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | export type UpdateTitleType = (title: string) => { payload: string }; 3 | export type UpdateLinkType = (link: Object[]) => { payload: Object[] }; 4 | -------------------------------------------------------------------------------- /app/share/components/routing/logicBundle.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import identity from "lodash/identity"; 3 | import { routerReducer } from "react-router-redux"; 4 | import globalizeSelectors from "../../helpers/globalizeSelectors"; 5 | 6 | export const mountPoint = "routing"; 7 | 8 | export const selectors = globalizeSelectors( 9 | { 10 | getRouting: identity 11 | }, 12 | mountPoint 13 | ); 14 | 15 | export default routerReducer; 16 | -------------------------------------------------------------------------------- /app/share/components/static-page/__tests__/__snapshots__/index.jsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Component: StaticPage should render component 1`] = ` 4 |
7 |
10 |
13 |

14 | Static Page 15 |

16 | 21 | Back to Home page 22 | 23 |
24 |
25 |
26 | `; 27 | -------------------------------------------------------------------------------- /app/share/components/static-page/__tests__/index.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { shallow } from "enzyme"; 3 | import StaticPage from "../index"; 4 | 5 | describe("Component: StaticPage", () => { 6 | let component; 7 | 8 | beforeEach(() => { 9 | component = shallow(); 10 | }); 11 | 12 | it("should render component", () => { 13 | expect(component).toMatchSnapshot(); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /app/share/components/static-page/index.jsx: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | /* eslint-disable jsx-a11y/anchor-is-valid */ 3 | import React from "react"; 4 | import { Link } from "react-router"; 5 | 6 | export default () => ( 7 |
8 |
9 |
10 |

Static Page

11 | Back to Home page 12 |
13 |
14 |
15 | ); 16 | -------------------------------------------------------------------------------- /app/share/components/todos/TodosAdd.jsx: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import React from "react"; 3 | import type { AddTodoActionType } from "./types"; 4 | 5 | export default class TodosAdd extends React.PureComponent< 6 | { addTodo: AddTodoActionType }, 7 | { todo: string } 8 | > { 9 | state = { 10 | todo: "" 11 | }; 12 | 13 | updateTodo = (e: Object) => { 14 | this.setState({ todo: e.target.value }); 15 | }; 16 | 17 | addTodo = () => { 18 | this.props.addTodo(this.state.todo); 19 | this.setState({ todo: "" }); 20 | }; 21 | 22 | render() { 23 | return ( 24 |
25 |
26 |
27 | 34 |
35 | 42 |
43 |
44 | ); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /app/share/components/todos/TodosBody.jsx: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | /* eslint-disable react/no-array-index-key */ 3 | import React from "react"; 4 | import partial from "lodash/partial"; 5 | import style from "./TodosBodyStyle.scss"; 6 | import type { 7 | CompleteTodoActionType, 8 | RemoveTodoActionType, 9 | TodoType 10 | } from "./types"; 11 | 12 | const TodosBody = ({ 13 | todos, 14 | completeTodo, 15 | removeTodo 16 | }: { 17 | todos: TodoType[], 18 | completeTodo: CompleteTodoActionType, 19 | removeTodo: RemoveTodoActionType 20 | }) => ( 21 |
22 | 23 | 24 | {todos.map((todo: TodoType, index: number) => { 25 | const text = todo.complete ? ( 26 | {todo.text} 27 | ) : ( 28 | {todo.text} 29 | ); 30 | 31 | return ( 32 | 33 | 36 | 37 | 46 | 55 | 56 | ); 57 | })} 58 | 59 |
34 | {index + 1} 35 | {text} 38 | 45 | 47 | 54 |
60 |
61 | ); 62 | 63 | export default TodosBody; 64 | -------------------------------------------------------------------------------- /app/share/components/todos/TodosBodyStyle.scss: -------------------------------------------------------------------------------- 1 | :local { 2 | .container { 3 | margin-top: 20px; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /app/share/components/todos/TodosFooter.jsx: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | /* eslint-disable jsx-a11y/anchor-is-valid */ 3 | import React from "react"; 4 | import { Link } from "react-router"; 5 | 6 | export default () => ( 7 |
8 | Go to static page 9 |
10 | ); 11 | -------------------------------------------------------------------------------- /app/share/components/todos/TodosHeader.jsx: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import React from "react"; 3 | 4 | export default () => ( 5 |
6 |

Todos List

7 |
8 | ); 9 | -------------------------------------------------------------------------------- /app/share/components/todos/__tests__/TodosAdd.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { mount } from "enzyme"; 3 | import TodosAdd from "../TodosAdd"; 4 | 5 | describe("Component: TodosAdd", () => { 6 | it("should define state.todo", () => { 7 | const component = mount(); 8 | 9 | expect(component.state().todo).toEqual(""); 10 | }); 11 | 12 | it("should call the addTodo action when click on the 'Add Todo' button", () => { 13 | const callback = jest.fn(); 14 | const component = mount(); 15 | const input = component.find("input"); 16 | const button = component.find("button"); 17 | 18 | input.simulate("change", { 19 | target: { 20 | value: "do chore" 21 | } 22 | }); 23 | expect(component.state().todo).toEqual("do chore"); 24 | 25 | button.simulate("click"); 26 | expect(callback).toBeCalledWith("do chore"); 27 | expect(component.state().todo).toEqual(""); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /app/share/components/todos/__tests__/TodosBody.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { mount } from "enzyme"; 3 | import { noop } from "lodash"; 4 | import TodosBody from "../TodosBody"; 5 | 6 | describe("Component: TodosBody", () => { 7 | const todos = [ 8 | { text: "Todo 1", complete: false }, 9 | { text: "Todo 2", complete: false }, 10 | { text: "Todo 3", complete: false }, 11 | { text: "Todo 4", complete: false } 12 | ]; 13 | 14 | it("should display a list of todos", () => { 15 | const component = mount( 16 | 17 | ); 18 | const trComponents = component.find("tr"); 19 | 20 | expect(trComponents).toHaveLength(todos.length); 21 | }); 22 | 23 | it("should call 'removeTodo' when click on the delete button", () => { 24 | const removeTodo = jest.fn(); 25 | const component = mount( 26 | 27 | ); 28 | const trComponents = component.find("tr"); 29 | 30 | trComponents.forEach((tr, index) => { 31 | const removeButton = tr.find(".btn-danger"); 32 | removeButton.simulate("click"); 33 | 34 | expect(removeTodo.mock.calls[index][0]).toBe(index); 35 | }); 36 | expect(removeTodo.mock.calls.length).toEqual(todos.length); 37 | }); 38 | 39 | it("should call 'completeTodo' when click on the complete button", () => { 40 | const completeTodo = jest.fn(); 41 | const component = mount( 42 | 43 | ); 44 | const trComponents = component.find("tr"); 45 | 46 | trComponents.forEach((tr, index) => { 47 | const completeButton = tr.find(".btn-success"); 48 | completeButton.simulate("click"); 49 | 50 | expect(completeTodo.mock.calls[index][0]).toBe(index); 51 | }); 52 | expect(completeTodo.mock.calls.length).toEqual(todos.length); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /app/share/components/todos/__tests__/TodosFooter.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { shallow } from "enzyme"; 3 | import TodosFooter from "../TodosFooter"; 4 | 5 | describe("Component: TodosFooter", () => { 6 | let component; 7 | 8 | beforeEach(() => { 9 | component = shallow(); 10 | }); 11 | 12 | it("should render 'Go to static page' link", () => { 13 | expect(component.html()).toContain("Go to static page"); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /app/share/components/todos/__tests__/TodosHeader.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { shallow } from "enzyme"; 3 | import TodosHeader from "../TodosHeader"; 4 | 5 | describe("Component: TodosHeader", () => { 6 | it("should render 'TodosHeader' component", () => { 7 | expect(shallow()).toBeDefined(); 8 | }); 9 | 10 | it("should have title 'Todos List'", () => { 11 | const component = shallow(); 12 | expect(component.text()).toEqual("Todos List"); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /app/share/components/todos/__tests__/__snapshots__/index.jsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Component: Todos should render component 1`] = ` 4 |
5 |
8 |
11 |
14 |

15 | Todos List 16 |

17 |
18 |
21 |
24 |
27 | 33 |
34 | 40 |
41 |
42 |
45 | 48 | 49 | 50 | 55 | 60 | 70 | 80 | 81 | 82 | 87 | 92 | 102 | 112 | 113 | 114 | 119 | 124 | 134 | 144 | 145 | 146 | 151 | 156 | 166 | 176 | 177 | 178 |
51 | 52 | 1 53 | 54 | 56 | 57 | Todo 1 58 | 59 | 61 | 69 | 71 | 79 |
83 | 84 | 2 85 | 86 | 88 | 89 | Todo 2 90 | 91 | 93 | 101 | 103 | 111 |
115 | 116 | 3 117 | 118 | 120 | 121 | Todo 3 122 | 123 | 125 | 133 | 135 | 143 |
147 | 148 | 4 149 | 150 | 152 | 153 | Todo 4 154 | 155 | 157 | 165 | 167 | 175 |
179 |
180 | 187 |
188 |
189 |
190 | `; 191 | -------------------------------------------------------------------------------- /app/share/components/todos/__tests__/index.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render } from "enzyme"; 3 | import { Provider } from "react-redux"; 4 | import createStore from "../../../createStore"; 5 | import todosReducer, { 6 | mountPoint as todosMountPoint, 7 | setTodos 8 | } from "../logicBundle"; 9 | import injectReducers from "../../../helpers/injectReducers"; 10 | import Todos from ".."; 11 | 12 | describe("Component: Todos", () => { 13 | let store; 14 | 15 | beforeEach(() => { 16 | store = createStore(); 17 | injectReducers(store, { [todosMountPoint]: todosReducer }); 18 | store.dispatch( 19 | setTodos([ 20 | { text: "Todo 1", complete: false }, 21 | { text: "Todo 2", complete: false }, 22 | { text: "Todo 3", complete: false }, 23 | { text: "Todo 4", complete: false } 24 | ]) 25 | ); 26 | }); 27 | 28 | it("should render component", () => { 29 | expect( 30 | render( 31 | 32 |
33 | 34 |
35 |
36 | ) 37 | ).toMatchSnapshot(); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /app/share/components/todos/__tests__/logicBundle.js: -------------------------------------------------------------------------------- 1 | /* global process */ 2 | import nock from "nock"; 3 | import reducer, { 4 | addTodo, 5 | completeTodo, 6 | fetchTodos, 7 | removeTodo, 8 | setTodos 9 | } from "../logicBundle"; 10 | 11 | describe("Module: Todos", () => { 12 | describe("Actions", () => { 13 | describe("fetchTodos", () => { 14 | const todos = [ 15 | { text: "Todo 1", complete: false }, 16 | { text: "Todo 2", complete: false }, 17 | { text: "Todo 3", complete: false }, 18 | { text: "Todo 4", complete: false } 19 | ]; 20 | let RUNTIME_ENV; 21 | 22 | beforeAll(() => { 23 | RUNTIME_ENV = process.env.RUNTIME_ENV; 24 | 25 | process.env.RUNTIME_ENV = "server"; 26 | 27 | nock(`http://localhost:${process.env.PORT}`) 28 | .get("/api/v1/todos") 29 | .reply(200, todos); 30 | }); 31 | 32 | afterAll(() => { 33 | process.env.RUNTIME_ENV = RUNTIME_ENV; 34 | }); 35 | 36 | it("should return a function when calls 'fetchTodos' then return 'setTodos' action", async () => { 37 | const callback = jest.fn(); 38 | const action = fetchTodos(); 39 | 40 | await action(callback); 41 | 42 | expect(callback).toBeCalledWith(setTodos(todos)); 43 | }); 44 | }); 45 | }); 46 | 47 | describe("Reducer", () => { 48 | it("should return the default state", () => { 49 | expect( 50 | reducer([], { type: "ANOTHER_ACTION", payload: "random value" }) 51 | ).toEqual([]); 52 | }); 53 | 54 | it("should return a todos list with 1 todo item when calls 'addTodo' action", () => { 55 | expect(reducer([], addTodo("do chore"))).toEqual([ 56 | { text: "do chore", complete: false } 57 | ]); 58 | }); 59 | 60 | it("should return an empty todos list when calls 'removeTodo' action", () => { 61 | expect( 62 | reducer([{ text: "do chore", complete: false }], removeTodo(0)) 63 | ).toEqual([]); 64 | }); 65 | 66 | it("should return an todos list when calls 'setTodos' action", () => { 67 | expect( 68 | reducer([], setTodos([{ text: "do chore", complete: false }])) 69 | ).toEqual([{ text: "do chore", complete: false }]); 70 | }); 71 | 72 | it("should return a todos list with 1 completed todo when calls 'completeTodo' action", () => { 73 | expect( 74 | reducer([{ text: "do chore", complete: false }], completeTodo(0)) 75 | ).toEqual([{ text: "do chore", complete: true }]); 76 | 77 | expect( 78 | reducer([{ text: "do chore", complete: true }], completeTodo(0)) 79 | ).toEqual([{ text: "do chore", complete: false }]); 80 | }); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /app/share/components/todos/index.jsx: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import React from "react"; 3 | import { compose, bindActionCreators } from "redux"; 4 | import { connect } from "react-redux"; 5 | import TodosHeader from "./TodosHeader"; 6 | import TodosAdd from "./TodosAdd"; 7 | import TodosBody from "./TodosBody"; 8 | import TodosFooter from "./TodosFooter"; 9 | import type { TodoType } from "./types"; 10 | import { 11 | addTodo, 12 | completeTodo, 13 | fetchTodos, 14 | removeTodo, 15 | selectors 16 | } from "./logicBundle"; 17 | import { updateLink } from "../helmet/logicBundle"; 18 | import createRedialHooks from "../../helpers/createRedialHooks"; 19 | import { FETCH_DATA_HOOK, UPDATE_HEADER_HOOK } from "../../helpers/fetchData"; 20 | 21 | export const Todos = ({ 22 | todos, 23 | actions 24 | }: { 25 | todos: TodoType[], 26 | actions: Object 27 | }) => ( 28 |
29 |
30 | 31 | 32 | 37 | 38 |
39 |
40 | ); 41 | 42 | const hooks: Object = { 43 | [FETCH_DATA_HOOK]: ({ store }) => store.dispatch(fetchTodos()) 44 | }; 45 | 46 | if (process.env.RUNTIME_ENV === "client") { 47 | Object.assign(hooks, { 48 | [UPDATE_HEADER_HOOK]: ({ store }) => 49 | store.dispatch( 50 | updateLink([ 51 | // window.javascriptAssets will be injected to do preload link for optimizing route 52 | { 53 | rel: "prefetch", 54 | href: window.javascriptAssets["static-page"], 55 | as: "script" 56 | } 57 | ]) 58 | ) 59 | }); 60 | } 61 | 62 | export default compose( 63 | createRedialHooks(hooks), 64 | connect( 65 | state => ({ 66 | todos: selectors.getTodos(state) 67 | }), 68 | dispatch => ({ 69 | actions: bindActionCreators( 70 | { 71 | addTodo, 72 | removeTodo, 73 | completeTodo 74 | }, 75 | dispatch 76 | ) 77 | }) 78 | ) 79 | )(Todos); 80 | -------------------------------------------------------------------------------- /app/share/components/todos/logicBundle.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import identity from "lodash/identity"; 3 | import update from "immutability-helper"; 4 | import { createAction, handleActions } from "redux-actions"; 5 | import globalizeSelectors from "../../helpers/globalizeSelectors"; 6 | import fetch from "../../helpers/fetch"; 7 | import type { 8 | AddTodoActionType, 9 | CompleteTodoActionType, 10 | RemoveTodoActionType, 11 | SetTodosActionType, 12 | TodoType 13 | } from "./types"; 14 | 15 | export const mountPoint = "todos"; 16 | 17 | export const selectors = globalizeSelectors( 18 | { 19 | getTodos: identity 20 | }, 21 | mountPoint 22 | ); 23 | 24 | export const ADD_TODO = "todos/ADD_TODO"; 25 | export const REMOVE_TODO = "todos/REMOVE_TODO"; 26 | export const COMPLETE_TODO = "todos/COMPLETE_TODO"; 27 | export const SET_TODOS = "todos/SET_TODOS"; 28 | 29 | export const addTodo: AddTodoActionType = createAction(ADD_TODO); 30 | export const removeTodo: RemoveTodoActionType = createAction(REMOVE_TODO); 31 | export const completeTodo: CompleteTodoActionType = createAction(COMPLETE_TODO); 32 | export const setTodos: SetTodosActionType = createAction(SET_TODOS); 33 | export const fetchTodos = () => (dispatch: Function): Promise => 34 | fetch("/api/v1/todos") 35 | .then(res => res.json()) 36 | .then((res: TodoType[]) => dispatch(setTodos(res))); 37 | 38 | export default handleActions( 39 | { 40 | [ADD_TODO]: (state, { payload: text }) => 41 | update(state, { 42 | $push: [{ text, complete: false }] 43 | }), 44 | [REMOVE_TODO]: (state, { payload: index }) => 45 | update(state, { 46 | $splice: [[index, 1]] 47 | }), 48 | [COMPLETE_TODO]: (state, { payload: index }) => 49 | update(state, { 50 | $splice: [ 51 | [index, 1], 52 | [index, 0, { ...state[index], complete: !state[index].complete }] 53 | ] 54 | }), 55 | [SET_TODOS]: (state, { payload: todos }) => todos 56 | }, 57 | [] 58 | ); 59 | -------------------------------------------------------------------------------- /app/share/components/todos/types.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | export type TodoType = { text: string, complete: boolean }; 3 | export type AddTodoActionType = (text: string) => { payload: string }; 4 | export type RemoveTodoActionType = (index: number) => { payload: number }; 5 | export type CompleteTodoActionType = (index: number) => { payload: number }; 6 | export type SetTodosActionType = (todos: TodoType[]) => { payload: TodoType[] }; 7 | -------------------------------------------------------------------------------- /app/share/createReducer.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import routingReducer, { 3 | mountPoint as routingMountPoint 4 | } from "./components/routing/logicBundle"; 5 | import helmetReducer, { 6 | mountPoint as helmetMountPoint 7 | } from "./components/helmet/logicBundle"; 8 | 9 | export default { 10 | [routingMountPoint]: routingReducer, 11 | [helmetMountPoint]: helmetReducer 12 | }; 13 | -------------------------------------------------------------------------------- /app/share/createStore.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | /* global process */ 3 | import _ from "lodash"; 4 | import { applyMiddleware, combineReducers, compose, createStore } from "redux"; 5 | import thunkMiddleware from "redux-thunk"; 6 | import { createLogger } from "redux-logger"; 7 | import injectReducers from "./helpers/injectReducers"; 8 | import reducers from "./createReducer"; 9 | 10 | const middlewares = [thunkMiddleware]; 11 | const enhancers = []; 12 | 13 | // support for development 14 | if (process.env.RUNTIME_ENV === "client") { 15 | if (process.env.NODE_ENV === "development") { 16 | middlewares.push(createLogger({ level: "info" })); 17 | } 18 | 19 | if (window.__REDUX_DEVTOOLS_EXTENSION__) { 20 | enhancers.push(global.__REDUX_DEVTOOLS_EXTENSION__()); 21 | } 22 | } 23 | 24 | export default (initialState: Object = {}) => { 25 | // preserve state in SSR 26 | const newReducers = _.reduce( 27 | Object.keys(initialState), 28 | (prev, key) => { 29 | if (!(key in prev)) { 30 | prev[key] = _.constant(initialState[key]); 31 | } 32 | 33 | return prev; 34 | }, 35 | reducers 36 | ); 37 | 38 | const store = createStore( 39 | combineReducers(newReducers), 40 | initialState, 41 | compose(applyMiddleware(...middlewares), ...enhancers) 42 | ); 43 | 44 | store.reducers = newReducers; 45 | 46 | // $FlowFixMe 47 | if (process.env.NODE_ENV === "development" && module.hot) { 48 | module.hot.accept("./createReducer", () => 49 | injectReducers(store, combineReducers(require("./createReducer").default)) 50 | ); 51 | } 52 | 53 | return store; 54 | }; 55 | -------------------------------------------------------------------------------- /app/share/helpers/__tests__/__snapshots__/createMockingComponent.jsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`createMockingComponent 1`] = ` 4 |
5 | MockingComponent {"prop1":"value1"} 6 |
7 | `; 8 | -------------------------------------------------------------------------------- /app/share/helpers/__tests__/__snapshots__/createRedialHooks.jsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`createRedialHooks should render component 1`] = ` 4 |
5 | Handler {"message":"Hello world"} 6 |
7 | `; 8 | -------------------------------------------------------------------------------- /app/share/helpers/__tests__/createMockingComponent.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render } from "enzyme"; 3 | import createMockingComponent from "../createMockingComponent"; 4 | 5 | test("createMockingComponent", () => { 6 | const Component = createMockingComponent("MockingComponent", ["prop1"]); 7 | 8 | expect(render()).toMatchSnapshot(); 9 | }); 10 | -------------------------------------------------------------------------------- /app/share/helpers/__tests__/createRedialHooks.jsx: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | import faker from "faker"; 3 | import React from "react"; 4 | import { render } from "enzyme"; 5 | import { trigger } from "redial"; 6 | import createMockingComponent from "../createMockingComponent"; 7 | import redialEnhancer from "../createRedialHooks"; 8 | 9 | describe("createRedialHooks", () => { 10 | let Handler; 11 | let Component; 12 | let callback; 13 | 14 | beforeEach(() => { 15 | callback = jest.fn(); 16 | Handler = createMockingComponent("Handler", ["message"]); 17 | Component = redialEnhancer({ callback })(Handler); 18 | }); 19 | 20 | it("should 'trigger' the 'callback' function with arguments", async () => { 21 | const args = _.range(4).map(() => faker.random.uuid()); 22 | await trigger("callback", Component, args); 23 | expect(callback).toBeCalledWith(args); 24 | }); 25 | 26 | it("should render component", () => { 27 | expect(render()).toMatchSnapshot(); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /app/share/helpers/__tests__/fetchData.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jest-environment-jsdom-global 3 | */ 4 | import _ from "lodash"; 5 | import faker from "faker"; 6 | 7 | describe("Helper: fetchData", () => { 8 | const SERVER_URL = `http://localhost:${process.env.PORT}`; 9 | let redial; 10 | let reactRouter; 11 | let fetchData; 12 | 13 | beforeAll(() => { 14 | jest.mock("redial", () => jest.genMockFromModule("redial")); 15 | jest.mock("react-router", () => jest.genMockFromModule("react-router")); 16 | 17 | redial = require("redial"); 18 | reactRouter = require("react-router"); 19 | fetchData = require("../fetchData"); 20 | }); 21 | 22 | afterAll(() => { 23 | jest.unmock("redial"); 24 | jest.unmock("react-router"); 25 | }); 26 | 27 | describe("# serverFetchData", () => { 28 | let components; 29 | let renderProps; 30 | let store; 31 | 32 | beforeAll(() => { 33 | components = _.range(4); 34 | renderProps = { 35 | components: _.range(4), 36 | params: faker.random.uuid(), 37 | location: faker.random.uuid() 38 | }; 39 | store = faker.random.uuid(); 40 | }); 41 | 42 | it("should call 'trigger' with 'components' and 'locals'", () => { 43 | fetchData.serverFetchData(renderProps, store); 44 | 45 | expect(redial.trigger).toBeCalledWith( 46 | fetchData.FETCH_DATA_HOOK, 47 | components, 48 | { 49 | store, 50 | params: renderProps.params, 51 | location: renderProps.location 52 | } 53 | ); 54 | }); 55 | }); 56 | 57 | describe("# clientFetchData", () => { 58 | let history; 59 | let store; 60 | let components; 61 | 62 | beforeAll(() => { 63 | history = { 64 | listen: callback => callback(faker.random.uuid()), 65 | getCurrentLocation: _.noop 66 | }; 67 | components = _.range(4); 68 | store = faker.random.uuid(); 69 | }); 70 | 71 | describe("# match route", () => { 72 | it("should navigate to error page", () => { 73 | fetchData.clientFetchData(history, components, store); 74 | reactRouter.match.mock.calls[0][1](true); 75 | expect(window.location.href).toEqual(`${SERVER_URL}/500.html`); 76 | }); 77 | 78 | it("should redirect to /hello-world.html page", () => { 79 | fetchData.clientFetchData(history, components, store); 80 | reactRouter.match.mock.calls[0][1](undefined, { 81 | pathname: "/hello-world.html", 82 | search: "" 83 | }); 84 | expect(window.location.href).toEqual(`${SERVER_URL}/hello-world.html`); 85 | }); 86 | 87 | it("should trigger not FETCH_DATA_HOOK", () => { 88 | window.prerenderData = faker.random.uuid(); 89 | 90 | fetchData.clientFetchData(history, components, store); 91 | reactRouter.match.mock.calls[0][1](undefined, undefined, { 92 | components, 93 | location: "/", 94 | params: { 95 | test: faker.random.uuid() 96 | } 97 | }); 98 | 99 | expect(window.prerenderData).toBeUndefined(); 100 | }); 101 | 102 | it("should trigger FETCH_DATA_HOOK", () => { 103 | const renderProps = { 104 | components, 105 | location: "/", 106 | params: { 107 | test: faker.random.uuid() 108 | } 109 | }; 110 | 111 | fetchData.clientFetchData(history, components, store); 112 | reactRouter.match.mock.calls[0][1](undefined, undefined, renderProps); 113 | 114 | expect(redial.trigger).toBeCalledWith( 115 | fetchData.FETCH_DATA_HOOK, 116 | renderProps.components, 117 | fetchData.getDefaultParams(store, renderProps) 118 | ); 119 | }); 120 | 121 | it("should navigate to /404.html page", () => { 122 | fetchData.clientFetchData(history, components, store); 123 | reactRouter.match.mock.calls[0][1](undefined, undefined, undefined); 124 | 125 | expect(window.location.href).toEqual(`${SERVER_URL}/404.html`); 126 | }); 127 | }); 128 | }); 129 | }); 130 | -------------------------------------------------------------------------------- /app/share/helpers/__tests__/globalizeSelectors.js: -------------------------------------------------------------------------------- 1 | import globalizeSelectors from "../globalizeSelectors"; 2 | 3 | test("globalizeSelectors", () => { 4 | const mountPoint = "todos"; 5 | const selector1 = jest.fn(); 6 | const selectors = globalizeSelectors({ selector1 }, mountPoint); 7 | const data = [ 8 | { complete: false, text: "Todo 1" }, 9 | { complete: false, text: "Todo 2" }, 10 | { complete: false, text: "Todo 3" } 11 | ]; 12 | 13 | selectors.selector1({ [mountPoint]: data }); 14 | expect(selector1).toBeCalledWith(data); 15 | }); 16 | -------------------------------------------------------------------------------- /app/share/helpers/__tests__/injectReducers.js: -------------------------------------------------------------------------------- 1 | import { createAction, handleActions } from "redux-actions"; 2 | import createStore from "../../createStore"; 3 | import injectReducers from "../injectReducers"; 4 | 5 | describe("injectReducers", () => { 6 | const mountPoint = "newReducer"; 7 | const NEW_ACTION = "action/NEW_ACTION"; 8 | const newAction = createAction(NEW_ACTION); 9 | const newReducer = { 10 | [mountPoint]: handleActions( 11 | { 12 | [NEW_ACTION]: (state, { payload }) => payload 13 | }, 14 | null 15 | ) 16 | }; 17 | let store; 18 | 19 | beforeEach(() => { 20 | store = createStore(); 21 | }); 22 | 23 | it(`should not contain '${mountPoint}' state`, () => { 24 | expect(store.getState()).not.toHaveProperty(mountPoint); 25 | }); 26 | 27 | describe("injecting", () => { 28 | beforeEach(() => { 29 | injectReducers(store, newReducer); 30 | }); 31 | 32 | it(`should inject '${mountPoint}'`, () => { 33 | expect(store.getState()).toHaveProperty(mountPoint); 34 | }); 35 | 36 | it("should have default state", () => { 37 | expect(store.getState()[mountPoint]).toEqual(null); 38 | }); 39 | 40 | it("should work when dispatch 'NEW_ACTION'", () => { 41 | const data = Symbol("data"); 42 | store.dispatch(newAction(data)); 43 | 44 | expect(store.getState()[mountPoint]).toEqual(data); 45 | }); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /app/share/helpers/createMockingComponent.jsx: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import _ from "lodash"; 3 | import React from "react"; 4 | 5 | export default (mockTagName: string, props: any[] = []) => (componentProps: { 6 | [key: string]: any 7 | }) => ( 8 |
{`${mockTagName} ${JSON.stringify(_.pick(componentProps, props))}`}
9 | ); 10 | -------------------------------------------------------------------------------- /app/share/helpers/createRedialHooks.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import { provideHooks } from "redial"; 3 | 4 | export default (hooks: { [key: string]: Function }): Function => ( 5 | ComposedComponent: React$Component 6 | ): React$Component => 7 | provideHooks( 8 | Object.keys(hooks).reduce((obj: Object, key: string) => { 9 | obj[key] = (...args) => hooks[key](...args); 10 | return obj; 11 | }, {}) 12 | )(ComposedComponent); 13 | -------------------------------------------------------------------------------- /app/share/helpers/fetch.js: -------------------------------------------------------------------------------- 1 | import omit from "lodash/omit"; 2 | import fetch from "isomorphic-fetch"; 3 | 4 | export const getBaseUrl = () => { 5 | if (process.env.RUNTIME_ENV === "client") { 6 | return ""; 7 | } 8 | 9 | const { PORT } = process.env; 10 | 11 | if (!PORT) { 12 | throw new Error("Missing 'process.env.PORT'."); 13 | } 14 | 15 | return `http://localhost:${PORT}`; 16 | }; 17 | 18 | // Default options for the Fetch API 19 | // https://developer.mozilla.org/docs/Web/API/Fetch_API/Using_Fetch 20 | export const create = (baseUrl: string) => ( 21 | url: string, 22 | options: Object = {} 23 | ) => { 24 | const headers = { 25 | Accept: "application/json", 26 | "Content-Type": "application/json", 27 | ...(options && options.headers) 28 | }; 29 | 30 | if (process.env.RUNTIME_ENV === "client") { 31 | Object.assign(headers, { 32 | "X-CSRF-Token": window.__csrf 33 | }); 34 | } else { 35 | Object.assign(headers, { 36 | "X-App-Secret": process.env.SECRET_KEY 37 | }); 38 | } 39 | 40 | return fetch(`${baseUrl}${url}`, { 41 | headers, 42 | mode: baseUrl ? "cors" : "same-origin", 43 | credentials: baseUrl ? "include" : "same-origin", 44 | ...omit(options, "headers") 45 | }); 46 | }; 47 | 48 | export default create(getBaseUrl()); 49 | -------------------------------------------------------------------------------- /app/share/helpers/fetchData.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | /* global process */ 3 | import { trigger } from "redial"; 4 | import isEmpty from "lodash/isEmpty"; 5 | import { match } from "react-router"; 6 | import { getBaseUrl} from "./fetch"; 7 | 8 | export const FETCH_DATA_HOOK = "FETCH_DATA_HOOK"; 9 | 10 | export const UPDATE_HEADER_HOOK = "UPDATE_HEADER_HOOK"; 11 | 12 | export const getDefaultParams = ( 13 | store: Object, 14 | { location, params }: { location: Object, params: Object } 15 | ): Object => ({ store, location, params }); 16 | 17 | export const serverFetchData = ( 18 | renderProps: Object, 19 | store: Object 20 | ): Promise => 21 | trigger( 22 | FETCH_DATA_HOOK, 23 | renderProps.components, 24 | getDefaultParams(store, renderProps) 25 | ); 26 | 27 | const redirectTo = (url: string): void => { 28 | if (process.env.NODE_ENV === "test") { 29 | global.jsdom.reconfigure({ url: `${getBaseUrl()}${url}` }); 30 | } else if (process.env.RUNTIME_ENV === "client") { 31 | window.location.replace(url); 32 | } 33 | }; 34 | 35 | export const clientFetchData = ( 36 | history: Object, 37 | routes: Object, 38 | store: Object 39 | ) => { 40 | const callback = location => 41 | match({ routes, location }, (error, redirectLocation, renderProps) => { 42 | if (error) { 43 | redirectTo("/500.html"); 44 | } else if (redirectLocation) { 45 | redirectTo(redirectLocation.pathname + redirectLocation.search); 46 | } else if (renderProps) { 47 | if (!isEmpty(window.prerenderData)) { 48 | // Delete initial data so that subsequent data fetches can occur 49 | window.prerenderData = undefined; 50 | } else { 51 | // Fetch mandatory data dependencies for 2nd route change onwards 52 | trigger( 53 | FETCH_DATA_HOOK, 54 | renderProps.components, 55 | getDefaultParams(store, renderProps) 56 | ); 57 | } 58 | 59 | trigger( 60 | UPDATE_HEADER_HOOK, 61 | renderProps.components, 62 | getDefaultParams(store, renderProps) 63 | ); 64 | } else { 65 | redirectTo("/404.html"); 66 | } 67 | }); 68 | 69 | history.listen(callback); 70 | callback(history.getCurrentLocation()); 71 | }; 72 | -------------------------------------------------------------------------------- /app/share/helpers/globalizeSelectors.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import get from "lodash/get"; 3 | 4 | const fromRoot = (path: string, selector: Function): Function => ( 5 | state: Object, 6 | ...args: any[] 7 | ): any => selector(get(state, path), ...args); 8 | 9 | export default ( 10 | selectors: { [key: string]: Function }, 11 | path: string 12 | ): { [key: string]: Function } => 13 | Object.keys(selectors).reduce((obj, key) => { 14 | obj[key] = fromRoot(path, selectors[key]); 15 | return obj; 16 | }, {}); 17 | -------------------------------------------------------------------------------- /app/share/helpers/injectReducers.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import { combineReducers } from "redux"; 3 | 4 | export default (store: Object, reducers: Object): void => { 5 | store.reducers = { 6 | ...store.reducers, 7 | ...reducers 8 | }; 9 | 10 | store.replaceReducer(combineReducers(store.reducers)); 11 | }; 12 | -------------------------------------------------------------------------------- /config/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | path: { 3 | app: "/app/", 4 | assets: "/assets/", 5 | publicAssets: "/public/assets/", 6 | build: "/build/", 7 | tmp: "/tmp/" 8 | }, 9 | cssModules: { 10 | localIdentName: "[local]__[hash:base64:5]" 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /config/path-helper.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | 3 | const ROOT = path.join(__dirname, "../"); 4 | 5 | exports.ROOT = ROOT; 6 | exports.PUBLIC = path.join(ROOT, "public"); 7 | -------------------------------------------------------------------------------- /config/webpack/client/development.js: -------------------------------------------------------------------------------- 1 | const _ = require("lodash"); 2 | const path = require("path"); 3 | const webpack = require("webpack"); 4 | const cssnext = require("postcss-cssnext"); 5 | const StyleLintPlugin = require("stylelint-webpack-plugin"); 6 | const { clientConfiguration } = require("universal-webpack"); 7 | const { ROOT } = require("../../path-helper"); 8 | const config = require("../.."); 9 | 10 | const developmentConfig = clientConfiguration( 11 | require("../default-config"), 12 | require("../universal-webpack-settings"), 13 | { 14 | development: true 15 | } 16 | ); 17 | 18 | _.mergeWith( 19 | developmentConfig, 20 | { 21 | mode: "development", 22 | entry: { 23 | app: ["react-hot-loader/patch"] 24 | }, 25 | output: { 26 | publicPath: `http://localhost:8080${config.path.build}`, 27 | filename: "[name].js", 28 | chunkFilename: "[id].js" 29 | }, 30 | cache: true, 31 | devServer: { 32 | contentBase: ROOT, 33 | hot: true, 34 | headers: { 35 | "Access-Control-Allow-Origin": "*" 36 | }, 37 | inline: true 38 | }, 39 | recordsPath: path.join(ROOT, config.path.tmp, "client-records.json"), 40 | optimization: { 41 | noEmitOnErrors: true, 42 | // Keep the runtime chunk separated to enable long term caching 43 | // https://twitter.com/wSokra/status/969679223278505985 44 | runtimeChunk: { 45 | name: "runtime" 46 | } 47 | } 48 | }, 49 | (obj1, obj2) => (_.isArray(obj2) ? obj2.concat(obj1) : undefined) 50 | ); 51 | 52 | developmentConfig.plugins.push( 53 | new webpack.DefinePlugin({ 54 | "process.env.RUNTIME_ENV": "'client'", 55 | "process.env.SERVER_RENDERING": process.env.SERVER_RENDERING || false 56 | }), 57 | new webpack.LoaderOptionsPlugin({ 58 | test: /\.(css|less|scss)$/, 59 | options: { 60 | postcss() { 61 | return [cssnext()]; 62 | } 63 | } 64 | }), 65 | new StyleLintPlugin(), 66 | new webpack.HotModuleReplacementPlugin() 67 | ); 68 | 69 | module.exports = developmentConfig; 70 | -------------------------------------------------------------------------------- /config/webpack/client/production.js: -------------------------------------------------------------------------------- 1 | const _ = require("lodash"); 2 | const webpack = require("webpack"); 3 | const cssnext = require("postcss-cssnext"); 4 | const OfflinePlugin = require("offline-plugin"); 5 | const MinifyPlugin = require("uglifyjs-webpack-plugin"); 6 | const StyleLintPlugin = require("stylelint-webpack-plugin"); 7 | const MiniCssExtractPlugin = require("mini-css-extract-plugin"); 8 | const CompressionPlugin = require("compression-webpack-plugin"); 9 | const OptimizeCSSAssetsPlugin = require("optimize-css-assets-webpack-plugin"); 10 | const { clientConfiguration } = require("universal-webpack"); 11 | const config = require("../.."); 12 | 13 | const productionConfig = clientConfiguration( 14 | require("../default-config"), 15 | require("../universal-webpack-settings"), 16 | { 17 | development: false, 18 | useMiniCssExtractPlugin: true 19 | } 20 | ); 21 | 22 | _.merge(productionConfig, { 23 | mode: "production", 24 | devtool: false, 25 | output: { 26 | publicPath: config.path.assets, 27 | filename: "[name].[chunkhash].js", 28 | chunkFilename: "[id].[chunkhash].js" 29 | }, 30 | optimization: { 31 | minimizer: [ 32 | new MinifyPlugin({ 33 | cache: true, 34 | parallel: true, 35 | sourceMap: false 36 | }), 37 | new OptimizeCSSAssetsPlugin({}) 38 | ], 39 | // Keep the runtime chunk separated to enable long term caching 40 | // https://twitter.com/wSokra/status/969679223278505985 41 | runtimeChunk: { 42 | name: "runtime" 43 | } 44 | } 45 | }); 46 | 47 | productionConfig.plugins.push( 48 | new webpack.DefinePlugin({ 49 | "process.env.RUNTIME_ENV": "'client'", 50 | "process.env.SERVER_RENDERING": true 51 | }), 52 | new webpack.LoaderOptionsPlugin({ 53 | minimize: true, 54 | debug: false 55 | }), 56 | new webpack.LoaderOptionsPlugin({ 57 | test: /\.(css|less|scss)$/, 58 | options: { 59 | postcss() { 60 | return [cssnext()]; 61 | } 62 | } 63 | }), 64 | new StyleLintPlugin(), 65 | new MiniCssExtractPlugin({ 66 | filename: "[name].[contenthash].css", 67 | chunkFilename: "[id].[contenthash].css" 68 | }), 69 | new OfflinePlugin({ 70 | safeToUseOptionalCaches: true, 71 | caches: { 72 | main: ["*.js", "*.css"], 73 | additional: [":externals:"], 74 | optional: [":rest:"] 75 | }, 76 | externals: ["*.woff", "*.woff2", "*.eot", "*.ttf"], 77 | relativePaths: false, 78 | ServiceWorker: { 79 | output: "../sw.js", 80 | publicPath: "/sw.js", 81 | events: true 82 | } 83 | }), 84 | new CompressionPlugin() 85 | ); 86 | 87 | module.exports = productionConfig; 88 | -------------------------------------------------------------------------------- /config/webpack/default-config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const webpack = require("webpack"); 3 | const { ROOT } = require("../path-helper"); 4 | const config = require(".."); 5 | 6 | module.exports = { 7 | context: ROOT, 8 | entry: { 9 | app: [path.join(ROOT, config.path.app, "app")] 10 | }, 11 | output: { 12 | path: path.join(ROOT, config.path.publicAssets) 13 | }, 14 | devtool: "source-map", 15 | externals: [], 16 | resolve: { 17 | extensions: [".js", ".jsx"], 18 | modules: [path.resolve(ROOT, "app"), "node_modules"], 19 | alias: { 20 | modernizr$: path.join(ROOT, ".modernizrrc") 21 | } 22 | }, 23 | module: { 24 | rules: [ 25 | { 26 | test: /\.(js|jsx)$/, 27 | exclude: /node_modules/, 28 | use: ["babel-loader", "eslint-loader"] 29 | }, 30 | { 31 | test: /\.modernizrrc$/, 32 | use: ["modernizr-loader"] 33 | }, 34 | { 35 | test: /\.(gif|jpg|jpeg|png|svg|ttf|eot|woff|woff2)(\?v=[0-9]\.[0-9]\.[0-9])?$/, 36 | use: ["file-loader"] 37 | }, 38 | { 39 | test: /\.css$/, 40 | use: [ 41 | "style-loader", 42 | { 43 | loader: "css-loader", 44 | options: { ...config.cssModules, importLoaders: 1 } 45 | }, 46 | "postcss-loader" 47 | ] 48 | }, 49 | { 50 | test: /\.less$/, 51 | use: [ 52 | "style-loader", 53 | { 54 | loader: "css-loader", 55 | options: { ...config.cssModules, importLoaders: 2 } 56 | }, 57 | "postcss-loader", 58 | "less-loader" 59 | ] 60 | }, 61 | { 62 | test: /\.scss$/, 63 | use: [ 64 | "style-loader", 65 | { 66 | loader: "css-loader", 67 | options: { ...config.cssModules, importLoaders: 2 } 68 | }, 69 | "postcss-loader", 70 | "sass-loader" 71 | ] 72 | } 73 | ] 74 | }, 75 | optimization: { 76 | // Automatically split vendor and commons 77 | // https://twitter.com/wSokra/status/969633336732905474 78 | // https://medium.com/webpack/webpack-4-code-splitting-chunk-graph-and-the-splitchunks-optimization-be739a861366 79 | splitChunks: { 80 | chunks: "all", 81 | name: "vendor", 82 | } 83 | }, 84 | plugins: [ 85 | new webpack.LoaderOptionsPlugin({ 86 | test: /\.(js|jsx)$/, 87 | options: { 88 | eslint: { 89 | emitWarning: true 90 | } 91 | } 92 | }) 93 | ] 94 | }; 95 | -------------------------------------------------------------------------------- /config/webpack/server/development.js: -------------------------------------------------------------------------------- 1 | const _ = require("lodash"); 2 | const path = require("path"); 3 | const crypto = require("crypto"); 4 | const webpack = require("webpack"); 5 | const { serverConfiguration } = require("universal-webpack"); 6 | const { ROOT } = require("../../path-helper"); 7 | const config = require("../.."); 8 | 9 | const developmentConfig = serverConfiguration( 10 | require("../default-config"), 11 | require("../universal-webpack-settings") 12 | ); 13 | 14 | _.mergeWith( 15 | developmentConfig, 16 | { 17 | mode: "development", 18 | recordsPath: path.join(ROOT, config.path.tmp, "server-records.json"), 19 | optimization: { 20 | noEmitOnErrors: true 21 | } 22 | }, 23 | (obj1, obj2) => (_.isArray(obj2) ? obj2.concat(obj1) : undefined) 24 | ); 25 | 26 | developmentConfig.plugins.push( 27 | new webpack.DefinePlugin({ 28 | "process.env.RUNTIME_ENV": "'server'", 29 | "process.env.SECRET_KEY": `"${crypto.randomBytes(8).toString("hex")}"`, 30 | "process.env.SERVER_RENDERING": process.env.SERVER_RENDERING || false 31 | }), 32 | new webpack.HotModuleReplacementPlugin() 33 | ); 34 | 35 | module.exports = developmentConfig; 36 | -------------------------------------------------------------------------------- /config/webpack/server/production.js: -------------------------------------------------------------------------------- 1 | const _ = require("lodash"); 2 | const webpack = require("webpack"); 3 | const MinifyPlugin = require("uglifyjs-webpack-plugin"); 4 | const { serverConfiguration } = require("universal-webpack"); 5 | 6 | const productionConfig = serverConfiguration( 7 | require("../default-config"), 8 | require("../universal-webpack-settings") 9 | ); 10 | 11 | _.merge(productionConfig, { 12 | mode: "production" 13 | }); 14 | 15 | productionConfig.plugins.push( 16 | new webpack.LoaderOptionsPlugin({ 17 | minimize: true, 18 | debug: false 19 | }), 20 | new MinifyPlugin({ 21 | cache: true, 22 | parallel: true, 23 | sourceMap: true 24 | }), 25 | new webpack.DefinePlugin({ 26 | "process.env.RUNTIME_ENV": "'server'", 27 | "process.env.SERVER_RENDERING": true 28 | }) 29 | ); 30 | 31 | module.exports = productionConfig; 32 | -------------------------------------------------------------------------------- /config/webpack/universal-webpack-settings.js: -------------------------------------------------------------------------------- 1 | // noinspection WebpackConfigHighlighting 2 | module.exports = { 3 | server: { 4 | input: "./app/server.js", 5 | output: "./build/server.js" 6 | } 7 | }; 8 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | webapp: 5 | container_name: webapp 6 | build: . 7 | ports: 8 | - "3000:3000" 9 | -------------------------------------------------------------------------------- /flow/css-modules.js.flow: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | type CssModuleType = { 3 | [key: string]: string 4 | } 5 | 6 | const emptyCSSModule: CssModuleType = {}; 7 | 8 | export default emptyCSSModule; 9 | -------------------------------------------------------------------------------- /flow/webpack-assets.js.flow: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | const s: string = ""; 3 | 4 | export default s; 5 | -------------------------------------------------------------------------------- /marko.json: -------------------------------------------------------------------------------- 1 | { 2 | "tags-dir": "./app/server/application/templates/helpers" 3 | } 4 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": [ 3 | "app/server/application/apis", 4 | "app/server/application/controllers", 5 | "app/server/domain", 6 | "app/server/infrastructure", 7 | "app/server.js", 8 | "prod-server.js" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "koa-react-isomorphic", 3 | "version": "4.5.0", 4 | "dependencies": { 5 | "babel-runtime": "^6.26.0", 6 | "dataloader": "^1.3.0", 7 | "debug": "^3.1.0", 8 | "faker": "^4.1.0", 9 | "immutability-helper": "^2.7.0", 10 | "immutable": "^3.8.2", 11 | "isomorphic-fetch": "^2.2.1", 12 | "jquery": "^3.3.1", 13 | "koa": "^2.5.1", 14 | "koa-bodyparser": "^4.2.1", 15 | "koa-compress": "^3.0.0", 16 | "koa-conditional-get": "^2.0.0", 17 | "koa-convert": "^1.2.0", 18 | "koa-cors": "0.0.16", 19 | "koa-csrf": "^3.0.6", 20 | "koa-etag": "^3.0.0", 21 | "koa-helmet": "^4.0.0", 22 | "koa-html-minifier2": "^1.0.7", 23 | "koa-logger": "^3.1.0", 24 | "koa-router": "^7.3.0", 25 | "koa-session": "^5.5.1", 26 | "koa-static": "^4.0.3", 27 | "lodash": "^4.17.10", 28 | "marko": "^4.10.0", 29 | "popper.js": "^1.12.9", 30 | "prop-types": "^15.6.0", 31 | "react": "^16.4.0", 32 | "react-dom": "^16.4.0", 33 | "react-helmet": "^5.2.0", 34 | "react-loadable": "^5.4.0", 35 | "react-redux": "^5.0.6", 36 | "react-router": "^3.2.0", 37 | "react-router-redux": "^4.0.8", 38 | "redial": "^0.5.0", 39 | "redux": "^4.0.0", 40 | "redux-actions": "^2.4.0", 41 | "redux-logger": "^3.0.6", 42 | "redux-thunk": "^2.3.0", 43 | "reselect": "^3.0.1", 44 | "rxjs": "^6.2.0" 45 | }, 46 | "devDependencies": { 47 | "babel-core": "^6.26.0", 48 | "babel-eslint": "^8.1.2", 49 | "babel-loader": "^7.1.2", 50 | "babel-plugin-dynamic-import-node": "^1.2.0", 51 | "babel-plugin-transform-export-extensions": "^6.22.0", 52 | "babel-plugin-transform-react-constant-elements": "^6.23.0", 53 | "babel-plugin-transform-react-inline-elements": "^6.22.0", 54 | "babel-plugin-transform-react-remove-prop-types": "^0.4.12", 55 | "babel-plugin-transform-runtime": "^6.23.0", 56 | "babel-preset-env": "^1.6.1", 57 | "babel-preset-react": "^6.24.1", 58 | "babel-preset-stage-0": "^6.24.1", 59 | "bootstrap": "^4.1.1", 60 | "bundle-loader": "^0.5.5", 61 | "caniuse-lite": "^1.0.30000851", 62 | "chokidar": "^2.0.0", 63 | "chokidar-cli": "^1.2.0", 64 | "compression-webpack-plugin": "^1.1.3", 65 | "css-loader": "^0.28.8", 66 | "env-cmd": "^8.0.2", 67 | "enzyme": "^3.3.0", 68 | "enzyme-adapter-react-16": "^1.1.1", 69 | "enzyme-to-json": "^3.3.4", 70 | "eslint": "^4.15.0", 71 | "eslint-config-airbnb": "^16.1.0", 72 | "eslint-config-prettier": "^2.9.0", 73 | "eslint-import-resolver-webpack": "^0.10.0", 74 | "eslint-loader": "^2.0.0", 75 | "eslint-plugin-flowtype": "^2.49.3", 76 | "eslint-plugin-import": "^2.12.0", 77 | "eslint-plugin-jest": "^21.17.0", 78 | "eslint-plugin-jsx-a11y": "^6.0.3", 79 | "eslint-plugin-react": "^7.9.1", 80 | "exports-loader": "^0.7.0", 81 | "expose-loader": "^0.7.5", 82 | "file-loader": "^1.1.6", 83 | "flow-bin": "^0.74.0", 84 | "font-awesome": "^4.7.0", 85 | "identity-obj-proxy": "^3.0.0", 86 | "imports-loader": "^0.8.0", 87 | "jest": "^23.1.0", 88 | "jest-environment-jsdom": "^23.1.0", 89 | "jest-environment-jsdom-global": "^1.1.0", 90 | "json-loader": "^0.5.7", 91 | "less": "^3.0.4", 92 | "less-loader": "^4.0.5", 93 | "mini-css-extract-plugin": "^0.4.0", 94 | "modernizr": "^3.5.0", 95 | "modernizr-loader": "^1.0.1", 96 | "nock": "^9.3.2", 97 | "node-sass": "^4.9.0", 98 | "nodemon": "^1.17.5", 99 | "offline-plugin": "^5.0.5", 100 | "optimize-css-assets-webpack-plugin": "^4.0.2", 101 | "postcss": "^6.0.22", 102 | "postcss-cssnext": "^3.0.2", 103 | "postcss-loader": "^2.1.5", 104 | "prettier": "^1.13.5", 105 | "react-hot-loader": "^4.3.1", 106 | "react-test-renderer": "^16.4.0", 107 | "redux-mock-store": "^1.4.0", 108 | "rimraf": "^2.6.2", 109 | "sass-loader": "^7.0.3", 110 | "script-loader": "^0.7.2", 111 | "source-map-support": "^0.5.6", 112 | "style-loader": "^0.21.0", 113 | "stylelint": "^9.2.1", 114 | "stylelint-config-css-modules": "^1.1.0", 115 | "stylelint-config-standard": "^18.0.0", 116 | "stylelint-webpack-plugin": "^0.10.5", 117 | "supertest": "^3.1.0", 118 | "uglifyjs-webpack-plugin": "^1.2.5", 119 | "universal-webpack": "^0.6.6", 120 | "url-loader": "^1.0.1", 121 | "webpack": "^4.12.0", 122 | "webpack-cli": "^3.0.3", 123 | "webpack-dev-server": "^3.1.4" 124 | }, 125 | "engines": { 126 | "node": ">=8.0.0", 127 | "npm": ">=5.0.0" 128 | }, 129 | "files": [ 130 | "app", 131 | "build", 132 | "config", 133 | "public", 134 | "scripts", 135 | "Dockerfile", 136 | "Procfile", 137 | "compiler.js", 138 | "docker-compose.yml", 139 | "marko.json", 140 | "nodemon.json", 141 | "package.json", 142 | "prod-server.json" 143 | ], 144 | "jest": { 145 | "testRegex": "/__tests__/.*\\.(js|jsx)$", 146 | "setupFiles": [ 147 | "./shim.js", 148 | "./test-setup.js" 149 | ], 150 | "snapshotSerializers": [ 151 | "enzyme-to-json/serializer" 152 | ], 153 | "moduleNameMapper": { 154 | "\\.(css|less|sass|scss)$": "identity-obj-proxy", 155 | "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/__mocks__/fileMock.js", 156 | "^client(.*)$": "/app/client$1", 157 | "^server(.*)$": "/app/server$1", 158 | "^share(.*)$": "/app/share$1" 159 | } 160 | }, 161 | "keywords": [ 162 | "es6", 163 | "isomorphic", 164 | "jest", 165 | "koa", 166 | "ramda", 167 | "react", 168 | "redux", 169 | "relay", 170 | "rxjs", 171 | "universal" 172 | ], 173 | "license": "MIT", 174 | "scripts": { 175 | "backend-build": "./scripts/backend-build.sh", 176 | "backend-watch": "./scripts/backend-watch.sh", 177 | "build": "./scripts/build.sh", 178 | "clean": "node ./scripts/clean.js", 179 | "compile-templates": "./scripts/compile-templates.sh", 180 | "debug": "./scripts/debug.sh", 181 | "dev": "./scripts/dev.sh", 182 | "flow-status": "flow status; test $? -eq 0 -o $? -eq 2", 183 | "flow-stop": "flow stop", 184 | "flow-watch": "yarn run flow-status && chokidar './app/**/*.js' './app/**/*.jsx' -c 'yarn run flow-status'", 185 | "frontend-build": "./scripts/frontend-build.sh", 186 | "frontend-watch": "./scripts/frontend-watch.sh", 187 | "start": "./scripts/start.sh", 188 | "test": "env-cmd --no-override ./scripts/env/test jest", 189 | "watch": "./scripts/watch.sh" 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | "postcss-cssnext": {} 4 | } 5 | }; 6 | -------------------------------------------------------------------------------- /prod-server.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const { ROOT } = require("./config/path-helper"); 3 | const startServer = require("universal-webpack/server"); 4 | 5 | // expose node require function 6 | global.nodeRequire = require; 7 | 8 | // enable source-map support 9 | require("source-map-support").install({ environment: "node" }); 10 | 11 | // enable hot-reload for template on development 12 | if (process.env.NODE_ENV === "development") { 13 | require("marko/hot-reload").enable(); 14 | require("chokidar") 15 | .watch("./app/server/application/templates/**/*.marko") 16 | .on("change", filename => { 17 | require("marko/hot-reload").handleFileModified(path.join(ROOT, filename)); 18 | }); 19 | } 20 | 21 | if (process.env.NODE_DEBUGGER) { 22 | require("babel-core/register"); 23 | require("./app/server").default(); 24 | } else { 25 | startServer( 26 | require("./config/webpack/default-config"), 27 | require("./config/webpack/universal-webpack-settings") 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 75 | 76 | 77 |
78 |

Not found :(

79 |

Sorry, but the page you were trying to view does not exist.

80 |

It looks like this was the result of either:

81 |
    82 |
  • a mistyped address
  • 83 |
  • an out-of-date link
  • 84 |
85 |
86 | 87 | 88 | -------------------------------------------------------------------------------- /public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 75 | 76 | 77 |
78 |

The change you wanted was rejected.

79 |
80 |

Maybe you tried to change something you didn't have access to.

81 |
82 |

If you are the application owner check the logs for more information

83 |
84 | 85 | 86 | -------------------------------------------------------------------------------- /public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 75 | 76 | 77 |
78 |

We're sorry, but something went wrong. :(

79 |

If you are the application owner check the logs for more information

80 |
81 | 82 | 83 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hung-phan/koa-react-isomorphic/b53016b019c71f7291a5b58a8441b8a37ff1a51d/public/favicon.ico -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Application", 3 | "name": "Koa React Isomorphic", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | } 10 | ], 11 | "start_url": "/", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | # 3 | # To ban all spiders from the entire site uncomment the next two lines: 4 | # User-agent: * 5 | # Disallow: / 6 | -------------------------------------------------------------------------------- /redux-structure.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hung-phan/koa-react-isomorphic/b53016b019c71f7291a5b58a8441b8a37ff1a51d/redux-structure.png -------------------------------------------------------------------------------- /scripts/backend-build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | source $(dirname "$0")/env/backend.sh 4 | source $(dirname "$0")/env/production.sh 5 | webpack --config config/webpack/server/production.js 6 | -------------------------------------------------------------------------------- /scripts/backend-watch.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | source $(dirname "$0")/env/backend.sh 4 | source $(dirname "$0")/env/development.sh 5 | webpack --watch --config config/webpack/server/development.js 6 | -------------------------------------------------------------------------------- /scripts/build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | npm run clean 4 | npm run compile-templates & npm run frontend-build & npm run backend-build & wait 5 | -------------------------------------------------------------------------------- /scripts/clean.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const rimraf = require("rimraf"); 3 | const configuration = require("../config"); 4 | const { ROOT } = require("../config/path-helper"); 5 | 6 | const removeDirectory = glob => { 7 | rimraf(glob, err => { 8 | if (err) { 9 | console.error(err); // eslint-disable-line 10 | } else { 11 | console.log(`Deleted files/folders: ${glob}`); // eslint-disable-line 12 | } 13 | }); 14 | }; 15 | 16 | removeDirectory(path.join(ROOT, configuration.path.build)); 17 | removeDirectory(path.join(ROOT, configuration.path.publicAssets)); 18 | removeDirectory(path.join(ROOT, configuration.path.publicAssets, "../sw.js")); 19 | removeDirectory(path.join(ROOT, configuration.path.publicAssets, "../sw.js.gz")); 20 | -------------------------------------------------------------------------------- /scripts/compile-templates.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | markoc ./app/server/application/templates 4 | -------------------------------------------------------------------------------- /scripts/debug.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | source $(dirname "$0")/env/debug.sh 4 | node --inspect prod-server.js 5 | -------------------------------------------------------------------------------- /scripts/dev.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | source $(dirname "$0")/env/development.sh 4 | nodemon prod-server.js 5 | -------------------------------------------------------------------------------- /scripts/env/backend.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [ -z "${RUNTIME_ENV}" ]; then 4 | export RUNTIME_ENV=server 5 | fi 6 | 7 | if [ -z "${RUN_MODE}" ]; then 8 | export RUN_MODE=commonjs 9 | fi 10 | -------------------------------------------------------------------------------- /scripts/env/debug.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [ -z "${NODE_ENV}" ]; then 4 | export NODE_ENV=production 5 | fi 6 | 7 | if [ -z "${PORT}" ]; then 8 | export PORT=3000 9 | fi 10 | 11 | if [ -z "${NODE_DEBUGGER}" ]; then 12 | export NODE_DEBUGGER=1 13 | fi 14 | 15 | if [ -z "${RUNTIME_ENV}" ]; then 16 | export RUNTIME_ENV=server 17 | fi 18 | 19 | if [ -z "${SECRET_KEY}" ]; then 20 | export SECRET_KEY=secret 21 | fi 22 | 23 | if [ -z "${SERVER_RENDERING}" ]; then 24 | export SERVER_RENDERING=0 25 | fi 26 | -------------------------------------------------------------------------------- /scripts/env/development.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [ -z "${NODE_ENV}" ]; then 4 | export NODE_ENV=development 5 | fi 6 | 7 | if [ -z "${PORT}" ]; then 8 | export PORT=3000 9 | fi 10 | -------------------------------------------------------------------------------- /scripts/env/frontend.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [ -z "${RUNTIME_ENV}" ]; then 4 | export RUNTIME_ENV=client 5 | fi 6 | 7 | if [ -z "${RUN_MODE}" ]; then 8 | export RUN_MODE=es 9 | fi 10 | -------------------------------------------------------------------------------- /scripts/env/production.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [ -z "${NODE_ENV}" ]; then 4 | export NODE_ENV=production 5 | fi 6 | 7 | if [ -z "${PORT}" ]; then 8 | export PORT=3000 9 | fi 10 | -------------------------------------------------------------------------------- /scripts/env/test: -------------------------------------------------------------------------------- 1 | NODE_ENV=test 2 | PORT=3001 3 | SECRET_KEY=secret 4 | -------------------------------------------------------------------------------- /scripts/frontend-build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | source $(dirname "$0")/env/frontend.sh 4 | source $(dirname "$0")/env/production.sh 5 | webpack --config config/webpack/client/production.js 6 | -------------------------------------------------------------------------------- /scripts/frontend-watch.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | source $(dirname "$0")/env/frontend.sh 4 | source $(dirname "$0")/env/development.sh 5 | webpack-dev-server --config config/webpack/client/development.js 6 | -------------------------------------------------------------------------------- /scripts/preset-js.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | "babel-preset-env", 5 | { 6 | targets: 7 | process.env.RUNTIME_ENV === "client" 8 | ? { browsers: ["> 1%", "last 2 versions", "Firefox ESR"] } 9 | : { node: "current" }, 10 | useBuiltIns: true, 11 | modules: process.env.RUN_MODE === "es" ? false : "commonjs" 12 | } 13 | ] 14 | ], 15 | plugins: [ 16 | [ 17 | "transform-runtime", 18 | { 19 | polyfill: false, 20 | useBuiltIns: true, 21 | useESModules: true 22 | } 23 | ] 24 | ] 25 | }; 26 | -------------------------------------------------------------------------------- /scripts/start.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | source $(dirname "$0")/env/production.sh 4 | node prod-server.js 5 | -------------------------------------------------------------------------------- /scripts/watch.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | npm run clean 4 | npm run frontend-watch & npm run backend-watch 5 | -------------------------------------------------------------------------------- /shim.js: -------------------------------------------------------------------------------- 1 | global.nodeRequire = require; 2 | global.requestAnimationFrame = cb => { 3 | setTimeout(cb, 0); 4 | }; 5 | -------------------------------------------------------------------------------- /test-setup.js: -------------------------------------------------------------------------------- 1 | import { configure } from "enzyme"; 2 | import Adapter from "enzyme-adapter-react-16"; 3 | 4 | configure({ adapter: new Adapter() }); 5 | --------------------------------------------------------------------------------