chanjs 2.7.4 → 2.7.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/USAGE.md +533 -0
- package/config/index.js +37 -6
- package/core/App.js +166 -0
- package/core/BaseComponent.js +27 -0
- package/core/Container.js +68 -0
- package/core/Controller.js +29 -0
- package/core/Database.js +93 -0
- package/core/Repository.js +323 -0
- package/core/Service.js +11 -0
- package/core/bootstrap/error-handler.js +101 -0
- package/core/bootstrap/hook-runner.js +64 -0
- package/core/bootstrap/middleware.js +35 -0
- package/core/bootstrap/router-loader.js +53 -0
- package/core/errors.js +251 -0
- package/core/loader.js +89 -0
- package/core/registry.js +17 -0
- package/doc/Cache.md +279 -106
- package/doc/Common.md +590 -134
- package/doc/Controller.md +166 -95
- package/doc/Help.md +299 -698
- package/doc/QuickStart.md +116 -0
- package/doc/Repository.md +560 -0
- package/doc/Service.md +201 -527
- package/index.js +61 -37
- package/middleware/body.js +17 -0
- package/middleware/cookie.js +7 -15
- package/middleware/cors.js +9 -27
- package/middleware/favicon.js +15 -17
- package/middleware/header.js +15 -16
- package/middleware/index.js +11 -11
- package/middleware/log.js +26 -56
- package/middleware/static.js +15 -28
- package/middleware/template.js +75 -115
- package/middleware/validate.js +79 -0
- package/middleware/waf.js +176 -197
- package/package.json +9 -2
- package/response/code.js +73 -0
- package/response/index.js +9 -6
- package/response/response.js +82 -236
- package/security/checker.js +26 -74
- package/security/index.js +4 -9
- package/security/jwt.js +84 -139
- package/security/keywords.js +33 -137
- package/security/rate-limit.js +38 -80
- package/security/sign.js +83 -176
- package/security/xss-filter.js +21 -53
- package/storage/cache.js +58 -198
- package/storage/index.js +3 -6
- package/storage/redis.js +124 -181
- package/storage/store.js +163 -188
- package/utils/data-parse.js +42 -186
- package/utils/file.js +73 -244
- package/utils/filter.js +22 -25
- package/utils/html.js +49 -33
- package/utils/index.js +20 -7
- package/utils/ip.js +31 -71
- package/utils/logger.js +117 -0
- package/utils/pages.js +55 -0
- package/utils/paths.js +18 -0
- package/utils/request.js +95 -136
- package/utils/signal.js +87 -0
- package/utils/time.js +33 -75
- package/utils/tree.js +112 -104
- package/App.js +0 -533
- package/base/Aop.js +0 -195
- package/base/Container.js +0 -161
- package/base/Controller.js +0 -65
- package/base/Database.js +0 -133
- package/base/Event.js +0 -61
- package/base/Repository.js +0 -644
- package/common/api.js +0 -35
- package/common/code.js +0 -52
- package/common/email.js +0 -191
- package/common/index.js +0 -5
- package/common/pages.js +0 -120
- package/common/utils.js +0 -73
- package/config/code.js +0 -166
- package/config/paths.js +0 -60
- package/doc/Aop.md +0 -269
- package/doc/Email.md +0 -114
- package/doc/Event.md +0 -232
- package/global/env.js +0 -11
- package/global/import.js +0 -39
- package/global/index.js +0 -8
- package/helper/index.js +0 -79
- package/loader/index.js +0 -6
- package/loader/loader.js +0 -138
- package/middleware/compress.js +0 -185
- package/middleware/setBody.js +0 -32
- package/realtime/index.js +0 -7
- package/realtime/sse.js +0 -424
- package/realtime/websocket.js +0 -540
- package/schedule/index.js +0 -6
- package/schedule/schedule.js +0 -491
package/utils/ip.js
CHANGED
|
@@ -1,72 +1,32 @@
|
|
|
1
|
-
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
* const ip = getIp(req);
|
|
33
|
-
* console.log(`访问 IP: ${ip}`);
|
|
34
|
-
* next();
|
|
35
|
-
* });
|
|
36
|
-
*/
|
|
37
|
-
export function getIp(req) {
|
|
38
|
-
const headers = req.headers || {};
|
|
39
|
-
|
|
40
|
-
// 1. 仅在 CF_ENABLED=true 时信任 cf-* 头部
|
|
41
|
-
// 单凭 cf-ray 头部仍可被伪造,必须显式声明部署在 Cloudflare 后才信任
|
|
42
|
-
// 否则攻击者可任意伪造 cf-connecting-ip / cf-ray 绕过 IP 限流
|
|
43
|
-
if (process.env.CF_ENABLED === 'true' && headers["cf-ray"]) {
|
|
44
|
-
let cfIp = headers["cf-connecting-ip"];
|
|
45
|
-
if (cfIp) {
|
|
46
|
-
cfIp = cfIp.split(',').map(ip => ip.trim())[0];
|
|
47
|
-
if (cfIp) {
|
|
48
|
-
return cfIp;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// 2. 其次使用 Express 解析的 req.ip(已处理代理头部)
|
|
54
|
-
if (req.ip) {
|
|
55
|
-
let ip = req.ip;
|
|
56
|
-
|
|
57
|
-
// 处理 IPv6 映射的 IPv4 地址(如 ::ffff:192.168.1.1 → 192.168.1.1)
|
|
58
|
-
if (ip.startsWith('::ffff:')) {
|
|
59
|
-
ip = ip.substring(7);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// 处理本地回环地址(::1 → 127.0.0.1)
|
|
63
|
-
if (ip === '::1') {
|
|
64
|
-
ip = '127.0.0.1';
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
return ip;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// 3. 无法获取时返回约定的默认值
|
|
71
|
-
return "0.0.0.0";
|
|
1
|
+
/**
|
|
2
|
+
* 获取客户端真实 IP(Express 专用)
|
|
3
|
+
* 前置要求:App 已配置 app.set('trust proxy', <上游代理IP/CIDR列表>),Express 自动解析真实客户端 IP。
|
|
4
|
+
* 注意:trust proxy 必须精确声明真实上游代理(如 loopback / 10.0.0.0/8),切勿设为 true,
|
|
5
|
+
* 否则攻击者可伪造 X-Forwarded-For 伪装成内网 IP 绕过 WAF。
|
|
6
|
+
* IP 信任优先级(防头部伪造,层级严格限制):
|
|
7
|
+
* 1. cf-connecting-ip(仅同时满足 CF_ENABLED=true + 请求携带 cf-ray 请求头,才可信,避免伪造CF头)
|
|
8
|
+
* 2. Express 内置解析 req.ip(已融合代理链路,最稳妥通用)
|
|
9
|
+
* 3. 兜底默认值 0.0.0.0
|
|
10
|
+
* 额外处理:IPv6 映射IPv4、IPv6本地回环统一转为标准IPv4格式
|
|
11
|
+
* @param {import('express').Request} req Express 请求对象
|
|
12
|
+
* @returns {string} 客户端IP地址
|
|
13
|
+
*/
|
|
14
|
+
export function getIp(req) {
|
|
15
|
+
// 1. Cloudflare 源IP(双重校验环境变量+cf-ray头,防止恶意伪造CF头部)
|
|
16
|
+
if (process.env.CF_ENABLED === 'true' && req?.headers?.['cf-ray']) {
|
|
17
|
+
const cfIp = req.headers['cf-connecting-ip']
|
|
18
|
+
?.split(',')[0]?.trim();
|
|
19
|
+
if (cfIp) return cfIp;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// 2. Express 自动解析后的标准IP
|
|
23
|
+
if (typeof req?.ip === 'string' && req.ip.trim()) {
|
|
24
|
+
const ip = req.ip.trim()
|
|
25
|
+
.replace(/^::ffff:/, '') // IPv6映射IPv4
|
|
26
|
+
.replace(/^::1$/, '127.0.0.1'); // IPv6本地回环
|
|
27
|
+
return ip;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// 兜底未知IP
|
|
31
|
+
return '0.0.0.0';
|
|
72
32
|
}
|
package/utils/logger.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 统一日志工具
|
|
3
|
+
* 1. 本地dev:彩色友好打印
|
|
4
|
+
* 2. 线上prd:JSON单行字符串输出,供日志采集器解析
|
|
5
|
+
* 3. 日志分级控制 LOG_LEVEL,线上关闭调试日志减少磁盘占用
|
|
6
|
+
* 4. error/fatal 自动走 stderr
|
|
7
|
+
* 5. 支持自定义模块标签、本地文件落盘、错误堆栈结构化解析
|
|
8
|
+
*/
|
|
9
|
+
import fs from "fs";
|
|
10
|
+
import path from "path";
|
|
11
|
+
import { parseStack } from "../core/errors.js";
|
|
12
|
+
|
|
13
|
+
const LOG_LEVELS = Object.freeze({
|
|
14
|
+
trace: 0, debug: 1, info: 2, warn: 3, error: 4, fatal: 5, silent: 99,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const COLORS = Object.freeze({
|
|
18
|
+
trace: "\x1b[90m",
|
|
19
|
+
debug: "\x1b[34m",
|
|
20
|
+
info: "\x1b[32m",
|
|
21
|
+
warn: "\x1b[33m",
|
|
22
|
+
error: "\x1b[31m",
|
|
23
|
+
fatal: "\x1b[41m\x1b[37m",
|
|
24
|
+
reset: "\x1b[0m",
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const ERROR_LEVELS = new Set(["error", "fatal"]);
|
|
28
|
+
|
|
29
|
+
const NODE_ENV = (process.env.NODE_ENV || "dev").toLowerCase();
|
|
30
|
+
const IS_PROD = ["prd", "prod", "production"].includes(NODE_ENV);
|
|
31
|
+
const DEFAULT_LOG_LEVEL = IS_PROD ? "info" : "debug";
|
|
32
|
+
const CURRENT_LEVEL = LOG_LEVELS[(process.env.LOG_LEVEL || DEFAULT_LOG_LEVEL).toLowerCase()] ?? LOG_LEVELS.info;
|
|
33
|
+
|
|
34
|
+
const LOG_FILE = process.env.LOG_FILE || "";
|
|
35
|
+
let writeStream = null;
|
|
36
|
+
|
|
37
|
+
/** 判断当前级别是否允许输出 */
|
|
38
|
+
const isLevelEnable = level => (LOG_LEVELS[level] ?? 99) >= CURRENT_LEVEL;
|
|
39
|
+
|
|
40
|
+
/** 格式化毫秒级时间戳 YYYY-MM-DD HH:mm:ss.SSS */
|
|
41
|
+
function getTime() {
|
|
42
|
+
const now = new Date();
|
|
43
|
+
const pad = (n, len = 2) => String(n).padStart(len, "0");
|
|
44
|
+
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}.${pad(now.getMilliseconds(), 3)}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 本地日志文件写入(仅开发环境) */
|
|
48
|
+
function writeFile(line) {
|
|
49
|
+
if (!LOG_FILE.trim() || IS_PROD) return;
|
|
50
|
+
try {
|
|
51
|
+
if (!writeStream) {
|
|
52
|
+
const dir = path.dirname(LOG_FILE);
|
|
53
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
54
|
+
writeStream = fs.createWriteStream(LOG_FILE, { flags: "a" });
|
|
55
|
+
writeStream.on("error", () => {}); // 静默吞掉流异常,不阻塞业务
|
|
56
|
+
process.once("exit", () => writeStream?.end());
|
|
57
|
+
}
|
|
58
|
+
writeStream.write(`${line}\n`);
|
|
59
|
+
} catch { /* 写入失败静默 */ }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 解析日志入参,分离普通文本与错误堆栈 */
|
|
63
|
+
function parseArgs(args) {
|
|
64
|
+
let stackInfo = null;
|
|
65
|
+
const parts = [];
|
|
66
|
+
for (const arg of args) {
|
|
67
|
+
if (arg instanceof Error) {
|
|
68
|
+
const stackData = parseStack(arg.stack);
|
|
69
|
+
stackInfo = { ...stackData, raw: arg.stack };
|
|
70
|
+
parts.push(arg.message);
|
|
71
|
+
} else if (arg !== null && typeof arg === "object") {
|
|
72
|
+
try { parts.push(JSON.stringify(arg)); }
|
|
73
|
+
catch { parts.push(String(arg)); }
|
|
74
|
+
} else {
|
|
75
|
+
parts.push(arg);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return { message: parts.join(" "), stackInfo };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 日志核心输出 */
|
|
82
|
+
function emit(level, label, args) {
|
|
83
|
+
if (!isLevelEnable(level)) return;
|
|
84
|
+
|
|
85
|
+
const { message, stackInfo } = parseArgs(args);
|
|
86
|
+
const time = getTime();
|
|
87
|
+
const isErrLog = ERROR_LEVELS.has(level);
|
|
88
|
+
const printFn = isErrLog ? console.error : console.log;
|
|
89
|
+
|
|
90
|
+
if (IS_PROD) {
|
|
91
|
+
const logLine = JSON.stringify({ time, env: NODE_ENV, level, label, message, stack: stackInfo, pid: process.pid });
|
|
92
|
+
printFn(logLine);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const color = COLORS[level] ?? "";
|
|
97
|
+
printFn(`${color}[${time}] [${level.toUpperCase()}] [${label}]${COLORS.reset}`, ...args);
|
|
98
|
+
if (stackInfo) console.error(`${color}>>>> 错误堆栈详情:${COLORS.reset}`, stackInfo);
|
|
99
|
+
writeFile(`[${time}] [${level.toUpperCase()}] [${label}] ${message}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 创建带固定业务标签的日志实例 */
|
|
103
|
+
export function createLogger(label = "Chan") {
|
|
104
|
+
const safeLabel = String(label).trim() || "Chan";
|
|
105
|
+
const make = level => (...args) => emit(level, safeLabel, args);
|
|
106
|
+
return {
|
|
107
|
+
trace: make("trace"),
|
|
108
|
+
debug: make("debug"),
|
|
109
|
+
info: make("info"),
|
|
110
|
+
warn: make("warn"),
|
|
111
|
+
error: make("error"),
|
|
112
|
+
fatal: make("fatal"),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const logger = createLogger("Chan");
|
|
117
|
+
export default logger;
|
package/utils/pages.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 分页工具函数
|
|
3
|
+
* 样式采用 Tailwind CSS 原子类,紧凑精致布局
|
|
4
|
+
*/
|
|
5
|
+
const LI_DISABLED = 'inline-flex items-center px-3 py-1.5 text-sm text-slate-400 cursor-not-allowed rounded-md border border-slate-100 bg-slate-50';
|
|
6
|
+
const LI_ELLIPSIS = 'inline-flex items-center px-2 py-1.5 text-sm text-slate-400';
|
|
7
|
+
const LI_CURRENT = 'inline-flex items-center px-3 py-1.5 text-sm font-medium text-white bg-lime-600 border border-lime-600 rounded-md';
|
|
8
|
+
const LI_LINK = 'inline-flex items-center text-sm text-slate-700 bg-white border border-slate-200 rounded-md hover:border-lime-600 hover:text-lime-600 transition-colors';
|
|
9
|
+
const A_LINK = 'block px-3 py-1.5';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 渲染分页HTML
|
|
13
|
+
* @param {number} current 当前页码
|
|
14
|
+
* @param {number} total 总记录数
|
|
15
|
+
* @param {number} pageSize 每页大小
|
|
16
|
+
* @param {string} href 链接基础路径
|
|
17
|
+
* @param {string} [query=''] 查询参数字符串
|
|
18
|
+
* @returns {string}
|
|
19
|
+
*/
|
|
20
|
+
export function pages(current, total, pageSize, href, query = '') {
|
|
21
|
+
current = Math.max(1, parseInt(current) || 1);
|
|
22
|
+
total = parseInt(total) || 0;
|
|
23
|
+
pageSize = parseInt(pageSize) || 10;
|
|
24
|
+
|
|
25
|
+
const totalPage = Math.ceil(total / pageSize);
|
|
26
|
+
if (totalPage <= 1) return '';
|
|
27
|
+
if (current > totalPage) current = totalPage;
|
|
28
|
+
|
|
29
|
+
const pageLink = (i, text) => `<li class="${i === current ? LI_CURRENT : LI_LINK}"><a class="${A_LINK}" href='${href}${i}.html${query}'>${text || i}</a></li>`;
|
|
30
|
+
const navLink = (i, text) => i ? pageLink(i, text) : `<li class="${LI_DISABLED}">${text}</li>`;
|
|
31
|
+
const items = [];
|
|
32
|
+
|
|
33
|
+
// 上一页
|
|
34
|
+
items.push(navLink(current - 1, "上一页"));
|
|
35
|
+
|
|
36
|
+
// 页码
|
|
37
|
+
if (totalPage <= 7) {
|
|
38
|
+
for (let i = 1; i <= totalPage; i++) items.push(pageLink(i));
|
|
39
|
+
} else if (current <= 4) {
|
|
40
|
+
for (let i = 1; i <= 5; i++) items.push(pageLink(i));
|
|
41
|
+
items.push(`<li class="${LI_ELLIPSIS}">...</li>`, pageLink(totalPage));
|
|
42
|
+
} else if (current >= totalPage - 3) {
|
|
43
|
+
items.push(pageLink(1), `<li class="${LI_ELLIPSIS}">...</li>`);
|
|
44
|
+
for (let i = totalPage - 4; i <= totalPage; i++) items.push(pageLink(i));
|
|
45
|
+
} else {
|
|
46
|
+
items.push(pageLink(1), `<li class="${LI_ELLIPSIS}">...</li>`);
|
|
47
|
+
for (let i = current - 1; i <= current + 1; i++) items.push(pageLink(i));
|
|
48
|
+
items.push(`<li class="${LI_ELLIPSIS}">...</li>`, pageLink(totalPage));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 下一页
|
|
52
|
+
items.push(navLink(current + 1, "下一页"));
|
|
53
|
+
|
|
54
|
+
return items.join("");
|
|
55
|
+
}
|
package/utils/paths.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
|
|
3
|
+
/** 路径配置:项目常用路径访问器(getter 动态计算,避免快照不同步) */
|
|
4
|
+
export const Paths = {
|
|
5
|
+
rootPath: process.cwd(),
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
Object.defineProperties(Paths, {
|
|
9
|
+
appPath: { get() { return path.join(this.rootPath, "app"); } },
|
|
10
|
+
configPath: { get() { return path.join(this.rootPath, "config"); } },
|
|
11
|
+
publicPath: { get() { return path.join(this.rootPath, "public"); } },
|
|
12
|
+
modulesPath: { get() { return path.join(this.appPath, "modules"); } },
|
|
13
|
+
commonPath: { get() { return path.join(this.appPath, "common"); } },
|
|
14
|
+
helperPath: { get() { return path.join(this.appPath, "helper"); } },
|
|
15
|
+
extendPath: { get() { return path.join(this.appPath, "extend"); } },
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export default Paths;
|
package/utils/request.js
CHANGED
|
@@ -1,172 +1,131 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
*/
|
|
1
|
+
import logger from "./logger.js";
|
|
2
|
+
import dns from "dns";
|
|
3
|
+
import { promisify } from "util";
|
|
5
4
|
|
|
6
|
-
|
|
7
|
-
* 默认请求超时时间(毫秒)
|
|
8
|
-
* 防止无超时控制的 fetch 挂起导致连接堆积
|
|
9
|
-
*/
|
|
5
|
+
const dnsLookup = promisify(dns.lookup);
|
|
10
6
|
const DEFAULT_TIMEOUT = 10000;
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* 最大响应体大小(10MB)
|
|
14
|
-
* 超过此大小直接拒绝,防止远端返回超大响应导致内存爆炸
|
|
15
|
-
*/
|
|
16
7
|
const MAX_RESPONSE_BYTES = 10 * 1024 * 1024;
|
|
17
8
|
|
|
18
|
-
/**
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
9
|
+
/** IPv4 私网/回环段 */
|
|
10
|
+
const IPV4_PRIVATE = /^(127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|169\.254\.|0\.0\.0\.0$)/;
|
|
11
|
+
/** IPv6 私网/链路本地前缀 */
|
|
12
|
+
const IPV6_PRIVATE = /^(::1$|fc|fd|fe80:)/i;
|
|
13
|
+
|
|
14
|
+
/** 判断 IP 是否指向私网/回环(含 IPv4-mapped IPv6) */
|
|
15
|
+
function isPrivateIp(ip) {
|
|
16
|
+
if (!ip) return true;
|
|
17
|
+
const v = ip.toLowerCase();
|
|
18
|
+
if (v === "127.0.0.1" || v === "::1") return true;
|
|
19
|
+
if (IPV4_PRIVATE.test(v)) return true;
|
|
20
|
+
const m = v.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
|
21
|
+
if (m) return IPV4_PRIVATE.test(m[1]);
|
|
22
|
+
return IPV6_PRIVATE.test(v);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** SSRF 防护:协议白名单 + 主机名解析后 IP 二次校验 */
|
|
26
|
+
async function isPrivateUrl(urlStr) {
|
|
27
|
+
let u;
|
|
28
|
+
try { u = new URL(urlStr.trim()); }
|
|
29
|
+
catch { return true; }
|
|
30
|
+
if (!["http:", "https:"].includes(u.protocol)) return true;
|
|
31
|
+
const host = u.hostname.toLowerCase();
|
|
32
|
+
if (!host || host === "localhost") return true;
|
|
33
|
+
if (/^[\d.:a-f]+$/i.test(host)) return isPrivateIp(host);
|
|
34
|
+
try { return isPrivateIp((await dnsLookup(host)).address); }
|
|
35
|
+
catch { return true; }
|
|
40
36
|
}
|
|
41
37
|
|
|
42
38
|
/**
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* @param {string} url -
|
|
46
|
-
* @param {Object} [options={}] -
|
|
47
|
-
* @param {
|
|
48
|
-
* @param {
|
|
49
|
-
* @param {
|
|
50
|
-
* @param {
|
|
51
|
-
* @param {number} [options.
|
|
52
|
-
* @
|
|
53
|
-
* @returns {Promise<Object|null>} 响应数据,失败时返回 null
|
|
54
|
-
* @description
|
|
55
|
-
* 发送 HTTP 请求并返回 JSON 格式的响应数据
|
|
56
|
-
* - 自动将对象类型的 body 转换为 JSON 字符串
|
|
57
|
-
* - 默认 Content-Type 为 application/json
|
|
58
|
-
* - 内置 AbortController 超时控制,默认 10 秒
|
|
59
|
-
* - SSRF 防护:拒绝请求内网/localhost 地址
|
|
60
|
-
* - 响应大小限制:超过 maxResponseBytes 拒绝
|
|
61
|
-
* - response.ok 校验:HTTP 状态码非 2xx 时返回 null
|
|
62
|
-
* - 失败重试:网络错误时按 retry 次数重试
|
|
63
|
-
* @example
|
|
64
|
-
* const data = await request('https://api.example.com/users', {
|
|
65
|
-
* method: 'POST',
|
|
66
|
-
* body: { name: '张三' },
|
|
67
|
-
* timeout: 5000,
|
|
68
|
-
* retry: 2
|
|
69
|
-
* });
|
|
39
|
+
* HTTP 请求封装:超时/重试/SSRF 防护/大小限制
|
|
40
|
+
* 支持 JSON 和原始数据(Buffer/文本)两种模式
|
|
41
|
+
* @param {string} url - 请求地址
|
|
42
|
+
* @param {Object} [options={}] - 请求配置
|
|
43
|
+
* @param {number} [options.timeout=10000] - 超时时间(ms)
|
|
44
|
+
* @param {number} [options.retry=0] - 重试次数
|
|
45
|
+
* @param {boolean} [options.parseJson=true] - 是否 JSON.parse 响应
|
|
46
|
+
* @param {string} [options.responseType] - "arraybuffer" 返回 Buffer
|
|
47
|
+
* @param {number} [options.maxResponseBytes] - 最大响应字节数
|
|
48
|
+
* @returns {Promise<{success:boolean,data:any,error?:string}>}
|
|
70
49
|
*/
|
|
71
50
|
export async function request(url, options = {}) {
|
|
51
|
+
const rawUrl = String(url ?? "").trim();
|
|
52
|
+
if (!rawUrl) return { success: false, error: "url不能为空" };
|
|
53
|
+
|
|
72
54
|
const {
|
|
73
55
|
timeout = DEFAULT_TIMEOUT,
|
|
74
56
|
retry = 0,
|
|
57
|
+
parseJson = true,
|
|
58
|
+
responseType,
|
|
75
59
|
maxResponseBytes = MAX_RESPONSE_BYTES,
|
|
76
|
-
...
|
|
60
|
+
...rest
|
|
77
61
|
} = options;
|
|
78
62
|
|
|
79
|
-
// SSRF
|
|
80
|
-
if (await
|
|
81
|
-
|
|
82
|
-
return null;
|
|
63
|
+
// SSRF 防护
|
|
64
|
+
if (await isPrivateUrl(rawUrl)) {
|
|
65
|
+
return { success: false, error: `SSRF防护拦截:${rawUrl}` };
|
|
83
66
|
}
|
|
84
67
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
68
|
+
// 构建 fetch 选项
|
|
69
|
+
const fetchOpts = {
|
|
70
|
+
method: "GET",
|
|
71
|
+
headers: { "Content-Type": "application/json" },
|
|
72
|
+
...rest
|
|
90
73
|
};
|
|
91
74
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
75
|
+
// body 序列化
|
|
76
|
+
if (fetchOpts.body && typeof fetchOpts.body !== "string") {
|
|
77
|
+
try {
|
|
78
|
+
fetchOpts.body = JSON.stringify(fetchOpts.body);
|
|
79
|
+
} catch (err) {
|
|
80
|
+
return { success: false, error: `请求体JSON序列化失败: ${err.message}` };
|
|
81
|
+
}
|
|
96
82
|
}
|
|
97
83
|
|
|
98
|
-
//
|
|
99
|
-
let lastError = null;
|
|
84
|
+
// 重试循环
|
|
100
85
|
for (let attempt = 0; attempt <= retry; attempt++) {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
let timer = null;
|
|
104
|
-
if (timeout > 0) {
|
|
105
|
-
controller = new AbortController();
|
|
106
|
-
finalOptions.signal = controller.signal;
|
|
107
|
-
timer = setTimeout(() => controller.abort(), timeout);
|
|
108
|
-
}
|
|
86
|
+
const controller = new AbortController();
|
|
87
|
+
const timer = timeout > 0 ? setTimeout(() => controller.abort(), timeout) : null;
|
|
109
88
|
|
|
110
89
|
try {
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
// HTTP 状态码校验:非 2xx 视为失败
|
|
114
|
-
if (!response.ok) {
|
|
115
|
-
console.error(`[Request] HTTP ${response.status} ${response.statusText}: ${url}`);
|
|
116
|
-
return null;
|
|
117
|
-
}
|
|
90
|
+
const res = await fetch(rawUrl, { ...fetchOpts, signal: controller.signal });
|
|
91
|
+
if (timer) clearTimeout(timer);
|
|
118
92
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
if (contentLength && contentLength > maxResponseBytes) {
|
|
122
|
-
console.error(`[Request] 响应体过大 (${contentLength} bytes > ${maxResponseBytes}): ${url}`);
|
|
123
|
-
return null;
|
|
93
|
+
if (!res.ok) {
|
|
94
|
+
return { success: false, error: `HTTP ${res.status} ${res.statusText}` };
|
|
124
95
|
}
|
|
125
96
|
|
|
126
|
-
//
|
|
127
|
-
|
|
128
|
-
if (
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
while (!done) {
|
|
133
|
-
const { done: rDone, value } = await reader.read();
|
|
134
|
-
done = rDone;
|
|
135
|
-
if (value) {
|
|
136
|
-
totalSize += value.length;
|
|
137
|
-
if (totalSize > maxResponseBytes) {
|
|
138
|
-
console.error(`[Request] 流式响应体超过上限 (${totalSize} > ${maxResponseBytes}): ${url}`);
|
|
139
|
-
await reader.cancel?.();
|
|
140
|
-
return null;
|
|
141
|
-
}
|
|
142
|
-
chunks.push(value);
|
|
143
|
-
}
|
|
97
|
+
// 根据 responseType 决定如何读取响应
|
|
98
|
+
let data;
|
|
99
|
+
if (responseType === "arraybuffer") {
|
|
100
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
101
|
+
if (buf.length > maxResponseBytes) {
|
|
102
|
+
return { success: false, error: `响应超限 ${buf.length} > ${maxResponseBytes}` };
|
|
144
103
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
return null;
|
|
104
|
+
data = buf;
|
|
105
|
+
} else {
|
|
106
|
+
const text = await res.text();
|
|
107
|
+
if (text.length > maxResponseBytes) {
|
|
108
|
+
return { success: false, error: `响应超限 ${text.length} > ${maxResponseBytes}` };
|
|
151
109
|
}
|
|
110
|
+
data = parseJson ? JSON.parse(text) : text;
|
|
152
111
|
}
|
|
153
112
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
return data;
|
|
157
|
-
} catch (error) {
|
|
158
|
-
if (error.name === 'AbortError') {
|
|
159
|
-
console.error(`[Request] 请求超时 (${timeout}ms): ${url}`);
|
|
160
|
-
} else {
|
|
161
|
-
console.error(`[Request] 请求失败 (attempt ${attempt + 1}/${retry + 1}):`, error.message);
|
|
162
|
-
}
|
|
163
|
-
lastError = error;
|
|
164
|
-
// 仅网络错误重试,超时不重试(避免雪崩)
|
|
165
|
-
if (error.name === 'AbortError') break;
|
|
166
|
-
} finally {
|
|
113
|
+
return { success: true, data };
|
|
114
|
+
} catch (err) {
|
|
167
115
|
if (timer) clearTimeout(timer);
|
|
116
|
+
|
|
117
|
+
if (err.name === "AbortError") {
|
|
118
|
+
return { success: false, error: `请求超时 ${timeout}ms` };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (attempt < retry) {
|
|
122
|
+
logger.warn(`[Request] 第${attempt + 1}次请求失败,剩余重试${retry - attempt}次 msg:${err.message}`);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return { success: false, error: err.message };
|
|
168
127
|
}
|
|
169
128
|
}
|
|
170
129
|
|
|
171
|
-
return
|
|
130
|
+
return { success: false, error: "请求失败" };
|
|
172
131
|
}
|
package/utils/signal.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import logger from "./logger.js";
|
|
2
|
+
import { store } from "../storage/index.js";
|
|
3
|
+
import { setApp } from "../core/registry.js";
|
|
4
|
+
import { SHUTDOWN_TIMEOUT } from "../config/index.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 优雅停机:统一处理进程信号监听、资源关闭、超时保护
|
|
8
|
+
* 模块级状态,进程内单例
|
|
9
|
+
*/
|
|
10
|
+
let registered = false;
|
|
11
|
+
let shuttingDown = false;
|
|
12
|
+
|
|
13
|
+
/** 关闭 HTTP 服务、缓存存储、数据库连接 */
|
|
14
|
+
async function doShutdown(chan) {
|
|
15
|
+
const { dbManager, server } = chan;
|
|
16
|
+
|
|
17
|
+
if (server) {
|
|
18
|
+
try {
|
|
19
|
+
await new Promise((resolve, reject) => server.close(err => (err ? reject(err) : resolve())));
|
|
20
|
+
logger.info("[Chan] HTTP服务已停止接受新连接");
|
|
21
|
+
} catch (e) {
|
|
22
|
+
logger.warn(`[Chan] 关闭HTTP服务异常:${e.message}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const errors = [];
|
|
27
|
+
|
|
28
|
+
try { await store.close(); }
|
|
29
|
+
catch (e) { errors.push({ type: "store", err: e.message }); }
|
|
30
|
+
|
|
31
|
+
if (dbManager) {
|
|
32
|
+
const results = await dbManager.closeAll();
|
|
33
|
+
results.filter(item => !item.success).forEach(item => errors.push({ type: `db:${item.name}`, err: item.error }));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (errors.length) logger.warn(`[Chan] 停机存在${errors.length}处资源关闭失败`, errors);
|
|
37
|
+
setApp(null);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 注册进程信号监听 */
|
|
41
|
+
export function register(chan) {
|
|
42
|
+
if (registered) {
|
|
43
|
+
logger.warn("[Chan] 优雅停机已注册,跳过重复绑定");
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
registered = true;
|
|
47
|
+
|
|
48
|
+
const shutdownTimeout = chan.config?.SHUTDOWN_TIMEOUT ?? SHUTDOWN_TIMEOUT;
|
|
49
|
+
|
|
50
|
+
const handleSignal = async (signal) => {
|
|
51
|
+
if (shuttingDown) {
|
|
52
|
+
logger.error(`[Chan] 停机中重复接收信号 ${signal},强制终止`);
|
|
53
|
+
return process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
shuttingDown = true;
|
|
56
|
+
logger.info(`[Chan] 收到停机信号 ${signal},开始优雅停机`);
|
|
57
|
+
|
|
58
|
+
const exitTimer = setTimeout(() => {
|
|
59
|
+
logger.error(`[Chan] 停机超时(${shutdownTimeout}ms),强制退出`);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}, shutdownTimeout);
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
await doShutdown(chan);
|
|
65
|
+
logger.info("[Chan] 优雅停机完成");
|
|
66
|
+
process.exit(0);
|
|
67
|
+
} catch (err) {
|
|
68
|
+
logger.error("[Chan] 停机异常", err);
|
|
69
|
+
process.exit(1);
|
|
70
|
+
} finally {
|
|
71
|
+
clearTimeout(exitTimer);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
process.on("SIGTERM", () => void handleSignal("SIGTERM"));
|
|
76
|
+
process.on("SIGINT", () => void handleSignal("SIGINT"));
|
|
77
|
+
process.on("SIGQUIT", () => void handleSignal("SIGQUIT"));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 程序化触发停机(不退出进程) */
|
|
81
|
+
export async function shutdown(chan) {
|
|
82
|
+
if (shuttingDown) return;
|
|
83
|
+
shuttingDown = true;
|
|
84
|
+
logger.info("[Chan] 开始程序化停机");
|
|
85
|
+
await doShutdown(chan);
|
|
86
|
+
logger.info("[Chan] 程序化停机完成");
|
|
87
|
+
}
|