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
JavaScript
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,
}