chanjs 2.8.2 → 2.8.4
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/core/Container.js +11 -4
- package/core/Repository.js +35 -19
- package/core/bootstrap/middleware.js +20 -5
- package/core/bootstrap/router-loader.js +2 -1
- package/core/loader.js +2 -1
- package/doc/06-/345/272/224/347/224/250/347/224/237/345/221/275/345/221/250/346/234/237.md +2 -1
- package/middleware/cookie.js +5 -0
- package/middleware/header.js +11 -9
- package/package.json +1 -1
- package/utils/logger.js +49 -2
- package/utils/signal.js +2 -0
package/core/Container.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import path from "path";
|
|
3
|
-
import { pathToFileURL } from "url";
|
|
4
3
|
import { Paths } from "../utils/paths.js";
|
|
5
4
|
import { BaseComponent } from "./BaseComponent.js";
|
|
5
|
+
import { importFile } from "./loader.js";
|
|
6
6
|
import logger from "../utils/logger.js";
|
|
7
7
|
|
|
8
8
|
const NAME_REGEX = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
|
|
@@ -67,6 +67,11 @@ export class Container extends BaseComponent {
|
|
|
67
67
|
|
|
68
68
|
/**
|
|
69
69
|
* 物理加载文件、导入模块、安全校验
|
|
70
|
+
*
|
|
71
|
+
* 职责分层(组件加载唯一核心):
|
|
72
|
+
* 1. 本方法只做「用户输入校验」(模块/文件名白名单 + 路径越界)与「存在性探测」
|
|
73
|
+
* 2. 文件导入统一委托 loader.importFile(access + pathToFileURL + default 提取 + IO 错误日志),
|
|
74
|
+
* 与 loadController/loadConfig 共享同一原语,避免两套导入实现行为漂移
|
|
70
75
|
* @private
|
|
71
76
|
*/
|
|
72
77
|
async _loadFile(moduleName, fileName, type = this._type) {
|
|
@@ -80,15 +85,17 @@ export class Container extends BaseComponent {
|
|
|
80
85
|
throw new Error(`路径越界: ${filePath}`);
|
|
81
86
|
}
|
|
82
87
|
|
|
88
|
+
// 存在性是预期内路径(按需探测),静默返回 null 交给 get 打 info 日志
|
|
83
89
|
try {
|
|
84
90
|
await fs.promises.access(filePath, fs.constants.R_OK);
|
|
85
91
|
} catch {
|
|
86
92
|
return null;
|
|
87
93
|
}
|
|
88
94
|
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
95
|
+
// 项目约定组件必须是实例对象(export default new XxxService());
|
|
96
|
+
// 只导出类/函数的文件视为无效组件,不缓存不报错(与 loadController 的过滤口径一致)
|
|
97
|
+
const mod = await importFile(filePath);
|
|
98
|
+
return mod && typeof mod === "object" ? mod : null;
|
|
92
99
|
}
|
|
93
100
|
}
|
|
94
101
|
|
package/core/Repository.js
CHANGED
|
@@ -130,6 +130,19 @@ class Repository extends BaseComponent {
|
|
|
130
130
|
if (!this.db) throw new Error("Database connection not available");
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
/**
|
|
134
|
+
* 写操作 WHERE 防御(fail-closed):条件字段全部非法时拒绝执行。
|
|
135
|
+
* 被丢弃的条件会让 del/update 退化为无 WHERE 的全表操作,统一在此拦截。
|
|
136
|
+
* @param {object} query WHERE 条件
|
|
137
|
+
* @returns {{success:false,code:number,msg:string,data:{}}|null} 非法返回错误体(调用方应 return),合法返回 null
|
|
138
|
+
*/
|
|
139
|
+
#assertValidWhere(query) {
|
|
140
|
+
if (!Object.keys(query).some(f => SORT_FIELD_REGEX.test(f))) {
|
|
141
|
+
return { success: false, code: CODE_PARAM_INVALID, msg: "操作条件非法", data: {} };
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
133
146
|
/** 当前连接的数据库客户端类型(knex client:mysql2 / pg / better-sqlite3 ...) */
|
|
134
147
|
get _clientType() {
|
|
135
148
|
return this.db?.client?.config?.client || "";
|
|
@@ -175,21 +188,26 @@ class Repository extends BaseComponent {
|
|
|
175
188
|
return row;
|
|
176
189
|
}
|
|
177
190
|
|
|
178
|
-
/**
|
|
179
|
-
|
|
191
|
+
/**
|
|
192
|
+
* 查询全部。上限口径统一收口到 config.LIMIT_MAX(this.limit):
|
|
193
|
+
* 未传 limit 时默认取 this.limit;显式传入的 limit 也会被钳制到 [1, this.limit],
|
|
194
|
+
* 避免"all 默认 1000 / 分页上限 300"两套口径并存。
|
|
195
|
+
*/
|
|
196
|
+
async all({ query={}, sort={}, fields=[], limit }={}) {
|
|
180
197
|
const err = this._guard({ query });
|
|
181
198
|
if (err) return err;
|
|
182
|
-
const
|
|
199
|
+
const size = Math.min(Math.max(Number(limit) || this.limit, 1), this.limit);
|
|
200
|
+
const list = await this._buildBaseQuery({query,sort,fields}).limit(size);
|
|
183
201
|
return { success:true, code:CODE_OK, msg:"查询成功", data: list };
|
|
184
202
|
}
|
|
185
203
|
|
|
186
|
-
/**
|
|
204
|
+
/** 分页偏移查询(limit 同 all() 口径,钳制到 config.LIMIT_MAX) */
|
|
187
205
|
async find({ query={}, sort={}, fields=[], limit, offset }={}) {
|
|
188
206
|
const err = this._guard({ query });
|
|
189
207
|
if (err) return err;
|
|
190
208
|
let q = this._buildBaseQuery({query,sort,fields});
|
|
191
209
|
typeof offset === 'number' && (q = q.offset(offset));
|
|
192
|
-
typeof limit === 'number' && (q = q.limit(limit));
|
|
210
|
+
typeof limit === 'number' && (q = q.limit(Math.min(Math.max(limit, 1), this.limit)));
|
|
193
211
|
return { success:true, code:CODE_OK, msg:"查询成功", data: await q };
|
|
194
212
|
}
|
|
195
213
|
|
|
@@ -234,11 +252,10 @@ class Repository extends BaseComponent {
|
|
|
234
252
|
async del(query={}) {
|
|
235
253
|
this._checkDB();
|
|
236
254
|
if (!Object.keys(query).length) return { success:false, code:CODE_PARAM_MISSING, msg:"参数缺失", data:{} };
|
|
237
|
-
//
|
|
238
|
-
// 会生成不带 WHERE
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
}
|
|
255
|
+
// 防御纵深:条件字段全部非法(会被 applyQuery 静默丢弃)时,
|
|
256
|
+
// 会生成不带 WHERE 的全表删除,fail-closed 拒绝执行。
|
|
257
|
+
const invalid = this.#assertValidWhere(query);
|
|
258
|
+
if (invalid) return invalid;
|
|
242
259
|
const rows = await applyQuery(this.db(this.tableName), query).del();
|
|
243
260
|
return { success:true, code:CODE_OK, msg:"删除成功", data:{ affectedRows:rows } };
|
|
244
261
|
}
|
|
@@ -269,9 +286,8 @@ class Repository extends BaseComponent {
|
|
|
269
286
|
return { success:false, code:CODE_PARAM_INVALID, msg:"参数无效", data:{} };
|
|
270
287
|
}
|
|
271
288
|
// 防御纵深:与 del 同理,条件字段全部非法时会生成无 WHERE 的全表更新,fail-closed 拒绝。
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
}
|
|
289
|
+
const invalid = this.#assertValidWhere(query);
|
|
290
|
+
if (invalid) return invalid;
|
|
275
291
|
const rows = await applyQuery(this.db(this.tableName), query).update(this.#formatDate(data));
|
|
276
292
|
return { success:true, code:CODE_OK, msg:"更新成功", data:{ affectedRows:rows } };
|
|
277
293
|
}
|
|
@@ -299,18 +315,18 @@ class Repository extends BaseComponent {
|
|
|
299
315
|
if (!item.query || !Object.keys(item.query).length) {
|
|
300
316
|
return { success:false, code:CODE_PARAM_INVALID, msg:"批量更新条件不能为空", data:{} };
|
|
301
317
|
}
|
|
302
|
-
// 防御纵深:与 update 同口径,条件字段全部非法时 fail-closed 拒绝,
|
|
318
|
+
// 防御纵深:与 del/update 同口径,条件字段全部非法时 fail-closed 拒绝,
|
|
303
319
|
// 避免生成无 WHERE 的全表更新
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
}
|
|
320
|
+
const invalid = this.#assertValidWhere(item.query);
|
|
321
|
+
if (invalid) return invalid;
|
|
307
322
|
}
|
|
308
323
|
const trx = await this.db.transaction();
|
|
309
324
|
let total = 0;
|
|
310
325
|
try {
|
|
326
|
+
// 与单条 update 同口径:条件统一过 applyQuery(操作符解析 + 非法字段过滤),不再裸用 knex where
|
|
311
327
|
for (const { query, data } of updates) {
|
|
312
|
-
const row = await trx(this.tableName)
|
|
313
|
-
row === 0 && logger.info(
|
|
328
|
+
const row = await applyQuery(trx(this.tableName), query).update(this.#formatDate(data));
|
|
329
|
+
row === 0 && logger.info(`[Repository] updateMany无匹配行 ${JSON.stringify(query)}`);
|
|
314
330
|
total += row;
|
|
315
331
|
}
|
|
316
332
|
await trx.commit();
|
|
@@ -4,10 +4,11 @@ import {
|
|
|
4
4
|
} from "../../middleware/index.js";
|
|
5
5
|
import { health } from "../../middleware/health.js";
|
|
6
6
|
import { BODY_LIMIT } from "../../config/index.js";
|
|
7
|
+
import logger from "../../utils/logger.js";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* 注册核心中间件(顺序固化:不可随意调整)
|
|
10
|
-
* WAF 前置 → Favicon → 静态 → Cookie → Body 解析 → WAF Body 检查 →
|
|
11
|
+
* WAF 前置 → 健康检查 → Favicon → 静态 → CORS → Cookie → Body 解析 → WAF Body 检查 → 日志 → 模板 → 响应头
|
|
11
12
|
* @param {object} chan 框架实例
|
|
12
13
|
*/
|
|
13
14
|
export async function registerCoreMiddleware(chan) {
|
|
@@ -17,22 +18,36 @@ export async function registerCoreMiddleware(chan) {
|
|
|
17
18
|
await waf(app, cfg.waf ?? { enabled: false });
|
|
18
19
|
// 1.5 健康检查端点(默认开启,config.health = {enabled,path} 可调;浅模式零 IO)
|
|
19
20
|
app.use(health(chan, cfg.health ?? {}));
|
|
21
|
+
// 1.6 宿主全局中间件插槽:config.middleware 数组,按声明顺序注册。
|
|
22
|
+
// 元素支持两种形态:express 中间件本身 (req,res,next);或工厂 () => express 中间件(需闭包 chan/config 时用)。
|
|
23
|
+
// 注册位置在核心链早期、模块路由之前,保证对所有响应(含静态资源)生效。
|
|
24
|
+
// 注意:app/router.js(loadCommonRouter)的执行时机在模块路由之后,app.use 在那里
|
|
25
|
+
// 只罩得住 404 兜底请求,安全响应头等全局策略应经本插槽注册而非写在 router.js。
|
|
26
|
+
for (const mw of cfg.middleware ?? []) {
|
|
27
|
+
const fn = typeof mw === "function" && mw.length <= 1 ? mw(chan) : mw;
|
|
28
|
+
if (typeof fn !== "function") {
|
|
29
|
+
logger.warn(`[Chan] config.middleware 含非法项(须为中间件或中间件工厂),已跳过: ${String(mw)}`);
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
app.use(fn);
|
|
33
|
+
}
|
|
20
34
|
// 2. Favicon
|
|
21
35
|
favicon(app);
|
|
22
36
|
// 3. 静态资源
|
|
23
37
|
staticMw(app, cfg.statics ?? []);
|
|
38
|
+
// 3.5 CORS(前移到 body 之前:OPTIONS 预检无需等待 body 解析即返回;
|
|
39
|
+
// WAF 拦截预检时响应也能携带 CORS 头,浏览器报错不再误导排障)
|
|
40
|
+
Cors(app, cfg.cors ?? {});
|
|
24
41
|
// 4. Cookie
|
|
25
42
|
cookie(app, cfg.cookieKey);
|
|
26
43
|
// 5. 请求体解析
|
|
27
44
|
body(app, cfg.BODY_LIMIT ?? BODY_LIMIT);
|
|
28
45
|
// 5.1 WAF Body 检查(须在 body 解析之后)
|
|
29
46
|
wafBody(app, cfg.waf ?? { enabled: false });
|
|
30
|
-
// 6. CORS
|
|
31
|
-
Cors(app, cfg.cors ?? {});
|
|
32
47
|
// 7. 请求日志
|
|
33
48
|
log(app, cfg.logger ?? {});
|
|
34
|
-
// 8.
|
|
35
|
-
template(app, { views: cfg.views ?? [], NODE_ENV: cfg.NODE_ENV ?? "
|
|
49
|
+
// 8. 模板渲染(NODE_ENV 兜底 production:与「默认生产口径」约束一致,生产默认开启模板缓存)
|
|
50
|
+
template(app, { views: cfg.views ?? [], NODE_ENV: cfg.NODE_ENV ?? "production" });
|
|
36
51
|
// 9. 响应头
|
|
37
52
|
header(app, { APP_NAME: cfg.APP_NAME ?? "ChanCMS", APP_VERSION: cfg.APP_VERSION ?? "1.0.0" });
|
|
38
53
|
}
|
|
@@ -19,7 +19,8 @@ async function registerRouterFile(filePath, app, router, config) {
|
|
|
19
19
|
await register(app, router, config);
|
|
20
20
|
}
|
|
21
21
|
} catch (err) {
|
|
22
|
-
logger
|
|
22
|
+
// 详情必须拼进 msg 本身:logger 的 messageFormat 仅输出 msg 字段,第二参数会被 pino-pretty 忽略导致错误原因丢失
|
|
23
|
+
logger.error(`[RouterLoader] 路由文件加载失败: ${filePath}\n${err?.stack || err}`);
|
|
23
24
|
}
|
|
24
25
|
}
|
|
25
26
|
|
package/core/loader.js
CHANGED
|
@@ -23,9 +23,10 @@ export async function importFile(filepath) {
|
|
|
23
23
|
const mod = await import(pathToFileURL(filepath).href);
|
|
24
24
|
return mod.default ?? mod;
|
|
25
25
|
} catch (err) {
|
|
26
|
+
// 详情必须拼进 msg 本身:logger 的 messageFormat 仅输出 msg 字段,第二参数会被 pino-pretty 忽略导致错误原因丢失
|
|
26
27
|
if (err.code === "ENOENT") logger.error(`[Loader] 文件不存在: ${filepath}`);
|
|
27
28
|
else if (err.code === "EACCES") logger.error(`[Loader] 无访问权限: ${filepath}`);
|
|
28
|
-
else logger.error(`[Loader] 导入失败 ${filepath}
|
|
29
|
+
else logger.error(`[Loader] 导入失败 ${filepath}: ${err?.message || err}`);
|
|
29
30
|
return null;
|
|
30
31
|
}
|
|
31
32
|
}
|
|
@@ -172,7 +172,8 @@ const otherDb = app.dbManager.get("logs");
|
|
|
172
172
|
| `REDIS_ENABLED` / `REDIS` | Redis 配置 |
|
|
173
173
|
| `JWT_SECRET` | JWT 签名密钥 |
|
|
174
174
|
| `LOCALE` | i18n 默认语言(默认 `zh-CN`)|
|
|
175
|
-
| `LIMIT_MAX` | Repository 单页上限(默认 300
|
|
175
|
+
| `LIMIT_MAX` | Repository 单页上限(默认 300);`all()`/`find()` 显式 limit 亦被钳制到该值 |
|
|
176
|
+
| `middleware` | 宿主全局中间件插槽:数组,元素为 express 中间件或中间件工厂 `(chan) => mw`,注册于核心链早期(健康检查后、静态资源前),对所有响应生效 |
|
|
176
177
|
| `SLOW_THRESHOLD` | 数据库慢查询阈值(ms,默认 200),超过记 warn 日志 |
|
|
177
178
|
| `SHUTDOWN_TIMEOUT` | 优雅停机强制退出超时(ms,默认 5000)|
|
|
178
179
|
| `BODY_LIMIT` | 请求体上限(默认 `10mb`)|
|
package/middleware/cookie.js
CHANGED
|
@@ -3,6 +3,11 @@ import logger from "../utils/logger.js";
|
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Cookie解析中间件,支持签名校验
|
|
6
|
+
*
|
|
7
|
+
* 职责边界:本中间件只负责「解析」(含签名校验);「写入侧」的安全属性
|
|
8
|
+
* (httpOnly / sameSite / secure / maxAge)由业务写入时的 res.cookie(name, value, opts)
|
|
9
|
+
* 决定,框架不越俎代庖。业务侧统一在 app/common/auth-cookie.js 收口:
|
|
10
|
+
* sameSite=prd:strict / dev:lax,secure 可配,httpOnly=false 为统一登录态方案的已确认权衡。
|
|
6
11
|
* @param {express.Application} app Express实例
|
|
7
12
|
* @param {string} [cookieKey] Cookie签名密钥
|
|
8
13
|
*/
|
package/middleware/header.js
CHANGED
|
@@ -1,20 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* 品牌响应头中间件:隐藏原生 Express 标识,统一输出 ChanCMS。
|
|
3
|
+
*
|
|
4
|
+
* 注:安全响应头(X-Content-Type-Options / HSTS / Referrer-Policy /
|
|
5
|
+
* Permissions-Policy / CSP 等)属于宿主业务策略,不在框架层默认设置——
|
|
6
|
+
* 由各项目的 security-headers 中间件统一收敛(本项目见 app/middleware/security-headers.js)。
|
|
7
|
+
* 尤其 CSP frame-ancestors 一旦在框架写死 'self',会与需要被第三方
|
|
8
|
+
* iframe 嵌入的宿主业务直接冲突,故不在此声明。
|
|
5
9
|
*/
|
|
6
10
|
export const header = (app, opts = {}) => {
|
|
7
11
|
const appName = opts.APP_NAME || "ChanCMS";
|
|
8
12
|
const appVer = opts.APP_VERSION || "";
|
|
9
13
|
const poweredBy = appVer ? `${appName}/${appVer}` : appName;
|
|
14
|
+
// 静态资源在核心链第 3 步即被接住、到不了本中间件,Express 会默认补
|
|
15
|
+
// X-Powered-By: Express 泄露技术栈——先禁用默认注入,品牌头只由下方显式设置
|
|
16
|
+
app.disable("x-powered-by");
|
|
10
17
|
|
|
11
18
|
app.use((_, res, next) => {
|
|
12
19
|
res.setHeader("X-Powered-By", poweredBy);
|
|
13
|
-
// 常驻安全响应头:无论 WAF 是否启用都生效
|
|
14
|
-
// 禁止 MIME 嗅探(防 XSS 利用 Content-Type 歧义)
|
|
15
|
-
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
16
|
-
// 防点击劫持:仅允许同源 iframe 嵌套(如需被第三方嵌入请按需放宽)
|
|
17
|
-
res.setHeader("Content-Security-Policy", "frame-ancestors 'self'");
|
|
18
20
|
next();
|
|
19
21
|
});
|
|
20
|
-
};
|
|
22
|
+
};
|
package/package.json
CHANGED
package/utils/logger.js
CHANGED
|
@@ -11,7 +11,7 @@ const timestamp = () => {
|
|
|
11
11
|
|
|
12
12
|
// 输出格式:时间 + 空格 + 正文(pino-pretty 默认模板是 [time]: msg,方括号和冒号去不掉,
|
|
13
13
|
// 故忽略 time 字段、改由 messageFormat 把时间拼在行首)
|
|
14
|
-
|
|
14
|
+
const root = pino(
|
|
15
15
|
{ level: (process.env.LOG_LEVEL || "info").trim(), base: null, timestamp },
|
|
16
16
|
pretty({
|
|
17
17
|
colorize: false,
|
|
@@ -19,4 +19,51 @@ export const root = pino(
|
|
|
19
19
|
messageFormat: "{time} {msg}",
|
|
20
20
|
}),
|
|
21
21
|
);
|
|
22
|
-
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 附加参数序列化:error/warn 的第二参(Error/对象)拼进消息本身。
|
|
25
|
+
*
|
|
26
|
+
* 背景:pino-pretty 的 messageFormat 字符串模板只渲染 msg 字段,
|
|
27
|
+
* 「logger.error(msg, err)」的 err 会被整体丢弃(连堆栈都不输出),
|
|
28
|
+
* 曾导致 Gather.js 加载失败只剩「导入失败 xxx.js:」空冒号、无法定位根因。
|
|
29
|
+
* 在封装层统一序列化,保证文档承诺的「第二参数自动解析堆栈」真实生效。
|
|
30
|
+
* @param {unknown[]} args error/warn 的除消息外全部参数
|
|
31
|
+
* @returns {string} 可读文本(Error 优先 stack,对象 JSON,其余 String)
|
|
32
|
+
*/
|
|
33
|
+
function serializeExtra(args) {
|
|
34
|
+
return args
|
|
35
|
+
.map(v => {
|
|
36
|
+
if (v == null) return "";
|
|
37
|
+
if (v instanceof Error) return v.stack || `${v.name}: ${v.message}`;
|
|
38
|
+
if (typeof v === "object") {
|
|
39
|
+
try { return JSON.stringify(v); } catch { return String(v); }
|
|
40
|
+
}
|
|
41
|
+
return String(v);
|
|
42
|
+
})
|
|
43
|
+
.filter(Boolean)
|
|
44
|
+
.join(" ");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 统一日志门面:与文档用法对齐(logger.error("xx", err) 自动带出堆栈)
|
|
49
|
+
* 全部级别统一将附加参数序列化进 msg(pino-pretty messageFormat 只渲染 msg 字段,
|
|
50
|
+
* 任何直接透传的第二参都会被丢弃),保证 info("xx", obj) 与 error 行为一致
|
|
51
|
+
*/
|
|
52
|
+
const logger = {
|
|
53
|
+
info(msg, ...extra) {
|
|
54
|
+
extra.length ? root.info(`${msg} ${serializeExtra(extra)}`) : root.info(msg);
|
|
55
|
+
},
|
|
56
|
+
debug: root.debug.bind(root),
|
|
57
|
+
trace: root.trace.bind(root),
|
|
58
|
+
fatal: root.fatal.bind(root),
|
|
59
|
+
child: root.child.bind(root),
|
|
60
|
+
warn(msg, ...extra) {
|
|
61
|
+
extra.length ? root.warn(`${msg} ${serializeExtra(extra)}`) : root.warn(msg);
|
|
62
|
+
},
|
|
63
|
+
error(msg, ...extra) {
|
|
64
|
+
extra.length ? root.error(`${msg} ${serializeExtra(extra)}`) : root.error(msg);
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export { root };
|
|
69
|
+
export default logger;
|
package/utils/signal.js
CHANGED
|
@@ -23,6 +23,8 @@ async function doShutdown(chan) {
|
|
|
23
23
|
|
|
24
24
|
if (server) {
|
|
25
25
|
try {
|
|
26
|
+
// 先断开空闲 keep-alive 连接,避免 server.close 等待存量长连接拖满停机超时
|
|
27
|
+
typeof server.closeIdleConnections === "function" && server.closeIdleConnections();
|
|
26
28
|
await new Promise((resolve, reject) => server.close(err => (err ? reject(err) : resolve())));
|
|
27
29
|
logger.info("[Chan] HTTP服务已停止接受新连接");
|
|
28
30
|
} catch (e) {
|