├── .dockerignore ├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .husky ├── .gitignore ├── pre-commit └── pre-push ├── .lintstagedrc ├── .prettierignore ├── .prettierrc ├── .swcrc ├── .vscode └── settings.json ├── Dockerfile ├── LICENSE ├── README.md ├── generators └── component │ ├── index.js │ ├── index.jsx.hbs │ └── index.test.jsx.hbs ├── jest.config.js ├── jest ├── __mocks__ │ ├── api-mock.js │ ├── file-mock.js │ ├── style-mock.js │ └── svgr-mock.js ├── configs │ ├── base.config.js │ ├── client.config.js │ └── server.config.js └── setup-test.js ├── jsconfig.json ├── package.json ├── plopfile.js ├── postcss.config.js ├── public ├── android-icon-144x144.png ├── android-icon-192x192.png ├── android-icon-36x36.png ├── android-icon-48x48.png ├── android-icon-72x72.png ├── android-icon-96x96.png ├── apple-icon-114x114.png ├── apple-icon-120x120.png ├── apple-icon-144x144.png ├── apple-icon-152x152.png ├── apple-icon-180x180.png ├── apple-icon-57x57.png ├── apple-icon-60x60.png ├── apple-icon-72x72.png ├── apple-icon-76x76.png ├── apple-icon-precomposed.png ├── apple-icon.png ├── browserconfig.xml ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon-96x96.png ├── favicon.ico ├── icon-192x192.png ├── icon-512x512.png ├── icon-maskable.png ├── ms-icon-144x144.png ├── ms-icon-150x150.png ├── ms-icon-310x310.png ├── ms-icon-70x70.png ├── robots.txt └── site.webmanifest ├── scripts └── pretest.js ├── src ├── client │ ├── api │ │ ├── index.js │ │ └── request.js │ ├── app │ │ ├── __tests__ │ │ │ └── index.test.jsx │ │ └── index.jsx │ ├── assets │ │ ├── images │ │ │ └── logo.svg │ │ └── styles │ │ │ └── index.scss │ ├── components │ │ ├── Header │ │ │ ├── __tests__ │ │ │ │ └── index.test.jsx │ │ │ ├── index.jsx │ │ │ └── style.module.scss │ │ └── Loading │ │ │ ├── __tests__ │ │ │ └── index.test.jsx │ │ │ └── index.jsx │ ├── hooks │ │ └── useIsomorphicLayoutEffect.js │ ├── index.jsx │ ├── pages │ │ ├── NotFound │ │ │ ├── __tests__ │ │ │ │ └── index.test.jsx │ │ │ └── index.jsx │ │ ├── UserInfo │ │ │ ├── __tests__ │ │ │ │ └── index.test.jsx │ │ │ └── index.jsx │ │ └── UserList │ │ │ ├── __tests__ │ │ │ └── index.test.jsx │ │ │ └── index.jsx │ ├── report-web-vitals.js │ ├── routes.js │ ├── service-worker.js │ ├── store │ │ ├── index.js │ │ ├── reducers.js │ │ └── slices │ │ │ ├── user-info-slice.js │ │ │ └── user-list-slice.js │ └── utils │ │ └── string.js ├── configs │ ├── client.js │ ├── constants.js │ └── server.js ├── server │ ├── app │ │ ├── index.js │ │ ├── index.test.js │ │ └── websocket-server-creator.js │ ├── db │ │ └── users.json │ ├── index.js │ ├── middlewares │ │ └── webpack.middleware.js │ ├── render │ │ ├── index.js │ │ ├── index.test.js │ │ ├── render-html.js │ │ └── sitemap-generator.js │ └── terminate.js └── test-utils │ └── render.js ├── webpack ├── entries │ └── react-error-overlay.js ├── loaders │ └── url-loader.js ├── plugins │ ├── dotenv-webpack-plugin.js │ └── spawn-webpack-plugin.js ├── webpack.config.base.js ├── webpack.config.client.js └── webpack.config.server.js └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | build 4 | 5 | coverage 6 | 7 | public/* 8 | !public/robots.txt 9 | !public/android-icon-* 10 | !public/apple-icon* 11 | !public/browserconfig.xml 12 | !public/favicon* 13 | !public/ms-icon* 14 | !public/icon-192x192.png 15 | !public/icon-512x512.png 16 | !public/icon-maskable.png 17 | 18 | Dockerfile 19 | .dockerignore 20 | 21 | .git 22 | .github 23 | .gitignore 24 | 25 | .DS_Store 26 | .all-contributorsrc 27 | .editorconfig 28 | *.log 29 | *.md 30 | !README.md 31 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | public 4 | coverage 5 | .history -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | const { getAlias } = require('./webpack/webpack.config.base'); 2 | 3 | module.exports = { 4 | env: { 5 | browser: true, 6 | es2021: true, 7 | node: true, 8 | jest: true, 9 | }, 10 | extends: ['plugin:react/recommended', 'airbnb', 'prettier'], 11 | parserOptions: { 12 | ecmaFeatures: { 13 | jsx: true, 14 | }, 15 | ecmaVersion: 13, 16 | sourceType: 'module', 17 | }, 18 | plugins: ['react', 'prettier'], 19 | settings: { 20 | 'import/resolver': { 21 | alias: { 22 | map: Object.entries(getAlias()), 23 | extensions: ['.js', '.jsx', '.json'], 24 | }, 25 | }, 26 | }, 27 | globals: { 28 | __CLIENT__: 'readonly', 29 | __SERVER__: 'readonly', 30 | __DEV__: 'readonly', 31 | }, 32 | rules: { 33 | 'prettier/prettier': ['error', { endOfLine: 'auto' }], 34 | 'react/jsx-filename-extension': ['warn', { extensions: ['.js', '.jsx'] }], 35 | 'react/jsx-props-no-spreading': 'off', 36 | 'react/function-component-definition': [ 37 | 'error', 38 | { namedComponents: 'arrow-function', unnamedComponents: 'arrow-function' }, 39 | ], 40 | 'import/prefer-default-export': 'off', 41 | 'import/no-import-module-exports': [ 42 | 'error', 43 | { 44 | exceptions: ['**/src/client/index.jsx'], // only use for `module.hot` in src/client/index.jsx 45 | }, 46 | ], 47 | 'import/no-extraneous-dependencies': [ 48 | 'error', 49 | { 50 | devDependencies: true, 51 | }, 52 | ], 53 | 'no-unused-vars': ['error', { argsIgnorePattern: '^_' }], 54 | 'no-underscore-dangle': 'off', 55 | 'no-param-reassign': ['error', { props: true, ignorePropertyModificationsFor: ['state'] }], 56 | // 'no-console': 'warn', 57 | }, 58 | }; 59 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: CI 5 | 6 | on: 7 | push: 8 | branches: [main, develop] 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | 14 | strategy: 15 | matrix: 16 | node-version: [14.17.0, 16.x, 17.x] 17 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 18 | 19 | steps: 20 | - uses: actions/checkout@v2 21 | 22 | - name: Use Node.js ${{ matrix.node-version }} 23 | uses: actions/setup-node@v2 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | cache: 'yarn' 27 | 28 | - name: Install dependencies 29 | run: yarn install --frozen-lockfile 30 | 31 | - name: Run tests 32 | run: yarn test:ci 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | # Git history 107 | .history 108 | 109 | build 110 | 111 | public/* 112 | !public/robots.txt 113 | !public/android-icon-* 114 | !public/apple-icon* 115 | !public/browserconfig.xml 116 | !public/favicon* 117 | !public/ms-icon* 118 | !public/icon-192x192.png 119 | !public/icon-512x512.png 120 | !public/icon-maskable.png 121 | !public/site.webmanifest -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npm test 5 | -------------------------------------------------------------------------------- /.lintstagedrc: -------------------------------------------------------------------------------- 1 | { 2 | "*.{js,jsx}": [ 3 | "eslint --fix", 4 | "git add ." 5 | ], 6 | "**/*": [ 7 | "prettier -w -u", 8 | "git add ." 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | public 4 | coverage 5 | .history 6 | *.hbs -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "trailingComma": "all", 4 | "singleQuote": true, 5 | "semi": true, 6 | "bracketSpacing": true, 7 | "jsxSingleQuote": true, 8 | "bracketSameLine": true, 9 | "arrowParens": "always", 10 | "printWidth": 120 11 | } 12 | -------------------------------------------------------------------------------- /.swcrc: -------------------------------------------------------------------------------- 1 | { 2 | "jsc": { 3 | "parser": { 4 | "syntax": "ecmascript", 5 | "jsx": true, 6 | "dynamicImport": true 7 | }, 8 | "transform": { 9 | "react": { 10 | "runtime": "automatic" 11 | } 12 | } 13 | }, 14 | "module": { 15 | "type": "commonjs" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.associations": { 3 | "*.js": "javascript", 4 | "*.jsx": "javascriptreact", 5 | "*.ts": "typescript", 6 | "*.tsx": "typescriptreact" 7 | }, 8 | "emmet.includeLanguages": { 9 | "javascript": "javascriptreact" 10 | }, 11 | "editor.autoClosingQuotes": "always", 12 | "editor.defaultFormatter": "esbenp.prettier-vscode", 13 | "editor.formatOnSave": true 14 | } 15 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Install dependencies only when needed 2 | FROM node:14-alpine AS deps 3 | 4 | WORKDIR /app 5 | 6 | # install dependencies 7 | COPY package.json yarn.lock ./ 8 | 9 | RUN yarn install --frozen-lockfile 10 | 11 | # Rebuild the source code only when needed 12 | FROM node:14-alpine AS builder 13 | 14 | WORKDIR /app 15 | 16 | COPY . . 17 | COPY --from=deps /app/node_modules ./node_modules 18 | 19 | RUN yarn build 20 | RUN yarn install --production --ignore-scripts --prefer-offline 21 | 22 | # Production image, copy all the files and run app 23 | FROM node:14-alpine AS runner 24 | 25 | WORKDIR /app 26 | 27 | ENV NODE_ENV production 28 | 29 | COPY --from=builder /app/node_modules ./node_modules 30 | COPY --from=builder /app/build ./build 31 | COPY --from=builder /app/public ./public 32 | COPY --from=builder /app/package.json ./package.json 33 | 34 | EXPOSE 9090 35 | 36 | CMD yarn start -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Huỳnh Trần Đăng Khoa 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 |

react-ssr-starter

2 | 3 |

4 | banner 5 |

6 | 7 |

8 | 9 | Depfu 10 | 11 | 12 | 13 | CI 14 | 15 | 16 | 17 | CodeFactor 18 | 19 | 20 | 21 | Eslint: airbnb 22 | 23 | 24 | 25 | Formatter: prettier 26 | 27 |

28 | 29 |

This is a boilerplate inspired CRA. Designed with high scalability, an offline-first foundation, and a focus on performance and best practices.

30 | 31 | ## Prerequisites 32 | 33 | - [Node.js](https://nodejs.org/en/download/): `^14.17.0` or `>=16.0.0` 34 | 35 | ## Getting Started 36 | 37 | ```sh 38 | git clone https://github.com/htdangkhoa/react-ssr-starter 39 | 40 | yarn install # or npm install 41 | 42 | yarn dev # or npm run dev 43 | ``` 44 | 45 | ## Highlight 46 | 47 | Using [SWC](https://swc.rs) will give build times **1.5x** faster for the server and **2.2x** for the client instead of using Babel. 48 | 49 | | Babel | SWC | 50 | | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | 51 | | ![Babel](https://user-images.githubusercontent.com/15341301/140147312-322db462-9374-4da0-a8f9-e04fd10e7430.png) | ![Swc](https://user-images.githubusercontent.com/15341301/140154139-a71b21e3-d800-4ecd-8fa4-5329e563c05b.png) | 52 | 53 | ## Features 54 | 55 | - [React](https://reactjs.org) - A JavaScript library for building user interfaces. 56 | - [Redux](https://redux.js.org) - A Predictable State Container for JS Apps. 57 | - [Redux Toolkit](https://redux-toolkit.js.org) - The official, opinionated, batteries-included toolset for efficient Redux development. 58 | - [React Router](https://github.com/remix-run/react-router) - Declarative routing for React. 59 | - [pure-http](https://github.com/htdangkhoa/pure-http) - The simple web framework for Node.js with zero dependencies. 60 | - [axios](https://github.com/axios/axios) - Promise based HTTP client for the browser and node.js. 61 | - [Webpack](https://webpack.js.org) - App bundling. 62 | - [SWC](https://swc.rs) - A super-fast compiler written in rust, producing widely-supported javascript from modern standards and typescript. 63 | - [React Refresh](https://github.com/facebook/react/tree/main/packages/react-refresh) - Fast refresh components without losing their state. 64 | - [react-helmet](https://github.com/nfl/react-helmet) - A document head manager for React. 65 | - [react-helmet-async](https://github.com/staylor/react-helmet-async) - Thread-safe Helmet for React 16+ and friends. 66 | - [loadable-component](https://github.com/gregberge/loadable-components) - The recommended Code Splitting library for React. 67 | - [dotenv](https://github.com/motdotla/dotenv) - Loads environment variables from `.env` for nodejs projects. 68 | - [Webpack Dev Middleware](https://github.com/webpack/webpack-dev-middleware) - Serves the files emitted from webpack over the Express server. 69 | - [Webpack Hot Middleware](https://github.com/webpack-contrib/webpack-hot-middleware) - Allows you to add hot reloading into the Express server. 70 | - [ESLint](https://eslint.org) - Find and fix problems in your JavaScript code. 71 | - [Prettier](https://prettier.io/) - Format code and style. 72 | - Integrate [Jest](https://jestjs.io/) with [Supertest](https://github.com/visionmedia/supertest), [Nock](https://github.com/nock/nock) and [React Testing Library](https://github.com/testing-library/react-testing-library) as the solution for writing unit tests with code coverage support. 73 | 74 | ## Scripts 75 | 76 | | Script | Description | 77 | | ------------------- | --------------------------------------------------------------------------------- | 78 | | `dev` | Runs your app on the development server at `localhost:9090`. HMR will be enabled. | 79 | | `build` | Bundles both server-side and client-side files. | 80 | | `build:server` | Bundles server-side files in production mode and put it to the `build`. | 81 | | `build:client` | Bundles client-side files in production mode and put it to the `public`. | 82 | | `start` | Runs your app after bundled. | 83 | | `test` | Runs testing. | 84 | | `docker` | Builds then run docker. | 85 | | `docker:build` | Builds docker. | 86 | | `docker:run` | Runs docker. | 87 | | `gen` or `generate` | Generate React component automatic based on template. | 88 | 89 | ## Environment Variables 90 | 91 | Your project can consume variables declared in your environment as if they were declared locally in your JS files. By default you will have `NODE_ENV` defined for you, and you can define any other variables that you want but for the React app, your variables name must be have `REACT_APP_` prefix. 92 | 93 | > WARNING: Do not store any secrets (such as private API keys) in your React app! 94 | > 95 | > Environment variables are embedded into the build, meaning anyone can view them by inspecting your app's files. 96 | 97 | To define permanent environment variables, create a file called .env in the root of your project: 98 | 99 | ``` 100 | # For node 101 | PRIVATE_CODE=123456 102 | 103 | # For React app 104 | REACT_APP_NOT_SECRET_CODE=abcdef 105 | ``` 106 | 107 | > NOTE: You need to restart the development server after changing `.env` files. 108 | 109 | ### What other `.env` files can be used? 110 | 111 | - `.env`: Default. 112 | - `.env.local`: Local overrides. This file is loaded for all environments except test. 113 | - `.env.development`, `.env.test`, `.env.production`: Environment-specific settings. 114 | - `.env.development.local`, `.env.test.local`, `.env.production.local`: Local overrides of environment-specific settings. 115 | 116 | Or you can add custom `.env` path in `webpack/webpack.config.base.js`: 117 | 118 | ```js 119 | const DotenvWebpackPlugin = require('./plugins/dotenv-webpack-plugin'); 120 | 121 | // webpack config 122 | { 123 | ..., 124 | plugins: [ 125 | ..., 126 | new DotenvWebpackPlugin({ 127 | path: './custom-env', 128 | isWeb: true|false, 129 | }), 130 | ], 131 | } 132 | ``` 133 | 134 | Please refer to the [dotenv](https://github.com/motdotla/dotenv) documentation for more details. 135 | 136 | ### Expanding Environment Variables In .env 137 | 138 | Expand variables already on your machine for use in your .env file (using [dotenv-expand](https://github.com/motdotla/dotenv-expand)). 139 | 140 | For example, to get the environment variable npm_package_version: 141 | 142 | ``` 143 | REACT_APP_VERSION=$npm_package_version 144 | # also works: 145 | # REACT_APP_VERSION=${npm_package_version} 146 | ``` 147 | 148 | Or expand variables local to the current .env file: 149 | 150 | ``` 151 | DOMAIN=www.example.com 152 | REACT_APP_FOO=$DOMAIN/foo 153 | REACT_APP_BAR=$DOMAIN/bar 154 | ``` 155 | 156 | > **NOTE:** Support [Google Search Console verification code](https://www.youtube.com/watch?v=RktlwdM3k1s) with `GOOGLE_SITE_VERIFICATION` environment variable. 157 | 158 | ## Configurations 159 | 160 | ### Basic 161 | 162 | You can store your configurations in `src/configs/client.js` for client-side, `src/configs/server.js` for server-side. `src/configs/constants.js` is for constants. 163 | 164 | You can access the correct configuration with: 165 | 166 | ```js 167 | import configs from 'configs/client'; // for client-side 168 | import configs from 'configs/server'; // for server-side 169 | import constants from 'configs/constants'; 170 | 171 | // ... 172 | ``` 173 | 174 | ### Advanced 175 | 176 | You can adjust various development and production settings by setting environment variables in your shell or with [.env](#environment-variables). 177 | 178 | > Note: You do not need to declare `REACT_APP_` before the below variables as you would with custom environment variables. 179 | 180 | | Variable | Development | Production | Usage | 181 | | ------------------------- | ----------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 182 | | `BROWSER` | ✅ Used | 🚫 Ignored | By default, Create React App will open the default system browser, favoring Chrome on macOS. Specify a [browser](https://github.com/sindresorhus/open#app) to override this behavior, or set it to `none` to disable it completely. If you need to customize the way the browser is launched, you can specify a node script instead. Any arguments passed to `npm start` will also be passed to this script, and the url where your app is served will be the last argument. Your script's file name must have the `.js` extension. | 183 | | `BROWSER_ARGS` | ✅ Used | 🚫 Ignored | When the `BROWSER` environment variable is specified, any arguments that you set to this environment variable will be passed to the browser instance. Multiple arguments are supported as a space separated list. By default, no arguments are passed through to browsers. | 184 | | `PORT` | ✅ Used | 🚫 Ignored | By default, the development web server will attempt to listen on port 9090 or prompt you to attempt the next available port. You may use this variable to specify a different port. | 185 | | `IMAGE_INLINE_SIZE_LIMIT` | ✅ Used | ✅ Used | By default, images smaller than 10,000 bytes are encoded as a data URI in base64 and inlined in the CSS or JS build artifact. Set this to control the size limit in bytes. Setting it to `0` will disable the inlining of images. | 186 | | `ESLINT_NO_DEV_ERRORS` | ✅ Used | 🚫 Ignored | When set to `true`, ESLint errors are converted to warnings during development. As a result, ESLint output will no longer appear in the error overlay. | 187 | | `DISABLE_ESLINT_PLUGIN` | ✅ Used | ✅ Used | When set to `true`, [eslint-webpack-plugin](https://github.com/webpack-contrib/eslint-webpack-plugin) will be completely disabled. | 188 | 189 | ## Adding Styles 190 | 191 | The starter supports CSS, SASS and [CSS modules](https://github.com/css-Modules/css-Modules) is auto enabled for all files the `[name].module.*` naming convention. I use [PostCSS](https://github.com/webpack-contrib/postcss-loader) plugin to parse CSS and add autoprefixer to your stylesheet. You can access your stylesheet with two ways. 192 | 193 | ```css 194 | /* custom button style */ 195 | 196 | .Button { 197 | padding: 20px; 198 | } 199 | ``` 200 | 201 | ### With CSS modules 202 | 203 | ```jsx 204 | import styles from './styles.module.scss'; 205 | 206 | function Button() { 207 | return
; 208 | } 209 | ``` 210 | 211 | ### Without CSS modules 212 | 213 | ```jsx 214 | import './styles.scss'; 215 | 216 | function Button() { 217 | return
; 218 | } 219 | ``` 220 | 221 | You can also add the vendor CSS frameworks or global styles, just import it through the `src/client/app/index.jsx` file (app root component). For example: 222 | 223 | ```jsx 224 | import 'bootstrap/dist/css/bootstrap.min.css'; 225 | import '.css'; 226 | 227 | function App() { 228 | // ... 229 | } 230 | ``` 231 | 232 | ## Adding Images, Fonts, and Files 233 | 234 | With webpack, using static assets like images and fonts works similarly to CSS. 235 | 236 | You can **`import`** **a file right in a JavaScript module**. This tells webpack to include that file in the bundle. Unlike CSS imports, importing a file gives you a string value. This value is the final path you can reference in your code, e.g. as the `src` attribute of an image or the `href` of a link to a PDF. 237 | 238 | To reduce the number of requests to the server, importing images that are less than 10,000 bytes returns a [data URI](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs) instead of a path. This applies to the following file extensions: bmp, gif, jpg, jpeg, and png. SVG files are excluded for sprite. You can control the 10,000 byte threshold by setting the `IMAGE_INLINE_SIZE_LIMIT` environment variable. 239 | 240 | Here is an example: 241 | 242 | ```js 243 | import React from 'react'; 244 | import logo from './logo.png'; // Tell webpack this JS file uses this image 245 | 246 | console.log(logo); // /70a4f6b392fa19ff6912.png 247 | 248 | function Header() { 249 | // Import result is the URL of your image 250 | return Logo; 251 | } 252 | 253 | export default Header; 254 | ``` 255 | 256 | This ensures that when the project is built, webpack will correctly move the images into the build folder, and provide us with correct paths. 257 | 258 | This works in CSS too: 259 | 260 | ```css 261 | .Logo { 262 | background-image: url(./logo.png); 263 | } 264 | ``` 265 | 266 | ### Adding SVGs 267 | 268 | One way to add SVG files was described in the section above. You can also import SVGs directly as React components. You can use either of the two approaches. In your code it would look like this: 269 | 270 | ```js 271 | import { ReactComponent as Logo } from './logo.svg'; 272 | 273 | function App() { 274 | return ( 275 |
276 | {/* Logo is an actual React component */} 277 | 278 |
279 | ); 280 | } 281 | ``` 282 | 283 | ## Generators 284 | 285 | ### Usage 286 | 287 | - npm 288 | 289 | ```sh 290 | npm run gen component "" 291 | 292 | npm run generate component "" 293 | ``` 294 | 295 | - Yarn 296 | 297 | ```sh 298 | yarn gen component "" 299 | 300 | yarn generate component "" 301 | ``` 302 | 303 | ### Example 304 | 305 | ```sh 306 | yarn gen component "hello world" 307 | 308 | # or npm run gen component "hello world" 309 | ``` 310 | 311 | Output: `/src/client/components/HelloWorld/index.jsx` will be generated. `/src/client/components/HelloWorld/__tests__/index.test.jsx` will be generated if you want add the unit testing. 312 | 313 | ### Custom 314 | 315 | You can add template in `generators` directory, please read more at [plopjs](https://github.com/plopjs/plop). 316 | -------------------------------------------------------------------------------- /generators/component/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | description: 'Component Generator', 3 | prompts: [ 4 | { 5 | type: 'input', 6 | name: 'name', 7 | message: 'component name', 8 | }, 9 | { 10 | type: 'confirm', 11 | name: 'unit_test', 12 | message: 'do you want to add unit test?', 13 | default: false, 14 | }, 15 | ], 16 | actions: (data) => { 17 | const actions = [ 18 | { 19 | type: 'add', 20 | path: 'src/client/components/{{properCase name}}/index.jsx', 21 | templateFile: 'generators/component/index.jsx.hbs', 22 | }, 23 | ]; 24 | 25 | if (data.unit_test) { 26 | actions.push({ 27 | type: 'add', 28 | path: 'src/client/components/{{properCase name}}/__tests__/index.test.jsx', 29 | templateFile: 'generators/component/index.test.jsx.hbs', 30 | }); 31 | } 32 | 33 | return actions; 34 | }, 35 | }; 36 | -------------------------------------------------------------------------------- /generators/component/index.jsx.hbs: -------------------------------------------------------------------------------- 1 | import React, { memo } from 'react'; 2 | 3 | const {{properCase name}} = (props) =>
{{properCase name}}
; 4 | 5 | export default memo({{properCase name}}); 6 | -------------------------------------------------------------------------------- /generators/component/index.test.jsx.hbs: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | 4 | import {{properCase name}} from '../index'; 5 | 6 | test('render <{{properCase name}} />', () => { 7 | render(<{{properCase name}} />); 8 | 9 | const div = screen.getByText('{{properCase name}}'); 10 | 11 | expect(div).toBeInTheDocument(); 12 | }); 13 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /* 2 | * For a detailed explanation regarding each configuration property, visit: 3 | * https://jestjs.io/docs/configuration 4 | */ 5 | 6 | module.exports = { 7 | projects: ['/jest/configs/server.config.js', '/jest/configs/client.config.js'], 8 | }; 9 | -------------------------------------------------------------------------------- /jest/__mocks__/api-mock.js: -------------------------------------------------------------------------------- 1 | import httpAdapter from 'axios/lib/adapters/http'; 2 | import axios from 'axios'; 3 | import nock from 'nock'; 4 | import appConfig from 'configs/client'; 5 | 6 | axios.defaults.adapter = httpAdapter; 7 | 8 | const user = { 9 | id: 1, 10 | name: 'Leanne Graham', 11 | phone: '1-770-736-8031 x56442', 12 | email: 'Sincere@april.biz', 13 | website: 'hildegard.org', 14 | }; 15 | 16 | nock(appConfig.baseUrl) 17 | .get('/users') 18 | .reply(200, { 19 | success: true, 20 | users: [user], 21 | }) 22 | .get('/users/1') 23 | .reply(200, { 24 | success: true, 25 | user, 26 | }); 27 | -------------------------------------------------------------------------------- /jest/__mocks__/file-mock.js: -------------------------------------------------------------------------------- 1 | module.exports = 'test-file-stub'; 2 | -------------------------------------------------------------------------------- /jest/__mocks__/style-mock.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /jest/__mocks__/svgr-mock.js: -------------------------------------------------------------------------------- 1 | // References: https://github.com/gregberge/svgr/issues/83#issuecomment-575038115 2 | 3 | export default 'SvgrURL'; 4 | 5 | export const ReactComponent = 'svg'; 6 | -------------------------------------------------------------------------------- /jest/configs/base.config.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const { getAlias } = require('../../webpack/webpack.config.base'); 4 | 5 | const swcConfig = JSON.parse(fs.readFileSync(path.resolve(process.cwd(), '.swcrc'), 'utf-8')); 6 | 7 | const alias = Object.entries(getAlias()).reduce((original, [key, value]) => { 8 | const obj = original; 9 | 10 | obj[`^${key}(.*)$`] = `${value.replace(process.cwd(), '')}$1`; 11 | 12 | return obj; 13 | }, {}); 14 | 15 | module.exports = (isWeb) => ({ 16 | rootDir: process.cwd(), 17 | collectCoverage: true, 18 | coverageDirectory: 'coverage', 19 | coverageReporters: ['html', 'lcov'], 20 | globals: { 21 | __SERVER__: !isWeb, 22 | __DEV__: false, 23 | }, 24 | moduleNameMapper: { 25 | '\\.(jpg|jpeg|png|gif|eot|otf|webp|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': 26 | '/jest/__mocks__/file-mock.js', 27 | '\\.(svg)$': '/jest/__mocks__/svgr-mock.js', 28 | '\\.(css|less|scss|sss|styl)$': '/jest/__mocks__/style-mock.js', 29 | '\\.module.(css|less|scss|sss|styl)$': 'identity-obj-proxy', 30 | ...alias, 31 | }, 32 | modulePathIgnorePatterns: ['/.history'], 33 | setupFilesAfterEnv: ['/jest/setup-test.js'], 34 | transform: { 35 | '^.+\\.(jsx?)$': ['@swc/jest', swcConfig], 36 | }, 37 | testTimeout: 120000, 38 | }); 39 | -------------------------------------------------------------------------------- /jest/configs/client.config.js: -------------------------------------------------------------------------------- 1 | const baseConfig = require('./base.config'); 2 | 3 | module.exports = { 4 | ...baseConfig(true), 5 | testEnvironment: 'jsdom', 6 | testMatch: ['**/src/client/**/__tests__/**/?(*.)+(spec|test).js?(x)'], 7 | }; 8 | -------------------------------------------------------------------------------- /jest/configs/server.config.js: -------------------------------------------------------------------------------- 1 | const baseConfig = require('./base.config'); 2 | 3 | module.exports = { 4 | ...baseConfig(), 5 | testEnvironment: 'node', 6 | testMatch: ['/src/server/**/?(*.)+(spec|test).(js)'], 7 | }; 8 | -------------------------------------------------------------------------------- /jest/setup-test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | import '@testing-library/dom'; 3 | import '@testing-library/jest-dom'; 4 | import './__mocks__/api-mock'; 5 | 6 | jest.setTimeout(60000); 7 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "src", 4 | "paths": { 5 | "~": [".."], 6 | "~/*": ["../*"], 7 | "*": ["*"] 8 | } 9 | }, 10 | "exclude": ["node_modules", "**/node_modules/*"] 11 | } 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-ssr-starter", 3 | "version": "0.0.1", 4 | "description": "A React boilerplate for a universal web app with a highly scalable, offline-first foundation and our focus on performance and best practices.", 5 | "keywords": [ 6 | "react", 7 | "redux", 8 | "unit-testing", 9 | "boilerplate", 10 | "performance", 11 | "webpack", 12 | "universal", 13 | "es6+", 14 | "jest", 15 | "seo", 16 | "ssr", 17 | "server-side-rendering", 18 | "supertest", 19 | "react-router", 20 | "react-hooks", 21 | "redux-toolkit", 22 | "react-testing-library", 23 | "offline-first", 24 | "pwa", 25 | "best-practices", 26 | "eslint", 27 | "prettier", 28 | "swc" 29 | ], 30 | "homepage": "https://github.com/htdangkhoa/react-ssr-starter", 31 | "bugs": "https://github.com/htdangkhoa/react-ssr-starter/issues", 32 | "repository": "https://github.com/htdangkhoa/react-ssr-starter", 33 | "license": "MIT", 34 | "author": "Khoa Huynh (https://github.com/htdangkhoa)", 35 | "main": "build/index.js", 36 | "scripts": { 37 | "analyze:server": "cross-env NODE_ENV=analyze webpack --config webpack/webpack.config.server.js", 38 | "analyze:client": "cross-env NODE_ENV=analyze webpack --config webpack/webpack.config.client.js", 39 | "build:server": "cross-env NODE_ENV=production webpack --config webpack/webpack.config.server.js", 40 | "build:client": "cross-env NODE_ENV=production webpack --config webpack/webpack.config.client.js", 41 | "build": "npm run build:server && npm run build:client", 42 | "dev": "cross-env NODE_ENV=development webpack --config webpack/webpack.config.server.js", 43 | "docker:build": "docker build --rm -t htdangkhoa/react-ssr-starter .", 44 | "docker:start": "docker run --rm -it -p 9090:9090 htdangkhoa/react-ssr-starter", 45 | "docker": "npm run docker:build && npm run docker:start", 46 | "generate": "plop", 47 | "gen": "plop", 48 | "prepare": "husky install", 49 | "start": "node build/index.js", 50 | "pretest:ci": "node scripts/pretest.js", 51 | "test:ci": "cross-env NODE_ENV=test jest --runInBand", 52 | "pretest": "node scripts/pretest.js", 53 | "test": "cross-env NODE_ENV=test jest --maxWorkers=50%" 54 | }, 55 | "dependencies": { 56 | "@loadable/component": "^5.15.2", 57 | "@loadable/server": "^5.15.2", 58 | "@reduxjs/toolkit": "^1.8.0", 59 | "axios": "^0.26.0", 60 | "compression": "^1.7.4", 61 | "helmet": "^5.0.2", 62 | "html-minifier": "^4.0.0", 63 | "prop-types": "^15.8.1", 64 | "pure-http": "^3.3.1", 65 | "react": "^17.0.2", 66 | "react-dom": "^17.0.2", 67 | "react-helmet-async": "^1.2.3", 68 | "react-redux": "^7.2.6", 69 | "react-router-dom": "^6.2.2", 70 | "react-ssr-prepass": "^1.5.0", 71 | "serialize-javascript": "^6.0.0", 72 | "serve-favicon": "^2.5.0", 73 | "serve-static": "^1.14.2", 74 | "web-vitals": "^2.1.4" 75 | }, 76 | "devDependencies": { 77 | "@loadable/webpack-plugin": "^5.15.2", 78 | "@pmmmwh/react-refresh-webpack-plugin": "^0.5.4", 79 | "@svgr/webpack": "^6.2.1", 80 | "@swc/core": "^1.2.147", 81 | "@swc/jest": "^0.2.20", 82 | "@testing-library/dom": "^8.11.3", 83 | "@testing-library/jest-dom": "^5.16.2", 84 | "@testing-library/react": "^12.1.3", 85 | "@testing-library/user-event": "^13.5.0", 86 | "autoprefixer": "^10.4.2", 87 | "clean-webpack-plugin": "^4.0.0", 88 | "cross-env": "^7.0.3", 89 | "cross-spawn": "^7.0.3", 90 | "css-loader": "^6.6.0", 91 | "css-minimizer-webpack-plugin": "^3.4.1", 92 | "dotenv": "^16.0.0", 93 | "dotenv-expand": "^8.0.1", 94 | "eslint": "^8.10.0", 95 | "eslint-config-airbnb": "^19.0.4", 96 | "eslint-config-prettier": "^8.4.0", 97 | "eslint-import-resolver-alias": "^1.1.2", 98 | "eslint-plugin-import": "^2.25.4", 99 | "eslint-plugin-jsx-a11y": "^6.5.1", 100 | "eslint-plugin-prettier": "^4.0.0", 101 | "eslint-plugin-react": "^7.29.2", 102 | "eslint-plugin-react-hooks": "^4.3.0", 103 | "eslint-webpack-plugin": "^3.1.1", 104 | "glob": "^7.2.0", 105 | "husky": "^7.0.4", 106 | "identity-obj-proxy": "^3.0.0", 107 | "jest": "^27.5.1", 108 | "lint-staged": "^12.3.4", 109 | "mini-css-extract-plugin": "^2.5.3", 110 | "nock": "^13.2.4", 111 | "picocolors": "^1.0.0", 112 | "plop": "^3.0.5", 113 | "postcss": "^8.4.7", 114 | "postcss-loader": "^6.2.1", 115 | "prettier": "^2.5.1", 116 | "process": "^0.11.10", 117 | "react-dev-utils": "^12.0.0", 118 | "react-error-overlay": "6.0.10", 119 | "react-refresh": "^0.11.0", 120 | "sass": "^1.49.9", 121 | "sass-loader": "^12.6.0", 122 | "style-loader": "^3.3.1", 123 | "supertest": "^6.2.2", 124 | "swc-loader": "^0.1.15", 125 | "terser-webpack-plugin": "^5.3.1", 126 | "webpack": "^5.69.1", 127 | "webpack-bundle-analyzer": "^4.5.0", 128 | "webpack-cli": "^4.9.2", 129 | "webpack-dev-middleware": "^5.3.1", 130 | "webpack-dev-server": "^4.7.4", 131 | "webpack-format-messages": "^3.0.1", 132 | "webpack-hot-middleware": "^2.25.1", 133 | "webpack-merge": "^5.8.0", 134 | "webpack-node-externals": "^3.0.0", 135 | "workbox-webpack-plugin": "^6.5.0", 136 | "ws": "^8.5.0" 137 | }, 138 | "engines": { 139 | "node": "^14.17.0 || >=16.0.0" 140 | }, 141 | "browserslist": [ 142 | ">0.2%", 143 | "not dead", 144 | "not op_mini all" 145 | ] 146 | } 147 | -------------------------------------------------------------------------------- /plopfile.js: -------------------------------------------------------------------------------- 1 | const componentPlopConfig = require('./generators/component'); 2 | 3 | module.exports = (plop) => { 4 | plop.setGenerator('component', componentPlopConfig); 5 | }; 6 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | const autoprefixer = require('autoprefixer'); 2 | 3 | module.exports = { 4 | plugins: [autoprefixer()], 5 | }; 6 | -------------------------------------------------------------------------------- /public/android-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htdangkhoa/react-ssr-starter/647f2697293bc0e2dab41dacdc853eb88ef0b6cb/public/android-icon-144x144.png -------------------------------------------------------------------------------- /public/android-icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htdangkhoa/react-ssr-starter/647f2697293bc0e2dab41dacdc853eb88ef0b6cb/public/android-icon-192x192.png -------------------------------------------------------------------------------- /public/android-icon-36x36.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htdangkhoa/react-ssr-starter/647f2697293bc0e2dab41dacdc853eb88ef0b6cb/public/android-icon-36x36.png -------------------------------------------------------------------------------- /public/android-icon-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htdangkhoa/react-ssr-starter/647f2697293bc0e2dab41dacdc853eb88ef0b6cb/public/android-icon-48x48.png -------------------------------------------------------------------------------- /public/android-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htdangkhoa/react-ssr-starter/647f2697293bc0e2dab41dacdc853eb88ef0b6cb/public/android-icon-72x72.png -------------------------------------------------------------------------------- /public/android-icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htdangkhoa/react-ssr-starter/647f2697293bc0e2dab41dacdc853eb88ef0b6cb/public/android-icon-96x96.png -------------------------------------------------------------------------------- /public/apple-icon-114x114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htdangkhoa/react-ssr-starter/647f2697293bc0e2dab41dacdc853eb88ef0b6cb/public/apple-icon-114x114.png -------------------------------------------------------------------------------- /public/apple-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htdangkhoa/react-ssr-starter/647f2697293bc0e2dab41dacdc853eb88ef0b6cb/public/apple-icon-120x120.png -------------------------------------------------------------------------------- /public/apple-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htdangkhoa/react-ssr-starter/647f2697293bc0e2dab41dacdc853eb88ef0b6cb/public/apple-icon-144x144.png -------------------------------------------------------------------------------- /public/apple-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htdangkhoa/react-ssr-starter/647f2697293bc0e2dab41dacdc853eb88ef0b6cb/public/apple-icon-152x152.png -------------------------------------------------------------------------------- /public/apple-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htdangkhoa/react-ssr-starter/647f2697293bc0e2dab41dacdc853eb88ef0b6cb/public/apple-icon-180x180.png -------------------------------------------------------------------------------- /public/apple-icon-57x57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htdangkhoa/react-ssr-starter/647f2697293bc0e2dab41dacdc853eb88ef0b6cb/public/apple-icon-57x57.png -------------------------------------------------------------------------------- /public/apple-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htdangkhoa/react-ssr-starter/647f2697293bc0e2dab41dacdc853eb88ef0b6cb/public/apple-icon-60x60.png -------------------------------------------------------------------------------- /public/apple-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htdangkhoa/react-ssr-starter/647f2697293bc0e2dab41dacdc853eb88ef0b6cb/public/apple-icon-72x72.png -------------------------------------------------------------------------------- /public/apple-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htdangkhoa/react-ssr-starter/647f2697293bc0e2dab41dacdc853eb88ef0b6cb/public/apple-icon-76x76.png -------------------------------------------------------------------------------- /public/apple-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htdangkhoa/react-ssr-starter/647f2697293bc0e2dab41dacdc853eb88ef0b6cb/public/apple-icon-precomposed.png -------------------------------------------------------------------------------- /public/apple-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htdangkhoa/react-ssr-starter/647f2697293bc0e2dab41dacdc853eb88ef0b6cb/public/apple-icon.png -------------------------------------------------------------------------------- /public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | #ffffff -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htdangkhoa/react-ssr-starter/647f2697293bc0e2dab41dacdc853eb88ef0b6cb/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htdangkhoa/react-ssr-starter/647f2697293bc0e2dab41dacdc853eb88ef0b6cb/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htdangkhoa/react-ssr-starter/647f2697293bc0e2dab41dacdc853eb88ef0b6cb/public/favicon-96x96.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htdangkhoa/react-ssr-starter/647f2697293bc0e2dab41dacdc853eb88ef0b6cb/public/favicon.ico -------------------------------------------------------------------------------- /public/icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htdangkhoa/react-ssr-starter/647f2697293bc0e2dab41dacdc853eb88ef0b6cb/public/icon-192x192.png -------------------------------------------------------------------------------- /public/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htdangkhoa/react-ssr-starter/647f2697293bc0e2dab41dacdc853eb88ef0b6cb/public/icon-512x512.png -------------------------------------------------------------------------------- /public/icon-maskable.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htdangkhoa/react-ssr-starter/647f2697293bc0e2dab41dacdc853eb88ef0b6cb/public/icon-maskable.png -------------------------------------------------------------------------------- /public/ms-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htdangkhoa/react-ssr-starter/647f2697293bc0e2dab41dacdc853eb88ef0b6cb/public/ms-icon-144x144.png -------------------------------------------------------------------------------- /public/ms-icon-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htdangkhoa/react-ssr-starter/647f2697293bc0e2dab41dacdc853eb88ef0b6cb/public/ms-icon-150x150.png -------------------------------------------------------------------------------- /public/ms-icon-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htdangkhoa/react-ssr-starter/647f2697293bc0e2dab41dacdc853eb88ef0b6cb/public/ms-icon-310x310.png -------------------------------------------------------------------------------- /public/ms-icon-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htdangkhoa/react-ssr-starter/647f2697293bc0e2dab41dacdc853eb88ef0b6cb/public/ms-icon-70x70.png -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "icons": [ 3 | { 4 | "src": "favicon.ico", 5 | "sizes": "64x64 32x32 24x24 16x16", 6 | "type": "image/x-icon" 7 | }, 8 | { 9 | "src": "/icon-192x192.png", 10 | "type": "image/png", 11 | "sizes": "192x192" 12 | }, 13 | { 14 | "src": "/icon-512x512.png", 15 | "type": "image/png", 16 | "sizes": "512x512" 17 | }, 18 | { 19 | "src": "/icon-512x512.png", 20 | "type": "image/png", 21 | "sizes": "512x512", 22 | "purpose": "maskable" 23 | } 24 | ], 25 | "name": "React SSR Starter", 26 | "short_name": "React SSR", 27 | "orientation": "portrait", 28 | "display": "standalone", 29 | "start_url": ".", 30 | "description": "The best react universal starter boilerplate in the world.", 31 | "background_color": "#ffffff", 32 | "theme_color": "#ffffff" 33 | } -------------------------------------------------------------------------------- /scripts/pretest.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const { spawnSync } = require('child_process'); 4 | 5 | const isExistStats = fs.existsSync(path.resolve(process.cwd(), 'public/stats.json')); 6 | 7 | const isExistBuild = fs.existsSync(path.resolve(process.cwd(), 'build')); 8 | 9 | if (!isExistStats || !isExistBuild) 10 | spawnSync('npm', ['run', 'build'], { shell: true, env: process.env, stdio: 'inherit' }); 11 | -------------------------------------------------------------------------------- /src/client/api/index.js: -------------------------------------------------------------------------------- 1 | import { createAsyncThunk } from '@reduxjs/toolkit'; 2 | 3 | import request from './request'; 4 | 5 | export const getUsers = createAsyncThunk('getUsers', async () => { 6 | try { 7 | const { data } = await request.get('users'); 8 | 9 | return data; 10 | } catch (error) { 11 | throw error.response.data.error; 12 | } 13 | }); 14 | 15 | export const getUser = createAsyncThunk('getUser', async (id) => { 16 | try { 17 | const { data } = await request.get(`users/${id}`); 18 | 19 | return data; 20 | } catch (error) { 21 | throw error.response.data.error; 22 | } 23 | }); 24 | -------------------------------------------------------------------------------- /src/client/api/request.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import appConfig from 'configs/client'; 3 | 4 | const request = axios.create({ baseURL: appConfig.baseUrl }); 5 | 6 | export default request; 7 | -------------------------------------------------------------------------------- /src/client/app/__tests__/index.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { render, screen, cleanup } from 'test-utils/render'; 4 | import App from '../index'; 5 | 6 | test('render ', async () => { 7 | render(, { useRouter: true }); 8 | expect(await screen.findByText(/User List/i)).toBeInTheDocument(); 9 | cleanup(); 10 | 11 | render(, { useRouter: true, path: '/home' }); 12 | expect(await screen.findByText(/User List/i)).toBeInTheDocument(); 13 | cleanup(); 14 | 15 | render(, { useRouter: true, path: '/user-info/1' }); 16 | expect(await screen.findByText(/User Info/i)).toBeInTheDocument(); 17 | cleanup(); 18 | 19 | render(, { useRouter: true, path: '/not-found' }); 20 | expect(await screen.findByText('Page not found.')).toBeInTheDocument(); 21 | cleanup(); 22 | }); 23 | -------------------------------------------------------------------------------- /src/client/app/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { memo, Suspense } from 'react'; 2 | import { Helmet } from 'react-helmet-async'; 3 | import { Routes, Route } from 'react-router-dom'; 4 | 5 | import appConfig from 'configs/client'; 6 | import routes from 'client/routes'; 7 | import Loading from 'client/components/Loading'; 8 | import { makeId } from 'client/utils/string'; 9 | 10 | const Header = React.lazy(() => import('client/components/Header')); 11 | 12 | const App = () => ( 13 | }> 14 | 15 | 16 |
17 | 18 |
19 | 20 | {routes.map(({ path, to, element: Element }) => { 21 | const elementProps = {}; 22 | 23 | // handle redirects with Navigate component 24 | // reference: https://gist.github.com/htdangkhoa/5b3407c749b6fb8cf05cfb591ec3ef07 25 | if (typeof to === 'string') { 26 | elementProps.to = to; 27 | elementProps.replace = true; 28 | } 29 | 30 | return } key={makeId()} />; 31 | })} 32 | 33 |
34 | 35 | ); 36 | 37 | export default memo(App); 38 | -------------------------------------------------------------------------------- /src/client/assets/images/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/client/assets/styles/index.scss: -------------------------------------------------------------------------------- 1 | *, 2 | ::after, 3 | ::before { 4 | box-sizing: border-box; 5 | } 6 | 7 | body { 8 | background-color: #fff; 9 | color: #212529; 10 | font-family: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', 'Liberation Sans', 11 | sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; 12 | font-size: 1rem; 13 | font-weight: 400; 14 | line-height: 1.5; 15 | margin: 0; 16 | -webkit-tap-highlight-color: rgb(0 0 0 / 0%); 17 | text-size-adjust: 100%; 18 | } 19 | 20 | hr { 21 | background-color: currentColor; 22 | border: 0; 23 | color: inherit; 24 | margin: 1rem 0; 25 | opacity: 0.25; 26 | } 27 | 28 | hr:not([size]) { 29 | height: 1px; 30 | } 31 | 32 | .container-fluid, 33 | .container { 34 | margin-left: auto; 35 | margin-right: auto; 36 | padding-left: 0.75rem; 37 | padding-right: 0.75rem; 38 | width: 100%; 39 | } 40 | 41 | @media (min-width: 576px) { 42 | .container { 43 | max-width: 540px; 44 | } 45 | } 46 | 47 | @media (min-width: 768px) { 48 | .container { 49 | max-width: 720px; 50 | } 51 | } 52 | 53 | @media (min-width: 992px) { 54 | .container { 55 | max-width: 960px; 56 | } 57 | } 58 | 59 | @media (min-width: 1200px) { 60 | .container { 61 | max-width: 1140px; 62 | } 63 | } 64 | @media (min-width: 1400px) { 65 | .container { 66 | max-width: 1320px; 67 | } 68 | } 69 | 70 | h1 { 71 | font-size: 1rem; 72 | } 73 | -------------------------------------------------------------------------------- /src/client/components/Header/__tests__/index.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from 'test-utils/render'; 3 | 4 | import Header from '../index'; 5 | 6 | test('render
', () => { 7 | render(
, { useRouter: true }); 8 | 9 | const h1 = screen.getByText(/React SSR Starter/i); 10 | 11 | expect(h1).toBeInTheDocument(); 12 | }); 13 | -------------------------------------------------------------------------------- /src/client/components/Header/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | 4 | import { ReactComponent as Logo } from 'client/assets/images/logo.svg'; 5 | import styles from './style.module.scss'; 6 | 7 | const Header = () => ( 8 |
9 | 10 | 11 | 12 |

React SSR Starter

13 | 14 | 15 |
16 |
17 | ); 18 | 19 | export default memo(Header); 20 | -------------------------------------------------------------------------------- /src/client/components/Header/style.module.scss: -------------------------------------------------------------------------------- 1 | .logo { 2 | float: left; 3 | margin-right: 0.5rem; 4 | } 5 | 6 | .h1 { 7 | font-size: revert; 8 | } 9 | -------------------------------------------------------------------------------- /src/client/components/Loading/__tests__/index.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from 'test-utils/render'; 3 | 4 | import Loading from '../index'; 5 | 6 | test('render ', () => { 7 | render(); 8 | 9 | const div = screen.getByText(/Loading\.\.\./i); 10 | 11 | expect(div).toBeInTheDocument(); 12 | }); 13 | -------------------------------------------------------------------------------- /src/client/components/Loading/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Loading = () =>
Loading...
; 4 | 5 | export default Loading; 6 | -------------------------------------------------------------------------------- /src/client/hooks/useIsomorphicLayoutEffect.js: -------------------------------------------------------------------------------- 1 | // References: https://gist.github.com/gaearon/e7d97cdf38a2907924ea12e4ebdf3c85#gistcomment-2911761 2 | import { useLayoutEffect, useEffect } from 'react'; 3 | 4 | const useIsomorphicLayoutEffect = __SERVER__ ? useEffect : useLayoutEffect; 5 | 6 | export default useIsomorphicLayoutEffect; 7 | -------------------------------------------------------------------------------- /src/client/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { StrictMode } from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { Provider } from 'react-redux'; 4 | import { BrowserRouter } from 'react-router-dom'; 5 | import { HelmetProvider } from 'react-helmet-async'; 6 | import { loadableReady } from '@loadable/component'; 7 | 8 | import './assets/styles/index.scss'; 9 | 10 | import App from './app'; 11 | import configurationStore from './store'; 12 | import reportWebVitals from './report-web-vitals'; 13 | import * as serviceWorker from './service-worker'; 14 | 15 | const initialState = window.__INITIAL_STATE__; 16 | delete window.__INITIAL_STATE__; 17 | 18 | const store = configurationStore({ initialState }); 19 | 20 | const render = () => { 21 | ReactDOM.hydrate( 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | , 31 | document.getElementById('app'), 32 | ); 33 | }; 34 | 35 | loadableReady(render); 36 | 37 | if (module.hot) { 38 | module.hot.accept(); 39 | } 40 | 41 | // If you want to start measuring performance in your app, pass a function 42 | // to log results (for example: reportWebVitals(console.log)) 43 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 44 | reportWebVitals(); 45 | 46 | // If you want your app to work offline and load faster, you can change 47 | // unregister() to register() below. 48 | serviceWorker.register(); 49 | -------------------------------------------------------------------------------- /src/client/pages/NotFound/__tests__/index.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Routes, Route } from 'react-router-dom'; 3 | 4 | import { render, screen } from 'test-utils/render'; 5 | import NotFound from '../index'; 6 | 7 | test('render NotFound page', () => { 8 | render( 9 | 10 | } /> 11 | , 12 | { useRouter: true, path: '/not-found' }, 13 | ); 14 | 15 | const element = screen.getByText(/Page not found/i); 16 | 17 | expect(element).toBeInTheDocument(); 18 | }); 19 | -------------------------------------------------------------------------------- /src/client/pages/NotFound/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from 'react'; 2 | 3 | const NotFound = () =>
Page not found.
; 4 | 5 | export default memo(NotFound); 6 | -------------------------------------------------------------------------------- /src/client/pages/UserInfo/__tests__/index.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Routes, Route } from 'react-router-dom'; 3 | 4 | import { render, screen, waitFor } from 'test-utils/render'; 5 | import UserInfo from '../index'; 6 | 7 | test('render ', async () => { 8 | render( 9 | 10 | } /> 11 | , 12 | { useRouter: true, path: '/user-info/1' }, 13 | ); 14 | 15 | expect(screen.queryByText(/Loading\.\.\./i)).toBeInTheDocument(); 16 | 17 | await waitFor(() => screen.findByRole('list'), { timeout: 1000 }); 18 | 19 | const ul = await screen.findByRole('list'); 20 | 21 | expect(ul).toBeInTheDocument(); 22 | }); 23 | -------------------------------------------------------------------------------- /src/client/pages/UserInfo/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from 'react'; 2 | import { useParams } from 'react-router-dom'; 3 | import { useDispatch, useSelector } from 'react-redux'; 4 | import { createSelector } from '@reduxjs/toolkit'; 5 | import { Helmet } from 'react-helmet-async'; 6 | 7 | import Loading from 'client/components/Loading'; 8 | import useIsomorphicLayoutEffect from 'client/hooks/useIsomorphicLayoutEffect'; 9 | import { getUserInfoIfNeed } from 'client/store/slices/user-info-slice'; 10 | import { STATUS } from 'configs/constants'; 11 | 12 | const selectUser = (state, id) => state.userInfo[id]; 13 | 14 | export const loadData = ({ params }) => [getUserInfoIfNeed(params.id)]; 15 | 16 | const UserInfoAsync = memo(() => { 17 | const params = useParams(); 18 | 19 | const selectUserById = createSelector(selectUser, (data) => data); 20 | 21 | const userInfo = useSelector((state) => selectUserById(state, params.id)); 22 | 23 | if (userInfo?.loading === STATUS.LOADING) return ; 24 | 25 | if (userInfo?.loading === STATUS.FAILED) 26 | return ( 27 |
28 |

Oops! Failed to load data.

29 | 30 |

Message: {userInfo?.error?.message}

31 | 32 |

Stack: {userInfo?.error?.stack}

33 |
34 | ); 35 | 36 | return ( 37 |
    38 |
  • Name: {userInfo?.data?.name}
  • 39 |
  • Phone: {userInfo?.data?.phone}
  • 40 |
  • Email: {userInfo?.data?.email}
  • 41 |
  • Website: {userInfo?.data?.website}
  • 42 |
43 | ); 44 | }); 45 | 46 | const UserInfo = () => { 47 | const params = useParams(); 48 | 49 | const dispatch = useDispatch(); 50 | 51 | useIsomorphicLayoutEffect(() => { 52 | dispatch(getUserInfoIfNeed(params.id)); 53 | }, []); 54 | 55 | return ( 56 |
57 | 58 | 59 |

User Info

60 | 61 | 62 |
63 | ); 64 | }; 65 | 66 | export default memo(UserInfo); 67 | -------------------------------------------------------------------------------- /src/client/pages/UserList/__tests__/index.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Routes, Route } from 'react-router-dom'; 3 | 4 | import { render, screen, waitFor } from 'test-utils/render'; 5 | import UserList from '../index'; 6 | 7 | test('render ', async () => { 8 | render( 9 | 10 | } /> 11 | , 12 | { useRouter: true, path: '/home' }, 13 | ); 14 | 15 | expect(screen.queryByText(/Loading\.\.\./i)).toBeInTheDocument(); 16 | 17 | await waitFor(() => screen.findByRole('list'), { timeout: 60000 }); 18 | 19 | const ul = await screen.findByRole('list'); 20 | 21 | expect(ul).toBeInTheDocument(); 22 | }); 23 | -------------------------------------------------------------------------------- /src/client/pages/UserList/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | import { Link } from 'react-router-dom'; 4 | 5 | import Loading from 'client/components/Loading'; 6 | import useIsomorphicLayoutEffect from 'client/hooks/useIsomorphicLayoutEffect'; 7 | import { getUserListIfNeed } from 'client/store/slices/user-list-slice'; 8 | import { STATUS } from 'configs/constants'; 9 | import { makeId } from 'client/utils/string'; 10 | 11 | export const loadData = () => [getUserListIfNeed()]; 12 | 13 | const UserListAsync = memo(() => { 14 | const { loading, error, users } = useSelector((state) => state.userList); 15 | 16 | if (loading === STATUS.LOADING) return ; 17 | 18 | if (loading === STATUS.FAILED) 19 | return ( 20 |
21 |

Oops! Failed to load data.

22 | 23 |

Message: {error?.message}

24 | 25 |

Stack: {error?.stack}

26 |
27 | ); 28 | 29 | return ( 30 |
    31 | {users.map((user) => ( 32 |
  • 33 | {user.name} 34 |
  • 35 | ))} 36 |
37 | ); 38 | }); 39 | 40 | const UserList = () => { 41 | const dispatch = useDispatch(); 42 | 43 | useIsomorphicLayoutEffect(() => { 44 | dispatch(getUserListIfNeed()); 45 | }, []); 46 | 47 | return ( 48 |
49 |

User List

50 | 51 | 52 |
53 | ); 54 | }; 55 | 56 | export default memo(UserList); 57 | -------------------------------------------------------------------------------- /src/client/report-web-vitals.js: -------------------------------------------------------------------------------- 1 | const reportWebVitals = (onPerfEntry) => { 2 | if (onPerfEntry && typeof onPerfEntry === 'function') { 3 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 4 | getCLS(onPerfEntry); 5 | getFID(onPerfEntry); 6 | getFCP(onPerfEntry); 7 | getLCP(onPerfEntry); 8 | getTTFB(onPerfEntry); 9 | }); 10 | } 11 | }; 12 | 13 | export default reportWebVitals; 14 | -------------------------------------------------------------------------------- /src/client/routes.js: -------------------------------------------------------------------------------- 1 | import { lazy } from 'react'; 2 | import { loadData as loadDataForUserList } from './pages/UserList'; 3 | import { loadData as loadDataForUserInfo } from './pages/UserInfo'; 4 | 5 | const Navigate = lazy(() => import('react-router-dom').then(({ Navigate: m }) => ({ default: m }))); 6 | const UserList = lazy(() => import('./pages/UserList')); 7 | const UserInfo = lazy(() => import('./pages/UserInfo')); 8 | const NotFound = lazy(() => import('./pages/NotFound')); 9 | 10 | const routes = [ 11 | { 12 | path: '/', 13 | to: '/home', 14 | element: Navigate, 15 | }, 16 | { 17 | path: '/home', 18 | element: UserList, 19 | loadData: loadDataForUserList, 20 | }, 21 | { 22 | path: '/user-info/:id', 23 | element: UserInfo, 24 | loadData: loadDataForUserInfo, 25 | }, 26 | { 27 | path: '*', 28 | element: NotFound, 29 | }, 30 | ]; 31 | 32 | export default routes; 33 | -------------------------------------------------------------------------------- /src/client/service-worker.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | export function register() { 3 | if (!__DEV__ && 'serviceWorker' in navigator) { 4 | window.self.__WB_DISABLE_DEV_LOGS = true; 5 | 6 | window.addEventListener('load', () => { 7 | navigator.serviceWorker 8 | .register('sw.js') 9 | .then((registration) => { 10 | console.log('SW registered: ', registration); 11 | 12 | registration.onupdatefound = () => { 13 | const installingWorker = registration.installing; 14 | 15 | if (!installingWorker) return; 16 | 17 | installingWorker.onstatechange = () => { 18 | if (installingWorker.state === 'installed') { 19 | if (navigator.serviceWorker.controller) { 20 | console.log( 21 | 'New content is available and will be used when all tabs for this page are closed. See https://bit.ly/CRA-PWA.', 22 | ); 23 | 24 | if (window.confirm(`New app update is available!. Click OK to refresh`)) { 25 | window.location.reload(); 26 | } 27 | } else { 28 | console.log('Content is cached for offline use.'); 29 | } 30 | } 31 | }; 32 | }; 33 | }) 34 | .catch((registrationError) => { 35 | console.log('SW registration failed: ', registrationError); 36 | }); 37 | }); 38 | } 39 | } 40 | 41 | export function unregister() { 42 | if ('serviceWorker' in navigator) { 43 | navigator.serviceWorker.ready 44 | .then((registration) => registration.unregister()) 45 | .catch((err) => console.error(err.message)); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/client/store/index.js: -------------------------------------------------------------------------------- 1 | import { configureStore } from '@reduxjs/toolkit'; 2 | import reducers from './reducers'; 3 | 4 | const configurationStore = ({ initialState } = {}) => { 5 | const store = configureStore({ 6 | preloadedState: initialState, 7 | reducer: reducers, 8 | devTools: __DEV__, 9 | }); 10 | 11 | return store; 12 | }; 13 | 14 | export default configurationStore; 15 | -------------------------------------------------------------------------------- /src/client/store/reducers.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from '@reduxjs/toolkit'; 2 | 3 | import userList from './slices/user-list-slice'; 4 | import userInfo from './slices/user-info-slice'; 5 | 6 | const reducers = combineReducers({ 7 | userList, 8 | userInfo, 9 | }); 10 | 11 | export default reducers; 12 | -------------------------------------------------------------------------------- /src/client/store/slices/user-info-slice.js: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit'; 2 | import { getUser } from 'client/api'; 3 | import { STATUS } from 'configs/constants'; 4 | 5 | const userInfoSlice = createSlice({ 6 | name: 'user-info-slice', 7 | initialState: {}, 8 | reducers: { 9 | checkUserExist: (state, { payload }) => { 10 | state[payload] = { loading: STATUS.IDLE }; 11 | }, 12 | }, 13 | extraReducers: (builder) => { 14 | builder 15 | .addCase(getUser.pending, (state, { meta: { arg } }) => { 16 | state[arg].loading = STATUS.LOADING; 17 | }) 18 | .addCase(getUser.rejected, (state, { meta: { arg }, error }) => { 19 | state[arg].loading = STATUS.FAILED; 20 | state[arg].error = error; 21 | }) 22 | .addCase(getUser.fulfilled, (state, { meta: { arg }, payload }) => { 23 | state[arg].loading = STATUS.SUCCEED; 24 | state[arg].data = payload.user; 25 | }); 26 | }, 27 | }); 28 | 29 | const shouldGetUserInfo = (state, id) => !Object.values(STATUS).includes(state.userInfo[id]?.loading); 30 | 31 | export const getUserInfoIfNeed = (id) => (dispatch, getState) => { 32 | if (shouldGetUserInfo(getState())) { 33 | dispatch(userInfoSlice.actions.checkUserExist(id)); 34 | 35 | return dispatch(getUser(id)); 36 | } 37 | 38 | return null; 39 | }; 40 | 41 | export default userInfoSlice.reducer; 42 | -------------------------------------------------------------------------------- /src/client/store/slices/user-list-slice.js: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit'; 2 | import { getUsers } from 'client/api'; 3 | import { STATUS } from 'configs/constants'; 4 | 5 | const userListSlice = createSlice({ 6 | name: 'user-list-slice', 7 | initialState: { 8 | loading: STATUS.IDLE, 9 | users: [], 10 | error: null, 11 | }, 12 | reducers: {}, 13 | extraReducers: (builder) => { 14 | builder 15 | .addCase(getUsers.pending, (state) => { 16 | state.loading = STATUS.LOADING; 17 | }) 18 | .addCase(getUsers.rejected, (state, action) => { 19 | state.loading = STATUS.FAILED; 20 | state.error = action.error; 21 | }) 22 | .addCase(getUsers.fulfilled, (state, action) => { 23 | state.loading = STATUS.SUCCEED; 24 | state.users = [].concat(...action.payload.users); 25 | }); 26 | }, 27 | }); 28 | 29 | const shouldGetUserList = (state) => state.userList.loading === STATUS.IDLE; 30 | 31 | export const getUserListIfNeed = () => (dispatch, getState) => { 32 | if (shouldGetUserList(getState())) return dispatch(getUsers()); 33 | 34 | return null; 35 | }; 36 | 37 | export default userListSlice.reducer; 38 | -------------------------------------------------------------------------------- /src/client/utils/string.js: -------------------------------------------------------------------------------- 1 | export const makeId = (length = 8) => { 2 | let result = ''; 3 | 4 | const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; 5 | 6 | for (let i = 0; i < length; i += 1) { 7 | result += characters.charAt(Math.floor(Math.random() * characters.length)); 8 | } 9 | 10 | return result; 11 | }; 12 | -------------------------------------------------------------------------------- /src/configs/client.js: -------------------------------------------------------------------------------- 1 | const description = 2 | '🔥 ⚛️ A React boilerplate for a universal web app with a highly scalable, offline-first foundation and our focus on performance and best practices.'; 3 | 4 | const appConfig = { 5 | baseUrl: process.env.REACT_APP_BASE_URL || 'http://localhost:9090/api/', 6 | seo: { 7 | htmlAttributes: { lang: 'en' }, 8 | defaultTitle: '⚛️ React SSR Starter', 9 | titleTemplate: '%s - ⚛️ React SSR Starter', 10 | meta: [ 11 | { 12 | name: 'keywords', 13 | content: 14 | 'react, redux, unit-testing, boilerplate, performance, webpack, universal, es6+, jest, seo, ssr, server-side-rendering, supertest, react-router, react-hooks, redux-toolkit, react-testing-library, offline-first, pwa, best-practices, eslint, prettier, swc', 15 | }, 16 | { 17 | name: 'description', 18 | content: description, 19 | }, 20 | ], 21 | }, 22 | }; 23 | 24 | export default appConfig; 25 | -------------------------------------------------------------------------------- /src/configs/constants.js: -------------------------------------------------------------------------------- 1 | export const STATUS = { 2 | IDLE: 'IDLE', 3 | LOADING: 'LOADING', 4 | SUCCEED: 'SUCCEED', 5 | FAILED: 'FAILED', 6 | }; 7 | -------------------------------------------------------------------------------- /src/configs/server.js: -------------------------------------------------------------------------------- 1 | const serverConfig = { 2 | PORT: process.env.PORT || 9090, 3 | }; 4 | 5 | export default serverConfig; 6 | -------------------------------------------------------------------------------- /src/server/app/index.js: -------------------------------------------------------------------------------- 1 | import { resolve } from 'path'; 2 | import PureHttp from 'pure-http'; 3 | import helmet from 'helmet'; 4 | import favicon from 'serve-favicon'; 5 | import compression from 'compression'; 6 | import serve from 'serve-static'; 7 | 8 | import render from '../render'; 9 | import users from '../db/users.json'; 10 | 11 | const app = PureHttp(); 12 | 13 | app.use(favicon(resolve(process.cwd(), 'public/favicon.ico'))); 14 | app.use(compression()); 15 | app.use(helmet({ contentSecurityPolicy: false })); 16 | app.use(serve(resolve(process.cwd(), 'public'))); 17 | 18 | /* istanbul ignore next */ 19 | const websocketServerCreator = __DEV__ ? require('./websocket-server-creator').default : undefined; 20 | const webpackMiddleware = __DEV__ ? require('../middlewares/webpack.middleware').default : undefined; 21 | 22 | /* istanbul ignore next */ 23 | if (typeof webpackMiddleware === 'function') { 24 | const ws = websocketServerCreator(app); 25 | 26 | app.use(webpackMiddleware(ws)); 27 | } 28 | 29 | app.get('/api/health', (req, res) => res.status(200).end()); 30 | 31 | app.get('/api/users', (req, res) => 32 | res.json({ 33 | success: true, 34 | users, 35 | }), 36 | ); 37 | 38 | app.get('/api/users/:id', (req, res) => { 39 | const user = users.find((_user) => _user.id === parseInt(req.params.id, 10)) || {}; 40 | 41 | return res.json({ 42 | success: true, 43 | user, 44 | }); 45 | }); 46 | 47 | app.get(/^(?!.*^\/api\/)(.*)/, render); 48 | 49 | app.use((req, res, _next) => 50 | res.status(404).json({ 51 | success: false, 52 | error: `Cannot ${req.method} ${req.path}`, 53 | }), 54 | ); 55 | 56 | export default app; 57 | -------------------------------------------------------------------------------- /src/server/app/index.test.js: -------------------------------------------------------------------------------- 1 | import supertest from 'supertest'; 2 | 3 | import app from '.'; 4 | 5 | const request = supertest(app); 6 | 7 | describe('GET /api/health', () => { 8 | it('the status code should be 200.', async () => { 9 | await request.get('/api/health').expect(200); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/server/app/websocket-server-creator.js: -------------------------------------------------------------------------------- 1 | import { Server as ServerWebSocket } from 'ws'; 2 | 3 | const websocketServerCreator = (server) => { 4 | const wss = new ServerWebSocket({ server, path: '/ws' }); 5 | 6 | return wss; 7 | }; 8 | 9 | export default websocketServerCreator; 10 | -------------------------------------------------------------------------------- /src/server/db/users.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 1, 4 | "name": "Leanne Graham", 5 | "username": "Bret", 6 | "email": "Sincere@april.biz", 7 | "address": { 8 | "street": "Kulas Light", 9 | "suite": "Apt. 556", 10 | "city": "Gwenborough", 11 | "zipcode": "92998-3874", 12 | "geo": { 13 | "lat": "-37.3159", 14 | "lng": "81.1496" 15 | } 16 | }, 17 | "phone": "1-770-736-8031 x56442", 18 | "website": "hildegard.org", 19 | "company": { 20 | "name": "Romaguera-Crona", 21 | "catchPhrase": "Multi-layered client-server neural-net", 22 | "bs": "harness real-time e-markets" 23 | } 24 | }, 25 | { 26 | "id": 2, 27 | "name": "Ervin Howell", 28 | "username": "Antonette", 29 | "email": "Shanna@melissa.tv", 30 | "address": { 31 | "street": "Victor Plains", 32 | "suite": "Suite 879", 33 | "city": "Wisokyburgh", 34 | "zipcode": "90566-7771", 35 | "geo": { 36 | "lat": "-43.9509", 37 | "lng": "-34.4618" 38 | } 39 | }, 40 | "phone": "010-692-6593 x09125", 41 | "website": "anastasia.net", 42 | "company": { 43 | "name": "Deckow-Crist", 44 | "catchPhrase": "Proactive didactic contingency", 45 | "bs": "synergize scalable supply-chains" 46 | } 47 | }, 48 | { 49 | "id": 3, 50 | "name": "Clementine Bauch", 51 | "username": "Samantha", 52 | "email": "Nathan@yesenia.net", 53 | "address": { 54 | "street": "Douglas Extension", 55 | "suite": "Suite 847", 56 | "city": "McKenziehaven", 57 | "zipcode": "59590-4157", 58 | "geo": { 59 | "lat": "-68.6102", 60 | "lng": "-47.0653" 61 | } 62 | }, 63 | "phone": "1-463-123-4447", 64 | "website": "ramiro.info", 65 | "company": { 66 | "name": "Romaguera-Jacobson", 67 | "catchPhrase": "Face to face bifurcated interface", 68 | "bs": "e-enable strategic applications" 69 | } 70 | }, 71 | { 72 | "id": 4, 73 | "name": "Patricia Lebsack", 74 | "username": "Karianne", 75 | "email": "Julianne.OConner@kory.org", 76 | "address": { 77 | "street": "Hoeger Mall", 78 | "suite": "Apt. 692", 79 | "city": "South Elvis", 80 | "zipcode": "53919-4257", 81 | "geo": { 82 | "lat": "29.4572", 83 | "lng": "-164.2990" 84 | } 85 | }, 86 | "phone": "493-170-9623 x156", 87 | "website": "kale.biz", 88 | "company": { 89 | "name": "Robel-Corkery", 90 | "catchPhrase": "Multi-tiered zero tolerance productivity", 91 | "bs": "transition cutting-edge web services" 92 | } 93 | }, 94 | { 95 | "id": 5, 96 | "name": "Chelsey Dietrich", 97 | "username": "Kamren", 98 | "email": "Lucio_Hettinger@annie.ca", 99 | "address": { 100 | "street": "Skiles Walks", 101 | "suite": "Suite 351", 102 | "city": "Roscoeview", 103 | "zipcode": "33263", 104 | "geo": { 105 | "lat": "-31.8129", 106 | "lng": "62.5342" 107 | } 108 | }, 109 | "phone": "(254)954-1289", 110 | "website": "demarco.info", 111 | "company": { 112 | "name": "Keebler LLC", 113 | "catchPhrase": "User-centric fault-tolerant solution", 114 | "bs": "revolutionize end-to-end systems" 115 | } 116 | }, 117 | { 118 | "id": 6, 119 | "name": "Mrs. Dennis Schulist", 120 | "username": "Leopoldo_Corkery", 121 | "email": "Karley_Dach@jasper.info", 122 | "address": { 123 | "street": "Norberto Crossing", 124 | "suite": "Apt. 950", 125 | "city": "South Christy", 126 | "zipcode": "23505-1337", 127 | "geo": { 128 | "lat": "-71.4197", 129 | "lng": "71.7478" 130 | } 131 | }, 132 | "phone": "1-477-935-8478 x6430", 133 | "website": "ola.org", 134 | "company": { 135 | "name": "Considine-Lockman", 136 | "catchPhrase": "Synchronised bottom-line interface", 137 | "bs": "e-enable innovative applications" 138 | } 139 | }, 140 | { 141 | "id": 7, 142 | "name": "Kurtis Weissnat", 143 | "username": "Elwyn.Skiles", 144 | "email": "Telly.Hoeger@billy.biz", 145 | "address": { 146 | "street": "Rex Trail", 147 | "suite": "Suite 280", 148 | "city": "Howemouth", 149 | "zipcode": "58804-1099", 150 | "geo": { 151 | "lat": "24.8918", 152 | "lng": "21.8984" 153 | } 154 | }, 155 | "phone": "210.067.6132", 156 | "website": "elvis.io", 157 | "company": { 158 | "name": "Johns Group", 159 | "catchPhrase": "Configurable multimedia task-force", 160 | "bs": "generate enterprise e-tailers" 161 | } 162 | }, 163 | { 164 | "id": 8, 165 | "name": "Nicholas Runolfsdottir V", 166 | "username": "Maxime_Nienow", 167 | "email": "Sherwood@rosamond.me", 168 | "address": { 169 | "street": "Ellsworth Summit", 170 | "suite": "Suite 729", 171 | "city": "Aliyaview", 172 | "zipcode": "45169", 173 | "geo": { 174 | "lat": "-14.3990", 175 | "lng": "-120.7677" 176 | } 177 | }, 178 | "phone": "586.493.6943 x140", 179 | "website": "jacynthe.com", 180 | "company": { 181 | "name": "Abernathy Group", 182 | "catchPhrase": "Implemented secondary concept", 183 | "bs": "e-enable extensible e-tailers" 184 | } 185 | }, 186 | { 187 | "id": 9, 188 | "name": "Glenna Reichert", 189 | "username": "Delphine", 190 | "email": "Chaim_McDermott@dana.io", 191 | "address": { 192 | "street": "Dayna Park", 193 | "suite": "Suite 449", 194 | "city": "Bartholomebury", 195 | "zipcode": "76495-3109", 196 | "geo": { 197 | "lat": "24.6463", 198 | "lng": "-168.8889" 199 | } 200 | }, 201 | "phone": "(775)976-6794 x41206", 202 | "website": "conrad.com", 203 | "company": { 204 | "name": "Yost and Sons", 205 | "catchPhrase": "Switchable contextually-based project", 206 | "bs": "aggregate real-time technologies" 207 | } 208 | }, 209 | { 210 | "id": 10, 211 | "name": "Clementina DuBuque", 212 | "username": "Moriah.Stanton", 213 | "email": "Rey.Padberg@karina.biz", 214 | "address": { 215 | "street": "Kattie Turnpike", 216 | "suite": "Suite 198", 217 | "city": "Lebsackbury", 218 | "zipcode": "31428-2261", 219 | "geo": { 220 | "lat": "-38.2386", 221 | "lng": "57.2232" 222 | } 223 | }, 224 | "phone": "024-648-3804", 225 | "website": "ambrose.net", 226 | "company": { 227 | "name": "Hoeger LLC", 228 | "catchPhrase": "Centralized empowering task-force", 229 | "bs": "target end-to-end models" 230 | } 231 | } 232 | ] 233 | -------------------------------------------------------------------------------- /src/server/index.js: -------------------------------------------------------------------------------- 1 | import serverConfig from 'configs/server'; 2 | import app from './app'; 3 | import terminate from './terminate'; 4 | 5 | app.listen(serverConfig.PORT, '0.0.0.0'); 6 | 7 | const exitHandler = terminate(app); 8 | 9 | process.on('uncaughtException', exitHandler(1, 'Unexpected Error')); 10 | process.on('unhandledRejection', exitHandler(1, 'Unhandled Promise')); 11 | process.on('SIGTERM', exitHandler(0, 'SIGTERM')); 12 | process.on('SIGINT', exitHandler(0, 'SIGINT')); 13 | -------------------------------------------------------------------------------- /src/server/middlewares/webpack.middleware.js: -------------------------------------------------------------------------------- 1 | import os from 'os'; 2 | import webpack from 'webpack'; 3 | import whm from 'webpack-hot-middleware'; 4 | import wdm from 'webpack-dev-middleware'; 5 | import formatWebpackMessages from 'react-dev-utils/formatWebpackMessages'; 6 | import errorOverlayMiddleware from 'react-dev-utils/errorOverlayMiddleware'; 7 | import openBrowser from 'react-dev-utils/openBrowser'; 8 | import colors from 'picocolors'; 9 | 10 | import serverConfig from 'configs/server'; 11 | import config from '~/webpack/webpack.config.client'; 12 | 13 | const isInteractive = process.stdout.isTTY; 14 | 15 | const clearConsole = () => 16 | process.stdout.write(process.platform === 'win32' ? '\x1B[2J\x1B[0f' : '\x1B[2J\x1B[3J\x1B[H'); 17 | 18 | const printInstructions = () => { 19 | const interfaces = os.networkInterfaces(); 20 | 21 | const arrInfo = Object.values(interfaces).reduce((original, details) => { 22 | let arr = original; 23 | arr = arr.concat(...details.filter(({ family, internal }) => family === 'IPv4' && !internal)); 24 | 25 | return arr; 26 | }, []); 27 | 28 | // get last IPv4 29 | const [lastIPv4] = arrInfo; 30 | 31 | const host = !lastIPv4 ? '0.0.0.0' : lastIPv4.address; 32 | 33 | console.log(`You can now view ${colors.bold('app')} in the browser.\n`); 34 | console.log(` ${colors.bold('Local:')}\t\thttp://localhost:${colors.bold(serverConfig.PORT)}`); 35 | console.log(` ${colors.bold('On Your Network:')}\thttp://${host}:${colors.bold(serverConfig.PORT)}\n`); 36 | console.log('Note that the development build is not optimized.'); 37 | console.log(`To create a production build, use ${colors.blue('npm run build')}.\n`); 38 | }; 39 | 40 | const webpackMiddleware = (wsServer) => { 41 | console.log(colors.cyan('Starting the development server...\n')); 42 | openBrowser(`http://localhost:${serverConfig.PORT}`); 43 | 44 | const compiler = webpack(config); 45 | 46 | let isFirstCompile = true; 47 | 48 | compiler.hooks.invalid.tap('invalid', () => { 49 | if (isInteractive) { 50 | clearConsole(); 51 | } 52 | 53 | console.log('Compiling...'); 54 | }); 55 | 56 | compiler.hooks.done.tap('done', (stats) => { 57 | if (isInteractive) { 58 | clearConsole(); 59 | } 60 | 61 | const statsData = stats.toJson({ 62 | all: false, 63 | warnings: true, 64 | errors: true, 65 | }); 66 | 67 | const messages = formatWebpackMessages(statsData); 68 | 69 | const isSuccessful = !messages.errors.length && !messages.warnings.length; 70 | 71 | if (isSuccessful) { 72 | console.log(colors.green('Compiled successfully!\n')); 73 | 74 | if (isInteractive || isFirstCompile) { 75 | printInstructions(); 76 | } 77 | } 78 | isFirstCompile = false; 79 | 80 | if (messages.errors.length) { 81 | if (messages.errors.length > 1) { 82 | messages.errors.length = 1; 83 | } 84 | 85 | const errors = messages.errors.join('\n\n'); 86 | 87 | console.log(colors.red('Failed to compile.\n')); 88 | console.log(errors); 89 | 90 | wsServer.on('connection', (ws) => { 91 | ws.send(JSON.stringify({ message: errors, type: 'error' })); 92 | }); 93 | 94 | return; 95 | } 96 | 97 | if (messages.warnings.length) { 98 | console.log(colors.yellow('Compiled with warnings.\n')); 99 | 100 | const warnings = messages.warnings.join('\n\n'); 101 | 102 | console.log(warnings); 103 | console.log(`\nSearch for the ${colors.underline(colors.yellow('keywords'))} to learn more about each warning.`); 104 | console.log(`To ignore, add ${colors.cyan('// eslint-disable-next-line')} to the line before.\n`); 105 | 106 | wsServer.on('connection', (ws) => { 107 | ws.send(JSON.stringify({ message: warnings, type: 'warn' })); 108 | }); 109 | } 110 | }); 111 | 112 | return [ 113 | whm(compiler, { log: false, path: '/__webpack_hmr', heartbeat: 200 }), 114 | wdm(compiler, { serverSideRender: true, writeToDisk: true }), 115 | errorOverlayMiddleware(), 116 | ]; 117 | }; 118 | 119 | export default webpackMiddleware; 120 | -------------------------------------------------------------------------------- /src/server/render/index.js: -------------------------------------------------------------------------------- 1 | import { resolve } from 'path'; 2 | import React from 'react'; 3 | import { renderToString } from 'react-dom/server'; 4 | import { matchRoutes } from 'react-router-dom'; 5 | import { StaticRouter } from 'react-router-dom/server'; 6 | import { ChunkExtractor, ChunkExtractorManager } from '@loadable/server'; 7 | import { HelmetProvider } from 'react-helmet-async'; 8 | import { Provider } from 'react-redux'; 9 | import ssrPrepass from 'react-ssr-prepass'; 10 | 11 | import App from 'client/app'; 12 | import configurationStore from 'client/store'; 13 | import routes from 'client/routes'; 14 | 15 | import renderHtml from './render-html'; 16 | import sitemapGenerator from './sitemap-generator'; 17 | 18 | const renderController = async (req, res) => { 19 | const store = configurationStore(); 20 | 21 | const contexts = []; 22 | 23 | const loadBranchData = () => { 24 | const routesMatch = matchRoutes(routes, req.path); 25 | 26 | sitemapGenerator(req, routesMatch); 27 | 28 | const promises = routesMatch.map(({ route, params }) => { 29 | // handling redirects in react-router v6 30 | // reference: https://gist.github.com/htdangkhoa/5b3407c749b6fb8cf05cfb591ec3ef07#handling-redirects-in-react-router-v6 31 | if (typeof route.to === 'string') { 32 | contexts.push({ url: route.to, status: 301 }); 33 | 34 | return Promise.resolve(null); 35 | } 36 | 37 | if (route.path === '*') { 38 | contexts.push({ status: 404 }); 39 | } else { 40 | contexts.push({ status: 200 }); 41 | } 42 | 43 | if (typeof route.loadData === 'function') { 44 | const thunks = route.loadData({ params, getState: store.getState }).map((thunk) => store.dispatch(thunk)); 45 | 46 | return Promise.all(thunks); 47 | } 48 | 49 | return Promise.resolve(null); 50 | }); 51 | 52 | return Promise.all(promises); 53 | }; 54 | 55 | await loadBranchData(); 56 | 57 | const [redirectContext] = contexts; 58 | 59 | // handling redirects in react-router v6 60 | // reference: https://gist.github.com/htdangkhoa/5b3407c749b6fb8cf05cfb591ec3ef07#handling-redirects-in-react-router-v6 61 | if (redirectContext && redirectContext.url) return res.redirect(301, redirectContext.url); 62 | 63 | const statsFile = resolve(process.cwd(), 'public/stats.json'); 64 | 65 | const extractor = new ChunkExtractor({ statsFile }); 66 | 67 | const helmetContext = {}; 68 | 69 | const node = await ssrPrepass( 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | , 79 | ); 80 | 81 | const markup = renderToString(node); 82 | 83 | const { helmet: head } = helmetContext; 84 | 85 | const initialState = store.getState(); 86 | 87 | const canonical = [ 88 | `${req.protocol}://`, 89 | req.host, 90 | ![80, 443].includes(req.port) && `:${req.port}`, 91 | req.originalUrl, 92 | ].join(''); 93 | 94 | const html = renderHtml(head, canonical, extractor, markup, initialState); 95 | 96 | // handle not found status 97 | const status = contexts.filter(({ status: stt }) => stt === 404).length !== 0 ? 404 : 200; 98 | 99 | return res.send(html, status, { 100 | 'Content-Type': 'text/html; charset=utf-8', 101 | 'Cache-Control': 'no-store', 102 | }); 103 | }; 104 | 105 | export default renderController; 106 | -------------------------------------------------------------------------------- /src/server/render/index.test.js: -------------------------------------------------------------------------------- 1 | import supertest from 'supertest'; 2 | import app from '../app'; 3 | 4 | const request = supertest(app); 5 | 6 | describe('server side rendering', () => { 7 | describe('GET /', () => { 8 | it('should redirect to /home', async () => { 9 | await request.get('/').expect('Location', '/home'); 10 | }); 11 | }); 12 | 13 | describe('GET /home', () => { 14 | it('should render html', async () => { 15 | await request.get('/home').expect('content-type', /text\/html/i); 16 | }); 17 | }); 18 | 19 | describe('GET /todo-info/1', () => { 20 | it('should render html', async () => { 21 | await request.get('/todo-info/1').expect('content-type', /text\/html/i); 22 | }); 23 | }); 24 | 25 | describe('GET /page-not-found', () => { 26 | it('should render html', async () => { 27 | await request.get('/page-not-found').expect(404); 28 | }); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/server/render/render-html.js: -------------------------------------------------------------------------------- 1 | import serialize from 'serialize-javascript'; 2 | import { minify } from 'html-minifier'; 3 | 4 | const renderHtml = (head, canonical, extractor, markup, initialState = {}) => { 5 | let googleMetaTag = ''; 6 | 7 | if (process.env.GOOGLE_SITE_VERIFICATION) { 8 | googleMetaTag = ``; 9 | } 10 | 11 | const html = ` 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | ${googleMetaTag} 38 | 39 | ${process.env.SECRET} 40 | 41 | ${head.title.toString()} 42 | ${head.base.toString()} 43 | ${head.meta.toString()} 44 | ${head.link.toString()} 45 | 46 | 47 | ${extractor.getLinkTags()} 48 | ${extractor.getStyleTags()} 49 | 50 | 51 | 52 | 53 |
${markup}
54 | 55 | 56 | 57 | 58 | ${extractor.getScriptTags()} 59 | 60 | ${head.script.toString()} 61 | 62 | 63 | `; 64 | 65 | const minifyConfig = { 66 | collapseWhitespace: true, 67 | removeComments: true, 68 | trimCustomFragments: true, 69 | minifyCSS: true, 70 | minifyJS: true, 71 | minifyURLs: true, 72 | }; 73 | 74 | return __DEV__ ? html : minify(html, minifyConfig); 75 | }; 76 | 77 | export default renderHtml; 78 | -------------------------------------------------------------------------------- /src/server/render/sitemap-generator.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | 4 | /** 5 | * @param {string} xml 6 | */ 7 | const formatXml = (xml) => { 8 | let s = xml; 9 | 10 | // Create the padding element 11 | const spaces = ' '.repeat(2); 12 | 13 | // Regex to match xml tags. 14 | const attrib = '\\s*\\w+(?:\\s*=\\s*(?:\\w+|"[^"\\\\]*(?:\\\\.[^"\\\\]*)*"))?'; 15 | const anyTag = new RegExp(`(<\\/?\\w+(?:${attrib})*\\s*\\/?>)`, 'g'); 16 | 17 | // Split into 'clean' new lines. 18 | s = s 19 | .split(anyTag) 20 | .map((line) => line.trim()) 21 | .filter((line) => line !== '') 22 | .map((line) => line.split(/\r?\n|\r/)); 23 | 24 | let pad = 0; 25 | // 'flatten' the array. 26 | return [] 27 | .concat(...s) 28 | .map((line) => { 29 | if (line[0] === '<' && line[1] === '/') pad -= 1; 30 | const out = spaces.repeat(pad) + line; 31 | if (line[0] === '<' && line[1] !== '/') pad += 1; 32 | 33 | return out; 34 | }) 35 | .join('\n'); 36 | }; 37 | 38 | /** 39 | * @param {import('pure-http').IRequest} req 40 | * @param {any[]} routesMatch 41 | */ 42 | const sitemapGenerator = (req, routesMatch = []) => { 43 | const baseUrl = [`${req.protocol}://`, req.hostname, ![80, 443].includes(req.port) && `:${req.port}`] 44 | .filter(Boolean) 45 | .join(''); 46 | 47 | const urls = routesMatch.map(({ pathname }, i) => { 48 | let s = `\n${baseUrl}${pathname}\n`; 49 | 50 | if (i !== routesMatch.length - 1) { 51 | s += '\n'; 52 | } 53 | 54 | return s; 55 | }); 56 | 57 | let beautyXml = '\n'; 58 | 59 | const template = ` 60 | 61 | ${urls.join('')} 62 | 63 | `; 64 | 65 | beautyXml += formatXml(template); 66 | 67 | const sitemapPath = path.resolve(process.cwd(), 'public/sitemap.xml'); 68 | 69 | fs.writeFileSync(sitemapPath, beautyXml, 'utf8'); 70 | 71 | return beautyXml; 72 | }; 73 | 74 | export default sitemapGenerator; 75 | -------------------------------------------------------------------------------- /src/server/terminate.js: -------------------------------------------------------------------------------- 1 | const terminate = (server) => { 2 | // Exit function 3 | const exit = (code) => { 4 | process.exit(code); 5 | }; 6 | 7 | // eslint-disable-next-line no-unused-vars 8 | return (code, reason) => (err, promise) => { 9 | if (err && err instanceof Error) { 10 | // Log error information, use a proper logging library here 11 | console.log(err.message, err.stack); 12 | } 13 | 14 | // Attempt a graceful shutdown 15 | server.close(exit); 16 | 17 | process.nextTick(exit); 18 | }; 19 | }; 20 | 21 | export default terminate; 22 | -------------------------------------------------------------------------------- /src/test-utils/render.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/prop-types */ 2 | /* eslint-disable import/no-extraneous-dependencies */ 3 | import React from 'react'; 4 | import { Provider } from 'react-redux'; 5 | import { HelmetProvider } from 'react-helmet-async'; 6 | import { render as rtlRender } from '@testing-library/react'; 7 | import { MemoryRouter } from 'react-router-dom'; 8 | import configurationStore from 'client/store'; 9 | 10 | const store = configurationStore(); 11 | 12 | export const render = (ui, { path = '/', useRouter = false, useStore = true } = {}) => { 13 | const Router = ({ children }) => 14 | !useRouter ? children : {children}; 15 | 16 | const Component = ({ children }) => 17 | useStore ? ( 18 | 19 | {children} 20 | 21 | ) : ( 22 | {children} 23 | ); 24 | 25 | return rtlRender( 26 | 27 | {ui} 28 | , 29 | ); 30 | }; 31 | 32 | export * from '@testing-library/react'; 33 | -------------------------------------------------------------------------------- /webpack/entries/react-error-overlay.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import { setEditorHandler, startReportingRuntimeErrors, reportBuildError } from 'react-error-overlay'; 3 | import launchEditorEndpoint from 'react-dev-utils/launchEditorEndpoint'; 4 | 5 | setEditorHandler((errorLocation) => { 6 | // Keep this sync with errorOverlayMiddleware.js 7 | fetch( 8 | `${launchEditorEndpoint}?fileName=${window.encodeURIComponent( 9 | errorLocation.fileName, 10 | )}&lineNumber=${window.encodeURIComponent(errorLocation.lineNumber || 1)}&colNumber=${window.encodeURIComponent( 11 | errorLocation.colNumber || 1, 12 | )}`, 13 | ); 14 | }); 15 | 16 | startReportingRuntimeErrors({ 17 | onError() { 18 | if (module.hot) { 19 | module.hot.addStatusHandler((status) => { 20 | if (status === 'apply') { 21 | window.location.reload(); 22 | } 23 | }); 24 | } 25 | }, 26 | }); 27 | 28 | const ws = new WebSocket(`ws://${window.location.host}/ws`); 29 | 30 | ws.onopen = () => { 31 | console.log('ws open'); 32 | }; 33 | 34 | ws.onmessage = (event) => { 35 | const { type = 'log', message } = JSON.parse(event.data); 36 | 37 | console[type](message); 38 | 39 | if (type === 'error') { 40 | reportBuildError(message); 41 | } 42 | }; 43 | -------------------------------------------------------------------------------- /webpack/loaders/url-loader.js: -------------------------------------------------------------------------------- 1 | module.exports = function urlLoader() { 2 | const path = JSON.stringify(this.resourcePath); 3 | return `export default new URL(${path}, import.meta.url).toString();`; 4 | }; 5 | -------------------------------------------------------------------------------- /webpack/plugins/dotenv-webpack-plugin.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const fs = require('fs'); 3 | const { DefinePlugin, ProvidePlugin } = require('webpack'); 4 | const dotenv = require('dotenv'); 5 | const { expand: dotenvExpand } = require('dotenv-expand'); 6 | 7 | const REACT_APP = /^REACT_APP_/i; 8 | 9 | // eslint-disable-next-line prefer-destructuring 10 | const NODE_ENV = process.env.NODE_ENV; 11 | 12 | const envPath = path.resolve(process.cwd(), `.env`); 13 | 14 | const loadEnv = (...paths) => { 15 | const envFiles = [ 16 | `${envPath}.${NODE_ENV}.local`, 17 | /** 18 | * Don't include `.env.local` for `test` environment 19 | * since normally you expect tests to produce the same 20 | * results for everyone 21 | */ 22 | NODE_ENV !== 'test' && `${envPath}.local`, 23 | `${envPath}.${NODE_ENV}`, 24 | envPath, 25 | ...paths, 26 | ].filter(Boolean); 27 | 28 | envFiles.forEach((file) => { 29 | if (fs.existsSync(file)) { 30 | dotenvExpand(dotenv.config({ path: file })); 31 | } 32 | }); 33 | }; 34 | exports.loadEnv = loadEnv; 35 | 36 | class DotenvWebpackPlugin { 37 | /** 38 | * @param {Object} [options] 39 | * @param {boolean} [options.path=".env"] 40 | * @param {boolean} [options.isWeb=false] 41 | */ 42 | constructor(options) { 43 | this.PLUGIN_NAME = 'DotenvWebpackPlugin'; 44 | 45 | this.options = { ...options }; 46 | 47 | loadEnv(this.options.path); 48 | } 49 | 50 | /** 51 | * @param {import('webpack').Compiler} compiler 52 | */ 53 | 54 | apply(compiler) { 55 | // it keeps your app from crashing when calling `process` on client-side 56 | if (this.options.isWeb) { 57 | new ProvidePlugin({ 58 | process: 'process/browser', 59 | }).apply(compiler); 60 | } 61 | 62 | // eslint-disable-next-line prefer-object-spread 63 | const vars = Object.assign({}, process.env); 64 | 65 | const raw = Object.keys(vars) 66 | .filter((key) => (this.options.isWeb ? REACT_APP.test(key) : true)) 67 | .reduce( 68 | (obj, key) => { 69 | const _obj = obj; 70 | _obj[key] = vars[key]; 71 | return _obj; 72 | }, 73 | { 74 | NODE_ENV: NODE_ENV || 'development', 75 | }, 76 | ); 77 | 78 | const formattedVars = Object.keys(raw).reduce((env, key) => { 79 | const _env = env; 80 | /** 81 | * Webpack 5 not polyfilling `process.env` 82 | * Reference: https://github.com/mrsteele/dotenv-webpack/issues/240#issuecomment-710231534 83 | */ 84 | _env[`process.env.${key}`] = JSON.stringify(raw[key]); 85 | 86 | return _env; 87 | }, {}); 88 | 89 | new DefinePlugin(formattedVars).apply(compiler); 90 | } 91 | } 92 | 93 | exports.DotenvWebpackPlugin = DotenvWebpackPlugin; 94 | -------------------------------------------------------------------------------- /webpack/plugins/spawn-webpack-plugin.js: -------------------------------------------------------------------------------- 1 | const spawn = require('cross-spawn'); 2 | 3 | const registerShutdown = (callback) => { 4 | let run = false; 5 | 6 | function wrapper() { 7 | if (run) return; 8 | 9 | run = true; 10 | 11 | callback(); 12 | } 13 | 14 | ['SIGINT', 'SIGTERM', 'exit'].forEach((signal) => { 15 | process.on(signal, wrapper); 16 | }); 17 | }; 18 | 19 | /** 20 | * @class ShellWebpackPlugin 21 | */ 22 | class SpawnWebpackPlugin { 23 | /** 24 | * @param {string} command 25 | * @param {string[]} [args] 26 | * @param {Object} [options] 27 | * @param {boolean} [options.dev=false] 28 | */ 29 | constructor(command, args, options) { 30 | this.PLUGIN_NAME = 'SpawnWebpackPlugin'; 31 | 32 | this.command = command; 33 | 34 | let _args = []; 35 | 36 | if (Array.isArray(args)) { 37 | _args = args; 38 | } 39 | 40 | this.args = _args; 41 | 42 | this.opts = { ...options }; 43 | 44 | registerShutdown(() => { 45 | this.tryToKillProcess(true); 46 | }); 47 | } 48 | 49 | tryToKillProcess(force) { 50 | if (this.nodeProcess) { 51 | try { 52 | this.nodeProcess.kill('SIGINT'); 53 | 54 | this.nodeProcess = undefined; 55 | } catch (err) { 56 | console.error(err); 57 | } 58 | } 59 | 60 | if (force) { 61 | process.exit(); 62 | } 63 | } 64 | 65 | /** 66 | * @param {import('webpack').Compiler} compiler 67 | */ 68 | apply(compiler) { 69 | if (!this.opts.dev) return; 70 | 71 | compiler.hooks.done.tapAsync(this.PLUGIN_NAME, (stats, callback) => { 72 | if (this.nodeProcess) { 73 | this.tryToKillProcess(); 74 | } 75 | 76 | if (stats.hasErrors()) { 77 | const msg = stats.toString({ 78 | colors: true, 79 | modules: false, 80 | children: false, 81 | chunks: false, 82 | chunkModules: false, 83 | }); 84 | 85 | process.stdout.write(`${msg}\n`); 86 | 87 | return process.exit(1); 88 | } 89 | 90 | this.nodeProcess = spawn(this.command, this.args, { 91 | shell: true, 92 | env: process.env, 93 | stdio: 'inherit', 94 | }); 95 | 96 | return callback(); 97 | }); 98 | } 99 | } 100 | 101 | module.exports = SpawnWebpackPlugin; 102 | -------------------------------------------------------------------------------- /webpack/webpack.config.base.js: -------------------------------------------------------------------------------- 1 | const { resolve } = require('path'); 2 | const fs = require('fs'); 3 | const glob = require('glob'); 4 | const webpack = require('webpack'); 5 | const TerserPlugin = require('terser-webpack-plugin'); 6 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 7 | const { CleanWebpackPlugin: CleanPlugin } = require('clean-webpack-plugin'); 8 | const ESLintPlugin = require('eslint-webpack-plugin'); 9 | const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer'); 10 | const eslintFormatter = require('react-dev-utils/eslintFormatter'); 11 | 12 | const { DotenvWebpackPlugin, loadEnv } = require('./plugins/dotenv-webpack-plugin'); 13 | 14 | // load default env 15 | loadEnv(); 16 | const emitErrorsAsWarnings = process.env.ESLINT_NO_DEV_ERRORS === 'true'; 17 | const disableESLintPlugin = process.env.DISABLE_ESLINT_PLUGIN === 'true'; 18 | 19 | // by default, a file with size less than 10000 bytes will be inlined as a data URI and emitted as a separate file otherwise 20 | const IMAGE_INLINE_SIZE_LIMIT = parseInt(process.env.IMAGE_INLINE_SIZE_LIMIT || '10000', 10); 21 | 22 | const isDev = () => !['production', 'test', 'analyze'].includes(process.env.NODE_ENV); 23 | exports.isDev = isDev; 24 | 25 | const getPath = (...args) => resolve(process.cwd(), ...args); 26 | exports.getPath = getPath; 27 | 28 | const mergeBaseEntry = (...main) => { 29 | const configsPath = getPath('src/configs'); 30 | 31 | const patten = `${configsPath}/**.js*`; 32 | 33 | const configs = glob.sync(patten); 34 | 35 | return configs.concat(...main).filter(Boolean); 36 | }; 37 | exports.mergeBaseEntry = mergeBaseEntry; 38 | 39 | const _isDev = isDev(); 40 | 41 | const getPlugins = (isWeb) => { 42 | const plugins = [ 43 | new webpack.ProgressPlugin(), 44 | new webpack.DefinePlugin({ 45 | __CLIENT__: isWeb, 46 | __SERVER__: !isWeb, 47 | __DEV__: _isDev, 48 | }), 49 | new DotenvWebpackPlugin({ isWeb }), 50 | new CleanPlugin({ 51 | cleanOnceBeforeBuildPatterns: [ 52 | '**/*', 53 | '!robots.txt', 54 | '!android-icon-*', 55 | '!apple-icon*', 56 | '!browserconfig.xml', 57 | '!favicon*', 58 | '!ms-icon*', 59 | '!icon-*.png', 60 | '!icon-*.png', 61 | '!site.webmanifest', 62 | ], 63 | }), 64 | ]; 65 | 66 | if (process.env.NODE_ENV === 'analyze') { 67 | plugins.push( 68 | new BundleAnalyzerPlugin({ 69 | analyzerMode: 'server', 70 | }), 71 | ); 72 | } 73 | 74 | if (!disableESLintPlugin) { 75 | plugins.push( 76 | new ESLintPlugin({ 77 | extensions: ['js', 'jsx'], 78 | cache: true, 79 | cacheLocation: getPath('.cache/.eslintcache'), 80 | threads: 2, 81 | formatter: eslintFormatter, 82 | failOnError: !(_isDev && emitErrorsAsWarnings), 83 | exclude: ['node_modules', !isWeb && 'src/client', isWeb && 'src/server'].filter(Boolean), 84 | }), 85 | ); 86 | } 87 | 88 | return plugins.filter(Boolean); 89 | }; 90 | 91 | const getStyleLoaders = (isWeb, isModule) => { 92 | const dev = _isDev; 93 | 94 | const loaders = [ 95 | { 96 | loader: 'css-loader', 97 | options: { 98 | sourceMap: dev, 99 | importLoaders: 2, 100 | modules: !isModule 101 | ? 'global' 102 | : { 103 | auto: true, 104 | localIdentName: dev ? '[name]__[local]__[contenthash:base64:5]' : '[contenthash:base64:5]', 105 | exportLocalsConvention: 'camelCase', 106 | exportOnlyLocals: !isWeb, 107 | }, 108 | }, 109 | }, 110 | { loader: 'sass-loader', options: { sourceMap: !dev } }, 111 | { 112 | loader: 'postcss-loader', 113 | options: { 114 | sourceMap: !dev, 115 | }, 116 | }, 117 | ]; 118 | 119 | if (isWeb) { 120 | loaders.unshift(dev ? 'style-loader' : MiniCssExtractPlugin.loader); 121 | } 122 | 123 | return loaders; 124 | }; 125 | 126 | const getAlias = () => ({ 127 | '~': getPath(), 128 | configs: getPath('src/configs'), 129 | client: getPath('src/client'), 130 | server: getPath('src/server'), 131 | 'test-utils': getPath('src/test-utils'), 132 | }); 133 | exports.getAlias = getAlias; 134 | 135 | const getOptimization = () => { 136 | if (_isDev) return undefined; 137 | 138 | return { 139 | minimizer: [ 140 | new TerserPlugin({ 141 | parallel: true, 142 | extractComments: false, 143 | minify: TerserPlugin.swcMinify, 144 | terserOptions: { 145 | compress: { 146 | drop_console: true, 147 | }, 148 | mangle: true, 149 | }, 150 | }), 151 | ], 152 | }; 153 | }; 154 | 155 | const swcConfig = JSON.parse(fs.readFileSync(getPath('.swcrc'), 'utf-8')); 156 | swcConfig.jsc.transform.react.development = _isDev; 157 | swcConfig.sourceMaps = _isDev; 158 | swcConfig.minify = !_isDev; 159 | 160 | exports.baseConfig = (isWeb) => ({ 161 | mode: _isDev ? 'development' : 'production', 162 | devtool: _isDev ? 'cheap-module-source-map' : false, 163 | stats: 'minimal', 164 | output: { clean: !isWeb }, 165 | plugins: getPlugins(isWeb), 166 | module: { 167 | rules: [ 168 | { 169 | test: /\.jsx?$/, 170 | exclude: /node_modules/, 171 | loader: 'swc-loader', 172 | options: swcConfig, 173 | }, 174 | { 175 | test: /\.(sa|sc|c)ss$/, 176 | exclude: /\.module\.(sa|sc|c)ss$/, 177 | use: getStyleLoaders(isWeb), 178 | }, 179 | { 180 | test: /\.module\.(sa|sc|c)ss$/, 181 | use: getStyleLoaders(isWeb, true), 182 | }, 183 | { 184 | test: [/\.avif$/], 185 | type: 'asset', 186 | generator: { 187 | emit: isWeb, 188 | publicPath: '/', 189 | dataUrl: { mimetype: 'image/avif' }, 190 | }, 191 | parser: { 192 | dataUrlCondition: { maxSize: IMAGE_INLINE_SIZE_LIMIT }, 193 | }, 194 | }, 195 | { 196 | test: /\.(bmp|png|jpe?g|gif|woff2?|eot|ttf|otf)$/, 197 | type: 'asset', 198 | generator: { 199 | emit: isWeb, 200 | publicPath: '/', 201 | }, 202 | parser: { 203 | dataUrlCondition: { maxSize: IMAGE_INLINE_SIZE_LIMIT }, 204 | }, 205 | }, 206 | { 207 | test: /\.svg$/, 208 | oneOf: [ 209 | { 210 | issuer: /\.jsx?$/, 211 | use: [ 212 | { 213 | loader: '@svgr/webpack', 214 | options: { 215 | exportType: 'named', 216 | prettier: false, 217 | svgo: false, 218 | svgoConfig: { 219 | plugins: [{ removeViewBox: false }], 220 | }, 221 | titleProp: true, 222 | ref: true, 223 | }, 224 | }, 225 | { 226 | loader: require.resolve('./loaders/url-loader'), 227 | }, 228 | ], 229 | }, 230 | { 231 | type: 'asset', 232 | generator: { 233 | emit: isWeb, 234 | publicPath: '/', 235 | }, 236 | }, 237 | ], 238 | }, 239 | ], 240 | }, 241 | resolve: { 242 | modules: ['node_modules'], 243 | extensions: ['.json', '.js', '.jsx'], 244 | alias: getAlias(), 245 | }, 246 | optimization: getOptimization(), 247 | // performance: { 248 | // maxEntrypointSize: 512000, 249 | // maxAssetSize: 512000, 250 | // }, 251 | }); 252 | -------------------------------------------------------------------------------- /webpack/webpack.config.client.js: -------------------------------------------------------------------------------- 1 | const { merge } = require('webpack-merge'); 2 | const LoadablePlugin = require('@loadable/webpack-plugin'); 3 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 4 | const WorkboxWebpackPlugin = require('workbox-webpack-plugin'); 5 | const webpack = require('webpack'); 6 | const ReactRefreshPlugin = require('@pmmmwh/react-refresh-webpack-plugin'); 7 | const CssMinimizerPlugin = require('css-minimizer-webpack-plugin'); 8 | const { baseConfig, getPath, isDev, mergeBaseEntry } = require('./webpack.config.base'); 9 | 10 | const _isDev = isDev(); 11 | 12 | const config = baseConfig(true); 13 | 14 | const rules = [...config.module.rules]; 15 | const [swcConfig] = rules.splice(0, 1); 16 | Object.assign(swcConfig.options.jsc.transform.react, { refresh: _isDev }); 17 | rules.shift(swcConfig); 18 | 19 | const getEntry = () => { 20 | const entries = [getPath('src/client/index.jsx')]; 21 | 22 | if (_isDev) { 23 | entries.unshift( 24 | require.resolve('./entries/react-error-overlay'), 25 | 'webpack-hot-middleware/client?reload=true&timeout=2000', 26 | 'react-refresh/runtime', 27 | ); 28 | } 29 | 30 | return mergeBaseEntry(...entries); 31 | }; 32 | 33 | const getPlugins = () => { 34 | const plugins = [ 35 | new LoadablePlugin({ filename: 'stats.json', writeToDisk: true }), 36 | new MiniCssExtractPlugin({ 37 | filename: _isDev ? '[name].css' : '[name].[contenthash:8].css', 38 | chunkFilename: _isDev ? '[id].css' : '[id].[contenthash:8].css', 39 | }), 40 | ]; 41 | 42 | if (_isDev) { 43 | plugins.push(new webpack.HotModuleReplacementPlugin()); 44 | plugins.push(new ReactRefreshPlugin()); 45 | } else { 46 | plugins.push( 47 | new WorkboxWebpackPlugin.GenerateSW({ 48 | mode: 'production', 49 | swDest: 'sw.js', 50 | skipWaiting: true, 51 | clientsClaim: true, 52 | cleanupOutdatedCaches: true, 53 | runtimeCaching: [ 54 | { 55 | urlPattern: /\.(jpe?g|png|svg|gif|webp)$/, 56 | handler: 'CacheFirst', 57 | options: { 58 | cacheName: 'images', 59 | }, 60 | }, 61 | { 62 | urlPattern: /\.(js|json|css)$/, 63 | handler: 'StaleWhileRevalidate', 64 | options: { cacheName: 'static-resources' }, 65 | }, 66 | ], 67 | }), 68 | ); 69 | } 70 | 71 | return plugins; 72 | }; 73 | 74 | const getOptimization = () => { 75 | if (_isDev) return undefined; 76 | 77 | return { 78 | minimize: !_isDev, 79 | minimizer: [ 80 | new CssMinimizerPlugin({ 81 | minimizerOptions: { 82 | preset: [ 83 | 'default', 84 | { 85 | discardComments: { removeAll: true }, 86 | }, 87 | ], 88 | }, 89 | }), 90 | ], 91 | splitChunks: { 92 | chunks: 'all', 93 | enforceSizeThreshold: 50000, 94 | cacheGroups: { 95 | defaultVendors: { 96 | test: /[\\/]node_modules[\\/]/, 97 | priority: -10, 98 | reuseExistingChunk: true, 99 | }, 100 | default: { 101 | minChunks: 2, 102 | priority: -20, 103 | reuseExistingChunk: true, 104 | }, 105 | }, 106 | }, 107 | runtimeChunk: { 108 | name: (entrypoint) => `runtime-${entrypoint.name}`, 109 | }, 110 | }; 111 | }; 112 | 113 | module.exports = merge(config, { 114 | entry: getEntry(), 115 | output: { 116 | path: getPath('public'), 117 | filename: _isDev ? '[name].js' : '[name].[contenthash:8].js', 118 | chunkFilename: _isDev ? '[id].js' : '[id].[contenthash:8].js', 119 | publicPath: '/', 120 | }, 121 | plugins: getPlugins(), 122 | optimization: getOptimization(), 123 | stats: _isDev ? 'none' : { children: true, errorDetails: true }, 124 | }); 125 | -------------------------------------------------------------------------------- /webpack/webpack.config.server.js: -------------------------------------------------------------------------------- 1 | const { merge } = require('webpack-merge'); 2 | const nodeExternals = require('webpack-node-externals'); 3 | const { baseConfig, getPath, isDev, mergeBaseEntry } = require('./webpack.config.base'); 4 | const SpawnWebpackPlugin = require('./plugins/spawn-webpack-plugin'); 5 | 6 | const _isDev = isDev(); 7 | 8 | const registerShutdown = (fn) => { 9 | let run = false; 10 | 11 | const wrapper = () => { 12 | if (!run) { 13 | run = true; 14 | fn(); 15 | } 16 | }; 17 | 18 | process.on('SIGINT', wrapper); 19 | process.on('SIGTERM', wrapper); 20 | process.on('exit', wrapper); 21 | }; 22 | 23 | module.exports = merge(baseConfig(false), { 24 | entry: mergeBaseEntry(getPath('src/server/index.js')), 25 | target: 'node', 26 | watch: _isDev, 27 | watchOptions: { 28 | ignored: [getPath('src/client'), getPath('src/test-utils'), '**/node_modules'], 29 | }, 30 | output: { 31 | path: getPath('build'), 32 | filename: 'index.js', 33 | chunkFilename: '[id].js', 34 | libraryTarget: 'commonjs2', 35 | }, 36 | node: { __dirname: true, __filename: true }, 37 | externals: [ 38 | nodeExternals({ 39 | allowlist: [/\.(?!(?:jsx?|json)$).{1,5}$/i], 40 | }), 41 | ], 42 | plugins: [new SpawnWebpackPlugin('npm', ['start'], { dev: _isDev })], 43 | }); 44 | 45 | registerShutdown(() => { 46 | process.on('SIGINT', () => { 47 | process.exit(0); 48 | }); 49 | }); 50 | --------------------------------------------------------------------------------