├── .nvmrc
├── .npmrc
├── mocks
├── json-server.json
├── db.json
└── server.js
├── .travis.yml
├── src
├── modules
│ ├── misc.js
│ ├── env.js
│ ├── router-utils.js
│ └── Request.js
├── components
│ ├── Loading.jsx
│ ├── NotFound.jsx
│ ├── AppError.jsx
│ └── Header.jsx
├── pages
│ ├── sub
│ │ ├── sub-name.jsx
│ │ └── sub2
│ │ │ └── sub3-module.jsx
│ ├── hello-async.jsx
│ ├── users
│ │ └── :user.jsx
│ ├── github.jsx
│ ├── hello.jsx
│ └── index.jsx
├── assets
│ └── static
│ │ ├── favicon.ico
│ │ ├── logo.svg
│ │ └── jetbrains.svg
├── style
│ └── index.css
├── App.jsx
├── index.js
├── AppRoutes.jsx
└── ssr
│ └── index.js
├── .prettierrc
├── .prettierignore
├── server.js
├── .dockerignore
├── dev.dockerfile
├── postcss.config.js
├── tailwind.config.js
├── views
└── index.html
├── utils
├── date.js
├── hash.js
└── common.js
├── config
├── config.default.dev.js
├── config.default.prod.js
├── app-config.js.sample
├── webpack.config.node.js
├── webpack.config.ssr.js
├── webpack.config.dev.js
├── webpack.config.prod.js
├── webpack.config.base.js
├── env.js
└── utils.js
├── pm2.config.js
├── Dockerfile
├── .gitignore
├── services
├── http-config.js
├── Cache.js
├── logger.js
├── ServerRenderer.js
└── HttpClient.js
├── LICENSE
├── .eslintrc.js
├── routes
├── proxy.js
└── index.js
├── api
└── api-config.js
├── .run
└── dev-kwk.run.xml
├── babel.config.js
├── __tests__
├── server
│ ├── HttpClient.js
│ └── koa.router.js
└── client
│ └── Request.js
├── deploy.sh
├── jest.config.node.js
├── jest.config.client.js
├── package.json
├── app.js
└── README.md
/.nvmrc:
--------------------------------------------------------------------------------
1 | 16
2 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | registry=https://registry.npm.taobao.org/
2 |
--------------------------------------------------------------------------------
/mocks/json-server.json:
--------------------------------------------------------------------------------
1 | {
2 | "host": "127.0.0.1"
3 | }
4 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js:
3 | - 16
4 | - stable
5 | script: npm test
6 |
--------------------------------------------------------------------------------
/src/modules/misc.js:
--------------------------------------------------------------------------------
1 | export function test() {
2 | console.log('in misc.text()');
3 | }
4 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "semi": true,
3 | "singleQuote": true,
4 | "trailingComma": "all"
5 | }
6 |
--------------------------------------------------------------------------------
/src/components/Loading.jsx:
--------------------------------------------------------------------------------
1 | export default function Loading() {
2 | return
Loading...
;
3 | }
4 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 | build
4 | logs
5 | docs
6 | package-lock.json
7 | mocks/db.json
8 |
--------------------------------------------------------------------------------
/src/pages/sub/sub-name.jsx:
--------------------------------------------------------------------------------
1 | function SubName() {
2 | return null;
3 | }
4 |
5 | export default SubName;
6 |
--------------------------------------------------------------------------------
/src/assets/static/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JasonBoy/koa-web-kit/HEAD/src/assets/static/favicon.ico
--------------------------------------------------------------------------------
/src/components/NotFound.jsx:
--------------------------------------------------------------------------------
1 | export default function NotFound() {
2 | return Not Found
;
3 | }
4 |
--------------------------------------------------------------------------------
/src/pages/sub/sub2/sub3-module.jsx:
--------------------------------------------------------------------------------
1 | function StyledSub3module() {
2 | return null;
3 | }
4 |
5 | export default StyledSub3module;
6 |
--------------------------------------------------------------------------------
/src/style/index.css:
--------------------------------------------------------------------------------
1 | /* purgecss start ignore */
2 | @tailwind base;
3 | @tailwind components;
4 | /* purgecss end ignore */
5 |
6 | @tailwind utilities;
7 |
--------------------------------------------------------------------------------
/server.js:
--------------------------------------------------------------------------------
1 | const app = require('./app');
2 |
3 | /**
4 | * Initialize koa app and start server
5 | */
6 | (async () => {
7 | app.listen(await app.create());
8 | })();
9 |
--------------------------------------------------------------------------------
/src/pages/hello-async.jsx:
--------------------------------------------------------------------------------
1 | export default function HelloAsync() {
2 | return (
3 |
4 |
Hello Async Component
5 |
6 | );
7 | }
8 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | .git
2 | .idea
3 | node_modules
4 | npm-debug.log
5 | build
6 | logs
7 | #app-config.js
8 | .DS_Store
9 | README.md
10 | .prettierignore
11 | .prettierrc
12 | .gitignore
13 | deploy.sh
14 |
15 |
--------------------------------------------------------------------------------
/src/pages/users/:user.jsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 |
3 | function User({ match }) {
4 | useEffect(() => {
5 | console.log('match: ', match);
6 | }, [match]);
7 | return 'user';
8 | }
9 |
10 | export default User;
11 |
--------------------------------------------------------------------------------
/dev.dockerfile:
--------------------------------------------------------------------------------
1 | # install stage
2 | FROM lts-alpine AS install
3 | WORKDIR /data/app
4 | COPY package*.json .npmrc ./
5 | RUN npm ci
6 |
7 | # build stage
8 | FROM install AS build
9 | WORKDIR /data/app
10 | COPY . .
11 | #COPY ./app-config.js ./app-config.js
12 |
13 | CMD [ "npm", "run", "dev"]
14 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | const cssnano = require('cssnano');
2 | const isProd = process.env.NODE_ENV === 'production';
3 |
4 | module.exports = {
5 | plugins: [
6 | require('postcss-import'),
7 | require('tailwindcss'),
8 | require('postcss-preset-env')({ stage: 1 }),
9 | isProd ? cssnano({ preset: 'default' }) : null,
10 | ],
11 | };
12 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | purge: ['./src/**/*.js', './src/**/*.jsx', './views/*.html'],
3 | corePlugins: {
4 | float: false,
5 | },
6 | theme: {
7 | extend: {},
8 | },
9 | variants: {
10 | appearance: [],
11 | },
12 | plugins: [],
13 | future: {
14 | removeDeprecatedGapUtilities: true,
15 | purgeLayersByDefault: true,
16 | },
17 | };
18 |
--------------------------------------------------------------------------------
/src/modules/env.js:
--------------------------------------------------------------------------------
1 | const env = {
2 | DEV_MODE: process.env.DEV_MODE,
3 | prefix: process.env.prefix,
4 | appPrefix: process.env.appPrefix,
5 | apiPrefix: process.env.apiPrefix,
6 | NODE_ENV: process.env.NODE_ENV || 'development',
7 | };
8 | // eslint-disable-next-line no-undef
9 | if (typeof __isBrowser__ !== 'undefined' && __isBrowser__) {
10 | console.log(env);
11 | }
12 |
13 | export default env;
14 |
--------------------------------------------------------------------------------
/views/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
10 | React App
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/src/assets/static/logo.svg:
--------------------------------------------------------------------------------
1 |
10 |
--------------------------------------------------------------------------------
/utils/date.js:
--------------------------------------------------------------------------------
1 | const moment = require('moment');
2 | const DATE_TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss';
3 | const DATE_FORMAT = 'YYYY-MM-DD';
4 | const DATE_TIME_FORMAT_NO_SPACE = 'YYYYMMDDHHmmss';
5 |
6 | function simpleDate() {
7 | return moment().format(DATE_TIME_FORMAT);
8 | }
9 |
10 | module.exports = {
11 | DATE_FORMAT,
12 | DATE_TIME_FORMAT,
13 | DATE_TIME_FORMAT_NO_SPACE,
14 | simpleDate,
15 | now() {
16 | return simpleDate();
17 | },
18 | format(date = Date.now(), pattern = DATE_TIME_FORMAT) {
19 | return moment(date).format(date, pattern);
20 | },
21 | };
22 |
--------------------------------------------------------------------------------
/src/App.jsx:
--------------------------------------------------------------------------------
1 | import { BrowserRouter as Router } from 'react-router-dom';
2 |
3 | import AppRoutes from './AppRoutes';
4 |
5 | let initialData = {};
6 | const initDataScript = document.getElementById('__INITIAL_DATA__');
7 | if (initDataScript) {
8 | try {
9 | initialData = JSON.parse(initDataScript.innerText);
10 | } catch (err) {
11 | console.error(err);
12 | }
13 | }
14 | if (__SSR__) {
15 | console.log('SSR initialData: ', initialData);
16 | }
17 |
18 | function App() {
19 | return (
20 |
21 |
22 |
23 | );
24 | }
25 |
26 | export default App;
27 |
--------------------------------------------------------------------------------
/config/config.default.dev.js:
--------------------------------------------------------------------------------
1 | /**
2 | * The default dev configuration if no custom config js file is provided
3 | */
4 | module.exports = {
5 | PORT: 3000,
6 | NODE_ENV: 'development',
7 | NODE_PROXY: true,
8 | PROXY_DEBUG_LEVEL: 0,
9 | LOG_PATH: '',
10 | STATIC_ENDPOINT: '',
11 | STATIC_PREFIX: '/static',
12 | PREFIX_TRAILING_SLASH: true,
13 | APP_PREFIX: '',
14 | CUSTOM_API_PREFIX: true,
15 | ENABLE_HMR: false,
16 | ENABLE_SSR: false,
17 | INLINE_STYLES: false,
18 | CSS_MODULES: false,
19 | API_ENDPOINTS: {
20 | defaultPrefix: '/api-proxy',
21 | '/api-proxy': 'http://127.0.0.1:3001',
22 | '/api-proxy-2': 'http://127.0.0.1:3002',
23 | },
24 | };
25 |
--------------------------------------------------------------------------------
/config/config.default.prod.js:
--------------------------------------------------------------------------------
1 | /**
2 | * The default production configuration if no custom config js file is provided
3 | */
4 | module.exports = {
5 | PORT: 3000,
6 | NODE_ENV: 'production',
7 | NODE_PROXY: true,
8 | PROXY_DEBUG_LEVEL: 0,
9 | LOG_PATH: '',
10 | STATIC_ENDPOINT: '',
11 | STATIC_PREFIX: '/static',
12 | PREFIX_TRAILING_SLASH: true,
13 | APP_PREFIX: '',
14 | CUSTOM_API_PREFIX: true,
15 | ENABLE_HMR: false,
16 | ENABLE_SSR: false,
17 | INLINE_STYLES: false,
18 | CSS_MODULES: false,
19 | API_ENDPOINTS: {
20 | defaultPrefix: '/api-proxy',
21 | '/api-proxy': 'http://127.0.0.1:3001',
22 | '/api-proxy-2': 'http://127.0.0.1:3002',
23 | },
24 | };
25 |
--------------------------------------------------------------------------------
/pm2.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | /**
3 | * Application configuration section
4 | * http://pm2.keymetrics.io/docs/usage/application-declaration/
5 | */
6 | apps: [
7 | // First application
8 | {
9 | name: process.env.APP_NAME || 'koa-web-kit',
10 | script: './server.js',
11 | instances: process.env.APP_INSTANCES
12 | ? parseInt(process.env.APP_INSTANCES)
13 | : 0,
14 | exec_mode: 'cluster_mode',
15 | watch: false,
16 | env: {
17 | NODE_ENV: 'development',
18 | },
19 | env_production: {
20 | NODE_ENV: 'production',
21 | },
22 | max_restarts: 10,
23 | vizion: false,
24 | },
25 | ],
26 | };
27 |
--------------------------------------------------------------------------------
/mocks/db.json:
--------------------------------------------------------------------------------
1 | {
2 | "posts": [
3 | {
4 | "title": "json-server_1547112231263",
5 | "author": "jason2",
6 | "id": 1
7 | },
8 | {
9 | "title": "koa-json-server_1547112557660",
10 | "author": "jason2",
11 | "id": 2
12 | },
13 | {
14 | "id": "1547113206653",
15 | "title": "koa-json-server_1547113206680",
16 | "author": "jason2"
17 | },
18 | {
19 | "id": "1547113364394",
20 | "title": "koa-json-server_1547113364420",
21 | "author": "jason2"
22 | }
23 | ],
24 | "comments": [
25 | {
26 | "id": 1,
27 | "body": "some comment",
28 | "postId": 1
29 | }
30 | ],
31 | "profile": {
32 | "name": "jason"
33 | }
34 | }
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # install stage
2 | FROM lts-alpine AS install
3 | WORKDIR /data/app
4 | COPY package*.json .npmrc ./
5 | RUN npm ci
6 |
7 | # install production dependence stage
8 | FROM lts-alpine AS install_prod
9 | WORKDIR /data/app
10 | COPY package*.json .npmrc ./
11 | RUN npm ci --production
12 |
13 | # build stage
14 | FROM install AS build
15 | WORKDIR /data/app
16 | #COPY --from=install /data/app .
17 | COPY . .
18 | RUN npm run build:noprogress
19 |
20 | # run stage
21 | FROM install_prod AS run
22 | WORKDIR /data/app
23 | COPY . .
24 | COPY --from=build /data/app/build ./build
25 | RUN rm -rf src Dockerfile app-config.js
26 | ENV PATH /data/app/node_modules/.bin:$PATH
27 | CMD [ "pm2-runtime", "pm2.config.js", "--env", "production" ]
28 |
--------------------------------------------------------------------------------
/utils/hash.js:
--------------------------------------------------------------------------------
1 | const crypto = require('crypto');
2 |
3 | // some hash and crypto stuff
4 |
5 | exports.genHash = function genHash(content, algorithm) {
6 | const c = crypto.createHash(algorithm);
7 | c.update(content);
8 | return c.digest('hex');
9 | };
10 |
11 | exports.genSHA1 = function genSHA1(content) {
12 | return exports.genHash(content, 'sha1');
13 | };
14 |
15 | exports.genMD5 = function genSHA1(content) {
16 | return exports.genHash(content, 'md5');
17 | };
18 |
19 | exports.getRandomSHA1 = function getRandomSHA1(byteLength) {
20 | return crypto.randomBytes(byteLength ? byteLength : 20).toString('hex');
21 | };
22 |
23 | exports.toBase64 = function (input) {
24 | return Buffer.from(input).toString('base64');
25 | };
26 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 |
6 | # Runtime data
7 | pids
8 | *.pid
9 | *.seed
10 |
11 | # Directory for instrumented libs generated by jscoverage/JSCover
12 | lib-cov
13 |
14 | # Coverage directory used by tools like istanbul
15 | coverage
16 |
17 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
18 | .grunt
19 |
20 | # node-waf configuration
21 | .lock-wscript
22 |
23 | # Compiled binary addons (http://nodejs.org/api/addons.html)
24 | build/Release
25 |
26 | # Dependency directory
27 | node_modules
28 |
29 | # Optional npm cache directory
30 | .npm
31 |
32 | # Optional REPL history
33 | .node_repl_history
34 |
35 | *.csv
36 | *.dat
37 | *.out
38 | *.gz
39 | .idea
40 | results
41 | build
42 | dist
43 | npm-debug.log
44 | *.map
45 | --no-cache
46 | .Ds_Store
47 | .sass-cache
48 | bundle*.js
49 | *cache
50 | #package-lock.json
51 | app.min.js
52 | app-config.js
53 | .env*
54 |
--------------------------------------------------------------------------------
/config/app-config.js.sample:
--------------------------------------------------------------------------------
1 | /**
2 | * A sample configuration for you application,
3 | * copy this and rename without the `.sample` ext to use as your local env config
4 | */
5 | module.exports = {
6 | PORT: 3000,
7 | NODE_ENV: 'development',
8 | NODE_PROXY: true,
9 | //request module http proxy
10 | HTTP_PROXY: '',
11 | SOCKS_PROXY: '',
12 | PROXY_DEBUG_LEVEL: 0,
13 | LOG_PATH: '',
14 | STATIC_ENDPOINT: '',
15 | STATIC_PREFIX: '',
16 | PREFIX_TRAILING_SLASH: true,
17 | APP_PREFIX: '',
18 | CUSTOM_API_PREFIX: true,
19 | ENABLE_HMR: true,
20 | HMR_PORT: 12345,
21 | ENABLE_SSR: false,
22 | INLINE_STYLES: false,
23 | CSS_MODULES: false,
24 | OUTPUT_DIR: '',
25 | DYNAMIC_ROUTES: false,
26 | API_ENDPOINTS: {
27 | defaultPrefix: '/api-proxy',
28 | '/api-proxy': 'http://127.0.0.1:3001',
29 | '/api-proxy-2': 'http://127.0.0.1:3002',
30 | '/api-proxy-3': 'http://127.0.0.1:3002',
31 | },
32 | };
33 |
--------------------------------------------------------------------------------
/mocks/server.js:
--------------------------------------------------------------------------------
1 | /**
2 | * A fake JSON Server for testing restful api
3 | * @type {module:path}
4 | */
5 |
6 | const path = require('path');
7 | const jsonServer = require('json-server');
8 | const server = jsonServer.create();
9 | const router = jsonServer.router(path.join(__dirname, './db.json'));
10 | const middlewares = jsonServer.defaults();
11 |
12 | const XAccessToken = 'x-access-token';
13 |
14 | server.use(middlewares);
15 |
16 | //send x-access-token header back
17 | server.use((req, res, next) => {
18 | res.set({
19 | [`${XAccessToken}-back`]: req.get(XAccessToken),
20 | });
21 | next();
22 | });
23 |
24 | server.use(router);
25 |
26 | module.exports = {
27 | XAccessToken,
28 | startJSONServer(port) {
29 | return new Promise((resolve) => {
30 | server.listen(port, () => {
31 | console.log(`JSON Server is running on ${port}`);
32 | resolve(server);
33 | });
34 | });
35 | },
36 | };
37 |
--------------------------------------------------------------------------------
/src/components/AppError.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | class AppError extends React.Component {
4 | constructor(props) {
5 | super(props);
6 |
7 | this.state = {
8 | hasError: false,
9 | error: null,
10 | info: null,
11 | };
12 | }
13 |
14 | componentDidMount() {}
15 |
16 | componentDidCatch(error, info) {
17 | this.setState({ hasError: true, error, info });
18 | }
19 |
20 | reload = (e) => {
21 | e.preventDefault();
22 | location.reload();
23 | };
24 |
25 | render() {
26 | if (this.state.hasError) {
27 | return (
28 |
34 | );
35 | }
36 | return this.props.children;
37 | }
38 | }
39 |
40 | export default AppError;
41 |
--------------------------------------------------------------------------------
/services/http-config.js:
--------------------------------------------------------------------------------
1 | exports.HTTP_METHOD = {
2 | GET: 'GET',
3 | DELETE: 'DELETE',
4 | HEAD: 'HEAD',
5 | OPTIONS: 'OPTIONS',
6 | PATCH: 'PATCH',
7 | POST: 'POST',
8 | PUT: 'PUT',
9 | TRACE: 'TRACE',
10 | };
11 |
12 | //Some common used http headers
13 | exports.HEADER = {
14 | CONTENT_TYPE: 'content-type',
15 | };
16 |
17 | //Some common used body content type
18 | exports.BODY_TYPE = {
19 | JSON: 'application/json',
20 | FORM_URL_ENCODED: 'application/x-www-form-urlencoded',
21 | };
22 |
23 | exports.getResponseContentType = function (response) {
24 | if (!response || !response.headers) return;
25 | const headers = response.headers;
26 | if (typeof headers.get === 'function') {
27 | return headers.get(exports.HEADER.CONTENT_TYPE);
28 | }
29 | return headers[exports.HEADER.CONTENT_TYPE];
30 | };
31 |
32 | exports.isJSONResponse = function (response) {
33 | const type = exports.getResponseContentType(response);
34 | if (!type) return false;
35 | return type.indexOf(exports.BODY_TYPE.JSON) >= 0;
36 | };
37 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2016-present jason
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 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | extends: [
4 | 'eslint:recommended',
5 | 'plugin:react/recommended',
6 | 'plugin:prettier/recommended',
7 | ],
8 | parser: '@babel/eslint-parser',
9 | parserOptions: {
10 | ecmaVersion: 2020,
11 | sourceType: 'module',
12 | ecmaFeatures: {
13 | jsx: true,
14 | },
15 | },
16 | env: {
17 | es6: true,
18 | browser: true,
19 | node: true,
20 | jest: true,
21 | },
22 | plugins: ['react', 'react-hooks'],
23 | globals: {
24 | __SSR__: true,
25 | __isBrowser__: true,
26 | __HMR__: true,
27 | },
28 | rules: {
29 | eqeqeq: 'off',
30 | 'no-prototype-builtins': 'off',
31 | 'no-unused-vars': 'warn',
32 | 'require-atomic-updates': 'warn',
33 | //react stuff
34 | 'react/display-name': 'off',
35 | 'react/prop-types': 'off',
36 | 'react-hooks/rules-of-hooks': 'error',
37 | 'react-hooks/exhaustive-deps': 'warn',
38 | 'react/jsx-uses-react': 'off',
39 | 'react/react-in-jsx-scope': 'off',
40 | //prettier
41 | // 'prettier/prettier': 'warn',
42 | },
43 | settings: {
44 | react: {
45 | version: 'detect',
46 | },
47 | },
48 | };
49 |
--------------------------------------------------------------------------------
/src/components/Header.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import logo from '../assets/static/logo.svg';
4 | import styled, { keyframes } from 'styled-components';
5 |
6 | const logoRotate = keyframes`
7 | from {
8 | transform: rotate(0deg);
9 | }
10 | to {
11 | transform: rotate(360deg);
12 | }
13 | `;
14 |
15 | const StyledAppHeader = styled.div`
16 | text-align: center;
17 | background-color: #222;
18 | padding: 20px;
19 | color: white;
20 | .app-logo {
21 | animation: ${logoRotate} infinite 20s linear;
22 | height: 80px;
23 | }
24 | `;
25 |
26 | class Header extends React.Component {
27 | constructor(props) {
28 | super(props);
29 | }
30 |
31 | componentDidMount() {}
32 |
33 | render() {
34 | return (
35 |
36 |
37 | Welcome to {this.props.appName}
38 | portal placeholder
39 |
40 | );
41 | }
42 | }
43 |
44 | Header.defualtProps = {
45 | appName: 'React',
46 | };
47 |
48 | Header.propTypes = {
49 | appName: PropTypes.string,
50 | };
51 |
52 | export default Header;
53 |
--------------------------------------------------------------------------------
/utils/common.js:
--------------------------------------------------------------------------------
1 | const slugify = require('slugify');
2 |
3 | slugify.extend({ '/': '-' });
4 |
5 | //Some weired phone brands with weired browser features support
6 | const fuckedBrands = [];
7 |
8 | //some UA related utilities, and some simple device check
9 |
10 | exports.normalizeUA = function normalizeUA(ua) {
11 | return String(ua || '').toLowerCase();
12 | };
13 |
14 | exports.isFuckedPhone = function isFuckedPhone(ua) {
15 | ua = this.normalizeUA(ua);
16 | for (const phone of fuckedBrands) {
17 | if (ua.indexOf(phone) >= 0) {
18 | return true;
19 | }
20 | }
21 | return false;
22 | };
23 |
24 | exports.isIOS = function isIOS(ua) {
25 | ua = this.normalizeUA(ua);
26 | return ua.indexOf('iphone') >= 0 || ua.indexOf('ipad') >= 0;
27 | };
28 |
29 | exports.isAndroid = function isAndroid(ua) {
30 | ua = this.normalizeUA(ua);
31 | return ua.indexOf('android') >= 0;
32 | };
33 |
34 | exports.isMobile = function isMobile(ua) {
35 | return this.isIOS(ua) || this.isAndroid(ua);
36 | };
37 |
38 | exports.slugify = function (input) {
39 | return slugify(input);
40 | };
41 |
42 | exports.wait = function (ms = 0) {
43 | return new Promise((resolve) => {
44 | setTimeout(resolve, ms);
45 | });
46 | };
47 |
--------------------------------------------------------------------------------
/config/webpack.config.node.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const MinifyPlugin = require('babel-minify-webpack-plugin');
4 | const nodeExternals = require('webpack-node-externals');
5 |
6 | const config = require('./env');
7 | const utils = require('./utils');
8 |
9 | const DEV_MODE = config.isDevMode();
10 | // const APP_PATH = utils.APP_PATH;
11 |
12 | const webpackConfig = {
13 | entry: {
14 | app: utils.resolve('app'),
15 | },
16 | output: {
17 | path: utils.resolve('build/node'),
18 | filename: `[name]${DEV_MODE ? '' : '.min'}.js`,
19 | },
20 | target: 'node',
21 | externals: [nodeExternals()],
22 | resolve: {
23 | // modules: [
24 | // APP_PATH,
25 | // 'node_modules',
26 | // ],
27 | extensions: ['.js', '.json'],
28 | },
29 | module: {
30 | // rules: [
31 | // {
32 | // test: /\.js$/,
33 | // exclude: /node_modules/,
34 | // use: {
35 | // loader: 'babel-loader',
36 | // options: {
37 | // cacheDirectory: true,
38 | // }
39 | // }
40 | // },
41 | // ]
42 | },
43 | plugins: [],
44 | };
45 |
46 | if (!DEV_MODE) {
47 | webpackConfig.plugins.push(new MinifyPlugin({}, {}));
48 | }
49 |
50 | module.exports = webpackConfig;
51 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import 'src/style/index.css';
2 | import 'modules/env';
3 | import ReactDOM from 'react-dom';
4 | import { loadableReady } from '@loadable/component';
5 | import App from './App';
6 | import AppError from 'components/AppError';
7 |
8 | const elRoot = document.getElementById('app');
9 |
10 | const render = (Component) => {
11 | // eslint-disable-next-line no-undef
12 | if (__SSR__) {
13 | console.log('in SSR mode');
14 | loadableReady(() => {
15 | ReactDOM.hydrate(
16 |
17 |
18 | ,
19 | elRoot,
20 | );
21 | });
22 | return;
23 | }
24 | ReactDOM.render(
25 |
26 |
27 | ,
28 | elRoot,
29 | );
30 | };
31 |
32 | render(App);
33 |
34 | // Webpack Hot Module Replacement API
35 | if (module.hot) {
36 | module.hot.accept('./App', () => {
37 | render(require('./App').default);
38 | });
39 | // module.hot.check().then(modules => {
40 | // console.log('modules: ', modules);
41 | // });
42 | // module.hot.addStatusHandler((status) => {
43 | // console.log('status: ', status);
44 | // if (status === 'idle') {
45 | // // window.location.reload()
46 | // }
47 | // })
48 | }
49 |
--------------------------------------------------------------------------------
/routes/proxy.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable require-atomic-updates */
2 | /**
3 | * Request proxy for backend api
4 | */
5 |
6 | const { PassThrough } = require('stream');
7 | const Router = require('koa-router');
8 |
9 | const { HttpClient } = require('../services/HttpClient');
10 |
11 | exports.handleApiRequests = function (prefix, endPoint) {
12 | const routerPrefix = prefix.endsWith('/') ? prefix : `${prefix}/`;
13 | // TODO: a path rewrite?
14 | const router = new Router({ prefix: routerPrefix });
15 | const apiProxy = new HttpClient({ endPoint, prefix });
16 | router.all('(.*)', async (ctx) => {
17 | const requestStream = apiProxy.proxyRequest(ctx);
18 | const pt = requestStream.pipe(PassThrough());
19 | try {
20 | await new Promise((resolve, reject) => {
21 | requestStream.on('response', (response) => {
22 | ctx.status = response.statusCode;
23 | ctx.set(response.headers);
24 | resolve();
25 | });
26 | requestStream.on('error', (error) => {
27 | reject(error);
28 | });
29 | });
30 | } catch (err) {
31 | ctx.status = 500;
32 | ctx.body = {
33 | code: err.code,
34 | };
35 | return;
36 | }
37 | ctx.body = pt;
38 | });
39 | return router.routes();
40 | };
41 |
--------------------------------------------------------------------------------
/src/AppRoutes.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import { Route, Switch } from 'react-router-dom';
3 | import loadable from '@loadable/component';
4 | import Loading from 'components/Loading';
5 | import { getRoutes } from 'modules/router-utils';
6 | import NotFound from 'components/NotFound';
7 |
8 | console.log('process.env.DYNAMIC_ROUTES: ', process.env.DYNAMIC_ROUTES);
9 | const routes = process.env.DYNAMIC_ROUTES
10 | ? // dynamic routes from src/pages
11 | getRoutes()
12 | : //manual routes
13 | [
14 | {
15 | name: 'index',
16 | path: '/',
17 | component: loadable(() => import('pages/index')),
18 | },
19 | {
20 | name: 'hello',
21 | path: '/hello',
22 | component: loadable(() => import('pages/hello')),
23 | },
24 | ];
25 | // console.log('routes: ', routes);
26 |
27 | function AppRoutes() {
28 | return (
29 |
30 | {routes.map(({ name, path, component: RouteComponent }) => (
31 | {
36 | return } {...props} />;
37 | }}
38 | />
39 | ))}
40 |
41 |
42 | );
43 | }
44 |
45 | AppRoutes.propTypes = {
46 | initialData: PropTypes.object,
47 | };
48 |
49 | export default AppRoutes;
50 |
--------------------------------------------------------------------------------
/src/pages/github.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import isEmpty from 'lodash.isempty';
4 | import { Request } from 'modules/Request';
5 | import styled from 'styled-components';
6 |
7 | const StyledGithub = styled.div`
8 | > ul {
9 | list-style: decimal;
10 | }
11 | `;
12 |
13 | class Github extends React.Component {
14 | constructor(props) {
15 | super(props);
16 |
17 | this.githubRequest = new Request({ noPrefix: true });
18 |
19 | // console.log('props.branches:', this.props.branches);
20 | this.state = {
21 | github: this.props.branches,
22 | };
23 | }
24 |
25 | componentDidMount() {
26 | if (isEmpty(this.state.github)) {
27 | this.githubRequest
28 | .get('https://api.github.com/repos/jasonboy/wechat-jssdk/branches')
29 | .then((data) => {
30 | this.setState({ github: data });
31 | });
32 | }
33 | }
34 |
35 | render() {
36 | if (isEmpty(this.state.github)) {
37 | return loading data...
;
38 | }
39 | return (
40 |
41 |
42 | {this.state.github.map((b) => {
43 | return - {b.name}
;
44 | })}
45 |
46 |
47 | );
48 | }
49 | }
50 |
51 | Github.defaultProps = {
52 | branches: [],
53 | };
54 |
55 | Github.propTypes = {
56 | branches: PropTypes.array,
57 | };
58 |
59 | export default Github;
60 |
--------------------------------------------------------------------------------
/src/pages/hello.jsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Created by jason on 31/01/2018.
3 | */
4 |
5 | import React from 'react';
6 | // import r, { api, Request } from 'modules/Request';
7 | import styled from 'styled-components';
8 |
9 | const StyledHello = styled.div`
10 | text-align: center;
11 | font-size: 1.5rem;
12 | color: pink;
13 | `;
14 |
15 | class Hello extends React.Component {
16 | constructor(props) {
17 | super(props);
18 |
19 | this.state = {
20 | name: 'Hello webpack 5',
21 | };
22 | }
23 |
24 | componentDidMount() {
25 | console.log('hello created!');
26 | // this.setState({
27 | // name: 'Hello Created!',
28 | // });
29 | // this.xxx();
30 |
31 | //api test
32 | // r.get(api.TEST, { page: 1 });
33 | // r.get(api.TEST_6, { page: 2 });
34 | // r.post(api.TEST_2, { a: 1, b: 2 }, { qs: { page: 2, pageSize: 10 } });
35 | //
36 | // const r2 = new Request({ form: true, apiPrefix: '/api-proxy-3' });
37 | // r2.post(api.TEST_3, { a: 1, b: 2, c: 'https://github.com' });
38 | // r2.put(api.TEST_4, { a: 1, b: 2 }, { noPrefix: true });
39 | // const r3 = new Request({ form: true, noPrefix: true });
40 | // r3.delete(api.TEST_5, { a: 1, b: 2 });
41 | }
42 |
43 | render() {
44 | return (
45 |
46 | Hello webpack 4
47 | Hello Component: {this.state.name || 'xxx'}
48 | {/*{this.yyy()}
*/}
49 |
50 | );
51 | }
52 | }
53 |
54 | export default Hello;
55 |
--------------------------------------------------------------------------------
/api/api-config.js:
--------------------------------------------------------------------------------
1 | /**
2 | * API urls configuration
3 | */
4 |
5 | /**
6 | * api urls
7 | */
8 | exports.api = {
9 | TEST: '/v1/login',
10 | TEST_2: {
11 | path: '/v2/logout',
12 | prefix: '/api-proxy-2',
13 | },
14 | TEST_3: {
15 | path: '/v3/logout',
16 | prefix: '/api-proxy-3',
17 | },
18 | TEST_4: '/v4/logout',
19 | TEST_5: {
20 | path: '/v5/login',
21 | },
22 | TEST_6: {
23 | path: '/v6/login',
24 | },
25 | };
26 |
27 | /**
28 | * Simplify the rest parameters creation, e.g:
29 | * //NOTICE: order of params in array is important, params use object do not care about order
30 | * formatRestfulUrl('/user/:id/:id2', [1,2]) -> /user/1/2
31 | * formatRestfulUrl('/user/:id/:id2', {id2: 2, id: 1}) -> /user/1/2
32 | * @param {string} url request url definition
33 | * @param {Array|Object} params rest parameters
34 | * @return {*}
35 | */
36 | exports.formatRestfulUrl = function (url, params) {
37 | if (!params || url.indexOf(':') < 0) return url;
38 | let parts = url.split('/');
39 | let partIndex = 0;
40 | const isArray = Array.isArray(params);
41 | parts.forEach(function (ele, index) {
42 | if (ele.indexOf(':') === 0) {
43 | parts[index] = isArray ? params[partIndex] : params[ele.substring(1)];
44 | partIndex++;
45 | }
46 | });
47 | return parts.join('/');
48 | };
49 |
50 | /**
51 | * Check the number of rest params in the current url definition
52 | * @param url
53 | * @return {number}
54 | */
55 | exports.numberOfRestParams = function (url) {
56 | const matched = url.match(/\/:/g);
57 | return matched ? matched.length : 0;
58 | };
59 |
--------------------------------------------------------------------------------
/config/webpack.config.ssr.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const path = require('path');
4 | const webpack = require('webpack');
5 | const { merge } = require('webpack-merge');
6 | const config = require('./env');
7 | const utils = require('./utils');
8 | const nodeExternals = require('webpack-node-externals');
9 |
10 | const DEV_MODE = config.isDevMode();
11 | const APP_PATH = utils.APP_PATH;
12 |
13 | const prefix = utils.normalizeTailSlash(
14 | utils.normalizePublicPath(
15 | path.join(config.getAppPrefix(), config.getStaticPrefix()),
16 | ),
17 | config.isPrefixTailSlashEnabled(),
18 | );
19 |
20 | const webpackConfig = merge(
21 | {},
22 | {
23 | entry: {
24 | main: utils.resolve('src/ssr/index.js'),
25 | },
26 | target: 'node',
27 | mode: DEV_MODE ? 'development' : 'production',
28 | output: {
29 | path: utils.resolve('build/node'),
30 | filename: '[name].js',
31 | libraryExport: 'default',
32 | libraryTarget: 'commonjs2',
33 | },
34 | externals: [
35 | nodeExternals({
36 | allowlist: [/\.(?!(?:jsx?|json)$).{1,5}$/i],
37 | }),
38 | ],
39 | resolve: {
40 | ...utils.getWebpackResolveConfig(),
41 | },
42 | module: {
43 | rules: [
44 | utils.getBabelLoader(true),
45 | ...utils.getAllStyleRelatedLoaders(
46 | DEV_MODE,
47 | false,
48 | false,
49 | undefined,
50 | true,
51 | ),
52 | utils.getImageLoader(DEV_MODE, APP_PATH),
53 | utils.getMediaLoader(DEV_MODE, APP_PATH),
54 | ],
55 | },
56 | plugins: [
57 | new webpack.DefinePlugin({
58 | __isBrowser__: false,
59 | __pathPrefix__: JSON.stringify(prefix),
60 | 'process.env.DYNAMIC_ROUTES': config.isDynamicRoutes(),
61 | }),
62 | ],
63 | },
64 | );
65 |
66 | module.exports = webpackConfig;
67 |
--------------------------------------------------------------------------------
/.run/dev-kwk.run.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = function (api) {
2 | const DEV_MODE = api.env('development');
3 | api.cache(true);
4 | return {
5 | presets: [
6 | [
7 | '@babel/preset-env',
8 | {
9 | useBuiltIns: 'usage',
10 | modules: false,
11 | corejs: 3,
12 | },
13 | ],
14 | [
15 | '@babel/preset-react',
16 | {
17 | development: DEV_MODE,
18 | runtime: 'automatic',
19 | },
20 | ],
21 | ],
22 | env: {
23 | development: {
24 | plugins: [
25 | [
26 | 'babel-plugin-styled-components',
27 | {
28 | displayName: DEV_MODE,
29 | },
30 | ],
31 | '@babel/plugin-transform-react-jsx-source',
32 | ],
33 | },
34 | test: {
35 | presets: [
36 | [
37 | '@babel/preset-env',
38 | {
39 | modules: false,
40 | },
41 | ],
42 | '@babel/preset-react',
43 | ],
44 | plugins: [
45 | [
46 | 'babel-plugin-styled-components',
47 | {
48 | displayName: DEV_MODE,
49 | },
50 | ],
51 | '@babel/plugin-transform-react-jsx-source',
52 | 'dynamic-import-node',
53 | ],
54 | },
55 | },
56 | plugins: [
57 | [
58 | 'babel-plugin-styled-components',
59 | {
60 | displayName: DEV_MODE,
61 | },
62 | ],
63 | '@babel/plugin-proposal-optional-chaining',
64 | '@babel/plugin-proposal-nullish-coalescing-operator',
65 | '@babel/plugin-transform-runtime',
66 | '@babel/plugin-proposal-object-rest-spread',
67 | '@babel/plugin-proposal-class-properties',
68 | '@babel/plugin-syntax-dynamic-import',
69 | '@babel/plugin-transform-modules-commonjs',
70 | '@loadable/babel-plugin',
71 | ],
72 | };
73 | };
74 |
--------------------------------------------------------------------------------
/__tests__/server/HttpClient.js:
--------------------------------------------------------------------------------
1 | const nock = require('nock');
2 | const { HttpClient } = require('../../services/HttpClient');
3 |
4 | const TEST_DOMAIN = 'http://localhost';
5 |
6 | describe('HttpClient verbs', () => {
7 | let client;
8 | let scope;
9 |
10 | beforeAll(() => {
11 | scope = nock(TEST_DOMAIN)
12 | .get('/')
13 | .reply(200, { name: 'jason' })
14 | .post('/user', (body) => body.name == 'jason')
15 | .reply(200, { method: 'post', name: 'jason' })
16 | .put('/user/xxx', (body) => body.name == 'jason2')
17 | .reply(200, { method: 'put', name: 'jason2' })
18 | .patch('/user/xxx', (body) => body.name == 'jason2')
19 | .reply(200, { method: 'patch', name: 'jason2' })
20 | .delete('/user/xxx')
21 | .reply(200, { method: 'delete' });
22 | client = new HttpClient(
23 | {
24 | endPoint: '',
25 | jsonResponse: true,
26 | },
27 | {
28 | throwHttpErrors: true,
29 | },
30 | );
31 | });
32 |
33 | afterAll(() => {
34 | scope.cleanAll();
35 | scope.restore();
36 | });
37 |
38 | test('http get', async () => {
39 | const data = await client.get(`${TEST_DOMAIN}/`);
40 | expect(data.name).toBe('jason');
41 | });
42 |
43 | test('http post', async () => {
44 | const data = await client.post(`${TEST_DOMAIN}/user`, { name: 'jason' });
45 | expect(data).toEqual({ method: 'post', name: 'jason' });
46 | });
47 |
48 | test('http put', async () => {
49 | const data = await client.put(`${TEST_DOMAIN}/user/xxx`, {
50 | name: 'jason2',
51 | });
52 | expect(data).toEqual({ method: 'put', name: 'jason2' });
53 | });
54 |
55 | test('http patch', async () => {
56 | const data = await client.patch(`${TEST_DOMAIN}/user/xxx`, {
57 | name: 'jason2',
58 | });
59 | expect(data).toEqual({ method: 'patch', name: 'jason2' });
60 | });
61 |
62 | test('http delete', async () => {
63 | const data = await client.delete(`${TEST_DOMAIN}/user/xxx`);
64 | expect(data).toEqual({ method: 'delete' });
65 | });
66 | });
67 |
--------------------------------------------------------------------------------
/config/webpack.config.dev.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const { merge } = require('webpack-merge');
4 | const MiniCssExtractPlugin = require('mini-css-extract-plugin');
5 | const ErrorOverlayPlugin = require('error-overlay-webpack-plugin');
6 | const baseWebpackConfig = require('./webpack.config.base');
7 | const config = require('./env');
8 | const utils = require('./utils');
9 | const isHMREnabled = config.isHMREnabled();
10 | const isCSSModules = config.isCSSModules();
11 |
12 | const webpackConfig = merge(baseWebpackConfig, {
13 | output: {
14 | publicPath: isHMREnabled ? '/' : utils.getPublicPath(),
15 | filename: '[name].js',
16 | chunkFilename: '[name].js',
17 | },
18 | module: {
19 | rules: [
20 | ...utils.getAllStyleRelatedLoaders(
21 | true,
22 | isHMREnabled,
23 | isCSSModules,
24 | undefined,
25 | false,
26 | !isHMREnabled,
27 | ),
28 | ],
29 | },
30 | mode: 'development',
31 | devtool: 'cheap-module-source-map',
32 | stats: { children: false },
33 | plugins: [new ErrorOverlayPlugin()],
34 | });
35 |
36 | // optimization new in webpack4
37 | if (!isHMREnabled) {
38 | webpackConfig.plugins.push(
39 | new MiniCssExtractPlugin({
40 | // Options similar to the same options in webpackOptions.output
41 | // both options are optional
42 | filename: '[name].css',
43 | chunkFilename: '[id].css',
44 | }),
45 | );
46 | webpackConfig.optimization = {
47 | namedModules: true,
48 | runtimeChunk: 'single',
49 | splitChunks: {
50 | // name: false,
51 | automaticNameMaxLength: 30,
52 | cacheGroups: {
53 | vendors: {
54 | test: /[\\/]node_modules[\\/]/i,
55 | name: utils.ENTRY_NAME.VENDORS,
56 | chunks: 'initial',
57 | },
58 | },
59 | },
60 | };
61 | }
62 |
63 | // console.log(
64 | // 'webpackConfig.output.publicPath: ',
65 | // webpackConfig.output.publicPath
66 | // );
67 | // console.log(webpackConfig);
68 | // console.log(webpackConfig.plugins);
69 | module.exports = webpackConfig;
70 |
--------------------------------------------------------------------------------
/deploy.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | #Create by Jason
3 | #This is meant for production
4 | # > ./deploy.sh skipInstall skipBuild skipServer
5 |
6 | ##Using node >= 8
7 | #nvm use 8
8 | #source $HOME/.nvm/nvm.sh; nvm use 8
9 |
10 | echo $(which node)
11 | echo $(which pm2)
12 |
13 | basepath=$(cd `dirname $0`; pwd)
14 | cd ${basepath}
15 |
16 | #Simple script to run app quickly
17 | NodeVersion=$(node -v)
18 | if [[ $? != 0 ]]; then
19 | #nodejs not installed yet
20 | echo ERROR: nodejs is not installed currently, pls install nodejs to continue
21 | exit
22 | else
23 | echo node/${NodeVersion}, npm/$(npm -v)
24 | # echo yarn/$(yarn -v)
25 | fi
26 |
27 | ##Remove the freaking package-lock.json
28 | #rm -f package-lock.json
29 |
30 | ##Installing npm modules
31 | if [[ $1 != "1" ]]; then
32 | ##=====Uncomment script below if you are in China=====
33 | #TaobaoRegistry="http://registry.npm.taobao.org/"
34 | #NpmRegistry=$(npm config get registry)
35 | #if [ "$TaobaoRegistry" != "$NpmRegistry" ]; then
36 | # echo changing npm registry to taobao registry: "$TaobaoRegistry"
37 | # npm config set registry "$TaobaoRegistry"
38 | #fi
39 | ##Change SASS binary site to taobao
40 | #export SASS_BINARY_SITE=https://npm.taobao.org/mirrors/node-sass/
41 |
42 | echo installing npm modules...
43 | ##Don't install based on the package-lock.json by default, instead, only refer to package.json
44 | ##Change at your own risk
45 | npm install --no-shrinkwrap
46 | # yarn install --production=false
47 | fi
48 |
49 | if [[ $2 != "1" ]]; then
50 | ##webpack is bundling modules
51 | echo webpack is bundling modules...
52 | npm run build
53 | npm run ssr
54 | echo =====Build Finished=====
55 | #yarn run build
56 | fi
57 |
58 | ##Start server with pm2
59 | if [[ $3 != "1" ]]; then
60 | export NODE_ENV=production
61 |
62 | PM2Version=$(pm2 -v)
63 | if [[ $? != 0 ]]; then
64 | echo ERROR: pls install pm2 to continue...
65 | exit
66 | fi
67 |
68 | echo NODE_ENV: ${NODE_ENV}
69 | echo "Using pm2: [$(which pm2)]"
70 |
71 | pm2 reload pm2.config.js --update-env --env production
72 | fi
73 |
--------------------------------------------------------------------------------
/src/modules/router-utils.js:
--------------------------------------------------------------------------------
1 | /**
2 | * only for pages dynamic routes
3 | */
4 |
5 | import loadable from '@loadable/component';
6 |
7 | const INDEX_PAGE = 'index';
8 |
9 | let routesGenerated = false;
10 | let routes = [];
11 |
12 | export function getPageModules() {
13 | const context = require.context('src/pages', true, /\.jsx?$/, 'lazy');
14 | console.log('context.keys(): ', context.keys());
15 | return context.keys().map((key) => {
16 | return {
17 | key,
18 | context,
19 | ...normalizeModulePath(key),
20 | };
21 | });
22 | }
23 |
24 | export function normalizeModulePath(path) {
25 | const withoutRoot = path.slice(2);
26 | const parts = withoutRoot.split('/');
27 | // const paramsParts = parts.filter((item) => item.startsWith(':'));
28 | // console.log('paramsParts: ', paramsParts);
29 | const filename = parts[parts.length - 1];
30 | const filenameMatch = filename.match(/(.+)\..+$/);
31 | const moduleName = filenameMatch
32 | ? filenameMatch[1].startsWith(':')
33 | ? filenameMatch[1].slice(1)
34 | : filenameMatch[1]
35 | : '';
36 | return {
37 | name: moduleName,
38 | filename,
39 | path: isIndexPage(moduleName)
40 | ? '/'
41 | : ['', ...parts.slice(0, parts.length - 1), filenameMatch[1]].join('/'),
42 | dir: withoutRoot,
43 | fullPath: path,
44 | };
45 | }
46 |
47 | function isIndexPage(name) {
48 | return name === INDEX_PAGE;
49 | }
50 |
51 | export function generateDynamicRoutes() {
52 | console.log('generating routes...');
53 | routes = getPageModules().map((route) => {
54 | return {
55 | ...route,
56 | component: loadable(() => {
57 | return route.context(route.key).then((m) => m.default);
58 | }),
59 | };
60 | });
61 | routesGenerated = true;
62 | return routes;
63 | }
64 |
65 | export function getRoutes(plain) {
66 | let ret;
67 | if (routesGenerated) {
68 | console.log('routes generated, return from cache');
69 | ret = routes;
70 | } else {
71 | ret = generateDynamicRoutes();
72 | }
73 | if (plain) {
74 | ret = ret.map((route) => {
75 | return {
76 | ...route,
77 | component: null,
78 | context: null,
79 | };
80 | });
81 | }
82 | return ret;
83 | }
84 |
--------------------------------------------------------------------------------
/__tests__/client/Request.js:
--------------------------------------------------------------------------------
1 | import r, { api, Request } from 'modules/Request';
2 |
3 | const origin = 'http://127.0.0.1:3000';
4 | const myUrl = `${origin}${api.TEST}?a=b&b=c&a=bb`;
5 | const myUrl2 = `${myUrl}#myHash`;
6 |
7 | test('getQueryString()', () => {
8 | const query = r.getQueryString(`${origin}?a=&b=c`);
9 | expect(query).toEqual({ a: '', b: 'c' });
10 | });
11 |
12 | test('getQueryString() multiple values for same key', () => {
13 | const query = r.getQueryString(myUrl);
14 | expect(query).toEqual({ a: ['b', 'bb'], b: 'c' });
15 | });
16 |
17 | test('addQueryString()', () => {
18 | const newUrl = r.addQueryString(myUrl, { d: 'e' }, undefined, false);
19 | expect(newUrl).toBe(`${myUrl}&d=e`);
20 | });
21 |
22 | test('stripUrlHash()', () => {
23 | const newUrl = r.stripUrlHash(myUrl2);
24 | expect(newUrl).toBe(myUrl);
25 | });
26 |
27 | test('normalizeRestfulParams() in array', () => {
28 | const newUrl = r.normalizeRestfulParams('/school/:id/classroom/:roomId', {
29 | restParams: ['123', '456'],
30 | });
31 | expect(newUrl).toBe('/school/123/classroom/456');
32 | });
33 | test('normalizeRestfulParams() in object', () => {
34 | const newUrl = r.normalizeRestfulParams('/school/:id/classroom/:roomId', {
35 | restParams: {
36 | roomId: '456',
37 | id: '123',
38 | },
39 | });
40 | expect(newUrl).toBe('/school/123/classroom/456');
41 | });
42 |
43 | test('formatFormUrlEncodeData()', () => {
44 | let data = r.formatFormUrlEncodeData({ a: 'b', c: 1 });
45 | data = decodeURIComponent(data);
46 | expect(data).toBe('a=b&c=1');
47 | });
48 | test('formatFormUrlEncodeData() value in array', () => {
49 | let data = r.formatFormUrlEncodeData({ a: ['b', 'bb'], c: 1 });
50 | data = decodeURIComponent(data);
51 | expect(data).toBe('a=b,bb&c=1');
52 | });
53 |
54 | test('normalizeBodyData()', () => {
55 | const data = { a: 'b', c: 1 };
56 | const newData = r.normalizeBodyData(data);
57 | expect(newData).toBe(JSON.stringify(data));
58 | });
59 | test('normalizeBodyData() in form_url_encoded', () => {
60 | const temp = new Request({ form: true });
61 | const data = { a: 'b', c: 1 };
62 | const newData = temp.normalizeBodyData(data);
63 | expect(newData).toBe('a=b&c=1');
64 | });
65 |
66 | test('getUrlWithPrefix(string)', () => {
67 | const temp = new Request({ apiPrefix: '/test' });
68 | const newUrl = temp.getUrlWithPrefix('/login');
69 | expect(newUrl).toBe('/test/login');
70 | });
71 |
72 | test('getUrlWithPrefix(object)', () => {
73 | const temp = new Request({ apiPrefix: '/test' });
74 | const newUrl = temp.getUrlWithPrefix({ prefix: '/test2', path: '/login' });
75 | expect(newUrl).toBe('/test2/login');
76 | });
77 |
--------------------------------------------------------------------------------
/config/webpack.config.prod.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const { merge } = require('webpack-merge');
4 | const MiniCssExtractPlugin = require('mini-css-extract-plugin');
5 | const TerserWebpackPlugin = require('terser-webpack-plugin');
6 | const BundleAnalyzerPlugin =
7 | require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
8 |
9 | const baseWebpackConfig = require('./webpack.config.base');
10 | const config = require('./env');
11 | const utils = require('./utils');
12 | const isBundleAnalyzerEnabled = config.isBundleAnalyzerEnabled();
13 | const isSSREnabled = config.isSSREnabled();
14 | const isCSSModules = config.isCSSModules();
15 |
16 | const webpackConfig = merge(baseWebpackConfig, {
17 | output: {
18 | publicPath: config.getStaticAssetsEndpoint() + utils.getPublicPath(),
19 | filename: utils.getName('[name]', 'js', '', false),
20 | },
21 | module: {
22 | rules: [
23 | ...utils.getAllStyleRelatedLoaders(
24 | false,
25 | false,
26 | isCSSModules,
27 | '[local]-[hash:base64:5]',
28 | ),
29 | ],
30 | },
31 | mode: 'production',
32 | // devtool: 'hidden-source-map',
33 | stats: { children: false, warnings: false },
34 | plugins: [
35 | new MiniCssExtractPlugin({
36 | filename: utils.getName('[name]', 'css', 'contenthash', false),
37 | }),
38 | ],
39 | //new in webpack4
40 | optimization: {
41 | namedModules: false,
42 | // runtimeChunk: { name: utils.ENTRY_NAME.VENDORS },
43 | runtimeChunk: 'single',
44 | noEmitOnErrors: true, // NoEmitOnErrorsPlugin
45 | concatenateModules: !isSSREnabled, //ModuleConcatenationPlugin
46 | splitChunks: {
47 | // name: false,
48 | automaticNameMaxLength: 30,
49 | cacheGroups: {
50 | vendors: {
51 | test: /[\\/]node_modules[\\/]/i,
52 | name: utils.ENTRY_NAME.VENDORS,
53 | chunks: 'initial',
54 | },
55 | },
56 | },
57 | minimizer: [
58 | new TerserWebpackPlugin({
59 | extractComments: false,
60 | parallel: true,
61 | sourceMap: false,
62 | terserOptions: {
63 | warnings: false,
64 | // ecma: 2015,
65 | compress: {
66 | warnings: false,
67 | drop_console: true,
68 | dead_code: true,
69 | drop_debugger: true,
70 | },
71 | output: {
72 | comments: false,
73 | // beautify: false,
74 | },
75 | mangle: true,
76 | },
77 | }),
78 | ],
79 | },
80 | });
81 |
82 | // removed in webpack4
83 | // if (!isSSREnabled) {
84 | // webpackConfig.plugins.push(new webpack.optimize.ModuleConcatenationPlugin());
85 | // }
86 |
87 | if (isBundleAnalyzerEnabled) {
88 | webpackConfig.plugins.push(new BundleAnalyzerPlugin());
89 | }
90 |
91 | // console.log('webpackConfig.output.publicPath: ', webpackConfig.output.publicPath);
92 |
93 | // console.log(webpackConfig);
94 |
95 | module.exports = webpackConfig;
96 |
--------------------------------------------------------------------------------
/routes/index.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | const Router = require('koa-router');
3 | const koaBody = require('koa-body');
4 | const { HttpClient } = require('../services/HttpClient');
5 | const ServerRenderer = require('../services/ServerRenderer');
6 | // const Cache = require('../services/Cache');
7 |
8 | const renderer = new ServerRenderer({
9 | stream: false,
10 | /*cache: new Cache({
11 | flush: true,
12 | // flushInterval: 1000 * 30, //flush every 30s
13 | }),*/
14 | });
15 |
16 | const config = require('../config/env');
17 | const utils = require('../config/utils');
18 | const { logger } = require('../services/logger');
19 |
20 | const appPrefix = utils.normalizeTailSlash(config.getAppPrefix());
21 | const router = new Router({
22 | prefix: appPrefix,
23 | });
24 |
25 | router.use(async function (ctx, next) {
26 | ctx.state = {
27 | initialData: {},
28 | };
29 | await next();
30 | });
31 |
32 | /**
33 | * File upload demo
34 | */
35 | router.post(
36 | '/upload',
37 | koaBody({
38 | multipart: true,
39 | keepExtensions: true,
40 | }),
41 | async function (ctx) {
42 | const { body, files } = ctx.request;
43 | ctx.body = { body, files };
44 | },
45 | );
46 |
47 | /**
48 | * Async request data for initial state demo
49 | */
50 | router.get('/github', async function (ctx) {
51 | if (renderer.isCacheMatched(ctx.path)) {
52 | renderer.renderFromCache(ctx.path, ctx);
53 | return;
54 | }
55 |
56 | if (!renderer.isSSREnabled()) {
57 | ctx.body = renderer.genHtml('', {}, ctx);
58 | return;
59 | }
60 |
61 | const client = new HttpClient({ jsonResponse: true });
62 |
63 | //you can use isomorphic-fetch to share the fetch logic
64 | logger.info('requesting github data...');
65 | const res = await client.get(
66 | 'https://api.github.com/repos/jasonboy/wechat-jssdk/branches',
67 | {},
68 | {
69 | headers: {
70 | 'User-Agent':
71 | 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1',
72 | },
73 | },
74 | );
75 | const data = { github: res };
76 | renderer.render(ctx, data);
77 | });
78 |
79 | router.get('/400', async (ctx) => {
80 | ctx.status = 400;
81 | ctx.body = {
82 | msg: '400',
83 | };
84 | });
85 |
86 | router.post('/400', koaBody(), async (ctx) => {
87 | ctx.status = 400;
88 | ctx.set('cache-control', 'no-store');
89 | ctx.body = {
90 | msg: '400',
91 | data: ctx.request.body,
92 | };
93 | });
94 |
95 | router.get('/500', async (ctx) => {
96 | ctx.throw(500);
97 | });
98 |
99 | /**
100 | * Other default handler
101 | */
102 | router.get('(.*)', async function (ctx) {
103 | // console.log('ctx.path: ', ctx.url);
104 | if (!config.isSSREnabled()) {
105 | ctx.set('Cache-Control', 'no-cache');
106 | }
107 | renderer.render(ctx);
108 | });
109 |
110 | module.exports = router;
111 |
--------------------------------------------------------------------------------
/services/Cache.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 | const path = require('path');
3 |
4 | const { logger } = require('../services/logger');
5 | const { slugify } = require('../utils/common');
6 | const makeDir = require('make-dir');
7 |
8 | const DEFAULT_CACHE_DIR = path.join(__dirname, '../build/cache');
9 |
10 | /**
11 | * A simple cache implementation for SSR rendered content, if you want to store/flush cache to somewhere else, e.g: redis, you can extend this Cache to meet your own needs.
12 | * @param {Object<{
13 | * flush: boolean,
14 | * flushInterval: number,
15 | * flushDir: string,
16 | * }>} options
17 | * @param {number} options.flush - if need to flush cache to some persistent storage
18 | * @param {boolean} options.flushInterval - in milliseconds to trigger a flush action, default 10 minutes
19 | * @param {string} options.flushDir - dir path to persist cached html to file, default: "${project_root}/build/cache"
20 | */
21 | class Cache {
22 | constructor(options = {}) {
23 | // logger.info('Initializing SSR Cache Storage...');
24 | //make cache dir before hand
25 | this.flushDir = options.flushDir || DEFAULT_CACHE_DIR;
26 | makeDir.sync(this.flushDir);
27 |
28 | this.flushInterval = options.flushInterval || 10 * 60 * 1000; //default 10m
29 | //init a Map instance for in-memory cache
30 | this.cache = new Map();
31 | if (options.flush) {
32 | this._initFlushTimer();
33 | }
34 | // logger.info('Initialize SSR Cache Storage Done!');
35 | }
36 |
37 | get(key) {
38 | return this.cache.get(key);
39 | }
40 |
41 | set(key, value) {
42 | this.cache.set(key, value);
43 | }
44 |
45 | has(key) {
46 | return this.cache.has(key);
47 | }
48 |
49 | clear() {
50 | this.cache.clear();
51 | }
52 |
53 | /**
54 | * Flush cache to storage
55 | * @returns {Promise} - resolved with Array with file names
56 | */
57 | flush() {
58 | const promises = [];
59 | for (let [key] of this.cache) {
60 | promises.push(this.persistSingleCache(key));
61 | }
62 | return Promise.all(promises);
63 | }
64 |
65 | /**
66 | * flush a single cache
67 | * @param key
68 | * @returns {Promise}
69 | */
70 | persistSingleCache(key) {
71 | //persist to file
72 | const fileName = `${slugify(key)}.html`;
73 | return new Promise((resolve, reject) => {
74 | fs.writeFile(path.join(this.flushDir, fileName), this.get(key), (err) => {
75 | if (err) {
76 | logger.error(err);
77 | return reject(err);
78 | }
79 | logger.info(`Persist cache [${key}] to file finished!`);
80 | resolve(fileName);
81 | });
82 | });
83 | }
84 |
85 | stopFlushTimer() {
86 | if (!this.flushTimer) return;
87 | clearInterval(this.flushTimer);
88 | }
89 |
90 | startFlushTimer() {
91 | this.stopFlushTimer();
92 | this._initFlushTimer();
93 | }
94 |
95 | _initFlushTimer() {
96 | this.flushTimer = setInterval(() => {
97 | this.flush()
98 | .then(() => {
99 | logger.info('Flush SSR Cache Done!');
100 | })
101 | .catch((err) => {
102 | logger.error(`Flush SSR Cache Failed: ${err.message}`);
103 | logger.error(err.stack);
104 | });
105 | }, this.flushInterval);
106 | }
107 | }
108 |
109 | module.exports = Cache;
110 |
--------------------------------------------------------------------------------
/src/pages/index.jsx:
--------------------------------------------------------------------------------
1 | import { Component } from 'react';
2 | import { Link } from 'react-router-dom';
3 | import styled, { keyframes } from 'styled-components';
4 | import logo from 'assets/static/logo.svg';
5 | // import { getRoutes } from 'modules/router-utils';
6 |
7 | const rotate = keyframes`
8 | from {
9 | transform: rotate(0deg);
10 | }
11 |
12 | to {
13 | transform: rotate(360deg);
14 | }
15 | `;
16 | const StyledHome = styled.div`
17 | font-size: 1.2rem;
18 | `;
19 | const StyledTitle = styled.h3`
20 | img,
21 | span {
22 | vertical-align: middle;
23 | }
24 | .logo-img {
25 | animation: ${rotate} 3s linear infinite;
26 | }
27 | `;
28 | const StyledList = styled.div`
29 | width: 300px;
30 | a {
31 | display: block;
32 | }
33 | `;
34 |
35 | class Index extends Component {
36 | constructor(props) {
37 | super(props);
38 |
39 | this.state = {
40 | error: { msg: 'Errored' },
41 | list: [
42 | {
43 | key: 1,
44 | path: '/hello',
45 | name: 'ssr name',
46 | },
47 | ],
48 | };
49 | }
50 |
51 | componentDidMount() {
52 | // const routes = getRoutes(true);
53 | // this.setState({ list: routes });
54 | /*const name = 'index';
55 | const weakDeps = require.resolveWeak(`src/pages/${name}`);
56 | console.log('weakDeps: ', weakDeps);
57 | console.log(
58 | '__webpack_modules__[weakDeps]: ',
59 | __webpack_modules__[weakDeps],
60 | );
61 | console.log(
62 | 'require.cache[weakDeps]: ',
63 | require.cache[require.resolveWeak(`src/pages/hello`)],
64 | );*/
65 | }
66 |
67 | makeError = (e) => {
68 | e.preventDefault();
69 | this.setState({ error: null });
70 | };
71 |
72 | render() {
73 | const { list } = this.state;
74 |
75 | return (
76 |
77 |
78 |
85 | Home
86 |
87 |
88 | {list.map((route) => {
89 | return (
90 |
95 | {route.name}
96 |
97 | );
98 | })}
99 | {/*
100 | Home
101 | */}
102 |
103 | Hello async
104 |
105 | {/*
106 | Hello Async
107 |
108 |
109 | Github
110 | */}
111 |
116 | {this.state.error.msg}
117 |
118 |
119 |
120 | );
121 | }
122 | }
123 |
124 | export default Index;
125 |
--------------------------------------------------------------------------------
/services/logger.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 | const path = require('path');
3 | const pino = require('pino');
4 | const koaPinoLogger = require('koa-pino-logger');
5 | const prettifier = require('pino-pretty');
6 | const morgan = require('koa-morgan');
7 |
8 | const makeDir = require('make-dir');
9 | const config = require('../config/env');
10 |
11 | const DEV_MODE = config.isDevMode();
12 | const logPath = path.resolve(config.getLogPath());
13 |
14 | try {
15 | fs.statSync(logPath);
16 | } catch (e) {
17 | makeDir.sync(logPath);
18 | }
19 |
20 | const prettyOptions = {
21 | prettyPrint: {
22 | levelFirst: true,
23 | translateTime: 'SYS:standard',
24 | ignore: 'pid,hostname',
25 | },
26 | prettifier,
27 | };
28 |
29 | class Logger {
30 | /**
31 | *
32 | * @param {object=} options - Logger options
33 | * @param {object=} pinoOptions - options for pino
34 | */
35 | constructor(options = {}, pinoOptions = {}) {
36 | this.options = Object.assign({}, options);
37 | this.pinoOptions = Object.assign(
38 | {
39 | name: 'app',
40 | },
41 | pinoOptions,
42 | );
43 |
44 | this._logger = this.createLogger(null, this.options.destination);
45 | this._consoleLogger = this.createConsoleLogger();
46 | }
47 |
48 | /**
49 | * Common log method to both console and file
50 | * @param {string=} type - log type, default: "info"
51 | * @param rest
52 | */
53 | log(type = 'info', ...rest) {
54 | this._consoleLogger[type](...rest);
55 | this._logger[type](...rest);
56 | }
57 |
58 | trace(...rest) {
59 | this.log('trace', ...rest);
60 | }
61 | debug(...rest) {
62 | this.log('debug', ...rest);
63 | }
64 | info(...rest) {
65 | this.log('info', ...rest);
66 | }
67 | warn(...rest) {
68 | this.log('warn', ...rest);
69 | }
70 | fatal(...rest) {
71 | this.log('fatal', ...rest);
72 | }
73 | error(...rest) {
74 | this.log('error', ...rest);
75 | }
76 |
77 | /**
78 | * Create a pino logger
79 | * @param {object=} options - pino options
80 | * @param {object=} destination - pino destination, e.g: to file
81 | */
82 | createLogger(options = {}, destination) {
83 | return pino(
84 | Object.assign(this.pinoOptions, options),
85 | destination || pino.destination(path.join(logPath, 'app.log')),
86 | );
87 | }
88 |
89 | /**
90 | * Create a pino logger to console
91 | * @param {object=} options - pino options with prettifier enabled
92 | */
93 | createConsoleLogger(options) {
94 | return pino(Object.assign(prettyOptions, options));
95 | }
96 |
97 | /**
98 | * Create pino logger that log koa requests to file
99 | * @param {object=} options
100 | * @param {object=} destination
101 | * @return {function}
102 | */
103 | createRequestsLogger(options, destination) {
104 | return koaPinoLogger(
105 | Object.assign(
106 | {
107 | logger: this.createLogger(
108 | options,
109 | destination || pino.destination(path.join(logPath, 'requests.log')),
110 | ),
111 | serializers: {
112 | req: pino.stdSerializers.req,
113 | res: pino.stdSerializers.res,
114 | },
115 | },
116 | options,
117 | ),
118 | );
119 | }
120 |
121 | /**
122 | * Create a morgan koa middleware for common log format
123 | * @static
124 | * @return {*}
125 | */
126 | static createMorganLogger() {
127 | return morgan(DEV_MODE ? 'dev' : 'tiny');
128 | }
129 | }
130 |
131 | exports.logger = new Logger();
132 | exports.Logger = Logger;
133 |
--------------------------------------------------------------------------------
/config/webpack.config.base.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const path = require('path');
4 | const webpack = require('webpack');
5 | const HtmlWebpackPlugin = require('html-webpack-plugin');
6 | const ManifestPlugin = require('webpack-manifest-plugin');
7 | const CopyWebpackPlugin = require('copy-webpack-plugin');
8 | const { CleanWebpackPlugin } = require('clean-webpack-plugin');
9 | const LoadablePlugin = require('@loadable/webpack-plugin');
10 | const MomentLocalesPlugin = require('moment-locales-webpack-plugin');
11 |
12 | const config = require('./env');
13 | const utils = require('./utils');
14 |
15 | const DEV_MODE = config.isDevMode();
16 | const isHMREnabled = config.isHMREnabled();
17 | const isSSREnabled = config.isSSREnabled();
18 | const APP_PATH = utils.APP_PATH;
19 | const CONTENT_PATH = APP_PATH;
20 | const APP_BUILD_PATH = utils.APP_BUILD_PATH;
21 | const ENTRY_NAME = utils.ENTRY_NAME;
22 |
23 | const defaultPrefix = config.getDefaultApiEndPointPrefix();
24 |
25 | const appPrefix = utils.normalizeTailSlash(
26 | config.getAppPrefix(),
27 | config.isPrefixTailSlashEnabled(),
28 | );
29 | const prefix = utils.normalizeTailSlash(
30 | utils.normalizePublicPath(
31 | path.join(config.getAppPrefix(), config.getStaticPrefix()),
32 | ),
33 | config.isPrefixTailSlashEnabled(),
34 | );
35 |
36 | const appIndex = path.join(APP_PATH, 'index.js');
37 |
38 | let entry;
39 | if (isHMREnabled) {
40 | entry = [appIndex];
41 | } else {
42 | entry = {
43 | [ENTRY_NAME.APP]: [appIndex],
44 | };
45 | }
46 |
47 | const webpackConfig = {
48 | entry,
49 | output: {
50 | path: APP_BUILD_PATH,
51 | },
52 | resolve: {
53 | ...utils.getWebpackResolveConfig(),
54 | },
55 | module: {
56 | rules: [
57 | utils.getBabelLoader(DEV_MODE),
58 | utils.getImageLoader(DEV_MODE, CONTENT_PATH),
59 | utils.getMediaLoader(DEV_MODE, CONTENT_PATH),
60 | ],
61 | },
62 | plugins: [
63 | //in HMR, do not clean built files
64 | ...(isHMREnabled ? [] : [new CleanWebpackPlugin({ verbose: false })]),
65 | new webpack.DefinePlugin({
66 | __isBrowser__: true,
67 | __HMR__: isHMREnabled,
68 | __SSR__: isSSREnabled,
69 | 'process.env.DEV_MODE': DEV_MODE,
70 | 'process.env.prefix': JSON.stringify(prefix),
71 | 'process.env.appPrefix': JSON.stringify(appPrefix),
72 | 'process.env.NODE_ENV': JSON.stringify(config.getNodeEnv()),
73 | 'process.env.apiPrefix': JSON.stringify(
74 | config.isCustomAPIPrefix() ? defaultPrefix : '',
75 | ),
76 | 'process.env.DYNAMIC_ROUTES': config.isDynamicRoutes(),
77 | }),
78 | new MomentLocalesPlugin({
79 | localesToKeep: ['zh-cn'],
80 | }),
81 | new HtmlWebpackPlugin({
82 | template: './views/index.html',
83 | filename: isSSREnabled ? 'index-backup.html' : 'index.html',
84 | inject: 'body',
85 | favicon: path.join(__dirname, '../src/assets/static/favicon.ico'),
86 | }),
87 | new CopyWebpackPlugin({
88 | patterns: [
89 | {
90 | from: utils.resolve('src/assets/static'),
91 | to: utils.resolve('build/app/assets/static'),
92 | },
93 | ],
94 | }),
95 | new LoadablePlugin({
96 | filename: 'loadable-stats.json',
97 | writeToDisk: {
98 | filename: utils.resolve('build/'),
99 | },
100 | }),
101 | new ManifestPlugin({
102 | publicPath: '',
103 | }),
104 | // new HtmlWebpackCustomPlugin(),
105 | ],
106 | };
107 |
108 | function HtmlWebpackCustomPlugin(options) {
109 | // Configure your plugin with options...
110 | console.log('HtmlWebpackCustomPlugin: ', options);
111 | }
112 |
113 | HtmlWebpackCustomPlugin.prototype.apply = function (compiler) {
114 | compiler.hooks.compilation.tap(
115 | 'InsertSSRBundleScriptsPlugin',
116 | (compilation) => {
117 | console.log('The compiler is starting a new compilation...');
118 | HtmlWebpackPlugin.getHooks(compilation).beforeEmit.tapAsync(
119 | 'InsertSSRBundleScriptsPlugin',
120 | (data, cb) => {
121 | console.log('data: ', data.assets);
122 | // console.log('chunks: ', data.assets.chunks);
123 | // console.log('compilation.assets.app: ', Object.getOwnPropertyNames(compilation));
124 | console.log('compilation.entries: ', compilation.entries.length);
125 | // console.log('compilation.entries: ', compilation.entries[0].NormalModule.dependencies);
126 | console.log(
127 | 'compilation.chunks: ',
128 | compilation.chunks.length,
129 | compilation.chunks,
130 | );
131 | // console.log('compilation.assets: ', compilation.assets);
132 | cb(null, data);
133 | },
134 | );
135 | },
136 | );
137 | };
138 |
139 | module.exports = webpackConfig;
140 |
--------------------------------------------------------------------------------
/src/ssr/index.js:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import { StaticRouter } from 'react-router-dom';
3 | import ReactDOMServer from 'react-dom/server';
4 | import { ChunkExtractor } from '@loadable/server';
5 | import { ServerStyleSheet } from 'styled-components';
6 | import manifest from '../../build/app/manifest.json';
7 | import AppRoutes from 'src/AppRoutes';
8 |
9 | const SOURCE_TYPE = {
10 | STYLE: {
11 | name: 'styles',
12 | test: /\.css$/,
13 | },
14 | SCRIPT: {
15 | name: 'scripts',
16 | test: /\.js$/,
17 | },
18 | SOURCE_MAP: {
19 | name: 'sourceMap',
20 | test: /\.map$/,
21 | },
22 | IMAGE: {
23 | name: 'images',
24 | test: /\.(png|jpe?g|gif|svg)$/,
25 | },
26 | };
27 | const typeKeys = Object.keys(SOURCE_TYPE);
28 |
29 | const groupedManifest = {
30 | manifest,
31 | };
32 |
33 | const manifestKeys = Object.keys(manifest);
34 | manifestKeys.forEach((key) => {
35 | const type = checkSourceType(key) || {};
36 | if (!groupedManifest.hasOwnProperty(type.name)) {
37 | groupedManifest[type.name] = [];
38 | }
39 | groupedManifest[type.name].push(manifest[key]);
40 | });
41 |
42 | // console.log('groupedManifest:', groupedManifest);
43 |
44 | function checkSourceType(sourceKey) {
45 | let type;
46 | const matchedKey = typeKeys.find((t) => {
47 | const temp = SOURCE_TYPE[t];
48 | return temp.test.test(sourceKey);
49 | });
50 | if (matchedKey) {
51 | type = SOURCE_TYPE[matchedKey];
52 | }
53 | return type;
54 | }
55 |
56 | const defaultContext = {
57 | userName: 'ssr-jason',
58 | };
59 |
60 | class SSR {
61 | constructor() {
62 | this.statsFile = path.resolve('build/loadable-stats.json');
63 | }
64 | render(url, data, routerContext = {}) {
65 | // let modules = [];
66 | const sheet = new ServerStyleSheet();
67 | const extractor = new ChunkExtractor({ statsFile: this.statsFile });
68 | let jsx,
69 | html,
70 | renderedScriptTags,
71 | renderedLinkTags,
72 | renderedStyleTags,
73 | scStyleTags,
74 | styleTags;
75 | try {
76 | jsx = extractor.collectChunks(
77 | sheet.collectStyles(
78 |
79 |
80 | ,
81 | ),
82 | );
83 | html = ReactDOMServer.renderToString(jsx);
84 |
85 | // You can now collect your script tags
86 | renderedScriptTags = extractor.getScriptTags(); // or extractor.getScriptElements();
87 | // You can also collect your "preload/prefetch" links
88 | renderedLinkTags = extractor.getLinkTags(); // or extractor.getLinkElements();
89 | // And you can even collect your style tags (if you use "mini-css-extract-plugin")
90 | renderedStyleTags = extractor.getStyleTags(); // or extractor.getStyleElements();
91 |
92 | scStyleTags = sheet.getStyleTags();
93 |
94 | styleTags = `${renderedStyleTags || ''}${scStyleTags || ''}`;
95 |
96 | // console.log('html: ', html);
97 | // console.log('renderedScriptTags: \n', renderedScriptTags);
98 | // console.log('renderedLinkTags: \n', renderedLinkTags);
99 | // console.log('renderedStyleTags: \n', renderedStyleTags);
100 | // console.log('scStyleTags: \n', scStyleTags);
101 | // console.log('together StyledTags: \n', styleTags);
102 | } catch (err) {
103 | console.error(err);
104 | } finally {
105 | sheet.seal();
106 | }
107 |
108 | return {
109 | html,
110 | extractor,
111 | scriptTags: renderedScriptTags || '',
112 | linkTags: renderedLinkTags || '',
113 | styleTags: styleTags || '',
114 | };
115 | }
116 |
117 | renderWithStream(url, data = {}, routerContext = {}) {
118 | // let modules = [];
119 | const sheet = new ServerStyleSheet();
120 | const extractor = new ChunkExtractor({ statsFile: this.statsFile });
121 | const jsx = extractor.collectChunks(
122 | sheet.collectStyles(
123 |
124 |
125 | ,
126 | ),
127 | );
128 | const htmlStream = sheet.interleaveWithNodeStream(
129 | ReactDOMServer.renderToNodeStream(jsx),
130 | );
131 | const renderedScriptTags = extractor.getScriptTags();
132 | const renderedLinkTags = extractor.getLinkTags();
133 | const renderedStyleTags = extractor.getStyleTags();
134 | /*console.log('renderedScriptTags: \n', renderedScriptTags);
135 | console.log('renderedLinkTags: \n', renderedLinkTags);
136 | console.log('renderedStyleTags: \n', renderedStyleTags);*/
137 | return {
138 | htmlStream,
139 | extractor,
140 | scriptTags: renderedScriptTags,
141 | linkTags: renderedLinkTags,
142 | styleTags: renderedStyleTags,
143 | };
144 | }
145 | }
146 |
147 | export default SSR;
148 |
--------------------------------------------------------------------------------
/src/assets/static/jetbrains.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
67 |
--------------------------------------------------------------------------------
/jest.config.node.js:
--------------------------------------------------------------------------------
1 | // For a detailed explanation regarding each configuration property, visit:
2 | // https://jestjs.io/docs/en/configuration.html
3 |
4 | module.exports = {
5 | // All imported modules in your tests should be mocked automatically
6 | // automock: false,
7 |
8 | // Stop running tests after the first failure
9 | // bail: false,
10 |
11 | // Respect "browser" field in package.json when resolving modules
12 | // browser: false,
13 |
14 | // The directory where Jest should store its cached dependency information
15 | // cacheDirectory: "/var/folders/vz/m3gk8ghx5rd2m64yhpcbx4p00000gn/T/jest_dx",
16 |
17 | // Automatically clear mock calls and instances between every test
18 | clearMocks: true,
19 |
20 | // Indicates whether the coverage information should be collected while executing the test
21 | // collectCoverage: false,
22 |
23 | // An array of glob patterns indicating a set of files for which coverage information should be collected
24 | // collectCoverageFrom: null,
25 |
26 | // The directory where Jest should output its coverage files
27 | coverageDirectory: 'coverage',
28 |
29 | // An array of regexp pattern strings used to skip coverage collection
30 | // coveragePathIgnorePatterns: [
31 | // "/node_modules/"
32 | // ],
33 |
34 | // A list of reporter names that Jest uses when writing coverage reports
35 | // coverageReporters: [
36 | // "json",
37 | // "text",
38 | // "lcov",
39 | // "clover"
40 | // ],
41 |
42 | // An object that configures minimum threshold enforcement for coverage results
43 | // coverageThreshold: null,
44 |
45 | // Make calling deprecated APIs throw helpful error messages
46 | // errorOnDeprecated: false,
47 |
48 | // Force coverage collection from ignored files usin a array of glob patterns
49 | // forceCoverageMatch: [],
50 |
51 | // A path to a module which exports an async function that is triggered once before all test suites
52 | // globalSetup: null,
53 |
54 | // A path to a module which exports an async function that is triggered once after all test suites
55 | // globalTeardown: null,
56 |
57 | // A set of global variables that need to be available in all test environments
58 | // globals: {},
59 |
60 | // An array of directory names to be searched recursively up from the requiring module's location
61 | moduleDirectories: ['node_modules', 'src'],
62 |
63 | // An array of file extensions your modules use
64 | // moduleFileExtensions: [
65 | // "js",
66 | // "json",
67 | // "jsx",
68 | // "node"
69 | // ],
70 |
71 | // A map from regular expressions to module names that allow to stub out resources with a single module
72 | // moduleNameMapper: {},
73 |
74 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
75 | // modulePathIgnorePatterns: [],
76 |
77 | // Activates notifications for test results
78 | // notify: false,
79 |
80 | // An enum that specifies notification mode. Requires { notify: true }
81 | // notifyMode: "always",
82 |
83 | // A preset that is used as a base for Jest's configuration
84 | // preset: null,
85 |
86 | // Run tests from one or more projects
87 | // projects: null,
88 |
89 | // Use this configuration option to add custom reporters to Jest
90 | // reporters: undefined,
91 |
92 | // Automatically reset mock state between every test
93 | // resetMocks: false,
94 |
95 | // Reset the module registry before running each individual test
96 | // resetModules: false,
97 |
98 | // A path to a custom resolver
99 | // resolver: null,
100 |
101 | // Automatically restore mock state between every test
102 | // restoreMocks: false,
103 |
104 | // The root directory that Jest should scan for tests and modules within
105 | // rootDir: null,
106 |
107 | // A list of paths to directories that Jest should use to search for files in
108 | // roots: [
109 | // ""
110 | // ],
111 |
112 | // Allows you to use a custom runner instead of Jest's default test runner
113 | // runner: "jest-runner",
114 |
115 | // The paths to modules that run some code to configure or set up the testing environment before each test
116 | // setupFiles: [],
117 |
118 | // The path to a module that runs some code to configure or set up the testing framework before each test
119 | // setupTestFrameworkScriptFile: null,
120 |
121 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing
122 | // snapshotSerializers: [],
123 |
124 | // The test environment that will be used for testing
125 | testEnvironment: 'node',
126 |
127 | // Options that will be passed to the testEnvironment
128 | // testEnvironmentOptions: {},
129 |
130 | // Adds a location field to test results
131 | // testLocationInResults: false,
132 |
133 | // The glob patterns Jest uses to detect test files
134 | testMatch: [
135 | '**/__tests__/server/**/*.js?(x)',
136 | // "**/?(*.)+(spec|test).js?(x)"
137 | ],
138 |
139 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
140 | // testPathIgnorePatterns: [
141 | // "/node_modules/"
142 | // ],
143 |
144 | // The regexp pattern Jest uses to detect test files
145 | // testRegex: "",
146 |
147 | // This option allows the use of a custom results processor
148 | // testResultsProcessor: null,
149 |
150 | // This option allows use of a custom test runner
151 | // testRunner: "jasmine2",
152 |
153 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href
154 | // testURL: "http://localhost",
155 |
156 | // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout"
157 | // timers: "real",
158 |
159 | // A map from regular expressions to paths to transformers
160 | // transform: null,
161 |
162 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
163 | // transformIgnorePatterns: [
164 | // "/node_modules/"
165 | // ],
166 |
167 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
168 | // unmockedModulePathPatterns: undefined,
169 |
170 | // Indicates whether each individual test should be reported during the run
171 | // verbose: null,
172 |
173 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
174 | // watchPathIgnorePatterns: [],
175 |
176 | // Whether to use watchman for file crawling
177 | // watchman: true,
178 | };
179 |
--------------------------------------------------------------------------------
/jest.config.client.js:
--------------------------------------------------------------------------------
1 | // For a detailed explanation regarding each configuration property, visit:
2 | // https://jestjs.io/docs/en/configuration.html
3 |
4 | module.exports = {
5 | // All imported modules in your tests should be mocked automatically
6 | // automock: false,
7 |
8 | // Stop running tests after the first failure
9 | // bail: false,
10 |
11 | // Respect "browser" field in package.json when resolving modules
12 | // browser: false,
13 |
14 | // The directory where Jest should store its cached dependency information
15 | // cacheDirectory: "/var/folders/vz/m3gk8ghx5rd2m64yhpcbx4p00000gn/T/jest_dx",
16 |
17 | // Automatically clear mock calls and instances between every test
18 | clearMocks: true,
19 |
20 | // Indicates whether the coverage information should be collected while executing the test
21 | // collectCoverage: false,
22 |
23 | // An array of glob patterns indicating a set of files for which coverage information should be collected
24 | // collectCoverageFrom: null,
25 |
26 | // The directory where Jest should output its coverage files
27 | coverageDirectory: 'coverage',
28 |
29 | // An array of regexp pattern strings used to skip coverage collection
30 | // coveragePathIgnorePatterns: [
31 | // "/node_modules/"
32 | // ],
33 |
34 | // A list of reporter names that Jest uses when writing coverage reports
35 | // coverageReporters: [
36 | // "json",
37 | // "text",
38 | // "lcov",
39 | // "clover"
40 | // ],
41 |
42 | // An object that configures minimum threshold enforcement for coverage results
43 | // coverageThreshold: null,
44 |
45 | // Make calling deprecated APIs throw helpful error messages
46 | // errorOnDeprecated: false,
47 |
48 | // Force coverage collection from ignored files usin a array of glob patterns
49 | // forceCoverageMatch: [],
50 |
51 | // A path to a module which exports an async function that is triggered once before all test suites
52 | // globalSetup: null,
53 |
54 | // A path to a module which exports an async function that is triggered once after all test suites
55 | // globalTeardown: null,
56 |
57 | // A set of global variables that need to be available in all test environments
58 | globals: {
59 | fetch: function () {},
60 | },
61 |
62 | // An array of directory names to be searched recursively up from the requiring module's location
63 | moduleDirectories: ['node_modules', 'src'],
64 |
65 | // An array of file extensions your modules use
66 | // moduleFileExtensions: [
67 | // "js",
68 | // "json",
69 | // "jsx",
70 | // "node"
71 | // ],
72 |
73 | // A map from regular expressions to module names that allow to stub out resources with a single module
74 | // moduleNameMapper: {},
75 |
76 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
77 | // modulePathIgnorePatterns: [],
78 |
79 | // Activates notifications for test results
80 | // notify: false,
81 |
82 | // An enum that specifies notification mode. Requires { notify: true }
83 | // notifyMode: "always",
84 |
85 | // A preset that is used as a base for Jest's configuration
86 | // preset: null,
87 |
88 | // Run tests from one or more projects
89 | // projects: null,
90 |
91 | // Use this configuration option to add custom reporters to Jest
92 | // reporters: undefined,
93 |
94 | // Automatically reset mock state between every test
95 | // resetMocks: false,
96 |
97 | // Reset the module registry before running each individual test
98 | // resetModules: false,
99 |
100 | // A path to a custom resolver
101 | // resolver: null,
102 |
103 | // Automatically restore mock state between every test
104 | // restoreMocks: false,
105 |
106 | // The root directory that Jest should scan for tests and modules within
107 | // rootDir: null,
108 |
109 | // A list of paths to directories that Jest should use to search for files in
110 | // roots: [
111 | // ""
112 | // ],
113 |
114 | // Allows you to use a custom runner instead of Jest's default test runner
115 | // runner: "jest-runner",
116 |
117 | // The paths to modules that run some code to configure or set up the testing environment before each test
118 | // setupFiles: [],
119 |
120 | // The path to a module that runs some code to configure or set up the testing framework before each test
121 | // setupTestFrameworkScriptFile: null,
122 |
123 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing
124 | // snapshotSerializers: [],
125 |
126 | // The test environment that will be used for testing
127 | testEnvironment: 'jsdom',
128 |
129 | // Options that will be passed to the testEnvironment
130 | // testEnvironmentOptions: {},
131 |
132 | // Adds a location field to test results
133 | // testLocationInResults: false,
134 |
135 | // The glob patterns Jest uses to detect test files
136 | testMatch: [
137 | '**/__tests__/client/**/*.js?(x)',
138 | '**/src/?(*.)+(spec|test).js?(x)',
139 | ],
140 |
141 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
142 | // testPathIgnorePatterns: [
143 | // "/node_modules/"
144 | // ],
145 |
146 | // The regexp pattern Jest uses to detect test files
147 | // testRegex: "",
148 |
149 | // This option allows the use of a custom results processor
150 | // testResultsProcessor: null,
151 |
152 | // This option allows use of a custom test runner
153 | // testRunner: "jasmine2",
154 |
155 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href
156 | // testURL: "http://localhost",
157 |
158 | // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout"
159 | // timers: "real",
160 |
161 | // A map from regular expressions to paths to transformers
162 | // transform: null,
163 |
164 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
165 | // transformIgnorePatterns: [
166 | // "/node_modules/"
167 | // ],
168 |
169 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
170 | // unmockedModulePathPatterns: undefined,
171 |
172 | // Indicates whether each individual test should be reported during the run
173 | // verbose: null,
174 |
175 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
176 | // watchPathIgnorePatterns: [],
177 |
178 | // Whether to use watchman for file crawling
179 | // watchman: true,
180 | };
181 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "koa-web-kit",
3 | "version": "3.0.0-alpha",
4 | "description": "A modern, production-ready, and full-stack node web framework",
5 | "main": "index.js",
6 | "scripts": {
7 | "dev": "cross-env ENABLE_HMR=1 ENABLE_SSR=0 STATIC_PREFIX='' node server.js",
8 | "dev:ssr": "cross-env ENABLE_HMR=0 CSS_MODULES=0 ENABLE_SSR=1 npm-run-all -p start watch watch:ssr",
9 | "dev:watch": "cross-env ENABLE_HMR=0 ENABLE_SSR=0 npm-run-all -p watch start",
10 | "start": "nodemon --trace-warnings server.js",
11 | "build": "cross-env NODE_ENV=production webpack -p --progress --hide-modules --config config/webpack.config.prod.js",
12 | "build:noprogress": "cross-env NODE_ENV=production webpack -p --hide-modules --config config/webpack.config.prod.js",
13 | "build:dev": "webpack --progress --hide-modules --config config/webpack.config.dev.js",
14 | "build:tailwindcss": "tailwindcss build ./src/style/index.css -o ./build/built-tailwindcss/style.css",
15 | "deploy": "./deploy.sh",
16 | "deploy:noinstall": "npm run deploy -- 1",
17 | "ssg": "cross-env STATIC_PREFIX='' APP_PREFIX='' PREFIX_TRAILING_SLASH='' npm run build",
18 | "ssr": "webpack --progress --config config/webpack.config.ssr.js",
19 | "watch": "webpack --watch --progress --hide-modules --config config/webpack.config.dev.js",
20 | "watch:ssr": "npm run ssr -- --watch",
21 | "node": "webpack --progress --config config/webpack.config.node.js",
22 | "report": "cross-env NODE_ENV=production BUNDLE_ANALYZER=true webpack -p --progress --hide-modules --config config/webpack.config.prod.js",
23 | "test": "npm run jest:node && npm run jest:client",
24 | "jest:node": "jest --config=jest.config.node.js --forceExit",
25 | "jest:client": "jest --config=jest.config.client.js",
26 | "lint": "eslint --ignore-path .gitignore --ext .jsx,.js ./"
27 | },
28 | "engines": {
29 | "node": ">= 16"
30 | },
31 | "husky": {
32 | "hooks": {
33 | "pre-commit": "lint-staged"
34 | }
35 | },
36 | "lint-staged": {
37 | "**/*.{js,jsx}": [
38 | "eslint --fix"
39 | ],
40 | "**/*.{json,css,html}": [
41 | "prettier --write"
42 | ]
43 | },
44 | "repository": {
45 | "type": "git",
46 | "url": "git+https://github.com/JasonBoy/koa-web-kit.git"
47 | },
48 | "keywords": [
49 | "koa",
50 | "es6",
51 | "webpack",
52 | "react",
53 | "bootstrap",
54 | "fullstack",
55 | "framework"
56 | ],
57 | "author": "jasonlikenfs@gmail.com",
58 | "license": "MIT",
59 | "bugs": {
60 | "url": "https://github.com/JasonBoy/koa-web-kit/issues"
61 | },
62 | "homepage": "https://github.com/JasonBoy/koa-web-kit#readme",
63 | "readme": "README.md",
64 | "dependencies": {
65 | "@loadable/component": "5.15.2",
66 | "@loadable/server": "5.15.2",
67 | "chalk": "4.1.1",
68 | "core-js": "3.20.1",
69 | "dotenv": "10.0.0",
70 | "got": "11.8.2",
71 | "html-minifier": "4.0.0",
72 | "koa": "2.13.4",
73 | "koa-body": "4.2.0",
74 | "koa-compress": "5.1.0",
75 | "koa-conditional-get": "3.0.0",
76 | "koa-etag": "4.0.0",
77 | "koa-favicon": "2.1.0",
78 | "koa-helmet": "6.1.0",
79 | "koa-morgan": "1.0.1",
80 | "koa-mount": "4.0.0",
81 | "koa-pino-logger": "3.0.0",
82 | "koa-router": "10.1.1",
83 | "koa-session": "6.2.0",
84 | "koa-static": "5.0.0",
85 | "lodash.isempty": "4.4.0",
86 | "make-dir": "3.1.0",
87 | "moment": "2.29.1",
88 | "pino": "6.13.0",
89 | "pino-pretty": "5.1.2",
90 | "pm2": "4.5.6",
91 | "prop-types": "15.8.0",
92 | "react": "17.0.2",
93 | "react-dom": "17.0.2",
94 | "react-router-dom": "5.2.0",
95 | "slugify": "1.6.4",
96 | "socks-proxy-agent": "6.1.1",
97 | "styled-components": "5.3.3",
98 | "tunnel": "0.0.6"
99 | },
100 | "devDependencies": {
101 | "@babel/cli": "7.16.0",
102 | "@babel/core": "7.16.5",
103 | "@babel/eslint-parser": "7.16.5",
104 | "@babel/helper-module-imports": "7.16.0",
105 | "@babel/plugin-proposal-class-properties": "7.16.5",
106 | "@babel/plugin-proposal-nullish-coalescing-operator": "7.16.5",
107 | "@babel/plugin-proposal-optional-chaining": "7.16.5",
108 | "@babel/plugin-transform-modules-commonjs": "7.16.5",
109 | "@babel/plugin-transform-react-jsx-source": "7.16.5",
110 | "@babel/plugin-transform-runtime": "7.16.5",
111 | "@babel/preset-env": "7.16.5",
112 | "@babel/preset-react": "7.16.5",
113 | "@babel/runtime": "7.16.5",
114 | "@loadable/babel-plugin": "5.13.2",
115 | "@loadable/webpack-plugin": "5.15.0",
116 | "babel-loader": "8.2.3",
117 | "babel-minify-webpack-plugin": "0.3.1",
118 | "babel-plugin-dynamic-import-node": "2.3.3",
119 | "babel-plugin-styled-components": "1.13.2",
120 | "cheerio": "1.0.0-rc.10",
121 | "clean-webpack-plugin": "3.0.0",
122 | "copy-webpack-plugin": "6.4.1",
123 | "cross-env": "7.0.3",
124 | "css-loader": "5.2.7",
125 | "cssnano": "5.0.7",
126 | "error-overlay-webpack-plugin": "0.4.2",
127 | "eslint": "8.5.0",
128 | "eslint-config-prettier": "8.3.0",
129 | "eslint-plugin-prettier": "4.0.0",
130 | "eslint-plugin-react": "7.28.0",
131 | "eslint-plugin-react-hooks": "4.3.0",
132 | "file-loader": "6.2.0",
133 | "get-port": "5.1.1",
134 | "globby": "11.0.4",
135 | "html-webpack-plugin": "4.5.2",
136 | "husky": "4.3.8",
137 | "jest": "26.4.2",
138 | "json-server": "0.16.3",
139 | "koa-history-api-fallback": "1.0.0",
140 | "koa-webpack": "6.0.0",
141 | "lint-staged": "12.1.4",
142 | "mini-css-extract-plugin": "1.6.2",
143 | "moment-locales-webpack-plugin": "1.2.0",
144 | "nock": "13.1.1",
145 | "nodemon": "2.0.15",
146 | "npm-run-all": "4.1.5",
147 | "null-loader": "4.0.1",
148 | "postcss": "8.3.6",
149 | "postcss-import": "14.0.2",
150 | "postcss-loader": "4.3.0",
151 | "postcss-preset-env": "6.7.0",
152 | "prettier": "2.5.1",
153 | "shelljs": "0.8.4",
154 | "style-loader": "2.0.0",
155 | "supertest": "3.4.2",
156 | "tailwindcss": "1.8.6",
157 | "terser-webpack-plugin": "4.2.3",
158 | "url-loader": "4.1.1",
159 | "webpack": "4.46.0",
160 | "webpack-bundle-analyzer": "3.9.0",
161 | "webpack-cli": "3.3.12",
162 | "webpack-manifest-plugin": "2.2.0",
163 | "webpack-merge": "5.8.0",
164 | "webpack-node-externals": "3.0.0"
165 | },
166 | "browserslist": [
167 | "> 1%",
168 | "last 2 versions",
169 | "not ie 11",
170 | "not dead",
171 | "not op_mini all"
172 | ],
173 | "nodemonConfig": {
174 | "watch": [
175 | "api/",
176 | "config/",
177 | "build/node",
178 | "routes/",
179 | "utils/",
180 | "services/",
181 | "app-config.js",
182 | "server.js",
183 | "app.js"
184 | ]
185 | }
186 | }
187 |
--------------------------------------------------------------------------------
/app.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const config = require('./config/env');
4 |
5 | const path = require('path');
6 | const Koa = require('koa');
7 | const mount = require('koa-mount');
8 | const compress = require('koa-compress');
9 | const session = require('koa-session');
10 | const serveStatic = require('koa-static');
11 | const helmet = require('koa-helmet');
12 | const favicon = require('koa-favicon');
13 | const isEmpty = require('lodash.isempty');
14 | const conditional = require('koa-conditional-get');
15 | const etag = require('koa-etag');
16 |
17 | const { logger, Logger } = require('./services/logger');
18 | const index = require('./routes/index');
19 | const { handleApiRequests } = require('./routes/proxy');
20 | const sysUtils = require('./config/utils');
21 | // const isSSREnabled = config.isSSREnabled();
22 |
23 | const PORT = config.getListeningPort();
24 | const DEV_MODE = config.isDevMode();
25 | const DEFAULT_PREFIX_KEY = 'defaultPrefix';
26 | const API_ENDPOINTS = config.getApiEndPoints();
27 | const isHMREnabled = config.isHMREnabled();
28 | const servingStaticIndex = config.isServingStaticIndex();
29 |
30 | function initAppCommon() {
31 | const app = new Koa();
32 | app.env = config.getNodeEnv() || 'development';
33 | app.keys = ['koa-web-kit'];
34 | app.proxy = true;
35 |
36 | app.use(Logger.createMorganLogger());
37 | app.use(logger.createRequestsLogger());
38 | // app.use(helmet());
39 | app.use(helmet.xssFilter());
40 | return app;
41 | }
42 |
43 | function initApp(app) {
44 | app.use(conditional());
45 | app.use(etag());
46 | if (!DEV_MODE) {
47 | app.use(compress());
48 | }
49 |
50 | app.use(favicon(path.join(__dirname, 'build/app/favicon.ico')));
51 |
52 | // =====serve static=====
53 | const ROOT_PATH = '/';
54 | let staticPrefixConfig = config.getStaticPrefix();
55 | let staticPrefix = path.join(config.getAppPrefix(), staticPrefixConfig);
56 | if (!staticPrefix.startsWith(ROOT_PATH)) {
57 | staticPrefix = ROOT_PATH;
58 | }
59 |
60 | if (sysUtils.isWindows()) {
61 | staticPrefix = sysUtils.replaceBackwardSlash(staticPrefix);
62 | }
63 |
64 | const staticOptions = {
65 | // one month cache for prod
66 | maxage: DEV_MODE ? 0 : 2592000000,
67 | gzip: false,
68 | setHeaders(res, path) {
69 | if (path.endsWith('.html')) {
70 | res.setHeader('Cache-Control', 'no-cache');
71 | }
72 | },
73 | };
74 | const isStaticAssetsInRoot =
75 | !staticPrefixConfig || staticPrefixConfig === ROOT_PATH;
76 | if (isStaticAssetsInRoot && !servingStaticIndex) {
77 | //workaround: use a random index to pass through the static middleware
78 | staticOptions.index = `${Math.random().toString()}.html`;
79 | }
80 |
81 | app.use(
82 | mount(
83 | staticPrefix,
84 | serveStatic(path.join(__dirname, 'build/app'), staticOptions),
85 | ),
86 | );
87 | // handle static not found, do not pass further down
88 | if (!isStaticAssetsInRoot) {
89 | app.use(
90 | mount(staticPrefix, (ctx) => {
91 | ctx.status = 404;
92 | ctx.body = 'Not Found';
93 | }),
94 | );
95 | }
96 | // =====serve static end=====
97 |
98 | app.use(session(app));
99 |
100 | app.use(index.routes());
101 |
102 | app.on('error', (err) => {
103 | logger.error(err.stack);
104 | });
105 |
106 | return app;
107 | }
108 |
109 | function listen(app, port = PORT) {
110 | const server = app.listen(port, '0.0.0.0');
111 | logger.info(`Koa listening on port ${port}`);
112 | if (DEV_MODE) {
113 | logger.info(`visit: http://localhost:${port}`);
114 | }
115 | return server;
116 | }
117 |
118 | //React SSR
119 | async function initSSR() {}
120 |
121 | async function initHMR(app) {
122 | if (!isHMREnabled) return;
123 | let HMRInitialized = false;
124 | logger.info('HMR enabled, initializing HMR...');
125 | const koaWebpack = require('koa-webpack');
126 | const historyApiFallback = require('koa-history-api-fallback');
127 | const webpack = require('webpack');
128 | const hmrPort = config.getHMRPort();
129 | const webpackConfig = require('./config/webpack.config.dev');
130 | const compiler = webpack(
131 | Object.assign({}, webpackConfig, {
132 | stats: {
133 | modules: false,
134 | colors: true,
135 | },
136 | }),
137 | );
138 | const hotClient = {
139 | logLevel: 'error',
140 | hmr: true,
141 | reload: true,
142 | host: {
143 | client: 'localhost',
144 | server: '0.0.0.0',
145 | },
146 | };
147 | if (hmrPort) {
148 | hotClient.port = parseInt(hmrPort);
149 | }
150 | return new Promise((resolve, reject) => {
151 | koaWebpack({
152 | compiler,
153 | hotClient,
154 | devMiddleware: {
155 | index: 'index.html',
156 | publicPath: webpackConfig.output.publicPath,
157 | watchOptions: {
158 | aggregateTimeout: 0,
159 | },
160 | writeToDisk: false,
161 | stats: {
162 | modules: false,
163 | colors: true,
164 | children: false,
165 | },
166 | },
167 | })
168 | .then((middleware) => {
169 | if (!HMRInitialized) {
170 | HMRInitialized = true;
171 | app.use(historyApiFallback());
172 | app.use(middleware);
173 | middleware.devMiddleware.waitUntilValid(resolve);
174 | }
175 | })
176 | .catch((err) => {
177 | logger.error('[koa-webpack]:', err);
178 | reject();
179 | });
180 | });
181 | }
182 |
183 | function initProxy(app) {
184 | //api proxy
185 | if (!(config.isNodeProxyEnabled() && !isEmpty(API_ENDPOINTS))) {
186 | return;
187 | }
188 | if ('string' === typeof API_ENDPOINTS) {
189 | const defaultPrefix = config.getDefaultApiEndPointPrefix();
190 | app.use(handleApiRequests(defaultPrefix, API_ENDPOINTS));
191 | logProxyInfo(API_ENDPOINTS, defaultPrefix);
192 | return;
193 | }
194 | for (const prefix in API_ENDPOINTS) {
195 | if (API_ENDPOINTS.hasOwnProperty(prefix) && prefix !== DEFAULT_PREFIX_KEY) {
196 | let endPoint = API_ENDPOINTS[prefix];
197 | if ('string' !== typeof endPoint) {
198 | endPoint = endPoint.endpoint;
199 | }
200 | app.use(handleApiRequests(prefix, endPoint));
201 | logProxyInfo(endPoint, prefix);
202 | }
203 | }
204 |
205 | function logProxyInfo(endPoint, prefix) {
206 | logger.info('Node proxy[' + endPoint + '] enabled for path: ' + prefix);
207 | }
208 | }
209 |
210 | module.exports = {
211 | listen,
212 | /**
213 | *
214 | * @return {Promise}
215 | */
216 | create: async function () {
217 | const app = initAppCommon();
218 | initProxy(app);
219 | await initSSR();
220 | await initHMR(app);
221 | initApp(app);
222 | return Promise.resolve(app);
223 | // logger.info(`${isHMREnabled ? 'HMR & ' : ''}Koa App initialized!`);
224 | },
225 | };
226 |
--------------------------------------------------------------------------------
/config/env.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Load app configurations
3 | */
4 |
5 | require('dotenv').config();
6 | const fs = require('fs');
7 | const path = require('path');
8 | const chalk = require('chalk');
9 |
10 | const devConfig = require('./config.default.dev');
11 | const prodConfig = require('./config.default.prod');
12 |
13 | /**
14 | * app configs
15 | * @type {{}}
16 | */
17 | let config = {};
18 |
19 | //get custom config path from env
20 | const customConfigPath = process.env.NODE_CONFIG_PATH;
21 | const nodeBuildEnv = process.env.NODE_BUILD_ENV;
22 |
23 | const DEFAULT_PREFIX_KEY = 'defaultPrefix';
24 |
25 | const DEFAULT_CONFIG_FILE_NAME = 'app-config.js';
26 |
27 | function initConfig(customConfig) {
28 | let defaultConfigJS = `../${DEFAULT_CONFIG_FILE_NAME}`;
29 | const defaultConfigJSAlt = `./${DEFAULT_CONFIG_FILE_NAME}`;
30 |
31 | try {
32 | fs.statSync(path.join(__dirname, defaultConfigJS));
33 | } catch (e) {
34 | defaultConfigJS = defaultConfigJSAlt;
35 | }
36 |
37 | let configPath = customConfigPath
38 | ? path.resolve(customConfigPath)
39 | : path.join(__dirname, defaultConfigJS);
40 | // console.log(configPath);
41 | let configInfo;
42 | let hasCustomConfig = true;
43 | let checkMsg = '';
44 |
45 | try {
46 | fs.statSync(configPath);
47 | } catch (e) {
48 | if (!process.env.NODE_ENV || process.env.NODE_ENV === 'development') {
49 | configPath = autoCreateConfigFile();
50 | } else {
51 | hasCustomConfig = false;
52 | }
53 | }
54 |
55 | // console.log('process.env.STATIC_PREFIX: ', process.env.STATIC_PREFIX);
56 | // console.log('process.env.APP_PREFIX: ', process.env.APP_PREFIX);
57 |
58 | if (hasCustomConfig) {
59 | configInfo = require(configPath);
60 | checkMsg += `Using [${chalk.green(configPath)}] as app configuration`;
61 | } else {
62 | configInfo =
63 | !nodeBuildEnv || nodeBuildEnv !== 'development' ? prodConfig : devConfig;
64 | checkMsg += `Using [${chalk.green(
65 | !nodeBuildEnv ? 'config.default.prod' : 'config.default.dev',
66 | )}] as app configuration`;
67 | }
68 | console.log(checkMsg);
69 |
70 | if (customConfig) {
71 | console.log('merging custom config into basic config from config file...');
72 | Object.assign(configInfo, customConfig);
73 | }
74 |
75 | //cache non-empty config from env at init time instead of accessing from process.env at runtime to improve performance
76 | for (let key in configInfo) {
77 | if (configInfo.hasOwnProperty(key)) {
78 | config[key] = process.env.hasOwnProperty(key)
79 | ? process.env[key]
80 | : configInfo[key];
81 | }
82 | }
83 | }
84 |
85 | function autoCreateConfigFile() {
86 | const targetFile = path.join(__dirname, `../${DEFAULT_CONFIG_FILE_NAME}`);
87 | fs.copyFileSync(path.join(__dirname, 'app-config.js.sample'), targetFile);
88 | console.log(`Create ${DEFAULT_CONFIG_FILE_NAME} in project root`);
89 | return targetFile;
90 | }
91 |
92 | function getConfigProperty(key) {
93 | let value;
94 | if (config.hasOwnProperty(key)) {
95 | // console.log(`config[${key}] from cache`);
96 | value = config[key];
97 | } else {
98 | // console.log(`config[${key}] from process.env`);
99 | value = process.env[key];
100 | }
101 | return value;
102 | }
103 |
104 | // console.log('config: ', config);
105 |
106 | initConfig();
107 |
108 | module.exports = {
109 | getListeningPort: () => {
110 | return getConfigProperty('PORT') || getConfigProperty('NODE_PORT');
111 | },
112 | getNodeEnv: () => {
113 | return getConfigProperty('NODE_ENV');
114 | },
115 | isDevMode: () => {
116 | const env = getConfigProperty('NODE_ENV');
117 | return 'dev' === env || 'development' === env;
118 | },
119 | isProdMode: () => {
120 | const env = getConfigProperty('NODE_ENV');
121 | return 'prod' === env || 'production' === env;
122 | },
123 | isNodeProxyEnabled: () => {
124 | return !!getConfigProperty('NODE_PROXY');
125 | },
126 | getStaticAssetsEndpoint: () => {
127 | //AKA, get CDN domain
128 | return getConfigProperty('STATIC_ENDPOINT');
129 | },
130 | getAppPrefix: () => {
131 | return getConfigProperty('APP_PREFIX') || '';
132 | },
133 | getStaticPrefix: () => {
134 | return getConfigProperty('STATIC_PREFIX') || '';
135 | },
136 | isPrefixTailSlashEnabled: () => {
137 | return !!getConfigProperty('PREFIX_TRAILING_SLASH');
138 | },
139 | getApiEndPointPrefix: () => {
140 | return getConfigProperty('API_ENDPOINTS_PREFIX');
141 | },
142 | getApiEndPoints: () => {
143 | return getConfigProperty('API_ENDPOINTS');
144 | },
145 | getDefaultApiEndPointPrefix: () => {
146 | const obj = getConfigProperty('API_ENDPOINTS');
147 | return 'string' === typeof obj
148 | ? module.exports.getApiEndPointPrefix() || '/api-proxy'
149 | : obj[DEFAULT_PREFIX_KEY];
150 | },
151 | getProxyDebugLevel: () => {
152 | return getConfigProperty('PROXY_DEBUG_LEVEL');
153 | },
154 | isHMREnabled: () => {
155 | const val = getConfigProperty('ENABLE_HMR');
156 | return module.exports.isDevMode() && isTrue(val);
157 | },
158 | isSSREnabled: () => {
159 | const val = getConfigProperty('ENABLE_SSR');
160 | return isTrue(val) && !module.exports.isHMREnabled();
161 | },
162 | isBundleAnalyzerEnabled: () => {
163 | const val = getConfigProperty('BUNDLE_ANALYZER');
164 | return isTrue(val);
165 | },
166 | isCustomAPIPrefix: () => {
167 | return !!getConfigProperty('CUSTOM_API_PREFIX');
168 | },
169 | getLogPath: () => {
170 | return getConfigProperty('LOG_PATH') || path.join(__dirname, '../logs');
171 | },
172 | getHttpProxy: () => {
173 | return getConfigProperty('HTTP_PROXY');
174 | },
175 | getSocksProxy: () => {
176 | return getConfigProperty('SOCKS_PROXY');
177 | },
178 | getDefaultApiEndPoint: () => {
179 | const obj = getConfigProperty('API_ENDPOINTS');
180 | return 'string' === typeof obj ? obj : obj[obj[DEFAULT_PREFIX_KEY]];
181 | },
182 | getDefaultApiEndPointKey: () => {
183 | return DEFAULT_PREFIX_KEY;
184 | },
185 | isInlineStyles: () => {
186 | return getConfigProperty('INLINE_STYLES');
187 | },
188 | isCSSModules: () => {
189 | return isTrue(getConfigProperty('CSS_MODULES'));
190 | },
191 | getHMRPort: () => {
192 | return getConfigProperty('HMR_PORT');
193 | },
194 | getOutputDir: () => {
195 | return getConfigProperty('OUTPUT_DIR') || 'build/app/';
196 | },
197 | isServingStaticIndex: () => {
198 | return isTrue(getConfigProperty('SERVE_STATIC_INDEX'));
199 | },
200 | isDynamicRoutes: () => {
201 | let dynamicRoutes = getConfigProperty('DYNAMIC_ROUTES');
202 | if (
203 | dynamicRoutes === null ||
204 | dynamicRoutes === undefined ||
205 | dynamicRoutes === ''
206 | ) {
207 | dynamicRoutes = false;
208 | }
209 | // console.log('dynamicRoutes: ', dynamicRoutes);
210 | return isTrue(dynamicRoutes);
211 | },
212 | getEnv: (key) => {
213 | return getConfigProperty(key);
214 | },
215 | };
216 |
217 | function isTrue(val) {
218 | return !!(val && (val === true || val === 'true' || val === '1'));
219 | }
220 |
--------------------------------------------------------------------------------
/config/utils.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const path = require('path');
4 | const globby = require('globby');
5 |
6 | const config = require('./env');
7 |
8 | const SLASH_REGEX = /[\\]+/g;
9 |
10 | const LOADER = {
11 | STYLE_LOADER: 'style-loader',
12 | CSS_LOADER: 'css-loader',
13 | POSTCSS_LOADER: 'postcss-loader',
14 | NULL_LOADER: 'null-loader',
15 | URL_LOADER: 'url-loader',
16 | FILE_LOADER: 'file-loader',
17 | BABEL_LOADER: 'babel-loader',
18 | };
19 |
20 | const ENTRY_NAME = {
21 | APP: 'main',
22 | VENDORS: 'vendors',
23 | RUNTIME: 'runtime',
24 | APP_JS: 'main.js',
25 | VENDORS_JS: 'vendors.js',
26 | RUNTIME_JS: 'runtime.js',
27 | };
28 |
29 | exports.ENTRY_NAME = ENTRY_NAME;
30 | exports.LOADER = LOADER;
31 |
32 | exports.getName = function (chunkName, ext, hashName, DEV_MODE) {
33 | return (
34 | chunkName +
35 | (DEV_MODE ? '.' : '-[' + (hashName ? hashName : 'contenthash') + ':9].') +
36 | ext
37 | );
38 | };
39 |
40 | exports.getResourceName = function (DEV_MODE) {
41 | return exports.getName('[path][name]', '[ext]', 'hash', DEV_MODE);
42 | };
43 |
44 | exports.getStyleLoaders = function (devMode, ...loaders) {
45 | const temp = [];
46 | for (let i = 0, length = loaders.length; i < length; i++) {
47 | let loader = loaders[i];
48 | const loaderOptions = loader.options || {};
49 | if (loader.loader) {
50 | loader = loader.loader;
51 | }
52 | let tempLoader = {
53 | loader,
54 | options: {
55 | sourceMap: devMode,
56 | ...loaderOptions,
57 | },
58 | };
59 | if (
60 | loader === LOADER.STYLE_LOADER ||
61 | loader === require('mini-css-extract-plugin').loader
62 | ) {
63 | delete tempLoader.options.sourceMap;
64 | }
65 | temp.push(tempLoader);
66 | }
67 | // console.log(temp);
68 | return temp;
69 | };
70 |
71 | exports.resolve = function (dir) {
72 | return path.join(__dirname, '..', dir);
73 | };
74 |
75 | exports.CONTENT_PATH = exports.APP_PATH = exports.resolve('src');
76 |
77 | exports.APP_BUILD_PATH = path.resolve(config.getOutputDir());
78 |
79 | exports.normalizePublicPath = function (publicPath) {
80 | return publicPath === '.' ? '' : publicPath;
81 | };
82 | exports.normalizeTailSlash = function (publicPath, withSlash) {
83 | if (publicPath.endsWith('/')) {
84 | publicPath = withSlash
85 | ? publicPath
86 | : publicPath.substring(0, publicPath.length - 1);
87 | } else {
88 | publicPath = withSlash ? publicPath + '/' : publicPath;
89 | }
90 | if (exports.isWindows()) {
91 | publicPath = exports.replaceBackwardSlash(publicPath);
92 | }
93 | return publicPath;
94 | };
95 | exports.normalizePath = function (publicPath, withSlash) {
96 | return exports.normalizeTailSlash(
97 | exports.normalizePublicPath(publicPath),
98 | withSlash,
99 | );
100 | };
101 | exports.getPublicPath = function () {
102 | return exports.normalizeTailSlash(
103 | exports.normalizePublicPath(
104 | path.join(config.getAppPrefix(), config.getStaticPrefix()),
105 | ),
106 | config.isPrefixTailSlashEnabled(),
107 | );
108 | };
109 | exports.isWindows = function () {
110 | return process.platform === 'win32';
111 | };
112 | exports.replaceBackwardSlash = function (str) {
113 | return str.replace(SLASH_REGEX, '/');
114 | };
115 | exports.getCSSLoaderExtract = function (devMode = false) {
116 | return {
117 | use: exports.getStyleLoaders(
118 | devMode,
119 | LOADER.CSS_LOADER,
120 | LOADER.POSTCSS_LOADER,
121 | ),
122 | fallback: LOADER.STYLE_LOADER,
123 | };
124 | };
125 |
126 | exports.getCSSLoader = function (
127 | modules,
128 | importLoaders = 1,
129 | localIdentName = '[name]_[local]-[hash:base64:5]',
130 | ) {
131 | if (!modules) {
132 | return LOADER.CSS_LOADER;
133 | }
134 | const temp = {
135 | loader: LOADER.CSS_LOADER,
136 | options: {
137 | modules,
138 | importLoaders,
139 | localIdentName,
140 | },
141 | };
142 | if (modules) {
143 | temp.options.camelCase = 'dashes';
144 | }
145 | return temp;
146 | };
147 |
148 | exports.getPostCSSLoader = function () {
149 | return {
150 | loader: LOADER.POSTCSS_LOADER,
151 | };
152 | };
153 |
154 | let MiniCssExtractPlugin;
155 |
156 | exports.getAllStyleRelatedLoaders = function (
157 | DEV_MODE,
158 | isHMREnabled,
159 | isCSSModules,
160 | cssModulesIndent,
161 | isSSR,
162 | isSSREnabled,
163 | ) {
164 | let styleLoader = LOADER.STYLE_LOADER;
165 | if (!DEV_MODE || isSSREnabled) {
166 | if (!MiniCssExtractPlugin) {
167 | MiniCssExtractPlugin = require('mini-css-extract-plugin');
168 | }
169 | styleLoader = MiniCssExtractPlugin.loader;
170 | } else {
171 | if (isHMREnabled) {
172 | styleLoader = LOADER.STYLE_LOADER;
173 | }
174 | }
175 | if (isSSR) {
176 | styleLoader = LOADER.NULL_LOADER;
177 | }
178 | return [
179 | {
180 | //just import css, without doing CSS MODULES stuff when it's from 3rd libs
181 | test: /\.css$/,
182 | include: /node_modules/,
183 | use: exports.getStyleLoaders(DEV_MODE, styleLoader, LOADER.CSS_LOADER),
184 | },
185 | {
186 | //app css code should check the CSS MODULES config
187 | test: /\.css$/,
188 | include: exports.resolve('src'),
189 | use: exports.getStyleLoaders(
190 | DEV_MODE,
191 | styleLoader,
192 | exports.getCSSLoader(isCSSModules, 1, cssModulesIndent),
193 | exports.getPostCSSLoader(),
194 | ),
195 | },
196 | ];
197 | };
198 |
199 | exports.getImageLoader = function (devMode, context) {
200 | return {
201 | test: /\.(png|jpe?g|gif|svg)$/,
202 | use: [
203 | {
204 | loader: LOADER.URL_LOADER,
205 | options: {
206 | context,
207 | name: exports.getResourceName(devMode),
208 | limit: 4096,
209 | },
210 | },
211 | // {
212 | // loader: 'image-webpack-loader',
213 | // options: {
214 | // bypassOnDebug: devMode,
215 | // },
216 | // },
217 | ],
218 | };
219 | };
220 | exports.getMediaLoader = function (devMode, context) {
221 | return {
222 | test: /\.(woff|woff2|eot|ttf|wav|mp3)$/,
223 | loader: LOADER.FILE_LOADER,
224 | options: {
225 | context,
226 | name: exports.getResourceName(devMode),
227 | limit: 5000,
228 | },
229 | };
230 | };
231 | exports.getBabelLoader = function (cache) {
232 | return {
233 | test: /\.jsx?$/,
234 | exclude: /node_modules/,
235 | use: {
236 | loader: LOADER.BABEL_LOADER,
237 | options: {
238 | cacheDirectory: cache,
239 | },
240 | },
241 | };
242 | };
243 | exports.getWebpackResolveConfig = function (customAlias = {}) {
244 | const appPath = exports.APP_PATH;
245 | return {
246 | modules: [appPath, 'node_modules'],
247 | extensions: ['.js', '.jsx', '.json'],
248 | alias: {
249 | src: appPath,
250 | modules: exports.resolve('src/modules'),
251 | components: exports.resolve('src/components'),
252 | assets: exports.resolve('src/assets'),
253 | style: exports.resolve('src/style'),
254 | pages: exports.resolve('src/pages'),
255 | ...customAlias,
256 | },
257 | };
258 | };
259 |
260 | exports.getFilesFromDir = function (dir = 'src/pages') {
261 | const paths = globby.sync(dir);
262 | // console.log('paths: ', paths);
263 | const modulesInfo = paths.map((path) => {
264 | const withoutRoot = path.slice(dir.length + 1);
265 | const parts = withoutRoot.split('/');
266 | const filename = parts[parts.length - 1];
267 | const filenameMatch = filename.match(/([\w_-]+)\..+$/);
268 | const moduleName = filenameMatch ? filenameMatch[1] : '';
269 | return {
270 | name: moduleName,
271 | filename,
272 | path: withoutRoot,
273 | routePath: [...parts.slice(0, parts.length - 1), moduleName].join('/'),
274 | fullPath: path,
275 | };
276 | });
277 | // console.log('modulesInfo: ', modulesInfo);
278 | return modulesInfo;
279 | };
280 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # koa-web-kit
2 |
3 | [](https://www.npmjs.com/package/koa-web-kit)
4 | [](https://travis-ci.org/JasonBoy/koa-web-kit)
5 | [](https://nodejs.org/)
6 | [](https://david-dm.org/JasonBoy/koa-web-kit)
7 | [](https://github.com/prettier/prettier)
8 |
9 | 🚀A Modern, Production-Ready, and Full-Stack Node Web Framework
10 |
11 | [Release Notes](https://github.com/JasonBoy/koa-web-kit/releases),
12 | [An Introduction for koa-web-kit](https://blog.lovemily.me/koa-web-kit-a-modern-production-ready-and-full-stack-node-web-framework/)
13 |
14 | > This readme is for v3(require node >= 16), if you need SASS/SCSS support, use [v2.x](https://github.com/JasonBoy/koa-web-kit/tree/v2.x)
15 |
16 | ## Features
17 |
18 | - ✨Built with all modern frameworks and libs, including Koa, React(like [Vue?](https://github.com/JasonBoy/vue-web-kit))...
19 | - 📦Get all the Node.JS full stack development experience out of the box
20 | - 🔥Hot Module Replacement support, and bundle size analyzer report
21 | - 📉Async/Await support for writing neat async code
22 | - 💖Great style solutions: [Styled-Components](https://www.styled-components.com), [TailwindCSS](https://tailwindcss.com/), CSS Modules
23 | - 🎉Simple API Proxy bundled, no complex extra reverse proxy configuration
24 | - 🌈Available for generating static site, also with SSR support
25 | - ⚡️Just one npm command to deploy your app to production
26 | - 🐳Docker support(dev and prod Dockerfile)
27 | - 👷Continuously Maintaining🍻
28 |
29 | ### Quick Start
30 |
31 | Get the [latest version](https://github.com/JasonBoy/koa-web-kit/releases), and go to your project root,
32 | Also available on [npm](https://www.npmjs.com/package/koa-web-kit).
33 |
34 | > Before start, copy the `config/app-config.js.sample` to `app-config.js`(to project root or `config` dir) for local dev configuration
35 |
36 | 1. Install Dependencies
37 |
38 | ```bash
39 | npm install
40 | ```
41 |
42 | 2. Start Dev Server
43 |
44 | `npm run dev` to start koa with HMR enabled, or
45 | `npm run dev:ssr` to start dev server with SSR enabled(yet HMR will be disabled for now)
46 |
47 | 3. Go to `http://localhost:3000` to view the default react page
48 |
49 | ### Project Structure
50 |
51 | - `__tests__` dir, for your tests
52 | - `mocks` dir, for your mock json server and other mock data
53 | - `api` dir, the API Proxy utility, also put your api urls in `api-config.js` for universal import across your app
54 | - `config` dir, all webpack build configs are put here, besides, some application-wide env configs getter utilities
55 | - `services` dir, some middleware here, default logger utility also located here
56 | - `routes` dir, put your koa app routes here
57 | - `src` dir, all your front-end assets, react components, modules, etc...
58 | - `utils` dir, utilities for both node.js and front-end
59 | - `views` dir, your view templates(*NOTE: when SSR is enabled, it will use the template literal string*)
60 | - *`build`* dir, all built assets for your project, git ignored
61 | - *`logs`* dir, logs are put here by default, git ignored
62 | - All other files in project root, which indicate their purposes clearly😀.
63 |
64 | ### Application Config and Environment Variables
65 |
66 | Every project has some configuration or environment variables to make it run differently in different environments,
67 | for koa-web-kit, it also provides different ways to configure your ENVs.
68 |
69 | #### app-config.js/app-config.js.sample
70 |
71 | The pre bundled file `config/app-config.js.sample` lists some common variables to use in the project, you should copy and rename it to `app-config.js` for your local config, both put it in `${project_root}` or the same `config` dir are supported:
72 | ```javascript
73 | module.exports = {
74 | //http server listen port
75 | "PORT": 3000,
76 | //most commonly used env
77 | "NODE_ENV": "development",
78 | //enable/disable built-in API Proxy
79 | "NODE_PROXY": true,
80 | //config the api proxy debug level, [0, 1, 2], 0 -> nothing, default: 1 -> simple, 2 -> verbose
81 | "PROXY_DEBUG_LEVEL": 1,
82 | //static endpoint, e.g CDN for your static assets
83 | "STATIC_ENDPOINT": "",
84 | //add a alternative prefix for your "STATIC_ENDPOINT"
85 | "STATIC_PREFIX": "",
86 | //add "/" to the end of your static url, if not existed
87 | "PREFIX_TRAILING_SLASH": true,
88 | //global prefix for your routes, e.g http://a.com/prefix/...your app routes,
89 | //like a github project site
90 | "APP_PREFIX": "",
91 | //customize build output dir, default ./build/app
92 | "OUTPUT_DIR": "",
93 | //if true, the "/prefix" below will be stripped, otherwise, the full pathname will be used for proxy
94 | "CUSTOM_API_PREFIX": true,
95 | //if enable HMR in dev mode, `npm run dev` will automatically enable this
96 | "ENABLE_HMR": true,
97 | //if need to enable Server Side Rendering, `npm run dev:ssr` will automatically enable this, HMR need to be disabled for now
98 | "ENABLE_SSR": false,
99 | //enable CSS Modules, should disable this when SSR is enabled for now
100 | "CSS_MODULES": false,
101 | //simple dynamic routes, based on file structure(like next.js)
102 | "DYNAMIC_ROUTES": false,
103 | //single endpoint string, multiple see below, type:
104 | "API_ENDPOINTS": 'http://127.0.0.1:3001',
105 | //API Proxies for multiple api endpoints with different prefix in router
106 | "API_ENDPOINTS": {
107 | //set a default prefix
108 | "defaultPrefix": "/prefix",
109 | //e.g http://127.0.0.1:3000/prefix/api/login -->proxy to--> http://127.0.0.1:3001/api/login
110 | "/prefix": "http://127.0.0.1:3001",
111 | "/prefix2": "http://127.0.0.1:3002",
112 | }
113 | }
114 | ```
115 |
116 | #### Environment Variables and Configuration
117 |
118 | All the variables in `app-config.js` can be set with Environment Variables, which have higher priority than `app-config.js`.
119 | e.g:
120 | `> NODE_ENV=production npm start`
121 | or
122 | ```bash
123 | export PORT=3001
124 | export NODE_ENV=production
125 | npm start
126 | ```
127 | You can also use `.env` file to config envs
128 |
129 | #### Default `config.default.[dev|prod].js` in `config` dir
130 |
131 | The project comes with default config files just like `app-config.js.sample`, which will be used if `app-config.js` above is not provided.
132 |
133 | > Priority: *Environment Variables* > .env > *app-config.js* > *config.default.[dev|prod].js*
134 |
135 | ### Logs
136 | The builtin `services/logger.js` provides some default log functionality for your app.
137 | By default, the manual log(calling like `logger.info()`) will be put into `./logs/app.log` file,
138 | and the http requests will be put into `./logs/requests.log`,
139 | both will also be logged to console.
140 | For more options, checkout the [pino](https://github.com/pinojs/pino).
141 |
142 | ```javascript
143 | //use the default logger
144 | const { logger, Logger } = require('../services/logger');
145 | logger.info('message');
146 | logger.error(new Error('test error'));
147 | //create custom logger, log into a different file
148 | const pino = require('pino');
149 | //the 2nd params for the constructor is for only for pino options
150 | const mylogger = new Logger({destination: pino.destination('./logs/my-log.log')}, {});
151 | mylogger.info('my log message');
152 | ```
153 |
154 | ### Production Deployment
155 |
156 | Deploy your app to production is extremely simple with only one npm script command, you can provide couple of options for different deployment phases(e.g: install, build, start server),
157 | [pm2](https://github.com/Unitech/pm2) inside is used as node process manager.
158 | > Global installation of PM2 is not required now, we will use the locally installed pm2, but if you want to use `pm2` cmd everywhere, you may still want to install it globally
159 |
160 |
161 | #### Usage
162 |
163 | `npm run deploy -- [skipInstall] [skipBuild] [skipServer]`
164 | The last three options are boolean values in `0`(or empty, false) and `1`(true).
165 |
166 | #### Examples:
167 |
168 | - `npm run deploy`: no options provided, defaults to do all the tasks.
169 | - `npm run deploy -- 1`: same as `npm run deploy:noinstall` as an alias, this will skip the `npm install --no-shrinkwrap`, and just go to build and start server.
170 | - `npm run deploy -- 1 0 1`: which will only build your assets
171 | - `npm run deploy -- 1 1 0`: which will just start node server, useful when all assets were built on a different machine.
172 |
173 | > You may need to create/update the `deploy.sh` to meet your own needs.
174 |
175 | ### Powered By
176 |
177 | 
178 |
179 | ### LICENSE
180 |
181 | MIT @ 2016-present [jason](http://blog.lovemily.me)
182 |
--------------------------------------------------------------------------------
/services/ServerRenderer.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 | const path = require('path');
3 | const { Transform } = require('stream');
4 | const { minify } = require('html-minifier');
5 |
6 | const config = require('../config/env');
7 | const { logger } = require('./logger');
8 |
9 | const Cache = require('./Cache');
10 |
11 | const isSSREnabled = config.isSSREnabled();
12 | const isHMREnabled = config.isHMREnabled();
13 | const DEV_MODE = config.isDevMode();
14 | const isCSSModules = config.isCSSModules();
15 |
16 | let indexHtml = '';
17 | let s;
18 |
19 | if (isSSREnabled) {
20 | if (isCSSModules) {
21 | logger.warn(
22 | 'When SSR is enabled, [CSS_MODULES] should be disabled for now, you can manually add plugin like "isomorphic-style-loader" to enable both SSR and CSS Modules',
23 | );
24 | }
25 | const SSR = require('../build/node/main');
26 | s = new SSR();
27 | } else if (!isHMREnabled) {
28 | indexHtml = readIndexHtml();
29 | }
30 |
31 | function readIndexHtml() {
32 | let ret = '';
33 | try {
34 | ret = fs.readFileSync(path.join(__dirname, `../build/app/index.html`), {
35 | encoding: 'utf-8',
36 | });
37 | } catch (e) {
38 | logger.warn('failed to read build/app/index.html...');
39 | }
40 | return ret;
41 | }
42 |
43 | /**
44 | * Renderer to render React SSR contents or just static html in koa app's routes
45 | * @param {Object<{
46 | * ssr: boolean,
47 | * stream: boolean,
48 | * cache: ./Cache,
49 | * }>} options
50 | * @param {boolean} options.ssr - enable/disable SSR manually, if provided, will use the app config
51 | * @param {boolean} options.stream - instance of custom Cache
52 | * @param {boolean|Cache} options.cache - enable/disable SSR cache manually, if provided with true, will use a default Cache, or you can provide your own Cache instance
53 | * @see ./Cache
54 | */
55 | class ServerRenderer {
56 | constructor(options = {}) {
57 | this.ssrEnabled = options.hasOwnProperty('ssr')
58 | ? !!options.ssr
59 | : isSSREnabled;
60 |
61 | this.cache = null;
62 | if (options.cache && options.cache instanceof Cache) {
63 | this.cache = options.cache;
64 | } else if (this.ssrEnabled && options.cache === true) {
65 | this.cache = new Cache(options);
66 | }
67 | this.cacheDisabled = !this.cache;
68 | this.streaming = !!options.stream || !!options.streaming;
69 | }
70 |
71 | /**
72 | * Check if SSR is enabled
73 | * @return {boolean|*}
74 | */
75 | isSSREnabled() {
76 | return this.ssrEnabled;
77 | }
78 |
79 | /**
80 | * Render static html content or Server side rendering components
81 | * @param {object} ctx koa ctx
82 | * @param {object=} data initial data
83 | * @param {Boolean=} streaming whether to user streaming api or not
84 | */
85 | render(ctx, data = {}, streaming) {
86 | if (!this.ssrEnabled) {
87 | ctx.body = this.genHtml('', {}, ctx);
88 | return;
89 | }
90 |
91 | this.renderSSR(ctx, data, streaming);
92 | }
93 |
94 | /**
95 | * Render content from cache
96 | * @param key {string} cache key
97 | * @param ctx {object} koa ctx object
98 | */
99 | renderFromCache(key, ctx) {
100 | logger.info('Rendering content from cache...');
101 | this.setHtmlContentType(ctx);
102 | ctx.body = this.cache.get(key);
103 | }
104 |
105 | /**
106 | * Server side rendering components
107 | * @param {object} ctx koa ctx
108 | * @param {object=} data initial data
109 | * @param {Boolean=} streaming whether to user streaming api or not
110 | */
111 | renderSSR(ctx, data = {}, streaming = this.streaming) {
112 | this.setHtmlContentType(ctx);
113 |
114 | if (!this.cacheDisabled && this.isCacheMatched(ctx.path)) {
115 | this.renderFromCache(ctx.path, ctx);
116 | return;
117 | }
118 |
119 | if (!streaming) {
120 | logger.info('-----> Use React SSR Sync API!');
121 | const rendered = s.render(ctx.url, data);
122 | rendered.initialData = data;
123 | ctx.body = this.genHtml(rendered.html, rendered, ctx);
124 | return;
125 | }
126 | logger.info('-----> Use React SSR Streaming API!');
127 | //use streaming api
128 | const rendered = s.renderWithStream(ctx.url, data);
129 | rendered.initialData = data;
130 | this.genHtmlStream(rendered.htmlStream, rendered, ctx);
131 | }
132 |
133 | /**
134 | * Generate static html from component
135 | * @param {string=} html SSRed html
136 | * @param {object=} extra extra info from SSR#render()
137 | * @param {object} ctx koa ctx object
138 | * @return {string} final html content
139 | */
140 | genHtml(html, extra = {}, ctx) {
141 | if (!this.ssrEnabled) {
142 | if (!indexHtml) {
143 | indexHtml = readIndexHtml();
144 | }
145 | if (!DEV_MODE) {
146 | return indexHtml;
147 | }
148 | indexHtml = readIndexHtml();
149 | return indexHtml;
150 | }
151 |
152 | let ret = `
153 |
154 |
155 |
156 |
157 |
158 | ${extra.title || 'React App'}
159 |
160 | ${extra.linkTags}
161 | ${extra.styleTags}
162 |
163 |
164 | ${html}
165 |
168 | ${extra.scriptTags}
169 |
170 |
171 | `;
172 | ret = this.minifyHtml(ret);
173 | this.cache && this.cache.set(ctx.path, ret);
174 | return ret;
175 | }
176 | /**
177 | * Generate html in stream
178 | * @param nodeStreamFromReact
179 | * @param extra
180 | * @param ctx
181 | */
182 | genHtmlStream(nodeStreamFromReact, extra = {}, ctx) {
183 | const res = ctx.res;
184 | ctx.status = 200;
185 | ctx.respond = false;
186 |
187 | let cacheStream = this.createCacheStream(ctx.path);
188 | cacheStream.pipe(res, { end: false });
189 |
190 | const before = `
191 |
192 |
193 |
194 |
195 |
196 | ${extra.title || 'React App'}
197 |
198 | ${extra.styleTags}
199 |
200 | `;
201 | cacheStream.write(before);
202 | // res.write(before);
203 |
204 | nodeStreamFromReact.pipe(
205 | // res,
206 | cacheStream,
207 | { end: false },
208 | );
209 |
210 | nodeStreamFromReact.on('end', () => {
211 | logger.info('nodeStreamFromReact end');
212 | logger.info('start streaming rest html content...');
213 | const after = `
214 |
217 | ${extra.extractor.getScriptTags()}
218 |
219 | `;
220 | // res.end(after);
221 |
222 | cacheStream.write(after);
223 | logger.info('streaming rest html content done!');
224 | res.end();
225 | cacheStream.end();
226 | });
227 | }
228 |
229 | /**
230 | *
231 | * @param {String} key for now it's ctx.url
232 | */
233 | createCacheStream(key) {
234 | logger.info(`Creating cache stream for ${key}`);
235 | const bufferedChunks = [];
236 | const self = this;
237 | return new Transform({
238 | // transform() is called with each chunk of data
239 | transform(data, enc, cb) {
240 | // We store the chunk of data (which is a Buffer) in memory
241 | bufferedChunks.push(data);
242 | // Then pass the data unchanged onwards to the next stream
243 | cb(null, data);
244 | },
245 |
246 | // flush() is called when everything is done
247 | flush(cb) {
248 | // We concatenate all the buffered chunks of HTML to get the full HTML
249 | // then cache it at "key"
250 | if (!self.cacheDisabled && self.cache) {
251 | self.cache.set(
252 | key,
253 | self.minifyHtml(Buffer.concat(bufferedChunks).toString()),
254 | );
255 | logger.info(`Cache stream for [${key}] finished!`);
256 | }
257 | cb();
258 | },
259 | });
260 | }
261 |
262 | /**
263 | * Check if provided key being found in the current cache pool
264 | * @param key
265 | * @return {boolean}
266 | */
267 | isCacheMatched(key) {
268 | if (this.cache && this.cache.has(key)) {
269 | logger.info(`Cache for [${key}] matched!`);
270 | return true;
271 | }
272 | return false;
273 | }
274 |
275 | /**
276 | * Set content-type for rendered html
277 | * @param ctx {object} koa ctx
278 | */
279 | setHtmlContentType(ctx) {
280 | ctx.set({
281 | 'Content-Type': 'text/html; charset=UTF-8',
282 | });
283 | }
284 |
285 | minifyHtml(html) {
286 | if (DEV_MODE) {
287 | return html;
288 | }
289 | return minify(html, { collapseWhitespace: true });
290 | }
291 | }
292 |
293 | module.exports = ServerRenderer;
294 |
--------------------------------------------------------------------------------
/services/HttpClient.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Proxy/Send requests to backend or other endpoints
3 | */
4 |
5 | const got = require('got');
6 | const tunnel = require('tunnel');
7 | const SocksProxyAgent = require('socks-proxy-agent');
8 | const { URL } = require('url');
9 | const util = require('util');
10 | const { logger } = require('./logger');
11 | const appConfig = require('../config/env');
12 | const { HTTP_METHOD } = require('./http-config');
13 | const isCustomAPIPrefix = appConfig.isCustomAPIPrefix();
14 | const httpProxy = appConfig.getHttpProxy();
15 | const socksProxy = appConfig.getSocksProxy();
16 | const debugLevel = appConfig.getProxyDebugLevel();
17 |
18 | const DEBUG_LEVEL = {
19 | NONE: 0,
20 | PLAIN: 1,
21 | VERBOSE: 2,
22 | };
23 |
24 | const LOG_LEVEL = {
25 | INFO: 'info',
26 | WARN: 'warn',
27 | ERROR: 'error',
28 | };
29 |
30 | /**
31 | * proxy global default got options
32 | */
33 | const defaultRequestOptions = {
34 | throwHttpErrors: false,
35 | };
36 |
37 | /**
38 | * Proxy for apis or other http requests
39 | */
40 | class HttpClient {
41 | /**
42 | *
43 | * @param {object=} options - Proxy instance options
44 | * {
45 | * endPoint: string, //endpoint for the instance, e.g: http://domain.com
46 | * prefix: string, //extra prefix for url path
47 | * debugLevel: number, //default based on the global app config
48 | * }
49 | * @param {object=} gotOptions - default options for "got" module
50 | */
51 | constructor(options = {}, gotOptions = {}) {
52 | this.endPoint = options.endPoint || '';
53 | this.endPointHost = '';
54 | this.endPointParsedUrl = {};
55 | if (this.endPoint) {
56 | this.endPointParsedUrl = new URL(this.endPoint);
57 | this.endPointHost = this.endPointParsedUrl.host;
58 | }
59 | this.useFormData = options.useForm === true;
60 | this.useJsonResponse = options.jsonResponse !== false;
61 | const clientBaseOptions = {
62 | prefixUrl: this.endPoint,
63 | };
64 | const agent = this._getAgent();
65 | if (agent) {
66 | clientBaseOptions.agent = agent;
67 | }
68 | this.options = options;
69 | this.got = got.extend(
70 | Object.assign(clientBaseOptions, defaultRequestOptions, gotOptions),
71 | );
72 | this.debugLevel = options.debugLevel || debugLevel;
73 | }
74 |
75 | /**
76 | * Get the http agent for proxying or requesting,
77 | * NOTE: when use https endpoint, you may need to set "NODE_TLS_REJECT_UNAUTHORIZED=0" node option
78 | * with self signed certificate to bypass the TLS check
79 | * @return {*}
80 | * @private
81 | */
82 | _getAgent() {
83 | let agent;
84 | //Simple proxy tunnel
85 | if (socksProxy) {
86 | const socksProtocol = 'socks:';
87 | agent = new SocksProxyAgent(
88 | String(socksProxy).startsWith(socksProtocol)
89 | ? socksProxy
90 | : `${socksProtocol}//${socksProxy}`,
91 | );
92 | agent = { http: agent };
93 | } else if (httpProxy) {
94 | const parsedUrl = new URL(httpProxy);
95 | const tunnelOptions = {
96 | proxy: {
97 | host: parsedUrl.hostname,
98 | port: parsedUrl.port,
99 | },
100 | };
101 | agent =
102 | this.endPointParsedUrl.protocol === 'https:'
103 | ? { https: tunnel.httpsOverHttp(tunnelOptions) }
104 | : { http: tunnel.httpOverHttp(tunnelOptions) };
105 | }
106 | return agent;
107 | }
108 |
109 | /**
110 | * Customize real options for destination
111 | * @param ctx
112 | * @param options
113 | * @return {object}
114 | */
115 | _prepareRequestOptions(ctx, options = { headers: {} }) {
116 | options.method = ctx.method;
117 | options.headers = Object.assign({}, ctx.headers, options.headers);
118 | return this._finalizeRequestOptions(ctx.url, options);
119 | }
120 |
121 | /**
122 | * Proxy koa http request to another endpoint
123 | * @param ctx - koa ctx
124 | * @param opts - request options
125 | * @return {Stream}
126 | */
127 | proxyRequest(ctx, opts = {}) {
128 | let requestStream;
129 | const { url, options } = this._prepareRequestOptions(ctx, opts);
130 | // console.log('opts: ', opts);
131 | requestStream = ctx.req.pipe(this.got.stream(url, options));
132 | if (this.debugLevel) {
133 | this.handleProxyEvents(requestStream);
134 | }
135 | requestStream.on('error', (error, body) => {
136 | this._log(null, error, LOG_LEVEL.ERROR);
137 | this._log(`response body: ${JSON.stringify(body)}`);
138 | });
139 | return requestStream;
140 | }
141 |
142 | handleProxyEvents(requestStream) {
143 | let chunks = [];
144 | let gotOptions = {};
145 | let gotResponse;
146 | requestStream.on('response', (response) => {
147 | gotResponse = response;
148 | const request = response.request;
149 | if (request) {
150 | gotOptions = request.options;
151 | this._log(
152 | `[${response.url}] request options: \n${util.inspect(
153 | request.options,
154 | )}`,
155 | );
156 | }
157 | this._log(
158 | `[${response.url}] response headers: \n${util.inspect(
159 | response.headers,
160 | )}`,
161 | );
162 | });
163 |
164 | if (this.debugLevel > DEBUG_LEVEL.PLAIN) {
165 | requestStream.on('data', (chunk) => {
166 | chunks.push(chunk);
167 | });
168 | requestStream.on('end', () => {
169 | const ret = Buffer.concat(chunks);
170 | const type = gotResponse.headers['content-type'];
171 | if (this._isPlainTextBody(type)) {
172 | this._log(
173 | `[${gotOptions.method}][${
174 | gotResponse.url
175 | }] response body: ${ret.toString()}`,
176 | );
177 | } else {
178 | this._log(
179 | `[${gotOptions.method}][${gotResponse.url}] response body[${type}] length: ${ret.length}`,
180 | );
181 | }
182 | });
183 | }
184 | }
185 |
186 | /**
187 | * Send http requests
188 | * @param requestUrl
189 | * @param opts
190 | * @return {Promise}
191 | */
192 | async sendRequest(requestUrl, opts = {}) {
193 | const { url, options } = this._finalizeRequestOptions(requestUrl, opts);
194 | // console.log('opts: ', opts);
195 | let ret = {};
196 | try {
197 | if (this.useJsonResponse) {
198 | ret = await this.got(url, options).json();
199 | } else {
200 | const response = await this.got(url, opts);
201 | ret = response.body;
202 | }
203 | } catch (err) {
204 | this._log(null, err, LOG_LEVEL.ERROR);
205 | return Promise.reject(err);
206 | }
207 | return Promise.resolve(ret);
208 | }
209 |
210 | /**
211 | * Proxy logger
212 | * @param {*=} msg - log message
213 | * @param {Error=} error - the Error instance
214 | * @param {string=} level - log level
215 | */
216 | _log(msg, error, level = LOG_LEVEL.INFO) {
217 | msg && logger[level](msg);
218 | error && logger[level](error.stack);
219 | }
220 |
221 | /**
222 | * Throw an exception
223 | * @param msg
224 | */
225 | _exception(msg) {
226 | throw new Error(msg);
227 | }
228 |
229 | get(url, query, options = {}) {
230 | options.method = HTTP_METHOD.GET;
231 | if (query) {
232 | options.searchParams = query;
233 | }
234 | return this.sendRequest(url, options);
235 | }
236 |
237 | post(url, body, options = {}) {
238 | return this.sendRequestWithBody(url, body, HTTP_METHOD.POST, options);
239 | }
240 |
241 | put(url, body, options = {}) {
242 | return this.sendRequestWithBody(url, body, HTTP_METHOD.PUT, options);
243 | }
244 |
245 | patch(url, body, options = {}) {
246 | return this.sendRequestWithBody(url, body, HTTP_METHOD.PATCH, options);
247 | }
248 |
249 | delete(url, options = {}) {
250 | options.method = HTTP_METHOD.DELETE;
251 | return this.sendRequest(url, options);
252 | }
253 |
254 | sendRequestWithBody(url, body, method, options = {}) {
255 | this._normalizeBodyContentType(body, options);
256 | options.method = method;
257 | return this.sendRequest(url, options);
258 | }
259 |
260 | /**
261 | * Normalize body for the upcoming request
262 | * @param data - body data
263 | * @param options - to which the body data will be attached
264 | * @return {object}
265 | */
266 | _normalizeBodyContentType(data, options = {}) {
267 | if (this.useFormData) {
268 | options.form = data;
269 | options.json = undefined;
270 | } else {
271 | options.json = data;
272 | options.form = undefined;
273 | }
274 | // options.body = data;
275 | return options;
276 | }
277 |
278 | /**
279 | * Get final request options
280 | * @param url
281 | * @param options - custom options
282 | * @return {object<{url, options}>} final request options
283 | */
284 | _finalizeRequestOptions(url, options = {}) {
285 | let optionPrefix = options.prefix || this.options.prefix;
286 | if (typeof url === 'string' && isCustomAPIPrefix && optionPrefix) {
287 | url = url.replace(new RegExp(`^${optionPrefix}/?`), '');
288 | }
289 | if (!options.headers) {
290 | options.headers = {};
291 | }
292 | if (this.endPointHost) {
293 | options.headers.host = this.endPointHost;
294 | }
295 | return {
296 | url,
297 | options,
298 | };
299 | }
300 |
301 | _isPlainTextBody(contentType) {
302 | if (!contentType) return true;
303 | return !!(
304 | contentType.startsWith('text/') ||
305 | contentType.startsWith('application/json')
306 | );
307 | }
308 | }
309 |
310 | exports.HttpClient = HttpClient;
311 |
--------------------------------------------------------------------------------
/__tests__/server/koa.router.js:
--------------------------------------------------------------------------------
1 | const shell = require('shelljs');
2 | const path = require('path');
3 | const fs = require('fs');
4 | const util = require('util');
5 | const supertest = require('supertest');
6 | const cheerio = require('cheerio');
7 | const makeDir = require('make-dir');
8 | const getPort = require('get-port');
9 | makeDir.sync(path.join(__dirname, '../../build'));
10 | const { startJSONServer, XAccessToken } = require('../../mocks/server');
11 | const { genMD5 } = require('../../utils/hash');
12 | const testConfig = {
13 | NODE_ENV: 'development',
14 | NODE_PROXY: true,
15 | HTTP_PROXY: '',
16 | PROXY_DEBUG_LEVEL: 0,
17 | STATIC_ENDPOINT: '',
18 | LOG_PATH: '',
19 | STATIC_PREFIX: '/static',
20 | PREFIX_TRAILING_SLASH: true,
21 | APP_PREFIX: '',
22 | CUSTOM_API_PREFIX: true,
23 | ENABLE_HMR: false,
24 | ENABLE_SSR: false,
25 | INLINE_STYLES: false,
26 | CSS_MODULES: false,
27 | HMR_PORT: 12333,
28 | API_ENDPOINTS: {
29 | defaultPrefix: '/api-proxy',
30 | '/api-proxy': 'http://127.0.0.1:3001',
31 | '/api-proxy2': 'http://127.0.0.1:3002',
32 | },
33 | };
34 |
35 | let jsonServerPort;
36 | let server2Port;
37 | let config;
38 | let app;
39 | let endPoints;
40 | let defaultEndpointKey;
41 | let defaultPrefix;
42 | const configPath = path.join(__dirname, '../../build/app-config.js');
43 | process.env.NODE_CONFIG_PATH = configPath;
44 |
45 | beforeAll(async () => {
46 | jsonServerPort = await getPort();
47 | server2Port = await getPort();
48 | testConfig.API_ENDPOINTS = {
49 | defaultPrefix: '/api-proxy',
50 | '/api-proxy': `http://127.0.0.1:${jsonServerPort}`,
51 | '/api-proxy2': `http://127.0.0.1:${server2Port}`,
52 | };
53 |
54 | fs.writeFileSync(configPath, `module.exports=${util.inspect(testConfig)}`);
55 | console.log('building assets...');
56 | shell.exec(`NODE_CONFIG_PATH=${configPath} npm run build:dev`, {
57 | silent: true,
58 | });
59 | config = require('../../config/env');
60 | app = require('../../app');
61 | endPoints = config.getApiEndPoints('API_ENDPOINTS');
62 | defaultEndpointKey = config.getDefaultApiEndPointKey();
63 | defaultPrefix = endPoints[defaultEndpointKey];
64 | });
65 |
66 | describe('normal routes', () => {
67 | let server;
68 | beforeAll(async () => {
69 | const koaApp = await app.create();
70 | // server = supertest(koaApp.callback());
71 | // server = supertest.agent(app.listen(koaApp));
72 | server = supertest.agent(koaApp.callback());
73 | });
74 |
75 | /*afterAll(async () => {
76 | await wait(500);
77 | return new Promise((resolve) => {
78 | server.app.close(() => {
79 | console.log('server closed');
80 | resolve();
81 | });
82 | });
83 | });*/
84 |
85 | test('get home page', async () => {
86 | // const response = await supertest(requestHandler).get('/');
87 | const response = await server.get('/');
88 | const $ = cheerio.load(response.text);
89 | expect(response.status).toEqual(200);
90 | expect(response.header['cache-control']).toBe('no-cache');
91 | expect($('#app').length).toBe(1);
92 | });
93 |
94 | test('multipart upload', async () => {
95 | const response = await server
96 | .post('/upload')
97 | .attach('image', path.join(__dirname, '../../src/assets/static/logo.svg'))
98 | .field({
99 | name: 'jason',
100 | });
101 | expect(response.status).toEqual(200);
102 | expect(response.body).toHaveProperty('body.name');
103 | expect(response.body).toHaveProperty('files.image');
104 | });
105 |
106 | /*test('get github branches', async () => {
107 | // const response = await supertest(requestHandler).get('/github');
108 | const response = await server.get('/github');
109 | const $ = cheerio.load(response.text);
110 | const branches = $('#app .github-wrapper > ul > li');
111 | // console.log('branches.length: ', branches.length);
112 | expect(branches.length).toBeGreaterThan(0);
113 | });*/
114 | });
115 |
116 | describe('request proxying', () => {
117 | let server;
118 | // let proxyServer2;
119 | // let jsonServer;
120 | let profileUrl;
121 | const newPostId = String(Date.now());
122 | beforeAll(async () => {
123 | await startJSONServer(jsonServerPort);
124 | const apps = await Promise.all([app.create(), app.create()]);
125 | server = supertest.agent(apps[0].callback());
126 | supertest.agent(app.listen(apps[1], server2Port));
127 | profileUrl = `${defaultPrefix}/profile`;
128 | });
129 |
130 | /*afterAll(done => {
131 | console.log('closing jsonServer...');
132 | jsonServer.close(() => {
133 | console.log('close jsonServer done');
134 | done();
135 | });
136 | });*/
137 |
138 | test('check passed headers and returned headers', async () => {
139 | const response = await server
140 | .get(profileUrl)
141 | .set(XAccessToken, 'some_token');
142 | // console.log('response.header: ', response.header);
143 | expect(response.header).toHaveProperty(
144 | `${XAccessToken}-back`,
145 | 'some_token',
146 | );
147 | expect(response.header).toHaveProperty('x-powered-by', 'Express');
148 | });
149 |
150 | test('get profile', async () => {
151 | const response = await server.get(profileUrl);
152 | // console.log('response.body: ', response.body);
153 | // console.log('response.header: ', response.header);
154 | expect(response.status).toEqual(200);
155 | expect(response.body).toHaveProperty('name', 'jason');
156 | });
157 |
158 | test('get posts', async () => {
159 | const response = await server
160 | .get(`${defaultPrefix}/posts`)
161 | .query({ id: 1 });
162 | expect(response.body.length).toEqual(1);
163 | expect(response.body[0]).toHaveProperty('id', 1);
164 | });
165 |
166 | test('post a post', async () => {
167 | const response = await server.post(`${defaultPrefix}/posts`).send({
168 | id: newPostId,
169 | title: `koa-json-server_${Date.now()}`,
170 | author: 'jason2',
171 | });
172 | const response2 = await server
173 | .get(`${defaultPrefix}/posts`)
174 | .query({ id: newPostId });
175 | expect(response.status).toEqual(201);
176 | expect(response.ok).toEqual(true);
177 | expect(response2.body[0]).toHaveProperty('id', newPostId);
178 | });
179 | test('put a post', async () => {
180 | const response = await server
181 | .put(`${defaultPrefix}/posts/${newPostId}`)
182 | .send({
183 | title: `json-server_${Date.now()}`,
184 | author: 'jason',
185 | });
186 | const response2 = await server
187 | .get(`${defaultPrefix}/posts`)
188 | .query({ id: newPostId });
189 | expect(response.ok).toEqual(true);
190 | expect(response2.body[0]).toHaveProperty('author', 'jason');
191 | });
192 | test('patch a post', async () => {
193 | const response = await server
194 | .patch(`${defaultPrefix}/posts/${newPostId}`)
195 | .send({
196 | author: 'jason2',
197 | });
198 | const response2 = await server
199 | .get(`${defaultPrefix}/posts`)
200 | .query({ id: newPostId });
201 | expect(response.ok).toEqual(true);
202 | expect(response2.body[0]).toHaveProperty('author', 'jason2');
203 | });
204 | test('delete a post', async () => {
205 | const response = await server.delete(`${defaultPrefix}/posts/${newPostId}`);
206 | const response2 = await server
207 | .get(`${defaultPrefix}/posts`)
208 | .query({ id: newPostId });
209 | expect(response.ok).toEqual(true);
210 | expect(response2.body).toHaveLength(0);
211 | });
212 |
213 | /**
214 | * app-config.js config:
215 | * API_ENDPOINTS: {
216 | defaultPrefix: '/api-proxy',
217 | '/api-proxy': 'http://127.0.0.1:3001',
218 | '/api-proxy2': 'http://127.0.0.1:3002',
219 | },
220 | */
221 | test('upload proxying', async () => {
222 | const response = await server
223 | .post('/api-proxy2/upload?a=b')
224 | .attach('image', path.join(__dirname, '../../src/assets/static/logo.svg'))
225 | .field({
226 | name: 'jason',
227 | });
228 | expect(response.status).toEqual(200);
229 | expect(response.body).toHaveProperty('body.name');
230 | expect(response.body).toHaveProperty('files.image');
231 | });
232 | test('download proxying', async () => {
233 | const fileBuffer = fs.readFileSync(
234 | path.join(__dirname, '../../build/app/assets/static/favicon.ico'),
235 | );
236 | const originalFileHash = genMD5(fileBuffer);
237 | return new Promise((resolve, reject) => {
238 | const writePath = path.join(__dirname, '../../build/favicon.ico');
239 | const writeStream = fs.createWriteStream(writePath);
240 | writeStream.on('finish', () => {
241 | const downloadHash = genMD5(fs.readFileSync(writePath));
242 | expect(downloadHash).toBe(originalFileHash);
243 | resolve();
244 | });
245 | writeStream.on('error', (err) => {
246 | reject(err);
247 | });
248 | server
249 | .get('/api-proxy2/static/assets/static/favicon.ico')
250 | .pipe(writeStream);
251 | });
252 | });
253 |
254 | test('proxying 400 response', async () => {
255 | const response = await server.get('/api-proxy2/400');
256 | expect(response.status).toEqual(400);
257 | expect(response.body).toHaveProperty('msg', '400');
258 | });
259 |
260 | test('proxying 400 response with post', async () => {
261 | const response = await server
262 | .post('/api-proxy2/400')
263 | .send({ name: 'jason' });
264 | expect(response.status).toEqual(400);
265 | expect(response.body).toHaveProperty('msg', '400');
266 | expect(response.body.data).toEqual({ name: 'jason' });
267 | });
268 |
269 | test('proxying 500 response', async () => {
270 | const response = await server.get('/api-proxy2/500');
271 | expect(response.status).toEqual(500);
272 | });
273 | });
274 |
--------------------------------------------------------------------------------
/src/modules/Request.js:
--------------------------------------------------------------------------------
1 | import isEmpty from 'lodash.isempty';
2 | import {
3 | api,
4 | formatRestfulUrl,
5 | numberOfRestParams,
6 | } from '../../api/api-config';
7 | import {
8 | HEADER,
9 | BODY_TYPE,
10 | isJSONResponse,
11 | HTTP_METHOD,
12 | } from '../../services/http-config';
13 | import env from './env';
14 |
15 | const CONTENT_TYPE_JSON = BODY_TYPE.JSON;
16 | const CONTENT_TYPE_FORM_URL_ENCODED = BODY_TYPE.FORM_URL_ENCODED;
17 |
18 | const defaultOptions = {
19 | credentials: 'same-origin',
20 | };
21 |
22 | function jsonResponseHandler(data) {
23 | return Promise.resolve(data);
24 | }
25 |
26 | /**
27 | * Send api requests
28 | * @param {object<{
29 | * noPrefix: boolean,
30 | * apiPrefix: string,
31 | * form: boolean
32 | * }>} options - you can provide other options, which will be passed to fetch api
33 | * @param {boolean} options.noPrefix - if there is prefix for the instance
34 | * @param {string} options.apiPrefix - prefix for the instance if "noPrefix" is false
35 | * @param {boolean} options.form - if true, body is "x-www-form-urlencoded", otherwise "json"
36 | * @return {Request}
37 | */
38 | class Request {
39 | constructor(options = {}) {
40 | if (!(this instanceof Request)) {
41 | return new Request(options);
42 | }
43 |
44 | this.jsonResponseHandler = jsonResponseHandler.bind(this);
45 |
46 | //no api prefix for the instance
47 | this.noPrefix = !!options.noPrefix;
48 | //set default api prefix
49 | this.apiPrefix = options.apiPrefix || (this.noPrefix ? '' : env.apiPrefix);
50 |
51 | const ops = { ...defaultOptions };
52 |
53 | this.formURLEncoded = !!options.form;
54 |
55 | ops.headers = {
56 | [HEADER.CONTENT_TYPE]: this.formURLEncoded
57 | ? CONTENT_TYPE_FORM_URL_ENCODED
58 | : CONTENT_TYPE_JSON,
59 | };
60 |
61 | if (!isEmpty(options) && !isEmpty(options.headers)) {
62 | ops.headers = Object.assign({}, ops.headers, options.headers);
63 | delete options.headers;
64 | }
65 | //set custom fetch options for the instance
66 | this.options = Object.assign(ops, options);
67 | }
68 |
69 | static isPlainUrl(url) {
70 | return 'string' === typeof url;
71 | }
72 |
73 | /**
74 | * Send request now
75 | * @param {string|object} pathname - if provided object, see demo in "api-config.js"
76 | * @param {object} options
77 | * {
78 | * noPrefix: true, //if there is prefix for this single request, default based on the instance's "noPrefix"
79 | * qs: {}, //extra query string for the request
80 | * restParams: [Array|Object], //params in pathname, @see formatRestfulUrl
81 | * ...fetch_api_related_options,
82 | * }
83 | * @see https://github.com/github/fetch
84 | * @see https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch
85 | * @return {Promise}
86 | */
87 | async sendRequest(pathname, options = {}) {
88 | let url = pathname;
89 | url = Request.isPlainUrl(url) ? url : url.path;
90 | const originalUrl = url;
91 | //normalize rest params
92 | url = this.normalizeRestfulParams(url, options);
93 | //normalize prefix
94 | if (!options.noPrefix && !this.noPrefix) {
95 | url = this.getUrlWithPrefix(url);
96 | options.hasOwnProperty('noPrefix') && (options.noPrefix = undefined);
97 | }
98 | //normalize query string
99 | if (!isEmpty(options.qs)) {
100 | url = this.addQueryString(url, options.qs, undefined, false);
101 | }
102 | //normalize headers
103 | const headers = {};
104 | const defaultHeaders = this.options.headers;
105 | Object.assign(headers, defaultHeaders, options.headers);
106 | options.headers = undefined;
107 | const apiOptions = Object.assign({}, this.options, options, { headers });
108 | if (apiOptions.multipart) {
109 | delete apiOptions.headers[HEADER.CONTENT_TYPE];
110 | }
111 | const response = await fetch(url, apiOptions);
112 | if (!response.ok) {
113 | console.error(
114 | `[koa-web-kit]:[API-ERROR]-[${response.status}]-[${originalUrl}]`,
115 | );
116 | return Promise.reject(response);
117 | }
118 | if (response.status === 204) {
119 | return Promise.resolve();
120 | }
121 | if (isJSONResponse(response)) {
122 | return response
123 | .json()
124 | .catch((err) => {
125 | console.error(err);
126 | return {};
127 | })
128 | .then((data) => this.jsonResponseHandler(data, apiOptions));
129 | }
130 | return Promise.resolve(response);
131 | }
132 |
133 | /**
134 | * Add query to the current url
135 | * @param {string} url - current url
136 | * @param {object} query - query object which will be added
137 | * @param {string=} baseUrl - baseUrl
138 | * @param {boolean=} noHost - return the url without the host, default true
139 | * @return {string} - new url string
140 | */
141 | addQueryString(url, query, baseUrl = location.origin, noHost = true) {
142 | if (isEmpty(query)) return url;
143 | const obj = new URL(url, baseUrl);
144 | for (const key of Object.keys(query)) {
145 | obj.searchParams.append(key, query[key]);
146 | }
147 | if (!noHost) {
148 | return obj.toString();
149 | }
150 |
151 | return `${obj.pathname}${obj.search}${obj.hash}`;
152 | }
153 |
154 | /**
155 | * Get method
156 | * @param {string|object} url api url config, when in object:
157 | * ```
158 | * {
159 | * path: 'url',
160 | * prefix: '/proxy-1', //prefix for the url
161 | * }
162 | * ```
163 | * @param {object=} querystring - query strings in object
164 | * @param {object=} options
165 | * @return {Promise}
166 | */
167 | get(url, querystring = {}, options = {}) {
168 | const getOptions = Object.assign(
169 | {
170 | method: HTTP_METHOD.GET,
171 | qs: querystring,
172 | },
173 | options,
174 | );
175 | return this.sendRequest(url, getOptions);
176 | }
177 |
178 | post(url, data = {}, options = {}) {
179 | return this.sendRequestWithBody(url, data, HTTP_METHOD.POST, options);
180 | }
181 |
182 | put(url, data = {}, options = {}) {
183 | return this.sendRequestWithBody(url, data, HTTP_METHOD.PUT, options);
184 | }
185 |
186 | patch(url, data = {}, options = {}) {
187 | return this.sendRequestWithBody(url, data, HTTP_METHOD.PATCH, options);
188 | }
189 |
190 | delete(url, querystring = {}, options = {}) {
191 | const getOptions = Object.assign(
192 | {
193 | method: HTTP_METHOD.DELETE,
194 | qs: querystring,
195 | },
196 | options,
197 | );
198 | return this.sendRequest(url, getOptions);
199 | }
200 |
201 | /**
202 | * Send request with http body, will normalize body before sending
203 | * @param url
204 | * @param body
205 | * @param method
206 | * @param options
207 | * @returns {Promise}
208 | */
209 | sendRequestWithBody(url, body, method, options) {
210 | const sendOptions = Object.assign(
211 | {
212 | method,
213 | body: this.normalizeBodyData(body),
214 | },
215 | options,
216 | );
217 | return this.sendRequest(url, sendOptions);
218 | }
219 |
220 | /**
221 | * Upload files
222 | * @param {string} url - upload url
223 | * @param {Array|Object} files - files in array or in object where key is the file field name
224 | * @param {Object} fields - extra body data
225 | * @param {Object} options - other request options
226 | * @return {Promise}
227 | */
228 | upload(url, files, fields, options = {}) {
229 | const formData = new FormData();
230 | if (!isEmpty(fields)) {
231 | for (const key of Object.keys(fields)) {
232 | formData.append(key, fields[key]);
233 | }
234 | }
235 | if (Array.isArray(files)) {
236 | const fileFieldName = options.fileFieldName || 'files';
237 | let i = 0;
238 | for (; i < files.length; i++) {
239 | formData.append(fileFieldName, files[i]);
240 | }
241 | } else {
242 | for (const key of Object.keys(files)) {
243 | let value = files[key];
244 | if (!Array.isArray(value)) {
245 | value = [value];
246 | }
247 | value.forEach((v) => formData.append(key, v));
248 | }
249 | }
250 |
251 | const apiOptions = Object.assign(
252 | {
253 | method: HTTP_METHOD.POST,
254 | body: formData,
255 | multipart: true,
256 | },
257 | defaultOptions,
258 | options,
259 | );
260 | return this.sendRequest(url, apiOptions);
261 | }
262 |
263 | /**
264 | * Get the query from url
265 | * @param url
266 | * @param baseUrl
267 | * @return {object} - parsed query string
268 | */
269 | getQueryString(url = location.href, baseUrl = location.origin) {
270 | const obj = new URL(url, baseUrl);
271 | const query = {};
272 | for (const [key, value] of obj.searchParams.entries()) {
273 | if (query.hasOwnProperty(key)) {
274 | query[key] = [].concat(query[key], value);
275 | } else {
276 | query[key] = value;
277 | }
278 | }
279 | return query;
280 | }
281 |
282 | /**
283 | * Remove the hash from url
284 | * @param url
285 | * @param baseUrl
286 | * @return {string} - new url without the hash
287 | */
288 | stripUrlHash(url, baseUrl = location.origin) {
289 | const u = new URL(url, baseUrl);
290 | u.hash = '';
291 | return u.toString();
292 | }
293 |
294 | normalizeRestfulParams(url, options) {
295 | const restLength = numberOfRestParams(url);
296 | const restParams = !isEmpty(options.restParams) ? options.restParams : [];
297 | if (restLength > 0) {
298 | url = formatRestfulUrl(url, restParams);
299 | }
300 | return url;
301 | }
302 |
303 | formatFormUrlEncodeData(data) {
304 | const params = new URLSearchParams();
305 | for (let key in data) {
306 | if (data.hasOwnProperty(key)) {
307 | params.append(key, data[key]);
308 | }
309 | }
310 | return params.toString();
311 | }
312 |
313 | normalizeBodyData(data = {}) {
314 | return this.formURLEncoded
315 | ? this.formatFormUrlEncodeData(data)
316 | : JSON.stringify(data);
317 | }
318 |
319 | getUrlWithPrefix(urlConfig) {
320 | let ret = '';
321 | const plain = Request.isPlainUrl(urlConfig);
322 | if (plain) {
323 | ret += this.apiPrefix;
324 | } else {
325 | ret += urlConfig.prefix || this.apiPrefix;
326 | }
327 | ret += plain ? urlConfig : urlConfig.path;
328 | return ret;
329 | }
330 | }
331 |
332 | export { Request, api, formatRestfulUrl };
333 | export default new Request();
334 |
--------------------------------------------------------------------------------