@lzpong/httpm 1.0.2 → 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.
Files changed (4) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +24 -9
  3. package/httpm.js +571 -240
  4. 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.0.0
5
+ * @version 1.1.0
6
6
  * @description 兼容 Express API,内置路由、中间件、静态文件服务、
7
7
  * WebSocket、SSE、流式上传、日志系统等功能
8
8
  * @license MIT
9
- * @requires node >= 14.0.0
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
- const pathname = urlStr.substring(0, qIdx);
49
- const qs = urlStr.substring(qIdx + 1);
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
- qs.split('&').forEach(pair => {
53
- const eIdx = pair.indexOf('=');
54
- if (eIdx === -1) {
55
- query[decodeURIComponent(pair)] = '';
56
- } else {
57
- query[decodeURIComponent(pair.substring(0, eIdx))] = decodeURIComponent(pair.substring(eIdx + 1));
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
- return { pathname, query };
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
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
- return (bytes / Math.pow(1024, i)).toFixed(2) + units[i];
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
- const parts = normalized.split(/[/\\]/);
178
- for (const part of parts) {
179
- if (part.startsWith('.')) return false;
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.end();
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
- if (!fs.existsSync(dir)) {
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
- const methodRoutes = this.routes[m] || [];
402
- const candidates = [...allRoutes, ...methodRoutes];
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 candidates) {
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
- params[name] = decodeURIComponent(match[i + 1]);
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
- params[name] = decodeURIComponent(match[i + 1]);
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
- this._req.on('data', chunk => chunks.push(chunk));
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', reject);
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
- this._res.end(data);
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)._send('');
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, stat.size);
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._res.statusCode = this.statusCode;
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, stat.size);
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, total) {
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
- const stream = fs.createReadStream(fullPath, { start, end });
683
- // 大文件进度展示(>1MB)
684
- if (total > 1024 * 1024) {
685
- const name = path.basename(fullPath);
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(url, code = 302) {
721
- this.status(code);
722
- this.setHeader('Location', url);
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 cookieValue = value;
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
- cookieValue = 's:' + value + '.' + sig;
844
+ encodedValue = 's:' + encodedValue + '.' + sig;
747
845
  }
748
846
  }
749
- let str = `${encodeURIComponent(name)}=${encodeURIComponent(cookieValue)}`;
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.writeHead(200, {
783
- 'Content-Type': 'text/event-stream',
784
- 'Cache-Control': 'no-cache',
785
- 'Connection': 'keep-alive',
786
- 'Access-Control-Allow-Origin': '*'
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
- res.on('close', () => {
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._emit('close');
994
+ this._emitClose();
865
995
  });
866
996
 
867
997
  // 监听数据帧
868
998
  socket.on('data', (data) => {
869
- this._handleFrame(data);
999
+ this._frameBuffer = Buffer.concat([this._frameBuffer, data]);
1000
+ this._parseFrames();
870
1001
  });
871
1002
 
872
- socket.on('error', () => {
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
- * 解析 WebSocket 帧
1011
+ * 循环解析帧缓冲区,处理多帧/半帧
880
1012
  */
881
- _handleFrame(data) {
882
- if (data.length < 2) return;
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 = data[0];
885
- const secondByte = data[1];
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
- payloadLength = data.readUInt16BE(offset);
1038
+ if (buf.length < 4) return null;
1039
+ payloadLength = buf.readUInt16BE(offset);
894
1040
  offset += 2;
895
1041
  } else if (payloadLength === 127) {
896
- payloadLength = Number(data.readBigUInt64BE(offset));
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
- mask = data.slice(offset, offset + 4);
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 = data.slice(offset, offset + payloadLength);
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
- switch (opcode) {
916
- case 0x01: { // 文本帧
917
- const text = payload.toString('utf8');
918
- this._emit('message', { type: 'text', data: text });
919
- this._emit('text', text);
920
- break;
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
- case 0x02: { // 二进制帧
923
- this._emit('message', { type: 'binary', data: payload });
924
- this._emit('binary', payload);
925
- break;
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
- case 0x08: // 关闭帧
928
- this._sendCloseFrame();
929
- this.connected = false;
930
- this._emit('close');
931
- break;
932
- case 0x09: // Ping 帧
933
- this._sendPong(payload);
934
- break;
935
- case 0x0A: // Pong 帧
936
- this._lastHeartbeat = Date.now();
937
- break;
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
- this._sendFrame(0x08, Buffer.alloc(0));
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._sendCloseFrame();
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
- try { this.socket.end(); } catch (e) { /* 忽略 */ }
1021
- this._emit('close');
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
- const handlers = this._handlers[event];
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
- const accept = crypto.createHash('sha1')
1083
- .update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11')
1084
- .digest('base64');
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
- this._removeConnection(ws);
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
- this._removeConnection(ws);
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
- const handlers = this._handlers[event];
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 str = buf.toString('utf8');
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) break;
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
- const chunk = buffer.toString('utf8');
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 = Buffer.alloc(0);
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
- currentFile.stream.write(buffer);
1457
- fileSize += buffer.length;
1742
+ fileSize += writeData.length;
1458
1743
  currentFile.size = fileSize;
1459
1744
  if (fileSize > maxFileSize) {
1460
- if (currentFile.stream) currentFile.stream.close();
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
- // 进度展示(>1MB)
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[fileData.length - 2] === 0x0D && fileData[fileData.length - 1] === 0x0A
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.close();
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:value.signature
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 value = unsigned.slice(0, dotIdx);
1848
+ const encodedValue = unsigned.slice(0, dotIdx);
1579
1849
  const sig = unsigned.slice(dotIdx + 1);
1580
- const expected = crypto.createHmac('sha256', secret).update(value).digest('base64').replace(/=+$/, '');
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] = value;
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
- req.path = decodeURIComponent(parsed.pathname);
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
- // 解析 Cookie(中间件会再次处理,此处先做基础解析)
1741
- req.cookies = parseCookies(incomingMessage.headers['cookie'] || '');
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, next);
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 || '86400');
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
- fs.readdir(dirPath, (err, files) => {
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 = files.map(f => {
2233
+ const items = entries.map(entry => {
1938
2234
  try {
1939
- const stat = fs.statSync(path.join(dirPath, f));
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: f,
1942
- isDirectory: stat.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
- const parentPath = path.dirname(requestPath);
1970
- let html = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>Directory: ${requestPath}</title>`;
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
- if (pathname === entry.path || pathname.startsWith(entry.path + '/')) {
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
- this._wsHandlers.push({ path: pathStr, handler });
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: '86400' },
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 || !stat.isFile()) {
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
- const query = {};
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