├── 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 | random dog 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 | --------------------------------------------------------------------------------