├── src
├── app
│ ├── fetchDog.js
│ ├── style.css
│ └── index.js
├── client.js
├── sw-template.js
├── server.js
└── html.js
├── index.js
├── README.md
├── webpack
├── webpack.common.js
├── webpack.dev.js
├── webpack-isomorphic-tools-config.js
└── webpack.prod.js
├── babel.config.js
├── .eslintrc.js
├── LICENSE
├── .gitignore
└── package.json
/src/app/fetchDog.js:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 |
3 | export default () => {
4 | return axios.get('https://dog.ceo/api/breeds/image/random');
5 | };
6 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | require('@babel/register'); // eslint-disable-line
2 |
3 | const WebpackIsomorphicTools = require('webpack-isomorphic-tools');
4 | global.webpackIsomorphicTools = new WebpackIsomorphicTools(require('./webpack/webpack-isomorphic-tools-config'))
5 | .server(__dirname, function () {
6 | require('./src/server');
7 | });
8 |
--------------------------------------------------------------------------------
/src/client.js:
--------------------------------------------------------------------------------
1 | import App from './app';
2 | import React from 'react';
3 | import { hydrate, render } from 'react-dom';
4 |
5 | const rootElement = document.getElementById('root');
6 |
7 | if (rootElement.hasChildNodes) {
8 | const preloaded = window.__PRELOADED__;
9 | delete window.__PRELOADED__;
10 | hydrate(, rootElement);
11 | } else {
12 | render(, rootElement);
13 | }
14 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # react-pwa-demo
2 | This is the demo app for my blog article [How To Turn a Server-side-rendered React SPA into a PWA](https://sunkanqiang.com/how-to-turn-ssr-react-spa-into-pwa/)
3 |
4 | ## How To Start
5 |
6 | git clone the project, then
7 | ```
8 | npm install
9 |
10 | npm run start
11 | ```
12 |
13 | The app will be running in `localhost:3000`.
14 |
15 | If you need to make some changes, you can do this in dev mode
16 |
17 | ```
18 | npm run dev
19 | ```
20 |
21 | ## License
22 |
23 | MIT
--------------------------------------------------------------------------------
/webpack/webpack.common.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 |
3 | module.exports = {
4 | entry: './src/client.js',
5 | output: {
6 | path: path.resolve(__dirname, '..', 'build'),
7 | filename: '[name].[hash].js',
8 | publicPath: '/'
9 | },
10 | devtool: 'source-map',
11 | module: {
12 | rules: [
13 | {
14 | enforce: 'pre',
15 | test: /\.(js|jsx)$/,
16 | exclude: /node_modules/,
17 | use: 'eslint-loader'
18 | },
19 | {
20 | test: /\.(js|jsx)$/,
21 | exclude: /node_modules/,
22 | use: 'babel-loader'
23 | },
24 | ]
25 | }
26 | };
27 |
--------------------------------------------------------------------------------
/src/app/style.css:
--------------------------------------------------------------------------------
1 | html, body {
2 | padding: 0;
3 | margin: 0;
4 | }
5 |
6 | .app {
7 | max-width: 480px;
8 | margin: 0 auto;
9 | }
10 |
11 | .header {
12 | font-size: 20px;
13 | font-weight: bold;
14 | text-align: center;
15 | padding: 10px 0;
16 | border-bottom: 1px solid #ddd;
17 | }
18 |
19 | .main__loading {
20 | padding: 100px 0;
21 | text-align: center;
22 | font-size: 20px;
23 | }
24 |
25 | .dog {
26 | margin: 30px 0;
27 | padding: 0 15px;
28 | }
29 | .dog > h1 {
30 | margin: 0 0 20px;
31 | text-align: center;
32 | }
33 | .dog > img {
34 | width: 100%;
35 | height: auto;
36 | border-radius: 8px;
37 | overflow: hidden;
38 | }
39 |
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = function(api) {
2 | /**
3 | * In order to enable tree-shaking in webpack, babel-loader should
4 | * use the setting: 'modules': false.
5 | * For server side, we need to set: 'modules': 'auto'.
6 | * So we use the caller.name to detect whether the config is called by server
7 | */
8 | const isRegister = api.caller(function(caller) {
9 | return !!(caller && caller.name === '@babel/register');
10 | });
11 |
12 | return {
13 | presets: [
14 | ['@babel/preset-env', { 'modules': isRegister ? 'auto' : false }],
15 | '@babel/preset-react'
16 | ],
17 | plugins: [
18 | '@babel/plugin-proposal-class-properties'
19 | ]
20 | };
21 | };
22 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | "env": {
3 | "browser": true,
4 | "commonjs": true,
5 | "es6": true,
6 | "node": true
7 | },
8 | "extends": "eslint:recommended",
9 | "parserOptions": {
10 | "ecmaFeatures": {
11 | "jsx": true,
12 | "classProperties": true
13 | },
14 | "ecmaVersion": 2018,
15 | "sourceType": "module"
16 | },
17 | "parser": "babel-eslint",
18 | "plugins": [
19 | "react"
20 | ],
21 | "rules": {
22 | "indent": [
23 | "error",
24 | 2
25 | ],
26 | "linebreak-style": [
27 | "error",
28 | "unix"
29 | ],
30 | "quotes": [
31 | "error",
32 | "single"
33 | ],
34 | "semi": [
35 | "error",
36 | "always"
37 | ],
38 | "react/jsx-uses-vars": 1,
39 | "react/jsx-uses-react": 1,
40 | "no-console": 0,
41 | "linebreak-style": 0
42 | }
43 | };
--------------------------------------------------------------------------------
/webpack/webpack.dev.js:
--------------------------------------------------------------------------------
1 | const merge = require('webpack-merge');
2 | const common = require('./webpack.common.js');
3 | const HtmlWebpackPlugin = require('html-webpack-plugin');
4 | const renderHtml = require('../src/html');
5 |
6 | module.exports = merge(common, {
7 | mode: 'development',
8 | devServer: {
9 | stats: 'minimal'
10 | },
11 | module: {
12 | rules: [
13 | {
14 | test: /\.css$/,
15 | use: [
16 | 'style-loader',
17 | 'css-loader',
18 | {
19 | loader: 'postcss-loader',
20 | options: {
21 | plugins: [
22 | require('autoprefixer')
23 | ]
24 | }
25 | }
26 | ]
27 | }
28 | ]
29 | },
30 | plugins: [
31 | new HtmlWebpackPlugin({
32 | templateContent: renderHtml(),
33 | minify: {
34 | removeComments: true,
35 | collapseWhitespace: true
36 | }
37 | })
38 | ]
39 | });
40 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 Sun Kanqiang
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 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 |
8 | # Runtime data
9 | pids
10 | *.pid
11 | *.seed
12 | *.pid.lock
13 |
14 | # Directory for instrumented libs generated by jscoverage/JSCover
15 | lib-cov
16 |
17 | # Coverage directory used by tools like istanbul
18 | coverage
19 |
20 | # nyc test coverage
21 | .nyc_output
22 |
23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
24 | .grunt
25 |
26 | # Bower dependency directory (https://bower.io/)
27 | bower_components
28 |
29 | # node-waf configuration
30 | .lock-wscript
31 |
32 | # Compiled binary addons (https://nodejs.org/api/addons.html)
33 | build/Release
34 |
35 | # Dependency directories
36 | node_modules/
37 | jspm_packages/
38 |
39 | # TypeScript v1 declaration files
40 | typings/
41 |
42 | # Optional npm cache directory
43 | .npm
44 |
45 | # Optional eslint cache
46 | .eslintcache
47 |
48 | # Optional REPL history
49 | .node_repl_history
50 |
51 | # Output of 'npm pack'
52 | *.tgz
53 |
54 | # Yarn Integrity file
55 | .yarn-integrity
56 |
57 | # dotenv environment variables file
58 | .env
59 |
60 | # next.js build output
61 | .next
62 |
63 | # build result folder
64 | build/
65 |
--------------------------------------------------------------------------------
/src/app/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTyeps from 'prop-types';
3 | import fetchDog from './fetchDog';
4 | import './style.css';
5 |
6 | export default class App extends React.Component {
7 |
8 | static propTypes = {
9 | preloaded: PropTyeps.object
10 | };
11 |
12 | componentDidMount() {
13 | if (!this.props.preloaded) {
14 | this.loadDog();
15 | }
16 | }
17 |
18 | state = {
19 | dog: this.props.preloaded
20 | };
21 |
22 | loadDog() {
23 | fetchDog()
24 | .then(response => {
25 | this.setState({
26 | dog: response.data
27 | });
28 | })
29 | .catch(err => console.error(err));
30 | }
31 |
32 | render() {
33 | const { dog } = this.state;
34 |
35 | return (
36 |
37 |
React PWA Demo
38 |
39 | {
40 | dog ?
41 | (
42 |
43 |
A Random Cute Dog!
44 |

45 |
46 | ) :
47 | (
48 |
Loading
49 | )
50 | }
51 |
52 |
53 | );
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/webpack/webpack-isomorphic-tools-config.js:
--------------------------------------------------------------------------------
1 | var WebpackIsomorphicToolsPlugin = require('webpack-isomorphic-tools/plugin');
2 |
3 | // see this link for more info on what all of this means
4 | // https://github.com/halt-hammerzeit/webpack-isomorphic-tools
5 |
6 | module.exports = {
7 |
8 | // when adding "js" extension to asset types
9 | // and then enabling debug mode, it may cause a weird error:
10 | //
11 | // [0] npm run start-prod exited with code 1
12 | // Sending SIGTERM to other processes..
13 | //
14 | // debug: true,
15 | webpack_assets_file_path: 'build/webpack-assets.json',
16 |
17 | assets: {
18 | images: {
19 | extensions: [
20 | 'jpeg',
21 | 'jpg',
22 | 'png',
23 | 'gif'
24 | ]
25 | },
26 | svg: {
27 | extension: 'svg'
28 | },
29 | style_modules: {
30 | extensions: ['css', 'scss'],
31 | filter: function (module, regex, options, log) {
32 | if (options.development) {
33 | // in development e's webpack "style-loader",
34 | // so the module.name is not equal to module.name
35 | return WebpackIsomorphicToolsPlugin.style_loader_filter(module, regex, options, log);
36 | }
37 | },
38 | path: WebpackIsomorphicToolsPlugin.style_loader_path_extractor,
39 | parser: WebpackIsomorphicToolsPlugin.css_modules_loader_parser
40 | }
41 | }
42 | };
43 |
--------------------------------------------------------------------------------
/src/sw-template.js:
--------------------------------------------------------------------------------
1 | /*eslint-disable */
2 |
3 | self.__precacheManifest = [].concat(self.__precacheManifest || []);
4 |
5 | workbox.core.setCacheNameDetails({
6 | prefix: 'react-pwa-demo',
7 | suffix: 'v1',
8 | precache: 'install-time',
9 | runtime: 'run-time',
10 | googleAnalytics: 'ga',
11 | });
12 |
13 | // active new service worker as long as it's installed
14 | workbox.clientsClaim();
15 | workbox.skipWaiting();
16 |
17 | // suppress warnings if revision is not provided
18 | workbox.precaching.suppressWarnings();
19 |
20 | // precahce and route asserts built by webpack
21 | workbox.precaching.precacheAndRoute(self.__precacheManifest, {});
22 |
23 | // return app shell for all navigation requests
24 | workbox.routing.registerNavigationRoute('/app-shell');
25 |
26 | // routing for api
27 | workbox.routing.registerRoute(
28 | /^https:\/\/dog\.ceo/i,
29 | workbox.strategies.networkFirst({
30 | cacheName: 'react-pwa-demo-api-cache'
31 | })
32 | );
33 |
34 | // routing for cloud served images
35 | workbox.routing.registerRoute(
36 | /^https:\/\/.+\.(jpe?g|png|gif|svg)$/i,
37 | workbox.strategies.cacheFirst({
38 | cacheName: 'react-pwa-demo-image-cache',
39 | plugins: [
40 | new workbox.expiration.Plugin({
41 | // Only cache requests for a week
42 | maxAgeSeconds: 7 * 24 * 60 * 60,
43 | // Only cache 20 requests.
44 | maxEntries: 20
45 | }),
46 | new workbox.cacheableResponse.Plugin({
47 | statuses: [0, 200]
48 | })
49 | ]
50 | })
51 | );
52 |
53 | /*eslint-enable */
54 |
--------------------------------------------------------------------------------
/src/server.js:
--------------------------------------------------------------------------------
1 | import Express from 'express';
2 | import path from 'path';
3 | import React from 'react';
4 | import { renderToString } from 'react-dom/server';
5 | import fetchDog from './app/fetchDog';
6 | import renderHtml from './html';
7 | import App from './app';
8 |
9 | const app = Express();
10 | const port = 3000;
11 |
12 | // Serve static files
13 | app.use(Express.static(path.resolve(__dirname, '..', 'build'), {
14 | maxAge: 365 * 24 * 3600000 // long time cache static files
15 | }));
16 |
17 | function hydrateOnClient() {
18 | return renderHtml({
19 | assets: webpackIsomorphicTools.assets(), // eslint-disable-line
20 | enableSW: true
21 | });
22 | }
23 |
24 | app.use('/app-shell', (req, res) => {
25 | res.send(hydrateOnClient());
26 | });
27 |
28 | // serve request
29 | app.use((req, res) => {
30 |
31 | fetchDog()
32 | .then(response => {
33 | const preloaded = response.data;
34 |
35 | // render the app
36 | const html = renderToString();
37 |
38 | // Send the rendered page back to the client
39 | res.send(renderHtml({
40 | assets: webpackIsomorphicTools.assets(), // eslint-disable-line
41 | html,
42 | preloaded,
43 | enableSW: true
44 | }));
45 | })
46 | .catch(err => {
47 | console.error(err);
48 | res.status(500);
49 | res.send(hydrateOnClient());
50 | });
51 | });
52 |
53 | app.listen(port, (err) => {
54 | if (err) {
55 | console.error(err);
56 | }
57 | console.info('==> Open localhost:%s in a browser to view the app.', port);
58 | });
59 |
--------------------------------------------------------------------------------
/webpack/webpack.prod.js:
--------------------------------------------------------------------------------
1 | const merge = require('webpack-merge');
2 | const common = require('./webpack.common.js');
3 | const path = require('path');
4 |
5 | // const path = require('path');
6 | const MiniCssExtractPlugin = require('mini-css-extract-plugin');
7 | const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
8 | const UglifyJsPlugin = require('uglifyjs-webpack-plugin');
9 | const WebpackIsomorphicToolsPlugin = require('webpack-isomorphic-tools/plugin');
10 | const { InjectManifest } = require('workbox-webpack-plugin');
11 |
12 |
13 | module.exports = merge(common, {
14 | mode: 'production',
15 | module: {
16 | rules: [
17 | {
18 | test: /\.css$/,
19 | use: [
20 | MiniCssExtractPlugin.loader,
21 | 'css-loader',
22 | {
23 | loader: 'postcss-loader',
24 | options: {
25 | plugins: [
26 | require('autoprefixer')
27 | ]
28 | }
29 | }
30 | ]
31 | }
32 | ]
33 | },
34 | plugins: [
35 | new MiniCssExtractPlugin({ filename: '[name].[hash].css' }),
36 |
37 | new WebpackIsomorphicToolsPlugin(require('./webpack-isomorphic-tools-config')),
38 |
39 | new InjectManifest({
40 | swDest: 'sw.js',
41 | swSrc: path.resolve(__dirname, '..', 'src/sw-template.js'),
42 | include: ['/app-shell', /\.js$/, /\.css$/],
43 | templatedUrls: {
44 | '/app-shell': new Date().toString(),
45 | },
46 | }),
47 | ],
48 | optimization: {
49 | minimizer: [
50 | new UglifyJsPlugin({
51 | cache: true,
52 | parallel: true,
53 | sourceMap: true
54 | }),
55 | new OptimizeCSSAssetsPlugin({})
56 | ]
57 | }
58 | });
59 |
--------------------------------------------------------------------------------
/src/html.js:
--------------------------------------------------------------------------------
1 | module.exports = (options = {}) => {
2 | const { assets = { styles: {}, javascript: {}}, html = '', preloaded= '', enableSW = false } = options;
3 |
4 | return (
5 | `
6 |
7 |
8 |
9 | React PWA Demo
10 |
11 |
12 |
13 |
14 | ${Object.keys(assets.styles).map(style => ``).join('')}
15 |
16 |
17 | ${html}
18 | ${preloaded&&
19 | `
20 |
23 | `}
24 | ${Object.keys(assets.javascript).map(js => ``).join('')}
25 | ${enableSW ? `
26 | ` : ''}
37 |
38 |
39 | `
40 | );
41 | };
42 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-pwa-demo",
3 | "version": "1.0.0",
4 | "description": "react pwa demo app",
5 | "main": "index.js",
6 | "dependencies": {
7 | "@babel/register": "^7.0.0",
8 | "axios": "^0.18.0",
9 | "express": "^4.16.3",
10 | "prop-types": "^15.6.2",
11 | "react": "^16.5.1",
12 | "react-dom": "^16.5.1"
13 | },
14 | "devDependencies": {
15 | "@babel/core": "^7.0.1",
16 | "@babel/plugin-proposal-class-properties": "^7.0.0",
17 | "@babel/preset-env": "^7.0.0",
18 | "@babel/preset-react": "^7.0.0",
19 | "autoprefixer": "^9.1.5",
20 | "babel-eslint": "^9.0.0",
21 | "babel-loader": "^8.0.2",
22 | "css-loader": "^1.0.0",
23 | "eslint": "^5.6.0",
24 | "eslint-loader": "^2.1.0",
25 | "eslint-plugin-react": "^7.11.1",
26 | "html-webpack-plugin": "^3.2.0",
27 | "mini-css-extract-plugin": "^0.4.2",
28 | "optimize-css-assets-webpack-plugin": "^5.0.1",
29 | "postcss": "^7.0.2",
30 | "postcss-loader": "^3.0.0",
31 | "rimraf": "2.6.2",
32 | "style-loader": "^0.23.0",
33 | "uglifyjs-webpack-plugin": "^2.0.0",
34 | "webpack": "^4.19.0",
35 | "webpack-cli": "^3.1.0",
36 | "webpack-dev-server": "^3.1.8",
37 | "webpack-isomorphic-tools": "^3.0.6",
38 | "webpack-merge": "^4.1.4",
39 | "workbox-webpack-plugin": "^3.5.0"
40 | },
41 | "sideEffects": [
42 | "*.css"
43 | ],
44 | "browserslist": [
45 | "> 0.5%",
46 | "last 2 versions"
47 | ],
48 | "scripts": {
49 | "dev": "webpack-dev-server --hot --inline --config webpack/webpack.dev.js",
50 | "build": "rimraf build && webpack --config webpack/webpack.prod.js",
51 | "start": "npm run build && node index.js"
52 | },
53 | "repository": {
54 | "type": "git",
55 | "url": "git+https://github.com/SickSAMA/react-pwa-demo.git"
56 | },
57 | "keywords": [
58 | "react",
59 | "pwa",
60 | "webpack",
61 | "service",
62 | "worker",
63 | "workbox",
64 | "server-side-rendering"
65 | ],
66 | "author": "Sun Kanqiang",
67 | "license": "MIT",
68 | "bugs": {
69 | "url": "https://github.com/SickSAMA/react-pwa-demo/issues"
70 | },
71 | "homepage": "https://github.com/SickSAMA/react-pwa-demo#readme"
72 | }
73 |
--------------------------------------------------------------------------------