├── .babelrc
├── .eslintignore
├── .eslintrc.js
├── .gitignore
├── LICENSE.md
├── README.md
├── app
├── app.js
├── components
│ ├── Button
│ │ ├── index.js
│ │ └── styles.less
│ ├── UserItem
│ │ ├── index.js
│ │ └── styles.less
│ ├── UsersList
│ │ ├── index.js
│ │ └── styles.less
│ └── index.js
├── configureStore.js
├── containers
│ ├── App
│ │ ├── actionTypes.js
│ │ ├── actions
│ │ │ └── app.js
│ │ ├── index.js
│ │ ├── reducers
│ │ │ └── app.js
│ │ ├── routes
│ │ │ ├── StaticRoutes.js
│ │ │ └── routesJson.js
│ │ ├── sagas
│ │ │ └── index.js
│ │ └── selectors
│ │ │ ├── router.js
│ │ │ └── users.js
│ └── pages
│ │ ├── NotFound
│ │ └── index.js
│ │ ├── Users
│ │ ├── index.js
│ │ └── styles.less
│ │ └── UsersGender
│ │ ├── index.js
│ │ └── styles.less
├── reducers.js
├── sagas.js
└── static
│ ├── index.html
│ └── sitemap.xml
├── browser
└── index.js
├── internals
├── scripts
│ ├── config.json
│ └── sitemap.js
└── webpack
│ ├── alias.js
│ ├── client
│ ├── webpack.base.babel.js
│ ├── webpack.dev.babel.js
│ └── webpack.prod.babel.js
│ ├── server
│ ├── rules.js
│ ├── webpack.dev.server.js
│ └── webpack.prod.server.js
│ └── window.mock.js
├── package.json
├── postcss.config.js
├── server.js
└── yarn.lock
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "plugins": [
3 | "babel-plugin-transform-decorators-legacy",
4 | "babel-plugin-transform-decorators",
5 | "styled-components",
6 | "syntax-dynamic-import"
7 | ],
8 | "presets": [
9 | [
10 | "env",
11 | {
12 | "modules": false
13 | }
14 | ],
15 | "react",
16 | "stage-0"
17 | ],
18 | "env": {
19 | "production": {
20 | "plugins": [
21 | "transform-react-remove-prop-types",
22 | "transform-react-constant-elements"
23 | ]
24 | }
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | internals/scripts
2 | node_modules/
3 | build
4 | server.js
5 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | "parser": "babel-eslint",
3 | "extends": "airbnb",
4 | "env": {
5 | "browser": true,
6 | "node": true,
7 | "jest": true,
8 | "es6": true
9 | },
10 | "plugins": [
11 | "react",
12 | "jsx-a11y"
13 | ],
14 | "parserOptions": {
15 | "ecmaVersion": 6,
16 | "sourceType": "module",
17 | "ecmaFeatures": {
18 | "jsx": true
19 | }
20 | },
21 | "rules": {
22 | "import/no-extraneous-dependencies": ["error", {
23 | "devDependencies": [
24 | "./internals/**",
25 | ],
26 | }],
27 | "import/no-dynamic-require": 0,
28 | "import/no-webpack-loader-syntax": 0,
29 | "jsx-a11y/media-has-caption": 0,
30 | "no-loop-func": 0,
31 | "no-bitwise": 0,
32 | "react/forbid-prop-types": 0,
33 | "react/jsx-filename-extension": 0,
34 | "no-script-url": 0,
35 | "class-methods-use-this": 0,
36 | "import/prefer-default-export": 0,
37 | "react/sort-prop-types": "error",
38 | "react/jsx-sort-props": "error",
39 | "react/jsx-sort-default-props": "error",
40 | "linebreak-style": 0
41 | },
42 | "settings": {
43 | "import/resolver": {
44 | "webpack": {
45 | "config": "./internals/webpack/client/webpack.prod.babel.js"
46 | }
47 | }
48 | }
49 | };
50 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .DS_STORE
3 | .idea
4 | build
5 | yarn-error.log
6 | package-lock.json
7 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2019 Yan Tsishko
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # ssr-demo-expressjs
2 | Server side rendering Demo using Express.js
3 |
4 | Habr article https://habr.com/ru/post/473210/
5 |
6 |
7 | Install yarn https://yarnpkg.com/en/docs/install
8 |
9 | For start server
10 |
11 | $ yarn server:start
12 |
13 | For start develop
14 |
15 | $ yarn dev:start
16 |
17 | For start production
18 |
19 | $ yarn prod:build
20 |
21 |
--------------------------------------------------------------------------------
/app/app.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { hydrate } from 'react-dom';
3 | import { Provider } from 'react-redux';
4 | import { createBrowserHistory, createMemoryHistory } from 'history';
5 | import { ConnectedRouter } from 'connected-react-router/immutable';
6 | import routes from 'containers/App/routes/routesJson';
7 | import {
8 | ReduxAsyncConnect,
9 | } from 'redux-connect';
10 | import StyleContext from 'isomorphic-style-loader/StyleContext';
11 |
12 | import configureStore from './configureStore';
13 |
14 | import { StaticRoutesConfig } from './containers/App/routes/StaticRoutes';
15 |
16 | // eslint-disable-next-line no-underscore-dangle
17 | const initialState = !process.env.IS_SERVER ? window.__INITIAL_DATA__ : {};
18 |
19 | const history = process.env.IS_SERVER
20 | ? createMemoryHistory({
21 | initialEntries: ['/'],
22 | })
23 | : createBrowserHistory();
24 |
25 | const store = configureStore(initialState, history);
26 | if (!process.env.IS_SERVER) {
27 | window.store = store;
28 | }
29 |
30 | const insertCss = (...styles) => {
31 | // eslint-disable-next-line no-underscore-dangle
32 | const removeCss = styles.map(style => style._insertCss());
33 | return () => removeCss.forEach(dispose => dispose());
34 | };
35 |
36 | export const browserRender = () => {
37 | const dynamicRoutes = [...routes];
38 | dynamicRoutes[0].routes = [...dynamicRoutes[0].routes, ...StaticRoutesConfig];
39 |
40 | hydrate(
41 |
42 |
43 |
44 |
45 |
46 |
47 | ,
48 | document.getElementById('app'),
49 | );
50 | };
51 |
--------------------------------------------------------------------------------
/app/components/Button/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import withStyles from 'isomorphic-style-loader/withStyles';
4 | import { Link } from 'react-router-dom';
5 |
6 | import s from './styles.less';
7 |
8 | function Button(props) {
9 | return (
10 |
16 | );
17 | }
18 |
19 | Button.propTypes = {
20 | link: PropTypes.string.isRequired,
21 | text: PropTypes.string.isRequired,
22 | };
23 |
24 | export default withStyles(s)(Button);
25 |
--------------------------------------------------------------------------------
/app/components/Button/styles.less:
--------------------------------------------------------------------------------
1 | .button {
2 | width: 150px;
3 | height: 30px;
4 | border-radius: 8px;
5 | margin-right: 20px;
6 | box-shadow: 0px 4px 20px 10px;
7 |
8 | &:focus, &:active, &:hover {
9 | outline: none;
10 | }
11 | }
12 |
13 | .link {
14 | text-decoration: none;
15 | color: #000000;
16 | font-weight: bold;
17 | }
18 |
--------------------------------------------------------------------------------
/app/components/UserItem/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 | import withStyles from 'isomorphic-style-loader/withStyles';
4 |
5 | import s from './styles.less';
6 |
7 | @withStyles(s)
8 | export default class User extends Component {
9 | static propTypes = {
10 | avatar: PropTypes.string.isRequired,
11 | email: PropTypes.string.isRequired,
12 | name: PropTypes.string.isRequired,
13 | phone: PropTypes.string.isRequired,
14 | };
15 |
16 | render() {
17 | const { name, email, phone, avatar } = this.props;
18 |
19 | return (
20 |
21 |
{name}
22 |
{email}
23 |
{phone}
24 |
25 |

26 |
27 |
28 | );
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/app/components/UserItem/styles.less:
--------------------------------------------------------------------------------
1 | .block {
2 | display: inline-block;
3 | width: 300px;
4 | text-align: center;
5 | background-color: #ccc;
6 | border: 1px solid white;
7 | padding: 10px;
8 | border-radius: 20px;
9 | }
10 |
--------------------------------------------------------------------------------
/app/components/UsersList/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 | import withStyles from 'isomorphic-style-loader/withStyles';
4 |
5 | import User from '../UserItem';
6 | import s from './styles.less';
7 |
8 | @withStyles(s)
9 | export default class UsersList extends Component {
10 | static propTypes = {
11 | users: PropTypes.shape().isRequired,
12 | };
13 |
14 | render() {
15 | const { users } = this.props;
16 |
17 | return (
18 |
19 | {
20 | users.map(user => (
21 | ))
28 | }
29 |
30 | );
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/app/components/UsersList/styles.less:
--------------------------------------------------------------------------------
1 | .list {
2 | display: block;
3 | }
4 |
--------------------------------------------------------------------------------
/app/components/index.js:
--------------------------------------------------------------------------------
1 | import User from './UserItem';
2 | import UsersList from './UsersList';
3 | import Button from './Button';
4 |
5 | export {
6 | User,
7 | UsersList,
8 | Button,
9 | };
10 |
--------------------------------------------------------------------------------
/app/configureStore.js:
--------------------------------------------------------------------------------
1 | import { createStore, applyMiddleware, compose } from 'redux';
2 | import { fromJS } from 'immutable';
3 | import thunk from 'redux-thunk';
4 | import createSagaMiddleware, { END } from 'redux-saga';
5 | import { routerMiddleware } from 'connected-react-router/immutable';
6 | import createRootReducer from './reducers';
7 |
8 | export default function configureStore(initialState = {}, history) {
9 | const sagaMiddleWare = createSagaMiddleware();
10 | const middlewares = [
11 | thunk,
12 | routerMiddleware(history),
13 | sagaMiddleWare,
14 | ];
15 |
16 | const enhancers = [
17 | applyMiddleware(...middlewares),
18 | ];
19 |
20 | // If Redux DevTools Extension is installed use it, otherwise use Redux compose
21 | /* eslint-disable no-underscore-dangle */
22 | const composeEnhancers =
23 | process.env.NODE_ENV !== 'production' &&
24 | typeof window === 'object' &&
25 | window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
26 | ? window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({
27 | shouldHotReload: false,
28 | })
29 | : compose;
30 | /* eslint-enable */
31 |
32 | const store = createStore(
33 | createRootReducer(history),
34 | fromJS(initialState),
35 | composeEnhancers(...enhancers),
36 | );
37 |
38 | store.runSaga = sagaMiddleWare.run;
39 | store.close = () => store.dispatch(END);
40 | // Extensions
41 | store.injectedReducers = {}; // Reducer registry
42 |
43 | if (module.hot) {
44 | module.hot.accept('./reducers', () => {
45 | store.replaceReducer(createRootReducer(store.injectedReducers));
46 | });
47 | }
48 |
49 | return store;
50 | }
51 |
--------------------------------------------------------------------------------
/app/containers/App/actionTypes.js:
--------------------------------------------------------------------------------
1 | export const SET_USERS_SAGA = 'app/users/SET_USERS_SAGA';
2 | export const SET_USERS_ASYNC_CONNECT = 'app/users/SET_USERS_ASYNC_CONNECT';
3 |
--------------------------------------------------------------------------------
/app/containers/App/actions/app.js:
--------------------------------------------------------------------------------
1 | import superagent from 'superagent';
2 | import { SET_USERS_SAGA, SET_USERS_ASYNC_CONNECT } from '../actionTypes';
3 |
4 | export function setUsersData(users) {
5 | return {
6 | type: SET_USERS_SAGA,
7 | payload: {
8 | data: users,
9 | },
10 | };
11 | }
12 |
13 | export function getUsersData(params) {
14 | return async (dispatch) => {
15 | const { body: { results } } = await superagent.get(`https://randomuser.me/api/?results=10${params ? `&gender=${params.gender}` : ''}`);
16 |
17 | return dispatch({
18 | type: SET_USERS_ASYNC_CONNECT,
19 | payload: {
20 | data: results,
21 | },
22 | });
23 | };
24 | }
25 |
26 |
--------------------------------------------------------------------------------
/app/containers/App/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 | import { connect } from 'react-redux';
4 | import { Helmet } from 'react-helmet';
5 | import { renderRoutes } from 'react-router-config';
6 |
7 | import { getRouterLocation } from './selectors/router';
8 |
9 |
10 | @connect(state => ({
11 | location: getRouterLocation(state),
12 | }), null)
13 | export default class App extends Component {
14 | static propTypes = {
15 | location: PropTypes.shape().isRequired,
16 | route: PropTypes.shape().isRequired,
17 | };
18 |
19 | renderSiteMeta() {
20 | const canonical = this.props.location.toJS().pathname.toLowerCase();
21 | return (
31 | );
32 | }
33 |
34 | render() {
35 | const { route } = this.props;
36 |
37 | return (
38 |
39 | {this.renderSiteMeta()}
40 | {renderRoutes(route.routes)}
41 |
42 | );
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/app/containers/App/reducers/app.js:
--------------------------------------------------------------------------------
1 | import { fromJS } from 'immutable';
2 | import { SET_USERS_ASYNC_CONNECT, SET_USERS_SAGA } from '../actionTypes';
3 |
4 | const initialState = fromJS({
5 | usersFromSaga: [],
6 | usersFromAsyncConnect: [],
7 | });
8 |
9 | const app = (state = initialState, action) => {
10 | switch (action.type) {
11 | case SET_USERS_SAGA: {
12 | return state.set('usersFromSaga', fromJS(action.payload.data));
13 | }
14 | case SET_USERS_ASYNC_CONNECT: {
15 | return state.set('usersFromAsyncConnect', fromJS(action.payload.data));
16 | }
17 | default:
18 | return state;
19 | }
20 | };
21 |
22 | export default app;
23 |
24 |
--------------------------------------------------------------------------------
/app/containers/App/routes/StaticRoutes.js:
--------------------------------------------------------------------------------
1 | import Users from './../../pages/Users';
2 | import UsersGender from './../../pages/UsersGender';
3 | import NotFound from './../../pages/NotFound';
4 |
5 | export const StaticRoutesConfig = [
6 | {
7 | key: 'usersGender',
8 | component: UsersGender,
9 | exact: true,
10 | path: '/users-gender/:gender',
11 | },
12 | {
13 | key: 'USERS',
14 | component: Users,
15 | exact: true,
16 | path: '/users',
17 | },
18 | {
19 | key: 'main',
20 | component: Users,
21 | exact: true,
22 | path: '/',
23 | },
24 | {
25 | key: 'not-found',
26 | component: NotFound,
27 | },
28 | ];
29 |
--------------------------------------------------------------------------------
/app/containers/App/routes/routesJson.js:
--------------------------------------------------------------------------------
1 | import App from './../index';
2 |
3 | const routes = [
4 | {
5 | path: '/',
6 | component: App,
7 | routes: [],
8 | },
9 | ];
10 |
11 | export default routes;
12 |
--------------------------------------------------------------------------------
/app/containers/App/sagas/index.js:
--------------------------------------------------------------------------------
1 | import { put, call, fork, all } from 'redux-saga/effects';
2 | import superagent from 'superagent';
3 |
4 | import { setUsersData } from './../actions/app';
5 |
6 | export function* loadInitialData() {
7 | try {
8 | const { body: { results } } = yield call(superagent.get, 'https://randomuser.me/api/?results=10');
9 |
10 | yield put(setUsersData(results));
11 | } catch (e) {
12 | console.error(e);
13 | }
14 | }
15 |
16 | function* watch() {
17 | yield fork(loadInitialData);
18 | }
19 |
20 |
21 | function* appSagas() {
22 | yield all([
23 | fork(watch),
24 | ]);
25 | }
26 |
27 | export default appSagas;
28 |
--------------------------------------------------------------------------------
/app/containers/App/selectors/router.js:
--------------------------------------------------------------------------------
1 | import { createSelector } from 'reselect';
2 |
3 | export const getRouter = state => state.get('router');
4 |
5 | export const getRouterLocation = createSelector(
6 | getRouter,
7 | router => router.get('location'),
8 | );
9 |
--------------------------------------------------------------------------------
/app/containers/App/selectors/users.js:
--------------------------------------------------------------------------------
1 | import { createSelector } from 'reselect';
2 |
3 | export const getAppReducer = state => state.get('appReducer');
4 |
5 | export const getUsersSaga = createSelector(
6 | getAppReducer,
7 | router => router.get('usersFromSaga'),
8 | );
9 |
10 | export const getUsersAsyncConnect = createSelector(
11 | getAppReducer,
12 | router => router.get('usersFromAsyncConnect'),
13 | );
14 |
--------------------------------------------------------------------------------
/app/containers/pages/NotFound/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export default function NotFound() {
4 | return (
5 | Not found
6 | );
7 | }
8 |
--------------------------------------------------------------------------------
/app/containers/pages/Users/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 | import { connect } from 'react-redux';
4 | import { asyncConnect } from 'redux-connect';
5 | import withStyles from 'isomorphic-style-loader/withStyles';
6 |
7 | import { getUsersSaga, getUsersAsyncConnect } from 'containers/App/selectors/users';
8 | import { getUsersData } from 'containers/App/actions/app';
9 |
10 | import { UsersList, Button } from 'components';
11 |
12 | import s from './styles.less';
13 |
14 | @asyncConnect([
15 | {
16 | key: 'usersFromServer',
17 | promise: async ({ store: { dispatch } }) => {
18 | await dispatch(getUsersData());
19 |
20 | return Promise.resolve();
21 | },
22 | },
23 | ])
24 | @connect(state => ({
25 | usersSaga: getUsersSaga(state),
26 | usersAsyncConnect: getUsersAsyncConnect(state),
27 | }), null)
28 | @withStyles(s)
29 | export default class Users extends Component {
30 | static propTypes = {
31 | usersAsyncConnect: PropTypes.shape().isRequired,
32 | usersSaga: PropTypes.shape().isRequired,
33 | };
34 |
35 | render() {
36 | const { usersSaga, usersAsyncConnect } = this.props;
37 |
38 | return (
39 |
40 |
44 |
48 |
Users from Saga
49 |
50 |
Users from Async Connect
51 |
52 |
53 | );
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/app/containers/pages/Users/styles.less:
--------------------------------------------------------------------------------
1 | .title {
2 | font-weight: bold;
3 | margin: 25px 0 25px 0;
4 | font-size: 25px;
5 | }
6 |
--------------------------------------------------------------------------------
/app/containers/pages/UsersGender/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 | import { connect } from 'react-redux';
4 | import { asyncConnect } from 'redux-connect';
5 | import withStyles from 'isomorphic-style-loader/withStyles';
6 |
7 | import { getUsersAsyncConnect } from 'containers/App/selectors/users';
8 | import { getUsersData } from 'containers/App/actions/app';
9 |
10 | import { UsersList, Button } from 'components';
11 |
12 | import s from './styles.less';
13 |
14 | @asyncConnect([
15 | {
16 | key: 'usersFromServerGender',
17 | promise: async ({ store: { dispatch }, match: { params } }) => {
18 | await dispatch(getUsersData(params));
19 | },
20 | },
21 | ])
22 | @connect(state => ({
23 | usersAsyncConnect: getUsersAsyncConnect(state),
24 | }), null)
25 | @withStyles(s)
26 | export default class UsersGender extends Component {
27 | static propTypes = {
28 | usersAsyncConnect: PropTypes.shape().isRequired,
29 | };
30 |
31 | render() {
32 | const { usersAsyncConnect } = this.props;
33 |
34 | return (
35 |
44 | );
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/app/containers/pages/UsersGender/styles.less:
--------------------------------------------------------------------------------
1 | .title {
2 | font-weight: bold;
3 | margin: 25px 0 25px 0;
4 | font-size: 25px;
5 | }
6 |
7 | .wrapperList {
8 | margin-top: 30px;
9 | }
10 |
--------------------------------------------------------------------------------
/app/reducers.js:
--------------------------------------------------------------------------------
1 | import { combineReducers } from 'redux-immutable';
2 | import { fromJS } from 'immutable';
3 | import { connectRouter } from 'connected-react-router/immutable';
4 |
5 | import appReducer from 'containers/App/reducers/app';
6 |
7 | import {
8 | immutableReducer as reduxAsyncConnect,
9 | setToImmutableStateFunc,
10 | setToMutableStateFunc,
11 | } from 'redux-connect';
12 |
13 | setToImmutableStateFunc(mutableState => fromJS(mutableState));
14 | setToMutableStateFunc(immutableState => immutableState.toJS());
15 |
16 | export default history => combineReducers({
17 | reduxAsyncConnect,
18 | router: connectRouter(history),
19 | appReducer,
20 | });
21 |
--------------------------------------------------------------------------------
/app/sagas.js:
--------------------------------------------------------------------------------
1 | import appSagas from './containers/App/sagas';
2 |
3 | export default appSagas;
4 |
--------------------------------------------------------------------------------
/app/static/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | SSR Starter Kit
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/app/static/sitemap.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | https://example.com/ monthly 0.8
4 |
--------------------------------------------------------------------------------
/browser/index.js:
--------------------------------------------------------------------------------
1 | import 'babel-polyfill';
2 |
3 | import { browserRender } from '../app/app';
4 |
5 | browserRender();
6 |
--------------------------------------------------------------------------------
/internals/scripts/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "siteMapUrls": [
3 | "/"
4 | ]
5 | }
6 |
--------------------------------------------------------------------------------
/internals/scripts/sitemap.js:
--------------------------------------------------------------------------------
1 | const sm = require('sitemap');
2 | const fs = require('fs');
3 |
4 | const config = require('./config.json');
5 |
6 | const sitemap = sm.createSitemap({
7 | hostname: 'https://example.com',
8 | cacheTime: 600000,
9 | urls: config.siteMapUrls.map(url => ({ url, changefreq: 'monthly', priority: 0.8 })),
10 | });
11 |
12 | fs.writeFileSync('app/static/sitemap.xml', sitemap.toString());
13 |
--------------------------------------------------------------------------------
/internals/webpack/alias.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 |
3 | const alias = {
4 | components: path.resolve('./app/components'),
5 | containers: path.resolve('./app/containers'),
6 | };
7 |
8 | module.exports = alias;
9 |
--------------------------------------------------------------------------------
/internals/webpack/client/webpack.base.babel.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const webpack = require('webpack');
3 | const CopyWebpackPlugin = require('copy-webpack-plugin');
4 | const ProgressBarPlugin = require('progress-bar-webpack-plugin');
5 | const autoprefixer = require('autoprefixer');
6 | const alias = require('./../alias');
7 |
8 | const plugins = [
9 | new ProgressBarPlugin(),
10 | new CopyWebpackPlugin([
11 | { from: 'app/images', to: 'images' },
12 | { from: 'app/static/**', to: '.' },
13 | ]),
14 | new webpack.ProvidePlugin({
15 | // make fetch available
16 | fetch: 'exports-loader?self.fetch!whatwg-fetch',
17 | }),
18 | new webpack.DefinePlugin({
19 | 'process.env': {
20 | NODE_ENV: JSON.stringify(process.env.NODE_ENV),
21 | },
22 | }),
23 | new webpack.NamedModulesPlugin(),
24 | ];
25 |
26 | module.exports = options => ({
27 | entry: options.entry,
28 | output: Object.assign({
29 | path: path.resolve(process.cwd(), 'build'),
30 | publicPath: '/',
31 | }, options.output),
32 | module: {
33 | rules: [
34 | {
35 | test: /\.jsx?$/,
36 | exclude: /(node_modules|app[/\\]+libs.*)/,
37 | use: {
38 | loader: 'babel-loader',
39 | options: options.babelQuery,
40 | },
41 | },
42 | {
43 | test: /\.less$/,
44 | exclude: /node_modules/,
45 | use: [
46 | 'isomorphic-style-loader',
47 | {
48 | loader: 'css-loader?modules=false',
49 | options: {
50 | importLoaders: 1,
51 | modules: true,
52 | localIdentName: process.env.NODE_ENV !== 'production' ? '[name]-[local]-[hash:base64:5]' : '[hash:base64:5]',
53 | },
54 | },
55 | {
56 | loader: 'postcss-loader',
57 | options: {
58 | plugins: [
59 | autoprefixer({
60 | browsers: [
61 | 'ie >= 8',
62 | 'last 4 version',
63 | 'iOS >= 8',
64 | ],
65 | }),
66 | ],
67 | sourceMap: true,
68 | },
69 | },
70 | 'less-loader',
71 | ],
72 | },
73 | {
74 | test: /\.css$/,
75 | include: /(node_modules|app)/,
76 | use: ['isomorphic-style-loader', 'css-loader?modules=false'],
77 | },
78 | {
79 | test: /\.(eot|svg|otf|ttf|woff|woff2)$/,
80 | use: 'file-loader',
81 | },
82 | {
83 | test: /\.(mp4|webm|png|gif)$/,
84 | use: {
85 | loader: 'url-loader',
86 | options: {
87 | limit: 10000,
88 | },
89 | },
90 | },
91 | ],
92 | },
93 | plugins: options.plugins.concat(plugins),
94 | resolve: {
95 | alias,
96 | modules: [
97 | path.resolve('./app'),
98 | path.resolve(process.cwd(), 'node_modules'),
99 | ],
100 | extensions: [
101 | '.js',
102 | '.jsx',
103 | '.react.js',
104 | ],
105 | mainFields: [
106 | 'browser',
107 | 'main',
108 | 'jsnext:main',
109 | ],
110 | },
111 | devtool: options.devtool,
112 | target: 'web',
113 | performance: options.performance || {},
114 | node: {
115 | child_process: 'empty',
116 | fs: 'empty',
117 | module: 'empty',
118 | net: 'empty',
119 | tls: 'empty',
120 | },
121 | });
122 |
--------------------------------------------------------------------------------
/internals/webpack/client/webpack.dev.babel.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const webpack = require('webpack');
3 | const HtmlWebpackPlugin = require('html-webpack-plugin');
4 | const CircularDependencyPlugin = require('circular-dependency-plugin');
5 |
6 | const plugins = [
7 | new webpack.NoEmitOnErrorsPlugin(),
8 | new HtmlWebpackPlugin({
9 | inject: true,
10 | template: 'app/static/index.html',
11 | filename: 'main.html',
12 | }),
13 | new CircularDependencyPlugin({
14 | exclude: /a\.js|node_modules/,
15 | failOnError: false,
16 | }),
17 | ];
18 |
19 | module.exports = require('./webpack.base.babel')({
20 | entry: [
21 | 'eventsource-polyfill', // Necessary for hot reloading with IE
22 | path.join(process.cwd(), 'browser/index.js'),
23 | ],
24 |
25 | output: {
26 | filename: '[name].js',
27 | chunkFilename: '[name].js',
28 | },
29 |
30 | plugins,
31 | devtool: 'eval-source-map',
32 |
33 | performance: {
34 | hints: false,
35 | },
36 | });
37 |
--------------------------------------------------------------------------------
/internals/webpack/client/webpack.prod.babel.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const webpack = require('webpack');
3 | const UglifyJsPlugin = require('uglifyjs-webpack-plugin');
4 | const HtmlWebpackPlugin = require('html-webpack-plugin');
5 |
6 | module.exports = require('./webpack.base.babel')({
7 | entry: [
8 | path.join(process.cwd(), 'browser/index.js'),
9 | ],
10 |
11 | output: {
12 | filename: '[name].js',
13 | chunkFilename: '[name].chunk.js',
14 | },
15 | plugins: [
16 | new UglifyJsPlugin(),
17 | new webpack.optimize.ModuleConcatenationPlugin(),
18 | new webpack.optimize.CommonsChunkPlugin({
19 | name: 'vendor',
20 | children: true,
21 | minChunks: 2,
22 | async: true,
23 | }),
24 | // Minify and optimize the index.html
25 | new HtmlWebpackPlugin({
26 | template: 'app/static/index.html',
27 | filename: 'main.html',
28 | minify: {
29 | removeComments: true,
30 | collapseWhitespace: true,
31 | removeRedundantAttributes: true,
32 | useShortDoctype: true,
33 | removeEmptyAttributes: true,
34 | removeStyleLinkTypeAttributes: true,
35 | keepClosingSlash: true,
36 | minifyJS: true,
37 | // minifyCSS: true,
38 | minifyURLs: true,
39 | },
40 | inject: true,
41 | }),
42 | ],
43 | });
44 |
--------------------------------------------------------------------------------
/internals/webpack/server/rules.js:
--------------------------------------------------------------------------------
1 | const autoprefixer = require('autoprefixer');
2 |
3 | const rules = [
4 | {
5 | test: /\.jsx?$/,
6 | exclude: /(node_modules|app[/\\]+libs.*)/,
7 | use: {
8 | loader: 'babel-loader',
9 | },
10 | },
11 | {
12 | test: /\.less$/,
13 | exclude: /node_modules/,
14 | use: [
15 | 'isomorphic-style-loader',
16 | {
17 | loader: 'css-loader?modules=false',
18 | options: {
19 | importLoaders: 1,
20 | modules: true,
21 | localIdentName: process.env.NODE_ENV !== 'production' ? '[name]-[local]-[hash:base64:5]' : '[hash:base64:5]',
22 | },
23 | },
24 | {
25 | loader: 'postcss-loader',
26 | options: {
27 | plugins: [
28 | autoprefixer({
29 | browsers: [
30 | 'ie >= 8',
31 | 'last 4 version',
32 | 'iOS >= 8',
33 | ],
34 | }),
35 | ],
36 | sourceMap: true,
37 | },
38 | },
39 | 'less-loader',
40 | ],
41 | },
42 | {
43 | test: /\.css$/,
44 | include: /(node_modules|app)/,
45 | use: ['isomorphic-style-loader', 'css-loader?modules=false'],
46 | },
47 | {
48 | test: /\.(gif)$/,
49 | use: 'file-loader',
50 | },
51 | {
52 | test: /\.(jpe?g|png|ttf|eot|otf|svg|woff(2)?)(\?[a-z0-9=&.]+)?$/,
53 | use: 'base64-inline-loader?limit=1000&name=[name].[ext]',
54 | },
55 | {
56 | test: /\.html$/,
57 | use: 'html-loader',
58 | },
59 | {
60 | test: /\.(mp4|webm|gif)$/,
61 | use: {
62 | loader: 'url-loader',
63 | options: {
64 | limit: true,
65 | },
66 | },
67 | },
68 | ];
69 |
70 | module.exports = rules;
71 |
--------------------------------------------------------------------------------
/internals/webpack/server/webpack.dev.server.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const nodeExternals = require('webpack-node-externals');
3 | const webpack = require('webpack');
4 | const CopyWebpackPlugin = require('copy-webpack-plugin');
5 |
6 | const alias = require('./../alias');
7 | const rules = require('./rules');
8 |
9 | const nodeConf = {
10 | target: 'node',
11 | entry: './server.js',
12 | externals: [nodeExternals(), 'react-helmet'],
13 | output: {
14 | path: path.resolve('build'),
15 | filename: 'server.js',
16 | library: 'app',
17 | libraryTarget: 'commonjs2',
18 | publicPath: '/',
19 | },
20 | module: {
21 | rules,
22 | },
23 | plugins: [
24 | new CopyWebpackPlugin([
25 | { from: 'app/images', to: 'images' },
26 | { from: 'app/static/**', to: '.' },
27 | ]),
28 | new webpack.ProvidePlugin({
29 | window: path.resolve(path.join(__dirname, './../window.mock')),
30 | document: 'global/document',
31 | }),
32 | ],
33 | resolve: {
34 | alias,
35 | modules: [
36 | path.resolve('./app'),
37 | path.resolve(process.cwd(), 'node_modules'),
38 | ],
39 | extensions: [
40 | '.js',
41 | '.jsx',
42 | '.react.js',
43 | ],
44 | mainFields: [
45 | 'browser',
46 | 'jsnext:main',
47 | 'main',
48 | ],
49 | },
50 | };
51 |
52 | const browserConf = require('../client/webpack.dev.babel');
53 |
54 | module.exports = [browserConf, nodeConf];
55 |
--------------------------------------------------------------------------------
/internals/webpack/server/webpack.prod.server.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const nodeExternals = require('webpack-node-externals');
3 | const webpack = require('webpack');
4 | const UglifyJsPlugin = require('uglifyjs-webpack-plugin');
5 |
6 | const alias = require('./../alias');
7 | const rules = require('./rules');
8 |
9 | const nodeConf = {
10 | target: 'node',
11 | entry: './server.js',
12 | externals: [nodeExternals(), 'react-helmet'],
13 | output: {
14 | path: path.resolve('build'),
15 | filename: 'server.js',
16 | library: 'app',
17 | libraryTarget: 'commonjs2',
18 | publicPath: '/',
19 | },
20 | module: {
21 | rules,
22 | },
23 | plugins: [
24 | new UglifyJsPlugin(),
25 | new webpack.optimize.ModuleConcatenationPlugin(),
26 | new webpack.optimize.CommonsChunkPlugin({
27 | name: 'vendor',
28 | children: true,
29 | minChunks: 2,
30 | async: true,
31 | }),
32 | new webpack.DefinePlugin({
33 | 'process.env': {
34 | NODE_ENV: JSON.stringify(process.env.NODE_ENV),
35 | },
36 | }),
37 | new webpack.ProvidePlugin({
38 | window: path.resolve(path.join(__dirname, './../window.mock')),
39 | document: 'global/document',
40 | }),
41 | ],
42 | resolve: {
43 | alias,
44 | modules: [
45 | path.resolve('./app'),
46 | path.resolve(process.cwd(), 'node_modules'),
47 | ],
48 | extensions: [
49 | '.js',
50 | '.jsx',
51 | '.react.js',
52 | ],
53 | mainFields: [
54 | 'browser',
55 | 'jsnext:main',
56 | 'main',
57 | ],
58 | },
59 | };
60 |
61 | const browserConf = require('../client/webpack.prod.babel');
62 |
63 | module.exports = [browserConf, nodeConf];
64 |
--------------------------------------------------------------------------------
/internals/webpack/window.mock.js:
--------------------------------------------------------------------------------
1 | let win;
2 |
3 | if (typeof window !== 'undefined' && typeof window.getComputedStyle === 'function') {
4 | win = window;
5 | } else {
6 | win = {
7 | getComputedStyle() {
8 | return {
9 | getPropertyValue() {},
10 | };
11 | },
12 | addEventListener() {},
13 | };
14 | }
15 |
16 | module.exports = win;
17 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "SSRDemo",
3 | "version": "1.0.0",
4 | "description": "Server Side Rendering Demo",
5 | "repository": {
6 | "type": "git",
7 | "url": "https://github.com/yantsishko/ssr-demo-expressjs"
8 | },
9 | "engines": {
10 | "npm": ">=3",
11 | "node": ">=5"
12 | },
13 | "author": "Yan Tsishko",
14 | "scripts": {
15 | "lint:eslint": "eslint --ignore-path .gitignore --ignore-path .eslintignore --ignore-pattern internals/scripts",
16 | "lint:staged": "lint-staged",
17 | "generate:sitemap": "node ./internals/scripts/sitemap.js",
18 | "build:clean": "rimraf ./build",
19 | "dev:start": "npm run build:clean && cross-env NODE_ENV=development webpack -w --config internals/webpack/server/webpack.dev.server.js",
20 | "prod:build": "npm run generate:sitemap && cross-env NODE_ENV=production webpack --config internals/webpack/server/webpack.prod.server.js",
21 | "server:start": "nodemon ./build/server.js"
22 | },
23 | "lint-staged": {
24 | "*.js": "lint:eslint"
25 | },
26 | "pre-commit": "lint:staged",
27 | "dependencies": {
28 | "add-asset-html-webpack-plugin": "^3.1.3",
29 | "autoprefixer": "^9.4.4",
30 | "babel-polyfill": "6.23.0",
31 | "base64-inline-loader": "^1.1.1",
32 | "circular-dependency-plugin": "3.0.0",
33 | "classnames": "^2.2.5",
34 | "compression": "1.6.2",
35 | "connected-react-router": "^6.4.0",
36 | "copy-webpack-plugin": "^4.2.3",
37 | "cross-env": "5.0.0",
38 | "eventsource-polyfill": "^0.9.6",
39 | "express": "4.15.3",
40 | "global": "^4.4.0",
41 | "history": "4.6.3",
42 | "html-webpack-plugin": "2.29.0",
43 | "immutable": "3.8.1",
44 | "isomorphic-style-loader": "^5.1.0",
45 | "less": "^2.7.3",
46 | "less-loader": "^4.0.5",
47 | "localStorage": "^1.0.4",
48 | "lodash": "^4.17.19",
49 | "mini-create-react-context": "^0.3.2",
50 | "nodemon": "^1.19.1",
51 | "prop-types": "15.5.10",
52 | "react": "16.6",
53 | "react-dom": "16.6",
54 | "react-helmet": "^5.2.0",
55 | "react-immutable-proptypes": "^2.1.0",
56 | "react-loadable": "^5.5.0",
57 | "react-redux": "^6.0.0",
58 | "react-router-config": "^5.0.1",
59 | "react-router-dom": "^5.0.1",
60 | "redux": "3.6.0",
61 | "redux-actions": "^2.6.5",
62 | "redux-connect": "^9.0.0",
63 | "redux-connect-decorator": "^0.2.1",
64 | "redux-immutable": "4.0.0",
65 | "redux-saga": "^1.0.3",
66 | "redux-thunk": "^2.2.0",
67 | "reselect": "3.0.1",
68 | "sitemap": "^1.13.0",
69 | "superagent": "^3.8.1",
70 | "uglifyjs-webpack-plugin": "1.1.5",
71 | "webpack": "3.5.5",
72 | "webpack-dev-middleware": "1.11.0",
73 | "webpack-hot-middleware": "2.18.0",
74 | "webpack-node-externals": "^1.7.2",
75 | "whatwg-fetch": "2.0.3"
76 | },
77 | "devDependencies": {
78 | "babel-cli": "6.24.1",
79 | "babel-core": "6.24.1",
80 | "babel-eslint": "7.2.3",
81 | "babel-loader": "7.1.0",
82 | "babel-plugin-react-transform": "2.0.2",
83 | "babel-plugin-styled-components": "1.1.4",
84 | "babel-plugin-transform-decorators": "^6.24.1",
85 | "babel-plugin-transform-decorators-legacy": "^1.3.5",
86 | "babel-plugin-transform-react-constant-elements": "6.23.0",
87 | "babel-plugin-transform-react-remove-prop-types": "0.4.5",
88 | "babel-preset-env": "1.5.1",
89 | "babel-preset-react": "6.24.1",
90 | "babel-preset-stage-0": "6.24.1",
91 | "css-loader": "0.28.4",
92 | "eslint": "^6.1.0",
93 | "eslint-config-airbnb": "15.0.1",
94 | "eslint-config-airbnb-base": "11.2.0",
95 | "eslint-import-resolver-webpack": "0.8.3",
96 | "eslint-plugin-import": "2.7.0",
97 | "eslint-plugin-jsx-a11y": "5.0.3",
98 | "eslint-plugin-react": "^7.12.4",
99 | "exports-loader": "0.6.4",
100 | "file-loader": "0.11.1",
101 | "html-loader": "0.4.5",
102 | "lint-staged": "3.5.1",
103 | "postcss-loader": "^3.0.0",
104 | "pre-commit": "1.2.2",
105 | "progress-bar-webpack-plugin": "^1.11.0",
106 | "rimraf": "2.6.1",
107 | "style-loader": "0.18.1",
108 | "url-loader": "0.5.8"
109 | }
110 | }
111 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | 'postcss-import': {},
4 | 'postcss-cssnext': {},
5 | cssnano: {},
6 | },
7 | };
8 |
--------------------------------------------------------------------------------
/server.js:
--------------------------------------------------------------------------------
1 | import 'babel-polyfill';
2 |
3 | import path from 'path';
4 | import fs from 'fs';
5 | import { Provider } from 'react-redux';
6 | import Loadable from 'react-loadable';
7 |
8 | import Helmet from 'react-helmet';
9 | import React from 'react';
10 | import express from 'express';
11 | import { createMemoryHistory } from 'history';
12 | import ReactDOMServer from 'react-dom/server';
13 | import { StaticRouter } from 'react-router-dom';
14 |
15 | import configureStore from './app/configureStore';
16 | import { parse as parseUrl } from 'url'
17 | import { ReduxAsyncConnect, loadOnServer } from 'redux-connect'
18 | import StyleContext from 'isomorphic-style-loader/StyleContext'
19 |
20 | import routes from './app/containers/App/routes/routesJson';
21 | import { StaticRoutesConfig } from './app/containers/App/routes/StaticRoutes';
22 | import sagas from './app/sagas';
23 |
24 | const PORT = process.env.PORT || 3001;
25 | const app = express();
26 |
27 | app.use(express.static('./build'));
28 |
29 | const initialState = {};
30 |
31 | app.get('*', (req, res) => {
32 | const url = req.originalUrl || req.url;
33 | const history = createMemoryHistory({
34 | initialEntries: [url],
35 | });
36 | const store = configureStore(initialState, history);
37 | const location = parseUrl(url);
38 | const helpers = {};
39 | const indexFile = path.resolve('./build/main.html');
40 |
41 | store.runSaga(sagas).toPromise().then(() => {
42 | return loadOnServer({ store, location, routes, helpers })
43 | .then(() => {
44 | const context = {};
45 |
46 | if (context.url) {
47 | req.header('Location', context.url);
48 | return res.send(302)
49 | }
50 |
51 | const css = new Set(); // CSS for all rendered React components
52 | const insertCss = (...styles) => styles.forEach(style => css.add(style._getCss()));
53 |
54 | const dynamicRoutes = [...routes];
55 | dynamicRoutes[0].routes = [...dynamicRoutes[0].routes, ...StaticRoutesConfig];
56 |
57 | const appContent = ReactDOMServer.renderToString(
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 | );
66 |
67 | const helmet = Helmet.renderStatic();
68 |
69 | fs.readFile(indexFile, 'utf8', (err, data) => {
70 | if (err) {
71 | console.log('Something went wrong:', err);
72 | return res.status(500).send('Oops, better luck next time!');
73 | }
74 | data = data.replace('__STYLES__', [...css].join(''));
75 | data = data.replace('__LOADER__', '');
76 | data = data.replace('', `${appContent}
`);
77 | data = data.replace('', `${appContent}
`);
78 | data = data.replace('', helmet.title.toString());
79 | data = data.replace('', helmet.meta.toString());
80 | data = data.replace('', ``);
81 |
82 | return res.send(data);
83 | });
84 | });
85 | store.close();
86 | });
87 | });
88 |
89 | Loadable.preloadAll().then(() => {
90 | app.listen(PORT, () => {
91 | console.log(`😎 Server is listening on port ${PORT}`);
92 | });
93 | });
94 |
95 |
--------------------------------------------------------------------------------