├── .gitignore
├── .travis.yml
├── tests
├── files
│ └── src
│ │ └── client
│ │ ├── index.html
│ │ ├── App.jsx
│ │ ├── index.js
│ │ └── reducer.js
├── cli.test.js
└── middleware.test.js
├── templates
├── .eslintrc.json
└── tiny-todos
│ └── src
│ ├── client
│ ├── index.html
│ ├── reducer.js
│ ├── index.js
│ └── App.jsx
│ └── server
│ └── index.js
├── lib
├── app-emulation.js
├── webpack-prod-config.js
├── webpack-dev-config.js
├── cli.js
├── webpack-base-config.js
└── main.js
├── LICENSE
├── CHANGELOG.md
├── README.md
└── package.json
/.gitignore:
--------------------------------------------------------------------------------
1 | *~
2 | *.swp
3 | npm-debug.log
4 | node_modules
5 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js:
3 | - 6
4 | - 7
5 | - 8
6 |
--------------------------------------------------------------------------------
/tests/files/src/client/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/templates/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "rules": {
3 | "import/no-unresolved": 0,
4 | "import/no-extraneous-dependencies": 0
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/tests/files/src/client/App.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export default () => (
4 |
5 |
Hello
6 |
7 | );
8 |
--------------------------------------------------------------------------------
/templates/tiny-todos/src/client/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Tiny TODOs
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/templates/tiny-todos/src/server/index.js:
--------------------------------------------------------------------------------
1 | const express = require('express');
2 |
3 | const app = express();
4 |
5 | app.use(require('express-react-redux')());
6 |
7 | app.listen(process.env.PORT || 3000);
8 |
--------------------------------------------------------------------------------
/tests/files/src/client/index.js:
--------------------------------------------------------------------------------
1 | /* eslint-env browser */
2 |
3 | import React from 'react';
4 | import { render } from 'react-dom';
5 |
6 | import App from './App';
7 |
8 | render(React.createElement(App), document.getElementById('app'));
9 |
--------------------------------------------------------------------------------
/tests/files/src/client/reducer.js:
--------------------------------------------------------------------------------
1 | export default (state = { todos: [] }, action) => {
2 | if (action.type === 'ADD_TODO') {
3 | return {
4 | ...state,
5 | todos: [...state.todos, action.todo],
6 | };
7 | }
8 | return state;
9 | };
10 |
--------------------------------------------------------------------------------
/templates/tiny-todos/src/client/reducer.js:
--------------------------------------------------------------------------------
1 | export default (state = { todos: [] }, action) => {
2 | if (action.type === 'ADD_TODO') {
3 | return {
4 | ...state,
5 | todos: [...state.todos, action.todo],
6 | };
7 | }
8 | return state;
9 | };
10 |
--------------------------------------------------------------------------------
/lib/app-emulation.js:
--------------------------------------------------------------------------------
1 | // emulating express middleware
2 | const app = {
3 | stack: [],
4 | use: func => app.stack.push(func),
5 | get: (url, func) => app.stack.push((req, res, next) => {
6 | if (req.method === 'get' && req.url === url) {
7 | func(req, res, next);
8 | } else {
9 | next();
10 | }
11 | }),
12 | handle: (index, req, res, next) => {
13 | if (app.stack[index]) {
14 | app.stack[index](req, res, err =>
15 | (err ? next(err) : app.handle(index + 1, req, res, next)));
16 | } else {
17 | next();
18 | }
19 | },
20 | };
21 |
22 | module.exports = app;
23 |
--------------------------------------------------------------------------------
/lib/webpack-prod-config.js:
--------------------------------------------------------------------------------
1 | const webpack = require('webpack');
2 | const merge = require('webpack-merge');
3 |
4 | const baseConfig = require('./webpack-base-config.js');
5 |
6 | module.exports = baseConfig.map(config => merge(config, {
7 | plugins: [
8 | new webpack.LoaderOptionsPlugin({
9 | minimize: true,
10 | debug: false,
11 | }),
12 | new webpack.DefinePlugin({
13 | 'process.env': {
14 | NODE_ENV: JSON.stringify('production'),
15 | },
16 | }),
17 | new webpack.optimize.UglifyJsPlugin({
18 | beautify: false,
19 | mangle: {
20 | screw_ie8: true,
21 | keep_fnames: true,
22 | },
23 | compress: {
24 | screw_ie8: true,
25 | },
26 | comments: false,
27 | }),
28 | ],
29 | }));
30 |
--------------------------------------------------------------------------------
/lib/webpack-dev-config.js:
--------------------------------------------------------------------------------
1 | const webpack = require('webpack');
2 | const merge = require('webpack-merge');
3 |
4 | const baseConfig = require('./webpack-base-config.js');
5 |
6 | module.exports = baseConfig.map(config => merge.smartStrategy({
7 | entry: 'prepend',
8 | plugins: 'prepend',
9 | })(config, {
10 | entry: [
11 | 'react-hot-loader/patch',
12 | 'webpack-hot-middleware/client',
13 | ],
14 | devtool: 'inline-source-map',
15 | plugins: [
16 | new webpack.HotModuleReplacementPlugin(),
17 | ],
18 | module: {
19 | rules: [{
20 | test: /\.jsx?$/,
21 | exclude: /node_modules/,
22 | use: [{
23 | loader: 'babel-loader',
24 | options: {
25 | presets: [
26 | ['@babel/env', { modules: false }],
27 | '@babel/react',
28 | '@babel/stage-3',
29 | ],
30 | compact: true,
31 | plugins: ['react-hot-loader/babel'],
32 | },
33 | }],
34 | }],
35 | },
36 | }));
37 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2016 Daishi Kato
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
13 | all 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
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/templates/tiny-todos/src/client/index.js:
--------------------------------------------------------------------------------
1 | /* eslint-env browser */
2 |
3 | import { createElement as h } from 'react';
4 | import ReactDOM from 'react-dom';
5 | import { AppContainer } from 'react-hot-loader';
6 | import { createStore } from 'redux';
7 | import { Provider } from 'react-redux';
8 | import { BrowserRouter } from 'react-router-dom';
9 |
10 | import App from './App';
11 | import reducer from './reducer';
12 |
13 | const store = createStore(reducer, window.__PRELOADED_STATE__);
14 |
15 | const render = (Component) => {
16 | ReactDOM.render(
17 | h(
18 | AppContainer, {},
19 | h(
20 | Provider, { store },
21 | h(
22 | BrowserRouter, {},
23 | h(Component),
24 | ),
25 | ),
26 | ),
27 | document.getElementById('app'),
28 | );
29 | };
30 |
31 | render(App);
32 |
33 | if (module.hot) {
34 | module.hot.accept('./App', () => {
35 | const NewApp = require('./App').default;
36 | render(NewApp);
37 | });
38 | module.hot.accept('./reducer', () => {
39 | const newReducer = require('./reducer').default;
40 | store.replaceReducer(newReducer);
41 | });
42 | }
43 |
--------------------------------------------------------------------------------
/lib/cli.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | const path = require('path');
4 | const program = require('commander');
5 | const copyfiles = require('copyfiles');
6 | const json = require('json');
7 |
8 | program
9 | .command('import
')
10 | .description('import an app')
11 | .action((template) => {
12 | json.main([
13 | null,
14 | null,
15 | '-q',
16 | '-I',
17 | '-f',
18 | 'package.json',
19 | '-e',
20 | 'this.scripts.build="express-react-redux build-client";this.scripts.start="node src/server"',
21 | ]);
22 | const dir = path.join(__dirname, '../templates', template);
23 | const depth = dir.split(path.sep).length;
24 | copyfiles([`${dir}/**/*`, '.'], depth, (err) => {
25 | if (err) console.error(err);
26 | });
27 | });
28 |
29 | program
30 | .command('build-client')
31 | .description('build client code by webpack')
32 | .action(() => {
33 | const webpackProdConfig = require('./webpack-prod-config.js');
34 | const compiler = require('webpack')(webpackProdConfig);
35 |
36 | compiler.run((err) => {
37 | if (err) console.error(err);
38 | });
39 | });
40 |
41 | if (!process.argv.slice(2).length) {
42 | program.help();
43 | }
44 |
45 | program.parse(process.argv);
46 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Change Log
2 |
3 | ## [Unreleased]
4 |
5 | ## [0.5.2] - 2017-11-24
6 | ### Changed
7 | - fix exclude option in webpack config, again
8 |
9 | ## [0.5.1] - 2017-11-24
10 | ### Changed
11 | - fix exclude option in webpack config
12 |
13 | ## [0.5.0] - 2017-11-24
14 | ### Changed
15 | - update npm packages
16 | - use babel 7
17 |
18 | ## [0.4.1] - 2017-11-24
19 | ### Added
20 | - option to exclude `node_modules` in webpack config
21 | - include package-lock.json
22 |
23 | ## [0.4.0] - 2017-09-18
24 | ### Added
25 | - option for pre server-side rendering hook (#8)
26 |
27 | ## [0.3.1] - 2017-09-07
28 | ### Changed
29 | - fix babel-preset-env option
30 |
31 | ## [0.3.0] - 2017-09-07
32 | ### Added
33 | - option for server-side rendering for `/` (#7)
34 |
35 | ### Changed
36 | - update npm packages
37 | - use babel-preset-env
38 |
39 | ## [0.2.1] - 2017-02-14
40 | ### Changed
41 | - server-side rendering HMR support
42 |
43 | ## [0.2.0] - 2017-02-13
44 | ### Added
45 | - add react-router
46 | - server-side rendering
47 |
48 | ### Changed
49 | - update npm packages
50 |
51 | ## [0.1.1] - 2017-01-24
52 | ### Added
53 | - Support for hot module replacement
54 |
55 | ### Changed
56 | - update webpack 2.2.0
57 | - webpack production build
58 |
59 | ## [0.1.0] - 2017-01-20
60 | ### Added
61 | - Initial release
62 |
--------------------------------------------------------------------------------
/templates/tiny-todos/src/client/App.jsx:
--------------------------------------------------------------------------------
1 | import React, { PropTypes } from 'react';
2 | import { connect } from 'react-redux';
3 | import { Route, Link } from 'react-router-dom';
4 |
5 | const Home = ({ todos, addTodo }) => {
6 | const onSubmit = (event) => {
7 | event.preventDefault();
8 | const ele = event.target.getElementsByTagName('input')[0];
9 | addTodo({ text: ele.value });
10 | ele.value = '';
11 | };
12 | return (
13 |
14 |
[Home]
15 |
[About]
16 |
TODOs
17 |
18 | {todos.map(({ text }) => - {text}
)}
19 | -
20 |
23 |
24 |
25 |
26 | );
27 | };
28 |
29 | Home.propTypes = {
30 | todos: PropTypes.arrayOf(PropTypes.shape({
31 | text: PropTypes.string,
32 | })).isRequired,
33 | addTodo: PropTypes.func.isRequired,
34 | };
35 |
36 | const mapStateToProps = ({ todos }) => ({ todos });
37 | const mapDispatchToProps = dispatch => ({
38 | addTodo: todo => dispatch({ type: 'ADD_TODO', todo }),
39 | });
40 |
41 | const ConnectedHome = connect(mapStateToProps, mapDispatchToProps)(Home);
42 |
43 | const About = () => (
44 |
45 |
[Home]
46 |
[About]
47 |
About
48 |
This is a tiny TODO app example
49 |
50 | );
51 |
52 | const App = () => (
53 |
54 |
55 |
56 |
57 | );
58 |
59 | export default App;
60 |
--------------------------------------------------------------------------------
/tests/cli.test.js:
--------------------------------------------------------------------------------
1 | /* eslint-env jest */
2 |
3 | const path = require('path');
4 | const fs = require('fs-extra');
5 | const { execSync } = require('child_process');
6 |
7 | const cli = path.join(__dirname, '../lib/cli.js');
8 |
9 | describe('cli import test', () => {
10 | const encoding = 'utf8';
11 | let dir;
12 | beforeEach(() => {
13 | dir = fs.mkdtempSync('./temp-');
14 | });
15 | afterEach(() => {
16 | fs.removeSync(dir);
17 | });
18 |
19 | it('invoke without commands', () => {
20 | const stdout = execSync(`${cli} --help`, { cwd: dir, encoding });
21 | expect(stdout).toMatch(/Usage/);
22 | });
23 |
24 | it('invoke import tiny-todos', () => {
25 | const cwd = path.join(dir, 'app');
26 | fs.mkdirSync(cwd);
27 | execSync('npm init -y', { cwd });
28 | const stdout = execSync(`${cli} import tiny-todos`, { cwd, encoding });
29 | expect(stdout.length).toBe(0);
30 | const pjson = fs.readFileSync(path.join(cwd, 'package.json'), { encoding });
31 | expect(pjson).toMatch(/express-react-redux build-client/);
32 | const appjs = fs.readFileSync(path.join(cwd, 'src/server/index.js'), { encoding });
33 | expect(appjs).toMatch(/app.use\(require\('express-react-redux'\)\(\)\);/);
34 | });
35 |
36 | it('invoke build command', () => {
37 | const cwd = path.join(dir, 'app');
38 | fs.mkdirSync(cwd);
39 | execSync('npm init -y', { cwd });
40 | execSync(`${cli} import tiny-todos`, { cwd, encoding });
41 | const stdout = execSync(`${cli} build-client src/client build/client`, { cwd, encoding });
42 | expect(stdout.length).toBe(0);
43 | const files = fs.readdirSync(path.join(cwd, 'build/client'), { encoding });
44 | expect(files.length).toBe(4);
45 | expect(files.join(' ')).toMatch(/index\.js/);
46 | expect(files.join(' ')).toMatch(/index\.html/);
47 | expect(files.join(' ')).toMatch(/App\.js/);
48 | expect(files.join(' ')).toMatch(/reducer\.js/);
49 | });
50 | });
51 |
52 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # express-react-redux
2 |
3 | [](https://travis-ci.org/dai-shi/express-react-redux)
4 | [](https://www.npmjs.com/package/express-react-redux)
5 |
6 | Express middleware for React/Redux applications
7 |
8 | ## Motivation
9 |
10 | For beginners, building a development environment for a React/Redux app
11 | is a time-consuming task, while they want to start to coding an app.
12 | To this end, there are various packages that support building React/Redux apps.
13 | However, I wasn't able to find one that fulfills my requirements:
14 |
15 | - It's provided as a library not as a tool nor a boilerplate.
16 | - It can be used not only for learning but for production.
17 | - It's not a blackbox, but can be used for learning how it works.
18 | - It should be customizable if you learn enough.
19 |
20 | Hence, I decided to create yet another pakcage for the same purpose.
21 | This package is express middleware with the assumption that server
22 | logic is implemented in express and express provides APIs to the client app.
23 | Let's call it an Express/React/Redux app.
24 |
25 | ## What is this
26 |
27 | This is simple express middleware that comes with
28 | a default opinionated webpack config.
29 | It provides some functionalities by default:
30 |
31 | - babel transformation (latest+stage3)
32 | - hot module replacement
33 | - server side rendering
34 |
35 | ## How to use
36 |
37 | create an app folder:
38 |
39 | ```
40 | mkdir sample-app
41 | cd sample-app
42 | npm init
43 | ```
44 |
45 | install packages:
46 |
47 | ```
48 | npm install express express-react-redux --save
49 | ```
50 |
51 | import a template app:
52 |
53 | ```
54 | $(npm bin)/express-react-redux import tiny-todos
55 | ```
56 |
57 | run a dev server:
58 |
59 | ```
60 | PORT=3000 npm start
61 | ```
62 |
63 | build and run a production server:
64 |
65 | ```
66 | npm run build
67 | PORT=3000 NODE_ENV=production npm start
68 | ```
69 |
70 | ## Similar Projects
71 |
72 | - https://github.com/insin/nwb
73 | - https://github.com/petehunt/rwb
74 | - https://github.com/facebookincubator/create-react-app
75 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "express-react-redux",
3 | "description": "Express middleware for React/Redux applications",
4 | "version": "0.5.2",
5 | "repository": "dai-shi/express-react-redux",
6 | "main": "./lib/main.js",
7 | "bin": "./lib/cli.js",
8 | "scripts": {
9 | "pretest": "npm run lint --silent",
10 | "test": "jest --forceExit",
11 | "lint": "eslint . --ext .js,.jsx"
12 | },
13 | "dependencies": {
14 | "@babel/core": "^7.0.0-beta.32",
15 | "@babel/preset-env": "^7.0.0-beta.32",
16 | "@babel/preset-react": "^7.0.0-beta.32",
17 | "@babel/preset-stage-3": "^7.0.0-beta.32",
18 | "babel-core": "^7.0.0-bridge.0",
19 | "babel-loader": "^8.0.0-beta.0",
20 | "commander": "^2.12.1",
21 | "copyfiles": "^1.2.0",
22 | "css-loader": "^0.28.7",
23 | "file-loader": "^1.1.5",
24 | "html-webpack-plugin": "^2.30.1",
25 | "json": "^9.0.6",
26 | "lodash": "^4.17.4",
27 | "react": "^16.1.1",
28 | "react-dom": "^16.1.1",
29 | "react-hot-loader": "^3.1.3",
30 | "react-redux": "^5.0.6",
31 | "react-router-dom": "^4.2.2",
32 | "redux": "^3.7.2",
33 | "require-from-string": "^2.0.1",
34 | "serialize-javascript": "^1.4.0",
35 | "serve-static": "^1.13.1",
36 | "style-loader": "^0.19.0",
37 | "webpack": "^3.8.1",
38 | "webpack-dev-middleware": "^1.12.1",
39 | "webpack-hot-middleware": "^2.20.0",
40 | "webpack-merge": "^4.1.1"
41 | },
42 | "devDependencies": {
43 | "eslint": "^4.11.0",
44 | "eslint-config-airbnb": "^16.1.0",
45 | "eslint-plugin-import": "^2.8.0",
46 | "eslint-plugin-jsx-a11y": "^6.0.2",
47 | "eslint-plugin-react": "^7.5.1",
48 | "express": "^4.16.2",
49 | "fs-extra": "^4.0.2",
50 | "jest": "^21.2.1",
51 | "request": "^2.83.0"
52 | },
53 | "engines": {
54 | "node": ">=8.2.1"
55 | },
56 | "eslintConfig": {
57 | "extends": "airbnb",
58 | "rules": {
59 | "no-param-reassign": 0,
60 | "global-require": 0,
61 | "import/no-extraneous-dependencies": [
62 | 2,
63 | {
64 | "devDependencies": true
65 | }
66 | ],
67 | "strict": 0,
68 | "no-console": 0,
69 | "react/no-unused-prop-types": [
70 | 2,
71 | {
72 | "skipShapeProps": true
73 | }
74 | ],
75 | "no-underscore-dangle": [
76 | 2,
77 | {
78 | "allow": [
79 | "__PRELOADED_STATE__"
80 | ]
81 | }
82 | ],
83 | "jsx-a11y/anchor-is-valid": [
84 | 2,
85 | {
86 | "components": [
87 | "Link"
88 | ],
89 | "specialLink": [
90 | "to"
91 | ]
92 | }
93 | ]
94 | }
95 | },
96 | "jest": {
97 | "testEnvironment": "node"
98 | },
99 | "license": "MIT"
100 | }
101 |
--------------------------------------------------------------------------------
/lib/webpack-base-config.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const webpack = require('webpack');
3 | const HtmlWebpackPlugin = require('html-webpack-plugin');
4 |
5 | // TODO make it more readable
6 |
7 | module.exports = [{
8 | name: 'client',
9 | entry: [
10 | './src/client/index.js',
11 | ],
12 | output: {
13 | filename: '[hash]-index.js',
14 | path: path.resolve('./build/client'),
15 | publicPath: '/',
16 | },
17 | plugins: [
18 | new HtmlWebpackPlugin({
19 | filename: 'index.html',
20 | template: './src/client/index.html',
21 | }),
22 | new webpack.NamedModulesPlugin(),
23 | new webpack.NoEmitOnErrorsPlugin(),
24 | ],
25 | module: {
26 | rules: [{
27 | test: /\.jsx?$/,
28 | exclude: /node_modules/,
29 | use: [{
30 | loader: 'babel-loader',
31 | options: {
32 | presets: [
33 | ['@babel/env', { modules: false }],
34 | '@babel/react',
35 | '@babel/stage-3',
36 | ],
37 | compact: true,
38 | },
39 | }],
40 | }, {
41 | test: /\.(png|gif|jpg|svg)$/,
42 | use: [{
43 | loader: 'file-loader',
44 | options: { name: '[hash]-[name].[ext]' },
45 | }],
46 | }, {
47 | test: /\.css$/,
48 | use: ['style-loader', 'css-loader'],
49 | }],
50 | },
51 | resolve: {
52 | extensions: ['.js', '.jsx'],
53 | },
54 | }, {
55 | name: 'client-App for server-side rendering',
56 | entry: [
57 | './src/client/App.jsx',
58 | ],
59 | output: {
60 | filename: 'App.js',
61 | path: path.resolve('./build/client'),
62 | publicPath: '/',
63 | libraryTarget: 'commonjs2',
64 | },
65 | externals: /^[a-z\-0-9]+$/,
66 | plugins: [
67 | new webpack.NamedModulesPlugin(),
68 | new webpack.NoEmitOnErrorsPlugin(),
69 | ],
70 | module: {
71 | rules: [{
72 | test: /\.jsx?$/,
73 | exclude: /node_modules/,
74 | use: [{
75 | loader: 'babel-loader',
76 | options: {
77 | presets: [
78 | ['@babel/env', { modules: false }],
79 | '@babel/react',
80 | '@babel/stage-3',
81 | ],
82 | compact: true,
83 | },
84 | }],
85 | }],
86 | },
87 | }, {
88 | name: 'client-reducer for server-side rendering',
89 | entry: [
90 | './src/client/reducer.js',
91 | ],
92 | output: {
93 | filename: 'reducer.js',
94 | path: path.resolve('./build/client'),
95 | publicPath: '/',
96 | libraryTarget: 'commonjs2',
97 | },
98 | externals: /^[a-z\-0-9]+$/,
99 | plugins: [
100 | new webpack.NamedModulesPlugin(),
101 | new webpack.NoEmitOnErrorsPlugin(),
102 | ],
103 | module: {
104 | rules: [{
105 | test: /\.jsx?$/,
106 | exclude: /node_modules/,
107 | use: [{
108 | loader: 'babel-loader',
109 | options: {
110 | presets: [
111 | ['@babel/env', { modules: false }],
112 | '@babel/react',
113 | '@babel/stage-3',
114 | ],
115 | compact: true,
116 | },
117 | }],
118 | }],
119 | },
120 | }];
121 |
--------------------------------------------------------------------------------
/tests/middleware.test.js:
--------------------------------------------------------------------------------
1 | /* eslint-env jest */
2 | /* global jasmine */
3 |
4 | const path = require('path');
5 | const http = require('http');
6 | const express = require('express');
7 | const request = require('request');
8 | const HtmlWebpackPlugin = require('html-webpack-plugin');
9 |
10 | const main = require('../lib/main.js');
11 |
12 | jasmine.DEFAULT_TIMEOUT_INTERVAL = 3 * 60 * 1000;
13 |
14 | const webpackDevConfig = require('../lib/webpack-dev-config.js');
15 |
16 | webpackDevConfig[0].entry[2] = path.join(__dirname, 'files/src/client/index.js');
17 | webpackDevConfig[1].entry[2] = path.join(__dirname, 'files/src/client/App.jsx');
18 | webpackDevConfig[2].entry[2] = path.join(__dirname, 'files/src/client/reducer.js');
19 | webpackDevConfig[0].plugins[1] = new HtmlWebpackPlugin({
20 | filename: 'index.html',
21 | template: path.join(__dirname, 'files/src/client/index.html'),
22 | });
23 |
24 | describe('middleware unit test', () => {
25 | it('call it with minimal option', (done) => {
26 | main({
27 | webpackDevConfig,
28 | webpackDevBuildCallback: () => done(),
29 | });
30 | });
31 | });
32 |
33 | describe('middleware run test', () => {
34 | let server;
35 | let port;
36 | beforeAll((done) => {
37 | const app = express();
38 | app.use(main({
39 | webpackDevConfig,
40 | webpackDevBuildCallback: () => done(),
41 | }));
42 | server = http.createServer(app);
43 | server.listen(() => {
44 | ({ port } = server.address());
45 | });
46 | });
47 |
48 | it('get /', (done) => {
49 | request.get(`http://localhost:${port}/`, (err, res, body) => {
50 | expect(err).toBeFalsy();
51 | expect(body).toContain('script');
52 | done();
53 | });
54 | });
55 |
56 | afterAll((done) => {
57 | server.close(done);
58 | });
59 | });
60 |
61 | describe('middleware run test with / route SSR', () => {
62 | let server;
63 | let port;
64 | beforeAll((done) => {
65 | const app = express();
66 | app.use(main({
67 | webpackDevConfig,
68 | webpackDevBuildCallback: () => done(),
69 | indexSSR: true,
70 | }));
71 | server = http.createServer(app);
72 | server.listen(() => {
73 | ({ port } = server.address());
74 | });
75 | });
76 |
77 | it('get /', (done) => {
78 | request.get(`http://localhost:${port}/`, (err, res, body) => {
79 | expect(body).toContain(' {
85 | server.close(done);
86 | });
87 | });
88 |
89 | describe('middleware run test with async functions to populate the Store before SSR', () => {
90 | let server;
91 | let port;
92 | beforeAll((done) => {
93 | const app = express();
94 | app.use(main({
95 | webpackDevConfig,
96 | webpackDevBuildCallback: () => done(),
97 | indexSSR: true,
98 | beforeSSR: (store, req) => new Promise((resolve) => {
99 | setTimeout(() => {
100 | store.dispatch({ type: 'ADD_TODO', todo: `Current path: ${req.url}, Async function resolved 👏` });
101 | resolve();
102 | }, 1000);
103 | }),
104 | }));
105 | server = http.createServer(app);
106 | server.listen(() => {
107 | ({ port } = server.address());
108 | });
109 | });
110 |
111 | it('get /', (done) => {
112 | request.get(`http://localhost:${port}/about`, (err, res, body) => {
113 | expect(body).toContain('Current path: \\u002Fabout, Async function resolved 👏');
114 | done();
115 | });
116 | });
117 |
118 | afterAll((done) => {
119 | server.close(done);
120 | });
121 | });
122 |
--------------------------------------------------------------------------------
/lib/main.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 | const path = require('path');
3 | const _ = require('lodash');
4 | const serveStatic = require('serve-static');
5 | const app = require('./app-emulation.js');
6 | const { createElement: h } = require('react');
7 | const { renderToString } = require('react-dom/server');
8 | const { StaticRouter } = require('react-router-dom');
9 | const { createStore } = require('redux');
10 | const { Provider } = require('react-redux');
11 | const requireFromString = require('require-from-string');
12 | const serialize = require('serialize-javascript');
13 |
14 |
15 | const defaultOptions = {
16 | isProductionMode: () => process.env.NODE_ENV === 'production',
17 | isRoutingUrl: (url, req) => (/\.html?$/.test(url) || !/\.\w+$/.test(url)) && req.header('accept') !== 'text/event-stream',
18 | dirSourceClient: './src/client',
19 | dirBuildClient: './build/client',
20 | fileIndexHtml: 'index.html',
21 | fileIndexJs: 'index.js',
22 | fileAppJs: 'App.js',
23 | fileReducerJs: 'reducer.js',
24 | dirSourceServer: './src/server',
25 | dirBuildServer: './build/server',
26 | optsServeClient: { redirect: false },
27 | webpackDevConfig: require('./webpack-dev-config.js'),
28 | webpackDevOptions: { noInfo: true, publicPath: '/' },
29 | webpackDevBuildCallback: () => console.log('webpack dev build done.'),
30 | indexSSR: false,
31 | beforeSSR: (store, req, cb) => cb(),
32 | };
33 |
34 |
35 | function middleware(options = {}) {
36 | options = _.merge(defaultOptions, options);
37 | if (options.indexSSR) {
38 | options.optsServeClient.index = false;
39 | options.webpackDevOptions.index = false;
40 | }
41 |
42 | // STEP-01 check production mode
43 | const productionMode = options.isProductionMode();
44 | let compiler; // webpack compiler only used in non-production mode
45 | const getCompiler = filename => (
46 | compiler.compilers &&
47 | compiler.compilers.find(x => x.options.output.filename.endsWith(filename))
48 | ) || compiler;
49 |
50 | // STEP-02 serve assets and index.html
51 | if (productionMode) {
52 | app.use(serveStatic(options.dirBuildClient, options.optsServeClient));
53 | } else {
54 | compiler = require('webpack')(options.webpackDevConfig);
55 | compiler.plugin('done', options.webpackDevBuildCallback);
56 | compiler.plugin('failed', options.webpackDevBuildCallback);
57 | app.use(require('webpack-dev-middleware')(compiler, options.webpackDevOptions));
58 |
59 | app.get('/', (req, res) => {
60 | const c = getCompiler(options.fileIndexJs);
61 | const html = c.outputFileSystem.readFileSync(path.join(c.outputPath, options.fileIndexHtml), 'utf8');
62 | res.set('content-type', 'text/html');
63 | res.send(html);
64 | });
65 | app.use(require('webpack-hot-middleware')(compiler, options.webpackHotOptions));
66 | }
67 |
68 | // STEP-03 serve prerendered html
69 | const clientModuleMap = new Map();
70 | const getClientModule = (file) => {
71 | let module;
72 | if (productionMode) {
73 | module = clientModuleMap.get(file);
74 | if (!module) {
75 | // eslint-disable-next-line import/no-dynamic-require
76 | module = require(path.resolve(options.dirBuildClient, file));
77 | clientModuleMap.set(file, module);
78 | }
79 | } else {
80 | module = clientModuleMap.get(file);
81 | if (!module) {
82 | const c = getCompiler(file);
83 | const filename = path.join(c.outputPath, file);
84 | const content = c.outputFileSystem.readFileSync(filename, 'utf8');
85 | module = requireFromString(content, filename);
86 | clientModuleMap.set(file, module);
87 | c.watch({}, () => clientModuleMap.delete(file));
88 | }
89 | }
90 | if (module.default) module = module.default;
91 | return module;
92 | };
93 | let indexHtml;
94 | const getIndexHtml = () => {
95 | if (productionMode) {
96 | if (!indexHtml) {
97 | indexHtml = fs.readFileSync(path.resolve(options.dirBuildClient, options.fileIndexHtml), 'utf8');
98 | }
99 | return indexHtml;
100 | }
101 | // non-production mode
102 | if (!indexHtml) {
103 | const c = getCompiler(options.fileIndexJs);
104 | indexHtml = c.outputFileSystem.readFileSync(path.join(c.outputPath, options.fileIndexHtml), 'utf8');
105 | }
106 | return indexHtml;
107 | };
108 | app.use((req, res, next) => {
109 | if (req.url === '/' && !options.indexSSR) return next();
110 | if (!options.isRoutingUrl(req.url, req)) return next();
111 | const reducer = getClientModule(options.fileReducerJs);
112 | const App = getClientModule(options.fileAppJs);
113 | if (!reducer || !App) return next();
114 | const store = createStore(reducer);
115 | const render = (err) => {
116 | if (err) return next(err);
117 | const context = {};
118 | const appHtml = renderToString(h(
119 | Provider, { store },
120 | h(
121 | StaticRouter, { location: req.url, context },
122 | h(App),
123 | ),
124 | ));
125 | if (context.url) {
126 | res.redirect(302, context.url);
127 | } else {
128 | let html = getIndexHtml();
129 | const appDiv = '
';
130 | html = html.replace(appDiv, appDiv + appHtml);
131 | const preloadedState = ``;
132 | const scriptTag = '