UNPKG

dalao-proxy

Version:

An expandable HTTP proxy based on the plug-in system for frontend developers with request caching request mock and development!

876 lines (758 loc) 32.6 kB
const chalk = require('chalk'); const request = require('request'); const through = require('through2'); const concat = require('concat-stream'); const zlib = require('zlib'); const URL = require('url').URL; const querystring = require('querystring'); const BodyParser = require('../parser/body-parser'); const { PluginInterrupt } = require('../plugin'); const { version } = require('../../config/index'); const { program } = require('..'); const { getType, fixJson, addHttpProtocol, locationMatch, formatHeaders, parseHeaders, locationTransform, } = require('../utils'); let plugins = []; /** * Calling single plugin instance method (not middleware defined method) * @private * @param {import('../plugin').Plugin} plugin * @param {string} method method name * @param {import('../context')} context * @returns {Promise} */ function _invokePluginMiddleware(plugin, method, context) { if (context.config.debug) { console.log( chalk.yellow(`[DEV] START run [${method}] of [${plugin.name}]` + (method !== 'beforeCreate' ? ` for [${context.request.url}]` : '') ) ); } return new Promise((resolve, reject) => { if (!plugin) return resolve(); const targetMethod = plugin[method]; if (typeof targetMethod === 'function') { targetMethod.call(plugin, context, error => { if (error) { reject({ error, plugin, method }); } else { resolve(); } if (context.config.debug) { console.log( chalk.yellow(`[DEV] END run [${method}] of [${plugin.name}]` + (method !== 'beforeCreate' ? ` for [${context.request.url}]` : '') + ' DONE' ) ); } }); } else { throw new Error(`${targetMethod} is not a middleware method`); } }); } /** * Base function to invoke all middlewares * @param {String} hookName * @param {Object} context * @param {Function} next */ function _invokeAllPluginsMiddlewares(hookName, context, next) { if (context.config.debug) { console.log( chalk.yellow(`[DEV] START run [${hookName}]` + (hookName !== 'beforeCreate' ? ` for [${context.request.url}]` : '') ) ); } if (!next) { plugins.forEach(plugin => { return _invokePluginMiddleware(plugin, hookName, context); }); return; } let callChain = Promise.resolve(); let chainInterrupted; plugins.forEach(plugin => { callChain = callChain .then(() => _invokePluginMiddleware(plugin, hookName, context)) .catch(errorContext => { chainInterrupted = errorContext; return context; }) }); callChain .then(() => { if (chainInterrupted) throw chainInterrupted; next.call(null, null, null, hookName); }) .catch(ctx => { if (next) { next.call(null, ctx.error, ctx.plugin, hookName); } else { console.error(hookName, ctx) } }) .finally(() => { if (context.config.debug) { console.log( chalk.yellow(`[DEV] END run [${hookName}]` + (hookName !== 'beforeCreate' ? ` for [${context.request.url}]` : '') + ` DONE.` ) ); } }) } /** * Invoke all plugins. Used for `onPipeRequest`, `onPipeResponse` * @private * @param {String} hookName * @param {Object} context * @param {Buffer} chunk * @param {String} enc * @param {Function} callback */ function _invokePipeAllPlugin(hookName, context, chunk, enc, transform, callback) { let total = plugins.length; if (!total) { callback(null, chunk); } let index = 0, currentPlugin = plugins[index], lastValue = chunk; actuator(currentPlugin, () => { callback(null, lastValue); }); function actuator(plugin, cb) { const hook = plugin[hookName]; hook.call(plugin, { ...context, chunk: lastValue, enc, transform }, (err, returnValue) => { if (!err) { lastValue = returnValue; } next(); }) function next() { if (index < total - 1) { currentPlugin = plugins[++index]; actuator(currentPlugin, cb); } else { cb(); } } } } // plugin interrupter handler function interrupter(context, resolve, reject) { return function (reason, plugin, hookName) { if (reason) { if (reason instanceof Error) { reject(reason); } else { reject(new PluginInterrupt(plugin, hookName, reason)); } } else { resolve(context); } } } /** * proxyRequestWrapper * @summary proxy life cycle flow detail * - Middleware: Resolve request params data [life-cycle:onRequest] * - Route matching * - Middleware: Route matching result [life-cycle:onRouteMatch] * - Route proxy * - Calculate proxy route * - Middleware: before proxy request [life-cycle:beforeProxy] * - Proxy request * - Collect request/proxy-response data * - Middleware: after proxy request [life-cycle:afterProxy] */ function proxyRequestWrapper(config, corePlugins) { plugins = corePlugins; function proxyRequest(req, res) { const { logger, host, port, headers: userHeaders, proxyTable, gzip } = config; const serverHost = host === '0.0.0.0' ? 'localhost' : host; const { method, url } = req; const _request = request[method.toLowerCase()]; res.setHeader('Via', 'dalao-proxy/' + version); res.setHeader('Connection', 'keep-alive'); Promise.resolve() .then(() => { const context = { config, request: req, response: res }; req.URL = require('url').parse(req.url); if (req.method === 'OPTIONS') { res.setHeader('Access-Control-Allow-Origin', req.headers.Origin); res.writeHead(200); res.end(); return Promise.reject(); } return context; }) /** * Middleware: on request arrived * @lifecycle onRequest * @param {Object} context * @returns {Object} context */ .then(context => Middleware_onRequest(context)) /** * Route matching * @resolve context.matched * @returns {Object} context */ .then(context => { // Matching strategy const locationMatcher = locationMatch(url, proxyTable); const mostAccurateMatch = locationMatcher.matched; let proxyPath; let matchedRoute; // Matched Proxy if (mostAccurateMatch) { proxyPath = mostAccurateMatch; matchedRoute = proxyTable[proxyPath]; context.matched = { path: proxyPath, route: matchedRoute, notFound: false, result: locationMatcher.matchResult }; return context; } // if the request not in the proxy table else { context.matched = { notFound: true }; return context; } }) /** * Route matching result * @param {Object} context * @param {Object} context.matched * @resolve context.matched * @returns {Object} context */ .then(context => Middleware_onRouteMatch(context)) /** * Calculate proxy route * @desc transform url * @param {Object} context * @param {Object} context.matched * @resolve context.proxy * @returns {Object} context */ .then(context => { context.proxy = {}; const { route: matchedRoute, path: matchedPath, result: matchedResult, notFound, redirect, } = context.matched; if (notFound) { const { request: { method, url }, response } = context; console.log(`404 ${method.toUpperCase()} ${url} can\'t match any route`); response.writeHead(404); response.end(); return Promise.reject('404 Not found'); } else if (!redirect) { // route config const { target: overwriteTarget } = matchedRoute; const proxyUrl = locationTransform(matchedRoute, matchedResult); // invalid request if (new RegExp(`\\b${serverHost}:${port}\\b`).test(overwriteTarget)) { res.writeHead(403, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(` <h1>🔴 403 Forbidden</h1> <p>Path to ${overwriteTarget} proxy cancelled</p> <h3>Can NOT proxy request to proxy server address, which may cause endless proxy loop.</h3> `); return Promise.reject(chalk.red(`> 🔴 Forbidden Hit! [${matchedPath}]`)); } context.proxy = { error: null, data: { error: null, request: null, response: null }, route: matchedRoute, uri: proxyUrl, URL: require('url').parse(proxyUrl) }; } return context; }) /** * Middleware: before proxy request * @lifecycle beforeProxy * @param {Object} context * @returns {Object} context */ .then(context => Middleware_beforeProxy(context)) /** * Proxy request * @desc send request * @proxy * @param {Object} context * @param {Object} context.matched * @param {Object} context.proxy * @resolve context.proxy.response * @returns {Object} context */ .then(context => { const { uri: proxyUrl, route: matchedRoute } = context.proxy; const { path: matchedPath, redirectMeta = {} } = context.matched; const x = _request(proxyUrl, { gzip: true, forever: true, timeout: 2 * 60 * 60 }); setProxyRequestHeaders(x, matchedRoute, proxyUrl); return new Promise(resolve => { const waitingList = []; let baseResStream; x.on('response', response => { // 检查并处理 br 编码 if (response.headers['content-encoding'] === 'br') { baseResStream = baseResStream.pipe(zlib.createBrotliDecompress()); // delete response.headers['content-encoding']; } context.proxy.parsedResponseStream = baseResStream; // 现在构建最终的响应流 let xResStream = baseResStream.pipe(through( function (chunk, encode, callback) { if (delayResponsePipeHandler) { delayResponsePipeHandler(callback); } else { callback(); } delayResponsePipeHandler = ((ctx, chk, enc) => { return (cb, isLastChunk) => { ctx.isLastChunk = isLastChunk; _invokePipeAllPlugin('onPipeResponse', ctx, chk, enc, this, (err, value) => { this.push(err ? chk : value); cb(); }); } }).call(null, context, chunk, encode); }, function (callback) { if (delayResponsePipeHandler) { setImmediate(() => { delayResponsePipeHandler(callback, true); delayResponsePipeHandler = null; }); } else { callback(); } } )); // 添加 gzip 压缩 let hasGziped; const acceptEncoding = req.headers['accept-encoding']; if (gzip && acceptEncoding && acceptEncoding.includes('gzip')) { xResStream = xResStream.pipe(zlib.createGzip()); hasGziped = true; } // 建立最终的输出管道 xResStream.pipe(res); /** * Real proxy request * Instance of http.ClientRequest */ context.proxy.req = x.req; /** * Real proxy response * Instance of http.IncomingMessage */ context.proxy.response = response; context.proxy.responseStream = xResStream; const resHeaders = { ...response.headers }; if (hasGziped) { resHeaders['content-encoding'] = 'gzip'; } else { delete resHeaders['content-encoding']; } setResponseHeaders(resHeaders, matchedRoute); res.writeHead(response.statusCode, response.statusMessage); // collect proxy request data if (program._collectingProxyData) { waitingList.push( collectResponseData(context.proxy.parsedResponseStream, response.headers) .then(data => { context.proxy.data.response = data; Middleware_onProxyDataRespond(context); }) .catch(err => context.data.error = err) ); } // collect request data if (program._collectingData) { waitingList.push( collectResponseData(context.proxy.responseStream, res.getHeaders(), true) .then(data => { context.data.response = data; }) .catch(err => context.proxy.data.error = err) ); } /** * onProxyRespond * @desc send request * @returns {Object} context */ const pluginOnProxyRespondPromise = Middleware_onProxyRespond(context); pluginOnProxyRespondPromise.catch(() => { }); waitingList.push(pluginOnProxyRespondPromise); }); x.on('error', error => { setResponseHeaders(); logger && console.error(chalk.red(`> Cannot to proxy ${method.toUpperCase()} [${matchedPath}] to [${proxyUrl}]: ${error.message}`)); context.proxy.error = error; res.writeHead(503, 'Service Unavailable'); res.write(error.message); res.end(' Connect to server failed with code ' + error.code); resolve([context]); }); x.on('end', () => { resolve(Promise.all(waitingList)); }); /** * Buffer pipeline stream data * so that the last chunk flag is added when the last data flows in. * Plugin.onPipeRequest/onPipeResponse can tell the timing of the last piece of data. */ let delayRequestPipeHandler; let delayResponsePipeHandler; const xReqStream = req .pipe(through( function (chunk, encode, callback) { if (delayRequestPipeHandler) { delayRequestPipeHandler(callback); } else { callback(); } delayRequestPipeHandler = ((ctx, chk, enc) => { return (cb, isLastChunk) => { ctx.isLastChunk = isLastChunk; /** * Middleware: on proxy response pipe request * @lifecycle onPipeRequest * @param {Object} ctx * @param {Buffer} chunk * @param {String} enc * @param {TransformStream} transform * @param {Function} next */ _invokePipeAllPlugin('onPipeRequest', ctx, chk, enc, this, (err, value) => { this.push(err ? chk : value); cb(); }); } }).call(null, context, chunk, encode); }, function (callback) { if (delayRequestPipeHandler) { /** * Help! * Looking for a more elegant solution! * * If using synchronize call may cause the last chunk not in the right order, * except the plugins implement the pipe API using async `next` calling. * Still not very clear with the reason. */ setImmediate(() => { delayRequestPipeHandler(callback, true); delayRequestPipeHandler = null; }); } else { callback(); } } )); const xResOriginStream = xReqStream .pipe(x); baseResStream = xResOriginStream; logger && console.log(chalk.green(`> Proxy [${matchedPath}]`) + ` ${method.toUpperCase()} ${redirectMeta.matched ? chalk.yellow(url) : url} ${chalk.green('>>>>')} ${proxyUrl}`); /** * x is an instance of request.Request */ context.proxy.request = x; /** * Proxy request stream after transformed */ context.proxy.requestStream = xReqStream; /** * Proxy response stream after transformed */ context.proxy.responseStream = null; /** * Original response stream */ context.proxy.originResponseStream = xResOriginStream; // collect data context.data = { error: null, request: null, response: null }; const dataCollector = { onDataCollected(fn) { this._onDataFn = fn; }, onProxyDataCollected(fn) { this._onProxyDataFn = fn; }, _onDataFn: () => null, _onProxyDataFn: () => null }; context.onDataCollected = dataCollector.onDataCollected.bind(dataCollector); context.onProxyDataCollected = dataCollector.onProxyDataCollected.bind(dataCollector); // collect client request data if (program._collectingData) { collectRequestData(context, req, (err, data) => { context.data.error = err; context.data.request = data; if (typeof dataCollector._onDataFn === 'function') { dataCollector._onDataFn.call(null, context, data); } }); } // collect proxy request data if (program._collectingProxyData) { collectRequestData(context, context.proxy.requestStream, (err, data) => { context.proxy.data.error = err; context.proxy.data.request = data; if (typeof dataCollector._onProxyDataFn === 'function') { dataCollector._onProxyDataFn.call(null, context, data); } }); } Middleware_onProxySetup(context); }); }) /** * Middleware: after proxy request * @lifecycle afterProxy * @param {Object} context * @returns {Object} context */ .then(([...args]) => { Middleware_afterProxy(args.pop()) }) .catch(error => { if (!error instanceof PluginInterrupt || config.debug) { console.error(error); console.log(); } }) /********************************************************/ /* Functions in Content ------------------------------- */ /********************************************************/ /** * Collect request data * @param {Context} context * @param {ReadableStream<Buffer>} source * @param {Function} callback */ function collectRequestData(context, source, callback) { let error; const reqContentType = formatHeaders(req.headers)['content-type']; source.on('error', error => { callback(error); }); source.pipe(concat(buffer => { const data = { rawBuffer: buffer, body: '', query: querystring.parse(context.request.URL.query), type: reqContentType }; data.body = BodyParser.parse(reqContentType, buffer, { appendRawFormData: true, errorHandler: err => { error = err; logger && console.log(' > Error: can\'t parse requset body. ' + error.message); } }); callback(error, data); })); } // Collect response data /** * * @param {Stream} source * @param {Stream} response * @param {boolean} isClient is the response to client * @returns */ function collectResponseData(source, headers, isClient) { return new Promise(resolve => { source.pipe(concat(buffer => { const data = { rawBuffer: buffer, rawData: buffer.toString(), data: '', type: null, size: buffer.byteLength, }; try { const _headers = formatHeaders(headers); const contentType = data.type = _headers['content-type']; const gziped = _headers['content-encoding'] === 'gzip'; if (/json/.test(contentType) && (isClient ? !gziped : true)) { data.data = JSON.parse(fixJson(data.rawData)); } } catch (error) { console.error(chalk.red(` > An error occurred (${error.message}) while parsing response data.`)); } resolve(data); })); }) } // set headers for proxy request function setProxyRequestHeaders(proxyRequest, matchedRoute, proxyUrl) { const { changeOrigin, headers } = matchedRoute || {}; const clientHeaders = formatHeaders(req.headers); const mergeList = []; const rewriteHeaders = formatHeaders({ 'Connection': 'keep-alive', 'Transfer-Encoding': 'chunked', 'Host': new URL(proxyUrl).host, 'Origin': changeOrigin ? new URL(proxyUrl).origin : clientHeaders['origin'], 'Content-Length': null }); // originalHeaders < rewriteHeaders < userHeaders < routeHeaders mergeList.push(rewriteHeaders); mergeList.push(formatHeaders(parseHeaders(userHeaders, 'request'))); mergeList.push(formatHeaders(parseHeaders(headers, 'request'))); setHeadersFor(proxyRequest, Object.assign({}, clientHeaders, ...mergeList)); } // set headers for response function setResponseHeaders(headers, matchedRoute) { const { headers: routeHeaders } = matchedRoute || { headers: {} }; const mergeList = []; const origin = formatHeaders(req.headers)['origin']; const rewriteHeaders = { 'transfer-encoding': 'chunked', 'connection': 'keep-alive', 'via': 'dalao-proxy/' + version, 'access-control-allow-origin': origin ? addHttpProtocol(origin) : '*', 'access-control-allow-methods': 'GET, POST, OPTIONS, PUT, PATCH, DELETE', 'access-control-allow-credentials': true, 'access-control-allow-headers': 'Content-Type, Authorization, Token', }; const proxyResponseHeaders = formatHeaders(headers || {}); // originalHeaders < rewriteHeaders < userHeaders < routeHeaders mergeList.push(rewriteHeaders); mergeList.push(formatHeaders(parseHeaders(userHeaders, 'response'))); mergeList.push(formatHeaders(parseHeaders(routeHeaders, 'response'))); const formattedHeaders = Object.assign({}, proxyResponseHeaders, ...mergeList, { // 'content-encoding': null, 'content-length': null, }); setHeadersFor(res, formattedHeaders); } function setHeadersFor(target, headers) { for (const header in headers) { const value = headers[header]; if (value === null || value === undefined) { target.removeHeader(header); } else { if (getType(value, ['String', 'Number', 'Boolean', 'Array'])) { target.setHeader(header, value); } } } } /********************************************************/ /* Middleware Functions in Content --------------------- */ /********************************************************/ // after request data resolved function Middleware_onRequest(context) { return new Promise((resolve, reject) => { _invokeAllPluginsMiddlewares('onRequest', context, interrupter(context, resolve, reject)); }); } // on route match function Middleware_onRouteMatch(context) { return new Promise((resolve, reject) => { _invokeAllPluginsMiddlewares('onRouteMatch', context, interrupter(context, resolve, reject)); }); } function Middleware_beforeProxy(context) { return new Promise((resolve, reject) => { _invokeAllPluginsMiddlewares('beforeProxy', context, interrupter(context, resolve, reject)); }); } function Middleware_onProxySetup(context) { _invokeAllPluginsMiddlewares('onProxySetup', context); } function Middleware_onProxyRespond(context) { return new Promise((resolve, reject) => { _invokeAllPluginsMiddlewares('onProxyRespond', context, interrupter(context, resolve, reject)); }); } function Middleware_onProxyDataRespond(context) { return new Promise((resolve, reject) => { _invokeAllPluginsMiddlewares('onProxyDataRespond', context, interrupter(context, resolve, reject)); }); } function Middleware_afterProxy(context) { _invokeAllPluginsMiddlewares('afterProxy', context); } } (function Middleware_beforeCreate() { _invokeAllPluginsMiddlewares('beforeCreate', { config }); })(); return proxyRequest; } module.exports = { httpCallback: proxyRequestWrapper, }