├── .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 | 2 | React Logo 3 | 4 | 5 | 6 | 7 | 8 | 9 | 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 |
29 | Something went wrong!!! 30 | 31 | Reload 32 | 33 |
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 | logo 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 | 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 | 21 | 33 | 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 | logo 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 | 6 | 7 | 8 | 9 | 10 | 11 | 14 | 15 | 16 | 17 | 18 | 19 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 31 | 32 | 33 | 34 | 35 | 36 | 39 | 40 | 41 | 42 | 43 | 45 | 47 | 48 | 51 | 54 | 56 | 57 | 59 | 63 | 64 | 65 | 66 | 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 | [![npm](https://img.shields.io/npm/v/koa-web-kit.svg?style=flat-square)](https://www.npmjs.com/package/koa-web-kit) 4 | [![Building Status](https://img.shields.io/travis/JasonBoy/koa-web-kit.svg?style=flat-square)](https://travis-ci.org/JasonBoy/koa-web-kit) 5 | [![node](https://img.shields.io/node/v/koa-web-kit.svg?style=flat-square)](https://nodejs.org/) 6 | [![Dependency Status](https://img.shields.io/david/JasonBoy/koa-web-kit.svg?style=flat-square)](https://david-dm.org/JasonBoy/koa-web-kit) 7 | [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square)](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 | ![powered by jetbrains](https://raw.githubusercontent.com/JasonBoy/koa-web-kit/master/src/assets/static/jetbrains.svg) 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 | --------------------------------------------------------------------------------