@calvin_von/proxy-plugin-monitor
Version:
A dalao-proxy plugin for request monitoring
438 lines (393 loc) • 15.6 kB
JavaScript
const fs = require('fs');
const path = require('path');
const mime = require('mime-types');
const chalk = require('chalk');
const open = require('launch-editor');
const FileStorage = require('./formdata-files.storage');
class WsSendData {
constructor(error, value) {
this.type = null;
this.action = null;
this.error = error instanceof Error ? error.message : error;
this.value = value;
}
setId(id) {
this.id = id;
return this;
}
setAction(type) {
this.action = type;
return this;
}
setType(type) {
this.type = type;
return this;
}
setResposeOf(request) {
this.setType('response');
this.setId(request.id);
this.setAction(request.type);
return this;
}
stringify() {
return JSON.stringify(this);
}
}
const Monitor = module.exports = function (app, config) {
const broadcast = app.ws.broadcast;
app.ws.on('connection', client => {
client.send(
new WsSendData(null, app.monitorService.config)
.setType('config')
.stringify()
);
console.log(' [monitor] Connected!');
client.on('message', message => {
const request = JSON.parse(message);
const { type, action, value } = request;
if (type === 'action') {
switch (action) {
case 'clean':
FileStorage.clean();
break;
case 'open-file':
open(value, config.editor, (filename, error) => {
console.error(chalk.red('> Open file error: ' + error.message));
});
break;
case 'download-file':
(() => {
const { field, savePath, id: recordId } = value;
const record = FileStorage.getRecord(recordId);
if (record) {
const fileData = record.files[field];
const filePath = path.join(savePath, fileData.name);
fs.writeFile(filePath, fileData.buffer, err => {
if (!err) {
open(filePath, config.editor, err => {
client.send(new WsSendData(err)
.setResposeOf(request)
.stringify())
});
}
else {
client.send(new WsSendData(err)
.setResposeOf(request)
.stringify())
}
});
}
else {
client.send(new WsSendData('no file cache found!')
.setResposeOf(request)
.stringify())
}
})();
break;
default:
break;
}
}
else if (type === 'file-system') {
(() => {
const {
path: currentPath,
isForward,
forwardDirname
} = value;
let dirPath = currentPath || process.cwd();
if (isForward) {
dirPath = path.join(dirPath, forwardDirname);
}
else if (isForward === false) {
dirPath = path.resolve(dirPath, '../');
}
const fileList = fs.readdirSync(dirPath, { withFileTypes: true }).map(it => ({ name: it.name, isDir: it.isDirectory() }));
client.send(
new WsSendData(null, {
path: dirPath,
list: fileList
})
.setResposeOf(request)
.stringify()
);
})();
}
});
});
app.ws.on('close', () => {
console.log(' [monitor] Disconnected!');
});
app.on('proxy:beforeProxy', function (ctx) {
try {
const id = ctx.monitor.id =
ctx.request.url + '__'
+ Date.now() + '-'
+ Math.random().toString(16).substring(2);
const nameRes = ctx.request.url.match(/\/(?:\S+)?$/)[0];
const data = {
id,
url: ctx.request.url,
name: {
suffix: nameRes,
prefix: ctx.request.url.replace(nameRes, '')
},
type: 'beforeProxy',
status: '(Pending)',
'General': {
'Origin URI': ctx.request.url,
'Proxy URI': ctx.proxy.uri,
'Method': ctx.request.method,
'Match Route': ctx.matched.path,
},
'Request Headers': ctx.request.headers,
'Response Headers': null,
'Proxy': {
'Proxy URI': ctx.proxy.uri,
'Matched Path': ctx.matched.path,
'Matched Target': ctx.matched.route.target,
'Change Origin': ctx.matched.route.changeOrigin,
},
'Proxy Request Headers': null,
'Proxy Response Headers': null,
data: {
request: {},
response: {},
},
'Timing': 0
};
ctx.monitor.data = data;
if (ctx.cache) {
const cacheMeta = {
...ctx.cache
};
delete cacheMeta.rawData;
delete cacheMeta.data;
data['type'] = 'hitCache';
data['General']['Status Code'] = `${ctx.response.statusCode} ${ctx.response.statusMessage}`;
data['Response Headers'] = ctx.response.getHeaders();
const now = ctx.monitor.times.request_end = Date.now();
data['Timing'] = now - ctx.monitor.times.request_start;
data['Cache'] = cacheMeta;
data.data = {
response: { ...ctx.cache }
};
delete data.data.response.rawBuffer;
data.status = {
code: ctx.response.statusCode,
message: ctx.response.statusMessage
}
broadcast(data);
}
else {
broadcast(data);
}
} catch (error) {
console.error(' [monitor] Error: ' + error.message);
}
});
app.on('proxy:onProxyRespond', function (ctx) {
const { req: proxyRequest, response: proxyResponse } = ctx.proxy;
const { data, times } = ctx.monitor;
data.data.request = { ...ctx.data.request };
delete data.data.request.rawBuffer;
data.status = '(Proxy responded)';
data.type = 'onProxyRespond';
data['Proxy Request Headers'] = proxyRequest.getHeaders();
data['Proxy Response Headers'] = proxyResponse.headers;
data['Timing'] = times.proxy_end - times.request_start;
data['Proxy']['Timing'] = times.proxy_end - times.proxy_start;
const cUrlString = requestToCurl(proxyRequest, data.data.request);
// console.log("🚀 ~ cUrlString:", cUrlString)
data['General']['cURL'] = cUrlString;
// send request data
if (ctx.data.request && ctx.data.request.body) {
const reqBodyType = ctx.data.request.type;
const originBody = ctx.data.request.body;
const proxyBody = ctx.proxy.data.request.body;
if (/json/.test(reqBodyType)) {
data['Request Payload'] = JSON.stringify(originBody, null, 4);
data['Request Payload[parsed]'] = originBody;
data['Proxy Request Payload'] = JSON.stringify(proxyBody, null, 4);
data['Proxy Request Payload[parsed]'] = proxyBody;
}
else if (/form-data/.test(reqBodyType)) {
const rawOriginBody = originBody._raw;
const rawProxyBody = proxyBody._raw;
delete originBody._raw;
delete proxyBody._raw;
data['Form Data'] = transformRawFormData(reqBodyType, rawOriginBody, originBody);
data['Form Data[parsed]'] = transformFormData(rawOriginBody, originBody);
data['Proxy Form Data'] = transformRawFormData(reqBodyType, rawProxyBody, proxyBody);
data['Proxy Form Data[parsed]'] = transformFormData(rawProxyBody, proxyBody);
const filesData = attachDownloadableFile(rawOriginBody, originBody);
FileStorage.storeRecord({ id: data.id, files: filesData });
}
else if (/x-www-form-urlencoded/.test(reqBodyType)) {
data['Form Data'] = JSON.stringify(originBody, null, 4);
data['Form Data[parsed]'] = originBody;
data['Proxy Form Data'] = JSON.stringify(proxyBody, null, 4);
data['Proxy Form Data[parsed]'] = proxyBody;
}
}
// send request query data
if (ctx.request.URL.query) {
data['Query String Parameters'] = JSON.stringify(ctx.data.request.query, null, 4);
data['Query String Parameters[parsed]'] = ctx.data.request.query;
data['Proxy Query String Parameters'] = JSON.stringify(ctx.data.request.query, null, 4);
data['Proxy Query String Parameters[parsed]'] = ctx.data.request.query;
}
broadcast(data);
});
app.on('proxy:afterProxy', function (ctx) {
const gziped = ctx.config.gzip;
try {
const headers = ctx.response.getHeaders();
const data = {
id: ctx.monitor.id,
type: 'afterProxy',
data: {
request: {
...ctx.proxy.data.request
},
response: {
...(gziped ? ctx.proxy.data.response : ctx.data.response)
}
},
'General': {
'Status Code': `${ctx.proxy.response.statusCode} ${ctx.proxy.response.statusMessage}`,
},
status: {
code: ctx.proxy.response.statusCode,
message: ctx.proxy.response.statusMessage
},
'Proxy Response': {
...ctx.proxy.data.response,
rawBuffer: null
},
'Response Headers': headers,
'Timing': ctx.monitor.times.request_end - ctx.monitor.times.request_start
};
delete data.data.request.rawBuffer;
delete data.data.response.rawBuffer;
if (ctx.data.error) {
data['General']['Status Code'] = `(failed) ${ctx.data.error.code}`;
data.status = {
code: '(failed)',
message: ctx.data.error.code
};
}
if (!headers['content-type']) {
if (ctx.request.url === '/') {
headers['content-type'] = 'text/html';
}
else if (/\.\w+$/.test(ctx.request.url)) {
headers['content-type'] = mime.lookup(ctx.request.url);
}
}
broadcast(Object.assign(ctx.monitor.data, data));
} catch (error) {
console.error(' [monitor] Error: ' + error.message);
}
});
}
Monitor.syncConfig = function (app) {
app.ws.broadcast(
new WsSendData(null, app.monitorService.config)
.setType('config')
.stringify()
);
};
Monitor.cleanMonitor = function (app) {
app.ws.broadcast(
new WsSendData(null)
.setType('clean')
.stringify()
);
};
function attachDownloadableFile(rawBody, body) {
const files = {};
Object.keys(rawBody).forEach(field => {
if (rawBody[field].isFile) {
files[field] = {
buffer: rawBody[field].body,
name: body[field].name
};
}
});
return files;
}
function transformFormData(rawBody, body) {
const formData = {};
for (const field in body) {
if (rawBody[field].isFile) {
formData[field] = `<File: ${body[field].name}>`;
}
else {
formData[field] = body[field];
}
}
return formData;
}
/**
* replace file content of form data
* @param {String} type request's Content-Type
* @param {String} rawBody raw request body
*/
function transformRawFormData(contentType, rawBody, body) {
const boundary = '--' + contentType.match(/boundary=(\S+)/)[1];
let content = '';
Object.keys(rawBody).forEach(field => {
const fieldHead = rawBody[field].head.toString();
let fieldBody;
if (rawBody[field].isFile) {
fieldBody = `<File: ${body[field].name}>`;
}
else {
fieldBody = body[field];
}
const fieldValue = boundary + '\n' + fieldHead + '\n\n' + fieldBody + '\n';
content += fieldValue;
});
return content + boundary + '--';
}
/**
* 将 Node.js HTTP 请求参数转换为 cURL 命令字符串
*/
function requestToCurl(req, body) {
const method = req.method ?? 'GET';
// 尝试从 req.socket 和 req.path 组合 URL
const protocol = (req.protocol ?? (req.agent && req.agent.protocol)) ?? 'http:';
const socket = req.socket;
const host = req.getHeader('host') || socket?.remoteAddress || 'localhost';
const path = req.path || req.getHeader(':path') || '/';
const url = `${protocol}//${host}${path}`;
// 构建 curl 命令
let curlCommand = `curl`;
// 添加请求方法(如果不是 GET)
if (method !== 'GET') {
curlCommand += ` -X ${method}`;
}
curlCommand += ` '${url}'`;
// 添加 headers
const headers = req.getHeaders ? req.getHeaders() : {};
Object.entries(headers)
.filter(([_, value]) => value != null)
.forEach(([key, value]) => {
if (Array.isArray(value)) {
value.forEach(v => {
curlCommand += ` \\\n -H '${key}: ${v}'`;
});
} else {
curlCommand += ` \\\n -H '${key}: ${value}'`;
}
});
// 添加 body 数据
if (body) {
const bodyStr = typeof body === 'string' ? body : JSON.stringify(body);
curlCommand += ` \\\n --data '${bodyStr}'`;
}
// 添加常用选项
curlCommand += ` \\\n --compressed \\\n --insecure`;
return curlCommand;
}