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.
Files changed (94) hide show
  1. package/USAGE.md +533 -0
  2. package/config/index.js +37 -6
  3. package/core/App.js +166 -0
  4. package/core/BaseComponent.js +27 -0
  5. package/core/Container.js +68 -0
  6. package/core/Controller.js +29 -0
  7. package/core/Database.js +93 -0
  8. package/core/Repository.js +323 -0
  9. package/core/Service.js +11 -0
  10. package/core/bootstrap/error-handler.js +101 -0
  11. package/core/bootstrap/hook-runner.js +64 -0
  12. package/core/bootstrap/middleware.js +35 -0
  13. package/core/bootstrap/router-loader.js +53 -0
  14. package/core/errors.js +251 -0
  15. package/core/loader.js +89 -0
  16. package/core/registry.js +17 -0
  17. package/doc/Cache.md +279 -106
  18. package/doc/Common.md +590 -134
  19. package/doc/Controller.md +166 -95
  20. package/doc/Help.md +299 -698
  21. package/doc/QuickStart.md +116 -0
  22. package/doc/Repository.md +560 -0
  23. package/doc/Service.md +201 -527
  24. package/index.js +61 -37
  25. package/middleware/body.js +17 -0
  26. package/middleware/cookie.js +7 -15
  27. package/middleware/cors.js +9 -27
  28. package/middleware/favicon.js +15 -17
  29. package/middleware/header.js +15 -16
  30. package/middleware/index.js +11 -11
  31. package/middleware/log.js +26 -56
  32. package/middleware/static.js +15 -28
  33. package/middleware/template.js +75 -115
  34. package/middleware/validate.js +79 -0
  35. package/middleware/waf.js +176 -197
  36. package/package.json +9 -2
  37. package/response/code.js +73 -0
  38. package/response/index.js +9 -6
  39. package/response/response.js +82 -236
  40. package/security/checker.js +26 -74
  41. package/security/index.js +4 -9
  42. package/security/jwt.js +84 -139
  43. package/security/keywords.js +33 -137
  44. package/security/rate-limit.js +38 -80
  45. package/security/sign.js +83 -176
  46. package/security/xss-filter.js +21 -53
  47. package/storage/cache.js +58 -198
  48. package/storage/index.js +3 -6
  49. package/storage/redis.js +124 -181
  50. package/storage/store.js +163 -188
  51. package/utils/data-parse.js +42 -186
  52. package/utils/file.js +73 -244
  53. package/utils/filter.js +22 -25
  54. package/utils/html.js +49 -33
  55. package/utils/index.js +20 -7
  56. package/utils/ip.js +31 -71
  57. package/utils/logger.js +117 -0
  58. package/utils/pages.js +55 -0
  59. package/utils/paths.js +18 -0
  60. package/utils/request.js +95 -136
  61. package/utils/signal.js +87 -0
  62. package/utils/time.js +33 -75
  63. package/utils/tree.js +112 -104
  64. package/App.js +0 -533
  65. package/base/Aop.js +0 -195
  66. package/base/Container.js +0 -161
  67. package/base/Controller.js +0 -65
  68. package/base/Database.js +0 -133
  69. package/base/Event.js +0 -61
  70. package/base/Repository.js +0 -644
  71. package/common/api.js +0 -35
  72. package/common/code.js +0 -52
  73. package/common/email.js +0 -191
  74. package/common/index.js +0 -5
  75. package/common/pages.js +0 -120
  76. package/common/utils.js +0 -73
  77. package/config/code.js +0 -166
  78. package/config/paths.js +0 -60
  79. package/doc/Aop.md +0 -269
  80. package/doc/Email.md +0 -114
  81. package/doc/Event.md +0 -232
  82. package/global/env.js +0 -11
  83. package/global/import.js +0 -39
  84. package/global/index.js +0 -8
  85. package/helper/index.js +0 -79
  86. package/loader/index.js +0 -6
  87. package/loader/loader.js +0 -138
  88. package/middleware/compress.js +0 -185
  89. package/middleware/setBody.js +0 -32
  90. package/realtime/index.js +0 -7
  91. package/realtime/sse.js +0 -424
  92. package/realtime/websocket.js +0 -540
  93. package/schedule/index.js +0 -6
  94. package/schedule/schedule.js +0 -491
package/utils/ip.js CHANGED
@@ -1,72 +1,32 @@
1
- /**
2
- * 从请求对象中获取客户端真实 IP 地址
3
- * @param {Object} req - Express 请求对象
4
- * @param {string} [req.ip] - Express 解析的 IP(已包含代理头部解析)
5
- * @param {Object} [req.headers] - 请求头对象
6
- * @param {string} [req.headers.cf-connecting-ip] - Cloudflare 连接 IP
7
- * @param {string} [req.headers.cf-ray] - Cloudflare 请求追踪 ID
8
- * @returns {string} 客户端 IP 地址,如果无法获取则返回 "0.0.0.0"
9
- * @description
10
- * Express 的 req.ip 获取客户端 IP。
11
- *
12
- * 注意:需要在 App.js 中设置 trust proxy:
13
- * app.set("trust proxy", true);
14
- *
15
- * 设置 trust proxy 后,Express 会自动从以下头部解析真实 IP
16
- * - X-Forwarded-For(反向代理,如 Nginx)
17
- * - X-Real-IP
18
- * - CF-Connecting-IP(Cloudflare CDN)
19
- *
20
- * 此方法按优先级从以下来源获取 IP 地址:
21
- * 1. cf-connecting-ip 请求头(仅当同时存在 cf-ray 时才信任,防止伪造)
22
- * 2. req.ip(Express 自动解析,已包含代理头部处理)
23
- * 3. 默认值 "0.0.0.0"
24
- *
25
- * 安全改进:
26
- * - 仅当同时存在 cf-ray 头时才信任 cf-connecting-ip
27
- * - 避免攻击者直接伪造 cf-connecting-ip 头绕过 IP 限流
28
- * - Cloudflare CDN 总是会同时下发这两个头
29
- *
30
- * @example
31
- * app.use((req, res, next) => {
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
  }
@@ -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
- * HTTP 请求工具函数
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
- * 内网/特殊地址检查(SSRF 防护)
20
- * 拒绝请求内网 IP 和非 http/https 协议
21
- * @param {string} urlStr - 待检查的 URL
22
- * @returns {Promise<boolean>} true 表示不安全(应拒绝)
23
- * @private
24
- */
25
- async function _isPrivateUrl(urlStr) {
26
- try {
27
- const u = new URL(urlStr);
28
- // 仅允许 http/https 协议
29
- if (!['http:', 'https:'].includes(u.protocol)) return true;
30
- const host = u.hostname;
31
- if (!host) return true;
32
- // 拒绝 localhost 和内网 IP
33
- if (host === 'localhost' || /^(127\.|10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.|169\.254\.|0\.)/.test(host)) {
34
- return true;
35
- }
36
- return false;
37
- } catch {
38
- return true;
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
- * 发送 HTTP 请求
44
- * @async
45
- * @param {string} url - 请求 URL
46
- * @param {Object} [options={}] - 请求选项
47
- * @param {string} [options.method='GET'] - HTTP 方法
48
- * @param {Object} [options.headers] - 请求头
49
- * @param {Object|string} [options.body] - 请求体
50
- * @param {number} [options.timeout=10000] - 请求超时时间(毫秒),0 表示不超时
51
- * @param {number} [options.retry=0] - 失败重试次数,默认 0
52
- * @param {number} [options.maxResponseBytes=10485760] - 最大响应字节数,默认 10MB
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
- ...restOptions
60
+ ...rest
77
61
  } = options;
78
62
 
79
- // SSRF 防护:拒绝内网/特殊地址
80
- if (await _isPrivateUrl(url)) {
81
- console.error(`[Request] SSRF 防护:拒绝请求内网/非法地址: ${url}`);
82
- return null;
63
+ // SSRF 防护
64
+ if (await isPrivateUrl(rawUrl)) {
65
+ return { success: false, error: `SSRF防护拦截:${rawUrl}` };
83
66
  }
84
67
 
85
- const defaultOptions = {
86
- method: 'GET',
87
- headers: {
88
- 'Content-Type': 'application/json',
89
- },
68
+ // 构建 fetch 选项
69
+ const fetchOpts = {
70
+ method: "GET",
71
+ headers: { "Content-Type": "application/json" },
72
+ ...rest
90
73
  };
91
74
 
92
- const finalOptions = { ...defaultOptions, ...restOptions };
93
-
94
- if (finalOptions.body && typeof finalOptions.body !== 'string') {
95
- finalOptions.body = JSON.stringify(finalOptions.body);
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
- // 超时控制:通过 AbortController 在指定时间后中止请求
102
- let controller = null;
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 response = await fetch(url, finalOptions);
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
- // 响应大小校验:content-length 超限直接拒绝
120
- const contentLength = parseInt(response.headers.get('content-length') || '0', 10);
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
- const reader = response.body?.getReader();
128
- if (reader) {
129
- const chunks = [];
130
- let totalSize = 0;
131
- let done = false;
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
- const buf = Buffer.concat(chunks);
146
- try {
147
- return JSON.parse(buf.toString('utf8'));
148
- } catch (e) {
149
- console.error('[Request] JSON 解析失败:', e.message);
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
- // body 流的兜底(理论上极少走到)
155
- const data = await response.json();
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 null;
130
+ return { success: false, error: "请求失败" };
172
131
  }
@@ -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
+ }