@lzpong/httpm 1.0.3 → 1.2.0
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/LICENSE +201 -0
- package/README.md +33 -24
- package/httpm.js +702 -253
- package/package.json +2 -2
package/httpm.js
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
* httpm - 基于 Node.js 原生模块的单文件、零依赖 HTTP 服务库
|
|
3
3
|
*
|
|
4
4
|
* @name httpm
|
|
5
|
-
* @version 1.
|
|
5
|
+
* @version 1.2.0
|
|
6
6
|
* @description 兼容 Express API,内置路由、中间件、静态文件服务、
|
|
7
7
|
* WebSocket、SSE、流式上传、日志系统等功能
|
|
8
8
|
* @license MIT
|
|
9
|
-
* @requires node >=
|
|
9
|
+
* @requires node >= 18.0.0
|
|
10
10
|
* @module httpm
|
|
11
11
|
* @author lzpong
|
|
12
12
|
* @link https://gitee.com/lzpong/httpm
|
|
@@ -41,24 +41,44 @@ const zlib = require('zlib');
|
|
|
41
41
|
* 解析 URL,拆分路径与 Query 参数
|
|
42
42
|
*/
|
|
43
43
|
function parseUrl(urlStr) {
|
|
44
|
+
const hashIdx = urlStr.indexOf('#');
|
|
45
|
+
if (hashIdx !== -1) {
|
|
46
|
+
urlStr = urlStr.substring(0, hashIdx);
|
|
47
|
+
}
|
|
44
48
|
const qIdx = urlStr.indexOf('?');
|
|
45
49
|
if (qIdx === -1) {
|
|
46
50
|
return { pathname: urlStr, query: {} };
|
|
47
51
|
}
|
|
48
|
-
|
|
49
|
-
|
|
52
|
+
return { pathname: urlStr.substring(0, qIdx), query: _parseQueryString(urlStr.substring(qIdx + 1)) };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 解析查询字符串为键值对象
|
|
57
|
+
*/
|
|
58
|
+
function _parseQueryString(qs, plusAsSpace = false) {
|
|
50
59
|
const query = {};
|
|
51
|
-
if (qs)
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
60
|
+
if (!qs) return query;
|
|
61
|
+
qs.split('&').forEach(pair => {
|
|
62
|
+
const eIdx = pair.indexOf('=');
|
|
63
|
+
if (eIdx === -1) {
|
|
64
|
+
try {
|
|
65
|
+
query[decodeURIComponent(plusAsSpace ? pair.replace(/\+/g, ' ') : pair)] = '';
|
|
66
|
+
} catch (e) {
|
|
67
|
+
// 非法 URI 编码,保留原始值
|
|
68
|
+
query[plusAsSpace ? pair.replace(/\+/g, ' ') : pair] = '';
|
|
58
69
|
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
|
|
70
|
+
} else {
|
|
71
|
+
const key = pair.substring(0, eIdx);
|
|
72
|
+
const val = pair.substring(eIdx + 1);
|
|
73
|
+
try {
|
|
74
|
+
query[decodeURIComponent(plusAsSpace ? key.replace(/\+/g, ' ') : key)] = decodeURIComponent(plusAsSpace ? val.replace(/\+/g, ' ') : val);
|
|
75
|
+
} catch (e) {
|
|
76
|
+
// 非法 URI 编码,保留原始值
|
|
77
|
+
query[plusAsSpace ? key.replace(/\+/g, ' ') : key] = plusAsSpace ? val.replace(/\+/g, ' ') : val;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
return query;
|
|
62
82
|
}
|
|
63
83
|
|
|
64
84
|
/**
|
|
@@ -77,6 +97,13 @@ function parseCookies(cookieStr) {
|
|
|
77
97
|
return cookies;
|
|
78
98
|
}
|
|
79
99
|
|
|
100
|
+
/**
|
|
101
|
+
* HTML 实体转义,防止 XSS
|
|
102
|
+
*/
|
|
103
|
+
function escapeHtml(str) {
|
|
104
|
+
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
105
|
+
}
|
|
106
|
+
|
|
80
107
|
/**
|
|
81
108
|
* MIME 类型映射表
|
|
82
109
|
*/
|
|
@@ -146,10 +173,14 @@ function getMimeType(ext) {
|
|
|
146
173
|
* 字节单位格式化(B/KB/MB/GB)
|
|
147
174
|
*/
|
|
148
175
|
function fmtSize(bytes) {
|
|
176
|
+
if (!Number.isFinite(bytes) || bytes < 0) return '0B';
|
|
149
177
|
if (bytes === 0) return '0B';
|
|
150
178
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
151
179
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
152
|
-
|
|
180
|
+
const value = bytes / (1024 ** i);
|
|
181
|
+
// 小于 10 时保留 2 位小数,否则保留 1 位,整数不显示小数
|
|
182
|
+
const formatted = value < 10 ? value.toFixed(2) : (value === Math.floor(value) ? value.toString() : value.toFixed(1));
|
|
183
|
+
return formatted + units[i];
|
|
153
184
|
}
|
|
154
185
|
|
|
155
186
|
/**
|
|
@@ -164,19 +195,19 @@ function fmtTime(ms) {
|
|
|
164
195
|
/**
|
|
165
196
|
* 路径安全校验,防遍历攻击
|
|
166
197
|
*/
|
|
167
|
-
function isPathSafe(requestPath, rootDir) {
|
|
168
|
-
// 去掉前导斜杠,防止 path.resolve 将其视为绝对路径
|
|
198
|
+
function isPathSafe(requestPath, rootDir, allowAllFiles = false) {
|
|
169
199
|
const normalized = requestPath.replace(/^\/+/, '');
|
|
170
200
|
const resolved = path.resolve(rootDir, normalized);
|
|
171
201
|
const root = path.resolve(rootDir);
|
|
172
|
-
// 确保解析后的路径在根目录内
|
|
173
202
|
if (!resolved.startsWith(root + path.sep) && resolved !== root) {
|
|
174
203
|
return false;
|
|
175
204
|
}
|
|
176
|
-
//
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
205
|
+
// allowAllFiles=true 时允许访问所有文件(包括 .env、.git 等隐藏文件)
|
|
206
|
+
if (!allowAllFiles) {
|
|
207
|
+
const parts = normalized.split(/[/\\]/);
|
|
208
|
+
for (const part of parts) {
|
|
209
|
+
if (part.startsWith('.')) return false;
|
|
210
|
+
}
|
|
180
211
|
}
|
|
181
212
|
return true;
|
|
182
213
|
}
|
|
@@ -240,13 +271,14 @@ class Logger {
|
|
|
240
271
|
this.name = options.name || '';
|
|
241
272
|
this.level = options.level || 'info';
|
|
242
273
|
this.logDir = options.logDir || './log';
|
|
243
|
-
this._levels = { debug: 0, info: 1, notice: 2, warn: 3, error: 4 };
|
|
274
|
+
this._levels = { debug: 0, info: 1, notice: 2, warn: 3, error: 4, fatal: 5 };
|
|
244
275
|
this._colors = {
|
|
245
276
|
debug: '\x1b[1;30m', // 灰色
|
|
246
277
|
info: '\x1b[1;37m', // 白色
|
|
247
278
|
notice: '\x1b[1;35m', // 品红
|
|
248
279
|
warn: '\x1b[1;33m', // 黄色
|
|
249
|
-
error: '\x1b[1;31m'
|
|
280
|
+
error: '\x1b[1;31m', // 红色
|
|
281
|
+
fatal: '\x1b[1;31;1m' // 红色加粗
|
|
250
282
|
};
|
|
251
283
|
this._reset = '\x1b[0m';
|
|
252
284
|
this._stream = null;
|
|
@@ -269,23 +301,24 @@ class Logger {
|
|
|
269
301
|
const year = now.getFullYear().toString();
|
|
270
302
|
const month = (now.getMonth() + 1).toString().padStart(2, '0');
|
|
271
303
|
const day = now.getDate().toString().padStart(2, '0');
|
|
272
|
-
const dateStr = `${day}`;
|
|
273
304
|
|
|
274
305
|
// 同一天复用同一个流
|
|
275
306
|
const streamKey = `${year}-${month}-${day}`;
|
|
276
307
|
if (this._stream && this._streamDate === streamKey) {
|
|
277
308
|
return this._stream;
|
|
278
309
|
}
|
|
279
|
-
//
|
|
310
|
+
// 关闭旧流(等待写入完成后再销毁,避免跨日切换时丢失日志)
|
|
311
|
+
// this._stream 已置 null,新日志将写入新流,旧流异步刷盘不会与新流冲突
|
|
280
312
|
if (this._stream) {
|
|
281
|
-
this._stream
|
|
313
|
+
const oldStream = this._stream;
|
|
314
|
+
oldStream.end(() => {
|
|
315
|
+
oldStream.destroy();
|
|
316
|
+
});
|
|
282
317
|
this._stream = null;
|
|
283
318
|
}
|
|
284
|
-
// 创建日志目录: ./log/YYYY/MM
|
|
319
|
+
// 创建日志目录: ./log/YYYY/MM/(recursive: true 自动处理已存在的情况)
|
|
285
320
|
const dir = path.join(this.logDir, year, month);
|
|
286
|
-
|
|
287
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
288
|
-
}
|
|
321
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
289
322
|
// 文件名: name_DD.log 或 DD.log
|
|
290
323
|
const prefix = this.name ? this.name + '_' : '';
|
|
291
324
|
const filePath = path.join(dir, `${prefix}${day}.log`);
|
|
@@ -321,6 +354,7 @@ class Logger {
|
|
|
321
354
|
notice(...args) { this.log('notice', ...args); }
|
|
322
355
|
warn(...args) { this.log('warn', ...args); }
|
|
323
356
|
error(...args) { this.log('error', ...args); }
|
|
357
|
+
fatal(...args) { this.log('fatal', ...args); }
|
|
324
358
|
}
|
|
325
359
|
|
|
326
360
|
// ============================================================
|
|
@@ -363,10 +397,6 @@ class Router {
|
|
|
363
397
|
if (this.routes[key]) {
|
|
364
398
|
this.routes[key].push(route);
|
|
365
399
|
}
|
|
366
|
-
// 同时注册到 ALL
|
|
367
|
-
if (key !== 'ALL') {
|
|
368
|
-
// ALL 路由单独处理,不在此处添加
|
|
369
|
-
}
|
|
370
400
|
return this;
|
|
371
401
|
}
|
|
372
402
|
|
|
@@ -395,18 +425,30 @@ class Router {
|
|
|
395
425
|
const results = [];
|
|
396
426
|
const m = method.toUpperCase();
|
|
397
427
|
|
|
428
|
+
// HEAD 请求同时匹配 GET 路由(Express 兼容行为)
|
|
429
|
+
const methods = [m];
|
|
430
|
+
if (m === 'HEAD') methods.push('GET');
|
|
431
|
+
|
|
398
432
|
// 检查 ALL 路由
|
|
399
433
|
const allRoutes = this.routes['ALL'] || [];
|
|
400
434
|
// 检查对应方法路由
|
|
401
|
-
|
|
402
|
-
const
|
|
435
|
+
let methodRoutes = [...allRoutes];
|
|
436
|
+
for (const meth of methods) {
|
|
437
|
+
const routes = this.routes[meth] || [];
|
|
438
|
+
methodRoutes = methodRoutes.concat(routes);
|
|
439
|
+
}
|
|
403
440
|
|
|
404
|
-
for (const route of
|
|
441
|
+
for (const route of methodRoutes) {
|
|
405
442
|
const match = route.pattern.exec(pathname);
|
|
406
443
|
if (match) {
|
|
407
444
|
const params = {};
|
|
408
445
|
route.params.forEach((name, i) => {
|
|
409
|
-
|
|
446
|
+
try {
|
|
447
|
+
params[name] = decodeURIComponent(match[i + 1]);
|
|
448
|
+
} catch (e) {
|
|
449
|
+
// 非法 URI 编码,保留原始值
|
|
450
|
+
params[name] = match[i + 1];
|
|
451
|
+
}
|
|
410
452
|
});
|
|
411
453
|
results.push({ route, params, handlers: route.handlers });
|
|
412
454
|
}
|
|
@@ -434,7 +476,12 @@ class Router {
|
|
|
434
476
|
const match = mw.pattern.exec(pathname);
|
|
435
477
|
if (match) {
|
|
436
478
|
mw.params.forEach((name, i) => {
|
|
437
|
-
|
|
479
|
+
try {
|
|
480
|
+
params[name] = decodeURIComponent(match[i + 1]);
|
|
481
|
+
} catch (e) {
|
|
482
|
+
// 非法 URI 编码,保留原始值
|
|
483
|
+
params[name] = match[i + 1];
|
|
484
|
+
}
|
|
438
485
|
});
|
|
439
486
|
}
|
|
440
487
|
}
|
|
@@ -463,6 +510,8 @@ class Router {
|
|
|
463
510
|
class Request {
|
|
464
511
|
constructor(incomingMessage) {
|
|
465
512
|
this._req = incomingMessage;
|
|
513
|
+
this._app = null;
|
|
514
|
+
this._res = null;
|
|
466
515
|
this.query = {};
|
|
467
516
|
this.params = {};
|
|
468
517
|
this.body = null;
|
|
@@ -494,22 +543,61 @@ class Request {
|
|
|
494
543
|
}
|
|
495
544
|
|
|
496
545
|
/**
|
|
497
|
-
*
|
|
546
|
+
* 获取请求头(Express 兼容),不区分大小写
|
|
547
|
+
* req.get('Content-Type') / req.get('content-type')
|
|
548
|
+
*/
|
|
549
|
+
get(name) {
|
|
550
|
+
if (!name) return undefined;
|
|
551
|
+
const lower = name.toLowerCase();
|
|
552
|
+
// 特殊别名
|
|
553
|
+
if (lower === 'referrer' || lower === 'referer') {
|
|
554
|
+
return this._req.headers['referer'] || this._req.headers['referrer'];
|
|
555
|
+
}
|
|
556
|
+
return this._req.headers[lower];
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* 读取请求体原始数据(带超时保护和大小限制)
|
|
498
561
|
*/
|
|
499
|
-
_readBody() {
|
|
562
|
+
_readBody(timeoutMs = 30000, maxSize = null) {
|
|
500
563
|
return new Promise((resolve, reject) => {
|
|
501
564
|
if (this._bodyParsed) {
|
|
502
565
|
resolve(this._rawBody);
|
|
503
566
|
return;
|
|
504
567
|
}
|
|
505
568
|
const chunks = [];
|
|
506
|
-
|
|
569
|
+
let totalSize = 0;
|
|
570
|
+
const limit = maxSize || (this._app?.settings?.maxBodySize) || 128 * 1024 * 1024;
|
|
571
|
+
let timedOut = false;
|
|
572
|
+
// 超时定时器
|
|
573
|
+
const timer = setTimeout(() => {
|
|
574
|
+
timedOut = true;
|
|
575
|
+
this._req.destroy();
|
|
576
|
+
reject(new Error('Request body read timeout', { cause: { timeoutMs } }));
|
|
577
|
+
}, timeoutMs);
|
|
578
|
+
this._req.on('data', chunk => {
|
|
579
|
+
totalSize += chunk.length;
|
|
580
|
+
// 流式大小检查,超限时立即中断
|
|
581
|
+
if (totalSize > limit) {
|
|
582
|
+
clearTimeout(timer);
|
|
583
|
+
this._req.destroy();
|
|
584
|
+
const err = new Error(`Request body exceeds maximum size of ${fmtSize(limit)}`, { cause: { actual: totalSize, maxSize: limit } });
|
|
585
|
+
err.status = 413;
|
|
586
|
+
reject(err);
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
chunks.push(chunk);
|
|
590
|
+
});
|
|
507
591
|
this._req.on('end', () => {
|
|
592
|
+
clearTimeout(timer);
|
|
508
593
|
this._rawBody = Buffer.concat(chunks);
|
|
509
594
|
this._bodyParsed = true;
|
|
510
595
|
resolve(this._rawBody);
|
|
511
596
|
});
|
|
512
|
-
this._req.on('error',
|
|
597
|
+
this._req.on('error', (err) => {
|
|
598
|
+
clearTimeout(timer);
|
|
599
|
+
if (!timedOut) reject(err);
|
|
600
|
+
});
|
|
513
601
|
});
|
|
514
602
|
}
|
|
515
603
|
}
|
|
@@ -524,6 +612,7 @@ class Response {
|
|
|
524
612
|
this._app = app;
|
|
525
613
|
this.statusCode = 200;
|
|
526
614
|
this._headersSent = false;
|
|
615
|
+
this._isHead = false;
|
|
527
616
|
this._sse = null;
|
|
528
617
|
}
|
|
529
618
|
|
|
@@ -545,6 +634,59 @@ class Response {
|
|
|
545
634
|
return this;
|
|
546
635
|
}
|
|
547
636
|
|
|
637
|
+
/**
|
|
638
|
+
* 设置响应头(Express 兼容),支持单键和对象批量设置
|
|
639
|
+
* res.set('Content-Type', 'text/html')
|
|
640
|
+
* res.set({ 'Content-Type': 'text/html', 'X-Custom': 'value' })
|
|
641
|
+
*/
|
|
642
|
+
set(name, value) {
|
|
643
|
+
if (typeof name === 'object') {
|
|
644
|
+
for (const key of Object.keys(name)) {
|
|
645
|
+
this._res.setHeader(key, name[key]);
|
|
646
|
+
}
|
|
647
|
+
} else {
|
|
648
|
+
this._res.setHeader(name, value);
|
|
649
|
+
}
|
|
650
|
+
return this;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* 设置 Content-Type(Express 兼容),支持简写
|
|
655
|
+
* res.type('html') → text/html
|
|
656
|
+
* res.type('.html') → text/html
|
|
657
|
+
* res.type('text/html') → text/html
|
|
658
|
+
*/
|
|
659
|
+
type(contentType) {
|
|
660
|
+
// 已是完整 MIME 类型,直接设置
|
|
661
|
+
if (contentType.includes('/')) {
|
|
662
|
+
this.setHeader('Content-Type', contentType);
|
|
663
|
+
return this;
|
|
664
|
+
}
|
|
665
|
+
// 简写或带点号扩展名,通过 getMimeType 解析
|
|
666
|
+
const ext = contentType.startsWith('.') ? contentType : '.' + contentType;
|
|
667
|
+
const mime = getMimeType(ext);
|
|
668
|
+
this.setHeader('Content-Type', mime);
|
|
669
|
+
return this;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* 代理原生 ServerResponse 事件监听(Express 兼容)
|
|
674
|
+
* res.on('finish', fn) / res.on('close', fn)
|
|
675
|
+
*/
|
|
676
|
+
on(event, listener) {
|
|
677
|
+
this._res.on(event, listener);
|
|
678
|
+
return this;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* 获取响应头(Express 兼容),不区分大小写
|
|
683
|
+
* res.get('Content-Type')
|
|
684
|
+
*/
|
|
685
|
+
get(name) {
|
|
686
|
+
if (!name) return undefined;
|
|
687
|
+
return this._res.getHeader(name);
|
|
688
|
+
}
|
|
689
|
+
|
|
548
690
|
/**
|
|
549
691
|
* 设置 HTTP 状态码,支持链式调用
|
|
550
692
|
*/
|
|
@@ -567,11 +709,18 @@ class Response {
|
|
|
567
709
|
* 通用输出,支持字符串、HTML、Buffer、对象
|
|
568
710
|
*/
|
|
569
711
|
send(data) {
|
|
570
|
-
|
|
712
|
+
// Express 兼容:null 序列化为 "null",undefined 返回空响应
|
|
713
|
+
if (data === undefined) {
|
|
571
714
|
this.setHeader('Content-Length', 0);
|
|
572
715
|
this._send('');
|
|
573
716
|
return;
|
|
574
717
|
}
|
|
718
|
+
if (data === null) {
|
|
719
|
+
this.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
720
|
+
this.setHeader('Content-Length', 4);
|
|
721
|
+
this._send('null');
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
575
724
|
if (Buffer.isBuffer(data)) {
|
|
576
725
|
this.setHeader('Content-Type', 'application/octet-stream');
|
|
577
726
|
this.setHeader('Content-Length', data.length);
|
|
@@ -602,7 +751,12 @@ class Response {
|
|
|
602
751
|
if (this._res.finished) return;
|
|
603
752
|
this._res.statusCode = this.statusCode;
|
|
604
753
|
this._headersSent = true;
|
|
605
|
-
|
|
754
|
+
// HEAD 请求只发送头部,不发送响应体
|
|
755
|
+
if (this._isHead) {
|
|
756
|
+
this._res.end();
|
|
757
|
+
} else {
|
|
758
|
+
this._res.end(data);
|
|
759
|
+
}
|
|
606
760
|
}
|
|
607
761
|
|
|
608
762
|
/**
|
|
@@ -635,7 +789,10 @@ class Response {
|
|
|
635
789
|
const ifNoneMatch = this._req?.headers?.['if-none-match'];
|
|
636
790
|
const ifModifiedSince = this._req?.headers?.['if-modified-since'];
|
|
637
791
|
if (ifNoneMatch === etag || (ifModifiedSince && new Date(ifModifiedSince) >= stat.mtime)) {
|
|
638
|
-
this.status(304)
|
|
792
|
+
this.status(304);
|
|
793
|
+
this.removeHeader('Content-Length');
|
|
794
|
+
this._res.statusCode = 304;
|
|
795
|
+
this._res.end();
|
|
639
796
|
return;
|
|
640
797
|
}
|
|
641
798
|
}
|
|
@@ -648,7 +805,7 @@ class Response {
|
|
|
648
805
|
this.status(206);
|
|
649
806
|
this.setHeader('Content-Range', `bytes ${range.start}-${range.end}/${range.total}`);
|
|
650
807
|
this.setHeader('Content-Length', range.end - range.start + 1);
|
|
651
|
-
this._streamFile(fullPath, range.start, range.end
|
|
808
|
+
this._streamFile(fullPath, range.start, range.end);
|
|
652
809
|
return;
|
|
653
810
|
}
|
|
654
811
|
}
|
|
@@ -658,50 +815,57 @@ class Response {
|
|
|
658
815
|
// Gzip 压缩(仅文本类文件)
|
|
659
816
|
const acceptEncoding = this._req?.headers?.['accept-encoding'] || '';
|
|
660
817
|
if (this._app.settings.enableGzip && isTextMime(mime) && acceptEncoding.includes('gzip')) {
|
|
818
|
+
// HEAD 请求不传输内容
|
|
819
|
+
if (this._isHead) {
|
|
820
|
+
this._send('');
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
661
823
|
this.removeHeader('Content-Length');
|
|
662
824
|
this.setHeader('Content-Encoding', 'gzip');
|
|
663
|
-
this.
|
|
825
|
+
this.status(this.statusCode);
|
|
664
826
|
this._headersSent = true;
|
|
665
827
|
const raw = fs.createReadStream(fullPath);
|
|
666
828
|
const gzip = zlib.createGzip();
|
|
829
|
+
// 流错误处理:文件读取或压缩出错时返回 500
|
|
830
|
+
const onError = (err) => {
|
|
831
|
+
raw.destroy();
|
|
832
|
+
gzip.destroy();
|
|
833
|
+
if (!this._res.finished) {
|
|
834
|
+
this._res.statusCode = 500;
|
|
835
|
+
this._res.end('Internal Server Error');
|
|
836
|
+
}
|
|
837
|
+
};
|
|
838
|
+
raw.on('error', onError);
|
|
839
|
+
gzip.on('error', onError);
|
|
667
840
|
raw.pipe(gzip).pipe(this._res);
|
|
668
841
|
return;
|
|
669
842
|
}
|
|
670
843
|
|
|
671
|
-
this._streamFile(fullPath, 0, stat.size - 1
|
|
844
|
+
this._streamFile(fullPath, 0, stat.size - 1);
|
|
672
845
|
});
|
|
673
846
|
}
|
|
674
847
|
|
|
675
848
|
/**
|
|
676
849
|
* 流式发送文件
|
|
677
850
|
*/
|
|
678
|
-
_streamFile(fullPath, start, end
|
|
851
|
+
_streamFile(fullPath, start, end) {
|
|
679
852
|
if (this._res.finished) return;
|
|
680
853
|
this._res.statusCode = this.statusCode;
|
|
681
854
|
this._headersSent = true;
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
const totalSize = end - start + 1;
|
|
687
|
-
let sent = 0;
|
|
688
|
-
const startTime = Date.now();
|
|
689
|
-
let lastLine = '';
|
|
690
|
-
stream.on('data', (chunk) => {
|
|
691
|
-
sent += chunk.length;
|
|
692
|
-
const pct = ((sent / totalSize) * 100).toFixed(1);
|
|
693
|
-
const elapsed = Date.now() - startTime;
|
|
694
|
-
const speed = elapsed > 0 ? sent / (elapsed / 1000) : 0;
|
|
695
|
-
const line = `\r[${name}] ${fmtSize(sent)}/${fmtSize(totalSize)} ${pct}% ${fmtSize(speed)}/s ${fmtTime(elapsed)}`;
|
|
696
|
-
if (line !== lastLine) {
|
|
697
|
-
process.stdout.write(line);
|
|
698
|
-
lastLine = line;
|
|
699
|
-
}
|
|
700
|
-
});
|
|
701
|
-
stream.on('end', () => {
|
|
702
|
-
if (sent > 0) process.stdout.write('\n');
|
|
703
|
-
});
|
|
855
|
+
// HEAD 请求只发送头部,不传输文件内容
|
|
856
|
+
if (this._isHead) {
|
|
857
|
+
this._res.end();
|
|
858
|
+
return;
|
|
704
859
|
}
|
|
860
|
+
const stream = fs.createReadStream(fullPath, { start, end });
|
|
861
|
+
// 流错误处理:文件读取出错时返回 500
|
|
862
|
+
stream.on('error', (err) => {
|
|
863
|
+
stream.destroy();
|
|
864
|
+
if (!this._res.finished) {
|
|
865
|
+
this._res.statusCode = 500;
|
|
866
|
+
this._res.end('Internal Server Error');
|
|
867
|
+
}
|
|
868
|
+
});
|
|
705
869
|
stream.pipe(this._res);
|
|
706
870
|
}
|
|
707
871
|
|
|
@@ -715,11 +879,18 @@ class Response {
|
|
|
715
879
|
}
|
|
716
880
|
|
|
717
881
|
/**
|
|
718
|
-
*
|
|
882
|
+
* 重定向响应(兼容 Express: redirect(status, url) 或 redirect(url))
|
|
719
883
|
*/
|
|
720
|
-
redirect(
|
|
721
|
-
|
|
722
|
-
|
|
884
|
+
redirect(...args) {
|
|
885
|
+
if (typeof args[0] === 'number') {
|
|
886
|
+
// redirect(status, url)
|
|
887
|
+
this.status(args[0]);
|
|
888
|
+
this.setHeader('Location', args[1]);
|
|
889
|
+
} else {
|
|
890
|
+
// redirect(url) 默认 302
|
|
891
|
+
this.status(302);
|
|
892
|
+
this.setHeader('Location', args[0]);
|
|
893
|
+
}
|
|
723
894
|
this.setHeader('Content-Length', 0);
|
|
724
895
|
this._send('');
|
|
725
896
|
}
|
|
@@ -737,16 +908,16 @@ class Response {
|
|
|
737
908
|
* 设置响应 Cookie
|
|
738
909
|
*/
|
|
739
910
|
cookie(name, value, opts = {}) {
|
|
740
|
-
let
|
|
741
|
-
// 签名 Cookie:s:value.signature
|
|
911
|
+
let encodedValue = encodeURIComponent(value);
|
|
912
|
+
// 签名 Cookie:s:value.signature(s: 前缀不参与编码)
|
|
742
913
|
if (opts.signed) {
|
|
743
914
|
const secret = this._app && this._app.settings && this._app.settings.cookieParserSecret;
|
|
744
915
|
if (secret) {
|
|
745
916
|
const sig = crypto.createHmac('sha256', secret).update(value).digest('base64').replace(/=+$/, '');
|
|
746
|
-
|
|
917
|
+
encodedValue = 's:' + encodedValue + '.' + sig;
|
|
747
918
|
}
|
|
748
919
|
}
|
|
749
|
-
let str = `${encodeURIComponent(name)}=${
|
|
920
|
+
let str = `${encodeURIComponent(name)}=${encodedValue}`;
|
|
750
921
|
if (opts.maxAge !== undefined) str += `; Max-Age=${opts.maxAge}`;
|
|
751
922
|
if (opts.domain) str += `; Domain=${opts.domain}`;
|
|
752
923
|
if (opts.path) str += `; Path=${opts.path}`;
|
|
@@ -778,18 +949,24 @@ class SSE {
|
|
|
778
949
|
this._res = res;
|
|
779
950
|
this.connected = true;
|
|
780
951
|
|
|
781
|
-
// 设置 SSE
|
|
782
|
-
res.
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
952
|
+
// 设置 SSE 标准响应头(仅在 headers 未发送时)
|
|
953
|
+
if (!res.headersSent) {
|
|
954
|
+
res.writeHead(200, {
|
|
955
|
+
'Content-Type': 'text/event-stream',
|
|
956
|
+
'Cache-Control': 'no-cache',
|
|
957
|
+
'Connection': 'keep-alive',
|
|
958
|
+
'Access-Control-Allow-Origin': '*'
|
|
959
|
+
});
|
|
960
|
+
}
|
|
788
961
|
|
|
789
|
-
//
|
|
790
|
-
|
|
962
|
+
// 监听连接关闭(兼容 HTTP/1.1 和 HTTP/2)
|
|
963
|
+
const onClose = () => {
|
|
791
964
|
this.connected = false;
|
|
792
|
-
}
|
|
965
|
+
};
|
|
966
|
+
this._onClose = onClose;
|
|
967
|
+
res.on('close', onClose);
|
|
968
|
+
// HTTP/1.1 兼容:aborted 事件在请求被客户端中断时触发
|
|
969
|
+
res.on('aborted', onClose);
|
|
793
970
|
}
|
|
794
971
|
|
|
795
972
|
/**
|
|
@@ -831,11 +1008,13 @@ class SSE {
|
|
|
831
1008
|
}
|
|
832
1009
|
|
|
833
1010
|
/**
|
|
834
|
-
* 主动关闭 SSE
|
|
1011
|
+
* 主动关闭 SSE 连接,移除监听器防止内存泄漏
|
|
835
1012
|
*/
|
|
836
1013
|
close() {
|
|
837
1014
|
if (!this.connected) return;
|
|
838
1015
|
this.connected = false;
|
|
1016
|
+
this._res.removeListener('close', this._onClose);
|
|
1017
|
+
this._res.removeListener('aborted', this._onClose);
|
|
839
1018
|
this._res.end();
|
|
840
1019
|
}
|
|
841
1020
|
}
|
|
@@ -844,14 +1023,41 @@ class SSE {
|
|
|
844
1023
|
// WebSocket 类
|
|
845
1024
|
// ============================================================
|
|
846
1025
|
|
|
1026
|
+
/**
|
|
1027
|
+
* 通用事件触发函数(WebSocket 和 WebSocketServer 共用)
|
|
1028
|
+
*/
|
|
1029
|
+
function _emitEvent(handlers, event, args) {
|
|
1030
|
+
const list = handlers[event];
|
|
1031
|
+
if (list) {
|
|
1032
|
+
list.forEach(h => {
|
|
1033
|
+
try { h(...args); } catch (e) { console.error(`[httpm] Event handler error on "${event}":`, e); }
|
|
1034
|
+
});
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
847
1038
|
class WebSocket {
|
|
848
|
-
constructor(socket, pathStr) {
|
|
1039
|
+
constructor(socket, pathStr, options = {}) {
|
|
849
1040
|
this.socket = socket;
|
|
850
1041
|
this.path = pathStr;
|
|
851
1042
|
this.id = uid();
|
|
852
1043
|
this.connected = true;
|
|
853
1044
|
this._lastHeartbeat = Date.now();
|
|
854
1045
|
this._handlers = {};
|
|
1046
|
+
// 最大帧负载大小(默认 100MB,防止恶意超大帧耗尽内存)
|
|
1047
|
+
this._maxPayload = options.maxPayload || 100 * 1024 * 1024;
|
|
1048
|
+
// 写入背压标记
|
|
1049
|
+
this._writeBacklogged = false;
|
|
1050
|
+
// 帧解析状态:缓存不完整帧数据
|
|
1051
|
+
this._frameBuffer = Buffer.alloc(0);
|
|
1052
|
+
// 分片帧状态
|
|
1053
|
+
this._fragmented = false;
|
|
1054
|
+
this._fragmentOpcode = 0;
|
|
1055
|
+
this._fragmentPayloads = [];
|
|
1056
|
+
// 防止 close 事件重复触发
|
|
1057
|
+
this._closed = false;
|
|
1058
|
+
// 关闭握手状态
|
|
1059
|
+
this._closing = false;
|
|
1060
|
+
this._closeTimer = null;
|
|
855
1061
|
|
|
856
1062
|
// 监听底层 Pong 帧
|
|
857
1063
|
socket.on('pong', () => {
|
|
@@ -861,28 +1067,43 @@ class WebSocket {
|
|
|
861
1067
|
// 监听连接关闭
|
|
862
1068
|
socket.on('close', () => {
|
|
863
1069
|
this.connected = false;
|
|
864
|
-
this.
|
|
1070
|
+
this._emitClose();
|
|
865
1071
|
});
|
|
866
1072
|
|
|
867
1073
|
// 监听数据帧
|
|
868
1074
|
socket.on('data', (data) => {
|
|
869
|
-
this.
|
|
1075
|
+
this._frameBuffer = Buffer.concat([this._frameBuffer, data]);
|
|
1076
|
+
this._parseFrames();
|
|
870
1077
|
});
|
|
871
1078
|
|
|
872
|
-
|
|
1079
|
+
// 监听底层错误,传递错误对象
|
|
1080
|
+
socket.on('error', (err) => {
|
|
873
1081
|
this.connected = false;
|
|
874
|
-
this._emit('error');
|
|
1082
|
+
this._emit('error', err);
|
|
875
1083
|
});
|
|
876
1084
|
}
|
|
877
1085
|
|
|
878
1086
|
/**
|
|
879
|
-
*
|
|
1087
|
+
* 循环解析帧缓冲区,处理多帧/半帧
|
|
880
1088
|
*/
|
|
881
|
-
|
|
882
|
-
|
|
1089
|
+
_parseFrames() {
|
|
1090
|
+
while (this._frameBuffer.length >= 2) {
|
|
1091
|
+
const result = this._decodeFrame(this._frameBuffer);
|
|
1092
|
+
if (result === null) break; // 数据不完整,等待更多数据
|
|
1093
|
+
this._frameBuffer = this._frameBuffer.slice(result.bytesConsumed);
|
|
1094
|
+
this._processFrame(result.opcode, result.payload, result.fin, result.oversize);
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
883
1097
|
|
|
884
|
-
|
|
885
|
-
|
|
1098
|
+
/**
|
|
1099
|
+
* 从缓冲区解码一个帧,返回 { opcode, payload, fin, bytesConsumed } 或 null(数据不完整)
|
|
1100
|
+
*/
|
|
1101
|
+
_decodeFrame(buf) {
|
|
1102
|
+
if (buf.length < 2) return null;
|
|
1103
|
+
|
|
1104
|
+
const firstByte = buf[0];
|
|
1105
|
+
const secondByte = buf[1];
|
|
1106
|
+
const fin = (firstByte & 0x80) !== 0;
|
|
886
1107
|
const opcode = firstByte & 0x0F;
|
|
887
1108
|
const isMasked = (secondByte & 0x80) !== 0;
|
|
888
1109
|
let payloadLength = secondByte & 0x7F;
|
|
@@ -890,51 +1111,132 @@ class WebSocket {
|
|
|
890
1111
|
|
|
891
1112
|
// 解析长度
|
|
892
1113
|
if (payloadLength === 126) {
|
|
893
|
-
|
|
1114
|
+
if (buf.length < 4) return null;
|
|
1115
|
+
payloadLength = buf.readUInt16BE(offset);
|
|
894
1116
|
offset += 2;
|
|
895
1117
|
} else if (payloadLength === 127) {
|
|
896
|
-
|
|
1118
|
+
if (buf.length < 10) return null;
|
|
1119
|
+
const bigLen = buf.readBigUInt64BE(offset);
|
|
1120
|
+
// 超过 Number.MAX_SAFE_INTEGER 时精度丢失,直接视为超限
|
|
1121
|
+
if (bigLen > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
1122
|
+
return { opcode: 0xFF, payload: Buffer.alloc(0), fin: true, bytesConsumed: buf.length, oversize: true };
|
|
1123
|
+
}
|
|
1124
|
+
payloadLength = Number(bigLen);
|
|
897
1125
|
offset += 8;
|
|
898
1126
|
}
|
|
899
1127
|
|
|
900
1128
|
// 解析掩码
|
|
901
1129
|
let mask = null;
|
|
902
1130
|
if (isMasked) {
|
|
903
|
-
|
|
1131
|
+
if (buf.length < offset + 4) return null;
|
|
1132
|
+
mask = buf.slice(offset, offset + 4);
|
|
904
1133
|
offset += 4;
|
|
905
1134
|
}
|
|
906
1135
|
|
|
1136
|
+
// 检查负载数据是否完整
|
|
1137
|
+
if (buf.length < offset + payloadLength) return null;
|
|
1138
|
+
|
|
1139
|
+
// 最大负载限制检查,超限时返回错误标记
|
|
1140
|
+
if (payloadLength > this._maxPayload) {
|
|
1141
|
+
return { opcode: 0xFF, payload: Buffer.alloc(0), fin: true, bytesConsumed: offset + payloadLength, oversize: true };
|
|
1142
|
+
}
|
|
1143
|
+
|
|
907
1144
|
// 提取负载
|
|
908
|
-
let payload =
|
|
1145
|
+
let payload = buf.slice(offset, offset + payloadLength);
|
|
909
1146
|
if (isMasked && mask) {
|
|
910
1147
|
for (let i = 0; i < payload.length; i++) {
|
|
911
1148
|
payload[i] ^= mask[i % 4];
|
|
912
1149
|
}
|
|
913
1150
|
}
|
|
914
1151
|
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
1152
|
+
return { opcode, payload, fin, bytesConsumed: offset + payloadLength };
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
/**
|
|
1156
|
+
* 处理解码后的帧,支持分片帧(continuation frame, opcode=0x00)
|
|
1157
|
+
*/
|
|
1158
|
+
_processFrame(opcode, payload, fin, oversize) {
|
|
1159
|
+
// 超限帧:直接关闭连接
|
|
1160
|
+
if (oversize) {
|
|
1161
|
+
this.close(1009, 'Frame payload too large');
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
// 关闭握手期间忽略数据帧
|
|
1165
|
+
if (this._closing && opcode !== 0x08 && opcode !== 0x09 && opcode !== 0x0A) {
|
|
1166
|
+
return;
|
|
1167
|
+
}
|
|
1168
|
+
// 控制帧(close/ping/pong)不分片,立即处理
|
|
1169
|
+
if (opcode === 0x08) {
|
|
1170
|
+
// 解析 Close 帧状态码和原因(RFC 6455 Section 5.5.1)
|
|
1171
|
+
let code = 1005;
|
|
1172
|
+
let reason = '';
|
|
1173
|
+
if (payload.length >= 2) {
|
|
1174
|
+
code = payload.readUInt16BE(0);
|
|
1175
|
+
// RFC 6455 Section 7.4: 状态码 0-999 为非法,1005 表示无状态码
|
|
1176
|
+
if (code < 1000) code = 1005;
|
|
1177
|
+
reason = payload.length > 2 ? payload.slice(2).toString('utf8') : '';
|
|
921
1178
|
}
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
this.
|
|
925
|
-
|
|
1179
|
+
// 如果正在关闭握手中,对端已回复 Close 帧,完成握手
|
|
1180
|
+
if (this._closing) {
|
|
1181
|
+
clearTimeout(this._closeTimer);
|
|
1182
|
+
this._closeTimer = null;
|
|
1183
|
+
try { this.socket.end(); } catch (e) { /* 忽略 */ }
|
|
1184
|
+
this._emitClose(code, reason);
|
|
1185
|
+
return;
|
|
1186
|
+
}
|
|
1187
|
+
// 非关闭握手状态:回复 Close 帧后关闭
|
|
1188
|
+
// RFC 6455 Section 7.4.1: 1005/1006 状态码不得在 Close 帧中发送
|
|
1189
|
+
this._sendCloseFrame(code === 1005 || code === 1006 ? undefined : code);
|
|
1190
|
+
this.connected = false;
|
|
1191
|
+
this._emitClose(code, reason);
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1194
|
+
if (opcode === 0x09) {
|
|
1195
|
+
this._sendPong(payload);
|
|
1196
|
+
return;
|
|
1197
|
+
}
|
|
1198
|
+
if (opcode === 0x0A) {
|
|
1199
|
+
this._lastHeartbeat = Date.now();
|
|
1200
|
+
return;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
// 数据帧:处理分片
|
|
1204
|
+
if (opcode === 0x00) {
|
|
1205
|
+
// 分片续帧:必须有前导帧
|
|
1206
|
+
if (!this._fragmented) return;
|
|
1207
|
+
this._fragmentPayloads.push(payload);
|
|
1208
|
+
if (fin) {
|
|
1209
|
+
// 分片结束,合并并触发事件
|
|
1210
|
+
const fullPayload = Buffer.concat(this._fragmentPayloads);
|
|
1211
|
+
this._emitData(this._fragmentOpcode, fullPayload);
|
|
1212
|
+
this._fragmented = false;
|
|
1213
|
+
this._fragmentPayloads = [];
|
|
1214
|
+
}
|
|
1215
|
+
} else {
|
|
1216
|
+
// 新消息帧(opcode=0x01/0x02)
|
|
1217
|
+
if (fin) {
|
|
1218
|
+
// 非分片:直接触发
|
|
1219
|
+
this._emitData(opcode, payload);
|
|
1220
|
+
} else {
|
|
1221
|
+
// 分片开始
|
|
1222
|
+
this._fragmented = true;
|
|
1223
|
+
this._fragmentOpcode = opcode;
|
|
1224
|
+
this._fragmentPayloads = [payload];
|
|
926
1225
|
}
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
/**
|
|
1230
|
+
* 根据操作码触发数据事件
|
|
1231
|
+
*/
|
|
1232
|
+
_emitData(opcode, payload) {
|
|
1233
|
+
if (opcode === 0x01) {
|
|
1234
|
+
const text = payload.toString('utf8');
|
|
1235
|
+
this._emit('data', { type: 'text', data: text });
|
|
1236
|
+
this._emit('text', text);
|
|
1237
|
+
} else if (opcode === 0x02) {
|
|
1238
|
+
this._emit('data', { type: 'binary', data: payload });
|
|
1239
|
+
this._emit('binary', payload);
|
|
938
1240
|
}
|
|
939
1241
|
}
|
|
940
1242
|
|
|
@@ -980,9 +1282,15 @@ class WebSocket {
|
|
|
980
1282
|
frames.push(payload);
|
|
981
1283
|
const frame = Buffer.concat(frames);
|
|
982
1284
|
try {
|
|
983
|
-
this.socket.write(frame);
|
|
1285
|
+
const canWrite = this.socket.write(frame);
|
|
1286
|
+
// 写入缓冲区满时记录警告(WebSocket 不像 HTTP 可暂停请求流,只能记录)
|
|
1287
|
+
if (!canWrite) {
|
|
1288
|
+
this._writeBacklogged = true;
|
|
1289
|
+
}
|
|
984
1290
|
} catch (e) {
|
|
985
1291
|
this.connected = false;
|
|
1292
|
+
// 发送失败时触发 error 事件,便于用户感知和处理
|
|
1293
|
+
this._emit('error', e);
|
|
986
1294
|
}
|
|
987
1295
|
}
|
|
988
1296
|
|
|
@@ -1003,22 +1311,57 @@ class WebSocket {
|
|
|
1003
1311
|
/**
|
|
1004
1312
|
* 发送关闭帧
|
|
1005
1313
|
*/
|
|
1006
|
-
_sendCloseFrame() {
|
|
1314
|
+
_sendCloseFrame(code, reason) {
|
|
1007
1315
|
try {
|
|
1008
|
-
|
|
1316
|
+
let payload = Buffer.alloc(0);
|
|
1317
|
+
// RFC 6455: Close 帧可携带状态码(2字节)+ 可选原因(UTF-8)
|
|
1318
|
+
if (code !== undefined && code !== null) {
|
|
1319
|
+
const reasonBuf = reason ? Buffer.from(reason, 'utf8') : Buffer.alloc(0);
|
|
1320
|
+
payload = Buffer.alloc(2 + reasonBuf.length);
|
|
1321
|
+
payload.writeUInt16BE(code, 0);
|
|
1322
|
+
reasonBuf.copy(payload, 2);
|
|
1323
|
+
}
|
|
1324
|
+
this._sendFrame(0x08, payload);
|
|
1009
1325
|
} catch (e) {
|
|
1010
1326
|
// 忽略关闭帧发送错误
|
|
1011
1327
|
}
|
|
1012
1328
|
}
|
|
1013
1329
|
|
|
1014
1330
|
/**
|
|
1015
|
-
*
|
|
1331
|
+
* 关闭连接(限时等待对端 Close 帧完成握手)
|
|
1332
|
+
* @param {number} [code] 关闭状态码(RFC 6455 Section 7.4)
|
|
1333
|
+
* @param {string} [reason] 关闭原因
|
|
1016
1334
|
*/
|
|
1017
|
-
close() {
|
|
1018
|
-
this.
|
|
1335
|
+
close(code, reason) {
|
|
1336
|
+
if (this._closed) return;
|
|
1337
|
+
// 重要:必须先发送 Close 帧再设 connected=false
|
|
1338
|
+
// _sendFrame 内部检查 this.connected,若先断开则 Close 帧无法发出
|
|
1339
|
+
this._sendCloseFrame(code, reason);
|
|
1340
|
+
// 标记为关闭中,拒绝后续数据帧发送和 _sendFrame 写入
|
|
1019
1341
|
this.connected = false;
|
|
1020
|
-
|
|
1021
|
-
|
|
1342
|
+
this._closing = true;
|
|
1343
|
+
// 限时等待对端 Close 帧(2秒超时)
|
|
1344
|
+
this._closeTimer = setTimeout(() => {
|
|
1345
|
+
this._closeTimer = null;
|
|
1346
|
+
// 先标记已关闭,防止 socket.destroy 触发 close 事件时重复 _emitClose
|
|
1347
|
+
this._closed = true;
|
|
1348
|
+
try { this.socket.destroy(); } catch (e) { /* 忽略 */ }
|
|
1349
|
+
this._emitClose(code, reason);
|
|
1350
|
+
}, 2000);
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
/**
|
|
1354
|
+
* 安全触发 close 事件(防止重复触发)
|
|
1355
|
+
*/
|
|
1356
|
+
_emitClose(code, reason) {
|
|
1357
|
+
if (this._closed) return;
|
|
1358
|
+
this._closed = true;
|
|
1359
|
+
// 清理关闭握手定时器
|
|
1360
|
+
if (this._closeTimer) {
|
|
1361
|
+
clearTimeout(this._closeTimer);
|
|
1362
|
+
this._closeTimer = null;
|
|
1363
|
+
}
|
|
1364
|
+
this._emit('close', code, reason);
|
|
1022
1365
|
}
|
|
1023
1366
|
|
|
1024
1367
|
/**
|
|
@@ -1034,12 +1377,7 @@ class WebSocket {
|
|
|
1034
1377
|
* 触发事件
|
|
1035
1378
|
*/
|
|
1036
1379
|
_emit(event, ...args) {
|
|
1037
|
-
|
|
1038
|
-
if (handlers) {
|
|
1039
|
-
handlers.forEach(h => {
|
|
1040
|
-
try { h(...args); } catch (e) { /* 忽略 */ }
|
|
1041
|
-
});
|
|
1042
|
-
}
|
|
1380
|
+
_emitEvent(this._handlers, event, args);
|
|
1043
1381
|
}
|
|
1044
1382
|
}
|
|
1045
1383
|
|
|
@@ -1050,7 +1388,7 @@ class WebSocket {
|
|
|
1050
1388
|
/**
|
|
1051
1389
|
* WebSocket 握手辅助函数:计算 Sec-WebSocket-Accept 值
|
|
1052
1390
|
*/
|
|
1053
|
-
function
|
|
1391
|
+
function WebSocketHandShak(key) {
|
|
1054
1392
|
return crypto.createHash('sha1')
|
|
1055
1393
|
.update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11')
|
|
1056
1394
|
.digest('base64');
|
|
@@ -1062,6 +1400,8 @@ class WebSocketServer {
|
|
|
1062
1400
|
this.groups = new Map(); // path -> Set<WebSocket>
|
|
1063
1401
|
this._heartbeatInterval = options.heartbeatInterval || 30000;
|
|
1064
1402
|
this._heartbeatTimeout = options.heartbeatTimeout || 30000;
|
|
1403
|
+
this._maxPayload = options.maxPayload || 100 * 1024 * 1024;
|
|
1404
|
+
this._allowedOrigins = options.allowedOrigins || null;
|
|
1065
1405
|
this._timer = null;
|
|
1066
1406
|
this._handlers = {};
|
|
1067
1407
|
}
|
|
@@ -1079,9 +1419,17 @@ class WebSocketServer {
|
|
|
1079
1419
|
return null;
|
|
1080
1420
|
}
|
|
1081
1421
|
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
.
|
|
1422
|
+
// Origin 校验(防止跨站 WebSocket 劫持 CSWSH)
|
|
1423
|
+
if (this._allowedOrigins) {
|
|
1424
|
+
const origin = req.headers['origin'];
|
|
1425
|
+
if (!origin || !this._allowedOrigins.includes(origin)) {
|
|
1426
|
+
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
|
|
1427
|
+
socket.destroy();
|
|
1428
|
+
return null;
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
const accept = WebSocketHandShak(key);
|
|
1085
1433
|
|
|
1086
1434
|
// 发送握手响应
|
|
1087
1435
|
const responseHeaders = [
|
|
@@ -1093,7 +1441,7 @@ class WebSocketServer {
|
|
|
1093
1441
|
socket.write(responseHeaders.join('\r\n') + '\r\n\r\n');
|
|
1094
1442
|
|
|
1095
1443
|
// 创建 WebSocket 实例
|
|
1096
|
-
const ws = new WebSocket(socket, pathname);
|
|
1444
|
+
const ws = new WebSocket(socket, pathname, { maxPayload: this._maxPayload });
|
|
1097
1445
|
this.connections.set(ws.id, ws);
|
|
1098
1446
|
|
|
1099
1447
|
// 按路径分组
|
|
@@ -1141,19 +1489,24 @@ class WebSocketServer {
|
|
|
1141
1489
|
if (this._timer) return;
|
|
1142
1490
|
this._timer = setInterval(() => {
|
|
1143
1491
|
const now = Date.now();
|
|
1492
|
+
// 先收集需移除的连接,遍历结束后统一移除,避免迭代中修改 Map
|
|
1493
|
+
const toRemove = [];
|
|
1144
1494
|
for (const ws of this.connections.values()) {
|
|
1145
1495
|
if (!ws.connected) {
|
|
1146
|
-
|
|
1496
|
+
toRemove.push(ws);
|
|
1147
1497
|
continue;
|
|
1148
1498
|
}
|
|
1149
1499
|
// 检查心跳超时
|
|
1150
1500
|
if (now - ws._lastHeartbeat > this._heartbeatInterval + this._heartbeatTimeout) {
|
|
1151
1501
|
ws.close();
|
|
1152
|
-
|
|
1502
|
+
toRemove.push(ws);
|
|
1153
1503
|
continue;
|
|
1154
1504
|
}
|
|
1155
1505
|
ws._sendPing();
|
|
1156
1506
|
}
|
|
1507
|
+
for (const ws of toRemove) {
|
|
1508
|
+
this._removeConnection(ws);
|
|
1509
|
+
}
|
|
1157
1510
|
}, this._heartbeatInterval);
|
|
1158
1511
|
}
|
|
1159
1512
|
|
|
@@ -1170,7 +1523,7 @@ class WebSocketServer {
|
|
|
1170
1523
|
/**
|
|
1171
1524
|
* 按路径广播消息
|
|
1172
1525
|
*/
|
|
1173
|
-
|
|
1526
|
+
broadcast(pathStr, data, exclude = null) {
|
|
1174
1527
|
const group = this.groups.get(pathStr);
|
|
1175
1528
|
if (!group) return;
|
|
1176
1529
|
for (const ws of group) {
|
|
@@ -1183,7 +1536,7 @@ class WebSocketServer {
|
|
|
1183
1536
|
/**
|
|
1184
1537
|
* 全局广播消息
|
|
1185
1538
|
*/
|
|
1186
|
-
|
|
1539
|
+
broadcastAll(data, exclude = null) {
|
|
1187
1540
|
for (const ws of this.connections.values()) {
|
|
1188
1541
|
if (ws.connected && ws !== exclude) {
|
|
1189
1542
|
ws.send(data);
|
|
@@ -1214,12 +1567,7 @@ class WebSocketServer {
|
|
|
1214
1567
|
* 触发事件
|
|
1215
1568
|
*/
|
|
1216
1569
|
_emit(event, ...args) {
|
|
1217
|
-
|
|
1218
|
-
if (handlers) {
|
|
1219
|
-
handlers.forEach(h => {
|
|
1220
|
-
try { h(...args); } catch (e) { /* 忽略 */ }
|
|
1221
|
-
});
|
|
1222
|
-
}
|
|
1570
|
+
_emitEvent(this._handlers, event, args);
|
|
1223
1571
|
}
|
|
1224
1572
|
}
|
|
1225
1573
|
|
|
@@ -1257,6 +1605,10 @@ function bodyParser(options = {}) {
|
|
|
1257
1605
|
const boundary = _extractBoundary(contentType);
|
|
1258
1606
|
if (boundary) {
|
|
1259
1607
|
_parseMultipart(req, boundary, maxFileSize, maxFieldSize, next);
|
|
1608
|
+
// 仅 multipart 需要临时文件清理(使用 res.on 保持封装一致性)
|
|
1609
|
+
res.on('finish', () => {
|
|
1610
|
+
_cleanupTempFiles(req._tempFiles);
|
|
1611
|
+
});
|
|
1260
1612
|
} else {
|
|
1261
1613
|
next();
|
|
1262
1614
|
}
|
|
@@ -1267,11 +1619,6 @@ function bodyParser(options = {}) {
|
|
|
1267
1619
|
next();
|
|
1268
1620
|
}).catch(next);
|
|
1269
1621
|
}
|
|
1270
|
-
|
|
1271
|
-
// 监听响应完成,自动清理临时文件
|
|
1272
|
-
res._res.on('finish', () => {
|
|
1273
|
-
_cleanupTempFiles(req._tempFiles);
|
|
1274
|
-
});
|
|
1275
1622
|
};
|
|
1276
1623
|
}
|
|
1277
1624
|
|
|
@@ -1281,7 +1628,7 @@ function bodyParser(options = {}) {
|
|
|
1281
1628
|
function _parseJSON(req, maxSize, next) {
|
|
1282
1629
|
req._readBody().then(buf => {
|
|
1283
1630
|
if (buf.length > maxSize) {
|
|
1284
|
-
const err = new Error(`Body exceeds maximum size of ${fmtSize(maxSize)}
|
|
1631
|
+
const err = new Error(`Body exceeds maximum size of ${fmtSize(maxSize)}`, { cause: { actual: buf.length, maxSize } });
|
|
1285
1632
|
err.status = 413;
|
|
1286
1633
|
next(err);
|
|
1287
1634
|
return;
|
|
@@ -1305,21 +1652,12 @@ function _parseJSON(req, maxSize, next) {
|
|
|
1305
1652
|
function _parseUrlencoded(req, maxSize, next) {
|
|
1306
1653
|
req._readBody().then(buf => {
|
|
1307
1654
|
if (buf.length > maxSize) {
|
|
1308
|
-
const err = new Error(`Body exceeds maximum size of ${fmtSize(maxSize)}
|
|
1655
|
+
const err = new Error(`Body exceeds maximum size of ${fmtSize(maxSize)}`, { cause: { actual: buf.length, maxSize } });
|
|
1309
1656
|
err.status = 413;
|
|
1310
1657
|
next(err);
|
|
1311
1658
|
return;
|
|
1312
1659
|
}
|
|
1313
|
-
const
|
|
1314
|
-
const parsed = {};
|
|
1315
|
-
str.split('&').forEach(pair => {
|
|
1316
|
-
const eIdx = pair.indexOf('=');
|
|
1317
|
-
if (eIdx === -1) {
|
|
1318
|
-
parsed[decodeURIComponent(pair)] = '';
|
|
1319
|
-
} else {
|
|
1320
|
-
parsed[decodeURIComponent(pair.substring(0, eIdx))] = decodeURIComponent(pair.substring(eIdx + 1));
|
|
1321
|
-
}
|
|
1322
|
-
});
|
|
1660
|
+
const parsed = _parseQueryString(buf.toString('utf8'), true);
|
|
1323
1661
|
req.body = parsed;
|
|
1324
1662
|
Object.assign(req.formData.fields, parsed);
|
|
1325
1663
|
next();
|
|
@@ -1342,6 +1680,8 @@ function _parseMultipart(req, boundary, maxFileSize, maxFieldSize, next) {
|
|
|
1342
1680
|
const tempDir = req._app?.settings?.tempDir || 'tempupdir';
|
|
1343
1681
|
const delimiter = Buffer.from('--' + boundary);
|
|
1344
1682
|
const endDelimiter = Buffer.from('--' + boundary + '--');
|
|
1683
|
+
// 回看长度:分隔符最大可能被截断的字节数
|
|
1684
|
+
const lookBehind = delimiter.length - 1;
|
|
1345
1685
|
let state = 'INIT'; // INIT, HEADERS, BODY_FIELD, BODY_FILE
|
|
1346
1686
|
let partHeadersBuf = Buffer.alloc(0);
|
|
1347
1687
|
let currentField = { name: '', value: '' };
|
|
@@ -1350,6 +1690,7 @@ function _parseMultipart(req, boundary, maxFileSize, maxFieldSize, next) {
|
|
|
1350
1690
|
let fileSize = 0;
|
|
1351
1691
|
let buffer = Buffer.alloc(0);
|
|
1352
1692
|
let cleanupOnError = false;
|
|
1693
|
+
let paused = false;
|
|
1353
1694
|
|
|
1354
1695
|
// 确保临时目录存在
|
|
1355
1696
|
if (!fs.existsSync(tempDir)) {
|
|
@@ -1361,7 +1702,13 @@ function _parseMultipart(req, boundary, maxFileSize, maxFieldSize, next) {
|
|
|
1361
1702
|
if (state === 'INIT') {
|
|
1362
1703
|
// 查找第一个分隔符
|
|
1363
1704
|
const idx = buffer.indexOf(delimiter);
|
|
1364
|
-
if (idx === -1)
|
|
1705
|
+
if (idx === -1) {
|
|
1706
|
+
// 保留尾部回看字节,防止分隔符跨 chunk 截断
|
|
1707
|
+
if (buffer.length > lookBehind) {
|
|
1708
|
+
buffer = buffer.slice(buffer.length - lookBehind);
|
|
1709
|
+
}
|
|
1710
|
+
break;
|
|
1711
|
+
}
|
|
1365
1712
|
buffer = buffer.slice(idx + delimiter.length);
|
|
1366
1713
|
// 跳过 \r\n
|
|
1367
1714
|
if (buffer.length >= 2 && buffer[0] === 0x0D && buffer[1] === 0x0A) {
|
|
@@ -1411,23 +1758,25 @@ function _parseMultipart(req, boundary, maxFileSize, maxFieldSize, next) {
|
|
|
1411
1758
|
const idx = buffer.indexOf(delimiter);
|
|
1412
1759
|
if (idx === -1) {
|
|
1413
1760
|
// 还没结束,缓存数据(但检查大小限制)
|
|
1414
|
-
|
|
1761
|
+
// 保留尾部回看字节,防止分隔符跨 chunk 截断
|
|
1762
|
+
const safeLen = Math.max(0, buffer.length - lookBehind);
|
|
1763
|
+
const chunk = buffer.toString('utf8', 0, safeLen);
|
|
1415
1764
|
fieldSize += chunk.length;
|
|
1416
1765
|
if (fieldSize > maxFieldSize) {
|
|
1417
|
-
const err = new Error(`Field exceeds maximum size of ${fmtSize(maxFieldSize)}
|
|
1766
|
+
const err = new Error(`Field exceeds maximum size of ${fmtSize(maxFieldSize)}`, { cause: { actual: fieldSize, maxSize: maxFieldSize } });
|
|
1418
1767
|
err.status = 413;
|
|
1419
1768
|
next(err);
|
|
1420
1769
|
return;
|
|
1421
1770
|
}
|
|
1422
1771
|
currentField.value += chunk;
|
|
1423
|
-
buffer =
|
|
1772
|
+
buffer = buffer.slice(safeLen);
|
|
1424
1773
|
break;
|
|
1425
1774
|
}
|
|
1426
1775
|
// 字段结束
|
|
1427
1776
|
const chunk = buffer.toString('utf8', 0, idx);
|
|
1428
1777
|
fieldSize += chunk.length;
|
|
1429
1778
|
if (fieldSize > maxFieldSize) {
|
|
1430
|
-
const err = new Error(`Field exceeds maximum size of ${fmtSize(maxFieldSize)}
|
|
1779
|
+
const err = new Error(`Field exceeds maximum size of ${fmtSize(maxFieldSize)}`, { cause: { actual: fieldSize, maxSize: maxFieldSize } });
|
|
1431
1780
|
err.status = 413;
|
|
1432
1781
|
next(err);
|
|
1433
1782
|
return;
|
|
@@ -1450,30 +1799,41 @@ function _parseMultipart(req, boundary, maxFileSize, maxFieldSize, next) {
|
|
|
1450
1799
|
const idx = buffer.indexOf(delimiter);
|
|
1451
1800
|
if (idx === -1) {
|
|
1452
1801
|
// 还没结束,写入临时文件
|
|
1802
|
+
// 保留尾部回看字节,防止分隔符跨 chunk 截断
|
|
1803
|
+
const safeLen = Math.max(0, buffer.length - lookBehind);
|
|
1804
|
+
const writeData = buffer.slice(0, safeLen);
|
|
1453
1805
|
if (!currentFile.stream) {
|
|
1454
1806
|
currentFile.stream = fs.createWriteStream(currentFile.path);
|
|
1807
|
+
// 背压处理:写入流满时暂停请求读取,drain 后恢复
|
|
1808
|
+
currentFile.stream.on('drain', () => {
|
|
1809
|
+
if (paused) {
|
|
1810
|
+
paused = false;
|
|
1811
|
+
req._req.resume();
|
|
1812
|
+
}
|
|
1813
|
+
});
|
|
1455
1814
|
}
|
|
1456
|
-
currentFile.stream.write(
|
|
1457
|
-
|
|
1815
|
+
const canWrite = currentFile.stream.write(writeData);
|
|
1816
|
+
// 写入流缓冲区满时暂停请求读取,避免内存积压
|
|
1817
|
+
if (!canWrite) {
|
|
1818
|
+
paused = true;
|
|
1819
|
+
req._req.pause();
|
|
1820
|
+
}
|
|
1821
|
+
fileSize += writeData.length;
|
|
1458
1822
|
currentFile.size = fileSize;
|
|
1459
1823
|
if (fileSize > maxFileSize) {
|
|
1460
|
-
if (currentFile.stream) currentFile.stream.
|
|
1461
|
-
const err = new Error(`File exceeds maximum size of ${fmtSize(maxFileSize)}
|
|
1824
|
+
if (currentFile.stream) currentFile.stream.destroy();
|
|
1825
|
+
const err = new Error(`File exceeds maximum size of ${fmtSize(maxFileSize)}`, { cause: { actual: fileSize, maxSize: maxFileSize, filename: currentFile.filename } });
|
|
1462
1826
|
err.status = 413;
|
|
1463
1827
|
next(err);
|
|
1464
1828
|
return;
|
|
1465
1829
|
}
|
|
1466
|
-
|
|
1467
|
-
if (fileSize > 1024 * 1024) {
|
|
1468
|
-
_showUploadProgress(currentFile.filename, fileSize, maxFileSize);
|
|
1469
|
-
}
|
|
1470
|
-
buffer = Buffer.alloc(0);
|
|
1830
|
+
buffer = buffer.slice(safeLen);
|
|
1471
1831
|
break;
|
|
1472
1832
|
}
|
|
1473
1833
|
// 文件结束
|
|
1474
1834
|
const fileData = buffer.slice(0, idx);
|
|
1475
1835
|
// 去掉文件数据前的 \r\n
|
|
1476
|
-
const trimmedData = fileData.length >= 2 && fileData
|
|
1836
|
+
const trimmedData = fileData.length >= 2 && fileData.at(-2) === 0x0D && fileData.at(-1) === 0x0A
|
|
1477
1837
|
? fileData.slice(0, -2)
|
|
1478
1838
|
: fileData;
|
|
1479
1839
|
|
|
@@ -1481,6 +1841,8 @@ function _parseMultipart(req, boundary, maxFileSize, maxFieldSize, next) {
|
|
|
1481
1841
|
currentFile.stream = fs.createWriteStream(currentFile.path);
|
|
1482
1842
|
}
|
|
1483
1843
|
currentFile.stream.write(trimmedData);
|
|
1844
|
+
// 结束写入(注意:stream.end 为异步操作,极端情况如磁盘满时可能写入不完整,
|
|
1845
|
+
// 但 fileInfo 仍会被记录。若需严格保证写入完整性,需改为异步流程)
|
|
1484
1846
|
currentFile.stream.end();
|
|
1485
1847
|
fileSize += trimmedData.length;
|
|
1486
1848
|
currentFile.size = fileSize;
|
|
@@ -1498,6 +1860,7 @@ function _parseMultipart(req, boundary, maxFileSize, maxFieldSize, next) {
|
|
|
1498
1860
|
req.files.push(fileInfo);
|
|
1499
1861
|
req._tempFiles.push(currentFile.path);
|
|
1500
1862
|
|
|
1863
|
+
// 清理上传进度条目
|
|
1501
1864
|
cleanupOnError = false;
|
|
1502
1865
|
buffer = buffer.slice(idx + delimiter.length);
|
|
1503
1866
|
// 跳过 \r\n
|
|
@@ -1526,26 +1889,13 @@ function _parseMultipart(req, boundary, maxFileSize, maxFieldSize, next) {
|
|
|
1526
1889
|
req._req.on('error', (err) => {
|
|
1527
1890
|
// 客户端断开,清理临时文件
|
|
1528
1891
|
if (cleanupOnError && currentFile.stream) {
|
|
1529
|
-
currentFile.stream.
|
|
1892
|
+
currentFile.stream.destroy();
|
|
1530
1893
|
}
|
|
1531
1894
|
_cleanupTempFiles(req._tempFiles);
|
|
1532
1895
|
next(err);
|
|
1533
1896
|
});
|
|
1534
1897
|
}
|
|
1535
1898
|
|
|
1536
|
-
/**
|
|
1537
|
-
* 上传进度展示
|
|
1538
|
-
*/
|
|
1539
|
-
let _lastProgressLine = '';
|
|
1540
|
-
function _showUploadProgress(filename, current, total) {
|
|
1541
|
-
const pct = ((current / total) * 100).toFixed(1);
|
|
1542
|
-
const line = `[${filename}] ${fmtSize(current)}/${fmtSize(total)} ${pct}%`;
|
|
1543
|
-
if (line !== _lastProgressLine) {
|
|
1544
|
-
process.stdout.write('\r' + line);
|
|
1545
|
-
_lastProgressLine = line;
|
|
1546
|
-
}
|
|
1547
|
-
}
|
|
1548
|
-
|
|
1549
1899
|
/**
|
|
1550
1900
|
* 清理临时文件
|
|
1551
1901
|
*/
|
|
@@ -1571,15 +1921,22 @@ function cookieParser(secret) {
|
|
|
1571
1921
|
req.signedCookies = {};
|
|
1572
1922
|
for (const [key, val] of Object.entries(req.cookies)) {
|
|
1573
1923
|
if (val.startsWith('s:')) {
|
|
1574
|
-
// 签名 Cookie 格式: s:
|
|
1924
|
+
// 签名 Cookie 格式: s:encodedValue.signature
|
|
1575
1925
|
const unsigned = val.slice(2);
|
|
1576
1926
|
const dotIdx = unsigned.lastIndexOf('.');
|
|
1577
1927
|
if (dotIdx !== -1) {
|
|
1578
|
-
const
|
|
1928
|
+
const encodedValue = unsigned.slice(0, dotIdx);
|
|
1579
1929
|
const sig = unsigned.slice(dotIdx + 1);
|
|
1580
|
-
|
|
1930
|
+
// 签名是对原始值计算的,需先解码
|
|
1931
|
+
let rawValue;
|
|
1932
|
+
try {
|
|
1933
|
+
rawValue = decodeURIComponent(encodedValue);
|
|
1934
|
+
} catch (e) {
|
|
1935
|
+
continue;
|
|
1936
|
+
}
|
|
1937
|
+
const expected = crypto.createHmac('sha256', secret).update(rawValue).digest('base64').replace(/=+$/, '');
|
|
1581
1938
|
if (sig === expected) {
|
|
1582
|
-
req.signedCookies[key] =
|
|
1939
|
+
req.signedCookies[key] = rawValue;
|
|
1583
1940
|
}
|
|
1584
1941
|
}
|
|
1585
1942
|
}
|
|
@@ -1651,7 +2008,7 @@ class Application extends Router {
|
|
|
1651
2008
|
if (this.settings.http2) {
|
|
1652
2009
|
// HTTP2 模式
|
|
1653
2010
|
if (!this.settings.https || !this.settings.https.key || !this.settings.https.cert) {
|
|
1654
|
-
throw new Error('HTTP2 requires HTTPS configuration (key and cert)');
|
|
2011
|
+
throw new Error('HTTP2 requires HTTPS configuration (key and cert)', { cause: { https: !!this.settings.https, hasKey: !!(this.settings.https && this.settings.https.key), hasCert: !!(this.settings.https && this.settings.https.cert) } });
|
|
1655
2012
|
}
|
|
1656
2013
|
const opts = {
|
|
1657
2014
|
key: fs.readFileSync(this.settings.https.key),
|
|
@@ -1685,7 +2042,9 @@ class Application extends Router {
|
|
|
1685
2042
|
// 初始化 WebSocket 服务
|
|
1686
2043
|
this._wss = new WebSocketServer({
|
|
1687
2044
|
heartbeatInterval: this.settings.wsHeartbeatInterval,
|
|
1688
|
-
heartbeatTimeout: this.settings.wsHeartbeatTimeout
|
|
2045
|
+
heartbeatTimeout: this.settings.wsHeartbeatTimeout,
|
|
2046
|
+
maxPayload: this.settings.wsMaxPayload,
|
|
2047
|
+
allowedOrigins: this.settings.wsAllowedOrigins
|
|
1689
2048
|
});
|
|
1690
2049
|
|
|
1691
2050
|
// 监听 WebSocket 升级请求
|
|
@@ -1734,11 +2093,20 @@ class Application extends Router {
|
|
|
1734
2093
|
|
|
1735
2094
|
// 基础解析
|
|
1736
2095
|
const parsed = parseUrl(incomingMessage.url);
|
|
1737
|
-
|
|
2096
|
+
try {
|
|
2097
|
+
req.path = decodeURIComponent(parsed.pathname);
|
|
2098
|
+
} catch (e) {
|
|
2099
|
+
// 非法 URI 编码,返回 400
|
|
2100
|
+
res.status(400).send('Bad Request: Invalid URI encoding');
|
|
2101
|
+
return;
|
|
2102
|
+
}
|
|
1738
2103
|
req.query = parsed.query;
|
|
2104
|
+
// Cookie 由 cookieParser 中间件统一解析,此处不再重复处理
|
|
1739
2105
|
|
|
1740
|
-
//
|
|
1741
|
-
|
|
2106
|
+
// HEAD 请求标记:执行路由处理器但丢弃响应体
|
|
2107
|
+
if (req.method === 'HEAD') {
|
|
2108
|
+
res._isHead = true;
|
|
2109
|
+
}
|
|
1742
2110
|
|
|
1743
2111
|
// 构建中间件 + 路由处理器执行链
|
|
1744
2112
|
this._dispatch(req, res);
|
|
@@ -1786,10 +2154,17 @@ class Application extends Router {
|
|
|
1786
2154
|
|
|
1787
2155
|
const item = stack[idx++];
|
|
1788
2156
|
const handler = item.handler;
|
|
2157
|
+
// 防止同一 handler 多次调用 next()
|
|
2158
|
+
let handlerCalledNext = false;
|
|
2159
|
+
const safeNext = (e) => {
|
|
2160
|
+
if (handlerCalledNext && !e) return;
|
|
2161
|
+
handlerCalledNext = true;
|
|
2162
|
+
next(e);
|
|
2163
|
+
};
|
|
1789
2164
|
|
|
1790
2165
|
try {
|
|
1791
2166
|
// 路由处理器返回 false → 进入静态文件兜底
|
|
1792
|
-
const result = handler(req, res,
|
|
2167
|
+
const result = handler(req, res, safeNext);
|
|
1793
2168
|
if (result === false) {
|
|
1794
2169
|
this._serveStatic(req, res);
|
|
1795
2170
|
return;
|
|
@@ -1835,7 +2210,7 @@ class Application extends Router {
|
|
|
1835
2210
|
const status = err.status || 500;
|
|
1836
2211
|
const msg = err.message || 'Internal Server Error';
|
|
1837
2212
|
this._logger.error(`[${status}] ${req.method} ${req.path} - ${msg}`);
|
|
1838
|
-
if (!res.headersSent
|
|
2213
|
+
if (!res.headersSent) {
|
|
1839
2214
|
res.status(status).json({ error: msg, status });
|
|
1840
2215
|
}
|
|
1841
2216
|
}
|
|
@@ -1853,8 +2228,9 @@ class Application extends Router {
|
|
|
1853
2228
|
// CORS 预检响应
|
|
1854
2229
|
this._handleCORS(req, res);
|
|
1855
2230
|
} else if (method === 'POST' || method === 'PUT' || method === 'DELETE' || method === 'PATCH') {
|
|
1856
|
-
// 已知方法但无匹配路由,返回 405 Method Not Allowed
|
|
1857
|
-
|
|
2231
|
+
// 已知方法但无匹配路由,返回 405 Method Not Allowed(RFC 7231 要求必须包含 Allow 头)
|
|
2232
|
+
const allowed = this._getAllowedMethods(req.path);
|
|
2233
|
+
res.set('Allow', allowed.join(', ')).status(405).json({ error: 'Method Not Allowed', status: 405 });
|
|
1858
2234
|
} else {
|
|
1859
2235
|
// 其他未知方法返回 404
|
|
1860
2236
|
res.status(404).json({ error: 'Not Found', status: 404 });
|
|
@@ -1862,7 +2238,7 @@ class Application extends Router {
|
|
|
1862
2238
|
}
|
|
1863
2239
|
|
|
1864
2240
|
/**
|
|
1865
|
-
* CORS
|
|
2241
|
+
* CORS 预检响应(动态查询该路径支持的 HTTP 方法)
|
|
1866
2242
|
*/
|
|
1867
2243
|
_handleCORS(req, res) {
|
|
1868
2244
|
const cors = this.settings.cors;
|
|
@@ -1870,17 +2246,53 @@ class Application extends Router {
|
|
|
1870
2246
|
res.status(204)._send('');
|
|
1871
2247
|
return;
|
|
1872
2248
|
}
|
|
2249
|
+
// 动态查找该路径匹配的所有 HTTP 方法
|
|
2250
|
+
const allowedMethods = this._getAllowedMethods(req.path);
|
|
1873
2251
|
const origin = typeof cors.origin === 'string' ? cors.origin : '*';
|
|
1874
2252
|
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
1875
|
-
|
|
2253
|
+
// 动态设置允许的方法列表,而非硬编码
|
|
2254
|
+
res.setHeader('Access-Control-Allow-Methods', allowedMethods.join(', '));
|
|
1876
2255
|
res.setHeader('Access-Control-Allow-Headers', cors.headers || 'Content-Type, Authorization');
|
|
1877
|
-
res.setHeader('Access-Control-Max-Age', cors.maxAge ||
|
|
2256
|
+
res.setHeader('Access-Control-Max-Age', parseInt(cors.maxAge, 10) || 86400);
|
|
2257
|
+
// Allow 头告知客户端该路径实际支持的方法
|
|
2258
|
+
res.setHeader('Allow', allowedMethods.join(', '));
|
|
1878
2259
|
if (cors.credentials) {
|
|
1879
2260
|
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
|
1880
2261
|
}
|
|
1881
2262
|
res.status(204)._send('');
|
|
1882
2263
|
}
|
|
1883
2264
|
|
|
2265
|
+
/**
|
|
2266
|
+
* 查询指定路径支持的所有 HTTP 方法(含隐式 HEAD)
|
|
2267
|
+
* 用于 OPTIONS Allow 头和 405 Method Not Allowed 响应
|
|
2268
|
+
*/
|
|
2269
|
+
_getAllowedMethods(pathname) {
|
|
2270
|
+
const methods = new Set();
|
|
2271
|
+
// 遍历所有已注册方法的路由,查找匹配该路径的方法
|
|
2272
|
+
for (const method of ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']) {
|
|
2273
|
+
const routes = this.routes[method] || [];
|
|
2274
|
+
for (const route of routes) {
|
|
2275
|
+
if (route.pattern.exec(pathname)) {
|
|
2276
|
+
methods.add(method);
|
|
2277
|
+
break;
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
}
|
|
2281
|
+
// 检查 ALL 中间件路由
|
|
2282
|
+
const allRoutes = this.routes['ALL'] || [];
|
|
2283
|
+
for (const route of allRoutes) {
|
|
2284
|
+
if (route.pattern.exec(pathname)) {
|
|
2285
|
+
['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'].forEach(m => methods.add(m));
|
|
2286
|
+
break;
|
|
2287
|
+
}
|
|
2288
|
+
}
|
|
2289
|
+
// GET 路由隐式支持 HEAD
|
|
2290
|
+
if (methods.has('GET')) methods.add('HEAD');
|
|
2291
|
+
// OPTIONS 始终可用(CORS 预检)
|
|
2292
|
+
methods.add('OPTIONS');
|
|
2293
|
+
return [...methods].sort();
|
|
2294
|
+
}
|
|
2295
|
+
|
|
1884
2296
|
/**
|
|
1885
2297
|
* 静态文件服务
|
|
1886
2298
|
*/
|
|
@@ -1890,7 +2302,7 @@ class Application extends Router {
|
|
|
1890
2302
|
let requestPath = req.path.replace(/^\/+/, '');
|
|
1891
2303
|
|
|
1892
2304
|
// 路径安全校验
|
|
1893
|
-
if (!isPathSafe(requestPath, rootPath)) {
|
|
2305
|
+
if (!isPathSafe(requestPath, rootPath, this.settings.allowAccessToAllFiles)) {
|
|
1894
2306
|
res.status(403).send('Forbidden');
|
|
1895
2307
|
return;
|
|
1896
2308
|
}
|
|
@@ -1928,18 +2340,23 @@ class Application extends Router {
|
|
|
1928
2340
|
* 目录列表展示
|
|
1929
2341
|
*/
|
|
1930
2342
|
_serveDirectory(req, res, dirPath, requestPath) {
|
|
1931
|
-
|
|
2343
|
+
// 使用 withFileTypes 避免对每个文件做 statSync 调用
|
|
2344
|
+
fs.readdir(dirPath, { withFileTypes: true }, (err, entries) => {
|
|
1932
2345
|
if (err) {
|
|
1933
2346
|
res.status(500).send('Internal Server Error');
|
|
1934
2347
|
return;
|
|
1935
2348
|
}
|
|
1936
2349
|
|
|
1937
|
-
const items =
|
|
2350
|
+
const items = entries.map(entry => {
|
|
1938
2351
|
try {
|
|
1939
|
-
|
|
2352
|
+
// 目录只需名称和类型,文件需要额外 stat 获取大小和修改时间
|
|
2353
|
+
if (entry.isDirectory()) {
|
|
2354
|
+
return { name: entry.name, isDirectory: true, size: 0, modified: '' };
|
|
2355
|
+
}
|
|
2356
|
+
const stat = fs.statSync(path.join(dirPath, entry.name));
|
|
1940
2357
|
return {
|
|
1941
|
-
name:
|
|
1942
|
-
isDirectory:
|
|
2358
|
+
name: entry.name,
|
|
2359
|
+
isDirectory: false,
|
|
1943
2360
|
size: stat.size,
|
|
1944
2361
|
modified: stat.mtime.toISOString()
|
|
1945
2362
|
};
|
|
@@ -1966,22 +2383,28 @@ class Application extends Router {
|
|
|
1966
2383
|
* 渲染目录列表 HTML
|
|
1967
2384
|
*/
|
|
1968
2385
|
_renderDirectoryHTML(requestPath, items) {
|
|
1969
|
-
|
|
1970
|
-
|
|
2386
|
+
// requestPath 已去掉前导 /,空字符串表示根目录
|
|
2387
|
+
// path.dirname('subdir') 返回 '.',应映射为根目录 ''
|
|
2388
|
+
let parentPath = requestPath ? path.dirname(requestPath) : '/';
|
|
2389
|
+
if (parentPath === '.') parentPath = '';
|
|
2390
|
+
// 根目录时显示 '/',否则显示请求路径
|
|
2391
|
+
const displayPath = requestPath || '/';
|
|
2392
|
+
let html = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>Directory: ${escapeHtml(displayPath)}</title>`;
|
|
1971
2393
|
html += `<style>body{font-family:-apple-system,sans-serif;margin:20px;background:#f5f5f5}h1{font-size:18px;color:#333}table{width:100%;border-collapse:collapse;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.1)}th{text-align:left;padding:10px 12px;background:#f8f8f8;border-bottom:2px solid #ddd;font-size:13px;color:#666}td{padding:8px 12px;border-bottom:1px solid #eee;font-size:13px}a{color:#0066cc;text-decoration:none}a:hover{text-decoration:underline}.dir{font-weight:bold}.size{color:#999}</style>`;
|
|
1972
|
-
html += `</head><body><h1>Directory: ${
|
|
2394
|
+
html += `</head><body><h1>Directory: ${escapeHtml(displayPath)}</h1><table><tr><th>Name</th><th>Size</th><th>Modified</th></tr>`;
|
|
1973
2395
|
|
|
1974
|
-
//
|
|
1975
|
-
if (requestPath !== '/') {
|
|
1976
|
-
html += `<tr><td><a href="${parentPath}" class="dir">../</a></td><td class="size">-</td><td>-</td></tr>`;
|
|
2396
|
+
// 父目录链接(根目录时不显示)
|
|
2397
|
+
if (requestPath && requestPath !== '/') {
|
|
2398
|
+
html += `<tr><td><a href="${escapeHtml(parentPath)}" class="dir">../</a></td><td class="size">-</td><td>-</td></tr>`;
|
|
1977
2399
|
}
|
|
1978
2400
|
|
|
1979
2401
|
for (const item of items) {
|
|
1980
|
-
|
|
2402
|
+
// 使用 '/' 拼接路径,防止 Windows 上 path.join 产生反斜杠导致 href 失效
|
|
2403
|
+
const href = requestPath ? requestPath + '/' + item.name : item.name;
|
|
1981
2404
|
const name = item.isDirectory ? item.name + '/' : item.name;
|
|
1982
2405
|
const size = item.isDirectory ? '-' : fmtSize(item.size);
|
|
1983
2406
|
const cls = item.isDirectory ? 'dir' : '';
|
|
1984
|
-
html += `<tr><td><a href="${href}" class="${cls}">${name}</a></td><td class="size">${size}</td><td class="size">${item.modified}</td></tr>`;
|
|
2407
|
+
html += `<tr><td><a href="${escapeHtml(href)}" class="${cls}">${escapeHtml(name)}</a></td><td class="size">${size}</td><td class="size">${item.modified}</td></tr>`;
|
|
1985
2408
|
}
|
|
1986
2409
|
|
|
1987
2410
|
html += `</table></body></html>`;
|
|
@@ -1995,11 +2418,30 @@ class Application extends Router {
|
|
|
1995
2418
|
const ws = this._wss.handleUpgrade(req, socket, head);
|
|
1996
2419
|
if (!ws) return;
|
|
1997
2420
|
|
|
1998
|
-
// 匹配 app.ws()
|
|
2421
|
+
// 匹配 app.ws() 注册的处理器(支持动态参数路径)
|
|
2422
|
+
// _compilePath 始终返回 pattern,静态和动态路径均通过正则精确匹配
|
|
1999
2423
|
if (this._wsHandlers) {
|
|
2000
2424
|
const pathname = parseUrl(req.url).pathname;
|
|
2001
2425
|
for (const entry of this._wsHandlers) {
|
|
2002
|
-
|
|
2426
|
+
let matched = false;
|
|
2427
|
+
let params = {};
|
|
2428
|
+
const m = entry.pattern.exec(pathname);
|
|
2429
|
+
if (m) {
|
|
2430
|
+
matched = true;
|
|
2431
|
+
entry.params.forEach((name, i) => {
|
|
2432
|
+
try {
|
|
2433
|
+
params[name] = decodeURIComponent(m[i + 1]);
|
|
2434
|
+
} catch (e) {
|
|
2435
|
+
// 非法 URI 编码,保留原始值
|
|
2436
|
+
params[name] = m[i + 1];
|
|
2437
|
+
}
|
|
2438
|
+
});
|
|
2439
|
+
}
|
|
2440
|
+
if (matched) {
|
|
2441
|
+
// 将动态参数挂载到 req 上
|
|
2442
|
+
if (Object.keys(params).length > 0) {
|
|
2443
|
+
req.params = params;
|
|
2444
|
+
}
|
|
2003
2445
|
const cleanup = entry.handler(ws, req);
|
|
2004
2446
|
if (typeof cleanup === 'function') {
|
|
2005
2447
|
ws.on('close', cleanup);
|
|
@@ -2038,7 +2480,9 @@ class Application extends Router {
|
|
|
2038
2480
|
*/
|
|
2039
2481
|
ws(pathStr, handler) {
|
|
2040
2482
|
if (!this._wsHandlers) this._wsHandlers = [];
|
|
2041
|
-
|
|
2483
|
+
// 支持动态参数路径,复用 Router 的路径编译逻辑
|
|
2484
|
+
const { pattern, params } = this._compilePath(pathStr);
|
|
2485
|
+
this._wsHandlers.push({ path: pathStr, pattern, params, handler });
|
|
2042
2486
|
}
|
|
2043
2487
|
}
|
|
2044
2488
|
|
|
@@ -2077,10 +2521,12 @@ const defaultConfig = {
|
|
|
2077
2521
|
tempDir: 'tempupdir',
|
|
2078
2522
|
maxFileSize: 128 * 1024 * 1024,
|
|
2079
2523
|
maxFieldSize: 1024 * 1024,
|
|
2524
|
+
maxBodySize: 128 * 1024 * 1024,
|
|
2080
2525
|
svrPort: 80,
|
|
2081
2526
|
svrIP: null,
|
|
2082
2527
|
|
|
2083
2528
|
showDir: false,
|
|
2529
|
+
allowAccessToAllFiles: false,
|
|
2084
2530
|
enableCache: false,
|
|
2085
2531
|
enableGzip: false,
|
|
2086
2532
|
enableRange: true,
|
|
@@ -2095,7 +2541,7 @@ const defaultConfig = {
|
|
|
2095
2541
|
logLevel: 'info',
|
|
2096
2542
|
logDir: './log',
|
|
2097
2543
|
|
|
2098
|
-
cors: { origin: '*', headers: 'Content-Type, Authorization', maxAge:
|
|
2544
|
+
cors: { origin: '*', headers: 'Content-Type, Authorization', maxAge: 86400 },
|
|
2099
2545
|
|
|
2100
2546
|
useBodyParser: true,
|
|
2101
2547
|
useCookieParser: true,
|
|
@@ -2103,7 +2549,9 @@ const defaultConfig = {
|
|
|
2103
2549
|
cookieParserSecret: null,
|
|
2104
2550
|
|
|
2105
2551
|
wsHeartbeatInterval: 30000,
|
|
2106
|
-
wsHeartbeatTimeout: 30000
|
|
2552
|
+
wsHeartbeatTimeout: 30000,
|
|
2553
|
+
wsMaxPayload: 100 * 1024 * 1024,
|
|
2554
|
+
wsAllowedOrigins: null
|
|
2107
2555
|
};
|
|
2108
2556
|
|
|
2109
2557
|
// ============================================================
|
|
@@ -2135,19 +2583,29 @@ httpm.cookieParser = cookieParser;
|
|
|
2135
2583
|
* static 中间件:Express 兼容的静态文件服务
|
|
2136
2584
|
* 用法: app.use(httpm.static('public'))
|
|
2137
2585
|
*/
|
|
2138
|
-
function staticMiddleware(rootPath) {
|
|
2586
|
+
function staticMiddleware(rootPath, options = {}) {
|
|
2139
2587
|
return function staticHandler(req, res, next) {
|
|
2140
2588
|
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
2141
2589
|
return next();
|
|
2142
2590
|
}
|
|
2143
2591
|
const root = path.resolve(rootPath || process.cwd());
|
|
2592
|
+
const allowAll = options.allowAccessToAllFiles || false;
|
|
2144
2593
|
let requestPath = req.path.replace(/^\/+/, '');
|
|
2145
|
-
if (!isPathSafe(requestPath, root)) {
|
|
2594
|
+
if (!isPathSafe(requestPath, root, allowAll)) {
|
|
2146
2595
|
return next();
|
|
2147
2596
|
}
|
|
2148
2597
|
const fullPath = path.join(root, requestPath);
|
|
2149
2598
|
fs.stat(fullPath, (err, stat) => {
|
|
2150
|
-
if (err
|
|
2599
|
+
if (err) {
|
|
2600
|
+
return next();
|
|
2601
|
+
}
|
|
2602
|
+
if (stat.isDirectory()) {
|
|
2603
|
+
// 目录:尝试 index.html
|
|
2604
|
+
const indexPath = path.join(fullPath, 'index.html');
|
|
2605
|
+
if (fs.existsSync(indexPath)) {
|
|
2606
|
+
res.sendFile(path.relative(root, indexPath), { root });
|
|
2607
|
+
return;
|
|
2608
|
+
}
|
|
2151
2609
|
return next();
|
|
2152
2610
|
}
|
|
2153
2611
|
res.sendFile(requestPath, { root });
|
|
@@ -2165,23 +2623,14 @@ httpm.fmtTime = fmtTime;
|
|
|
2165
2623
|
httpm.isPathSafe = isPathSafe;
|
|
2166
2624
|
httpm.generateETag = generateETag;
|
|
2167
2625
|
httpm.parseRange = parseRange;
|
|
2168
|
-
httpm.
|
|
2626
|
+
httpm.WebSocketHandShak = WebSocketHandShak;
|
|
2627
|
+
httpm.escapeHtml = escapeHtml;
|
|
2169
2628
|
|
|
2170
2629
|
/**
|
|
2171
|
-
* parseQuery:独立导出的 Query
|
|
2630
|
+
* parseQuery:独立导出的 Query 解析函数(复用内部 _parseQueryString)
|
|
2172
2631
|
*/
|
|
2173
2632
|
function parseQuery(qs) {
|
|
2174
|
-
|
|
2175
|
-
if (!qs) return query;
|
|
2176
|
-
qs.split('&').forEach(pair => {
|
|
2177
|
-
const eIdx = pair.indexOf('=');
|
|
2178
|
-
if (eIdx === -1) {
|
|
2179
|
-
query[decodeURIComponent(pair)] = '';
|
|
2180
|
-
} else {
|
|
2181
|
-
query[decodeURIComponent(pair.substring(0, eIdx))] = decodeURIComponent(pair.substring(eIdx + 1));
|
|
2182
|
-
}
|
|
2183
|
-
});
|
|
2184
|
-
return query;
|
|
2633
|
+
return _parseQueryString(qs);
|
|
2185
2634
|
}
|
|
2186
2635
|
httpm.parseQuery = parseQuery;
|
|
2187
2636
|
|