@lzpong/httpm 1.0.3 → 1.1.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 +14 -8
- package/httpm.js +571 -240
- 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.1.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;
|
|
@@ -276,16 +308,18 @@ class Logger {
|
|
|
276
308
|
if (this._stream && this._streamDate === streamKey) {
|
|
277
309
|
return this._stream;
|
|
278
310
|
}
|
|
279
|
-
//
|
|
311
|
+
// 关闭旧流(等待写入完成后再销毁,避免跨日切换时丢失日志)
|
|
312
|
+
// this._stream 已置 null,新日志将写入新流,旧流异步刷盘不会与新流冲突
|
|
280
313
|
if (this._stream) {
|
|
281
|
-
this._stream
|
|
314
|
+
const oldStream = this._stream;
|
|
315
|
+
oldStream.end(() => {
|
|
316
|
+
oldStream.destroy();
|
|
317
|
+
});
|
|
282
318
|
this._stream = null;
|
|
283
319
|
}
|
|
284
|
-
// 创建日志目录: ./log/YYYY/MM
|
|
320
|
+
// 创建日志目录: ./log/YYYY/MM/(recursive: true 自动处理已存在的情况)
|
|
285
321
|
const dir = path.join(this.logDir, year, month);
|
|
286
|
-
|
|
287
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
288
|
-
}
|
|
322
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
289
323
|
// 文件名: name_DD.log 或 DD.log
|
|
290
324
|
const prefix = this.name ? this.name + '_' : '';
|
|
291
325
|
const filePath = path.join(dir, `${prefix}${day}.log`);
|
|
@@ -321,6 +355,7 @@ class Logger {
|
|
|
321
355
|
notice(...args) { this.log('notice', ...args); }
|
|
322
356
|
warn(...args) { this.log('warn', ...args); }
|
|
323
357
|
error(...args) { this.log('error', ...args); }
|
|
358
|
+
fatal(...args) { this.log('fatal', ...args); }
|
|
324
359
|
}
|
|
325
360
|
|
|
326
361
|
// ============================================================
|
|
@@ -363,10 +398,6 @@ class Router {
|
|
|
363
398
|
if (this.routes[key]) {
|
|
364
399
|
this.routes[key].push(route);
|
|
365
400
|
}
|
|
366
|
-
// 同时注册到 ALL
|
|
367
|
-
if (key !== 'ALL') {
|
|
368
|
-
// ALL 路由单独处理,不在此处添加
|
|
369
|
-
}
|
|
370
401
|
return this;
|
|
371
402
|
}
|
|
372
403
|
|
|
@@ -395,18 +426,30 @@ class Router {
|
|
|
395
426
|
const results = [];
|
|
396
427
|
const m = method.toUpperCase();
|
|
397
428
|
|
|
429
|
+
// HEAD 请求同时匹配 GET 路由(Express 兼容行为)
|
|
430
|
+
const methods = [m];
|
|
431
|
+
if (m === 'HEAD') methods.push('GET');
|
|
432
|
+
|
|
398
433
|
// 检查 ALL 路由
|
|
399
434
|
const allRoutes = this.routes['ALL'] || [];
|
|
400
435
|
// 检查对应方法路由
|
|
401
|
-
|
|
402
|
-
const
|
|
436
|
+
let methodRoutes = [...allRoutes];
|
|
437
|
+
for (const meth of methods) {
|
|
438
|
+
const routes = this.routes[meth] || [];
|
|
439
|
+
methodRoutes = methodRoutes.concat(routes);
|
|
440
|
+
}
|
|
403
441
|
|
|
404
|
-
for (const route of
|
|
442
|
+
for (const route of methodRoutes) {
|
|
405
443
|
const match = route.pattern.exec(pathname);
|
|
406
444
|
if (match) {
|
|
407
445
|
const params = {};
|
|
408
446
|
route.params.forEach((name, i) => {
|
|
409
|
-
|
|
447
|
+
try {
|
|
448
|
+
params[name] = decodeURIComponent(match[i + 1]);
|
|
449
|
+
} catch (e) {
|
|
450
|
+
// 非法 URI 编码,保留原始值
|
|
451
|
+
params[name] = match[i + 1];
|
|
452
|
+
}
|
|
410
453
|
});
|
|
411
454
|
results.push({ route, params, handlers: route.handlers });
|
|
412
455
|
}
|
|
@@ -434,7 +477,12 @@ class Router {
|
|
|
434
477
|
const match = mw.pattern.exec(pathname);
|
|
435
478
|
if (match) {
|
|
436
479
|
mw.params.forEach((name, i) => {
|
|
437
|
-
|
|
480
|
+
try {
|
|
481
|
+
params[name] = decodeURIComponent(match[i + 1]);
|
|
482
|
+
} catch (e) {
|
|
483
|
+
// 非法 URI 编码,保留原始值
|
|
484
|
+
params[name] = match[i + 1];
|
|
485
|
+
}
|
|
438
486
|
});
|
|
439
487
|
}
|
|
440
488
|
}
|
|
@@ -463,6 +511,8 @@ class Router {
|
|
|
463
511
|
class Request {
|
|
464
512
|
constructor(incomingMessage) {
|
|
465
513
|
this._req = incomingMessage;
|
|
514
|
+
this._app = null;
|
|
515
|
+
this._res = null;
|
|
466
516
|
this.query = {};
|
|
467
517
|
this.params = {};
|
|
468
518
|
this.body = null;
|
|
@@ -494,22 +544,47 @@ class Request {
|
|
|
494
544
|
}
|
|
495
545
|
|
|
496
546
|
/**
|
|
497
|
-
*
|
|
547
|
+
* 读取请求体原始数据(带超时保护和大小限制)
|
|
498
548
|
*/
|
|
499
|
-
_readBody() {
|
|
549
|
+
_readBody(timeoutMs = 30000, maxSize = null) {
|
|
500
550
|
return new Promise((resolve, reject) => {
|
|
501
551
|
if (this._bodyParsed) {
|
|
502
552
|
resolve(this._rawBody);
|
|
503
553
|
return;
|
|
504
554
|
}
|
|
505
555
|
const chunks = [];
|
|
506
|
-
|
|
556
|
+
let totalSize = 0;
|
|
557
|
+
const limit = maxSize || (this._app?.settings?.maxBodySize) || 128 * 1024 * 1024;
|
|
558
|
+
let timedOut = false;
|
|
559
|
+
// 超时定时器
|
|
560
|
+
const timer = setTimeout(() => {
|
|
561
|
+
timedOut = true;
|
|
562
|
+
this._req.destroy();
|
|
563
|
+
reject(new Error('Request body read timeout', { cause: { timeoutMs } }));
|
|
564
|
+
}, timeoutMs);
|
|
565
|
+
this._req.on('data', chunk => {
|
|
566
|
+
totalSize += chunk.length;
|
|
567
|
+
// 流式大小检查,超限时立即中断
|
|
568
|
+
if (totalSize > limit) {
|
|
569
|
+
clearTimeout(timer);
|
|
570
|
+
this._req.destroy();
|
|
571
|
+
const err = new Error(`Request body exceeds maximum size of ${fmtSize(limit)}`, { cause: { actual: totalSize, maxSize: limit } });
|
|
572
|
+
err.status = 413;
|
|
573
|
+
reject(err);
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
chunks.push(chunk);
|
|
577
|
+
});
|
|
507
578
|
this._req.on('end', () => {
|
|
579
|
+
clearTimeout(timer);
|
|
508
580
|
this._rawBody = Buffer.concat(chunks);
|
|
509
581
|
this._bodyParsed = true;
|
|
510
582
|
resolve(this._rawBody);
|
|
511
583
|
});
|
|
512
|
-
this._req.on('error',
|
|
584
|
+
this._req.on('error', (err) => {
|
|
585
|
+
clearTimeout(timer);
|
|
586
|
+
if (!timedOut) reject(err);
|
|
587
|
+
});
|
|
513
588
|
});
|
|
514
589
|
}
|
|
515
590
|
}
|
|
@@ -524,6 +599,7 @@ class Response {
|
|
|
524
599
|
this._app = app;
|
|
525
600
|
this.statusCode = 200;
|
|
526
601
|
this._headersSent = false;
|
|
602
|
+
this._isHead = false;
|
|
527
603
|
this._sse = null;
|
|
528
604
|
}
|
|
529
605
|
|
|
@@ -602,7 +678,12 @@ class Response {
|
|
|
602
678
|
if (this._res.finished) return;
|
|
603
679
|
this._res.statusCode = this.statusCode;
|
|
604
680
|
this._headersSent = true;
|
|
605
|
-
|
|
681
|
+
// HEAD 请求只发送头部,不发送响应体
|
|
682
|
+
if (this._isHead) {
|
|
683
|
+
this._res.end();
|
|
684
|
+
} else {
|
|
685
|
+
this._res.end(data);
|
|
686
|
+
}
|
|
606
687
|
}
|
|
607
688
|
|
|
608
689
|
/**
|
|
@@ -635,7 +716,10 @@ class Response {
|
|
|
635
716
|
const ifNoneMatch = this._req?.headers?.['if-none-match'];
|
|
636
717
|
const ifModifiedSince = this._req?.headers?.['if-modified-since'];
|
|
637
718
|
if (ifNoneMatch === etag || (ifModifiedSince && new Date(ifModifiedSince) >= stat.mtime)) {
|
|
638
|
-
this.status(304)
|
|
719
|
+
this.status(304);
|
|
720
|
+
this.removeHeader('Content-Length');
|
|
721
|
+
this._res.statusCode = 304;
|
|
722
|
+
this._res.end();
|
|
639
723
|
return;
|
|
640
724
|
}
|
|
641
725
|
}
|
|
@@ -648,7 +732,7 @@ class Response {
|
|
|
648
732
|
this.status(206);
|
|
649
733
|
this.setHeader('Content-Range', `bytes ${range.start}-${range.end}/${range.total}`);
|
|
650
734
|
this.setHeader('Content-Length', range.end - range.start + 1);
|
|
651
|
-
this._streamFile(fullPath, range.start, range.end
|
|
735
|
+
this._streamFile(fullPath, range.start, range.end);
|
|
652
736
|
return;
|
|
653
737
|
}
|
|
654
738
|
}
|
|
@@ -658,50 +742,57 @@ class Response {
|
|
|
658
742
|
// Gzip 压缩(仅文本类文件)
|
|
659
743
|
const acceptEncoding = this._req?.headers?.['accept-encoding'] || '';
|
|
660
744
|
if (this._app.settings.enableGzip && isTextMime(mime) && acceptEncoding.includes('gzip')) {
|
|
745
|
+
// HEAD 请求不传输内容
|
|
746
|
+
if (this._isHead) {
|
|
747
|
+
this._send('');
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
661
750
|
this.removeHeader('Content-Length');
|
|
662
751
|
this.setHeader('Content-Encoding', 'gzip');
|
|
663
|
-
this.
|
|
752
|
+
this.status(this.statusCode);
|
|
664
753
|
this._headersSent = true;
|
|
665
754
|
const raw = fs.createReadStream(fullPath);
|
|
666
755
|
const gzip = zlib.createGzip();
|
|
756
|
+
// 流错误处理:文件读取或压缩出错时返回 500
|
|
757
|
+
const onError = (err) => {
|
|
758
|
+
raw.destroy();
|
|
759
|
+
gzip.destroy();
|
|
760
|
+
if (!this._res.finished) {
|
|
761
|
+
this._res.statusCode = 500;
|
|
762
|
+
this._res.end('Internal Server Error');
|
|
763
|
+
}
|
|
764
|
+
};
|
|
765
|
+
raw.on('error', onError);
|
|
766
|
+
gzip.on('error', onError);
|
|
667
767
|
raw.pipe(gzip).pipe(this._res);
|
|
668
768
|
return;
|
|
669
769
|
}
|
|
670
770
|
|
|
671
|
-
this._streamFile(fullPath, 0, stat.size - 1
|
|
771
|
+
this._streamFile(fullPath, 0, stat.size - 1);
|
|
672
772
|
});
|
|
673
773
|
}
|
|
674
774
|
|
|
675
775
|
/**
|
|
676
776
|
* 流式发送文件
|
|
677
777
|
*/
|
|
678
|
-
_streamFile(fullPath, start, end
|
|
778
|
+
_streamFile(fullPath, start, end) {
|
|
679
779
|
if (this._res.finished) return;
|
|
680
780
|
this._res.statusCode = this.statusCode;
|
|
681
781
|
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
|
-
});
|
|
782
|
+
// HEAD 请求只发送头部,不传输文件内容
|
|
783
|
+
if (this._isHead) {
|
|
784
|
+
this._res.end();
|
|
785
|
+
return;
|
|
704
786
|
}
|
|
787
|
+
const stream = fs.createReadStream(fullPath, { start, end });
|
|
788
|
+
// 流错误处理:文件读取出错时返回 500
|
|
789
|
+
stream.on('error', (err) => {
|
|
790
|
+
stream.destroy();
|
|
791
|
+
if (!this._res.finished) {
|
|
792
|
+
this._res.statusCode = 500;
|
|
793
|
+
this._res.end('Internal Server Error');
|
|
794
|
+
}
|
|
795
|
+
});
|
|
705
796
|
stream.pipe(this._res);
|
|
706
797
|
}
|
|
707
798
|
|
|
@@ -715,11 +806,18 @@ class Response {
|
|
|
715
806
|
}
|
|
716
807
|
|
|
717
808
|
/**
|
|
718
|
-
*
|
|
809
|
+
* 重定向响应(兼容 Express: redirect(status, url) 或 redirect(url))
|
|
719
810
|
*/
|
|
720
|
-
redirect(
|
|
721
|
-
|
|
722
|
-
|
|
811
|
+
redirect(...args) {
|
|
812
|
+
if (typeof args[0] === 'number') {
|
|
813
|
+
// redirect(status, url)
|
|
814
|
+
this.status(args[0]);
|
|
815
|
+
this.setHeader('Location', args[1]);
|
|
816
|
+
} else {
|
|
817
|
+
// redirect(url) 默认 302
|
|
818
|
+
this.status(302);
|
|
819
|
+
this.setHeader('Location', args[0]);
|
|
820
|
+
}
|
|
723
821
|
this.setHeader('Content-Length', 0);
|
|
724
822
|
this._send('');
|
|
725
823
|
}
|
|
@@ -737,16 +835,16 @@ class Response {
|
|
|
737
835
|
* 设置响应 Cookie
|
|
738
836
|
*/
|
|
739
837
|
cookie(name, value, opts = {}) {
|
|
740
|
-
let
|
|
741
|
-
// 签名 Cookie:s:value.signature
|
|
838
|
+
let encodedValue = encodeURIComponent(value);
|
|
839
|
+
// 签名 Cookie:s:value.signature(s: 前缀不参与编码)
|
|
742
840
|
if (opts.signed) {
|
|
743
841
|
const secret = this._app && this._app.settings && this._app.settings.cookieParserSecret;
|
|
744
842
|
if (secret) {
|
|
745
843
|
const sig = crypto.createHmac('sha256', secret).update(value).digest('base64').replace(/=+$/, '');
|
|
746
|
-
|
|
844
|
+
encodedValue = 's:' + encodedValue + '.' + sig;
|
|
747
845
|
}
|
|
748
846
|
}
|
|
749
|
-
let str = `${encodeURIComponent(name)}=${
|
|
847
|
+
let str = `${encodeURIComponent(name)}=${encodedValue}`;
|
|
750
848
|
if (opts.maxAge !== undefined) str += `; Max-Age=${opts.maxAge}`;
|
|
751
849
|
if (opts.domain) str += `; Domain=${opts.domain}`;
|
|
752
850
|
if (opts.path) str += `; Path=${opts.path}`;
|
|
@@ -778,18 +876,23 @@ class SSE {
|
|
|
778
876
|
this._res = res;
|
|
779
877
|
this.connected = true;
|
|
780
878
|
|
|
781
|
-
// 设置 SSE
|
|
782
|
-
res.
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
879
|
+
// 设置 SSE 标准响应头(仅在 headers 未发送时)
|
|
880
|
+
if (!res.headersSent) {
|
|
881
|
+
res.writeHead(200, {
|
|
882
|
+
'Content-Type': 'text/event-stream',
|
|
883
|
+
'Cache-Control': 'no-cache',
|
|
884
|
+
'Connection': 'keep-alive',
|
|
885
|
+
'Access-Control-Allow-Origin': '*'
|
|
886
|
+
});
|
|
887
|
+
}
|
|
788
888
|
|
|
789
|
-
//
|
|
790
|
-
|
|
889
|
+
// 监听连接关闭(兼容 HTTP/1.1 和 HTTP/2)
|
|
890
|
+
const onClose = () => {
|
|
791
891
|
this.connected = false;
|
|
792
|
-
}
|
|
892
|
+
};
|
|
893
|
+
res.on('close', onClose);
|
|
894
|
+
// HTTP/1.1 兼容:aborted 事件在请求被客户端中断时触发
|
|
895
|
+
res.on('aborted', onClose);
|
|
793
896
|
}
|
|
794
897
|
|
|
795
898
|
/**
|
|
@@ -844,14 +947,41 @@ class SSE {
|
|
|
844
947
|
// WebSocket 类
|
|
845
948
|
// ============================================================
|
|
846
949
|
|
|
950
|
+
/**
|
|
951
|
+
* 通用事件触发函数(WebSocket 和 WebSocketServer 共用)
|
|
952
|
+
*/
|
|
953
|
+
function _emitEvent(handlers, event, args) {
|
|
954
|
+
const list = handlers[event];
|
|
955
|
+
if (list) {
|
|
956
|
+
list.forEach(h => {
|
|
957
|
+
try { h(...args); } catch (e) { console.error(`[httpm] Event handler error on "${event}":`, e); }
|
|
958
|
+
});
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
847
962
|
class WebSocket {
|
|
848
|
-
constructor(socket, pathStr) {
|
|
963
|
+
constructor(socket, pathStr, options = {}) {
|
|
849
964
|
this.socket = socket;
|
|
850
965
|
this.path = pathStr;
|
|
851
966
|
this.id = uid();
|
|
852
967
|
this.connected = true;
|
|
853
968
|
this._lastHeartbeat = Date.now();
|
|
854
969
|
this._handlers = {};
|
|
970
|
+
// 最大帧负载大小(默认 100MB,防止恶意超大帧耗尽内存)
|
|
971
|
+
this._maxPayload = options.maxPayload || 100 * 1024 * 1024;
|
|
972
|
+
// 写入背压标记
|
|
973
|
+
this._writeBacklogged = false;
|
|
974
|
+
// 帧解析状态:缓存不完整帧数据
|
|
975
|
+
this._frameBuffer = Buffer.alloc(0);
|
|
976
|
+
// 分片帧状态
|
|
977
|
+
this._fragmented = false;
|
|
978
|
+
this._fragmentOpcode = 0;
|
|
979
|
+
this._fragmentPayloads = [];
|
|
980
|
+
// 防止 close 事件重复触发
|
|
981
|
+
this._closed = false;
|
|
982
|
+
// 关闭握手状态
|
|
983
|
+
this._closing = false;
|
|
984
|
+
this._closeTimer = null;
|
|
855
985
|
|
|
856
986
|
// 监听底层 Pong 帧
|
|
857
987
|
socket.on('pong', () => {
|
|
@@ -861,28 +991,43 @@ class WebSocket {
|
|
|
861
991
|
// 监听连接关闭
|
|
862
992
|
socket.on('close', () => {
|
|
863
993
|
this.connected = false;
|
|
864
|
-
this.
|
|
994
|
+
this._emitClose();
|
|
865
995
|
});
|
|
866
996
|
|
|
867
997
|
// 监听数据帧
|
|
868
998
|
socket.on('data', (data) => {
|
|
869
|
-
this.
|
|
999
|
+
this._frameBuffer = Buffer.concat([this._frameBuffer, data]);
|
|
1000
|
+
this._parseFrames();
|
|
870
1001
|
});
|
|
871
1002
|
|
|
872
|
-
|
|
1003
|
+
// 监听底层错误,传递错误对象
|
|
1004
|
+
socket.on('error', (err) => {
|
|
873
1005
|
this.connected = false;
|
|
874
|
-
this._emit('error');
|
|
1006
|
+
this._emit('error', err);
|
|
875
1007
|
});
|
|
876
1008
|
}
|
|
877
1009
|
|
|
878
1010
|
/**
|
|
879
|
-
*
|
|
1011
|
+
* 循环解析帧缓冲区,处理多帧/半帧
|
|
880
1012
|
*/
|
|
881
|
-
|
|
882
|
-
|
|
1013
|
+
_parseFrames() {
|
|
1014
|
+
while (this._frameBuffer.length >= 2) {
|
|
1015
|
+
const result = this._decodeFrame(this._frameBuffer);
|
|
1016
|
+
if (result === null) break; // 数据不完整,等待更多数据
|
|
1017
|
+
this._frameBuffer = this._frameBuffer.slice(result.bytesConsumed);
|
|
1018
|
+
this._processFrame(result.opcode, result.payload, result.fin, result.oversize);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/**
|
|
1023
|
+
* 从缓冲区解码一个帧,返回 { opcode, payload, fin, bytesConsumed } 或 null(数据不完整)
|
|
1024
|
+
*/
|
|
1025
|
+
_decodeFrame(buf) {
|
|
1026
|
+
if (buf.length < 2) return null;
|
|
883
1027
|
|
|
884
|
-
const firstByte =
|
|
885
|
-
const secondByte =
|
|
1028
|
+
const firstByte = buf[0];
|
|
1029
|
+
const secondByte = buf[1];
|
|
1030
|
+
const fin = (firstByte & 0x80) !== 0;
|
|
886
1031
|
const opcode = firstByte & 0x0F;
|
|
887
1032
|
const isMasked = (secondByte & 0x80) !== 0;
|
|
888
1033
|
let payloadLength = secondByte & 0x7F;
|
|
@@ -890,51 +1035,131 @@ class WebSocket {
|
|
|
890
1035
|
|
|
891
1036
|
// 解析长度
|
|
892
1037
|
if (payloadLength === 126) {
|
|
893
|
-
|
|
1038
|
+
if (buf.length < 4) return null;
|
|
1039
|
+
payloadLength = buf.readUInt16BE(offset);
|
|
894
1040
|
offset += 2;
|
|
895
1041
|
} else if (payloadLength === 127) {
|
|
896
|
-
|
|
1042
|
+
if (buf.length < 10) return null;
|
|
1043
|
+
const bigLen = buf.readBigUInt64BE(offset);
|
|
1044
|
+
// 超过 Number.MAX_SAFE_INTEGER 时精度丢失,直接视为超限
|
|
1045
|
+
if (bigLen > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
1046
|
+
return { opcode: 0xFF, payload: Buffer.alloc(0), fin: true, bytesConsumed: buf.length, oversize: true };
|
|
1047
|
+
}
|
|
1048
|
+
payloadLength = Number(bigLen);
|
|
897
1049
|
offset += 8;
|
|
898
1050
|
}
|
|
899
1051
|
|
|
900
1052
|
// 解析掩码
|
|
901
1053
|
let mask = null;
|
|
902
1054
|
if (isMasked) {
|
|
903
|
-
|
|
1055
|
+
if (buf.length < offset + 4) return null;
|
|
1056
|
+
mask = buf.slice(offset, offset + 4);
|
|
904
1057
|
offset += 4;
|
|
905
1058
|
}
|
|
906
1059
|
|
|
1060
|
+
// 检查负载数据是否完整
|
|
1061
|
+
if (buf.length < offset + payloadLength) return null;
|
|
1062
|
+
|
|
1063
|
+
// 最大负载限制检查,超限时返回错误标记
|
|
1064
|
+
if (payloadLength > this._maxPayload) {
|
|
1065
|
+
return { opcode: 0xFF, payload: Buffer.alloc(0), fin: true, bytesConsumed: offset + payloadLength, oversize: true };
|
|
1066
|
+
}
|
|
1067
|
+
|
|
907
1068
|
// 提取负载
|
|
908
|
-
let payload =
|
|
1069
|
+
let payload = buf.slice(offset, offset + payloadLength);
|
|
909
1070
|
if (isMasked && mask) {
|
|
910
1071
|
for (let i = 0; i < payload.length; i++) {
|
|
911
1072
|
payload[i] ^= mask[i % 4];
|
|
912
1073
|
}
|
|
913
1074
|
}
|
|
914
1075
|
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
1076
|
+
return { opcode, payload, fin, bytesConsumed: offset + payloadLength };
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
/**
|
|
1080
|
+
* 处理解码后的帧,支持分片帧(continuation frame, opcode=0x00)
|
|
1081
|
+
*/
|
|
1082
|
+
_processFrame(opcode, payload, fin, oversize) {
|
|
1083
|
+
// 超限帧:直接关闭连接
|
|
1084
|
+
if (oversize) {
|
|
1085
|
+
this.close(1009, 'Frame payload too large');
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
1088
|
+
// 关闭握手期间忽略数据帧
|
|
1089
|
+
if (this._closing && opcode !== 0x08 && opcode !== 0x09 && opcode !== 0x0A) {
|
|
1090
|
+
return;
|
|
1091
|
+
}
|
|
1092
|
+
// 控制帧(close/ping/pong)不分片,立即处理
|
|
1093
|
+
if (opcode === 0x08) {
|
|
1094
|
+
// 解析 Close 帧状态码和原因(RFC 6455 Section 5.5.1)
|
|
1095
|
+
let code = 1005;
|
|
1096
|
+
let reason = '';
|
|
1097
|
+
if (payload.length >= 2) {
|
|
1098
|
+
code = payload.readUInt16BE(0);
|
|
1099
|
+
// RFC 6455 Section 7.4: 状态码 0-999 为非法,1005 表示无状态码
|
|
1100
|
+
if (code < 1000) code = 1005;
|
|
1101
|
+
reason = payload.length > 2 ? payload.slice(2).toString('utf8') : '';
|
|
921
1102
|
}
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
this.
|
|
925
|
-
|
|
1103
|
+
// 如果正在关闭握手中,对端已回复 Close 帧,完成握手
|
|
1104
|
+
if (this._closing) {
|
|
1105
|
+
clearTimeout(this._closeTimer);
|
|
1106
|
+
this._closeTimer = null;
|
|
1107
|
+
try { this.socket.end(); } catch (e) { /* 忽略 */ }
|
|
1108
|
+
this._emitClose(code, reason);
|
|
1109
|
+
return;
|
|
1110
|
+
}
|
|
1111
|
+
// 非关闭握手状态:回复 Close 帧后关闭
|
|
1112
|
+
this._sendCloseFrame(code);
|
|
1113
|
+
this.connected = false;
|
|
1114
|
+
this._emitClose(code, reason);
|
|
1115
|
+
return;
|
|
1116
|
+
}
|
|
1117
|
+
if (opcode === 0x09) {
|
|
1118
|
+
this._sendPong(payload);
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
if (opcode === 0x0A) {
|
|
1122
|
+
this._lastHeartbeat = Date.now();
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
// 数据帧:处理分片
|
|
1127
|
+
if (opcode === 0x00) {
|
|
1128
|
+
// 分片续帧:必须有前导帧
|
|
1129
|
+
if (!this._fragmented) return;
|
|
1130
|
+
this._fragmentPayloads.push(payload);
|
|
1131
|
+
if (fin) {
|
|
1132
|
+
// 分片结束,合并并触发事件
|
|
1133
|
+
const fullPayload = Buffer.concat(this._fragmentPayloads);
|
|
1134
|
+
this._emitData(this._fragmentOpcode, fullPayload);
|
|
1135
|
+
this._fragmented = false;
|
|
1136
|
+
this._fragmentPayloads = [];
|
|
1137
|
+
}
|
|
1138
|
+
} else {
|
|
1139
|
+
// 新消息帧(opcode=0x01/0x02)
|
|
1140
|
+
if (fin) {
|
|
1141
|
+
// 非分片:直接触发
|
|
1142
|
+
this._emitData(opcode, payload);
|
|
1143
|
+
} else {
|
|
1144
|
+
// 分片开始
|
|
1145
|
+
this._fragmented = true;
|
|
1146
|
+
this._fragmentOpcode = opcode;
|
|
1147
|
+
this._fragmentPayloads = [payload];
|
|
926
1148
|
}
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
/**
|
|
1153
|
+
* 根据操作码触发数据事件
|
|
1154
|
+
*/
|
|
1155
|
+
_emitData(opcode, payload) {
|
|
1156
|
+
if (opcode === 0x01) {
|
|
1157
|
+
const text = payload.toString('utf8');
|
|
1158
|
+
this._emit('data', { type: 'text', data: text });
|
|
1159
|
+
this._emit('text', text);
|
|
1160
|
+
} else if (opcode === 0x02) {
|
|
1161
|
+
this._emit('data', { type: 'binary', data: payload });
|
|
1162
|
+
this._emit('binary', payload);
|
|
938
1163
|
}
|
|
939
1164
|
}
|
|
940
1165
|
|
|
@@ -980,7 +1205,11 @@ class WebSocket {
|
|
|
980
1205
|
frames.push(payload);
|
|
981
1206
|
const frame = Buffer.concat(frames);
|
|
982
1207
|
try {
|
|
983
|
-
this.socket.write(frame);
|
|
1208
|
+
const canWrite = this.socket.write(frame);
|
|
1209
|
+
// 写入缓冲区满时记录警告(WebSocket 不像 HTTP 可暂停请求流,只能记录)
|
|
1210
|
+
if (!canWrite) {
|
|
1211
|
+
this._writeBacklogged = true;
|
|
1212
|
+
}
|
|
984
1213
|
} catch (e) {
|
|
985
1214
|
this.connected = false;
|
|
986
1215
|
}
|
|
@@ -1003,22 +1232,57 @@ class WebSocket {
|
|
|
1003
1232
|
/**
|
|
1004
1233
|
* 发送关闭帧
|
|
1005
1234
|
*/
|
|
1006
|
-
_sendCloseFrame() {
|
|
1235
|
+
_sendCloseFrame(code, reason) {
|
|
1007
1236
|
try {
|
|
1008
|
-
|
|
1237
|
+
let payload = Buffer.alloc(0);
|
|
1238
|
+
// RFC 6455: Close 帧可携带状态码(2字节)+ 可选原因(UTF-8)
|
|
1239
|
+
if (code !== undefined && code !== null) {
|
|
1240
|
+
const reasonBuf = reason ? Buffer.from(reason, 'utf8') : Buffer.alloc(0);
|
|
1241
|
+
payload = Buffer.alloc(2 + reasonBuf.length);
|
|
1242
|
+
payload.writeUInt16BE(code, 0);
|
|
1243
|
+
reasonBuf.copy(payload, 2);
|
|
1244
|
+
}
|
|
1245
|
+
this._sendFrame(0x08, payload);
|
|
1009
1246
|
} catch (e) {
|
|
1010
1247
|
// 忽略关闭帧发送错误
|
|
1011
1248
|
}
|
|
1012
1249
|
}
|
|
1013
1250
|
|
|
1014
1251
|
/**
|
|
1015
|
-
*
|
|
1252
|
+
* 关闭连接(限时等待对端 Close 帧完成握手)
|
|
1253
|
+
* @param {number} [code] 关闭状态码(RFC 6455 Section 7.4)
|
|
1254
|
+
* @param {string} [reason] 关闭原因
|
|
1016
1255
|
*/
|
|
1017
|
-
close() {
|
|
1018
|
-
this.
|
|
1256
|
+
close(code, reason) {
|
|
1257
|
+
if (this._closed) return;
|
|
1258
|
+
// 重要:必须先发送 Close 帧再设 connected=false
|
|
1259
|
+
// _sendFrame 内部检查 this.connected,若先断开则 Close 帧无法发出
|
|
1260
|
+
this._sendCloseFrame(code, reason);
|
|
1261
|
+
// 标记为关闭中,拒绝后续数据帧发送和 _sendFrame 写入
|
|
1019
1262
|
this.connected = false;
|
|
1020
|
-
|
|
1021
|
-
|
|
1263
|
+
this._closing = true;
|
|
1264
|
+
// 限时等待对端 Close 帧(2秒超时)
|
|
1265
|
+
this._closeTimer = setTimeout(() => {
|
|
1266
|
+
this._closeTimer = null;
|
|
1267
|
+
// 先标记已关闭,防止 socket.destroy 触发 close 事件时重复 _emitClose
|
|
1268
|
+
this._closed = true;
|
|
1269
|
+
try { this.socket.destroy(); } catch (e) { /* 忽略 */ }
|
|
1270
|
+
this._emitClose(code, reason);
|
|
1271
|
+
}, 2000);
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
/**
|
|
1275
|
+
* 安全触发 close 事件(防止重复触发)
|
|
1276
|
+
*/
|
|
1277
|
+
_emitClose(code, reason) {
|
|
1278
|
+
if (this._closed) return;
|
|
1279
|
+
this._closed = true;
|
|
1280
|
+
// 清理关闭握手定时器
|
|
1281
|
+
if (this._closeTimer) {
|
|
1282
|
+
clearTimeout(this._closeTimer);
|
|
1283
|
+
this._closeTimer = null;
|
|
1284
|
+
}
|
|
1285
|
+
this._emit('close', code, reason);
|
|
1022
1286
|
}
|
|
1023
1287
|
|
|
1024
1288
|
/**
|
|
@@ -1034,12 +1298,7 @@ class WebSocket {
|
|
|
1034
1298
|
* 触发事件
|
|
1035
1299
|
*/
|
|
1036
1300
|
_emit(event, ...args) {
|
|
1037
|
-
|
|
1038
|
-
if (handlers) {
|
|
1039
|
-
handlers.forEach(h => {
|
|
1040
|
-
try { h(...args); } catch (e) { /* 忽略 */ }
|
|
1041
|
-
});
|
|
1042
|
-
}
|
|
1301
|
+
_emitEvent(this._handlers, event, args);
|
|
1043
1302
|
}
|
|
1044
1303
|
}
|
|
1045
1304
|
|
|
@@ -1062,6 +1321,8 @@ class WebSocketServer {
|
|
|
1062
1321
|
this.groups = new Map(); // path -> Set<WebSocket>
|
|
1063
1322
|
this._heartbeatInterval = options.heartbeatInterval || 30000;
|
|
1064
1323
|
this._heartbeatTimeout = options.heartbeatTimeout || 30000;
|
|
1324
|
+
this._maxPayload = options.maxPayload || 100 * 1024 * 1024;
|
|
1325
|
+
this._allowedOrigins = options.allowedOrigins || null;
|
|
1065
1326
|
this._timer = null;
|
|
1066
1327
|
this._handlers = {};
|
|
1067
1328
|
}
|
|
@@ -1079,9 +1340,17 @@ class WebSocketServer {
|
|
|
1079
1340
|
return null;
|
|
1080
1341
|
}
|
|
1081
1342
|
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
.
|
|
1343
|
+
// Origin 校验(防止跨站 WebSocket 劫持 CSWSH)
|
|
1344
|
+
if (this._allowedOrigins) {
|
|
1345
|
+
const origin = req.headers['origin'];
|
|
1346
|
+
if (!origin || !this._allowedOrigins.includes(origin)) {
|
|
1347
|
+
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
|
|
1348
|
+
socket.destroy();
|
|
1349
|
+
return null;
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
const accept = WebSocketHandShark(key);
|
|
1085
1354
|
|
|
1086
1355
|
// 发送握手响应
|
|
1087
1356
|
const responseHeaders = [
|
|
@@ -1093,7 +1362,7 @@ class WebSocketServer {
|
|
|
1093
1362
|
socket.write(responseHeaders.join('\r\n') + '\r\n\r\n');
|
|
1094
1363
|
|
|
1095
1364
|
// 创建 WebSocket 实例
|
|
1096
|
-
const ws = new WebSocket(socket, pathname);
|
|
1365
|
+
const ws = new WebSocket(socket, pathname, { maxPayload: this._maxPayload });
|
|
1097
1366
|
this.connections.set(ws.id, ws);
|
|
1098
1367
|
|
|
1099
1368
|
// 按路径分组
|
|
@@ -1141,19 +1410,24 @@ class WebSocketServer {
|
|
|
1141
1410
|
if (this._timer) return;
|
|
1142
1411
|
this._timer = setInterval(() => {
|
|
1143
1412
|
const now = Date.now();
|
|
1413
|
+
// 先收集需移除的连接,遍历结束后统一移除,避免迭代中修改 Map
|
|
1414
|
+
const toRemove = [];
|
|
1144
1415
|
for (const ws of this.connections.values()) {
|
|
1145
1416
|
if (!ws.connected) {
|
|
1146
|
-
|
|
1417
|
+
toRemove.push(ws);
|
|
1147
1418
|
continue;
|
|
1148
1419
|
}
|
|
1149
1420
|
// 检查心跳超时
|
|
1150
1421
|
if (now - ws._lastHeartbeat > this._heartbeatInterval + this._heartbeatTimeout) {
|
|
1151
1422
|
ws.close();
|
|
1152
|
-
|
|
1423
|
+
toRemove.push(ws);
|
|
1153
1424
|
continue;
|
|
1154
1425
|
}
|
|
1155
1426
|
ws._sendPing();
|
|
1156
1427
|
}
|
|
1428
|
+
for (const ws of toRemove) {
|
|
1429
|
+
this._removeConnection(ws);
|
|
1430
|
+
}
|
|
1157
1431
|
}, this._heartbeatInterval);
|
|
1158
1432
|
}
|
|
1159
1433
|
|
|
@@ -1214,12 +1488,7 @@ class WebSocketServer {
|
|
|
1214
1488
|
* 触发事件
|
|
1215
1489
|
*/
|
|
1216
1490
|
_emit(event, ...args) {
|
|
1217
|
-
|
|
1218
|
-
if (handlers) {
|
|
1219
|
-
handlers.forEach(h => {
|
|
1220
|
-
try { h(...args); } catch (e) { /* 忽略 */ }
|
|
1221
|
-
});
|
|
1222
|
-
}
|
|
1491
|
+
_emitEvent(this._handlers, event, args);
|
|
1223
1492
|
}
|
|
1224
1493
|
}
|
|
1225
1494
|
|
|
@@ -1257,6 +1526,10 @@ function bodyParser(options = {}) {
|
|
|
1257
1526
|
const boundary = _extractBoundary(contentType);
|
|
1258
1527
|
if (boundary) {
|
|
1259
1528
|
_parseMultipart(req, boundary, maxFileSize, maxFieldSize, next);
|
|
1529
|
+
// 仅 multipart 需要临时文件清理
|
|
1530
|
+
res._res.on('finish', () => {
|
|
1531
|
+
_cleanupTempFiles(req._tempFiles);
|
|
1532
|
+
});
|
|
1260
1533
|
} else {
|
|
1261
1534
|
next();
|
|
1262
1535
|
}
|
|
@@ -1267,11 +1540,6 @@ function bodyParser(options = {}) {
|
|
|
1267
1540
|
next();
|
|
1268
1541
|
}).catch(next);
|
|
1269
1542
|
}
|
|
1270
|
-
|
|
1271
|
-
// 监听响应完成,自动清理临时文件
|
|
1272
|
-
res._res.on('finish', () => {
|
|
1273
|
-
_cleanupTempFiles(req._tempFiles);
|
|
1274
|
-
});
|
|
1275
1543
|
};
|
|
1276
1544
|
}
|
|
1277
1545
|
|
|
@@ -1281,7 +1549,7 @@ function bodyParser(options = {}) {
|
|
|
1281
1549
|
function _parseJSON(req, maxSize, next) {
|
|
1282
1550
|
req._readBody().then(buf => {
|
|
1283
1551
|
if (buf.length > maxSize) {
|
|
1284
|
-
const err = new Error(`Body exceeds maximum size of ${fmtSize(maxSize)}
|
|
1552
|
+
const err = new Error(`Body exceeds maximum size of ${fmtSize(maxSize)}`, { cause: { actual: buf.length, maxSize } });
|
|
1285
1553
|
err.status = 413;
|
|
1286
1554
|
next(err);
|
|
1287
1555
|
return;
|
|
@@ -1305,21 +1573,12 @@ function _parseJSON(req, maxSize, next) {
|
|
|
1305
1573
|
function _parseUrlencoded(req, maxSize, next) {
|
|
1306
1574
|
req._readBody().then(buf => {
|
|
1307
1575
|
if (buf.length > maxSize) {
|
|
1308
|
-
const err = new Error(`Body exceeds maximum size of ${fmtSize(maxSize)}
|
|
1576
|
+
const err = new Error(`Body exceeds maximum size of ${fmtSize(maxSize)}`, { cause: { actual: buf.length, maxSize } });
|
|
1309
1577
|
err.status = 413;
|
|
1310
1578
|
next(err);
|
|
1311
1579
|
return;
|
|
1312
1580
|
}
|
|
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
|
-
});
|
|
1581
|
+
const parsed = _parseQueryString(buf.toString('utf8'), true);
|
|
1323
1582
|
req.body = parsed;
|
|
1324
1583
|
Object.assign(req.formData.fields, parsed);
|
|
1325
1584
|
next();
|
|
@@ -1342,6 +1601,8 @@ function _parseMultipart(req, boundary, maxFileSize, maxFieldSize, next) {
|
|
|
1342
1601
|
const tempDir = req._app?.settings?.tempDir || 'tempupdir';
|
|
1343
1602
|
const delimiter = Buffer.from('--' + boundary);
|
|
1344
1603
|
const endDelimiter = Buffer.from('--' + boundary + '--');
|
|
1604
|
+
// 回看长度:分隔符最大可能被截断的字节数
|
|
1605
|
+
const lookBehind = delimiter.length - 1;
|
|
1345
1606
|
let state = 'INIT'; // INIT, HEADERS, BODY_FIELD, BODY_FILE
|
|
1346
1607
|
let partHeadersBuf = Buffer.alloc(0);
|
|
1347
1608
|
let currentField = { name: '', value: '' };
|
|
@@ -1350,6 +1611,7 @@ function _parseMultipart(req, boundary, maxFileSize, maxFieldSize, next) {
|
|
|
1350
1611
|
let fileSize = 0;
|
|
1351
1612
|
let buffer = Buffer.alloc(0);
|
|
1352
1613
|
let cleanupOnError = false;
|
|
1614
|
+
let paused = false;
|
|
1353
1615
|
|
|
1354
1616
|
// 确保临时目录存在
|
|
1355
1617
|
if (!fs.existsSync(tempDir)) {
|
|
@@ -1361,7 +1623,13 @@ function _parseMultipart(req, boundary, maxFileSize, maxFieldSize, next) {
|
|
|
1361
1623
|
if (state === 'INIT') {
|
|
1362
1624
|
// 查找第一个分隔符
|
|
1363
1625
|
const idx = buffer.indexOf(delimiter);
|
|
1364
|
-
if (idx === -1)
|
|
1626
|
+
if (idx === -1) {
|
|
1627
|
+
// 保留尾部回看字节,防止分隔符跨 chunk 截断
|
|
1628
|
+
if (buffer.length > lookBehind) {
|
|
1629
|
+
buffer = buffer.slice(buffer.length - lookBehind);
|
|
1630
|
+
}
|
|
1631
|
+
break;
|
|
1632
|
+
}
|
|
1365
1633
|
buffer = buffer.slice(idx + delimiter.length);
|
|
1366
1634
|
// 跳过 \r\n
|
|
1367
1635
|
if (buffer.length >= 2 && buffer[0] === 0x0D && buffer[1] === 0x0A) {
|
|
@@ -1411,23 +1679,25 @@ function _parseMultipart(req, boundary, maxFileSize, maxFieldSize, next) {
|
|
|
1411
1679
|
const idx = buffer.indexOf(delimiter);
|
|
1412
1680
|
if (idx === -1) {
|
|
1413
1681
|
// 还没结束,缓存数据(但检查大小限制)
|
|
1414
|
-
|
|
1682
|
+
// 保留尾部回看字节,防止分隔符跨 chunk 截断
|
|
1683
|
+
const safeLen = Math.max(0, buffer.length - lookBehind);
|
|
1684
|
+
const chunk = buffer.toString('utf8', 0, safeLen);
|
|
1415
1685
|
fieldSize += chunk.length;
|
|
1416
1686
|
if (fieldSize > maxFieldSize) {
|
|
1417
|
-
const err = new Error(`Field exceeds maximum size of ${fmtSize(maxFieldSize)}
|
|
1687
|
+
const err = new Error(`Field exceeds maximum size of ${fmtSize(maxFieldSize)}`, { cause: { actual: fieldSize, maxSize: maxFieldSize } });
|
|
1418
1688
|
err.status = 413;
|
|
1419
1689
|
next(err);
|
|
1420
1690
|
return;
|
|
1421
1691
|
}
|
|
1422
1692
|
currentField.value += chunk;
|
|
1423
|
-
buffer =
|
|
1693
|
+
buffer = buffer.slice(safeLen);
|
|
1424
1694
|
break;
|
|
1425
1695
|
}
|
|
1426
1696
|
// 字段结束
|
|
1427
1697
|
const chunk = buffer.toString('utf8', 0, idx);
|
|
1428
1698
|
fieldSize += chunk.length;
|
|
1429
1699
|
if (fieldSize > maxFieldSize) {
|
|
1430
|
-
const err = new Error(`Field exceeds maximum size of ${fmtSize(maxFieldSize)}
|
|
1700
|
+
const err = new Error(`Field exceeds maximum size of ${fmtSize(maxFieldSize)}`, { cause: { actual: fieldSize, maxSize: maxFieldSize } });
|
|
1431
1701
|
err.status = 413;
|
|
1432
1702
|
next(err);
|
|
1433
1703
|
return;
|
|
@@ -1450,30 +1720,41 @@ function _parseMultipart(req, boundary, maxFileSize, maxFieldSize, next) {
|
|
|
1450
1720
|
const idx = buffer.indexOf(delimiter);
|
|
1451
1721
|
if (idx === -1) {
|
|
1452
1722
|
// 还没结束,写入临时文件
|
|
1723
|
+
// 保留尾部回看字节,防止分隔符跨 chunk 截断
|
|
1724
|
+
const safeLen = Math.max(0, buffer.length - lookBehind);
|
|
1725
|
+
const writeData = buffer.slice(0, safeLen);
|
|
1453
1726
|
if (!currentFile.stream) {
|
|
1454
1727
|
currentFile.stream = fs.createWriteStream(currentFile.path);
|
|
1728
|
+
// 背压处理:写入流满时暂停请求读取,drain 后恢复
|
|
1729
|
+
currentFile.stream.on('drain', () => {
|
|
1730
|
+
if (paused) {
|
|
1731
|
+
paused = false;
|
|
1732
|
+
req._req.resume();
|
|
1733
|
+
}
|
|
1734
|
+
});
|
|
1735
|
+
}
|
|
1736
|
+
const canWrite = currentFile.stream.write(writeData);
|
|
1737
|
+
// 写入流缓冲区满时暂停请求读取,避免内存积压
|
|
1738
|
+
if (!canWrite) {
|
|
1739
|
+
paused = true;
|
|
1740
|
+
req._req.pause();
|
|
1455
1741
|
}
|
|
1456
|
-
|
|
1457
|
-
fileSize += buffer.length;
|
|
1742
|
+
fileSize += writeData.length;
|
|
1458
1743
|
currentFile.size = fileSize;
|
|
1459
1744
|
if (fileSize > maxFileSize) {
|
|
1460
|
-
if (currentFile.stream) currentFile.stream.
|
|
1461
|
-
const err = new Error(`File exceeds maximum size of ${fmtSize(maxFileSize)}
|
|
1745
|
+
if (currentFile.stream) currentFile.stream.destroy();
|
|
1746
|
+
const err = new Error(`File exceeds maximum size of ${fmtSize(maxFileSize)}`, { cause: { actual: fileSize, maxSize: maxFileSize, filename: currentFile.filename } });
|
|
1462
1747
|
err.status = 413;
|
|
1463
1748
|
next(err);
|
|
1464
1749
|
return;
|
|
1465
1750
|
}
|
|
1466
|
-
|
|
1467
|
-
if (fileSize > 1024 * 1024) {
|
|
1468
|
-
_showUploadProgress(currentFile.filename, fileSize, maxFileSize);
|
|
1469
|
-
}
|
|
1470
|
-
buffer = Buffer.alloc(0);
|
|
1751
|
+
buffer = buffer.slice(safeLen);
|
|
1471
1752
|
break;
|
|
1472
1753
|
}
|
|
1473
1754
|
// 文件结束
|
|
1474
1755
|
const fileData = buffer.slice(0, idx);
|
|
1475
1756
|
// 去掉文件数据前的 \r\n
|
|
1476
|
-
const trimmedData = fileData.length >= 2 && fileData
|
|
1757
|
+
const trimmedData = fileData.length >= 2 && fileData.at(-2) === 0x0D && fileData.at(-1) === 0x0A
|
|
1477
1758
|
? fileData.slice(0, -2)
|
|
1478
1759
|
: fileData;
|
|
1479
1760
|
|
|
@@ -1481,6 +1762,7 @@ function _parseMultipart(req, boundary, maxFileSize, maxFieldSize, next) {
|
|
|
1481
1762
|
currentFile.stream = fs.createWriteStream(currentFile.path);
|
|
1482
1763
|
}
|
|
1483
1764
|
currentFile.stream.write(trimmedData);
|
|
1765
|
+
// 结束写入并等待刷盘完成
|
|
1484
1766
|
currentFile.stream.end();
|
|
1485
1767
|
fileSize += trimmedData.length;
|
|
1486
1768
|
currentFile.size = fileSize;
|
|
@@ -1498,6 +1780,7 @@ function _parseMultipart(req, boundary, maxFileSize, maxFieldSize, next) {
|
|
|
1498
1780
|
req.files.push(fileInfo);
|
|
1499
1781
|
req._tempFiles.push(currentFile.path);
|
|
1500
1782
|
|
|
1783
|
+
// 清理上传进度条目
|
|
1501
1784
|
cleanupOnError = false;
|
|
1502
1785
|
buffer = buffer.slice(idx + delimiter.length);
|
|
1503
1786
|
// 跳过 \r\n
|
|
@@ -1526,26 +1809,13 @@ function _parseMultipart(req, boundary, maxFileSize, maxFieldSize, next) {
|
|
|
1526
1809
|
req._req.on('error', (err) => {
|
|
1527
1810
|
// 客户端断开,清理临时文件
|
|
1528
1811
|
if (cleanupOnError && currentFile.stream) {
|
|
1529
|
-
currentFile.stream.
|
|
1812
|
+
currentFile.stream.destroy();
|
|
1530
1813
|
}
|
|
1531
1814
|
_cleanupTempFiles(req._tempFiles);
|
|
1532
1815
|
next(err);
|
|
1533
1816
|
});
|
|
1534
1817
|
}
|
|
1535
1818
|
|
|
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
1819
|
/**
|
|
1550
1820
|
* 清理临时文件
|
|
1551
1821
|
*/
|
|
@@ -1571,15 +1841,22 @@ function cookieParser(secret) {
|
|
|
1571
1841
|
req.signedCookies = {};
|
|
1572
1842
|
for (const [key, val] of Object.entries(req.cookies)) {
|
|
1573
1843
|
if (val.startsWith('s:')) {
|
|
1574
|
-
// 签名 Cookie 格式: s:
|
|
1844
|
+
// 签名 Cookie 格式: s:encodedValue.signature
|
|
1575
1845
|
const unsigned = val.slice(2);
|
|
1576
1846
|
const dotIdx = unsigned.lastIndexOf('.');
|
|
1577
1847
|
if (dotIdx !== -1) {
|
|
1578
|
-
const
|
|
1848
|
+
const encodedValue = unsigned.slice(0, dotIdx);
|
|
1579
1849
|
const sig = unsigned.slice(dotIdx + 1);
|
|
1580
|
-
|
|
1850
|
+
// 签名是对原始值计算的,需先解码
|
|
1851
|
+
let rawValue;
|
|
1852
|
+
try {
|
|
1853
|
+
rawValue = decodeURIComponent(encodedValue);
|
|
1854
|
+
} catch (e) {
|
|
1855
|
+
continue;
|
|
1856
|
+
}
|
|
1857
|
+
const expected = crypto.createHmac('sha256', secret).update(rawValue).digest('base64').replace(/=+$/, '');
|
|
1581
1858
|
if (sig === expected) {
|
|
1582
|
-
req.signedCookies[key] =
|
|
1859
|
+
req.signedCookies[key] = rawValue;
|
|
1583
1860
|
}
|
|
1584
1861
|
}
|
|
1585
1862
|
}
|
|
@@ -1651,7 +1928,7 @@ class Application extends Router {
|
|
|
1651
1928
|
if (this.settings.http2) {
|
|
1652
1929
|
// HTTP2 模式
|
|
1653
1930
|
if (!this.settings.https || !this.settings.https.key || !this.settings.https.cert) {
|
|
1654
|
-
throw new Error('HTTP2 requires HTTPS configuration (key and cert)');
|
|
1931
|
+
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
1932
|
}
|
|
1656
1933
|
const opts = {
|
|
1657
1934
|
key: fs.readFileSync(this.settings.https.key),
|
|
@@ -1685,7 +1962,9 @@ class Application extends Router {
|
|
|
1685
1962
|
// 初始化 WebSocket 服务
|
|
1686
1963
|
this._wss = new WebSocketServer({
|
|
1687
1964
|
heartbeatInterval: this.settings.wsHeartbeatInterval,
|
|
1688
|
-
heartbeatTimeout: this.settings.wsHeartbeatTimeout
|
|
1965
|
+
heartbeatTimeout: this.settings.wsHeartbeatTimeout,
|
|
1966
|
+
maxPayload: this.settings.wsMaxPayload,
|
|
1967
|
+
allowedOrigins: this.settings.wsAllowedOrigins
|
|
1689
1968
|
});
|
|
1690
1969
|
|
|
1691
1970
|
// 监听 WebSocket 升级请求
|
|
@@ -1734,11 +2013,20 @@ class Application extends Router {
|
|
|
1734
2013
|
|
|
1735
2014
|
// 基础解析
|
|
1736
2015
|
const parsed = parseUrl(incomingMessage.url);
|
|
1737
|
-
|
|
2016
|
+
try {
|
|
2017
|
+
req.path = decodeURIComponent(parsed.pathname);
|
|
2018
|
+
} catch (e) {
|
|
2019
|
+
// 非法 URI 编码,返回 400
|
|
2020
|
+
res.status(400).send('Bad Request: Invalid URI encoding');
|
|
2021
|
+
return;
|
|
2022
|
+
}
|
|
1738
2023
|
req.query = parsed.query;
|
|
2024
|
+
// Cookie 由 cookieParser 中间件统一解析,此处不再重复处理
|
|
1739
2025
|
|
|
1740
|
-
//
|
|
1741
|
-
|
|
2026
|
+
// HEAD 请求标记:执行路由处理器但丢弃响应体
|
|
2027
|
+
if (req.method === 'HEAD') {
|
|
2028
|
+
res._isHead = true;
|
|
2029
|
+
}
|
|
1742
2030
|
|
|
1743
2031
|
// 构建中间件 + 路由处理器执行链
|
|
1744
2032
|
this._dispatch(req, res);
|
|
@@ -1786,10 +2074,17 @@ class Application extends Router {
|
|
|
1786
2074
|
|
|
1787
2075
|
const item = stack[idx++];
|
|
1788
2076
|
const handler = item.handler;
|
|
2077
|
+
// 防止同一 handler 多次调用 next()
|
|
2078
|
+
let handlerCalledNext = false;
|
|
2079
|
+
const safeNext = (e) => {
|
|
2080
|
+
if (handlerCalledNext && !e) return;
|
|
2081
|
+
handlerCalledNext = true;
|
|
2082
|
+
next(e);
|
|
2083
|
+
};
|
|
1789
2084
|
|
|
1790
2085
|
try {
|
|
1791
2086
|
// 路由处理器返回 false → 进入静态文件兜底
|
|
1792
|
-
const result = handler(req, res,
|
|
2087
|
+
const result = handler(req, res, safeNext);
|
|
1793
2088
|
if (result === false) {
|
|
1794
2089
|
this._serveStatic(req, res);
|
|
1795
2090
|
return;
|
|
@@ -1874,7 +2169,7 @@ class Application extends Router {
|
|
|
1874
2169
|
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
1875
2170
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, OPTIONS');
|
|
1876
2171
|
res.setHeader('Access-Control-Allow-Headers', cors.headers || 'Content-Type, Authorization');
|
|
1877
|
-
res.setHeader('Access-Control-Max-Age', cors.maxAge ||
|
|
2172
|
+
res.setHeader('Access-Control-Max-Age', parseInt(cors.maxAge, 10) || 86400);
|
|
1878
2173
|
if (cors.credentials) {
|
|
1879
2174
|
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
|
1880
2175
|
}
|
|
@@ -1890,7 +2185,7 @@ class Application extends Router {
|
|
|
1890
2185
|
let requestPath = req.path.replace(/^\/+/, '');
|
|
1891
2186
|
|
|
1892
2187
|
// 路径安全校验
|
|
1893
|
-
if (!isPathSafe(requestPath, rootPath)) {
|
|
2188
|
+
if (!isPathSafe(requestPath, rootPath, this.settings.allowAccessToAllFiles)) {
|
|
1894
2189
|
res.status(403).send('Forbidden');
|
|
1895
2190
|
return;
|
|
1896
2191
|
}
|
|
@@ -1928,18 +2223,23 @@ class Application extends Router {
|
|
|
1928
2223
|
* 目录列表展示
|
|
1929
2224
|
*/
|
|
1930
2225
|
_serveDirectory(req, res, dirPath, requestPath) {
|
|
1931
|
-
|
|
2226
|
+
// 使用 withFileTypes 避免对每个文件做 statSync 调用
|
|
2227
|
+
fs.readdir(dirPath, { withFileTypes: true }, (err, entries) => {
|
|
1932
2228
|
if (err) {
|
|
1933
2229
|
res.status(500).send('Internal Server Error');
|
|
1934
2230
|
return;
|
|
1935
2231
|
}
|
|
1936
2232
|
|
|
1937
|
-
const items =
|
|
2233
|
+
const items = entries.map(entry => {
|
|
1938
2234
|
try {
|
|
1939
|
-
|
|
2235
|
+
// 目录只需名称和类型,文件需要额外 stat 获取大小和修改时间
|
|
2236
|
+
if (entry.isDirectory()) {
|
|
2237
|
+
return { name: entry.name, isDirectory: true, size: 0, modified: '' };
|
|
2238
|
+
}
|
|
2239
|
+
const stat = fs.statSync(path.join(dirPath, entry.name));
|
|
1940
2240
|
return {
|
|
1941
|
-
name:
|
|
1942
|
-
isDirectory:
|
|
2241
|
+
name: entry.name,
|
|
2242
|
+
isDirectory: false,
|
|
1943
2243
|
size: stat.size,
|
|
1944
2244
|
modified: stat.mtime.toISOString()
|
|
1945
2245
|
};
|
|
@@ -1966,14 +2266,15 @@ class Application extends Router {
|
|
|
1966
2266
|
* 渲染目录列表 HTML
|
|
1967
2267
|
*/
|
|
1968
2268
|
_renderDirectoryHTML(requestPath, items) {
|
|
1969
|
-
|
|
1970
|
-
|
|
2269
|
+
// requestPath 已去掉前导 /,空字符串表示根目录
|
|
2270
|
+
const parentPath = requestPath ? path.dirname(requestPath) : '/';
|
|
2271
|
+
let html = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>Directory: ${escapeHtml(requestPath)}</title>`;
|
|
1971
2272
|
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: ${requestPath}</h1><table><tr><th>Name</th><th>Size</th><th>Modified</th></tr>`;
|
|
2273
|
+
html += `</head><body><h1>Directory: ${escapeHtml(requestPath)}</h1><table><tr><th>Name</th><th>Size</th><th>Modified</th></tr>`;
|
|
1973
2274
|
|
|
1974
|
-
//
|
|
1975
|
-
if (requestPath !== '/') {
|
|
1976
|
-
html += `<tr><td><a href="${parentPath}" class="dir">../</a></td><td class="size">-</td><td>-</td></tr>`;
|
|
2275
|
+
// 父目录链接(根目录时不显示)
|
|
2276
|
+
if (requestPath && requestPath !== '/') {
|
|
2277
|
+
html += `<tr><td><a href="${escapeHtml(parentPath)}" class="dir">../</a></td><td class="size">-</td><td>-</td></tr>`;
|
|
1977
2278
|
}
|
|
1978
2279
|
|
|
1979
2280
|
for (const item of items) {
|
|
@@ -1981,7 +2282,7 @@ class Application extends Router {
|
|
|
1981
2282
|
const name = item.isDirectory ? item.name + '/' : item.name;
|
|
1982
2283
|
const size = item.isDirectory ? '-' : fmtSize(item.size);
|
|
1983
2284
|
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>`;
|
|
2285
|
+
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
2286
|
}
|
|
1986
2287
|
|
|
1987
2288
|
html += `</table></body></html>`;
|
|
@@ -1995,11 +2296,35 @@ class Application extends Router {
|
|
|
1995
2296
|
const ws = this._wss.handleUpgrade(req, socket, head);
|
|
1996
2297
|
if (!ws) return;
|
|
1997
2298
|
|
|
1998
|
-
// 匹配 app.ws()
|
|
2299
|
+
// 匹配 app.ws() 注册的处理器(支持动态参数路径)
|
|
1999
2300
|
if (this._wsHandlers) {
|
|
2000
2301
|
const pathname = parseUrl(req.url).pathname;
|
|
2001
2302
|
for (const entry of this._wsHandlers) {
|
|
2002
|
-
|
|
2303
|
+
let matched = false;
|
|
2304
|
+
let params = {};
|
|
2305
|
+
if (entry.pattern) {
|
|
2306
|
+
// 动态路径:正则匹配
|
|
2307
|
+
const m = entry.pattern.exec(pathname);
|
|
2308
|
+
if (m) {
|
|
2309
|
+
matched = true;
|
|
2310
|
+
entry.params.forEach((name, i) => {
|
|
2311
|
+
try {
|
|
2312
|
+
params[name] = decodeURIComponent(m[i + 1]);
|
|
2313
|
+
} catch (e) {
|
|
2314
|
+
// 非法 URI 编码,保留原始值
|
|
2315
|
+
params[name] = m[i + 1];
|
|
2316
|
+
}
|
|
2317
|
+
});
|
|
2318
|
+
}
|
|
2319
|
+
} else {
|
|
2320
|
+
// 静态路径:精确或前缀匹配
|
|
2321
|
+
matched = pathname === entry.path || pathname.startsWith(entry.path + '/');
|
|
2322
|
+
}
|
|
2323
|
+
if (matched) {
|
|
2324
|
+
// 将动态参数挂载到 req 上
|
|
2325
|
+
if (Object.keys(params).length > 0) {
|
|
2326
|
+
req.params = params;
|
|
2327
|
+
}
|
|
2003
2328
|
const cleanup = entry.handler(ws, req);
|
|
2004
2329
|
if (typeof cleanup === 'function') {
|
|
2005
2330
|
ws.on('close', cleanup);
|
|
@@ -2038,7 +2363,9 @@ class Application extends Router {
|
|
|
2038
2363
|
*/
|
|
2039
2364
|
ws(pathStr, handler) {
|
|
2040
2365
|
if (!this._wsHandlers) this._wsHandlers = [];
|
|
2041
|
-
|
|
2366
|
+
// 支持动态参数路径,复用 Router 的路径编译逻辑
|
|
2367
|
+
const { pattern, params } = this._compilePath(pathStr);
|
|
2368
|
+
this._wsHandlers.push({ path: pathStr, pattern, params, handler });
|
|
2042
2369
|
}
|
|
2043
2370
|
}
|
|
2044
2371
|
|
|
@@ -2077,10 +2404,12 @@ const defaultConfig = {
|
|
|
2077
2404
|
tempDir: 'tempupdir',
|
|
2078
2405
|
maxFileSize: 128 * 1024 * 1024,
|
|
2079
2406
|
maxFieldSize: 1024 * 1024,
|
|
2407
|
+
maxBodySize: 128 * 1024 * 1024,
|
|
2080
2408
|
svrPort: 80,
|
|
2081
2409
|
svrIP: null,
|
|
2082
2410
|
|
|
2083
2411
|
showDir: false,
|
|
2412
|
+
allowAccessToAllFiles: false,
|
|
2084
2413
|
enableCache: false,
|
|
2085
2414
|
enableGzip: false,
|
|
2086
2415
|
enableRange: true,
|
|
@@ -2095,7 +2424,7 @@ const defaultConfig = {
|
|
|
2095
2424
|
logLevel: 'info',
|
|
2096
2425
|
logDir: './log',
|
|
2097
2426
|
|
|
2098
|
-
cors: { origin: '*', headers: 'Content-Type, Authorization', maxAge:
|
|
2427
|
+
cors: { origin: '*', headers: 'Content-Type, Authorization', maxAge: 86400 },
|
|
2099
2428
|
|
|
2100
2429
|
useBodyParser: true,
|
|
2101
2430
|
useCookieParser: true,
|
|
@@ -2103,7 +2432,9 @@ const defaultConfig = {
|
|
|
2103
2432
|
cookieParserSecret: null,
|
|
2104
2433
|
|
|
2105
2434
|
wsHeartbeatInterval: 30000,
|
|
2106
|
-
wsHeartbeatTimeout: 30000
|
|
2435
|
+
wsHeartbeatTimeout: 30000,
|
|
2436
|
+
wsMaxPayload: 100 * 1024 * 1024,
|
|
2437
|
+
wsAllowedOrigins: null
|
|
2107
2438
|
};
|
|
2108
2439
|
|
|
2109
2440
|
// ============================================================
|
|
@@ -2135,19 +2466,29 @@ httpm.cookieParser = cookieParser;
|
|
|
2135
2466
|
* static 中间件:Express 兼容的静态文件服务
|
|
2136
2467
|
* 用法: app.use(httpm.static('public'))
|
|
2137
2468
|
*/
|
|
2138
|
-
function staticMiddleware(rootPath) {
|
|
2469
|
+
function staticMiddleware(rootPath, options = {}) {
|
|
2139
2470
|
return function staticHandler(req, res, next) {
|
|
2140
2471
|
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
2141
2472
|
return next();
|
|
2142
2473
|
}
|
|
2143
2474
|
const root = path.resolve(rootPath || process.cwd());
|
|
2475
|
+
const allowAll = options.allowAccessToAllFiles || false;
|
|
2144
2476
|
let requestPath = req.path.replace(/^\/+/, '');
|
|
2145
|
-
if (!isPathSafe(requestPath, root)) {
|
|
2477
|
+
if (!isPathSafe(requestPath, root, allowAll)) {
|
|
2146
2478
|
return next();
|
|
2147
2479
|
}
|
|
2148
2480
|
const fullPath = path.join(root, requestPath);
|
|
2149
2481
|
fs.stat(fullPath, (err, stat) => {
|
|
2150
|
-
if (err
|
|
2482
|
+
if (err) {
|
|
2483
|
+
return next();
|
|
2484
|
+
}
|
|
2485
|
+
if (stat.isDirectory()) {
|
|
2486
|
+
// 目录:尝试 index.html
|
|
2487
|
+
const indexPath = path.join(fullPath, 'index.html');
|
|
2488
|
+
if (fs.existsSync(indexPath)) {
|
|
2489
|
+
res.sendFile(path.relative(root, indexPath), { root });
|
|
2490
|
+
return;
|
|
2491
|
+
}
|
|
2151
2492
|
return next();
|
|
2152
2493
|
}
|
|
2153
2494
|
res.sendFile(requestPath, { root });
|
|
@@ -2168,20 +2509,10 @@ httpm.parseRange = parseRange;
|
|
|
2168
2509
|
httpm.WebSocketHandShark = WebSocketHandShark;
|
|
2169
2510
|
|
|
2170
2511
|
/**
|
|
2171
|
-
* parseQuery:独立导出的 Query
|
|
2512
|
+
* parseQuery:独立导出的 Query 解析函数(复用内部 _parseQueryString)
|
|
2172
2513
|
*/
|
|
2173
2514
|
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;
|
|
2515
|
+
return _parseQueryString(qs);
|
|
2185
2516
|
}
|
|
2186
2517
|
httpm.parseQuery = parseQuery;
|
|
2187
2518
|
|