├── .gitignore ├── lib ├── favicon.ico ├── cert.js ├── logger.js ├── anyproxy4-rule.js ├── ssi.js ├── qr-middleware.js ├── constants.js ├── utils.js ├── wx-middleware.js └── anyproxy-rule.js ├── test └── specs │ └── index.js ├── .editorconfig ├── .eslintrc.json ├── package.json ├── assets └── weex.html ├── bin └── index.js ├── README.md └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | tmp 4 | .idea 5 | -------------------------------------------------------------------------------- /lib/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hirokith/sonic/HEAD/lib/favicon.ico -------------------------------------------------------------------------------- /test/specs/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by 弘树 on 16/3/28. 3 | */ 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true, 4 | "es6": true 5 | }, 6 | "extends": "eslint:recommended", 7 | "rules": { 8 | "no-console": "off", 9 | "comma-dangle": [2, "only-multiline"], 10 | "no-new": 1 11 | }, 12 | "parserOptions": { 13 | "ecmaVersion": 2017 14 | } 15 | } -------------------------------------------------------------------------------- /lib/cert.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by 弘树 on 16/4/19. 3 | */ 4 | // create cert when you want to use https features 5 | // please manually trust this rootCA when it is the first time you run it 6 | "use strict"; 7 | 8 | const certMgr = require('anyproxy').utils.certMgr; 9 | const isRootCAFileExists = certMgr.isRootCAFileExists; 10 | 11 | if (isRootCAFileExists && !isRootCAFileExists()) { 12 | certMgr.generateRootCA(() => { 13 | console.log('Root certification generated.'); 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /lib/logger.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by 弘树 on 16/3/22. 3 | * Logger wrapper 4 | */ 5 | "use strict"; 6 | 7 | const _ = require('lodash'); 8 | const chalk = require('chalk'); 9 | 10 | const defaultLogger = { 11 | raw: (msg) => { 12 | console.log(msg); 13 | }, 14 | info: (msg) => { 15 | console.log(chalk.blue(`>> ${msg}`)); 16 | }, 17 | ok: (msg) => { 18 | console.log(chalk.green(`>> ${msg}`)); 19 | }, 20 | warn: (msg) => { 21 | console.log(chalk.yellow(`[!] ${msg}`)); 22 | }, 23 | error: (msg) => { 24 | console.log(chalk.red(`[!] ${msg}`)); 25 | }, 26 | verbose: {} 27 | }; 28 | 29 | Object.keys(defaultLogger).forEach(logType => { 30 | defaultLogger.verbose[logType] = msg => { 31 | if (process.argv.indexOf('--verbose') !== -1) { 32 | defaultLogger[logType](msg); 33 | } 34 | }; 35 | }); 36 | 37 | module.exports = (logger) => { 38 | return _.defaultsDeep(logger, defaultLogger); 39 | }; 40 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sonic-server", 3 | "version": "1.0.1", 4 | "description": "Front-end development env tool based on webpack & anyproxy", 5 | "main": "index.js", 6 | "bin": { 7 | "sonic": "bin/index.js" 8 | }, 9 | "engines": { 10 | "node": ">= 6.0" 11 | }, 12 | "scripts": { 13 | "test": "mocha" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git@github.com:dickeylth/sonic.git" 18 | }, 19 | "keywords": [ 20 | "front-end", 21 | "tool", 22 | "development", 23 | "debug" 24 | ], 25 | "author": { 26 | "name": "弘树", 27 | "email": "dickeylth@live.cn", 28 | "url": "http://dickeylth.github.io/" 29 | }, 30 | "license": "MIT", 31 | "dependencies": { 32 | "anyproxy": "^4.1.0", 33 | "chalk": "~1.1.1", 34 | "cheerio": "1.0.0-rc.3", 35 | "co": "~4.6.0", 36 | "express": "^4.15.2", 37 | "he": "^1.1.1", 38 | "hostile": "^1.3.2", 39 | "iconv-lite": "^0.4.24", 40 | "lodash": "~4.12.0", 41 | "minimist": "~1.2.0", 42 | "open": "0.0.5", 43 | "portfinder": "^1.0.20", 44 | "request": "^2.81.0", 45 | "serve-index": "^1.8.0", 46 | "shelljs": "^0.7.7", 47 | "ws": "^2.3.1", 48 | "yaqrcode": "^0.2.1" 49 | }, 50 | "devDependencies": { 51 | "eslint": "^3.19.0" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /lib/anyproxy4-rule.js: -------------------------------------------------------------------------------- 1 | const legacy = require('./anyproxy-rule'); 2 | 3 | module.exports = function(options) { 4 | const rule = legacy(options); 5 | 6 | function dealLocalResponse(req) { 7 | return new Promise(function(resolve) { 8 | rule.dealLocalResponse(req, {}, function(statusCode, header, body) { 9 | resolve({ 10 | statusCode, 11 | header, 12 | body 13 | }); 14 | }); 15 | }); 16 | } 17 | 18 | function replaceServerResDataAsync(req, res) { 19 | return new Promise(function(resolve) { 20 | res.headers = res.header; // 兼容 21 | rule.replaceServerResDataAsync(req, res, res.body, function(body) { 22 | resolve(Object.assign(Object.assign({}, res), { body })); 23 | }); 24 | }); 25 | } 26 | 27 | // 兼容 28 | function parseRequest(req) { 29 | req.requestOptions.headers.hostname = req.requestOptions.hostname; 30 | req.requestOptions.headers.host = req.requestOptions.headers.Host; 31 | req.headers = req.requestOptions.headers; 32 | req.connection = req._req.connection; 33 | return req; 34 | } 35 | 36 | return { 37 | summary: rule.summary(), 38 | 39 | *beforeDealHttpsRequest(req) { // eslint-disable-line 40 | // 兼容 41 | return rule.shouldInterceptHttpsReq({ 42 | headers: { 43 | host: req._req.headers.host, 44 | }, 45 | url: req._req.url 46 | }); 47 | }, 48 | 49 | *beforeSendRequest(req) { 50 | req = parseRequest(req); 51 | if (typeof options.modifyRequestObject === 'function') { 52 | req = options.modifyRequestObject(req); 53 | } 54 | req.requestOptions = rule.replaceRequestOption(req, req.requestOptions); 55 | req.requestOptions.headers = rule.replaceResponseHeader(req, {}, req.requestOptions.headers); 56 | 57 | if (rule.shouldUseLocalResponse(req)) { 58 | req.response = yield dealLocalResponse(req); 59 | } 60 | 61 | return req; 62 | }, 63 | 64 | *beforeSendResponse(req, res) { 65 | const response = yield replaceServerResDataAsync(parseRequest(req), res.response); 66 | return { response }; 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /lib/ssi.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by 弘树 on 16/6/3. 3 | */ 4 | "use strict"; 5 | 6 | const cheerio = require('cheerio'); 7 | const path = require('path'); 8 | const fs = require('fs'); 9 | const SSI_REG = /\#include\s*virtual=[\'|\"](.*)[\'|\"]/; 10 | 11 | module.exports = ($, commentNodes, fileAbsPath, fileOrigSource) => { 12 | const ssiNodes = commentNodes.filter(comNode => SSI_REG.test(comNode.data)); 13 | if (ssiNodes.length > 0) { 14 | const fileDirAbsPath = path.dirname(fileAbsPath); 15 | ssiNodes.forEach(ssiNode => { 16 | const ssiStr = ssiNode.data; 17 | const includeFilePath = ssiStr.match(SSI_REG)[1]; 18 | 19 | const fileResolvePath = path.resolve(path.dirname(fileAbsPath), includeFilePath); 20 | if (fs.existsSync(fileResolvePath)) { 21 | let ssiSegmentSource = fs.readFileSync(fileResolvePath, 'utf8'); 22 | const $$ = cheerio.load(ssiSegmentSource, { 23 | normalizeWhitespace: false, 24 | xmlMode: false, 25 | decodeEntities: false 26 | }); 27 | 28 | // 相对路径替换 29 | $$('script[src]').each((idx, scriptNode) => { 30 | scriptNode = $$(scriptNode); 31 | const scriptSrcPath = scriptNode.attr('src'); 32 | if (/^\./.test(scriptSrcPath)) { 33 | const scriptAbsPath = path.resolve(path.dirname(fileResolvePath), scriptSrcPath); 34 | // grunt.verbose.writeln('scriptAbsPath: ' + scriptAbsPath); 35 | const scriptNewRelPath = path.relative(fileDirAbsPath, scriptAbsPath); 36 | // grunt.verbose.writeln('scriptNewRelPath: ' + scriptNewRelPath); 37 | // 采用替换, 而不是直接修改 $$, 因为 SSI 区块可能不是完整的闭合标签 38 | ssiSegmentSource = ssiSegmentSource.replace(scriptSrcPath, scriptNewRelPath); 39 | } 40 | }); 41 | $$('link[href]').each((idx, styleNode) => { 42 | styleNode = $$(styleNode); 43 | const styleSrcPath = styleNode.attr('href'); 44 | if (/^\./.test(styleSrcPath)) { 45 | const styleAbsPath = path.resolve(path.dirname(fileResolvePath), styleSrcPath); 46 | // grunt.verbose.writeln('styleAbsPath: ' + styleAbsPath); 47 | const styleNewRelPath = path.relative(fileDirAbsPath, styleAbsPath); 48 | // grunt.verbose.writeln('styleNewRelPath: ' + styleNewRelPath); 49 | // 采用替换 50 | ssiSegmentSource = ssiSegmentSource.replace(styleSrcPath, styleNewRelPath); 51 | } 52 | }); 53 | // $(ssiNode).replaceWith(ssiSegmentSource); 54 | // console.log($(ssiNode)); 55 | fileOrigSource = fileOrigSource.replace(``, ssiSegmentSource); 56 | } else { 57 | ssiNode.data = `ERROR: SSI PATH ${fileResolvePath} not exist.`; 58 | } 59 | }); 60 | 61 | return cheerio.load(fileOrigSource, { 62 | normalizeWhitespace: false, 63 | xmlMode: false, 64 | decodeEntities: false 65 | }); 66 | } 67 | return $; 68 | }; 69 | -------------------------------------------------------------------------------- /lib/qr-middleware.js: -------------------------------------------------------------------------------- 1 | const pwd = process.cwd(); 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | const url = require('url'); 5 | const cheerio = require('cheerio'); 6 | const qrcode = require('yaqrcode'); 7 | const pkgPath = path.join(pwd, 'package.json'); 8 | const pkg = fs.existsSync(pkgPath) ? require(pkgPath) : {}; 9 | 10 | const injectHTML = (htmlSource, scanUrl, logger) => { 11 | logger.verbose.info('>> Injecting QRCode.'); 12 | 13 | const $ = cheerio.load(htmlSource, { 14 | normalizeWhitespace: false, 15 | xmlMode: false, 16 | decodeEntities: false 17 | }); 18 | 19 | let qrcodeLog = ''; 20 | 21 | try { 22 | qrcodeLog = `console.log("%c ", 23 | "padding:2px 80px 4px;" + 24 | "line-height:160px;background:url('" + "${qrcode(scanUrl)}" + "') no-repeat;" + 25 | "background-size:160px");` 26 | } catch (e) { 27 | qrcodeLog = `console.error("自动生成二维码失败,请手动拷贝以上链接去其他工具生成二维码");` 28 | } 29 | 30 | $('body').append(` 31 | 40 | `); 41 | 42 | return $.html(); 43 | }; 44 | 45 | function generateScanUrl(req, parsedReqUrl, serverHost) { 46 | const edithUrl = req.query.__edith_orig_url__; 47 | 48 | if (edithUrl) { 49 | parsedReqUrl = url.parse(edithUrl, true); 50 | } 51 | 52 | parsedReqUrl.search = null; // 设置为空,否则 format 时直接用老的 search 不用新的 query 对象 53 | parsedReqUrl.host = parsedReqUrl.host || req.host; 54 | parsedReqUrl.protocol = parsedReqUrl.protocol || req.protocol || 'http'; 55 | 56 | delete parsedReqUrl.query.__edith_orig_url__; 57 | 58 | if (pkg.flugy) { 59 | if (!parsedReqUrl.query.wbundle) { 60 | const parsedWbundleUrl = url.parse(parsedReqUrl.format(), true); 61 | parsedWbundleUrl.hash = ''; 62 | parsedWbundleUrl.query = {}; 63 | parsedWbundleUrl.search = null; 64 | parsedWbundleUrl.pathname = parsedWbundleUrl.pathname.replace(/\.html$/, pkg.lib === 'rax' ? '.web.js' : '.entry.js'); 65 | if (edithUrl) { 66 | if (!/\/clam\/share\//.test(parsedWbundleUrl.pathname)) { 67 | parsedWbundleUrl.pathname = parsedWbundleUrl.pathname.replace('/clam/', '/clam/share/') 68 | } 69 | } else { 70 | parsedWbundleUrl.host = serverHost; 71 | } 72 | parsedReqUrl.query.wbundle = parsedWbundleUrl.format(); 73 | } 74 | parsedReqUrl.query.un_flutter = true; 75 | } else if (edithUrl) { 76 | if (!/\/clam\/share\//.test(parsedReqUrl.pathname)) { 77 | parsedReqUrl.pathname = parsedReqUrl.pathname.replace('/clam/', '/clam/share/'); 78 | } 79 | } 80 | 81 | return parsedReqUrl.format(); 82 | } 83 | 84 | module.exports = (contentBase, logger, serverHost) => { 85 | return (req, res, next) => { 86 | const parsedReqUrl = url.parse(req.url, true); 87 | const reqPath = parsedReqUrl.pathname; 88 | const filePath = path.join(contentBase, reqPath); 89 | 90 | if (/\.html/.test(reqPath) && fs.existsSync(filePath)) { 91 | const htmlSource = fs.readFileSync(filePath, 'utf-8'); 92 | const scanUrl = generateScanUrl(req, parsedReqUrl, serverHost); 93 | res.send(injectHTML( 94 | htmlSource, 95 | scanUrl, 96 | logger) 97 | ); 98 | } else { 99 | next(); 100 | } 101 | }; 102 | } 103 | -------------------------------------------------------------------------------- /lib/constants.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by 弘树 on 16/3/2. 3 | */ 4 | 'use strict'; 5 | 6 | const path = require('path'); 7 | const fs = require('fs'); 8 | const PWD = process.cwd(); 9 | 10 | const USER_HOME_PATH = process.env[(process.platform == 'win32') ? 'USERPROFILE' : 'HOME']; 11 | const DEFAULT_USER_DIR = path.join(USER_HOME_PATH, '.clam-devserver-chrome'); 12 | let DEFAULT_BROWSER = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; 13 | const CHROME_CANARY_BROWSER = '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary'; 14 | 15 | // 优先使用 Google Chrome Canary 16 | if (fs.existsSync(CHROME_CANARY_BROWSER)) { 17 | DEFAULT_BROWSER = CHROME_CANARY_BROWSER; 18 | } 19 | 20 | const anyproxyCertDir = path.join(USER_HOME_PATH, '.anyproxy/certificates'); 21 | 22 | const Constants = { 23 | USER_HOME_PATH, 24 | DEFAULT_USER_DIR, 25 | DEFAULT_BROWSER, 26 | HTTPS: { 27 | CERT_DIR: anyproxyCertDir, 28 | CA: path.join(anyproxyCertDir, 'rootCA.crt') 29 | }, 30 | WV_SCRIPT: 'https://g.alicdn.com/mtb/lib-windvane/2.1.8/windvane.js', 31 | COMBO_SIGN: '??', 32 | COMBO_SEP: ',' 33 | }; 34 | 35 | Constants.defaultOptions = { 36 | 37 | // webpack 配置, 可为 webpack.config.js 路径或 webpack 配置对象 38 | webpackConfig: 'webpack.config.js', 39 | 40 | // 本地静态资源服务端口号 41 | serverPort: 8081, 42 | 43 | // 需要代理的 hosts 字符串数组 44 | hosts: [ 45 | 'dev.m.taobao.com', 46 | 'dev.wapa.taobao.com', 47 | 'dev.waptest.taobao.com' 48 | ], 49 | 50 | // 是否显示 webpack 编译进度 51 | progress: true, 52 | 53 | // 是否禁用控制台 log 输出 54 | silent: false, 55 | 56 | // 本地代理服务端口号 57 | proxyPort: 8080, 58 | 59 | // Anyproxy 的 web 请求监控页面端口号 60 | webPort: 8002, 61 | 62 | // Anyproxy 的 websocket 请求工作端口号 63 | socketPort: 8003, 64 | 65 | // weex 页面 reloader websocket 端口号 66 | weexPageReloadPort: 8082, 67 | 68 | // 是否自动注入 HMR 69 | injectHMR: true, 70 | 71 | // 注入 WindVane 脚本路径, 自动在 WindVane 容器下注入 windvane.js, 如不需要设置为 `false` 即可 72 | injectWV: Constants.WV_SCRIPT, 73 | 74 | // 是否切换到 https 75 | https: false, 76 | 77 | // 是否自动注入 CORS 响应头 78 | corsInject: false, 79 | 80 | // 内容根目录 81 | contentBase: PWD, 82 | 83 | // 是否仅启动静态资源服务, 而不基于 webpack-dev-server 84 | pureStatic: false, 85 | 86 | // 是否在浏览器自动打开 Url 87 | openBrowser: true, 88 | 89 | // 默认开启的路径 90 | openPath: '/', 91 | 92 | // 新起 Chrome 基于的用户目录绝对路径 93 | chromeUserDir: DEFAULT_USER_DIR, 94 | 95 | // 浏览器程序路径(mac 下 Chrome) 96 | browserApp: DEFAULT_BROWSER, 97 | 98 | // 要 mock 的请求 url 应该匹配的正则表达式 99 | mockRegExp: /api\.(waptest|wapa|m)\.taobao\.com/i, 100 | 101 | // 接口 mock 处理函数 102 | mockFunction: (requestUrl, response) => { 103 | 104 | const url = require('url'); 105 | 106 | let parsedReqUrl = url.parse(requestUrl, true); 107 | let params = parsedReqUrl.query; 108 | let responseBody = response.body; 109 | 110 | switch (params.api) { 111 | // case '...': 112 | // responseBody.test = 12345; 113 | // break; 114 | default: 115 | responseBody.default = true; 116 | break; 117 | } 118 | 119 | return response.body; 120 | }, 121 | 122 | // hosts 映射表, 域名 - IP 键值对 123 | hostsMap: {}, 124 | 125 | // html 拦截请求 URL 正则 126 | htmlInterceptReg: /$^/, 127 | 128 | // HTML 操作函数 129 | htmlModify: (reqUrl, reqHeaders, resHeaders, $, commentNodes, logger, cb) => { 130 | cb($.html()); 131 | }, 132 | 133 | // 二维码插件 134 | qrcodeMiddleWare: true, 135 | 136 | // webpack-dev-server stats options 137 | webpackStatsOption: {} 138 | }; 139 | 140 | module.exports = Constants; 141 | -------------------------------------------------------------------------------- /assets/weex.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Weex HTML5 6 | 7 | 8 | 9 | 10 | 17 | 18 | 21 | 22 | 23 | 24 | 25 |
26 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /bin/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * Created by 弘树 on 16/3/28. 5 | */ 6 | 7 | "use strict"; 8 | 9 | const fs = require('fs'); 10 | const path = require('path'); 11 | const argv = require('minimist')(process.argv.slice(2)); 12 | const merge = require('lodash').merge; 13 | const UtilsLib = require('../lib/utils'); 14 | 15 | if (argv.h || argv.help) { 16 | // 输出 help 17 | console.log(` 18 | Usage: sonic [options] 19 | 20 | Options: 21 | 22 | -h, --help 查看帮助 23 | -v, --version 查看版本号 24 | -w, --webpackConfig [value] webpack.config.js 配置文件路径 25 | -s, --serverPort [value] 本地静态资源服务端口号 26 | -p, --proxyPort [value] 代理服务工作端口号 27 | --hosts [value] 需要代理的本地虚拟域名, 请以 ',' 分隔 28 | --https 是否切换到 https 29 | `); 30 | 31 | } else if (argv.v || argv.version) { 32 | 33 | console.log(require('../package.json').version); 34 | 35 | } else { 36 | const Sonic = require('../index'); 37 | const PWD = process.cwd(); 38 | const CONFIG_FILE = 'sonic.config.js'; 39 | const CONFIG_FILE_PATH = path.join(PWD, CONFIG_FILE); 40 | 41 | let options = require('../lib/constants').defaultOptions; 42 | 43 | // 合并本地配置文件 44 | if (fs.existsSync(CONFIG_FILE_PATH)) { 45 | try { 46 | options = merge(options, require(CONFIG_FILE_PATH)); 47 | } catch (e) { 48 | console.error('Error parsing sonic config file from: ' + CONFIG_FILE_PATH); 49 | return; 50 | } 51 | } 52 | 53 | // 合并命令行参数配置 54 | var cliOptions = { 55 | webpackConfig: argv.w || argv.webpackConfig, 56 | serverPort: argv.s || argv.serverPort, 57 | proxyPort: argv.p || argv.proxyPort, 58 | hosts: argv.hosts && argv.split(',') || [], 59 | https: argv.https 60 | }; 61 | Object.keys(cliOptions).forEach(opt => { 62 | !cliOptions[opt] && delete cliOptions[opt]; 63 | }); 64 | 65 | var mergeOptions = merge(options, cliOptions); 66 | 67 | // write to local file 68 | if (!fs.existsSync(CONFIG_FILE_PATH)) { 69 | fs.writeFileSync(CONFIG_FILE_PATH, ` 70 | var PWD = process.cwd(); 71 | module.exports = { 72 | 73 | // webpack 配置, 可为 webpack.config.js 路径或 webpack 配置对象 74 | webpackConfig: '${mergeOptions.webpackConfig}', 75 | 76 | // 本地静态资源服务端口号 77 | serverPort: ${mergeOptions.serverPort}, 78 | 79 | // 需要代理的 hosts 字符串数组 80 | hosts: [${mergeOptions.hosts.map(h => '"' + h + '"').join(',')}], 81 | 82 | // 是否显示 webpack 编译进度 83 | progress: true, 84 | 85 | // 是否禁用控制台 log 输出 86 | silent: false, 87 | 88 | // 本地代理服务端口号 89 | proxyPort: ${mergeOptions.proxyPort}, 90 | 91 | // Anyproxy 的 web 请求监控页面端口号 92 | webPort: 8002, 93 | 94 | // Anyproxy 的 websocket 请求工作端口号 95 | socketPort: 8003, 96 | 97 | // 是否自动注入 HMR 98 | injectHMR: true, 99 | 100 | // 注入 WindVane 脚本路径, 自动在 WindVane 容器下注入 windvane.js, 如不需要设置为 false 即可 101 | injectWV: '${mergeOptions.injectWV}', 102 | 103 | // 是否切换到 https 104 | https: ${!!mergeOptions.https}, 105 | 106 | // 内容根目录 107 | contentBase: PWD, 108 | 109 | // 是否仅启动静态资源服务, 而不基于 webpack-dev-server 110 | pureStatic: false, 111 | 112 | // 是否在浏览器自动打开 Url 113 | openBrowser: true, 114 | 115 | // 默认开启的路径 116 | openPath: '/', 117 | 118 | // 要 mock 的请求 url 应该匹配的正则表达式 119 | mockRegExp: ${mergeOptions.mockRegExp}, 120 | 121 | /** 122 | * 接口 mock 处理函数 123 | * @param requestUrl {String} 请求 URL 124 | * @param response {Object} 服务端响应 125 | * @param response.headers {Object} 响应头 126 | * @param response.body {Object|String} 响应体, 如果是 JSON / JSONP, 自动转为 JSON 对象 127 | * @returns {Object} 返回可 JSON 序列化的对象 128 | */ 129 | mockFunction: (requestUrl, response) => { 130 | return response.body; 131 | }, 132 | 133 | // hosts 映射表, 域名 - IP 键值对 134 | hostsMap: {}, 135 | 136 | // 需要匹配的资源 combo regexp 137 | assetsComboRegExp: /$^/, 138 | 139 | /** 140 | * 拆分 combo 到本地 141 | * @param comboUrl {String} combo url 142 | * @param comboParts {Array} combo parts 143 | * @returns {Array} 映射到本地的路径, 相对当前工作目录 144 | */ 145 | assetsComboMapLocal: (comboUrl, comboParts) => { 146 | return comboParts; 147 | }, 148 | 149 | // 需要修改的 HTML 页面的 URL 匹配正则 150 | htmlInterceptReg: /$^/, 151 | 152 | /** 153 | * HTML 操作函数 154 | * @param reqUrl {String} 请求 URL 155 | * @param reqHeaders {Object} 请求头 156 | * @param resHeaders {Object} 响应头 157 | * @param $ {Object} jQuery 对象 158 | * @param commentNodes {Array} 注释节点 159 | * @param logger {Object} 160 | * @param callback {Function} 回调 161 | */ 162 | htmlModify: (reqUrl, reqHeaders, resHeaders, $, commentNodes, logger, cb) => { 163 | cb($.html()); 164 | }, 165 | 166 | // webpack-dev-server stats options 167 | webpackStatsOption: {} 168 | } 169 | `); 170 | } 171 | 172 | UtilsLib.reBindMockFns(mergeOptions, ['mockFunction', 'htmlModify', 'assetsComboMapLocal'], CONFIG_FILE_PATH); 173 | 174 | Sonic(mergeOptions); 175 | 176 | } 177 | -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by 弘树 on 16/2/25. 3 | */ 4 | "use strict"; 5 | 6 | const path = require('path'); 7 | const os = require('os'); 8 | const url = require('url'); 9 | // const cheerio = require('cheerio'); 10 | // const request = require('request'); 11 | // const iconv = require('iconv-lite'); 12 | // const _ = require('lodash'); 13 | // const he = require('he'); 14 | const chalk = require('chalk'); 15 | const shell = require('shelljs'); 16 | const hostile = require('hostile'); 17 | 18 | /** 19 | * Mock 服务端响应 20 | * @param reqUrl {String} 请求 url 21 | * @param serverResHeaders {Object} 服务端返回响应头 22 | * @param serverResData {String} 服务端返回数据 23 | * @param mockFunction {Function} mock 函数 24 | * @param logger {Object} logger 25 | * @param callback {Function} 回调函数 26 | */ 27 | function mockResponse(reqUrl, serverResHeaders, serverResData, mockFunction, logger, callback) { 28 | 29 | if (mockFunction && typeof mockFunction === 'function') { 30 | let ContentType = serverResHeaders['content-type'] || serverResHeaders['Content-Type']; 31 | if (ContentType) { 32 | let resContentType = ContentType.split(';').shift(); 33 | if (resContentType === 'application/json') { 34 | // 处理 JSON / JSONP 35 | let jsonRetObj = {}; 36 | 37 | try { 38 | jsonRetObj = JSON.parse(serverResData); 39 | 40 | // mockFunction 传入参数 41 | // - reqUrl: {String} 请求 URL 42 | // - response: {Object} 响应 43 | // - response.headers: {Object} 响应头 44 | // - response.body: {Object|String} 响应体(JSON Object / String) 45 | let mockResult = JSON.stringify(mockFunction(reqUrl, { 46 | headers: serverResHeaders, 47 | body: jsonRetObj 48 | })); 49 | 50 | // 加上请求 url 上的 callback padding 51 | const requestParams = url.parse(reqUrl, true).query; 52 | if (requestParams && requestParams.callback) { 53 | mockResult = `${requestParams.callback}(${mockResult})`; 54 | } 55 | 56 | logger.ok(`[Interface Mock] Interface Response Mocked for ${reqUrl}`); 57 | callback(mockResult); 58 | 59 | } catch (e) { 60 | // 非合法 JSON 串, 尝试转 JSONP 61 | var jsonpRegExp = /^\s*(\w*)\((.*)\);?$/g; 62 | var matchResult = jsonpRegExp.exec(serverResData); 63 | if (matchResult) { 64 | var jsonpCallback = matchResult[1], 65 | jsonStr = matchResult[2]; 66 | 67 | try { 68 | jsonRetObj = JSON.parse(jsonStr.trim()); 69 | 70 | var newResponse = mockFunction(reqUrl, { 71 | headers: serverResHeaders, 72 | body: jsonRetObj 73 | }); 74 | 75 | // 拿到改写的响应头, 记得拼接回 JSON Padding 76 | newResponse = jsonpCallback + '(' + JSON.stringify(newResponse) + ');'; 77 | 78 | logger.ok(`[Interface Mock] Interface Response Mocked for ${reqUrl}`); 79 | callback(newResponse); 80 | 81 | } catch (e) { 82 | // JSONP 包裹内容不是合法 JSON 83 | logger.error(chalk.red(`>> [Invalid JSON in JSONP Response!]: ${serverResData}`)); 84 | logger.error(e.stack); 85 | callback(serverResData); 86 | return; 87 | } 88 | 89 | } else { 90 | // 非合法 JSONP 格式响应, 退出 91 | logger.error(chalk.red(`>> [Invalid JSONP Response!]: ${serverResData}`)); 92 | callback(serverResData); 93 | return; 94 | } 95 | } 96 | } 97 | } 98 | } 99 | callback(serverResData); 100 | 101 | } 102 | 103 | 104 | /** 105 | * 读取本机 IP 地址(IPv4) 106 | * @returns {String} 107 | */ 108 | function getLocalIp() { 109 | const ifaces = os.networkInterfaces(); 110 | let lookupIpAddress = null; 111 | for (let dev in ifaces) { 112 | if (dev !== 'en1' && dev !== 'en0') { 113 | continue; 114 | } 115 | ifaces[dev].forEach(function (details) { 116 | if (details.family == 'IPv4') { 117 | lookupIpAddress = details.address; 118 | } 119 | }); 120 | } 121 | return lookupIpAddress || '127.0.0.1'; 122 | } 123 | 124 | 125 | /** 126 | * 递归调用解析出指定节点下所有注释节点 127 | * @param $ 128 | * @param node 129 | * @param commentNodes 130 | * @returns {Array} 131 | */ 132 | function parseCommentNodes($, node, commentNodes) { 133 | if (node.type === 'comment') { 134 | commentNodes.push(node); 135 | } else { 136 | $(node).contents().each((nodeIdx, childNode) => { 137 | parseCommentNodes($, childNode, commentNodes); 138 | }); 139 | } 140 | return commentNodes; 141 | } 142 | 143 | /** 144 | * 文件变化时重新绑定函数 145 | * 从而支持改 sonic config 文件时(如修改接口 mock 返回), 支持刷新页面即生效而不必重启服务 146 | * @param options 147 | * @param bindFnNames 148 | * @param CONFIG_FILE_PATH 149 | */ 150 | exports.reBindMockFns = (options, bindFnNames, CONFIG_FILE_PATH) => { 151 | bindFnNames.forEach(fnName => { 152 | options[fnName] = function () { 153 | delete require.cache[CONFIG_FILE_PATH]; 154 | const realTimeConfig = require(CONFIG_FILE_PATH); 155 | return realTimeConfig[fnName].apply(null, arguments); 156 | }; 157 | }); 158 | }; 159 | 160 | /** 161 | * 加载 npm package 162 | * @param pkgName {String} 163 | * @param pkgVersion {String} 164 | * @param logger {Object} 165 | * @returns {Promise} 166 | */ 167 | exports.loadPackage = (pkgName, pkgVersion, logger, cwd) => { 168 | return new Promise((resolve, reject) => { 169 | try { 170 | require(path.join(cwd || process.cwd(), 'node_modules', pkgName)); 171 | resolve(); 172 | } catch (e) { 173 | logger.warn(chalk.yellow(` 174 | ${pkgName} not found under local, to be installed... 175 | `)); 176 | shell.exec(`npm install ${pkgName}@${pkgVersion} --registry=https://registry.npm.taobao.org`, { 177 | silent: false 178 | }, (code, output) => { 179 | if (code !== 0) { 180 | logger.error(`Failed to install ${pkgName}, please check you network connection!`); 181 | reject(output); 182 | } else { 183 | resolve(); 184 | } 185 | }); 186 | } 187 | }); 188 | }; 189 | 190 | exports.checkHosts = () => { 191 | const ip = '127.0.0.1'; 192 | const host = 'localhost'; 193 | let hasLocalhost = false; 194 | 195 | try { 196 | const lines = hostile.get() || []; 197 | lines.some((line) => { 198 | return (hasLocalhost = line[0] === ip && !!line[1] && line[1].indexOf(host) > -1); 199 | }); 200 | } catch (err) { 201 | return; 202 | } 203 | 204 | if (!hasLocalhost) { 205 | try { 206 | console.log(chalk.yellow(`> 需要添加 ${ip} ${host} 到 ${hostile.HOSTS} 文件`)); 207 | console.log(chalk.yellow('> 正为尝试您自动添加...')); 208 | shell.exec(`sudo -- sh -c -e "echo '\n${ip} ${host}' >> ${hostile.HOSTS}";`); 209 | console.log(chalk.green('> 添加成功')); 210 | } catch (err) { 211 | console.error(chalk.red(`> 添加失败,请手动添加后重试命令`)); 212 | throw err; 213 | } 214 | } 215 | } 216 | 217 | exports.mockResponse = mockResponse; 218 | exports.getLocalIp = getLocalIp; 219 | exports.parseCommentNodes = parseCommentNodes; 220 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sonic-server 2 | 3 | ![Sonic](https://gw.alicdn.com/tps/TB1qqPfMXXXXXaiXXXXXXXXXXXX-800-600.png_320x320.jpg) 4 | 5 | Image From: 6 | 7 | > 一键式多功能前端本地开发调试环境 8 | > 9 | > Front-End Dev server based on [anyproxy](http://anyproxy.io) & [webpack-dev-server](https://github.com/webpack/webpack-dev-server). 10 | 11 | ## 特性 12 | 13 | 1. 轻量级的本地静态资源服务,自动起 Chrome 进程完成代理绑定,支持可配置的虚拟域名,淘系下 \*.taobao.com 域名开发(免登、mtop)无痛开发再也不是梦; 14 | 1. 自动按需整合 [webpack-dev-server](https://github.com/webpack/webpack-dev-server), 零配置支持 [HMR(Hot Module Replacement, 热模块替换)](https://webpack.github.io/docs/hot-module-replacement.html); 15 | 1. 支持方便的接口 Mock(JSONP 也不在话下)和输出页面 DOM 修改([TMS/EMS 区块嵌入页面](http://h5.alibaba-inc.com/awp/PageTags.html#tms_标签),脚本注入 so easy); 16 | 1. https 支持,只需信任 anyproxy 证书,即刻进入 https 的世界; 17 | 1. 轻松 hosts 绑定,指定 hosts 映射表即可,从此远离修改系统 hosts 文件; 18 | 1. TO BE CONTINUED... 19 | 20 | ## 安装 21 | 22 | ### 命令行使用 23 | 24 | ```shell 25 | npm i sonic-server -g 26 | ``` 27 | 28 | 然后 29 | 30 | ```shell 31 | sonic 32 | ``` 33 | 34 | ### 作为 lib 35 | 36 | ```shell 37 | npm i sonic-server -S 38 | ``` 39 | 40 | ## 使用 41 | 42 | ### 命令行 43 | 44 | 命令行下执行 `sonic`, 会自动将当前作为目录作为内容根目录起本地服务, 如果当前目录下有 `webpack.config.js` 文件, 会将其作为 webpackConfig, 起 webpack-dev-server, 并会在控制台输出相应配置: 45 | 46 | ``` 47 | h5 ➤ sonic --https 48 | Proxy for Sonic! 49 | Anyproxy rules initialize finished, have fun! 50 | >> Proxy server started at http://10.62.64.141:8080 51 | GUI interface started at : http://10.62.64.141:8002/ 52 | Http proxy started at 10.62.64.141:8080 53 | [internal https]certificate created for 10.62.64.141 54 | >> 55 | -------------- 服务配置 -------------- 56 | 本地 IP 地址 => 10.62.64.141 57 | 本地代理服务 => 10.62.64.141:8080 58 | 静态资源服务 => http://10.62.64.141:8081 59 | 请求代理监控 => http://localhost:8002 60 | -------------- 服务配置 -------------- 61 | ... 62 | ``` 63 | 64 | 如果当前目录下不存在 sonic 配置文件 (`sonic.config.js`), 则会自动根据模板和当前配置项生成一份, 后续命令行执行会合并命令行参数和 sonic 配置文件配置. 65 | 66 | 查看全部命令行参数: 67 | 68 | ``` 69 | h5 ➤ sonic -h 70 | 71 | Usage: sonic [options] 72 | 73 | Options: 74 | 75 | -h, --help 查看帮助 76 | -v, --version 查看版本号 77 | -w, --webpackConfig [value] webpack.config.js 配置文件路径 78 | -s, --serverPort [value] 本地静态资源服务端口号 79 | -p, --proxyPort [value] 代理服务工作端口号 80 | --hosts [value] 需要代理的本地虚拟域名, 请以 ',' 分隔 81 | --https 是否切换到 https 82 | 83 | ``` 84 | 85 | ### 作为 lib 86 | 87 | ``` 88 | var Sonic = require('sonic-server'); 89 | var options = { 90 | // webpack 配置, 可为 webpack.config.js 路径或 webpack 配置对象 91 | webpackConfig: path.join(pwd, 'webpack.config.js'), 92 | 93 | // 本地静态资源服务端口号 94 | serverPort: 8081, 95 | 96 | // 本地代理服务端口号 97 | proxyPort: 8080, 98 | 99 | // Anyproxy 的 web 请求监控页面端口号 100 | webPort: 8002, 101 | 102 | // Anyproxy 的 websocket 请求工作端口号 103 | socketPort: 8003, 104 | 105 | // 需要代理的 hosts 字符串数组 106 | hosts: [], 107 | 108 | // 是否显示 webpack 编译进度 109 | progress: true, 110 | 111 | // 是否自动注入 HMR 112 | injectHMR: true, 113 | 114 | // 注入 WindVane 脚本路径, 自动在 WindVane 容器下注入 windvane.js, 如不需要设置为 `false` 即可 115 | injectWV: true || 'http://xxx/windvane.js', 116 | 117 | // 是否切换到 https 118 | https: false, 119 | 120 | // 内容根目录 121 | contentBase: pwd, 122 | 123 | // 是否禁用控制台 log 输出 124 | silent: false, 125 | 126 | // 是否仅启动静态资源服务, 而不基于 webpack-dev-server 127 | pureStatic: false, 128 | 129 | // 是否在浏览器自动打开 Url 130 | openBrowser: true, 131 | 132 | // 默认开启的路径 133 | openPath: '/', 134 | 135 | // 新起 Chrome 基于的用户目录绝对路径 136 | chromeUserDir: DEFAULT_USER_DIR, 137 | 138 | // 浏览器程序路径(mac 下 Chrome) 139 | browserApp: DEFAULT_BROWSER, 140 | 141 | // 要 mock 的请求 url 应该匹配的正则表达式 142 | mockRegExp: null, 143 | 144 | /** 145 | * 接口 mock 处理函数 146 | * @param requestUrl {String} 请求 URL 147 | * @param response {Object} 服务端响应 148 | * @param response.headers {Object} 响应头 149 | * @param response.body {Object|String} 响应体, 如果是 JSON / JSONP, 自动转为 JSON 对象 150 | * @returns {Object} 返回可 JSON 序列化的对象 151 | */ 152 | mockFunction: (requestUrl, response) => { 153 | return responseBody; 154 | }, 155 | 156 | // hosts 映射表, 域名 - IP 键值对 157 | hostsMap: {}, 158 | 159 | /** 160 | * HTML 操作函数 161 | * @param reqUrl {String} 请求 URL 162 | * @param reqHeaders {Object} 请求头 163 | * @param resHeaders {Object} 响应头 164 | * @param $ {Object} jQuery 对象 165 | * @param commentNodes {Array} 注释节点 166 | * @param logger {Object} 167 | * @param callback {Function} 回调 168 | */ 169 | htmlModify: (reqUrl, reqHeaders, resHeaders, $, commentNodes, logger, cb) => { 170 | cb($.html()); 171 | }, 172 | 173 | /** 174 | * 改写发出的请求 175 | */ 176 | modifyRequestObject: (requestObj) => { 177 | requestObj.requestOptions.port = 1234; 178 | return requestObj; 179 | } 180 | }; 181 | Sonic(options, /*logger (optional)*/, (server) => { 182 | 183 | // 进程退出 184 | process.on('SIGINT', () => { 185 | server.close(); 186 | }); 187 | }); 188 | ``` 189 | 190 | ### Options 191 | 192 | #### options.webpackConfig 193 | 194 | - Type: `String|Object` 195 | - Default value: `path.join(process.cwd(), 'webpack.config.js')` 196 | - Webpack config 文件路径 / Webpack 配置对象 197 | 198 | #### options.serverPort 199 | 200 | - Type: `Number` 201 | - Default value: `8081` 202 | - 本地静态服务工作的端口号 203 | 204 | #### options.proxyPort 205 | 206 | - Type: `Number` 207 | - Default value: `8080` 208 | - 代理服务工作端口号 209 | 210 | #### options.webPort 211 | 212 | - Type: `Number` 213 | - Default value: `8002` 214 | - Anyproxy 的 web 请求监控页面端口号 215 | 216 | 217 | #### options.socketPort 218 | 219 | - Type: `Number` 220 | - Default value: `8003` 221 | - Anyproxy 的 websocket 请求工作端口号 222 | 223 | 224 | #### options.hosts 225 | 226 | - Type: `Array` 227 | - Default value: `[]` 228 | - 需要模拟的虚拟域名 229 | 230 | #### options.progress 231 | 232 | - Type: `Boolean` 233 | - Default value: `true` 234 | - 是否显示 webpack 编译进度 235 | 236 | 237 | #### options.injectHMR 238 | 239 | - Type: `Boolean` 240 | - Default value: `true` 241 | - 是否对 webpack 编译, 自动注入 [HMR](https://webpack.github.io/docs/hot-module-replacement.html) 242 | 243 | 244 | #### options.injectWV 245 | 246 | - Type: `Boolean|String` 247 | - Default value: `true` 248 | - 是否在 WindVane 容器内(根据 UserAgent 探测)自动注入 windvane.js, 或者可配置为 windvane.js 脚本路径 249 | 250 | ### options.https 251 | 252 | - Type: `Boolean` 253 | - Default value: `false` 254 | - 是否切换到 https. 255 | 256 | 257 | #### options.contentBase 258 | 259 | - Type: `String` 260 | - Default value: `process.cwd()` 261 | - [Webpack Dev Server 的 contentBase 配置](https://webpack.github.io/docs/webpack-dev-server.html#api) 262 | 263 | #### options.silent 264 | 265 | - Type: `Boolean` 266 | - Default value: `false` 267 | - 是否禁用掉控制台 anyproxy 输出 log 268 | 269 | #### options.pureStatic 270 | 271 | - Type: `Boolean` 272 | - Default value: `false` 273 | - 是否仅仅启动本地纯静态文件服务, 而不需要 webpack-dev-server 274 | 275 | #### options.openBrowser 276 | 277 | - Type: `Boolean` 278 | - Default value: `true` 279 | - 是否在浏览器自动打开 Url 280 | 281 | ### options.openPath 282 | 283 | - Type: `String` 284 | - Default value: `'/'` 285 | - 本地服务启动后自动加载的路径 286 | 287 | ### options.openUrl 288 | 289 | - Type: `String` 290 | - Default value: `'/'` 291 | - 本地服务启动后自动加载的页面完整 URL, 优先级高于 `options.openPath` 292 | 293 | ### options.mockRegExp 294 | 295 | - Type: `RegExp` 296 | - Default value: `null` 297 | - 需要接口 Mock 的 url 应该匹配的正则 298 | 299 | ### options.mockFunction 300 | 301 | - Type: `Function` 302 | - Default value: `(requestUrl, response) => { return responseBody; }` 303 | - 接口 Mock 方法 304 | 305 | ### options.mockBeforeFunction 306 | 307 | - Type: `Function` 308 | - Default value: `(requestUrl) => { return responseBody; }` 309 | - 接口 Mock 方法 310 | 311 | ### options.hostsMap 312 | 313 | - Type: `Object` 314 | - Default value: `{}` 315 | - hosts 映射表, 和本地绑 hosts 一样的原理 316 | 317 | ### options.htmlInterceptReg 318 | 319 | - Type: `RegExp` 320 | - Default value: `/$^/`(不匹配任何 URL) 321 | - 需要页面 Mock 的 url 应该匹配的正则 322 | 323 | ### options.htmlModify 324 | 325 | - Type: `Function` 326 | - Default value: `(reqUrl, reqHeaders, resHeaders, $, commentNodes, logger, cb) => { cb($.html()); }` 327 | - 对本地虚拟域名下加载的 html 页面进行自定义操作(如插入脚本, tms/ems 区块自动注入等) 328 | 329 | ### options.assetsComboRegExp 330 | 331 | - Type: `RegExp` 332 | - Default value: `/$^/`(不匹配任何 URL) 333 | - 需要拆分 js/css 资源 combo 的 url 应该匹配的正则 334 | 335 | ### options.assetsComboMapLocal 336 | 337 | - Type: `Function` 338 | - Default value: `(comboUrl, comboParts) => { return comboParts; }` 339 | - 输入远程 js/css 资源 combo 的 url 和拆分后的各个单独资源文件请求, 返回对应应该映射到本地的文件路径(相对于当前工作目录)。 340 | - 如某个映射本地文件不存在会自动加载线上。 341 | 342 | ### options.webpackStatsOption 343 | 344 | - Type: `Object` 345 | - Default value: `{}` 346 | - [`stats` option for webpack-dev-server](https://webpack.github.io/docs/webpack-dev-server.html#api) 347 | 348 | ### options.modifyRequestObject 349 | 350 | - Type: `Function` 351 | - Default value: `(requestObj) => { return requestObj; }` 352 | - 改写请求,请参考 。 353 | 354 | 355 | ### options.corsInject 356 | 357 | - Type: `Boolean` 358 | - Default value: `true` 359 | - 是否自动注入 CORS 响应头. 360 | 361 | ## 接口 Mock 362 | 363 | ### 接口 Mock 脚本 Demo 364 | 365 | ```js 366 | /** 367 | * 接口 mock 处理模块 368 | * @param requestUrl {String} 请求 URL 369 | * @param response {Object} 服务端响应 370 | * @param response.headers {Object} 响应头 371 | * @param response.body {Object|String} 响应体, 如果是 JSON / JSONP, 自动转为 JSON 对象 372 | * @returns {Object} 返回可 JSON 序列化的对象 373 | */ 374 | module.exports = function (requestUrl, response) { 375 | 376 | var url = require('url'); 377 | var parsedReqUrl = url.parse(requestUrl, true); 378 | var params = parsedReqUrl.query; 379 | var responseBody = response.body; 380 | 381 | switch (params.api) { 382 | // case 'mtop.xxx': 383 | // responseBody.test = 123; 384 | // break; 385 | default: 386 | responseBody.default = true; 387 | break; 388 | } 389 | 390 | return responseBody; 391 | }; 392 | ``` 393 | 394 | ## 原理图 395 | 396 | ![原理图](http://gtms02.alicdn.com/tps/i2/TB1ITFhMXXXXXbUaXXXFD0rNpXX-1778-1334.png) 397 | 398 | 1. 开发者从虚拟域名(如 `dev.waptest.taobao.com`)请求本地目录页面; 399 | 2. 本地代理服务对浏览器各个请求分别做不同的代理分发: 400 | 3. 如果是虚拟域名下的资源请求 `dev.waptest.taobao.com/*`,统一定向到本地 webpack-dev-server 的静态资源服务; 401 | 4. 如果是请求 url 匹配上接口 mock url 规则,将接口服务器的响应做代理,执行用户定义的响应数据重写逻辑后,再通过代理服务传递回浏览器端; 402 | 5. 其他类型的资源请求(如线上图片、埋点等),代理服务器透明代理,不做处理 403 | 404 | ## 常见问题 405 | 406 | - Q: 接口代理未生效? 407 | - A: 有可能站点证书已过期, 参见 [Anyproxy issue #1](http://gitlab.alibaba-inc.com/alipay-ct-wd/anyproxy/issues/1), 删除掉旧证书(路径默认在 ~/.anyproxy-certs)刷新即可重新生成新的证书. 408 | 409 | - Q: 切换到 https 时站点或接口访问有问题? 410 | - A: 尝试 `rm -rf ~/.anyproxy-certs`, 然后重新启动服务, 将 anyproxy 证书加入系统钥匙串, 然后重试. 411 | - A: 更多 https 配置可参考: [HTTPS相关教程](https://github.com/alibaba/anyproxy/wiki/HTTPS%E7%9B%B8%E5%85%B3%E6%95%99%E7%A8%8B) 412 | 413 | - Q: 移动端如何绑定 HTTP 代理? 414 | - A: 415 | 1. Android 416 | - 参考:[安卓手机如何进行代理设置](http://jingyan.baidu.com/article/fd8044faebfaa85030137a72.html) 417 | 2. iOS 418 | - 参考:[iOS开发工具——网络封包分析工具Charles](http://www.infoq.com/cn/articles/network-packet-analysis-tool-charles/)\#iPhone上的设置 419 | 420 | - Q: 移动端(手机、Pad 等)如何访问 https? 421 | - A: 在桌面浏览器打开控制台输出的 `请求代理监控 => http://localhost:8002` 部分的监控页面 url,点击『QRCode of rootCA.crt』,在新打开的 [http://localhost:8002/qr_root](http://localhost:8002/qr_root) 页面中通过移动端扫码应用扫码即会自动进入证书安装流程(建议最好使用系统原生浏览器打开二维码 url) 422 | 423 | ## 周边 424 | 425 | - Grunt task: [@ali/grunt-devserver](http://web.npm.alibaba-inc.com/package/@ali/grunt-devserver) 426 | 427 | ## Credits 428 | 429 | - [![Anyproxy](http://gtms04.alicdn.com/tps/i4/TB1XfxDHpXXXXXpapXX20ySQVXX-512-512.png_120x120.jpg)](http://anyproxy.io/) 430 | - [webpack-dev-server](https://webpack.github.io/docs/webpack-dev-server.html) 431 | 432 | ## Release History 433 | 434 | - [1.0.0] 435 | - initial version 436 | 437 | ## License 438 | Copyright (c) 2016 弘树. Licensed under the MIT license. 439 | -------------------------------------------------------------------------------- /lib/wx-middleware.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by 弘树 on 16/8/27. 3 | * weex middleware 4 | */ 5 | "use strict"; 6 | 7 | const fs = require('fs'); 8 | const path = require('path'); 9 | const url = require('url'); 10 | const request = require('request'); 11 | const cheerio = require('cheerio'); 12 | const qrcode = require('yaqrcode'); 13 | 14 | /** 15 | * 生成扫码 URL 16 | * @param reqUrlObj {Object} 请求 URL 对象 17 | * @param serverHost {String} 本机 host 18 | * @param weexWebpackConfig {Object} webpackConfig for weex 19 | * @return {String} 20 | */ 21 | const generateScanUrl = (reqUrlObj, serverHost, options, weexWebpackConfig) => { 22 | options = options || {}; 23 | const parsedReqUrl = Object.assign({}, reqUrlObj); 24 | parsedReqUrl.protocol = options.https ? 'https:' : (parsedReqUrl.protocol || 'http:'); 25 | parsedReqUrl.host = serverHost; 26 | parsedReqUrl.query['hot-reload'] = true; 27 | const wxTpl = parsedReqUrl.query._wx_tpl; 28 | if (wxTpl) { 29 | const parsedWxTpl = url.parse(decodeURIComponent(wxTpl)); 30 | parsedWxTpl.host = serverHost; 31 | parsedReqUrl.search = null; 32 | parsedReqUrl.query._wx_tpl = url.format(parsedWxTpl); 33 | // 防止页面刷新后二维码同时包含 _wx_tpl 和 wh_weex 参数,在手淘 android 下存在降级 H5 的 bug 34 | delete parsedReqUrl.query.wh_weex; 35 | } else if (weexWebpackConfig) { 36 | // 不带 `_wx_tpl` 参数, 且为基于 Vue 的 Weex 项目 37 | // vue 2.0 扫码URL: e.g. http://30.27.100.57:8081/demo/entry.weex.js?wh_weex=true 38 | const reqPathName = path.basename(reqUrlObj.pathname); 39 | // 去文件后缀名 40 | const weexNativePathFileName = weexWebpackConfig.output.filename 41 | .replace('[name]', reqPathName.split('.').slice(0, -1).join('.')); 42 | parsedReqUrl.pathname = reqUrlObj.pathname.replace(reqPathName, weexNativePathFileName); 43 | delete parsedReqUrl.search; 44 | parsedReqUrl.query.wh_weex = true; 45 | } 46 | return url.format(parsedReqUrl); 47 | }; 48 | 49 | // 注入 qr code 脚本到页面,控制台输出 qr code 二维码图 50 | const injectHTML = (htmlSource, scanUrl, weexPageReloadPort, logger) => { 51 | logger.verbose.info('>> Injecting HTML for weex.'); 52 | const LOCAL_IP = require('./utils').getLocalIp(); 53 | const $ = cheerio.load(htmlSource, { 54 | normalizeWhitespace: false, 55 | xmlMode: false, 56 | decodeEntities: false 57 | }); 58 | let qrcodeLog = ''; 59 | try { 60 | qrcodeLog = `console.log("%c ", 61 | "padding:2px 80px 4px;" + 62 | "line-height:160px;background:url('${qrcode(scanUrl)}') no-repeat;" + 63 | "background-size:160px");` 64 | } catch (e) { 65 | qrcodeLog = `console.error("自动生成二维码失败,请手动拷贝以上链接去其他工具生成二维码");`; 66 | } 67 | $('head').prepend(``); 75 | $('body').append(``); 109 | 110 | return $.html(); 111 | }; 112 | 113 | /** 114 | * weex proc middleware 115 | * @param contentBase 116 | * @param logger 117 | * @param proxyUrl 代理 URL 118 | * @param serverHost 119 | * @param protocol {String} 120 | * @param options {Object} 121 | * @param options.weexPageReloadPort {Number} weex 页面刷新 socket 端口号 122 | * @param webpackConfig {Object|Array} webpack 配置项 123 | * @returns {function(*, *, *)} 124 | * @constructor 125 | */ 126 | module.exports = (contentBase, logger, proxyUrl, serverHost, protocol, options, webpackConfig) => { 127 | const { weexPageReloadPort } = options; 128 | return (req, res, next) => { 129 | const parsedReqUrl = url.parse(req.url, true); 130 | const reqPath = parsedReqUrl.pathname; 131 | const filePath = path.join(contentBase, reqPath); 132 | 133 | delete parsedReqUrl.query.__edith_orig_url__; 134 | 135 | // 对 .we 和有对应 weex 文件的 .html 文件请求做处理 136 | if (/\.we\b/.test(reqPath) || 137 | (/\.html/.test(reqPath) && fs.existsSync(filePath.replace(/\.html/, '.we')))) { 138 | logger.verbose.info('>> Enter weex middleware'); 139 | if (!req.query._wx_tpl && !req.query.page) { 140 | if (req.query.page) { 141 | // 请求 url 参数上有 `page`, 用来做 H5 降级调试, 不要做处理 142 | } else { 143 | // 请求 url 参数上没有 `_wx_tpl` 144 | const reqProtocol = parsedReqUrl.protocol || `${protocol}:`; 145 | parsedReqUrl.query._wx_tpl = `${reqProtocol}//${req.headers.host}${req.url.replace(/\.(we|html)$/, '.js')}`; 146 | parsedReqUrl.query['hot-reload'] = true; 147 | delete parsedReqUrl.search; 148 | res.status(302).location(url.format(parsedReqUrl)); 149 | } 150 | next(); 151 | } else { 152 | if (/\.we\b/.test(reqPath)) { 153 | const matchHTMLPath = path.join(contentBase, reqPath.replace(/\.we$/, '.html')); 154 | const baseHTMLPath = path.join(contentBase, 'index.html'); 155 | const defaultHTMLPath = path.join(__dirname, '../assets/weex.html'); 156 | let sendFilePath; 157 | if (fs.existsSync(matchHTMLPath)) { 158 | sendFilePath = matchHTMLPath; 159 | } else { 160 | let upperDirPath = path.join(matchHTMLPath, '../'); 161 | let recursiveHTMLPath = path.join(upperDirPath, 'index.html'); 162 | while (recursiveHTMLPath !== baseHTMLPath && !sendFilePath) { 163 | upperDirPath = path.join(upperDirPath, '../'); 164 | recursiveHTMLPath = path.join(upperDirPath, 'index.html'); 165 | if (fs.existsSync(recursiveHTMLPath)) { 166 | logger.verbose.info(`>> Loading ${recursiveHTMLPath} for ${reqPath}`); 167 | sendFilePath = recursiveHTMLPath; 168 | } 169 | } 170 | // 使用默认内置的 html 托底 171 | if (!sendFilePath) { 172 | if (fs.existsSync(baseHTMLPath)) { 173 | sendFilePath = baseHTMLPath; 174 | } else { 175 | // sendFilePath = defaultHTMLPath; 176 | } 177 | } 178 | } 179 | 180 | res.type('html'); 181 | if (sendFilePath) { 182 | const reqUrlObj = Object.assign(parsedReqUrl, { 183 | protocol: `${protocol}:`, 184 | host: req.headers.host, 185 | pathname: path.relative(contentBase, sendFilePath) 186 | .replace(new RegExp('\\' + path.sep, 'g'), '/') 187 | }); 188 | // req.pipe(request(url.format(reqUrlObj), { 189 | // proxy: proxyUrl 190 | // })).pipe(res); 191 | 192 | const reqObj = { 193 | url: url.format(reqUrlObj), 194 | proxy: proxyUrl, 195 | strictSSL: false 196 | }; 197 | 198 | request(reqObj, (err, resp, body) => { 199 | if (!err && resp.statusCode === 200) { 200 | res.send(injectHTML( 201 | body.toString('utf-8'), 202 | generateScanUrl(parsedReqUrl, serverHost, options), 203 | weexPageReloadPort, 204 | logger) 205 | ); 206 | } else { 207 | logger.error(`>> Error fetch ${JSON.stringify( 208 | reqUrlObj, null, 2 209 | )} [${resp && resp.statusCode}]`); 210 | res.send(body); 211 | } 212 | }); 213 | } else { 214 | logger.verbose.info(`Loading ${defaultHTMLPath} for ${reqPath}`); 215 | res.send(injectHTML( 216 | fs.readFileSync(defaultHTMLPath, 'utf-8'), 217 | generateScanUrl(parsedReqUrl, serverHost, options), 218 | weexPageReloadPort, 219 | logger) 220 | ); 221 | } 222 | } else { 223 | res.type('html'); 224 | res.send(injectHTML( 225 | fs.readFileSync(filePath, 'utf-8'), 226 | generateScanUrl(parsedReqUrl, serverHost, options), 227 | weexPageReloadPort, 228 | logger) 229 | ); 230 | // next(); 231 | } 232 | } 233 | } else if (/\.vue\b/.test(reqPath) || 234 | (/\.html/.test(reqPath) 235 | && fs.existsSync(filePath) 236 | && fs.existsSync(filePath.replace(/\.html/, '.vue')))) { 237 | // .vue 和 .html 文件同时存在, 再检查 webpackConfig 里包含 weex bundle 构建, 以免误伤 vue in h5. 238 | res.type('html'); 239 | if (Array.isArray(webpackConfig)) { 240 | const weexWebpackConfig = webpackConfig.filter(function(c) { 241 | return (c.type === 'weex') || (c.output && c.output.filename && c.output.filename.indexOf('weex.js') > -1); 242 | })[0]; 243 | if (!weexWebpackConfig) { 244 | console.error('>> Unable to find webpackConfig for weex, please make sure `webpackConfig.type === "weex".`'); 245 | } 246 | res.send(injectHTML( 247 | fs.readFileSync(filePath, 'utf-8'), 248 | generateScanUrl(parsedReqUrl, serverHost, options, weexWebpackConfig), 249 | weexPageReloadPort, 250 | logger) 251 | ); 252 | } else { 253 | res.send(injectHTML( 254 | fs.readFileSync(filePath, 'utf-8'), 255 | generateScanUrl(parsedReqUrl, serverHost, options, webpackConfig), 256 | weexPageReloadPort, 257 | logger) 258 | ); 259 | } 260 | } else if (/\.html/.test(reqPath)) { 261 | // rax 请求的 html 文件 262 | const htmlSource = fs.readFileSync(filePath, 'utf-8'); 263 | if ( 264 | /web-rax-framework/.test(htmlSource) || 265 | /seed-weex/.test(htmlSource) 266 | ) { 267 | let wpConfig = webpackConfig; 268 | // 页面有引用 Rax 基础框架 269 | if (Array.isArray(webpackConfig)) { 270 | const weexWebpackConfig = webpackConfig.filter(function(c) { 271 | return (c.type === 'weex') || (c.output && c.output.filename && c.output.filename.indexOf('weex.js') > -1); 272 | })[0]; 273 | if (!weexWebpackConfig) { 274 | console.error('>> Unable to find webpackConfig for weex, please make sure `webpackConfig.type === "weex".`'); 275 | } 276 | wpConfig = weexWebpackConfig; 277 | } 278 | let scanUrl = generateScanUrl(parsedReqUrl, serverHost, options, wpConfig); 279 | 280 | // 访问 html?wh_weex=true,返回 weex.js 的内容 281 | if ( 282 | !req.query['_wx_tpl'] && 283 | req.query['wh_weex'] === 'true' && 284 | ( 285 | req.headers['f-refer'] === 'weex' || 286 | /Weex\//i.test(req.headers['user-agent'] || '') 287 | ) 288 | ) { 289 | request(scanUrl, function(err, resp, body) { 290 | if (!err && resp.statusCode === 200) { 291 | res.set('Content-Type', 'application/javascript'); 292 | res.send(body.toString('utf-8')); 293 | } else { 294 | res.send(injectHTML( 295 | htmlSource, 296 | scanUrl, 297 | weexPageReloadPort, 298 | logger) 299 | ); 300 | } 301 | }); 302 | } else { 303 | if (!url.parse(scanUrl, true)._wx_tpl) { 304 | delete parsedReqUrl.query.wh_weex; 305 | parsedReqUrl.protocol = protocol; 306 | parsedReqUrl.host = options.hosts.indexOf(req.headers.host) < 0 ? options.hosts[0] : req.headers.host; 307 | parsedReqUrl.query._wx_tpl = scanUrl 308 | parsedReqUrl.search = null; 309 | scanUrl = url.format(parsedReqUrl); 310 | } 311 | res.send(injectHTML( 312 | htmlSource, 313 | scanUrl, 314 | weexPageReloadPort, 315 | logger) 316 | ); 317 | } 318 | } else { next(); } 319 | } else { 320 | next(); 321 | } 322 | }; 323 | }; 324 | -------------------------------------------------------------------------------- /lib/anyproxy-rule.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by 弘树 on 16/1/20. 3 | */ 4 | "use strict"; 5 | 6 | const utilsLib = require('./utils'); 7 | const Constants = require('./constants'); 8 | const cheerio = require('cheerio'); 9 | const url = require('url'); 10 | const request = require('request'); 11 | const fs = require('fs'); 12 | const path = require('path'); 13 | const ssiLib = require('./ssi'); 14 | const iconv = require('iconv-lite'); 15 | 16 | function testLocalRegExp(localRegExp, pureReqPath) { 17 | if (Array.isArray(localRegExp)) { 18 | let result = false; 19 | localRegExp.some(function(r) { 20 | if (testLocalRegExp(r, pureReqPath)) { 21 | return (result = true); 22 | } 23 | }); 24 | return result; 25 | } else if (typeof localRegExp === 'function') { 26 | return localRegExp(pureReqPath); 27 | } else if (localRegExp instanceof RegExp) { 28 | return localRegExp.test(pureReqPath); 29 | } 30 | 31 | return false; 32 | } 33 | 34 | /** 35 | * 是否需要代理到本地静态服务|Webpack-dev-server 服务 36 | * @param options {Object} 配置对象 37 | * @param options.contentBase {String} 38 | * @param options.webpackConfig.output.publicPath 39 | * @param reqPath {String} 请求路径 40 | * @returns {boolean} 41 | */ 42 | const isProxyToLocalServe = (options, reqPath) => { 43 | // 去除 query 先 44 | const pureReqPath = reqPath.split('?')[0]; 45 | const contentBase = options.contentBase; 46 | 47 | // 兼容 webpack config 可能是数组,归一化为数组 48 | let { 49 | localRegExp, 50 | webpackConfig = [] 51 | } = options; 52 | if (!Array.isArray(webpackConfig)) { 53 | webpackConfig = [webpackConfig]; 54 | } 55 | const isReqPathMatchWebpack = (requestPath) => { 56 | const result = webpackConfig.some(configItem => 57 | requestPath.indexOf( 58 | ((configItem || {}).output || {}).publicPath) === 0 59 | ); 60 | return result; 61 | }; 62 | 63 | const proxyToLocalServe = fs.existsSync(path.join(contentBase, pureReqPath)) 64 | // .js 文件可能是需要 .we 文件构建后生成 65 | || fs.existsSync(path.join(contentBase, pureReqPath.replace(/\.js/, '.we'))) 66 | || isReqPathMatchWebpack(pureReqPath) 67 | || testLocalRegExp(localRegExp, pureReqPath); 68 | return proxyToLocalServe; 69 | }; 70 | 71 | // 是否是 favicon 标签页图标 72 | const isLocalFavicon = (req, hosts) => { 73 | let local = false; 74 | hosts.some(h => { 75 | return (local = req.url.indexOf(`${req.protocol}://${h}/favicon.ico`) === 0); 76 | }); 77 | return local; 78 | }; 79 | 80 | 81 | /* 82 | read the following wiki before using rule file 83 | https://github.com/alibaba/anyproxy/wiki/What-is-rule-file-and-how-to-write-one 84 | */ 85 | module.exports = (proxyOptions) => { 86 | 87 | let mockRegExp = proxyOptions.mockRegExp; 88 | let httpsRegExp = proxyOptions.httpsRegExp; 89 | if (!(mockRegExp instanceof RegExp)) { 90 | // 一个不会匹配任何字符串的正则表达式 91 | // ref: http://stackoverflow.com/questions/1723182/a-regex-that-will-never-be-matched-by-anything 92 | mockRegExp = /$^/; 93 | } 94 | if (!(httpsRegExp instanceof RegExp)) { 95 | httpsRegExp = /$^/; 96 | } 97 | // let localIp = utilsLib.getLocalIp(); 98 | const hostsMap = proxyOptions.hostsMap; 99 | const logger = proxyOptions.logger; 100 | 101 | return { 102 | /* 103 | * These functions will overwrite the default ones, write your own when necessary. 104 | */ 105 | summary() { 106 | return 'Proxy for Sonic!'; 107 | }, 108 | 109 | // ======================= 110 | // when getting a request from user 111 | // 收到用户请求之后 112 | // ======================= 113 | 114 | // 是否截获https请求 115 | // should intercept https request, or it will be forwarded to real server 116 | shouldInterceptHttpsReq(req) { 117 | 118 | const proxyHosts = proxyOptions.hosts; 119 | const reqHeaders = req.headers; 120 | let reqHost = reqHeaders.host || reqHeaders.hostname; 121 | if (reqHost) { 122 | reqHost = reqHost.split(':')[0]; 123 | // 仅对虚拟域名及匹配 mock 请求做拦截 124 | return (proxyHosts.indexOf(reqHost) !== -1) 125 | || mockRegExp.test(req.url) 126 | || (reqHost in hostsMap) 127 | || httpsRegExp.test(req.url); 128 | } 129 | return false; 130 | }, 131 | 132 | // 是否在本地直接发送响应(不再向服务器发出请求) 133 | // whether to intercept this request by local logic 134 | // if the return value is true, anyproxy will call dealLocalResponse to get response data 135 | // and will not send request to remote server anymore 136 | // req is the user's request sent to the proxy server 137 | shouldUseLocalResponse(req/* , reqBody*/) { 138 | // const parsedReqUrl = url.parse(req.url); 139 | let requestUrl = req.url; 140 | if (isLocalFavicon(req, proxyOptions.hosts)) { 141 | logger.verbose.info(`>> Parsing local favicon: ${req.url}`); 142 | return true; 143 | } 144 | if (proxyOptions.assetsComboMapLocal && 145 | proxyOptions.assetsComboRegExp && 146 | proxyOptions.assetsComboRegExp.test(req.url) && 147 | (req.url.indexOf(Constants.COMBO_SIGN) !== -1)) { 148 | // 匹配了需要映射到本地的资源 combo 149 | logger.verbose.info(`>> Parsing combo url: ${req.url}`); 150 | return true; 151 | // } else if (/\.we/.test(parsedReqUrl.pathname)) { 152 | // // 请求 .we 文件, 检查本地路径是否存在 153 | // return fs.existsSync(path.join(proxyOptions.contentBase, parsedReqUrl.pathname)); 154 | } 155 | 156 | // 接口 mock 157 | if (proxyOptions.mockBeforeFunction && mockRegExp) { 158 | 159 | if (/^\//.test(requestUrl)) { 160 | let protocol = 'http:'; 161 | if (req.connection.encrypted) { 162 | protocol = 'https:'; 163 | } 164 | requestUrl = `${protocol}//${req.headers.host}${requestUrl}`; 165 | } 166 | 167 | if (mockRegExp.test(requestUrl)) { 168 | let mockBeforeData = proxyOptions.mockBeforeFunction(requestUrl); 169 | if (mockBeforeData != undefined) { 170 | if (mockBeforeData.headers) { 171 | req.mockBeforeData = mockBeforeData; 172 | } else { 173 | req.mockBeforeData = { 174 | headers: { 175 | 'Access-Control-Allow-Origin': req.headers.Origin || req.headers.origin, 176 | 'Access-Control-Allow-Credentials': 'true', 177 | 'Access-Control-Allow-Methods': ['GET,PUT,POST'], 178 | 'Access-Control-Expose-Headers': ['Origin,X-Requested-With,Content-Type,Accept'], 179 | 'Content-Type': 'application/json;charset=UTF-8' 180 | }, 181 | body: typeof mockBeforeData === 'string' ? mockBeforeData : JSON.stringify(mockBeforeData) 182 | } 183 | } 184 | return true; 185 | } 186 | } 187 | } 188 | 189 | return false; 190 | }, 191 | 192 | // 如果shouldUseLocalResponse返回true,会调用这个函数来获取本地响应内容 193 | // you may deal the response locally instead of sending it to server 194 | // this function be called when shouldUseLocalResponse returns true 195 | // callback(statusCode,resHeader,responseData) 196 | // e.g. callback(200,{"content-type":"text/html"},"hello world") 197 | dealLocalResponse(req, reqBody, callback) { 198 | // const parsedReqUrl = url.parse(req.url, true); 199 | if (req.mockBeforeData != undefined) { 200 | const mockBeforeData = req.mockBeforeData; 201 | delete req.mockBeforeData; 202 | utilsLib.mockResponse(req.url, 203 | mockBeforeData.headers, 204 | mockBeforeData.body, 205 | (rq, rs) => rs.body, 206 | logger, 207 | (body) => { 208 | callback(200, mockBeforeData.headers, body) 209 | } 210 | ); 211 | } else if (isLocalFavicon(req, proxyOptions.hosts)) { 212 | fs.readFile(path.join(__dirname, 'favicon.ico'), (err, body) => { 213 | callback(200, { 214 | 'Content-Type': 'image/x-icon' 215 | }, body); 216 | }); 217 | } else if (proxyOptions.assetsComboMapLocal && 218 | proxyOptions.assetsComboRegExp && 219 | proxyOptions.assetsComboRegExp.test(req.url)) { 220 | let comboParts = req.url.split(Constants.COMBO_SIGN); 221 | const comboPrefix = comboParts[0]; 222 | comboParts = comboParts[1].split(Constants.COMBO_SEP) 223 | .map(comboPath => (comboPrefix + comboPath)); 224 | const localParts = proxyOptions.assetsComboMapLocal(req.url, comboParts); 225 | const protocol = proxyOptions.https ? 'https' : 'http'; 226 | const localServerPort = proxyOptions.serverPort; 227 | let ContentType; 228 | const localPartsPromise = localParts.map((localPath, index) => { 229 | return new Promise(resolve => { 230 | const localReqUrl = url.resolve(`${protocol}://127.0.0.1:${localServerPort}/`, localPath); 231 | // log 232 | logger.verbose.info(`>> request ${localPath} from : ${localReqUrl}`); 233 | request(localReqUrl, (err, resp, body) => { 234 | if (!err && resp.statusCode === 200) { 235 | !ContentType && (ContentType = resp.headers['content-type']); 236 | resolve(body); 237 | } else { 238 | // 本地加载失败, 请求远程 239 | const remoteUrl = comboParts[index]; 240 | // log 241 | logger.verbose.info(`>> request ${localPath} from : ${remoteUrl}`); 242 | request(remoteUrl, (remoteErr, remoteResp, remoteBody) => { 243 | if (!remoteErr && remoteResp.statusCode === 200) { 244 | !ContentType && (ContentType = remoteResp.headers['content-type']); 245 | resolve(remoteBody); 246 | } else { 247 | resolve( 248 | `;console.error("Error loading ${localPath} from remote ${remoteUrl}");` 249 | ); 250 | } 251 | }); 252 | } 253 | }); 254 | }); 255 | }); 256 | Promise.all(localPartsPromise).then(resolvedSourceCodes => { 257 | callback(200, { 258 | 'Content-Type': ContentType 259 | }, resolvedSourceCodes.join('\n')); 260 | }); 261 | } else { 262 | callback(404, {}, `[!]Error processing ${req.url}`); 263 | } 264 | }, 265 | 266 | 267 | // ======================= 268 | // when ready to send a request to server 269 | // 向服务端发出请求之前 270 | // ======================= 271 | 272 | // 替换向服务器发出的请求协议(http和https的替换) 273 | // replace the request protocol when sending to the real server 274 | // protocol : "http" or "https" 275 | // replaceRequestProtocol: function (req, protocol) { 276 | // var newProtocol = protocol; 277 | // return newProtocol; 278 | // }, 279 | 280 | // 替换向服务器发出的请求参数(option) 281 | // option is the configuration of the http request sent to remote server. 282 | // You may refers to http://nodejs.org/api/http.html#http_http_request_options_callback 283 | // you may return a customized option to replace the original one 284 | // you should not overwrite content-length header in options, 285 | // since anyproxy will handle it for you 286 | replaceRequestOption(req, option) { 287 | const newOption = option; 288 | 289 | const proxyHosts = proxyOptions.hosts; 290 | const requestHost = option.hostname; 291 | // TODO `isProxyToLocalServe` 可能不准, 如果 anyproxy 的 `replaceRequestOption` 支持异步才比较优雅 292 | if (proxyHosts.indexOf(requestHost) !== -1 293 | && isProxyToLocalServe(proxyOptions, option.path)) { 294 | // if (proxyHosts.indexOf(requestHost) != -1) { 295 | // 匹配代理 hostname 且符合代理到本地规则 296 | logger.info(`[Proxy Host] ${requestHost} => localhost`); 297 | newOption.hostname = 'localhost'; 298 | newOption.port = proxyOptions.serverPort; 299 | // prevent 304 not-modified response, http://stackoverflow.com/a/19168739/1661664 300 | newOption.headers['If-None-Match'] = 'no-match-for-this'; 301 | newOption.origUrl = req.url; 302 | 303 | } else if ((requestHost !== 'localhost') && (requestHost in hostsMap)) { 304 | // 匹配代理 hosts hostname 305 | const ipAddr = hostsMap[requestHost]; 306 | logger.info(`[Proxy Hosts] ${requestHost} => ${ipAddr}`); 307 | newOption.hostname = ipAddr; 308 | } else if (option.port === 80) { 309 | // console.log(newOption); 310 | // if (typeof proxyOptions.replaceRequestUrl === 'function') { 311 | // var newUrl = proxyOptions.replaceRequestUrl(url.format(newOption)); 312 | // } 313 | } 314 | 315 | return newOption; 316 | }, 317 | 318 | // 替换请求的body 319 | // replace the request body 320 | // replaceRequestData: function (req, data) { 321 | // return data; 322 | // }, 323 | 324 | // 325 | // ======================= 326 | // when ready to send the response to user after receiving response from server 327 | // 向用户返回服务端的响应之前 328 | // ======================= 329 | // 330 | // 替换服务器响应的http状态码 331 | // replace the statusCode before it's sent to the user 332 | // replaceResponseStatusCode(req, res, statusCode){ 333 | // let newStatusCode = statusCode; 334 | // const parsedReqUrl = url.parse(req.url, true); 335 | // const pathname = parsedReqUrl.pathname; 336 | // if (/\.html/.test(pathname)) { 337 | // // 检查对应 .we 文件本地路径是否存在 338 | // // 并且请求参数不包含 _wx_tpl 339 | // if ( 340 | // newStatusCode !== 302 && 341 | // fs.existsSync(path.join(proxyOptions.contentBase, pathname.replace(/\.html/, '.we'))) && 342 | // !parsedReqUrl.query._wx_tpl 343 | // ) { 344 | // // 302 重定向到带 _wx_tpl 参数的 URL 去 345 | // newStatusCode = 302; 346 | // } 347 | // } 348 | // return newStatusCode; 349 | // }, 350 | // 351 | // 替换服务器响应的http头 352 | // replace the httpHeader before it's sent to the user 353 | // Here header == res.headers 354 | replaceResponseHeader(req, res, header) { 355 | const newHeader = header; 356 | if (proxyOptions.corsInject) { 357 | newHeader['Access-Control-Allow-Origin'] = '*'; 358 | newHeader['Access-Control-Allow-Methods'] = ['GET,PUT,POST']; 359 | newHeader['Access-Control-Allow-Headers'] = 360 | ['Origin, X-Requested-With, Content-Type, Accept']; 361 | } 362 | // const parsedReqUrl = url.parse(req.url, true); 363 | // const pathname = parsedReqUrl.pathname; 364 | // if (/\.html/.test(pathname)) { 365 | // // 检查对应 .we 文件本地路径是否存在 366 | // // 并且请求参数不包含 _wx_tpl 367 | // if ( 368 | // fs.existsSync(path.join(proxyOptions.contentBase, pathname.replace(/\.html/, '.we'))) && 369 | // !parsedReqUrl.query._wx_tpl 370 | // ) { 371 | // // 302 重定向到带 _wx_tpl 参数的 URL 去 372 | // 373 | // // 没有 `_wx_tpl` 参数, 重定向一下 374 | // // TODO: https 开启时, req.url 仅为 pathname, to be fixed... 375 | // 376 | // parsedReqUrl.query._wx_tpl = `${req.url.replace(/\.html/, '.js')}`; 377 | // delete parsedReqUrl.search; 378 | // newHeader.location = url.format(parsedReqUrl); 379 | // } 380 | // } 381 | 382 | // 本地开发禁用 HSTS 的强制 HTTPS 行为 383 | // ref: http://stackoverflow.com/questions/34108241/non-authoritative-reason-header-field-http 384 | // ref: https://developer.mozilla.org/zh-CN/docs/Security/HTTP_Strict_Transport_Security 385 | newHeader['Strict-Transport-Security'] = 'max-age=0'; 386 | 387 | return newHeader; 388 | }, 389 | 390 | // 替换服务器响应的数据 391 | // replace the response from the server before it's sent to the user 392 | // you may return either a Buffer or a string 393 | // serverResData is a Buffer. 394 | // for those non-unicode response, serverResData.toString() should not be your first choice. 395 | replaceServerResDataAsync(req, res, serverResData, callback) { 396 | 397 | let requestUrl = req.url; 398 | 399 | // tag 是否需要 mock 400 | let needProxy = false; 401 | 402 | // 接口 mock 403 | if (proxyOptions.mockFunction && mockRegExp) { 404 | 405 | if (/^\//.test(requestUrl)) { 406 | let protocol = 'http:'; 407 | if (req.connection.encrypted) { 408 | protocol = 'https:'; 409 | } 410 | requestUrl = `${protocol}//${req.headers.host}${requestUrl}`; 411 | } 412 | 413 | if (mockRegExp.test(requestUrl)) { 414 | 415 | const resHeaders = res.headers; 416 | if (resHeaders.location || resHeaders.Location) { 417 | // 响应头包含 `"location"`, 表明到重定向 418 | callback(serverResData); 419 | } else { 420 | // 接口 mock 421 | utilsLib.mockResponse(requestUrl, 422 | resHeaders, 423 | serverResData.toString(), 424 | proxyOptions.mockFunction, 425 | logger, 426 | callback 427 | ); 428 | needProxy = true; 429 | } 430 | } 431 | } 432 | 433 | // 页面 mock, 处理 tms 区块 434 | const proxyHosts = proxyOptions.hosts; 435 | const reqHeaders = req.headers; 436 | const requestHost = reqHeaders.host || reqHeaders.hostname; 437 | const resContentType = res.headers['Content-Type'] || ''; 438 | if (resContentType.indexOf('text/html') !== -1 && 439 | (proxyHosts.indexOf(requestHost) !== -1 440 | || proxyOptions.htmlInterceptReg.test(requestUrl) 441 | ) 442 | ) { 443 | // 'content-type': 'text/html;charset=GBK' 处理 444 | let resCharset = 'utf-8'; 445 | if (resContentType && /charset=/i.test(resContentType)) { 446 | resCharset = resContentType.split('charset=')[1].toLowerCase(); 447 | } 448 | serverResData = iconv.decode(serverResData, resCharset); 449 | 450 | // cheerio parse html 451 | let $ = cheerio.load(serverResData, { 452 | normalizeWhitespace: false, 453 | xmlMode: false, 454 | decodeEntities: false 455 | }); 456 | const commentNodes = utilsLib.parseCommentNodes($, $.root(), []); 457 | 458 | const injectWV = proxyOptions.injectWV; 459 | if (injectWV) { 460 | // 在 WindVane 容器内注入 windvane.js 461 | const userAgent = reqHeaders['User-Agent']; 462 | const scriptPath = (typeof injectWV === 'string') ? injectWV : Constants.WV_SCRIPT; 463 | if (/WindVane/i.test(userAgent)) { 464 | $('head').prepend(``); 465 | } 466 | } 467 | 468 | // SSI include 469 | if (commentNodes.length > 0) { 470 | $ = ssiLib($, 471 | commentNodes, 472 | path.join(process.cwd(), url.parse(requestUrl).pathname), 473 | serverResData 474 | ) || $; 475 | } 476 | 477 | // 匹配代理 hostname, 且为页面请求 478 | if (typeof proxyOptions.htmlModify === 'function') { 479 | proxyOptions.htmlModify( 480 | req.url, 481 | reqHeaders, 482 | res.headers, 483 | $, 484 | commentNodes, 485 | logger, 486 | (finalServerResData) => { 487 | callback(iconv.encode(finalServerResData, resCharset)); 488 | } 489 | ); 490 | 491 | needProxy = true; 492 | } 493 | 494 | } 495 | 496 | if (!needProxy) { 497 | callback(serverResData); 498 | } 499 | } 500 | 501 | // 在请求返回给用户前的延迟时间 502 | // add a pause before sending response to user 503 | // pauseBeforeSendingResponse : function(req,res){ 504 | // var timeInMS = 0; //delay all requests for 1ms 505 | // return timeInMS; 506 | // } 507 | }; 508 | 509 | }; 510 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by 弘树 on 16/3/21. 3 | * http://gitlab.alibaba-inc.com/trip-tools/sonic 4 | * 5 | * Copyright (c) 2016 弘树 6 | * Licensed under the MIT license. 7 | */ 8 | "use strict"; 9 | 10 | const os = require('os'); 11 | const fs = require('fs'); 12 | const url = require('url'); 13 | const path = require('path'); 14 | const chalk = require('chalk'); 15 | const EventEmitter = require('events'); 16 | const PWD = process.cwd(); 17 | const _ = require('lodash'); 18 | const portfinder = require('portfinder'); 19 | const co = require('co'); 20 | 21 | // 先禁用掉 Anyproxy 的 Log 22 | const anyproxyLog = require('anyproxy/lib/log'); 23 | anyproxyLog.setPrintStatus(false); 24 | 25 | const proxy = require('anyproxy'); 26 | const certMgr = proxy.utils.certMgr; 27 | const express = require('express'); 28 | const serveIndex = require('serve-index'); 29 | const open = require('open'); 30 | const shell = require('shelljs'); 31 | const ws = require('ws'); 32 | const WebSocketServer = ws.Server; 33 | const utilsLib = require('./lib/utils'); 34 | const localIp = utilsLib.getLocalIp(); 35 | const initLogger = require('./lib/logger'); 36 | const Constants = require('./lib/constants'); 37 | const execSync = require('child_process').execSync; 38 | 39 | const isArm64 = () => { 40 | return execSync('uname -m').toString().trim() === 'arm64'; 41 | } 42 | 43 | const checkHttpsCA = (options) => { 44 | // create cert when you want to use https features 45 | // please manually trust this rootCA when it is the first time you run it 46 | if (options.https && !certMgr.isRootCAFileExists()) { 47 | shell.exec(`node ${__dirname}/lib/cert.js`, { 48 | silent: false 49 | }); 50 | // console.log('\n>> 请先参考 https://github.com/alibaba/anyproxy/wiki/HTTPS%E7%9B%B8%E5%85%B3%E6%95%99%E7%A8%8B 完成证书配置!'); 51 | console.log('\n>> 请先参考 http://anyproxy.io/cn/#osx%E7%B3%BB%E7%BB%9F%E4%BF%A1%E4%BB%BBca%E8%AF%81%E4%B9%A6 完成证书配置!'); 52 | console.log('>> 然后重新执行命令启动服务'); 53 | process.exit(); 54 | } 55 | } 56 | 57 | /** 58 | * 初始化 proxy 59 | * @param options 60 | * @param logger 61 | */ 62 | const initProxy = (options, logger) => { 63 | 64 | options.logger = logger; 65 | 66 | const proxyOptions = { 67 | type: 'http', 68 | port: options.proxyPort, 69 | hostname: 'localhost', 70 | rule: require('./lib/anyproxy4-rule')(options), 71 | 72 | // optional, save request data to a specified file, will use in-memory db if not specified 73 | // dbFile : null, 74 | 75 | // optional, port for web interface 76 | webPort: options.webPort, 77 | 78 | // optional, internal port for web socket, 79 | // replace this when it is conflict with your own service 80 | socketPort: options.socketPort, 81 | 82 | // optional, speed limit in kb/s 83 | // throttle : 10, 84 | 85 | // optional, set it when you don't want to use the web interface 86 | // disableWebInterface : false, 87 | 88 | // optional, do not print anything into terminal. do not set it when you are still debugging. 89 | silent: options.silent, 90 | interceptHttps: true, 91 | dangerouslyIgnoreUnauthorized: true 92 | }; 93 | 94 | new proxy.ProxyServer(proxyOptions).start(); 95 | }; 96 | 97 | /** 98 | * 创建 weex reload socket server 99 | * @param options {Object} 配置项 100 | * @param logger {Object} 101 | * @returns {*} 102 | * @constructor 103 | */ 104 | function WeexReloadSocket(options, logger) { 105 | const wss = new WebSocketServer({ port: options.weexPageReloadPort }); 106 | const self = this; 107 | 108 | wss.on('connection', wsClient => { 109 | logger.verbose.ok('>> Socket connection conneted for weex reload'); 110 | self.ws = wsClient; 111 | wsClient.on('message', message => { 112 | logger.verbose.info('>> Weex reload socket received: %s', message); 113 | }); 114 | // ws.send('something'); 115 | }); 116 | return self; 117 | } 118 | 119 | /** 120 | * 打印本地服务配置 121 | * @param options 122 | * @param logger 123 | */ 124 | const printServerTable = (options, logger) => { 125 | logger.ok(` 126 | -------------- 服务配置 -------------- 127 | 本地 IP 地址\t=> ${options.localIp} 128 | 本地代理服务\t=> ${options.localIp}:${options.proxyPort} 129 | 静态资源服务\t=> http://${options.localIp}:${options.serverPort} 130 | 请求代理监控\t=> http://localhost:${options.webPort} 131 | -------------- 服务配置 -------------- 132 | `); 133 | }; 134 | 135 | /** 136 | * 本地静态服务 setHeaders 共用逻辑 137 | * @param res 138 | * @param filePath 139 | * @constructor 140 | */ 141 | const StaticSetHeaders = (res, filePath) => { 142 | if (/\.jsbundle/.test(filePath)) { 143 | res.type('js'); 144 | } 145 | }; 146 | 147 | const weexReqMiddleWare = require('./lib/wx-middleware'); 148 | const qrcodeReqMiddleWare = require('./lib/qr-middleware'); 149 | 150 | const corsMiddleWare = (req, res, next) => { 151 | if (req.headers.origin) { 152 | res.set('Access-Control-Allow-Origin', req.headers.origin); 153 | } else if (req.headers.referer) { 154 | const refererObj = url.parse(req.headers.referer); 155 | res.set('Access-Control-Allow-Origin', `${refererObj.protocol}//${refererObj.host}`); 156 | } 157 | res.set('Access-Control-Allow-Headers', '*'); 158 | next(); 159 | } 160 | 161 | /** 162 | * 入口 163 | * @param options {object} 配置对象 164 | * @param [logger] {object} logger 165 | * @param callback {function} 回调 166 | */ 167 | module.exports = (options, logger, callbackFn) => { 168 | // 参数整理 169 | if (typeof callbackFn === 'undefined' && typeof logger === 'function') { 170 | callbackFn = logger; 171 | logger = {}; 172 | } 173 | 174 | const callback = (server) => { 175 | 176 | // 进程退出 177 | process.on('exit', () => { 178 | try { 179 | const cacheSize = Number(execSync(`ls -l |grep "^d"|wc -l`, { 180 | cwd: path.join(os.homedir(), '.anyproxy/cache') 181 | }).toString().trim()); 182 | if (cacheSize >= 80) { 183 | console.log(chalk.yellow.bold(`\n检查到 anyproxy 缓存文件(${cacheSize})可能占用磁盘空间较大,您可以执行:\n`)); 184 | console.log(chalk.yellow(`du -hs ~/.anyproxy/cache 查看大小`)); 185 | console.log(chalk.yellow(`rm -rf ~/.anyproxy/cache 清理空间\n`)); 186 | } 187 | } catch (err) { 188 | // console.error(err); 189 | } 190 | }); 191 | 192 | // ctrl+c 193 | process.on('SIGINT', () => { 194 | server.close(); 195 | process.exit(); 196 | }); 197 | 198 | callbackFn && callbackFn(server); 199 | }; 200 | 201 | const defaultOptions = Constants.defaultOptions; 202 | options = _.merge({}, defaultOptions, options); 203 | 204 | // 需要绑定 127.0.0.1 localhost,此处先检查 205 | utilsLib.checkHosts(); 206 | checkHttpsCA(options); 207 | 208 | logger = initLogger(logger); 209 | 210 | // 主流程 211 | co(function *() { 212 | 213 | // 先获取一堆可用的端口号,由于后面都是同步传入,而端口号的获取是异步的,因此先批量处理掉 214 | const PORT_OPTIONS = [ 215 | 'proxyPort', // 代理服务端口 216 | 'webPort', // anyproxy web ui 界面服务端口 217 | 'socketPort', // anyproxy websocket 服务端口 218 | 'serverPort', // 静态服务端口 219 | 'weexPageReloadPort', // weex 页面自动刷新端口 220 | ]; 221 | for (const [index, optionName] of PORT_OPTIONS.entries()) { 222 | const minPort = Math.max(index * 1000 + 8000, 8080); 223 | const port = yield portfinder.getPortPromise({ 224 | port: minPort, 225 | }); 226 | if ( 227 | options[optionName] == undefined || 228 | options[optionName] == defaultOptions[optionName] 229 | ) { 230 | options[optionName] = port; 231 | } 232 | } 233 | 234 | // 初始化 weex reloader websocket; 235 | const WeexReloaderSocket = options.weexPageReloadPort ? new WeexReloadSocket(options, logger) : null; 236 | 237 | // https 处理 238 | let server; 239 | const openBrowserEmitter = new EventEmitter(); 240 | const protocol = options.https ? 'https' : 'http'; 241 | const serverCert = path.join(Constants.HTTPS.CERT_DIR, `${localIp}.crt`); 242 | const serverKey = path.join(Constants.HTTPS.CERT_DIR, `${localIp}.key`); 243 | 244 | // node_modules 路径 245 | const resolver = (options.resolver && typeof options.resolver === 'string') ? { 246 | default: options.resolver 247 | } : Object.assign({ 248 | default: path.join(PWD, 'node_modules') 249 | }, options.resolver); 250 | 251 | // webpack 路径 252 | const webpackDir = resolver.webpack || resolver.default; 253 | const webpackDevServerDir = resolver.webpackDevServer || resolver.webpack || resolver.default; 254 | 255 | // 整理 webpackConfig 256 | var webpackConfig; 257 | if (!options.webpackConfig && !options.devServer) { 258 | // webpack config 为 null || undefined 259 | options.pureStatic = true; 260 | } else if (typeof options.webpackConfig === 'string') { 261 | // 字符串, 认为是 webpack 配置文件路径 262 | let webpackConfigPath = options.webpackConfig; 263 | 264 | // 相对/绝对路径处理 265 | if (!/^\//.test(webpackConfigPath)) { 266 | webpackConfigPath = path.join(PWD, webpackConfigPath); 267 | } 268 | 269 | try { 270 | webpackConfig = require(webpackConfigPath); 271 | // 更新 options.webpackConfig 272 | options.webpackConfig = webpackConfig; 273 | } catch (e) { 274 | if (fs.existsSync(webpackConfigPath)) { 275 | logger.error(`>> Error loading ${webpackConfigPath}`); 276 | logger.error(e.stack || e); 277 | } 278 | // webpack 配置不存在, 认为仅启动纯静态文件服务 279 | options.webpackConfig = null; 280 | options.pureStatic = true; 281 | } 282 | } else if (typeof options.webpackConfig === 'function') { 283 | var TEMP_ENV = process.env.NODE_ENV; 284 | process.env.NODE_ENV = 'development'; 285 | webpackConfig = options.webpackConfig(); 286 | process.env.NODE_ENV = TEMP_ENV; 287 | if (!webpackConfig) options.pureStatic = true; 288 | } else if (_.isObject(options.webpackConfig)) { 289 | webpackConfig = options.webpackConfig; 290 | } 291 | options.webpackConfig = webpackConfig; 292 | 293 | // HMR 注入 294 | // 3.3.0 开始自动注入 client 和 hmr,WTF!!! 295 | // https://github.com/webpack/webpack-dev-server/issues/1703 296 | // https://github.com/webpack/webpack-dev-server/pull/1738 297 | // https://github.com/webpack/webpack-dev-server/blob/v3.3.0/lib/utils/updateCompiler.js 298 | if (webpackConfig && options.injectHMR) { 299 | logger.verbose.info('HMR injected.'); 300 | const fixRelative = (str) => { 301 | if (!/^[./]/.test(str)) { 302 | return `./${str}`; 303 | } 304 | return str; 305 | }; 306 | const patchEntries = (webpackCfg) => { 307 | let context = webpackCfg.context || PWD; 308 | let entries = webpackCfg.entry; 309 | let hotScripts = [ 310 | fixRelative(`${path.relative(context, require.resolve(path.join(webpackDevServerDir, 'webpack-dev-server/client')))}?${protocol}://${localIp}:${options.serverPort}/`), 311 | fixRelative(`${path.relative(context, require.resolve(path.join(webpackDir, 'webpack/hot/dev-server')))}`) 312 | ]; 313 | 314 | if (_.isPlainObject(entries)) { 315 | // entry 为 map 对象 316 | // 为每个 entry 都拼接上 webpack hot scripts 317 | Object.keys(entries).forEach(entryKey => { 318 | let prevEntryScripts = entries[entryKey]; 319 | if (!Array.isArray(prevEntryScripts)) { 320 | if (typeof prevEntryScripts === 'string') { 321 | prevEntryScripts = [prevEntryScripts]; 322 | } else { 323 | prevEntryScripts = []; 324 | } 325 | } 326 | entries[entryKey] = prevEntryScripts.concat(hotScripts); 327 | }); 328 | } else if (Array.isArray(entries)) { 329 | // entry 为数组 330 | entries = entries.concat(hotScripts); 331 | } 332 | return Object.assign(webpackCfg, { 333 | entry: entries 334 | }); 335 | }; 336 | if (Array.isArray(webpackConfig)) { 337 | webpackConfig = webpackConfig.map(patchEntries); 338 | } else { 339 | webpackConfig = patchEntries(webpackConfig); 340 | } 341 | } 342 | 343 | // 初始化 Proxy Server 344 | initProxy(options, logger); 345 | 346 | // host 证书生成 347 | if (options.https && !( 348 | certMgr.isRootCAFileExists() 349 | && fs.existsSync(serverKey) 350 | && fs.existsSync(serverCert) 351 | )) { 352 | yield new Promise(resolve => { 353 | if (!certMgr.isRootCAFileExists()) { 354 | certMgr.generateRootCA(/* localIp, */() => { 355 | certMgr.getCertificate(localIp, resolve); 356 | }); 357 | } else { 358 | certMgr.getCertificate(localIp, resolve); 359 | } 360 | }); 361 | } 362 | 363 | // bind app middleware 364 | const bindAppMiddleware = (app) => { 365 | app.use(corsMiddleWare); 366 | if (options.middlewares) { 367 | options.middlewares.forEach(function(m) { 368 | app.use(m); 369 | }); 370 | } 371 | if (options.weexMiddleware || options.weexDebug) { 372 | app.use(weexReqMiddleWare( 373 | options.contentBase, 374 | logger, 375 | `http://127.0.0.1:${options.proxyPort}`, 376 | `${localIp}:${options.serverPort}`, 377 | protocol, 378 | options, 379 | webpackConfig 380 | )); 381 | } else if (options.qrcodeMiddleWare) { 382 | app.use(qrcodeReqMiddleWare( 383 | options.contentBase, 384 | logger, 385 | `${localIp}:${options.serverPort}` 386 | )); 387 | } 388 | // hack for contentBase support `index: false` 389 | app.use(serveIndex(options.contentBase, { 390 | icons: true 391 | })); 392 | app.use(express.static(options.contentBase, { 393 | index: false, 394 | setHeaders: StaticSetHeaders 395 | })); 396 | }; 397 | 398 | if (options.pureStatic) { 399 | // Pure Express Static Asset Server 400 | 401 | const app = express(); 402 | // bind middleware 403 | bindAppMiddleware(app); 404 | 405 | // start server 406 | if (options.https) { 407 | server = require('https').createServer({ 408 | key: fs.readFileSync(serverKey), 409 | cert: fs.readFileSync(serverCert) 410 | }, app).listen(options.serverPort); 411 | } else { 412 | server = app.listen(options.serverPort); 413 | } 414 | 415 | server.sockets = []; 416 | 417 | } else if (options.devServer) { 418 | const devServerOptions = options.devServerOptions || {}; 419 | 420 | if (typeof options.devServer === 'function') { 421 | server = yield options.devServer(Object.assign({ 422 | host: localIp, 423 | port: options.serverPort, 424 | https: options.https ? { 425 | key: serverKey, 426 | cert: serverCert, 427 | ca: Constants.HTTPS.CA 428 | } : false, 429 | bindAppMiddleware, 430 | afterCompile() { 431 | server.emitter.emit('compileDone'); 432 | }, 433 | }, devServerOptions)); 434 | } else { 435 | server = options.devServer; 436 | bindAppMiddleware(server); 437 | } 438 | 439 | if (devServerOptions.listen !== false) { 440 | server.listen(options.serverPort, '0.0.0.0', function() { 441 | server.emitter.emit('compileDone'); 442 | devServerOptions.afterCompile && devServerOptions.afterCompile.apply(this, arguments); 443 | }); 444 | } 445 | } else { 446 | 447 | // webpack-dev-server 448 | 449 | // 依赖确认 450 | yield utilsLib.loadPackage('webpack', '1.12', logger, webpackDir.replace(/\/node_modules\/?$/, '')); 451 | yield utilsLib.loadPackage('webpack-dev-server', '1.14', logger, webpackDevServerDir.replace(/\/node_modules\/?$/, '')); 452 | 453 | const webpack = require(path.join(webpackDir, 'webpack')); 454 | const ProgressPlugin = require(path.join(webpackDir, 'webpack/lib/ProgressPlugin')); 455 | const WebpackDevServer = require(path.join(webpackDevServerDir, 'webpack-dev-server')); 456 | 457 | if (options.progress) { 458 | const addProgressPlugin = function(cfg) { 459 | // 输出进度百分比 460 | let chars = 0; 461 | let lastState; 462 | let lastStateTime; 463 | 464 | cfg.plugins = cfg.plugins || []; 465 | cfg.plugins.push(new ProgressPlugin((percentage, msg) => { 466 | 467 | function goToLineStart(nextMessage) { 468 | let str = ''; 469 | for (; chars > nextMessage.length; chars--) { 470 | str += '\b \b'; 471 | } 472 | chars = nextMessage.length; 473 | for (let i = 0; i < chars; i++) { 474 | str += '\b'; 475 | } 476 | if (str) process.stderr.write(str); 477 | } 478 | 479 | var state = msg; 480 | if (percentage < 1) { 481 | percentage = Math.floor(percentage * 100); 482 | msg = `${percentage}% ${msg}`; 483 | if (percentage < 100) { 484 | msg = ` ${msg}`; 485 | } 486 | if (percentage < 10) { 487 | msg = ` ${msg}`; 488 | } 489 | } else { 490 | server.emitter.emit('compileDone'); 491 | } 492 | if (options.profile) { 493 | state = state.replace(/^\d+\/\d+\s+/, ''); 494 | if (percentage === 0) { 495 | lastState = null; 496 | lastStateTime = +new Date(); 497 | } else if (state !== lastState || percentage === 1) { 498 | const now = Date.now(); 499 | if (lastState) { 500 | const stateMsg = (now - lastStateTime) + 'ms ' + lastState; 501 | goToLineStart(stateMsg); 502 | process.stderr.write(stateMsg + '\n'); 503 | chars = 0; 504 | } 505 | lastState = state; 506 | lastStateTime = now; 507 | } 508 | } 509 | goToLineStart(msg); 510 | process.stderr.write(msg); 511 | })); 512 | } 513 | 514 | if (Array.isArray(webpackConfig)) { 515 | webpackConfig.forEach(function(cfg) { 516 | addProgressPlugin(cfg); 517 | }); 518 | } else { 519 | addProgressPlugin(webpackConfig); 520 | } 521 | } else { 522 | setTimeout(() => { 523 | openBrowserEmitter.emit('ready'); 524 | }, 3000); 525 | } 526 | 527 | // webpack compiler 初始化 528 | const compiler = webpack(webpackConfig); 529 | 530 | let outputConfig = webpackConfig.output; 531 | let devServerOptions = webpackConfig.devServer; 532 | if (Array.isArray(webpackConfig)) { 533 | // webpack.config 为数组时, 合并多个配置项 534 | devServerOptions = webpackConfig[0].devServer; 535 | outputConfig = _.merge.apply(null, [{}].concat( 536 | webpackConfig.map(configItem => configItem.output))); 537 | } 538 | 539 | server = new WebpackDevServer(compiler, _.merge({ 540 | filename: outputConfig.filename, 541 | publicPath: outputConfig.publicPath 542 | }, { 543 | // webpack-dev-server options 544 | 545 | // 3.3.1 开始需要指定这两个字段,WTF!!! 546 | // host and port can be undefined or null 547 | // https://github.com/webpack/webpack-dev-server/blob/master/CHANGELOG.md#bug-fixes 548 | // https://github.com/webpack/webpack-dev-server/pull/1779 549 | host: localIp, 550 | port: options.serverPort, 551 | 552 | // hack below static options 553 | contentBase: false, 554 | // contentBase: options.contentBase, 555 | // contentBase: "/path/to/directory", 556 | // or: contentBase: "http://localhost/", 557 | 558 | hot: true, 559 | // inline: true, 560 | 561 | // unused!!! 562 | // debug: true, 563 | // failsOnError: false, 564 | 565 | // Enable special support for Hot Module Replacement 566 | // Page is no longer updated, but a "webpackHotUpdate" message is send to the content 567 | // Use "webpack/hot/dev-server" as additional module in your entry point 568 | // Note: this does _not_ add the `HotModuleReplacementPlugin` like the CLI option does. 569 | 570 | // Set this as true if you want to access dev server from arbitrary url. 571 | // This is handy if you are using a html5 router. 572 | historyApiFallback: false, 573 | 574 | // Set this if you want webpack-dev-server to delegate a single path to an arbitrary server. 575 | // Use "*" to proxy all paths to the specified server. 576 | // This is useful if you want to get rid of 'http://localhost:8080/' in script[src], 577 | // and has many other use cases (see https://github.com/webpack/webpack-dev-server/pull/127 ). 578 | // proxy: { 579 | // "*": { 580 | // target: "http://localhost:8080", 581 | // secure: false 582 | // } 583 | // }, 584 | 585 | // webpack-dev-middleware options 586 | quiet: false, 587 | noInfo: false, 588 | // https://github.com/webpack/webpack-dev-server/issues/882#issuecomment-296436909 589 | // https://github.com/webpack/webpack-dev-server/commit/02ec65ba1016be2a20d0ff05cbcd5dd365d31a79#diff-15fb51940da53816af13330d8ce69b4eR332 590 | disableHostCheck: true, 591 | // lazy: true, 592 | // filename: "bundle.js", 593 | watchOptions: { 594 | aggregateTimeout: 300, 595 | poll: 1000 596 | }, 597 | https: options.https ? { 598 | key: fs.readFileSync(serverKey), 599 | cert: fs.readFileSync(serverCert), 600 | ca: fs.readFileSync(Constants.HTTPS.CA) 601 | } : false, 602 | // publicPath: "/assets/", 603 | headers: { 604 | 'X-Custom-Header': 'yes', 605 | 'Cache-Control': 'no-cache' 606 | }, 607 | before: (app/*, server, compiler*/) => { 608 | app.use(corsMiddleWare); 609 | }, 610 | stats: _.merge({ 611 | colors: true, 612 | chunks: false 613 | }, options.webpackStatsOption) 614 | }, devServerOptions)); 615 | 616 | // bind middleware 617 | bindAppMiddleware(server); 618 | 619 | server.listen(options.serverPort); 620 | 621 | } 622 | 623 | // 挂载 `EventEmitter` 624 | server.emitter = new EventEmitter(); 625 | 626 | function onWebpackCompileDone() { 627 | logger.ok('\n'); 628 | openBrowserEmitter.emit('ready'); 629 | if (WeexReloaderSocket && WeexReloaderSocket.ws) { 630 | try { 631 | WeexReloaderSocket.ws.send('refresh'); 632 | } catch (e) { 633 | logger.verbose.warn(e); 634 | } 635 | } 636 | } 637 | 638 | server.emitter.on('compileDone', function() { 639 | if (options.onCompileDone) { 640 | const res = options.onCompileDone.apply(this, arguments); 641 | if (res && res.then) { 642 | res.then(onWebpackCompileDone).catch(onWebpackCompileDone) 643 | } else { 644 | onWebpackCompileDone(); 645 | } 646 | } else { 647 | onWebpackCompileDone(); 648 | } 649 | }); 650 | 651 | const serverHost = `${protocol}://localhost:${options.serverPort}`; 652 | let serverPath = options.openUrl || (serverHost + options.openPath); 653 | 654 | const openBrowserKey = 'dev.openBrowser'; 655 | const clamRoot = path.join(os.homedir(), '.clam'); 656 | const clamConfigPath = path.join(clamRoot, 'config.json'); 657 | const clamConfigJSON = fs.existsSync(clamConfigPath) ? require(clamConfigPath) : {}; 658 | const openBrowser = (openBrowserKey in clamConfigJSON) ? clamConfigJSON[openBrowserKey] : options.openBrowser; 659 | 660 | if (openBrowser && server.sockets.length === 0) { 661 | // 当且仅当 webpack-dev-server 的 socket 连接不存在时自动打开浏览器 662 | 663 | // wait till first build complete 664 | openBrowserEmitter.once('ready', () => { 665 | 666 | // Print Server Detail 667 | printServerTable(_.merge(options, { 668 | localIp 669 | }), logger); 670 | 671 | // 重新打开 Anyprox 的 Log 672 | anyproxyLog.setPrintStatus(true); 673 | 674 | if (process.platform === 'darwin') { 675 | // 如果在 mac os 下, 默认打开新建 Chrome 浏览器配置代理 676 | serverPath = options.openUrl 677 | || ((`${protocol}://${options.hosts[0]}` || serverHost) + options.openPath); 678 | let cmd = [ 679 | isArm64() ? 'arch -arm64' : '', 680 | options.browserApp.replace(/\x20/g, '\\ '), 681 | `-proxy-server="http://127.0.0.1:${options.proxyPort}"`, 682 | '--auto-open-devtools-for-tabs', 683 | '--no-first-run', 684 | // '--js-flags="--trace-opt --trace-deopt --prof --noprof-lazy --log-timer-events"', 685 | `--user-data-dir="${path.join(options.chromeUserDir, `${options.proxyPort}`)}"`, 686 | serverPath 687 | ].join(' '); 688 | if (options.weexDebug && options.weexDebug.debugServerPort) { 689 | cmd += ` http://${localIp}:${options.weexDebug.debugServerPort}`; 690 | } 691 | logger.verbose.info(`>> [Open Chrome Command]: ${cmd}`); 692 | shell.exec(cmd, { 693 | silent: true, 694 | async: true 695 | }, (code, output) => { 696 | logger.verbose.info(`${code}\n------\n`); 697 | logger.verbose.info(output); 698 | }); 699 | } else { 700 | // 否则打开默认浏览器, 需用户手动绑定代理 701 | open(serverPath); 702 | } 703 | // for grunt watch task 704 | callback(server, options); 705 | }); 706 | } else { 707 | // wait till first build complete 708 | openBrowserEmitter.once('ready', () => { 709 | // for grunt watch task 710 | callback(server, options); 711 | }); 712 | } 713 | if (options.pureStatic) { 714 | // 别忘了自动打开浏览器 715 | openBrowserEmitter.emit('ready'); 716 | } 717 | }).then(val => { 718 | if (val) { 719 | logger.info(val); 720 | } 721 | }, err => { 722 | logger.error(err.stack); 723 | }); 724 | }; 725 | --------------------------------------------------------------------------------