├── .github └── workflows │ └── e2e.yml ├── .gitignore ├── .travis.yml ├── Makefile ├── README.md ├── cypress.config.js ├── cypress └── e2e │ ├── 00-page.cy.js │ └── 10-app.cy.js ├── nodemon.json ├── package.json ├── src ├── client │ └── app.js ├── server │ ├── api.js │ ├── counter.js │ ├── index.js │ └── webpack.js └── shared │ ├── actions.js │ ├── apps.js │ ├── components.js │ ├── constants.js │ ├── fetch.js │ ├── reducers.js │ └── store.js ├── views └── index.html ├── webpack.config.dev.js ├── webpack.config.js └── yarn.lock /.github/workflows/e2e.yml: -------------------------------------------------------------------------------- 1 | name: End-to-end tests 2 | on: [push] 3 | jobs: 4 | cypress-run: 5 | runs-on: ubuntu-20.04 6 | steps: 7 | - name: Checkout 8 | uses: actions/checkout@v2 9 | - name: Cypress run 10 | uses: cypress-io/github-action@v4 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lib 2 | node_modules 3 | public 4 | *.log 5 | cypress/fixtures 6 | cypress/plugins 7 | cypress/support 8 | cypress/screenshots 9 | cypress/video 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - 8 5 | 6 | cache: 7 | directories: 8 | - ~/.npm 9 | - node_modules 10 | 11 | install: 12 | - yarn install && yarn run cypress:install 13 | 14 | before_script: 15 | - yarn run start -- && sleep 10 & 16 | 17 | script: 18 | - sleep 10 && yarn run cypress:run 19 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | BIN=./node_modules/.bin 2 | 3 | SRC_JS=$(shell find src -name '*.js') 4 | LIB_JS=$(patsubst src/%.js, lib/%.js, $(SRC_JS)) 5 | 6 | all: build 7 | 8 | #dev:; @NODE_ENV=development $(MAKE) -j5 webpack-server dev-server watch 9 | dev-server: $(LIB_JS); $(BIN)/nodemon 10 | webpack-server: $(LIB_JS); node ./lib/server/webpack 11 | watch:; $(BIN)/babel -d lib --watch src --plugins=react-hot-loader/babel,@babel/proposal-class-properties --presets=@babel/react,@babel/env 12 | 13 | build: js webpack 14 | webpack: public/js/app.js 15 | clean:; rm -rf public lib 16 | 17 | public/js/app.js: $(SRC_JS); $(BIN)/webpack 18 | js: $(LIB_JS) 19 | $(LIB_JS): lib/%.js: src/%.js 20 | mkdir -p $(dir $@) 21 | $(BIN)/babel --plugins=@babel/proposal-class-properties --presets=@babel/react,@babel/env $< -o $@ 22 | 23 | .PHONY: all build clean dev dev-server js watch webpack webpack-server 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | #### changelog 2 | 3 | - Remove deprecated services and update dependencies - _August 10 2022_ 4 | - Upgraded to the latest versions. - _May 30 2019_ 5 | - Upgraded _everything_ to the latest versions. - _April 20 2019_ 6 | - Updated to the latest **React 16** and **Redux 4** - _June 23 2018_ 7 | - Added Cypress.io tests with Travis CI - _June 23 2018_ 8 | 9 | # What is this? 10 | 11 | Bundled with [Redux](https://redux.js.org/) is an example 12 | application called 13 | [counter](https://github.com/reduxjs/redux/tree/master/examples/counter). 14 | 15 | This is an isomorphic port of the counter app using [Koa](http://koajs.com/). It uses [isomorphic-fectch](https://github.com/matthew-andrews/isomorphic-fetch) to load the initial state on the server, and update the state from the client. Data retrieved and set via `POST`s and `GET`s to and from the API [src/server/api.js](https://github.com/khtdr/redux-react-koa-isomorphic-counter-example/blob/master/src/server/api.js). 16 | 17 | # Installing 18 | 19 | ```bash 20 | git clone git@github.com:khtdr/redux-react-koa-isomorphic-counter-example.git 21 | cd redux-react-koa-isomorphic-counter-example.git 22 | yarn install 23 | ``` 24 | 25 | # Running 26 | 27 | To run a compiled production version: 28 | 29 | ```bash 30 | yarn start 31 | ``` 32 | 33 | _If you have errors, make sure you are using an **LTS node version** and try again._ 34 | 35 | # Using 36 | 37 | - Open [http://localhost:3000](http://localhost:3000) 38 | - Press the buttons a few times 39 | - Reload page and inspect source to see the value in the HTML source 40 | 41 | # Development 42 | 43 | To run a development version with hot reloading: 44 | 45 | ```bash 46 | yarn run dev 47 | ``` 48 | 49 | To run dev + Cypress tests: 50 | 51 | ```bash 52 | yarn run dev & 53 | yarn run cypress:open 54 | ``` 55 | -------------------------------------------------------------------------------- /cypress.config.js: -------------------------------------------------------------------------------- 1 | const { defineConfig } = require('cypress') 2 | 3 | module.exports = defineConfig({ 4 | video: false, 5 | e2e: { 6 | // We've imported your old cypress plugins here. 7 | // You may want to clean this up later by importing these. 8 | setupNodeEvents(on, config) { 9 | return require('./cypress/plugins/index.js')(on, config) 10 | }, 11 | baseUrl: 'http://localhost:3000', 12 | }, 13 | }) 14 | -------------------------------------------------------------------------------- /cypress/e2e/00-page.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | context('Page', () => { 4 | beforeEach(() => { 5 | cy.visit('http://localhost:3000') 6 | }) 7 | it('found the expected page title', () => { 8 | cy.title().should('equal', 'Redux Counter Example App') 9 | }) 10 | }) 11 | -------------------------------------------------------------------------------- /cypress/e2e/10-app.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | const PRODUCT_ITEM_COUNT = 3 4 | 5 | context('App', () => { 6 | beforeEach(() => { 7 | cy.visit('http://localhost:3000') 8 | cy.get('button').last().click() 9 | }) 10 | it('starts with a count of 0', () => { 11 | cy.get('code').contains('0') 12 | }) 13 | it('increments with each click', () => { 14 | cy.get('button').first().click() 15 | cy.get('code').contains('1') 16 | cy.get('button').first().click() 17 | cy.get('code').contains('2') 18 | cy.get('button').first().click() 19 | cy.get('code').contains('3') 20 | cy.get('button').first().click() 21 | cy.get('code').contains('4') 22 | }) 23 | it('decrements with each click', () => { 24 | cy.get('button').first().click().click().click().click() 25 | cy.get('code').contains('4') 26 | cy.get('button').eq(1).click() 27 | cy.get('code').contains('3') 28 | cy.get('button').eq(1).click() 29 | cy.get('code').contains('2') 30 | cy.get('button').eq(1).click() 31 | cy.get('code').contains('1') 32 | cy.get('button').eq(1).click() 33 | cy.get('code').contains('0') 34 | }) 35 | it('increments oddly as designed', () => { 36 | cy.get('code').contains('0') 37 | cy.get('button').eq(2).click() 38 | cy.get('code').contains('0') 39 | cy.get('button').first().click() 40 | cy.get('code').contains('1') 41 | cy.get('button').eq(2).click() 42 | cy.get('code').contains('2') 43 | cy.get('button').eq(2).click() 44 | cy.get('code').contains('2') 45 | cy.get('button').first().click() 46 | cy.get('code').contains('3') 47 | cy.get('button').eq(2).click() 48 | cy.get('code').contains('4') 49 | }) 50 | it('resets when clicked', () => { 51 | cy.get('button').first().click().click().click().click() 52 | cy.get('code').contains('4') 53 | cy.get('button').eq(3).click() 54 | cy.get('code').contains('0') 55 | }) 56 | it('persists and is rerendered', () => { 57 | cy.get('button').first().click() 58 | cy.get('code').contains('1') 59 | cy.reload(true) 60 | cy.get('code').contains('1') 61 | }) 62 | }) 63 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { "NODE_ENV": "development" }, 3 | "ext": "js html", 4 | "watch": [ "lib/", "views/" ] 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "redux-react-koa-isomorphic-counter-example", 4 | "version": "3.0.0", 5 | "description": "Isomorphic port of the redux counter app", 6 | "main": "lib/server", 7 | "scripts": { 8 | "cypress:install": "cypress install", 9 | "cypress:run": "cypress run --browser chrome", 10 | "cypress:open": "cypress open", 11 | "prebuild": "make clean", 12 | "build": "NODE_ENV=production make -j5 build", 13 | "prestart": "yarn run build", 14 | "start": "NODE_ENV=production node lib/server", 15 | "dev": "NODE_ENV=development make -j5 webpack-server dev-server watch" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "https://github.com/khtdr/redux-react-koa-isomorphic-counter-example.git" 20 | }, 21 | "keywords": [ 22 | "react", 23 | "reactjs", 24 | "hot", 25 | "reload", 26 | "hmr", 27 | "live", 28 | "edit", 29 | "webpack", 30 | "redux", 31 | "koa", 32 | "server-side", 33 | "ssr", 34 | "cypress-io" 35 | ], 36 | "license": "MIT", 37 | "bugs": { 38 | "url": "https://github.com/khtdr/redux-react-koa-isomorphic-counter-example/issues" 39 | }, 40 | "homepage": "https://github.com/khtdr/redux-react-koa-isomorphic-counter-example", 41 | "dependencies": { 42 | "@babel/polyfill": "^7.4.4", 43 | "@hot-loader/react-dom": "17.0.2", 44 | "isomorphic-fetch": "^3.0.0", 45 | "koa": "^2.7.0", 46 | "koa-bodyparser": "^4.2.1", 47 | "koa-compose": "^4.1.0", 48 | "koa-route": "^3.2.0", 49 | "koa-static": "^5.0.0", 50 | "nunjucks": "^3.1.3", 51 | "prop-types": "^15.6.2", 52 | "react": "^17.0.2", 53 | "react-dom": "npm:@hot-loader/react-dom@17.0.2", 54 | "react-hot-loader": "^4.8.8", 55 | "react-redux": "^8.0.2", 56 | "redux": "^4.0.0" 57 | }, 58 | "devDependencies": { 59 | "@babel/cli": "^7.4.4", 60 | "@babel/core": "^7.4.5", 61 | "@babel/plugin-proposal-class-properties": "^7.4.4", 62 | "@babel/preset-env": "^7.4.5", 63 | "@babel/preset-react": "^7.0.0", 64 | "babel-loader": "^8.0.6", 65 | "cypress": "^10.4.0", 66 | "nodemon": "^2.0.19", 67 | "webpack": "^4.32.2", 68 | "webpack-cli": "^3.3.2", 69 | "webpack-dev-server": "^3.4.1" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/client/app.js: -------------------------------------------------------------------------------- 1 | window.__CLIENT__ = true; 2 | window.__SERVER__ = false; 3 | 4 | require('@babel/polyfill'); 5 | 6 | import React from 'react'; 7 | import ReactDOM from 'react-dom'; 8 | import { Provider } from 'react-redux'; 9 | 10 | import create from '../shared/store'; 11 | import reducers from '../shared/reducers'; 12 | import CounterApp from '../shared/apps'; 13 | 14 | const state = window.__initialState; 15 | const store = create(reducers, state); 16 | 17 | ReactDOM.hydrate( 18 | 19 | 20 | , 21 | document.getElementById('App') 22 | ); 23 | -------------------------------------------------------------------------------- /src/server/api.js: -------------------------------------------------------------------------------- 1 | import Koa from 'koa'; 2 | import bodyparser from 'koa-bodyparser'; 3 | import route from 'koa-route'; 4 | import compose from 'koa-compose'; 5 | 6 | let server_count = 0; 7 | 8 | const app = new Koa() 9 | app 10 | .use(bodyparser()) 11 | .use(route.get('/api/count', ctx => { 12 | ctx.body = server_count; 13 | })) 14 | .use(route.post('/api/count/inc', ctx => { 15 | ctx.body = ++server_count; 16 | })) 17 | .use(route.post('/api/count/dec', ctx => { 18 | ctx.body = --server_count; 19 | })) 20 | .use(route.post('/api/count/reset', ctx => { 21 | server_count = 0; 22 | ctx.body = server_count; 23 | })) 24 | .use(route.get('/favicon.ico', () => { 25 | // ignore this request 26 | })) 27 | ; 28 | 29 | export default function () { 30 | return compose(app.middleware); 31 | } 32 | 33 | -------------------------------------------------------------------------------- /src/server/counter.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOMServer from 'react-dom/server'; 3 | import nunjucks from 'nunjucks'; 4 | import { Provider } from 'react-redux'; 5 | 6 | import create from '../shared/store'; 7 | import reducers from '../shared/reducers'; 8 | import { loadCounter } from '../shared/actions'; 9 | import CounterApp from '../shared/apps'; 10 | 11 | nunjucks.configure('views', { autoescape: true }); 12 | 13 | export default function counter() { 14 | return async function (ctx, next) { 15 | if (ctx.url != '/') return next() 16 | const store = create(reducers); 17 | await store.dispatch(loadCounter()); 18 | var state = store.getState(); 19 | 20 | const appString = ReactDOMServer.renderToString( 21 | 22 | 23 | 24 | ); 25 | 26 | ctx.body = nunjucks.render('index.html', { 27 | appString, 28 | initialState:JSON.stringify(state), 29 | env:process.env 30 | }); 31 | }; 32 | } 33 | 34 | -------------------------------------------------------------------------------- /src/server/index.js: -------------------------------------------------------------------------------- 1 | global.__CLIENT__ = false; 2 | global.__SERVER__ = true; 3 | 4 | import '@babel/polyfill'; 5 | import Koa from 'koa'; 6 | 7 | const app = new Koa(); 8 | export default app; 9 | 10 | import serve from 'koa-static'; 11 | app.use(serve('public')); 12 | 13 | import api from './api'; 14 | app.use(api()); 15 | 16 | import counter from './counter'; 17 | app.use(counter()); 18 | 19 | app.listen(3000); 20 | console.log('http://localhost:3000'); 21 | -------------------------------------------------------------------------------- /src/server/webpack.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | //import 'babel/polyfill'; 4 | 5 | import webpack from 'webpack'; 6 | import WebpackDevServer from 'webpack-dev-server'; 7 | import config from '../../webpack.config.dev'; 8 | 9 | new WebpackDevServer(webpack(config), { 10 | publicPath: config.output.publicPath, 11 | hot: true, 12 | historyApiFallback: true, 13 | stats: { colors: true }, 14 | headers: { 15 | "Access-Control-Allow-Origin": "*", 16 | "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS", 17 | "Access-Control-Allow-Headers": "X-Requested-With, content-type, Authorization" 18 | } 19 | }).listen(3001, '0.0.0.0', function (err) { 20 | if (err) { console.log(err); } 21 | console.log('Listening at localhost:3001'); 22 | }); 23 | -------------------------------------------------------------------------------- /src/shared/actions.js: -------------------------------------------------------------------------------- 1 | import { get, post } from './fetch'; 2 | import { UPDATE_COUNTER } from './constants'; 3 | 4 | export function increment() { 5 | return { 6 | type: UPDATE_COUNTER, 7 | promise: post('/api/count/inc') 8 | }; 9 | } 10 | 11 | export function decrement() { 12 | return { 13 | type: UPDATE_COUNTER, 14 | promise: post('/api/count/dec') 15 | }; 16 | } 17 | 18 | export function loadCounter() { 19 | return { 20 | type: UPDATE_COUNTER, 21 | promise: get('/api/count') 22 | }; 23 | } 24 | 25 | export function incrementIfOdd(counter) { 26 | if (counter % 2 === 1) { 27 | return increment(); 28 | } 29 | return { type: 'NOOP' }; 30 | } 31 | 32 | export function reset() { 33 | return { 34 | type: UPDATE_COUNTER, 35 | promise: post('/api/count/reset') 36 | }; 37 | } 38 | -------------------------------------------------------------------------------- /src/shared/apps.js: -------------------------------------------------------------------------------- 1 | import { hot } from 'react-hot-loader/root'; 2 | import React from 'react'; 3 | import { bindActionCreators } from 'redux'; 4 | import { connect } from 'react-redux'; 5 | import Counter from './components'; 6 | import * as actions from './actions'; 7 | 8 | 9 | const App = connect(state => ({ 10 | counter: state.counter 11 | }))(props => { 12 | const { dispatch } = props; 13 | const creators = bindActionCreators(actions, dispatch); 14 | return ; 15 | }); 16 | 17 | export default hot(App); 18 | -------------------------------------------------------------------------------- /src/shared/components.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | export default class Counter extends React.Component { 5 | static propTypes = { 6 | increment: PropTypes.func.isRequired, 7 | incrementIfOdd: PropTypes.func.isRequired, 8 | decrement: PropTypes.func.isRequired, 9 | reset: PropTypes.func.isRequired, 10 | counter: PropTypes.number.isRequired 11 | }; 12 | 13 | render() { 14 | return ( 15 | <> 16 |

17 | Current Value: {this.props.counter} 18 |

19 |
20 | actions 21 | 22 | {' '} 23 | 24 | {' '} 25 | 28 | {' '} 29 | 30 |
31 | 32 | ); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/shared/constants.js: -------------------------------------------------------------------------------- 1 | export const UPDATE_COUNTER = 'UPDATE_COUNTER'; 2 | -------------------------------------------------------------------------------- /src/shared/fetch.js: -------------------------------------------------------------------------------- 1 | import 'isomorphic-fetch'; 2 | 3 | export async function get (path) { 4 | const url = __SERVER__? `http://localhost:3000${path}`: path; 5 | return fetch(url, {method:'get'}).then(res => res.json()); 6 | } 7 | 8 | export async function post (path) { 9 | const url = __SERVER__? `http://localhost:3000${path}`: path; 10 | return fetch(url, {method:'post'}).then(res => res.json()); 11 | } 12 | -------------------------------------------------------------------------------- /src/shared/reducers.js: -------------------------------------------------------------------------------- 1 | import { UPDATE_COUNTER } from './constants'; 2 | 3 | export default { 4 | counter: function (init=0, {type, count}) { 5 | switch (type) { 6 | case UPDATE_COUNTER: 7 | return count; 8 | default: 9 | return init; 10 | } 11 | } 12 | } 13 | 14 | -------------------------------------------------------------------------------- /src/shared/store.js: -------------------------------------------------------------------------------- 1 | import { createStore, combineReducers, applyMiddleware } from 'redux'; 2 | 3 | export default function create(reducers, initialState) { 4 | return createStore( 5 | combineReducers(reducers), 6 | initialState, 7 | applyMiddleware(thunk) 8 | ); 9 | } 10 | 11 | function thunk ({ dispatch, getState }) { 12 | return next => ({ promise, ...rest }) => { 13 | if (!promise) { 14 | return next({ ...rest }); 15 | } else { 16 | return promise.then( 17 | res => next({ ...rest, count:res }), 18 | err => console.log(err) 19 | ); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /views/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Redux Counter Example App 7 | 8 | 9 | 10 | 11 |
{{ appString | safe }}
12 | 13 | 14 | {% if env.NODE_ENV == 'development' %} 15 | 16 | {% else %} 17 | 18 | {% endif %} 19 | 20 | 21 | -------------------------------------------------------------------------------- /webpack.config.dev.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var path = require('path'); 4 | var webpack = require('webpack'); 5 | 6 | module.exports = { 7 | mode: 'development', 8 | devtool: 'inline-source-map', 9 | entry: [ 10 | 'webpack-dev-server/client?http://localhost:3001', 11 | 'webpack/hot/only-dev-server', 12 | './src/client/app' 13 | ], 14 | output: { 15 | path: path.join(__dirname, '/public/js/'), 16 | filename: 'app.js', 17 | publicPath: 'http://localhost:3001/js/' 18 | }, 19 | plugins: [ 20 | new webpack.HotModuleReplacementPlugin(), 21 | new webpack.DefinePlugin({ 22 | 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV) 23 | }) 24 | ], 25 | resolve: { 26 | extensions: ['.js'] 27 | }, 28 | module: { 29 | rules: [{ 30 | test: /\.jsx?$/, 31 | loaders: ['react-hot-loader/webpack', 'babel-loader?plugins[]=@babel/proposal-class-properties&presets[]=@babel/react&presets[]=@babel/env'], 32 | exclude: /node_modules/ 33 | }] 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var path = require('path'); 4 | var webpack = require('webpack'); 5 | 6 | module.exports = { 7 | mode: 'production', 8 | entry: './src/client/app', 9 | output: { 10 | path: path.join(__dirname, '/public/js'), 11 | filename: 'app.min.js', 12 | publicPath: '/js/' 13 | }, 14 | plugins: [ 15 | new webpack.DefinePlugin({ 16 | 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV), 17 | }) 18 | ], 19 | resolve: { 20 | extensions: ['.js'] 21 | }, 22 | module: { 23 | rules: [{ 24 | test: /\.jsx?$/, 25 | loaders: ['babel-loader?plugins[]=@babel/proposal-class-properties&presets[]=@babel/react&presets[]=@babel/env'], 26 | exclude: /node_modules/ 27 | }] 28 | } 29 | }; 30 | --------------------------------------------------------------------------------