├── .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 |
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 |
--------------------------------------------------------------------------------