├── .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 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
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 | |  |  |
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 ;
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 | You need to enable JavaScript to run this app.
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 |
--------------------------------------------------------------------------------