@lzpong/httpm 1.0.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/README.md +414 -0
- package/httpm.js +2188 -0
- package/package.json +39 -0
package/httpm.js
ADDED
|
@@ -0,0 +1,2188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* httpm - 基于 Node.js 原生模块的单文件、零依赖 HTTP 服务库
|
|
3
|
+
*
|
|
4
|
+
* @name httpm
|
|
5
|
+
* @version 1.0.0
|
|
6
|
+
* @description 兼容 Express API,内置路由、中间件、静态文件服务、
|
|
7
|
+
* WebSocket、SSE、流式上传、日志系统等功能
|
|
8
|
+
* @license MIT
|
|
9
|
+
* @requires node >= 14.0.0
|
|
10
|
+
* @module httpm
|
|
11
|
+
* @author lzpong
|
|
12
|
+
* @link https://gitee.com/lzpong/httpm
|
|
13
|
+
*
|
|
14
|
+
* 核心特性:
|
|
15
|
+
* - 单文件架构,零第三方依赖
|
|
16
|
+
* - Express 兼容:路由、中间件、请求/响应 API
|
|
17
|
+
* - 静态文件服务:Range 断点续传、ETag/Last-Modified 缓存、Gzip 压缩
|
|
18
|
+
* - WebSocket:路径分组、心跳保活、广播、文本/二进制子事件
|
|
19
|
+
* - SSE:服务端推送事件,支持 event/data/retry/comment
|
|
20
|
+
* - 流式文件上传:multipart/form-data 解析,内存零占用
|
|
21
|
+
* - 日志系统:彩色控制台输出 + 文件持久化,按级别过滤
|
|
22
|
+
* - Cookie 签名:HMAC-SHA256 签名与验证
|
|
23
|
+
* - 配置管理:默认配置 → app.json → 代码参数 → 运行时 app.set()
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
const http = require('http');
|
|
29
|
+
const https = require('https');
|
|
30
|
+
const http2 = require('http2');
|
|
31
|
+
const fs = require('fs');
|
|
32
|
+
const path = require('path');
|
|
33
|
+
const crypto = require('crypto');
|
|
34
|
+
const zlib = require('zlib');
|
|
35
|
+
|
|
36
|
+
// ============================================================
|
|
37
|
+
// 工具函数层
|
|
38
|
+
// ============================================================
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 解析 URL,拆分路径与 Query 参数
|
|
42
|
+
*/
|
|
43
|
+
function parseUrl(urlStr) {
|
|
44
|
+
const qIdx = urlStr.indexOf('?');
|
|
45
|
+
if (qIdx === -1) {
|
|
46
|
+
return { pathname: urlStr, query: {} };
|
|
47
|
+
}
|
|
48
|
+
const pathname = urlStr.substring(0, qIdx);
|
|
49
|
+
const qs = urlStr.substring(qIdx + 1);
|
|
50
|
+
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));
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
return { pathname, query };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 解析 Cookie 字符串为键值对象
|
|
66
|
+
*/
|
|
67
|
+
function parseCookies(cookieStr) {
|
|
68
|
+
const cookies = {};
|
|
69
|
+
if (!cookieStr) return cookies;
|
|
70
|
+
cookieStr.split(';').forEach(pair => {
|
|
71
|
+
const eIdx = pair.indexOf('=');
|
|
72
|
+
if (eIdx === -1) return;
|
|
73
|
+
const key = pair.substring(0, eIdx).trim();
|
|
74
|
+
const val = pair.substring(eIdx + 1).trim();
|
|
75
|
+
cookies[key] = val;
|
|
76
|
+
});
|
|
77
|
+
return cookies;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* MIME 类型映射表
|
|
82
|
+
*/
|
|
83
|
+
const MIME_TYPES = {
|
|
84
|
+
'.html': 'text/html; charset=utf-8',
|
|
85
|
+
'.htm': 'text/html; charset=utf-8',
|
|
86
|
+
'.css': 'text/css; charset=utf-8',
|
|
87
|
+
'.js': 'application/javascript; charset=utf-8',
|
|
88
|
+
'.mjs': 'application/javascript; charset=utf-8',
|
|
89
|
+
'.json': 'application/json; charset=utf-8',
|
|
90
|
+
'.xml': 'application/xml; charset=utf-8',
|
|
91
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
92
|
+
'.log': 'text/plain; charset=utf-8',
|
|
93
|
+
'.csv': 'text/csv; charset=utf-8',
|
|
94
|
+
'.md': 'text/markdown; charset=utf-8',
|
|
95
|
+
'.png': 'image/png',
|
|
96
|
+
'.jpg': 'image/jpeg',
|
|
97
|
+
'.jpeg': 'image/jpeg',
|
|
98
|
+
'.gif': 'image/gif',
|
|
99
|
+
'.svg': 'image/svg+xml',
|
|
100
|
+
'.ico': 'image/x-icon',
|
|
101
|
+
'.webp': 'image/webp',
|
|
102
|
+
'.bmp': 'image/bmp',
|
|
103
|
+
'.tif': 'image/tiff',
|
|
104
|
+
'.tiff': 'image/tiff',
|
|
105
|
+
'.pdf': 'application/pdf',
|
|
106
|
+
'.zip': 'application/zip',
|
|
107
|
+
'.rar': 'application/x-rar-compressed',
|
|
108
|
+
'.7z': 'application/x-7z-compressed',
|
|
109
|
+
'.tar': 'application/x-tar',
|
|
110
|
+
'.gz': 'application/gzip',
|
|
111
|
+
'.bz2': 'application/x-bzip2',
|
|
112
|
+
'.doc': 'application/msword',
|
|
113
|
+
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
114
|
+
'.xls': 'application/vnd.ms-excel',
|
|
115
|
+
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
116
|
+
'.ppt': 'application/vnd.ms-powerpoint',
|
|
117
|
+
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
118
|
+
'.mp3': 'audio/mpeg',
|
|
119
|
+
'.wav': 'audio/wav',
|
|
120
|
+
'.ogg': 'audio/ogg',
|
|
121
|
+
'.mp4': 'video/mp4',
|
|
122
|
+
'.webm': 'video/webm',
|
|
123
|
+
'.avi': 'video/x-msvideo',
|
|
124
|
+
'.mov': 'video/quicktime',
|
|
125
|
+
'.wmv': 'video/x-ms-wmv',
|
|
126
|
+
'.flv': 'video/x-flv',
|
|
127
|
+
'.woff': 'font/woff',
|
|
128
|
+
'.woff2': 'font/woff2',
|
|
129
|
+
'.ttf': 'font/ttf',
|
|
130
|
+
'.otf': 'font/otf',
|
|
131
|
+
'.eot': 'application/vnd.ms-fontobject',
|
|
132
|
+
'.wasm': 'application/wasm',
|
|
133
|
+
'.map': 'application/json'
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 根据文件后缀匹配标准 MIME 类型
|
|
138
|
+
*/
|
|
139
|
+
function getMimeType(ext) {
|
|
140
|
+
if (!ext) return 'application/octet-stream';
|
|
141
|
+
const lower = ext.toLowerCase();
|
|
142
|
+
return MIME_TYPES[lower] || 'application/octet-stream';
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* 字节单位格式化(B/KB/MB/GB)
|
|
147
|
+
*/
|
|
148
|
+
function fmtSize(bytes) {
|
|
149
|
+
if (bytes === 0) return '0B';
|
|
150
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
151
|
+
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
152
|
+
return (bytes / Math.pow(1024, i)).toFixed(2) + units[i];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* 毫秒时间格式化(ms/s/m)
|
|
157
|
+
*/
|
|
158
|
+
function fmtTime(ms) {
|
|
159
|
+
if (ms < 1000) return ms.toFixed(0) + 'ms';
|
|
160
|
+
if (ms < 60000) return (ms / 1000).toFixed(2) + 's';
|
|
161
|
+
return (ms / 60000).toFixed(2) + 'm';
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* 路径安全校验,防遍历攻击
|
|
166
|
+
*/
|
|
167
|
+
function isPathSafe(requestPath, rootDir) {
|
|
168
|
+
// 去掉前导斜杠,防止 path.resolve 将其视为绝对路径
|
|
169
|
+
const normalized = requestPath.replace(/^\/+/, '');
|
|
170
|
+
const resolved = path.resolve(rootDir, normalized);
|
|
171
|
+
const root = path.resolve(rootDir);
|
|
172
|
+
// 确保解析后的路径在根目录内
|
|
173
|
+
if (!resolved.startsWith(root + path.sep) && resolved !== root) {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
// 禁止访问隐藏文件/目录(以 . 开头)
|
|
177
|
+
const parts = normalized.split(/[/\\]/);
|
|
178
|
+
for (const part of parts) {
|
|
179
|
+
if (part.startsWith('.')) return false;
|
|
180
|
+
}
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* 根据文件信息生成 ETag 缓存标识
|
|
186
|
+
*/
|
|
187
|
+
function generateETag(stat) {
|
|
188
|
+
const hash = crypto.createHash('md5');
|
|
189
|
+
hash.update(stat.size.toString(36));
|
|
190
|
+
hash.update(stat.mtimeMs.toString(36));
|
|
191
|
+
return '"' + hash.digest('hex') + '"';
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* 解析 Range 请求头,提取字节分段范围
|
|
196
|
+
* 返回 { start, end, total } 或 null
|
|
197
|
+
*/
|
|
198
|
+
function parseRange(rangeHeader, fileSize) {
|
|
199
|
+
if (!rangeHeader) return null;
|
|
200
|
+
// 格式: bytes=start-end 或 bytes=start- 或 bytes=-suffix
|
|
201
|
+
const match = rangeHeader.match(/^bytes=(\d*)-(\d*)$/);
|
|
202
|
+
if (!match) return null;
|
|
203
|
+
let start = match[1] ? parseInt(match[1], 10) : null;
|
|
204
|
+
let end = match[2] ? parseInt(match[2], 10) : null;
|
|
205
|
+
if (start === null && end === null) return null;
|
|
206
|
+
if (start === null) {
|
|
207
|
+
// bytes=-suffix: 请求最后 suffix 字节
|
|
208
|
+
start = Math.max(0, fileSize - end);
|
|
209
|
+
end = fileSize - 1;
|
|
210
|
+
} else if (end === null) {
|
|
211
|
+
// bytes=start-: 从 start 到文件末尾
|
|
212
|
+
end = fileSize - 1;
|
|
213
|
+
}
|
|
214
|
+
if (start > end || start >= fileSize) return null;
|
|
215
|
+
// end 超出文件大小时修正为文件末尾
|
|
216
|
+
if (end >= fileSize) end = fileSize - 1;
|
|
217
|
+
return { start, end, total: fileSize };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* 判断 MIME 类型是否为文本类(可压缩)
|
|
222
|
+
*/
|
|
223
|
+
function isTextMime(mime) {
|
|
224
|
+
return /^(text\/|application\/(javascript|json|xml)|image\/svg\+xml)/.test(mime);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* 生成唯一 ID
|
|
229
|
+
*/
|
|
230
|
+
function uid() {
|
|
231
|
+
return Date.now().toString(36) + Math.random().toString(36).substring(2, 8);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ============================================================
|
|
235
|
+
// Logger 日志类
|
|
236
|
+
// ============================================================
|
|
237
|
+
|
|
238
|
+
class Logger {
|
|
239
|
+
constructor(options = {}) {
|
|
240
|
+
this.name = options.name || '';
|
|
241
|
+
this.level = options.level || 'info';
|
|
242
|
+
this.logDir = options.logDir || './log';
|
|
243
|
+
this._levels = { debug: 0, info: 1, notice: 2, warn: 3, error: 4 };
|
|
244
|
+
this._colors = {
|
|
245
|
+
debug: '\x1b[1;30m', // 灰色
|
|
246
|
+
info: '\x1b[1;37m', // 白色
|
|
247
|
+
notice: '\x1b[1;35m', // 品红
|
|
248
|
+
warn: '\x1b[1;33m', // 黄色
|
|
249
|
+
error: '\x1b[1;31m' // 红色
|
|
250
|
+
};
|
|
251
|
+
this._reset = '\x1b[0m';
|
|
252
|
+
this._stream = null;
|
|
253
|
+
this._streamDate = null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
_shouldLog(level) {
|
|
257
|
+
const current = this._levels[this.level] || 1;
|
|
258
|
+
const target = this._levels[level] || 1;
|
|
259
|
+
return target >= current;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
_formatTime(date) {
|
|
263
|
+
const pad = n => n.toString().padStart(2, '0');
|
|
264
|
+
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
_getLogStream() {
|
|
268
|
+
const now = new Date();
|
|
269
|
+
const year = now.getFullYear().toString();
|
|
270
|
+
const month = (now.getMonth() + 1).toString().padStart(2, '0');
|
|
271
|
+
const day = now.getDate().toString().padStart(2, '0');
|
|
272
|
+
const dateStr = `${day}`;
|
|
273
|
+
|
|
274
|
+
// 同一天复用同一个流
|
|
275
|
+
const streamKey = `${year}-${month}-${day}`;
|
|
276
|
+
if (this._stream && this._streamDate === streamKey) {
|
|
277
|
+
return this._stream;
|
|
278
|
+
}
|
|
279
|
+
// 关闭旧流
|
|
280
|
+
if (this._stream) {
|
|
281
|
+
this._stream.end();
|
|
282
|
+
this._stream = null;
|
|
283
|
+
}
|
|
284
|
+
// 创建日志目录: ./log/YYYY/MM/
|
|
285
|
+
const dir = path.join(this.logDir, year, month);
|
|
286
|
+
if (!fs.existsSync(dir)) {
|
|
287
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
288
|
+
}
|
|
289
|
+
// 文件名: name_DD.log 或 DD.log
|
|
290
|
+
const prefix = this.name ? this.name + '_' : '';
|
|
291
|
+
const filePath = path.join(dir, `${prefix}${day}.log`);
|
|
292
|
+
this._stream = fs.createWriteStream(filePath, { flags: 'a' });
|
|
293
|
+
this._streamDate = streamKey;
|
|
294
|
+
return this._stream;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
_writeFile(level, msg) {
|
|
298
|
+
try {
|
|
299
|
+
const stream = this._getLogStream();
|
|
300
|
+
stream.write(msg + '\n');
|
|
301
|
+
} catch (e) {
|
|
302
|
+
// 文件写入失败静默处理
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
log(level, ...args) {
|
|
307
|
+
if (!this._shouldLog(level)) return;
|
|
308
|
+
const timestamp = this._formatTime(new Date());
|
|
309
|
+
const color = this._colors[level] || '';
|
|
310
|
+
const levelTag = level.toUpperCase().padEnd(6);
|
|
311
|
+
const msg = `[${timestamp}] [${levelTag}] ${args.join(' ')}`;
|
|
312
|
+
|
|
313
|
+
// 控制台彩色输出
|
|
314
|
+
console.log(`${color}${msg}${this._reset}`);
|
|
315
|
+
// 文件持久化(无颜色码)
|
|
316
|
+
this._writeFile(level, msg);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
debug(...args) { this.log('debug', ...args); }
|
|
320
|
+
info(...args) { this.log('info', ...args); }
|
|
321
|
+
notice(...args) { this.log('notice', ...args); }
|
|
322
|
+
warn(...args) { this.log('warn', ...args); }
|
|
323
|
+
error(...args) { this.log('error', ...args); }
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ============================================================
|
|
327
|
+
// Router 类
|
|
328
|
+
// ============================================================
|
|
329
|
+
|
|
330
|
+
class Router {
|
|
331
|
+
constructor() {
|
|
332
|
+
this.routes = { GET: [], POST: [], PUT: [], DELETE: [], PATCH: [], HEAD: [], OPTIONS: [], ALL: [] };
|
|
333
|
+
this.middlewareStack = [];
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* 编译动态路由路径为正则表达式
|
|
338
|
+
*/
|
|
339
|
+
_compilePath(pathStr) {
|
|
340
|
+
const params = [];
|
|
341
|
+
// 将 :param 替换为正则捕获组
|
|
342
|
+
const patternStr = pathStr.replace(/:([^/]+)/g, (_, name) => {
|
|
343
|
+
params.push(name);
|
|
344
|
+
return '([^/]+)';
|
|
345
|
+
});
|
|
346
|
+
const pattern = new RegExp('^' + patternStr + '$');
|
|
347
|
+
return { pattern, params };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* 注册路由
|
|
352
|
+
*/
|
|
353
|
+
_addRoute(method, pathStr, ...handlers) {
|
|
354
|
+
const { pattern, params } = this._compilePath(pathStr);
|
|
355
|
+
const route = {
|
|
356
|
+
method: method.toUpperCase(),
|
|
357
|
+
path: pathStr,
|
|
358
|
+
pattern,
|
|
359
|
+
params,
|
|
360
|
+
handlers
|
|
361
|
+
};
|
|
362
|
+
const key = method.toUpperCase();
|
|
363
|
+
if (this.routes[key]) {
|
|
364
|
+
this.routes[key].push(route);
|
|
365
|
+
}
|
|
366
|
+
// 同时注册到 ALL
|
|
367
|
+
if (key !== 'ALL') {
|
|
368
|
+
// ALL 路由单独处理,不在此处添加
|
|
369
|
+
}
|
|
370
|
+
return this;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* 注册路径级/应用级中间件
|
|
375
|
+
*/
|
|
376
|
+
use(...args) {
|
|
377
|
+
if (typeof args[0] === 'string') {
|
|
378
|
+
// 路径级中间件
|
|
379
|
+
const pathStr = args[0];
|
|
380
|
+
const handlers = args.slice(1);
|
|
381
|
+
const { pattern, params } = this._compilePath(pathStr);
|
|
382
|
+
this.middlewareStack.push({ path: pathStr, pattern, params, handlers });
|
|
383
|
+
} else {
|
|
384
|
+
// 应用级中间件
|
|
385
|
+
const handlers = args;
|
|
386
|
+
this.middlewareStack.push({ path: '/', pattern: null, params: [], handlers });
|
|
387
|
+
}
|
|
388
|
+
return this;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* 路由匹配:查找匹配的路由并提取参数
|
|
393
|
+
*/
|
|
394
|
+
match(method, pathname) {
|
|
395
|
+
const results = [];
|
|
396
|
+
const m = method.toUpperCase();
|
|
397
|
+
|
|
398
|
+
// 检查 ALL 路由
|
|
399
|
+
const allRoutes = this.routes['ALL'] || [];
|
|
400
|
+
// 检查对应方法路由
|
|
401
|
+
const methodRoutes = this.routes[m] || [];
|
|
402
|
+
const candidates = [...allRoutes, ...methodRoutes];
|
|
403
|
+
|
|
404
|
+
for (const route of candidates) {
|
|
405
|
+
const match = route.pattern.exec(pathname);
|
|
406
|
+
if (match) {
|
|
407
|
+
const params = {};
|
|
408
|
+
route.params.forEach((name, i) => {
|
|
409
|
+
params[name] = decodeURIComponent(match[i + 1]);
|
|
410
|
+
});
|
|
411
|
+
results.push({ route, params, handlers: route.handlers });
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return results;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* 匹配路径级中间件(前缀匹配)
|
|
419
|
+
*/
|
|
420
|
+
matchMiddleware(pathname) {
|
|
421
|
+
const results = [];
|
|
422
|
+
for (const mw of this.middlewareStack) {
|
|
423
|
+
if (!mw.pattern) {
|
|
424
|
+
// 应用级中间件,匹配所有路径
|
|
425
|
+
results.push({ middleware: mw, params: {} });
|
|
426
|
+
} else {
|
|
427
|
+
// 路径级中间件:前缀匹配
|
|
428
|
+
// 中间件路径 /api 应匹配 /api、/api/users、/api/users/123 等
|
|
429
|
+
const mwPath = mw.path;
|
|
430
|
+
if (pathname === mwPath || pathname.startsWith(mwPath + '/')) {
|
|
431
|
+
const params = {};
|
|
432
|
+
// 如果有动态参数,也需要提取
|
|
433
|
+
if (mw.params.length > 0) {
|
|
434
|
+
const match = mw.pattern.exec(pathname);
|
|
435
|
+
if (match) {
|
|
436
|
+
mw.params.forEach((name, i) => {
|
|
437
|
+
params[name] = decodeURIComponent(match[i + 1]);
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
results.push({ middleware: mw, params });
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return results;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// HTTP 方法快捷注册
|
|
449
|
+
get(pathStr, ...handlers) { return this._addRoute('GET', pathStr, ...handlers); }
|
|
450
|
+
post(pathStr, ...handlers) { return this._addRoute('POST', pathStr, ...handlers); }
|
|
451
|
+
put(pathStr, ...handlers) { return this._addRoute('PUT', pathStr, ...handlers); }
|
|
452
|
+
delete(pathStr, ...handlers) { return this._addRoute('DELETE', pathStr, ...handlers); }
|
|
453
|
+
patch(pathStr, ...handlers) { return this._addRoute('PATCH', pathStr, ...handlers); }
|
|
454
|
+
head(pathStr, ...handlers) { return this._addRoute('HEAD', pathStr, ...handlers); }
|
|
455
|
+
options(pathStr, ...handlers) { return this._addRoute('OPTIONS', pathStr, ...handlers); }
|
|
456
|
+
all(pathStr, ...handlers) { return this._addRoute('ALL', pathStr, ...handlers); }
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ============================================================
|
|
460
|
+
// Request 类
|
|
461
|
+
// ============================================================
|
|
462
|
+
|
|
463
|
+
class Request {
|
|
464
|
+
constructor(incomingMessage) {
|
|
465
|
+
this._req = incomingMessage;
|
|
466
|
+
this.query = {};
|
|
467
|
+
this.params = {};
|
|
468
|
+
this.body = null;
|
|
469
|
+
this.cookies = {};
|
|
470
|
+
this.files = [];
|
|
471
|
+
this.path = '';
|
|
472
|
+
this.formData = {
|
|
473
|
+
fields: {},
|
|
474
|
+
files: []
|
|
475
|
+
};
|
|
476
|
+
this._tempFiles = [];
|
|
477
|
+
this._bodyParsed = false;
|
|
478
|
+
this._rawBody = null;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// 通用快捷属性代理
|
|
482
|
+
get method() { return this._req.method; }
|
|
483
|
+
get url() { return this._req.url; }
|
|
484
|
+
get headers() { return this._req.headers; }
|
|
485
|
+
get ip() {
|
|
486
|
+
// 优先取代理头
|
|
487
|
+
const fwd = this._req.headers['x-forwarded-for'];
|
|
488
|
+
if (fwd) return fwd.split(',')[0].trim();
|
|
489
|
+
return this._req.socket?.remoteAddress || '';
|
|
490
|
+
}
|
|
491
|
+
get hostname() { return this._req.headers['host']?.split(':')[0] || ''; }
|
|
492
|
+
get protocol() {
|
|
493
|
+
return (this._req.socket?.encrypted || this._req.connection?.encrypted) ? 'https' : 'http';
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* 读取请求体原始数据
|
|
498
|
+
*/
|
|
499
|
+
_readBody() {
|
|
500
|
+
return new Promise((resolve, reject) => {
|
|
501
|
+
if (this._bodyParsed) {
|
|
502
|
+
resolve(this._rawBody);
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
const chunks = [];
|
|
506
|
+
this._req.on('data', chunk => chunks.push(chunk));
|
|
507
|
+
this._req.on('end', () => {
|
|
508
|
+
this._rawBody = Buffer.concat(chunks);
|
|
509
|
+
this._bodyParsed = true;
|
|
510
|
+
resolve(this._rawBody);
|
|
511
|
+
});
|
|
512
|
+
this._req.on('error', reject);
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// ============================================================
|
|
518
|
+
// Response 类
|
|
519
|
+
// ============================================================
|
|
520
|
+
|
|
521
|
+
class Response {
|
|
522
|
+
constructor(serverResponse, app) {
|
|
523
|
+
this._res = serverResponse;
|
|
524
|
+
this._app = app;
|
|
525
|
+
this.statusCode = 200;
|
|
526
|
+
this._headersSent = false;
|
|
527
|
+
this._sse = null;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// 代理原生响应方法
|
|
531
|
+
get finished() { return this._res.finished; }
|
|
532
|
+
get headersSent() { return this._res.headersSent || this._headersSent; }
|
|
533
|
+
|
|
534
|
+
setHeader(name, value) {
|
|
535
|
+
this._res.setHeader(name, value);
|
|
536
|
+
return this;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
getHeader(name) {
|
|
540
|
+
return this._res.getHeader(name);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
removeHeader(name) {
|
|
544
|
+
this._res.removeHeader(name);
|
|
545
|
+
return this;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* 设置 HTTP 状态码,支持链式调用
|
|
550
|
+
*/
|
|
551
|
+
status(code) {
|
|
552
|
+
this.statusCode = code;
|
|
553
|
+
return this;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* 输出 JSON 格式响应
|
|
558
|
+
*/
|
|
559
|
+
json(data) {
|
|
560
|
+
const body = JSON.stringify(data);
|
|
561
|
+
this.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
562
|
+
this.setHeader('Content-Length', Buffer.byteLength(body));
|
|
563
|
+
this._send(body);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* 通用输出,支持字符串、HTML、Buffer、对象
|
|
568
|
+
*/
|
|
569
|
+
send(data) {
|
|
570
|
+
if (data === undefined || data === null) {
|
|
571
|
+
this.setHeader('Content-Length', 0);
|
|
572
|
+
this._send('');
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
if (Buffer.isBuffer(data)) {
|
|
576
|
+
this.setHeader('Content-Type', 'application/octet-stream');
|
|
577
|
+
this.setHeader('Content-Length', data.length);
|
|
578
|
+
this._send(data);
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
if (typeof data === 'object') {
|
|
582
|
+
this.json(data);
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
const str = String(data);
|
|
586
|
+
// 自动判断内容类型
|
|
587
|
+
if (!this.getHeader('Content-Type')) {
|
|
588
|
+
if (str.startsWith('<!DOCTYPE') || str.startsWith('<html') || str.startsWith('<HTML')) {
|
|
589
|
+
this.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
590
|
+
} else {
|
|
591
|
+
this.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
this.setHeader('Content-Length', Buffer.byteLength(str));
|
|
595
|
+
this._send(str);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* 内部发送方法
|
|
600
|
+
*/
|
|
601
|
+
_send(data) {
|
|
602
|
+
if (this._res.finished) return;
|
|
603
|
+
this._res.statusCode = this.statusCode;
|
|
604
|
+
this._headersSent = true;
|
|
605
|
+
this._res.end(data);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* 发送本地文件,内置断点续传、缓存、Gzip 能力
|
|
610
|
+
*/
|
|
611
|
+
sendFile(filePath, options = {}) {
|
|
612
|
+
const root = options.root || this._app.settings.rootPath || process.cwd();
|
|
613
|
+
const fullPath = path.resolve(root, filePath);
|
|
614
|
+
|
|
615
|
+
fs.stat(fullPath, (err, stat) => {
|
|
616
|
+
if (err || !stat.isFile()) {
|
|
617
|
+
this.status(404).send('Not Found');
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const mime = getMimeType(path.extname(fullPath));
|
|
622
|
+
this.setHeader('Content-Type', mime);
|
|
623
|
+
this.setHeader('Accept-Ranges', 'bytes');
|
|
624
|
+
|
|
625
|
+
// ETag 缓存校验
|
|
626
|
+
if (this._app.settings.enableCache !== false) {
|
|
627
|
+
const etag = generateETag(stat);
|
|
628
|
+
this.setHeader('ETag', etag);
|
|
629
|
+
this.setHeader('Last-Modified', stat.mtime.toUTCString());
|
|
630
|
+
// 设置 Cache-Control 头
|
|
631
|
+
if (this._app.settings.cacheControl) {
|
|
632
|
+
this.setHeader('Cache-Control', this._app.settings.cacheControl);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const ifNoneMatch = this._req?.headers?.['if-none-match'];
|
|
636
|
+
const ifModifiedSince = this._req?.headers?.['if-modified-since'];
|
|
637
|
+
if (ifNoneMatch === etag || (ifModifiedSince && new Date(ifModifiedSince) >= stat.mtime)) {
|
|
638
|
+
this.status(304)._send('');
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Range 断点续传
|
|
644
|
+
const rangeHeader = this._req?.headers?.['range'];
|
|
645
|
+
if (this._app.settings.enableRange !== false && rangeHeader) {
|
|
646
|
+
const range = parseRange(rangeHeader, stat.size);
|
|
647
|
+
if (range) {
|
|
648
|
+
this.status(206);
|
|
649
|
+
this.setHeader('Content-Range', `bytes ${range.start}-${range.end}/${range.total}`);
|
|
650
|
+
this.setHeader('Content-Length', range.end - range.start + 1);
|
|
651
|
+
this._streamFile(fullPath, range.start, range.end, stat.size);
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
this.setHeader('Content-Length', stat.size);
|
|
657
|
+
|
|
658
|
+
// Gzip 压缩(仅文本类文件)
|
|
659
|
+
const acceptEncoding = this._req?.headers?.['accept-encoding'] || '';
|
|
660
|
+
if (this._app.settings.enableGzip && isTextMime(mime) && acceptEncoding.includes('gzip')) {
|
|
661
|
+
this.removeHeader('Content-Length');
|
|
662
|
+
this.setHeader('Content-Encoding', 'gzip');
|
|
663
|
+
this._res.statusCode = this.statusCode;
|
|
664
|
+
this._headersSent = true;
|
|
665
|
+
const raw = fs.createReadStream(fullPath);
|
|
666
|
+
const gzip = zlib.createGzip();
|
|
667
|
+
raw.pipe(gzip).pipe(this._res);
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
this._streamFile(fullPath, 0, stat.size - 1, stat.size);
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* 流式发送文件
|
|
677
|
+
*/
|
|
678
|
+
_streamFile(fullPath, start, end, total) {
|
|
679
|
+
if (this._res.finished) return;
|
|
680
|
+
this._res.statusCode = this.statusCode;
|
|
681
|
+
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
|
+
});
|
|
704
|
+
}
|
|
705
|
+
stream.pipe(this._res);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* 触发浏览器文件下载,支持大文件进度展示
|
|
710
|
+
*/
|
|
711
|
+
download(filePath, filename) {
|
|
712
|
+
const name = filename || path.basename(filePath);
|
|
713
|
+
this.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(name)}"`);
|
|
714
|
+
this.sendFile(filePath);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* 重定向响应
|
|
719
|
+
*/
|
|
720
|
+
redirect(url, code = 302) {
|
|
721
|
+
this.status(code);
|
|
722
|
+
this.setHeader('Location', url);
|
|
723
|
+
this.setHeader('Content-Length', 0);
|
|
724
|
+
this._send('');
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* 创建 SSE 推送实例
|
|
729
|
+
*/
|
|
730
|
+
sse() {
|
|
731
|
+
if (this._sse) return this._sse;
|
|
732
|
+
this._sse = new SSE(this._res);
|
|
733
|
+
return this._sse;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* 设置响应 Cookie
|
|
738
|
+
*/
|
|
739
|
+
cookie(name, value, opts = {}) {
|
|
740
|
+
let cookieValue = value;
|
|
741
|
+
// 签名 Cookie:s:value.signature
|
|
742
|
+
if (opts.signed) {
|
|
743
|
+
const secret = this._app && this._app.settings && this._app.settings.cookieParserSecret;
|
|
744
|
+
if (secret) {
|
|
745
|
+
const sig = crypto.createHmac('sha256', secret).update(value).digest('base64').replace(/=+$/, '');
|
|
746
|
+
cookieValue = 's:' + value + '.' + sig;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
let str = `${encodeURIComponent(name)}=${encodeURIComponent(cookieValue)}`;
|
|
750
|
+
if (opts.maxAge !== undefined) str += `; Max-Age=${opts.maxAge}`;
|
|
751
|
+
if (opts.domain) str += `; Domain=${opts.domain}`;
|
|
752
|
+
if (opts.path) str += `; Path=${opts.path}`;
|
|
753
|
+
if (opts.secure) str += '; Secure';
|
|
754
|
+
if (opts.httpOnly) str += '; HttpOnly';
|
|
755
|
+
if (opts.sameSite) str += `; SameSite=${opts.sameSite}`;
|
|
756
|
+
const existing = this.getHeader('Set-Cookie');
|
|
757
|
+
const cookies = existing ? (Array.isArray(existing) ? existing : [existing]) : [];
|
|
758
|
+
cookies.push(str);
|
|
759
|
+
this.setHeader('Set-Cookie', cookies);
|
|
760
|
+
return this;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* 清除 Cookie
|
|
765
|
+
*/
|
|
766
|
+
clearCookie(name, opts = {}) {
|
|
767
|
+
this.cookie(name, '', { ...opts, maxAge: 0 });
|
|
768
|
+
return this;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// ============================================================
|
|
773
|
+
// SSE 类
|
|
774
|
+
// ============================================================
|
|
775
|
+
|
|
776
|
+
class SSE {
|
|
777
|
+
constructor(res) {
|
|
778
|
+
this._res = res;
|
|
779
|
+
this.connected = true;
|
|
780
|
+
|
|
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
|
+
});
|
|
788
|
+
|
|
789
|
+
// 监听连接关闭
|
|
790
|
+
res.on('close', () => {
|
|
791
|
+
this.connected = false;
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* 发送普通消息,自动兼容字符串/JSON 对象
|
|
797
|
+
*/
|
|
798
|
+
send(data) {
|
|
799
|
+
if (!this.connected) return this;
|
|
800
|
+
const msg = typeof data === 'string' ? data : JSON.stringify(data);
|
|
801
|
+
this._res.write(`data: ${msg}\n\n`);
|
|
802
|
+
return this;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* 发送自定义命名事件
|
|
807
|
+
*/
|
|
808
|
+
event(name, data) {
|
|
809
|
+
if (!this.connected) return this;
|
|
810
|
+
const msg = typeof data === 'string' ? data : JSON.stringify(data);
|
|
811
|
+
this._res.write(`event: ${name}\ndata: ${msg}\n\n`);
|
|
812
|
+
return this;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* 设置客户端重连间隔(毫秒)
|
|
817
|
+
*/
|
|
818
|
+
retry(ms) {
|
|
819
|
+
if (!this.connected) return this;
|
|
820
|
+
this._res.write(`retry: ${ms}\n\n`);
|
|
821
|
+
return this;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* 发送注释消息(可作为心跳保活)
|
|
826
|
+
*/
|
|
827
|
+
comment(text) {
|
|
828
|
+
if (!this.connected) return this;
|
|
829
|
+
this._res.write(`: ${text}\n\n`);
|
|
830
|
+
return this;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* 主动关闭 SSE 连接
|
|
835
|
+
*/
|
|
836
|
+
close() {
|
|
837
|
+
if (!this.connected) return;
|
|
838
|
+
this.connected = false;
|
|
839
|
+
this._res.end();
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// ============================================================
|
|
844
|
+
// WebSocket 类
|
|
845
|
+
// ============================================================
|
|
846
|
+
|
|
847
|
+
class WebSocket {
|
|
848
|
+
constructor(socket, pathStr) {
|
|
849
|
+
this.socket = socket;
|
|
850
|
+
this.path = pathStr;
|
|
851
|
+
this.id = uid();
|
|
852
|
+
this.connected = true;
|
|
853
|
+
this._lastHeartbeat = Date.now();
|
|
854
|
+
this._handlers = {};
|
|
855
|
+
|
|
856
|
+
// 监听底层 Pong 帧
|
|
857
|
+
socket.on('pong', () => {
|
|
858
|
+
this._lastHeartbeat = Date.now();
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
// 监听连接关闭
|
|
862
|
+
socket.on('close', () => {
|
|
863
|
+
this.connected = false;
|
|
864
|
+
this._emit('close');
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
// 监听数据帧
|
|
868
|
+
socket.on('data', (data) => {
|
|
869
|
+
this._handleFrame(data);
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
socket.on('error', () => {
|
|
873
|
+
this.connected = false;
|
|
874
|
+
this._emit('error');
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/**
|
|
879
|
+
* 解析 WebSocket 帧
|
|
880
|
+
*/
|
|
881
|
+
_handleFrame(data) {
|
|
882
|
+
if (data.length < 2) return;
|
|
883
|
+
|
|
884
|
+
const firstByte = data[0];
|
|
885
|
+
const secondByte = data[1];
|
|
886
|
+
const opcode = firstByte & 0x0F;
|
|
887
|
+
const isMasked = (secondByte & 0x80) !== 0;
|
|
888
|
+
let payloadLength = secondByte & 0x7F;
|
|
889
|
+
let offset = 2;
|
|
890
|
+
|
|
891
|
+
// 解析长度
|
|
892
|
+
if (payloadLength === 126) {
|
|
893
|
+
payloadLength = data.readUInt16BE(offset);
|
|
894
|
+
offset += 2;
|
|
895
|
+
} else if (payloadLength === 127) {
|
|
896
|
+
payloadLength = Number(data.readBigUInt64BE(offset));
|
|
897
|
+
offset += 8;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// 解析掩码
|
|
901
|
+
let mask = null;
|
|
902
|
+
if (isMasked) {
|
|
903
|
+
mask = data.slice(offset, offset + 4);
|
|
904
|
+
offset += 4;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// 提取负载
|
|
908
|
+
let payload = data.slice(offset, offset + payloadLength);
|
|
909
|
+
if (isMasked && mask) {
|
|
910
|
+
for (let i = 0; i < payload.length; i++) {
|
|
911
|
+
payload[i] ^= mask[i % 4];
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
switch (opcode) {
|
|
916
|
+
case 0x01: { // 文本帧
|
|
917
|
+
const text = payload.toString('utf8');
|
|
918
|
+
this._emit('message', text);
|
|
919
|
+
this._emit('text', text);
|
|
920
|
+
break;
|
|
921
|
+
}
|
|
922
|
+
case 0x02: { // 二进制帧
|
|
923
|
+
this._emit('message', payload);
|
|
924
|
+
this._emit('binary', payload);
|
|
925
|
+
break;
|
|
926
|
+
}
|
|
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;
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* 发送数据,自动区分文本、JSON 对象、二进制 Buffer
|
|
943
|
+
*/
|
|
944
|
+
send(data) {
|
|
945
|
+
if (!this.connected) return;
|
|
946
|
+
if (typeof data === 'object' && !Buffer.isBuffer(data)) {
|
|
947
|
+
this._sendFrame(0x01, Buffer.from(JSON.stringify(data)));
|
|
948
|
+
} else if (typeof data === 'string') {
|
|
949
|
+
this._sendFrame(0x01, Buffer.from(data));
|
|
950
|
+
} else if (Buffer.isBuffer(data)) {
|
|
951
|
+
this._sendFrame(0x02, data);
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/**
|
|
956
|
+
* 构造并发送 WebSocket 帧
|
|
957
|
+
*/
|
|
958
|
+
_sendFrame(opcode, payload) {
|
|
959
|
+
if (!this.connected) return;
|
|
960
|
+
const frames = [];
|
|
961
|
+
|
|
962
|
+
// 第一字节:FIN + opcode
|
|
963
|
+
frames.push(Buffer.from([0x80 | opcode]));
|
|
964
|
+
|
|
965
|
+
// 第二字节:长度(服务端发送不掩码)
|
|
966
|
+
if (payload.length < 126) {
|
|
967
|
+
frames.push(Buffer.from([payload.length]));
|
|
968
|
+
} else if (payload.length < 65536) {
|
|
969
|
+
const buf = Buffer.alloc(3);
|
|
970
|
+
buf[0] = 126;
|
|
971
|
+
buf.writeUInt16BE(payload.length, 1);
|
|
972
|
+
frames.push(buf);
|
|
973
|
+
} else {
|
|
974
|
+
const buf = Buffer.alloc(9);
|
|
975
|
+
buf[0] = 127;
|
|
976
|
+
buf.writeBigUInt64BE(BigInt(payload.length), 1);
|
|
977
|
+
frames.push(buf);
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
frames.push(payload);
|
|
981
|
+
const frame = Buffer.concat(frames);
|
|
982
|
+
try {
|
|
983
|
+
this.socket.write(frame);
|
|
984
|
+
} catch (e) {
|
|
985
|
+
this.connected = false;
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/**
|
|
990
|
+
* 发送 Ping 帧
|
|
991
|
+
*/
|
|
992
|
+
_sendPing() {
|
|
993
|
+
this._sendFrame(0x09, Buffer.alloc(0));
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/**
|
|
997
|
+
* 发送 Pong 帧
|
|
998
|
+
*/
|
|
999
|
+
_sendPong(payload) {
|
|
1000
|
+
this._sendFrame(0x0A, payload || Buffer.alloc(0));
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* 发送关闭帧
|
|
1005
|
+
*/
|
|
1006
|
+
_sendCloseFrame() {
|
|
1007
|
+
try {
|
|
1008
|
+
this._sendFrame(0x08, Buffer.alloc(0));
|
|
1009
|
+
} catch (e) {
|
|
1010
|
+
// 忽略关闭帧发送错误
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/**
|
|
1015
|
+
* 关闭连接
|
|
1016
|
+
*/
|
|
1017
|
+
close() {
|
|
1018
|
+
this._sendCloseFrame();
|
|
1019
|
+
this.connected = false;
|
|
1020
|
+
try { this.socket.end(); } catch (e) { /* 忽略 */ }
|
|
1021
|
+
this._emit('close');
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
/**
|
|
1025
|
+
* 事件注册
|
|
1026
|
+
*/
|
|
1027
|
+
on(event, handler) {
|
|
1028
|
+
if (!this._handlers[event]) this._handlers[event] = [];
|
|
1029
|
+
this._handlers[event].push(handler);
|
|
1030
|
+
return this;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/**
|
|
1034
|
+
* 触发事件
|
|
1035
|
+
*/
|
|
1036
|
+
_emit(event, ...args) {
|
|
1037
|
+
const handlers = this._handlers[event];
|
|
1038
|
+
if (handlers) {
|
|
1039
|
+
handlers.forEach(h => {
|
|
1040
|
+
try { h(...args); } catch (e) { /* 忽略 */ }
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// ============================================================
|
|
1047
|
+
// WebSocketServer 类
|
|
1048
|
+
// ============================================================
|
|
1049
|
+
|
|
1050
|
+
/**
|
|
1051
|
+
* WebSocket 握手辅助函数:计算 Sec-WebSocket-Accept 值
|
|
1052
|
+
*/
|
|
1053
|
+
function WebSocketHandShark(key) {
|
|
1054
|
+
return crypto.createHash('sha1')
|
|
1055
|
+
.update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11')
|
|
1056
|
+
.digest('base64');
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
class WebSocketServer {
|
|
1060
|
+
constructor(options = {}) {
|
|
1061
|
+
this.connections = new Map(); // id -> WebSocket
|
|
1062
|
+
this.groups = new Map(); // path -> Set<WebSocket>
|
|
1063
|
+
this._heartbeatInterval = options.heartbeatInterval || 30000;
|
|
1064
|
+
this._heartbeatTimeout = options.heartbeatTimeout || 30000;
|
|
1065
|
+
this._timer = null;
|
|
1066
|
+
this._handlers = {};
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
/**
|
|
1070
|
+
* 处理新的 WebSocket 升级请求
|
|
1071
|
+
*/
|
|
1072
|
+
handleUpgrade(req, socket, head) {
|
|
1073
|
+
const pathname = parseUrl(req.url).pathname;
|
|
1074
|
+
|
|
1075
|
+
// 执行 WebSocket 握手
|
|
1076
|
+
const key = req.headers['sec-websocket-key'];
|
|
1077
|
+
if (!key) {
|
|
1078
|
+
socket.destroy();
|
|
1079
|
+
return null;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
const accept = crypto.createHash('sha1')
|
|
1083
|
+
.update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11')
|
|
1084
|
+
.digest('base64');
|
|
1085
|
+
|
|
1086
|
+
// 发送握手响应
|
|
1087
|
+
const responseHeaders = [
|
|
1088
|
+
'HTTP/1.1 101 Switching Protocols',
|
|
1089
|
+
'Upgrade: websocket',
|
|
1090
|
+
'Connection: Upgrade',
|
|
1091
|
+
`Sec-WebSocket-Accept: ${accept}`
|
|
1092
|
+
];
|
|
1093
|
+
socket.write(responseHeaders.join('\r\n') + '\r\n\r\n');
|
|
1094
|
+
|
|
1095
|
+
// 创建 WebSocket 实例
|
|
1096
|
+
const ws = new WebSocket(socket, pathname);
|
|
1097
|
+
this.connections.set(ws.id, ws);
|
|
1098
|
+
|
|
1099
|
+
// 按路径分组
|
|
1100
|
+
if (!this.groups.has(pathname)) {
|
|
1101
|
+
this.groups.set(pathname, new Set());
|
|
1102
|
+
}
|
|
1103
|
+
this.groups.get(pathname).add(ws);
|
|
1104
|
+
|
|
1105
|
+
// 启动心跳
|
|
1106
|
+
this._startHeartbeat();
|
|
1107
|
+
|
|
1108
|
+
// 监听连接关闭,自动清理
|
|
1109
|
+
ws.on('close', () => {
|
|
1110
|
+
this._removeConnection(ws);
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1113
|
+
// 触发 connection 事件
|
|
1114
|
+
this._emit('connection', ws, req);
|
|
1115
|
+
|
|
1116
|
+
return ws;
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
/**
|
|
1120
|
+
* 移除连接
|
|
1121
|
+
*/
|
|
1122
|
+
_removeConnection(ws) {
|
|
1123
|
+
this.connections.delete(ws.id);
|
|
1124
|
+
const group = this.groups.get(ws.path);
|
|
1125
|
+
if (group) {
|
|
1126
|
+
group.delete(ws);
|
|
1127
|
+
if (group.size === 0) {
|
|
1128
|
+
this.groups.delete(ws.path);
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
// 无连接时停止心跳
|
|
1132
|
+
if (this.connections.size === 0) {
|
|
1133
|
+
this._stopHeartbeat();
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/**
|
|
1138
|
+
* 启动全局心跳定时器
|
|
1139
|
+
*/
|
|
1140
|
+
_startHeartbeat() {
|
|
1141
|
+
if (this._timer) return;
|
|
1142
|
+
this._timer = setInterval(() => {
|
|
1143
|
+
const now = Date.now();
|
|
1144
|
+
for (const ws of this.connections.values()) {
|
|
1145
|
+
if (!ws.connected) {
|
|
1146
|
+
this._removeConnection(ws);
|
|
1147
|
+
continue;
|
|
1148
|
+
}
|
|
1149
|
+
// 检查心跳超时
|
|
1150
|
+
if (now - ws._lastHeartbeat > this._heartbeatInterval + this._heartbeatTimeout) {
|
|
1151
|
+
ws.close();
|
|
1152
|
+
this._removeConnection(ws);
|
|
1153
|
+
continue;
|
|
1154
|
+
}
|
|
1155
|
+
ws._sendPing();
|
|
1156
|
+
}
|
|
1157
|
+
}, this._heartbeatInterval);
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
/**
|
|
1161
|
+
* 停止心跳定时器
|
|
1162
|
+
*/
|
|
1163
|
+
_stopHeartbeat() {
|
|
1164
|
+
if (this._timer) {
|
|
1165
|
+
clearInterval(this._timer);
|
|
1166
|
+
this._timer = null;
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
/**
|
|
1171
|
+
* 按路径广播消息
|
|
1172
|
+
*/
|
|
1173
|
+
broadcastTo(pathStr, data, exclude = null) {
|
|
1174
|
+
const group = this.groups.get(pathStr);
|
|
1175
|
+
if (!group) return;
|
|
1176
|
+
for (const ws of group) {
|
|
1177
|
+
if (ws.connected && ws !== exclude) {
|
|
1178
|
+
ws.send(data);
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
/**
|
|
1184
|
+
* 全局广播消息
|
|
1185
|
+
*/
|
|
1186
|
+
broadcast(data, exclude = null) {
|
|
1187
|
+
for (const ws of this.connections.values()) {
|
|
1188
|
+
if (ws.connected && ws !== exclude) {
|
|
1189
|
+
ws.send(data);
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
/**
|
|
1195
|
+
* 获取指定路径的所有连接
|
|
1196
|
+
*/
|
|
1197
|
+
getConnections(pathStr) {
|
|
1198
|
+
if (pathStr) {
|
|
1199
|
+
return Array.from(this.groups.get(pathStr) || []);
|
|
1200
|
+
}
|
|
1201
|
+
return Array.from(this.connections.values());
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
/**
|
|
1205
|
+
* 事件注册
|
|
1206
|
+
*/
|
|
1207
|
+
on(event, handler) {
|
|
1208
|
+
if (!this._handlers[event]) this._handlers[event] = [];
|
|
1209
|
+
this._handlers[event].push(handler);
|
|
1210
|
+
return this;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
/**
|
|
1214
|
+
* 触发事件
|
|
1215
|
+
*/
|
|
1216
|
+
_emit(event, ...args) {
|
|
1217
|
+
const handlers = this._handlers[event];
|
|
1218
|
+
if (handlers) {
|
|
1219
|
+
handlers.forEach(h => {
|
|
1220
|
+
try { h(...args); } catch (e) { /* 忽略 */ }
|
|
1221
|
+
});
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
// ============================================================
|
|
1227
|
+
// 内置中间件
|
|
1228
|
+
// ============================================================
|
|
1229
|
+
|
|
1230
|
+
/**
|
|
1231
|
+
* bodyParser 中间件:解析各类请求体
|
|
1232
|
+
*/
|
|
1233
|
+
function bodyParser(options = {}) {
|
|
1234
|
+
const maxFileSize = options.maxFileSize || 128 * 1024 * 1024;
|
|
1235
|
+
const maxFieldSize = options.maxFieldSize || 1024 * 1024;
|
|
1236
|
+
|
|
1237
|
+
return function bodyParserMiddleware(req, res, next) {
|
|
1238
|
+
// 初始化 formData
|
|
1239
|
+
req.formData = { fields: { ...req.query }, files: [] };
|
|
1240
|
+
req.files = [];
|
|
1241
|
+
req._tempFiles = [];
|
|
1242
|
+
|
|
1243
|
+
const contentType = req.headers['content-type'] || '';
|
|
1244
|
+
|
|
1245
|
+
// 有 Content-Type 才尝试解析,否则跳过
|
|
1246
|
+
if (!contentType) {
|
|
1247
|
+
req.body = null;
|
|
1248
|
+
next();
|
|
1249
|
+
return;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
if (contentType.includes('application/json')) {
|
|
1253
|
+
_parseJSON(req, maxFieldSize, next);
|
|
1254
|
+
} else if (contentType.includes('application/x-www-form-urlencoded')) {
|
|
1255
|
+
_parseUrlencoded(req, maxFieldSize, next);
|
|
1256
|
+
} else if (contentType.includes('multipart/form-data')) {
|
|
1257
|
+
const boundary = _extractBoundary(contentType);
|
|
1258
|
+
if (boundary) {
|
|
1259
|
+
_parseMultipart(req, boundary, maxFileSize, maxFieldSize, next);
|
|
1260
|
+
} else {
|
|
1261
|
+
next();
|
|
1262
|
+
}
|
|
1263
|
+
} else {
|
|
1264
|
+
// 其他类型:原始 Buffer 存储
|
|
1265
|
+
req._readBody().then(buf => {
|
|
1266
|
+
req.body = buf.length > 0 ? buf : null;
|
|
1267
|
+
next();
|
|
1268
|
+
}).catch(next);
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
// 监听响应完成,自动清理临时文件
|
|
1272
|
+
res._res.on('finish', () => {
|
|
1273
|
+
_cleanupTempFiles(req._tempFiles);
|
|
1274
|
+
});
|
|
1275
|
+
};
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
/**
|
|
1279
|
+
* 解析 JSON 请求体
|
|
1280
|
+
*/
|
|
1281
|
+
function _parseJSON(req, maxSize, next) {
|
|
1282
|
+
req._readBody().then(buf => {
|
|
1283
|
+
if (buf.length > maxSize) {
|
|
1284
|
+
const err = new Error(`Body exceeds maximum size of ${fmtSize(maxSize)}`);
|
|
1285
|
+
err.status = 413;
|
|
1286
|
+
next(err);
|
|
1287
|
+
return;
|
|
1288
|
+
}
|
|
1289
|
+
try {
|
|
1290
|
+
req.body = JSON.parse(buf.toString('utf8'));
|
|
1291
|
+
// 合并到 formData.fields
|
|
1292
|
+
if (typeof req.body === 'object' && req.body !== null) {
|
|
1293
|
+
Object.assign(req.formData.fields, req.body);
|
|
1294
|
+
}
|
|
1295
|
+
} catch (e) {
|
|
1296
|
+
req.body = null;
|
|
1297
|
+
}
|
|
1298
|
+
next();
|
|
1299
|
+
}).catch(next);
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
/**
|
|
1303
|
+
* 解析 URL 编码请求体
|
|
1304
|
+
*/
|
|
1305
|
+
function _parseUrlencoded(req, maxSize, next) {
|
|
1306
|
+
req._readBody().then(buf => {
|
|
1307
|
+
if (buf.length > maxSize) {
|
|
1308
|
+
const err = new Error(`Body exceeds maximum size of ${fmtSize(maxSize)}`);
|
|
1309
|
+
err.status = 413;
|
|
1310
|
+
next(err);
|
|
1311
|
+
return;
|
|
1312
|
+
}
|
|
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
|
+
});
|
|
1323
|
+
req.body = parsed;
|
|
1324
|
+
Object.assign(req.formData.fields, parsed);
|
|
1325
|
+
next();
|
|
1326
|
+
}).catch(next);
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
/**
|
|
1330
|
+
* 从 Content-Type 中提取 boundary
|
|
1331
|
+
*/
|
|
1332
|
+
function _extractBoundary(contentType) {
|
|
1333
|
+
const match = contentType.match(/boundary=(?:"([^"]+)"|([^\s;]+))/);
|
|
1334
|
+
return match ? (match[1] || match[2]) : null;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
/**
|
|
1338
|
+
* 流式解析 multipart/form-data
|
|
1339
|
+
* 基于状态机实现,边接收边解析边写入临时文件
|
|
1340
|
+
*/
|
|
1341
|
+
function _parseMultipart(req, boundary, maxFileSize, maxFieldSize, next) {
|
|
1342
|
+
const tempDir = req._app?.settings?.tempDir || 'tempupdir';
|
|
1343
|
+
const delimiter = Buffer.from('--' + boundary);
|
|
1344
|
+
const endDelimiter = Buffer.from('--' + boundary + '--');
|
|
1345
|
+
let state = 'INIT'; // INIT, HEADERS, BODY_FIELD, BODY_FILE
|
|
1346
|
+
let partHeadersBuf = Buffer.alloc(0);
|
|
1347
|
+
let currentField = { name: '', value: '' };
|
|
1348
|
+
let currentFile = { name: '', filename: '', contentType: '', size: 0, path: '', stream: null };
|
|
1349
|
+
let fieldSize = 0;
|
|
1350
|
+
let fileSize = 0;
|
|
1351
|
+
let buffer = Buffer.alloc(0);
|
|
1352
|
+
let cleanupOnError = false;
|
|
1353
|
+
|
|
1354
|
+
// 确保临时目录存在
|
|
1355
|
+
if (!fs.existsSync(tempDir)) {
|
|
1356
|
+
fs.mkdirSync(tempDir, { recursive: true });
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
function processBuffer() {
|
|
1360
|
+
while (buffer.length > 0) {
|
|
1361
|
+
if (state === 'INIT') {
|
|
1362
|
+
// 查找第一个分隔符
|
|
1363
|
+
const idx = buffer.indexOf(delimiter);
|
|
1364
|
+
if (idx === -1) break;
|
|
1365
|
+
buffer = buffer.slice(idx + delimiter.length);
|
|
1366
|
+
// 跳过 \r\n
|
|
1367
|
+
if (buffer.length >= 2 && buffer[0] === 0x0D && buffer[1] === 0x0A) {
|
|
1368
|
+
buffer = buffer.slice(2);
|
|
1369
|
+
}
|
|
1370
|
+
state = 'HEADERS';
|
|
1371
|
+
partHeadersBuf = Buffer.alloc(0);
|
|
1372
|
+
} else if (state === 'HEADERS') {
|
|
1373
|
+
// 查找头部结束标记 \r\n\r\n
|
|
1374
|
+
const headerEnd = buffer.indexOf('\r\n\r\n');
|
|
1375
|
+
if (headerEnd === -1) {
|
|
1376
|
+
// 累积 Buffer 避免多字节截断
|
|
1377
|
+
partHeadersBuf = Buffer.concat([partHeadersBuf, buffer]);
|
|
1378
|
+
buffer = Buffer.alloc(0);
|
|
1379
|
+
break;
|
|
1380
|
+
}
|
|
1381
|
+
partHeadersBuf = Buffer.concat([partHeadersBuf, buffer.slice(0, headerEnd)]);
|
|
1382
|
+
buffer = buffer.slice(headerEnd + 4);
|
|
1383
|
+
const partHeaders = partHeadersBuf.toString('utf8');
|
|
1384
|
+
|
|
1385
|
+
// 解析 Content-Disposition
|
|
1386
|
+
const nameMatch = partHeaders.match(/name="([^"]+)"/);
|
|
1387
|
+
const filenameMatch = partHeaders.match(/filename="([^"]+)"/);
|
|
1388
|
+
const ctMatch = partHeaders.match(/Content-Type:\s*(.+)/i);
|
|
1389
|
+
|
|
1390
|
+
if (filenameMatch) {
|
|
1391
|
+
// 文件字段
|
|
1392
|
+
currentFile = {
|
|
1393
|
+
name: nameMatch ? nameMatch[1] : '',
|
|
1394
|
+
filename: filenameMatch[1],
|
|
1395
|
+
contentType: ctMatch ? ctMatch[1].trim() : 'application/octet-stream',
|
|
1396
|
+
size: 0,
|
|
1397
|
+
path: path.join(tempDir, `${uid()}_${filenameMatch[1]}`),
|
|
1398
|
+
stream: null
|
|
1399
|
+
};
|
|
1400
|
+
fileSize = 0;
|
|
1401
|
+
state = 'BODY_FILE';
|
|
1402
|
+
cleanupOnError = true;
|
|
1403
|
+
} else {
|
|
1404
|
+
// 普通字段
|
|
1405
|
+
currentField = { name: nameMatch ? nameMatch[1] : '', value: '' };
|
|
1406
|
+
fieldSize = 0;
|
|
1407
|
+
state = 'BODY_FIELD';
|
|
1408
|
+
}
|
|
1409
|
+
} else if (state === 'BODY_FIELD') {
|
|
1410
|
+
// 查找分隔符(字段结束)
|
|
1411
|
+
const idx = buffer.indexOf(delimiter);
|
|
1412
|
+
if (idx === -1) {
|
|
1413
|
+
// 还没结束,缓存数据(但检查大小限制)
|
|
1414
|
+
const chunk = buffer.toString('utf8');
|
|
1415
|
+
fieldSize += chunk.length;
|
|
1416
|
+
if (fieldSize > maxFieldSize) {
|
|
1417
|
+
const err = new Error(`Field exceeds maximum size of ${fmtSize(maxFieldSize)}`);
|
|
1418
|
+
err.status = 413;
|
|
1419
|
+
next(err);
|
|
1420
|
+
return;
|
|
1421
|
+
}
|
|
1422
|
+
currentField.value += chunk;
|
|
1423
|
+
buffer = Buffer.alloc(0);
|
|
1424
|
+
break;
|
|
1425
|
+
}
|
|
1426
|
+
// 字段结束
|
|
1427
|
+
const chunk = buffer.toString('utf8', 0, idx);
|
|
1428
|
+
fieldSize += chunk.length;
|
|
1429
|
+
if (fieldSize > maxFieldSize) {
|
|
1430
|
+
const err = new Error(`Field exceeds maximum size of ${fmtSize(maxFieldSize)}`);
|
|
1431
|
+
err.status = 413;
|
|
1432
|
+
next(err);
|
|
1433
|
+
return;
|
|
1434
|
+
}
|
|
1435
|
+
currentField.value += chunk;
|
|
1436
|
+
// 去掉末尾的 \r\n
|
|
1437
|
+
if (currentField.value.endsWith('\r\n')) {
|
|
1438
|
+
currentField.value = currentField.value.slice(0, -2);
|
|
1439
|
+
}
|
|
1440
|
+
req.formData.fields[currentField.name] = currentField.value;
|
|
1441
|
+
buffer = buffer.slice(idx + delimiter.length);
|
|
1442
|
+
// 跳过 \r\n
|
|
1443
|
+
if (buffer.length >= 2 && buffer[0] === 0x0D && buffer[1] === 0x0A) {
|
|
1444
|
+
buffer = buffer.slice(2);
|
|
1445
|
+
}
|
|
1446
|
+
state = 'HEADERS';
|
|
1447
|
+
partHeadersBuf = Buffer.alloc(0);
|
|
1448
|
+
} else if (state === 'BODY_FILE') {
|
|
1449
|
+
// 查找分隔符(文件结束)
|
|
1450
|
+
const idx = buffer.indexOf(delimiter);
|
|
1451
|
+
if (idx === -1) {
|
|
1452
|
+
// 还没结束,写入临时文件
|
|
1453
|
+
if (!currentFile.stream) {
|
|
1454
|
+
currentFile.stream = fs.createWriteStream(currentFile.path);
|
|
1455
|
+
}
|
|
1456
|
+
currentFile.stream.write(buffer);
|
|
1457
|
+
fileSize += buffer.length;
|
|
1458
|
+
currentFile.size = fileSize;
|
|
1459
|
+
if (fileSize > maxFileSize) {
|
|
1460
|
+
if (currentFile.stream) currentFile.stream.close();
|
|
1461
|
+
const err = new Error(`File exceeds maximum size of ${fmtSize(maxFileSize)}`);
|
|
1462
|
+
err.status = 413;
|
|
1463
|
+
next(err);
|
|
1464
|
+
return;
|
|
1465
|
+
}
|
|
1466
|
+
// 进度展示(>1MB)
|
|
1467
|
+
if (fileSize > 1024 * 1024) {
|
|
1468
|
+
_showUploadProgress(currentFile.filename, fileSize, maxFileSize);
|
|
1469
|
+
}
|
|
1470
|
+
buffer = Buffer.alloc(0);
|
|
1471
|
+
break;
|
|
1472
|
+
}
|
|
1473
|
+
// 文件结束
|
|
1474
|
+
const fileData = buffer.slice(0, idx);
|
|
1475
|
+
// 去掉文件数据前的 \r\n
|
|
1476
|
+
const trimmedData = fileData.length >= 2 && fileData[fileData.length - 2] === 0x0D && fileData[fileData.length - 1] === 0x0A
|
|
1477
|
+
? fileData.slice(0, -2)
|
|
1478
|
+
: fileData;
|
|
1479
|
+
|
|
1480
|
+
if (!currentFile.stream) {
|
|
1481
|
+
currentFile.stream = fs.createWriteStream(currentFile.path);
|
|
1482
|
+
}
|
|
1483
|
+
currentFile.stream.write(trimmedData);
|
|
1484
|
+
currentFile.stream.end();
|
|
1485
|
+
fileSize += trimmedData.length;
|
|
1486
|
+
currentFile.size = fileSize;
|
|
1487
|
+
|
|
1488
|
+
// 保存文件信息
|
|
1489
|
+
const fileInfo = {
|
|
1490
|
+
fieldname: currentFile.name,
|
|
1491
|
+
originalname: currentFile.filename,
|
|
1492
|
+
encoding: '7bit',
|
|
1493
|
+
mimetype: currentFile.contentType,
|
|
1494
|
+
size: currentFile.size,
|
|
1495
|
+
path: currentFile.path
|
|
1496
|
+
};
|
|
1497
|
+
req.formData.files.push(fileInfo);
|
|
1498
|
+
req.files.push(fileInfo);
|
|
1499
|
+
req._tempFiles.push(currentFile.path);
|
|
1500
|
+
|
|
1501
|
+
cleanupOnError = false;
|
|
1502
|
+
buffer = buffer.slice(idx + delimiter.length);
|
|
1503
|
+
// 跳过 \r\n
|
|
1504
|
+
if (buffer.length >= 2 && buffer[0] === 0x0D && buffer[1] === 0x0A) {
|
|
1505
|
+
buffer = buffer.slice(2);
|
|
1506
|
+
}
|
|
1507
|
+
state = 'HEADERS';
|
|
1508
|
+
partHeadersBuf = Buffer.alloc(0);
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
// 监听请求数据流
|
|
1514
|
+
req._req.on('data', chunk => {
|
|
1515
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
1516
|
+
processBuffer();
|
|
1517
|
+
});
|
|
1518
|
+
|
|
1519
|
+
req._req.on('end', () => {
|
|
1520
|
+
// 处理剩余缓冲区
|
|
1521
|
+
processBuffer();
|
|
1522
|
+
req.body = req.formData.fields;
|
|
1523
|
+
next();
|
|
1524
|
+
});
|
|
1525
|
+
|
|
1526
|
+
req._req.on('error', (err) => {
|
|
1527
|
+
// 客户端断开,清理临时文件
|
|
1528
|
+
if (cleanupOnError && currentFile.stream) {
|
|
1529
|
+
currentFile.stream.close();
|
|
1530
|
+
}
|
|
1531
|
+
_cleanupTempFiles(req._tempFiles);
|
|
1532
|
+
next(err);
|
|
1533
|
+
});
|
|
1534
|
+
}
|
|
1535
|
+
|
|
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
|
+
/**
|
|
1550
|
+
* 清理临时文件
|
|
1551
|
+
*/
|
|
1552
|
+
function _cleanupTempFiles(files) {
|
|
1553
|
+
if (!files || files.length === 0) return;
|
|
1554
|
+
files.forEach(f => {
|
|
1555
|
+
try {
|
|
1556
|
+
if (fs.existsSync(f)) fs.unlinkSync(f);
|
|
1557
|
+
} catch (e) { /* 忽略清理失败 */ }
|
|
1558
|
+
});
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
/**
|
|
1562
|
+
* cookieParser 中间件:解析 Cookie
|
|
1563
|
+
*/
|
|
1564
|
+
function cookieParser(secret) {
|
|
1565
|
+
return function cookieParserMiddleware(req, res, next) {
|
|
1566
|
+
const cookieHeader = req.headers['cookie'] || '';
|
|
1567
|
+
req.cookies = parseCookies(cookieHeader);
|
|
1568
|
+
|
|
1569
|
+
// 如果有 secret,对 Cookie 进行签名验证
|
|
1570
|
+
if (secret) {
|
|
1571
|
+
req.signedCookies = {};
|
|
1572
|
+
for (const [key, val] of Object.entries(req.cookies)) {
|
|
1573
|
+
if (val.startsWith('s:')) {
|
|
1574
|
+
// 签名 Cookie 格式: s:value.signature
|
|
1575
|
+
const unsigned = val.slice(2);
|
|
1576
|
+
const dotIdx = unsigned.lastIndexOf('.');
|
|
1577
|
+
if (dotIdx !== -1) {
|
|
1578
|
+
const value = unsigned.slice(0, dotIdx);
|
|
1579
|
+
const sig = unsigned.slice(dotIdx + 1);
|
|
1580
|
+
const expected = crypto.createHmac('sha256', secret).update(value).digest('base64').replace(/=+$/, '');
|
|
1581
|
+
if (sig === expected) {
|
|
1582
|
+
req.signedCookies[key] = value;
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
next();
|
|
1589
|
+
};
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
// ============================================================
|
|
1593
|
+
// Application 类
|
|
1594
|
+
// ============================================================
|
|
1595
|
+
|
|
1596
|
+
class Application extends Router {
|
|
1597
|
+
constructor(options = {}) {
|
|
1598
|
+
super();
|
|
1599
|
+
// 配置加载优先级:默认配置 → app.json → 代码初始化参数 → 运行时 app.set()
|
|
1600
|
+
const fileConfig = _loadAppJson();
|
|
1601
|
+
this.settings = { ...defaultConfig, ...fileConfig, ...options };
|
|
1602
|
+
this.server = null;
|
|
1603
|
+
this._wss = null;
|
|
1604
|
+
this._logger = new Logger({
|
|
1605
|
+
level: this.settings.logLevel,
|
|
1606
|
+
logDir: this.settings.logDir
|
|
1607
|
+
});
|
|
1608
|
+
|
|
1609
|
+
// 自动注册内置中间件
|
|
1610
|
+
if (this.settings.useBodyParser !== false) {
|
|
1611
|
+
this.use(bodyParser(this.settings.bodyParserOptions || {}));
|
|
1612
|
+
}
|
|
1613
|
+
if (this.settings.useCookieParser !== false) {
|
|
1614
|
+
this.use(cookieParser(this.settings.cookieParserSecret));
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
/**
|
|
1619
|
+
* 设置全局运行时配置
|
|
1620
|
+
*/
|
|
1621
|
+
set(name, value) {
|
|
1622
|
+
if (value === undefined) return this.settings[name];
|
|
1623
|
+
this.settings[name] = value;
|
|
1624
|
+
// 同步更新日志级别
|
|
1625
|
+
if (name === 'logLevel') {
|
|
1626
|
+
this._logger.level = value;
|
|
1627
|
+
}
|
|
1628
|
+
return this;
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
/**
|
|
1632
|
+
* 获取配置(当参数为字符串且非路由路径时)或注册 GET 路由
|
|
1633
|
+
*/
|
|
1634
|
+
get(...args) {
|
|
1635
|
+
// 单个字符串参数且不是路径格式 → 获取配置
|
|
1636
|
+
if (args.length === 1 && typeof args[0] === 'string') {
|
|
1637
|
+
return this.settings[args[0]];
|
|
1638
|
+
}
|
|
1639
|
+
// 否则调用 Router 的 get 方法注册路由
|
|
1640
|
+
return Router.prototype.get.apply(this, args);
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
/**
|
|
1644
|
+
* 启动网络服务,监听端口
|
|
1645
|
+
*/
|
|
1646
|
+
listen(port, callback) {
|
|
1647
|
+
const listenPort = port || this.settings.svrPort;
|
|
1648
|
+
const ip = this.settings.svrIP;
|
|
1649
|
+
|
|
1650
|
+
// 创建服务器
|
|
1651
|
+
if (this.settings.http2) {
|
|
1652
|
+
// HTTP2 模式
|
|
1653
|
+
if (!this.settings.https || !this.settings.https.key || !this.settings.https.cert) {
|
|
1654
|
+
throw new Error('HTTP2 requires HTTPS configuration (key and cert)');
|
|
1655
|
+
}
|
|
1656
|
+
const opts = {
|
|
1657
|
+
key: fs.readFileSync(this.settings.https.key),
|
|
1658
|
+
cert: fs.readFileSync(this.settings.https.cert),
|
|
1659
|
+
allowHTTP1: true
|
|
1660
|
+
};
|
|
1661
|
+
if (this.settings.https.ca) opts.ca = fs.readFileSync(this.settings.https.ca);
|
|
1662
|
+
this.server = http2.createSecureServer(opts, this._handleRequest.bind(this));
|
|
1663
|
+
} else if (this.settings.https && this.settings.https.key && this.settings.https.cert) {
|
|
1664
|
+
// HTTPS 模式
|
|
1665
|
+
const opts = {
|
|
1666
|
+
key: fs.readFileSync(this.settings.https.key),
|
|
1667
|
+
cert: fs.readFileSync(this.settings.https.cert)
|
|
1668
|
+
};
|
|
1669
|
+
if (this.settings.https.ca) opts.ca = fs.readFileSync(this.settings.https.ca);
|
|
1670
|
+
if (this.settings.https.pfx) opts.pfx = fs.readFileSync(this.settings.https.pfx);
|
|
1671
|
+
this.server = https.createServer(opts, this._handleRequest.bind(this));
|
|
1672
|
+
} else {
|
|
1673
|
+
// HTTP 模式
|
|
1674
|
+
this.server = http.createServer(this._handleRequest.bind(this));
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
// 设置超时
|
|
1678
|
+
if (this.settings.timeout) {
|
|
1679
|
+
this.server.timeout = this.settings.timeout;
|
|
1680
|
+
}
|
|
1681
|
+
if (this.settings.keepAliveTimeout) {
|
|
1682
|
+
this.server.keepAliveTimeout = this.settings.keepAliveTimeout;
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
// 初始化 WebSocket 服务
|
|
1686
|
+
this._wss = new WebSocketServer({
|
|
1687
|
+
heartbeatInterval: this.settings.wsHeartbeatInterval,
|
|
1688
|
+
heartbeatTimeout: this.settings.wsHeartbeatTimeout
|
|
1689
|
+
});
|
|
1690
|
+
|
|
1691
|
+
// 监听 WebSocket 升级请求
|
|
1692
|
+
this.server.on('upgrade', (req, socket, head) => {
|
|
1693
|
+
this._handleUpgrade(req, socket, head);
|
|
1694
|
+
});
|
|
1695
|
+
|
|
1696
|
+
// 监听端口
|
|
1697
|
+
this.server.listen(listenPort, ip, () => {
|
|
1698
|
+
const addr = this.server.address();
|
|
1699
|
+
const protocol = this.settings.https ? 'https' : 'http';
|
|
1700
|
+
this._logger.info(`Server running at ${protocol}://${addr.address === '::' ? 'localhost' : addr.address}:${addr.port}/`);
|
|
1701
|
+
if (callback) callback();
|
|
1702
|
+
});
|
|
1703
|
+
|
|
1704
|
+
return this.server;
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
/**
|
|
1708
|
+
* 停止服务,释放端口资源
|
|
1709
|
+
*/
|
|
1710
|
+
close(callback) {
|
|
1711
|
+
if (this._wss) {
|
|
1712
|
+
// 关闭所有 WebSocket 连接
|
|
1713
|
+
for (const ws of this._wss.connections.values()) {
|
|
1714
|
+
ws.close();
|
|
1715
|
+
}
|
|
1716
|
+
this._wss._stopHeartbeat();
|
|
1717
|
+
}
|
|
1718
|
+
if (this.server) {
|
|
1719
|
+
this.server.close(callback);
|
|
1720
|
+
} else if (callback) {
|
|
1721
|
+
callback();
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
/**
|
|
1726
|
+
* 处理 HTTP 请求(核心入口)
|
|
1727
|
+
*/
|
|
1728
|
+
_handleRequest(incomingMessage, serverResponse) {
|
|
1729
|
+
const req = new Request(incomingMessage);
|
|
1730
|
+
const res = new Response(serverResponse, this);
|
|
1731
|
+
req._app = this;
|
|
1732
|
+
req._res = res;
|
|
1733
|
+
res._req = req;
|
|
1734
|
+
|
|
1735
|
+
// 基础解析
|
|
1736
|
+
const parsed = parseUrl(incomingMessage.url);
|
|
1737
|
+
req.path = decodeURIComponent(parsed.pathname);
|
|
1738
|
+
req.query = parsed.query;
|
|
1739
|
+
|
|
1740
|
+
// 解析 Cookie(中间件会再次处理,此处先做基础解析)
|
|
1741
|
+
req.cookies = parseCookies(incomingMessage.headers['cookie'] || '');
|
|
1742
|
+
|
|
1743
|
+
// 构建中间件 + 路由处理器执行链
|
|
1744
|
+
this._dispatch(req, res);
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
/**
|
|
1748
|
+
* 分发请求到中间件链和路由处理器
|
|
1749
|
+
*/
|
|
1750
|
+
_dispatch(req, res) {
|
|
1751
|
+
// 收集所有匹配的中间件和路由
|
|
1752
|
+
const stack = [];
|
|
1753
|
+
|
|
1754
|
+
// 1. 应用级和路径级中间件(跳过错误处理中间件,4参数函数)
|
|
1755
|
+
const mwMatches = this.matchMiddleware(req.path);
|
|
1756
|
+
for (const mw of mwMatches) {
|
|
1757
|
+
for (const handler of mw.middleware.handlers) {
|
|
1758
|
+
if (handler.length === 4) continue; // 错误处理中间件不在正常链中执行
|
|
1759
|
+
stack.push({ handler, params: mw.params });
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
// 2. 路由处理器
|
|
1764
|
+
const routeMatches = this.match(req.method, req.path);
|
|
1765
|
+
for (const match of routeMatches) {
|
|
1766
|
+
Object.assign(req.params, match.params);
|
|
1767
|
+
for (const handler of match.handlers) {
|
|
1768
|
+
stack.push({ handler, params: match.params });
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
let idx = 0;
|
|
1773
|
+
|
|
1774
|
+
const next = (err) => {
|
|
1775
|
+
// 错误处理:跳到错误处理中间件
|
|
1776
|
+
if (err) {
|
|
1777
|
+
this._handleError(err, req, res, stack, idx);
|
|
1778
|
+
return;
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
if (idx >= stack.length) {
|
|
1782
|
+
// 中间件和路由都执行完毕,进入默认兜底处理
|
|
1783
|
+
this._defaultHandler(req, res);
|
|
1784
|
+
return;
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
const item = stack[idx++];
|
|
1788
|
+
const handler = item.handler;
|
|
1789
|
+
|
|
1790
|
+
try {
|
|
1791
|
+
// 路由处理器返回 false → 进入静态文件兜底
|
|
1792
|
+
const result = handler(req, res, next);
|
|
1793
|
+
if (result === false) {
|
|
1794
|
+
this._serveStatic(req, res);
|
|
1795
|
+
return;
|
|
1796
|
+
}
|
|
1797
|
+
// 支持 async/await
|
|
1798
|
+
if (result && typeof result.then === 'function') {
|
|
1799
|
+
result.then(r => {
|
|
1800
|
+
if (r === false) {
|
|
1801
|
+
this._serveStatic(req, res);
|
|
1802
|
+
}
|
|
1803
|
+
}).catch(err => {
|
|
1804
|
+
this._handleError(err, req, res, stack, idx);
|
|
1805
|
+
});
|
|
1806
|
+
}
|
|
1807
|
+
} catch (err) {
|
|
1808
|
+
this._handleError(err, req, res, stack, idx);
|
|
1809
|
+
}
|
|
1810
|
+
};
|
|
1811
|
+
|
|
1812
|
+
next();
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
/**
|
|
1816
|
+
* 错误处理:查找错误处理中间件(4 个参数)
|
|
1817
|
+
*/
|
|
1818
|
+
_handleError(err, req, res, stack, startIdx) {
|
|
1819
|
+
// 从当前栈中查找错误处理中间件
|
|
1820
|
+
for (let i = startIdx; i < stack.length; i++) {
|
|
1821
|
+
const handler = stack[i].handler;
|
|
1822
|
+
if (handler.length === 4) {
|
|
1823
|
+
try {
|
|
1824
|
+
handler(err, req, res, (e) => {
|
|
1825
|
+
this._handleError(e || null, req, res, stack, i + 1);
|
|
1826
|
+
});
|
|
1827
|
+
return;
|
|
1828
|
+
} catch (e) {
|
|
1829
|
+
err = e;
|
|
1830
|
+
continue;
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
// 没有错误处理中间件,使用默认错误响应
|
|
1835
|
+
const status = err.status || 500;
|
|
1836
|
+
const msg = err.message || 'Internal Server Error';
|
|
1837
|
+
this._logger.error(`[${status}] ${req.method} ${req.path} - ${msg}`);
|
|
1838
|
+
if (!res.headersSent && !res._res.headersSent) {
|
|
1839
|
+
res.status(status).json({ error: msg, status });
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
/**
|
|
1844
|
+
* 默认兜底处理逻辑
|
|
1845
|
+
*/
|
|
1846
|
+
_defaultHandler(req, res) {
|
|
1847
|
+
const method = req.method.toUpperCase();
|
|
1848
|
+
|
|
1849
|
+
if (method === 'GET' || method === 'HEAD') {
|
|
1850
|
+
// 尝试静态文件服务
|
|
1851
|
+
this._serveStatic(req, res);
|
|
1852
|
+
} else if (method === 'OPTIONS') {
|
|
1853
|
+
// CORS 预检响应
|
|
1854
|
+
this._handleCORS(req, res);
|
|
1855
|
+
} else if (method === 'POST' || method === 'PUT' || method === 'DELETE' || method === 'PATCH') {
|
|
1856
|
+
// 已知方法但无匹配路由,返回 405 Method Not Allowed
|
|
1857
|
+
res.status(405).json({ error: 'Method Not Allowed', status: 405 });
|
|
1858
|
+
} else {
|
|
1859
|
+
// 其他未知方法返回 404
|
|
1860
|
+
res.status(404).json({ error: 'Not Found', status: 404 });
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
/**
|
|
1865
|
+
* CORS 预检响应
|
|
1866
|
+
*/
|
|
1867
|
+
_handleCORS(req, res) {
|
|
1868
|
+
const cors = this.settings.cors;
|
|
1869
|
+
if (!cors) {
|
|
1870
|
+
res.status(204)._send('');
|
|
1871
|
+
return;
|
|
1872
|
+
}
|
|
1873
|
+
const origin = typeof cors.origin === 'string' ? cors.origin : '*';
|
|
1874
|
+
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
1875
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, OPTIONS');
|
|
1876
|
+
res.setHeader('Access-Control-Allow-Headers', cors.headers || 'Content-Type, Authorization');
|
|
1877
|
+
res.setHeader('Access-Control-Max-Age', cors.maxAge || '86400');
|
|
1878
|
+
if (cors.credentials) {
|
|
1879
|
+
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
|
1880
|
+
}
|
|
1881
|
+
res.status(204)._send('');
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
/**
|
|
1885
|
+
* 静态文件服务
|
|
1886
|
+
*/
|
|
1887
|
+
_serveStatic(req, res) {
|
|
1888
|
+
const rootPath = this.settings.rootPath || process.cwd();
|
|
1889
|
+
// 去掉前导 / 防止被 path.resolve 当作绝对路径
|
|
1890
|
+
let requestPath = req.path.replace(/^\/+/, '');
|
|
1891
|
+
|
|
1892
|
+
// 路径安全校验
|
|
1893
|
+
if (!isPathSafe(requestPath, rootPath)) {
|
|
1894
|
+
res.status(403).send('Forbidden');
|
|
1895
|
+
return;
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
const fullPath = path.join(rootPath, requestPath);
|
|
1899
|
+
|
|
1900
|
+
fs.stat(fullPath, (err, stat) => {
|
|
1901
|
+
if (err) {
|
|
1902
|
+
res.status(404).json({ error: 'Not Found', status: 404 });
|
|
1903
|
+
return;
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
if (stat.isDirectory()) {
|
|
1907
|
+
// 目录:查找 index.html
|
|
1908
|
+
const indexPath = path.join(fullPath, 'index.html');
|
|
1909
|
+
if (fs.existsSync(indexPath)) {
|
|
1910
|
+
res.sendFile(path.relative(rootPath, indexPath), { root: rootPath });
|
|
1911
|
+
return;
|
|
1912
|
+
}
|
|
1913
|
+
// 展示目录列表
|
|
1914
|
+
if (this.settings.showDir) {
|
|
1915
|
+
this._serveDirectory(req, res, fullPath, requestPath);
|
|
1916
|
+
} else {
|
|
1917
|
+
res.status(404).json({ error: 'Not Found', status: 404 });
|
|
1918
|
+
}
|
|
1919
|
+
return;
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
// 文件:发送
|
|
1923
|
+
res.sendFile(requestPath, { root: rootPath });
|
|
1924
|
+
});
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
/**
|
|
1928
|
+
* 目录列表展示
|
|
1929
|
+
*/
|
|
1930
|
+
_serveDirectory(req, res, dirPath, requestPath) {
|
|
1931
|
+
fs.readdir(dirPath, (err, files) => {
|
|
1932
|
+
if (err) {
|
|
1933
|
+
res.status(500).send('Internal Server Error');
|
|
1934
|
+
return;
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
const items = files.map(f => {
|
|
1938
|
+
try {
|
|
1939
|
+
const stat = fs.statSync(path.join(dirPath, f));
|
|
1940
|
+
return {
|
|
1941
|
+
name: f,
|
|
1942
|
+
isDirectory: stat.isDirectory(),
|
|
1943
|
+
size: stat.size,
|
|
1944
|
+
modified: stat.mtime.toISOString()
|
|
1945
|
+
};
|
|
1946
|
+
} catch (e) {
|
|
1947
|
+
return null;
|
|
1948
|
+
}
|
|
1949
|
+
}).filter(Boolean);
|
|
1950
|
+
|
|
1951
|
+
// 排序:目录在前
|
|
1952
|
+
items.sort((a, b) => {
|
|
1953
|
+
if (a.isDirectory && !b.isDirectory) return -1;
|
|
1954
|
+
if (!a.isDirectory && b.isDirectory) return 1;
|
|
1955
|
+
return a.name.localeCompare(b.name);
|
|
1956
|
+
});
|
|
1957
|
+
|
|
1958
|
+
// 生成 HTML 目录列表
|
|
1959
|
+
const html = this._renderDirectoryHTML(requestPath, items);
|
|
1960
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
1961
|
+
res.send(html);
|
|
1962
|
+
});
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
/**
|
|
1966
|
+
* 渲染目录列表 HTML
|
|
1967
|
+
*/
|
|
1968
|
+
_renderDirectoryHTML(requestPath, items) {
|
|
1969
|
+
const parentPath = path.dirname(requestPath);
|
|
1970
|
+
let html = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>Directory: ${requestPath}</title>`;
|
|
1971
|
+
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>`;
|
|
1973
|
+
|
|
1974
|
+
// 父目录链接
|
|
1975
|
+
if (requestPath !== '/') {
|
|
1976
|
+
html += `<tr><td><a href="${parentPath}" class="dir">../</a></td><td class="size">-</td><td>-</td></tr>`;
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
for (const item of items) {
|
|
1980
|
+
const href = path.join(requestPath, item.name);
|
|
1981
|
+
const name = item.isDirectory ? item.name + '/' : item.name;
|
|
1982
|
+
const size = item.isDirectory ? '-' : fmtSize(item.size);
|
|
1983
|
+
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>`;
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
html += `</table></body></html>`;
|
|
1988
|
+
return html;
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
/**
|
|
1992
|
+
* 处理 WebSocket 升级请求
|
|
1993
|
+
*/
|
|
1994
|
+
_handleUpgrade(req, socket, head) {
|
|
1995
|
+
const ws = this._wss.handleUpgrade(req, socket, head);
|
|
1996
|
+
if (!ws) return;
|
|
1997
|
+
|
|
1998
|
+
// 匹配 app.ws() 注册的处理器
|
|
1999
|
+
if (this._wsHandlers) {
|
|
2000
|
+
const pathname = parseUrl(req.url).pathname;
|
|
2001
|
+
for (const entry of this._wsHandlers) {
|
|
2002
|
+
if (pathname === entry.path || pathname.startsWith(entry.path + '/')) {
|
|
2003
|
+
const cleanup = entry.handler(ws, req);
|
|
2004
|
+
if (typeof cleanup === 'function') {
|
|
2005
|
+
ws.on('close', cleanup);
|
|
2006
|
+
}
|
|
2007
|
+
break;
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
/**
|
|
2014
|
+
* 获取 WebSocketServer 实例
|
|
2015
|
+
*/
|
|
2016
|
+
get wss() {
|
|
2017
|
+
return this._wss;
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
/**
|
|
2021
|
+
* SSE 简化注册:app.sse(path, handler)
|
|
2022
|
+
* handler 签名: (sse, req) => cleanupFn|void
|
|
2023
|
+
*/
|
|
2024
|
+
sse(pathStr, handler) {
|
|
2025
|
+
this.get(pathStr, (req, res) => {
|
|
2026
|
+
const sseInstance = res.sse();
|
|
2027
|
+
const cleanup = handler(sseInstance, req);
|
|
2028
|
+
// 连接关闭时执行清理
|
|
2029
|
+
res._res.on('close', () => {
|
|
2030
|
+
if (typeof cleanup === 'function') cleanup();
|
|
2031
|
+
});
|
|
2032
|
+
});
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
/**
|
|
2036
|
+
* WebSocket 简化注册:app.ws(path, handler)
|
|
2037
|
+
* handler 签名: (ws, req) => cleanupFn|void
|
|
2038
|
+
*/
|
|
2039
|
+
ws(pathStr, handler) {
|
|
2040
|
+
if (!this._wsHandlers) this._wsHandlers = [];
|
|
2041
|
+
this._wsHandlers.push({ path: pathStr, handler });
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
// ============================================================
|
|
2046
|
+
// 默认配置
|
|
2047
|
+
// ============================================================
|
|
2048
|
+
|
|
2049
|
+
/**
|
|
2050
|
+
* 自动加载 app.json 配置文件
|
|
2051
|
+
* 查找顺序:当前工作目录 → 模块所在目录
|
|
2052
|
+
* 文件不存在或格式错误时静默返回空对象,不影响启动
|
|
2053
|
+
*/
|
|
2054
|
+
function _loadAppJson() {
|
|
2055
|
+
const candidates = [
|
|
2056
|
+
path.join(process.cwd(), 'app.json'),
|
|
2057
|
+
path.join(__dirname, 'app.json')
|
|
2058
|
+
];
|
|
2059
|
+
for (const filePath of candidates) {
|
|
2060
|
+
try {
|
|
2061
|
+
if (fs.existsSync(filePath)) {
|
|
2062
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
2063
|
+
const config = JSON.parse(content);
|
|
2064
|
+
if (typeof config === 'object' && config !== null && !Array.isArray(config)) {
|
|
2065
|
+
return config;
|
|
2066
|
+
}
|
|
2067
|
+
}
|
|
2068
|
+
} catch (e) {
|
|
2069
|
+
// 解析失败或读取失败,静默忽略
|
|
2070
|
+
}
|
|
2071
|
+
}
|
|
2072
|
+
return {};
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
const defaultConfig = {
|
|
2076
|
+
rootPath: process.cwd(),
|
|
2077
|
+
tempDir: 'tempupdir',
|
|
2078
|
+
maxFileSize: 128 * 1024 * 1024,
|
|
2079
|
+
maxFieldSize: 1024 * 1024,
|
|
2080
|
+
svrPort: 80,
|
|
2081
|
+
svrIP: null,
|
|
2082
|
+
|
|
2083
|
+
showDir: false,
|
|
2084
|
+
enableCache: false,
|
|
2085
|
+
enableGzip: false,
|
|
2086
|
+
enableRange: true,
|
|
2087
|
+
cacheControl: 'public, max-age=3600',
|
|
2088
|
+
|
|
2089
|
+
timeout: 120000,
|
|
2090
|
+
keepAliveTimeout: 65000,
|
|
2091
|
+
|
|
2092
|
+
https: null,
|
|
2093
|
+
http2: false,
|
|
2094
|
+
|
|
2095
|
+
logLevel: 'info',
|
|
2096
|
+
logDir: './log',
|
|
2097
|
+
|
|
2098
|
+
cors: { origin: '*', headers: 'Content-Type, Authorization', maxAge: '86400' },
|
|
2099
|
+
|
|
2100
|
+
useBodyParser: true,
|
|
2101
|
+
useCookieParser: true,
|
|
2102
|
+
bodyParserOptions: {},
|
|
2103
|
+
cookieParserSecret: null,
|
|
2104
|
+
|
|
2105
|
+
wsHeartbeatInterval: 30000,
|
|
2106
|
+
wsHeartbeatTimeout: 30000
|
|
2107
|
+
};
|
|
2108
|
+
|
|
2109
|
+
// ============================================================
|
|
2110
|
+
// 导出接口层
|
|
2111
|
+
// ============================================================
|
|
2112
|
+
|
|
2113
|
+
/**
|
|
2114
|
+
* httpm 入口函数,创建 Application 实例
|
|
2115
|
+
*/
|
|
2116
|
+
function httpm(options) {
|
|
2117
|
+
return new Application(options);
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
// 导出类
|
|
2121
|
+
httpm.Application = Application;
|
|
2122
|
+
httpm.Router = Router;
|
|
2123
|
+
httpm.Request = Request;
|
|
2124
|
+
httpm.Response = Response;
|
|
2125
|
+
httpm.SSE = SSE;
|
|
2126
|
+
httpm.WebSocket = WebSocket;
|
|
2127
|
+
httpm.WebSocketServer = WebSocketServer;
|
|
2128
|
+
httpm.Logger = Logger;
|
|
2129
|
+
|
|
2130
|
+
// 导出中间件
|
|
2131
|
+
httpm.bodyParser = bodyParser;
|
|
2132
|
+
httpm.cookieParser = cookieParser;
|
|
2133
|
+
|
|
2134
|
+
/**
|
|
2135
|
+
* static 中间件:Express 兼容的静态文件服务
|
|
2136
|
+
* 用法: app.use(httpm.static('public'))
|
|
2137
|
+
*/
|
|
2138
|
+
function staticMiddleware(rootPath) {
|
|
2139
|
+
return function staticHandler(req, res, next) {
|
|
2140
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
2141
|
+
return next();
|
|
2142
|
+
}
|
|
2143
|
+
const root = path.resolve(rootPath || process.cwd());
|
|
2144
|
+
let requestPath = req.path.replace(/^\/+/, '');
|
|
2145
|
+
if (!isPathSafe(requestPath, root)) {
|
|
2146
|
+
return next();
|
|
2147
|
+
}
|
|
2148
|
+
const fullPath = path.join(root, requestPath);
|
|
2149
|
+
fs.stat(fullPath, (err, stat) => {
|
|
2150
|
+
if (err || !stat.isFile()) {
|
|
2151
|
+
return next();
|
|
2152
|
+
}
|
|
2153
|
+
res.sendFile(requestPath, { root });
|
|
2154
|
+
});
|
|
2155
|
+
};
|
|
2156
|
+
}
|
|
2157
|
+
httpm.static = staticMiddleware;
|
|
2158
|
+
|
|
2159
|
+
// 导出工具函数
|
|
2160
|
+
httpm.parseUrl = parseUrl;
|
|
2161
|
+
httpm.parseCookies = parseCookies;
|
|
2162
|
+
httpm.getMimeType = getMimeType;
|
|
2163
|
+
httpm.fmtSize = fmtSize;
|
|
2164
|
+
httpm.fmtTime = fmtTime;
|
|
2165
|
+
httpm.isPathSafe = isPathSafe;
|
|
2166
|
+
httpm.generateETag = generateETag;
|
|
2167
|
+
httpm.parseRange = parseRange;
|
|
2168
|
+
httpm.WebSocketHandShark = WebSocketHandShark;
|
|
2169
|
+
|
|
2170
|
+
/**
|
|
2171
|
+
* parseQuery:独立导出的 Query 解析函数
|
|
2172
|
+
*/
|
|
2173
|
+
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;
|
|
2185
|
+
}
|
|
2186
|
+
httpm.parseQuery = parseQuery;
|
|
2187
|
+
|
|
2188
|
+
module.exports = httpm;
|