@lzpong/httpm 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -20
- package/httpm.js +157 -39
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -66,6 +66,9 @@ app.all('/any', (req, res) => res.json({ method: req.method }));
|
|
|
66
66
|
|
|
67
67
|
路由匹配优先级:精准静态路由 > 动态参数路由 > ALL 通用路由 > 静态文件服务。
|
|
68
68
|
|
|
69
|
+
- **HEAD 请求**:自动匹配 GET 路由,仅返回响应头(Express 兼容)
|
|
70
|
+
- **OPTIONS 请求**:自动返回 `Allow` 头和 CORS 预检响应,动态查询该路径支持的方法
|
|
71
|
+
|
|
69
72
|
路由处理器返回 `false` 时,请求进入静态文件兜底:
|
|
70
73
|
|
|
71
74
|
```javascript
|
|
@@ -119,10 +122,10 @@ app.use((err, req, res, next) => {
|
|
|
119
122
|
| `req.body` | 请求体(JSON/URL-encoded 自动解析) |
|
|
120
123
|
| `req.formData` | 表单数据(multipart 解析后) |
|
|
121
124
|
| `req.cookies` | Cookie 对象 |
|
|
125
|
+
| `req.get(name)` | 获取请求头(不区分大小写) |
|
|
122
126
|
| `req.headers` | 请求头对象 |
|
|
123
127
|
| `req.ip` | 客户端 IP |
|
|
124
128
|
| `req.protocol` | 协议(http/https) |
|
|
125
|
-
| `req.get(name)` | 获取请求头 |
|
|
126
129
|
|
|
127
130
|
## 响应对象 (res)
|
|
128
131
|
|
|
@@ -130,15 +133,16 @@ app.use((err, req, res, next) => {
|
|
|
130
133
|
|------|------|
|
|
131
134
|
| `res.status(code)` | 设置状态码,链式调用 |
|
|
132
135
|
| `res.json(obj)` | 发送 JSON 响应 |
|
|
133
|
-
| `res.send(data)` |
|
|
136
|
+
| `res.send(data)` | 发送响应(自动识别类型;`null`→`"null"`,`undefined`→空响应) |
|
|
134
137
|
| `res.sendFile(path, opts)` | 发送文件 |
|
|
135
138
|
| `res.download(path, name)` | 下载文件 |
|
|
136
|
-
| `res.redirect([code,] url)` |
|
|
139
|
+
| `res.redirect([code,] url)` | 重定向,兼容 Express 签名:`redirect(url)` 默认 302,`redirect(status, url)` 指定状态码 |
|
|
137
140
|
| `res.cookie(name, value, opts)` | 设置 Cookie |
|
|
138
141
|
| `res.clearCookie(name, opts)` | 清除 Cookie |
|
|
139
|
-
| `res.set(name, value)` / `res.setHeader()` |
|
|
142
|
+
| `res.set(name, value)` / `res.setHeader()` | 设置响应头(set 支持对象批量) |
|
|
140
143
|
| `res.get(name)` / `res.getHeader()` | 获取响应头 |
|
|
141
|
-
| `res.type(type)` | 设置 Content-Type |
|
|
144
|
+
| `res.type(type)` | 设置 Content-Type(支持简写:html→text/html) |
|
|
145
|
+
| `res.on(event, fn)` | 监听响应事件(finish/close 等) |
|
|
142
146
|
| `res.sse()` | 创建 SSE 实例 |
|
|
143
147
|
|
|
144
148
|
### Cookie 签名
|
|
@@ -199,31 +203,28 @@ app.post('/upload', (req, res) => {
|
|
|
199
203
|
### 简化注册
|
|
200
204
|
|
|
201
205
|
```javascript
|
|
206
|
+
// 静态路径
|
|
202
207
|
app.ws('/chat', (ws, req) => {
|
|
203
208
|
ws.send('Welcome!');
|
|
204
209
|
|
|
205
210
|
ws.on('text', msg => {
|
|
206
|
-
app.
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
ws.on('binary', data => {
|
|
210
|
-
// 处理二进制数据
|
|
211
|
+
app.wss.broadcast('/chat', msg, ws);
|
|
211
212
|
});
|
|
213
|
+
});
|
|
212
214
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
215
|
+
// 动态参数路径
|
|
216
|
+
app.ws('/chat/:room', (ws, req) => {
|
|
217
|
+
console.log('Room:', req.params.room);
|
|
218
|
+
ws.send(`Welcome to room ${req.params.room}!`);
|
|
216
219
|
});
|
|
217
220
|
```
|
|
218
221
|
|
|
219
222
|
### WebSocketServer API
|
|
220
223
|
|
|
221
224
|
```javascript
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
wss.
|
|
225
|
-
wss.broadcast('/chat', 'Hello', excludeWsId); // 排除指定连接
|
|
226
|
-
wss.getConnections(); // 获取所有连接
|
|
225
|
+
app.wss.broadcast('/chat', 'Hello everyone'); // 按路径分组广播
|
|
226
|
+
app.wss.broadcast('/chat', 'Hello', ws); // 排除指定连接(传 ws 对象)
|
|
227
|
+
app.wss.getConnections(); // 获取所有连接
|
|
227
228
|
```
|
|
228
229
|
|
|
229
230
|
### WebSocket 事件
|
|
@@ -233,7 +234,7 @@ wss.getConnections(); // 获取所有连接
|
|
|
233
234
|
| `data` | 接收消息(`{ type: 'text'/'binary', data }`) |
|
|
234
235
|
| `text` | 接收文本消息 |
|
|
235
236
|
| `binary` | 接收二进制消息 |
|
|
236
|
-
| `close` |
|
|
237
|
+
| `close` | 连接关闭,回调参数 `(code, reason)` |
|
|
237
238
|
| `error` | 连接错误 |
|
|
238
239
|
|
|
239
240
|
### data 事件使用
|
|
@@ -357,6 +358,8 @@ const app = httpm({ svrPort: 3000 }); // svrPort=3000, enableGzip=true(来自app
|
|
|
357
358
|
| `cookieParserSecret` | `null` | Cookie 签名密钥 |
|
|
358
359
|
| `wsMaxPayload` | `104857600` | WebSocket 最大帧负载(100MB) |
|
|
359
360
|
| `wsAllowedOrigins` | `null` | WebSocket 允许的 Origin 列表 |
|
|
361
|
+
| `wsHeartbeatInterval` | `30000` | WebSocket 心跳检测间隔(毫秒) |
|
|
362
|
+
| `wsHeartbeatTimeout` | `30000` | WebSocket 心跳超时时间(毫秒) |
|
|
360
363
|
|
|
361
364
|
## HTTPS / HTTP2
|
|
362
365
|
|
|
@@ -410,7 +413,7 @@ httpm.isPathSafe
|
|
|
410
413
|
httpm.generateETag
|
|
411
414
|
httpm.parseRange
|
|
412
415
|
httpm.escapeHtml
|
|
413
|
-
httpm.
|
|
416
|
+
httpm.WebSocketHandShak
|
|
414
417
|
```
|
|
415
418
|
|
|
416
419
|
## 运行要求
|
package/httpm.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* httpm - 基于 Node.js 原生模块的单文件、零依赖 HTTP 服务库
|
|
3
3
|
*
|
|
4
4
|
* @name httpm
|
|
5
|
-
* @version 1.
|
|
5
|
+
* @version 1.2.0
|
|
6
6
|
* @description 兼容 Express API,内置路由、中间件、静态文件服务、
|
|
7
7
|
* WebSocket、SSE、流式上传、日志系统等功能
|
|
8
8
|
* @license MIT
|
|
@@ -301,7 +301,6 @@ class Logger {
|
|
|
301
301
|
const year = now.getFullYear().toString();
|
|
302
302
|
const month = (now.getMonth() + 1).toString().padStart(2, '0');
|
|
303
303
|
const day = now.getDate().toString().padStart(2, '0');
|
|
304
|
-
const dateStr = `${day}`;
|
|
305
304
|
|
|
306
305
|
// 同一天复用同一个流
|
|
307
306
|
const streamKey = `${year}-${month}-${day}`;
|
|
@@ -543,6 +542,20 @@ class Request {
|
|
|
543
542
|
return (this._req.socket?.encrypted || this._req.connection?.encrypted) ? 'https' : 'http';
|
|
544
543
|
}
|
|
545
544
|
|
|
545
|
+
/**
|
|
546
|
+
* 获取请求头(Express 兼容),不区分大小写
|
|
547
|
+
* req.get('Content-Type') / req.get('content-type')
|
|
548
|
+
*/
|
|
549
|
+
get(name) {
|
|
550
|
+
if (!name) return undefined;
|
|
551
|
+
const lower = name.toLowerCase();
|
|
552
|
+
// 特殊别名
|
|
553
|
+
if (lower === 'referrer' || lower === 'referer') {
|
|
554
|
+
return this._req.headers['referer'] || this._req.headers['referrer'];
|
|
555
|
+
}
|
|
556
|
+
return this._req.headers[lower];
|
|
557
|
+
}
|
|
558
|
+
|
|
546
559
|
/**
|
|
547
560
|
* 读取请求体原始数据(带超时保护和大小限制)
|
|
548
561
|
*/
|
|
@@ -621,6 +634,59 @@ class Response {
|
|
|
621
634
|
return this;
|
|
622
635
|
}
|
|
623
636
|
|
|
637
|
+
/**
|
|
638
|
+
* 设置响应头(Express 兼容),支持单键和对象批量设置
|
|
639
|
+
* res.set('Content-Type', 'text/html')
|
|
640
|
+
* res.set({ 'Content-Type': 'text/html', 'X-Custom': 'value' })
|
|
641
|
+
*/
|
|
642
|
+
set(name, value) {
|
|
643
|
+
if (typeof name === 'object') {
|
|
644
|
+
for (const key of Object.keys(name)) {
|
|
645
|
+
this._res.setHeader(key, name[key]);
|
|
646
|
+
}
|
|
647
|
+
} else {
|
|
648
|
+
this._res.setHeader(name, value);
|
|
649
|
+
}
|
|
650
|
+
return this;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* 设置 Content-Type(Express 兼容),支持简写
|
|
655
|
+
* res.type('html') → text/html
|
|
656
|
+
* res.type('.html') → text/html
|
|
657
|
+
* res.type('text/html') → text/html
|
|
658
|
+
*/
|
|
659
|
+
type(contentType) {
|
|
660
|
+
// 已是完整 MIME 类型,直接设置
|
|
661
|
+
if (contentType.includes('/')) {
|
|
662
|
+
this.setHeader('Content-Type', contentType);
|
|
663
|
+
return this;
|
|
664
|
+
}
|
|
665
|
+
// 简写或带点号扩展名,通过 getMimeType 解析
|
|
666
|
+
const ext = contentType.startsWith('.') ? contentType : '.' + contentType;
|
|
667
|
+
const mime = getMimeType(ext);
|
|
668
|
+
this.setHeader('Content-Type', mime);
|
|
669
|
+
return this;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* 代理原生 ServerResponse 事件监听(Express 兼容)
|
|
674
|
+
* res.on('finish', fn) / res.on('close', fn)
|
|
675
|
+
*/
|
|
676
|
+
on(event, listener) {
|
|
677
|
+
this._res.on(event, listener);
|
|
678
|
+
return this;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* 获取响应头(Express 兼容),不区分大小写
|
|
683
|
+
* res.get('Content-Type')
|
|
684
|
+
*/
|
|
685
|
+
get(name) {
|
|
686
|
+
if (!name) return undefined;
|
|
687
|
+
return this._res.getHeader(name);
|
|
688
|
+
}
|
|
689
|
+
|
|
624
690
|
/**
|
|
625
691
|
* 设置 HTTP 状态码,支持链式调用
|
|
626
692
|
*/
|
|
@@ -643,11 +709,18 @@ class Response {
|
|
|
643
709
|
* 通用输出,支持字符串、HTML、Buffer、对象
|
|
644
710
|
*/
|
|
645
711
|
send(data) {
|
|
646
|
-
|
|
712
|
+
// Express 兼容:null 序列化为 "null",undefined 返回空响应
|
|
713
|
+
if (data === undefined) {
|
|
647
714
|
this.setHeader('Content-Length', 0);
|
|
648
715
|
this._send('');
|
|
649
716
|
return;
|
|
650
717
|
}
|
|
718
|
+
if (data === null) {
|
|
719
|
+
this.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
720
|
+
this.setHeader('Content-Length', 4);
|
|
721
|
+
this._send('null');
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
651
724
|
if (Buffer.isBuffer(data)) {
|
|
652
725
|
this.setHeader('Content-Type', 'application/octet-stream');
|
|
653
726
|
this.setHeader('Content-Length', data.length);
|
|
@@ -890,6 +963,7 @@ class SSE {
|
|
|
890
963
|
const onClose = () => {
|
|
891
964
|
this.connected = false;
|
|
892
965
|
};
|
|
966
|
+
this._onClose = onClose;
|
|
893
967
|
res.on('close', onClose);
|
|
894
968
|
// HTTP/1.1 兼容:aborted 事件在请求被客户端中断时触发
|
|
895
969
|
res.on('aborted', onClose);
|
|
@@ -934,11 +1008,13 @@ class SSE {
|
|
|
934
1008
|
}
|
|
935
1009
|
|
|
936
1010
|
/**
|
|
937
|
-
* 主动关闭 SSE
|
|
1011
|
+
* 主动关闭 SSE 连接,移除监听器防止内存泄漏
|
|
938
1012
|
*/
|
|
939
1013
|
close() {
|
|
940
1014
|
if (!this.connected) return;
|
|
941
1015
|
this.connected = false;
|
|
1016
|
+
this._res.removeListener('close', this._onClose);
|
|
1017
|
+
this._res.removeListener('aborted', this._onClose);
|
|
942
1018
|
this._res.end();
|
|
943
1019
|
}
|
|
944
1020
|
}
|
|
@@ -1109,7 +1185,8 @@ class WebSocket {
|
|
|
1109
1185
|
return;
|
|
1110
1186
|
}
|
|
1111
1187
|
// 非关闭握手状态:回复 Close 帧后关闭
|
|
1112
|
-
|
|
1188
|
+
// RFC 6455 Section 7.4.1: 1005/1006 状态码不得在 Close 帧中发送
|
|
1189
|
+
this._sendCloseFrame(code === 1005 || code === 1006 ? undefined : code);
|
|
1113
1190
|
this.connected = false;
|
|
1114
1191
|
this._emitClose(code, reason);
|
|
1115
1192
|
return;
|
|
@@ -1212,6 +1289,8 @@ class WebSocket {
|
|
|
1212
1289
|
}
|
|
1213
1290
|
} catch (e) {
|
|
1214
1291
|
this.connected = false;
|
|
1292
|
+
// 发送失败时触发 error 事件,便于用户感知和处理
|
|
1293
|
+
this._emit('error', e);
|
|
1215
1294
|
}
|
|
1216
1295
|
}
|
|
1217
1296
|
|
|
@@ -1309,7 +1388,7 @@ class WebSocket {
|
|
|
1309
1388
|
/**
|
|
1310
1389
|
* WebSocket 握手辅助函数:计算 Sec-WebSocket-Accept 值
|
|
1311
1390
|
*/
|
|
1312
|
-
function
|
|
1391
|
+
function WebSocketHandShak(key) {
|
|
1313
1392
|
return crypto.createHash('sha1')
|
|
1314
1393
|
.update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11')
|
|
1315
1394
|
.digest('base64');
|
|
@@ -1350,7 +1429,7 @@ class WebSocketServer {
|
|
|
1350
1429
|
}
|
|
1351
1430
|
}
|
|
1352
1431
|
|
|
1353
|
-
const accept =
|
|
1432
|
+
const accept = WebSocketHandShak(key);
|
|
1354
1433
|
|
|
1355
1434
|
// 发送握手响应
|
|
1356
1435
|
const responseHeaders = [
|
|
@@ -1444,7 +1523,7 @@ class WebSocketServer {
|
|
|
1444
1523
|
/**
|
|
1445
1524
|
* 按路径广播消息
|
|
1446
1525
|
*/
|
|
1447
|
-
|
|
1526
|
+
broadcast(pathStr, data, exclude = null) {
|
|
1448
1527
|
const group = this.groups.get(pathStr);
|
|
1449
1528
|
if (!group) return;
|
|
1450
1529
|
for (const ws of group) {
|
|
@@ -1457,7 +1536,7 @@ class WebSocketServer {
|
|
|
1457
1536
|
/**
|
|
1458
1537
|
* 全局广播消息
|
|
1459
1538
|
*/
|
|
1460
|
-
|
|
1539
|
+
broadcastAll(data, exclude = null) {
|
|
1461
1540
|
for (const ws of this.connections.values()) {
|
|
1462
1541
|
if (ws.connected && ws !== exclude) {
|
|
1463
1542
|
ws.send(data);
|
|
@@ -1526,8 +1605,8 @@ function bodyParser(options = {}) {
|
|
|
1526
1605
|
const boundary = _extractBoundary(contentType);
|
|
1527
1606
|
if (boundary) {
|
|
1528
1607
|
_parseMultipart(req, boundary, maxFileSize, maxFieldSize, next);
|
|
1529
|
-
// 仅 multipart
|
|
1530
|
-
res.
|
|
1608
|
+
// 仅 multipart 需要临时文件清理(使用 res.on 保持封装一致性)
|
|
1609
|
+
res.on('finish', () => {
|
|
1531
1610
|
_cleanupTempFiles(req._tempFiles);
|
|
1532
1611
|
});
|
|
1533
1612
|
} else {
|
|
@@ -1762,7 +1841,8 @@ function _parseMultipart(req, boundary, maxFileSize, maxFieldSize, next) {
|
|
|
1762
1841
|
currentFile.stream = fs.createWriteStream(currentFile.path);
|
|
1763
1842
|
}
|
|
1764
1843
|
currentFile.stream.write(trimmedData);
|
|
1765
|
-
//
|
|
1844
|
+
// 结束写入(注意:stream.end 为异步操作,极端情况如磁盘满时可能写入不完整,
|
|
1845
|
+
// 但 fileInfo 仍会被记录。若需严格保证写入完整性,需改为异步流程)
|
|
1766
1846
|
currentFile.stream.end();
|
|
1767
1847
|
fileSize += trimmedData.length;
|
|
1768
1848
|
currentFile.size = fileSize;
|
|
@@ -2130,7 +2210,7 @@ class Application extends Router {
|
|
|
2130
2210
|
const status = err.status || 500;
|
|
2131
2211
|
const msg = err.message || 'Internal Server Error';
|
|
2132
2212
|
this._logger.error(`[${status}] ${req.method} ${req.path} - ${msg}`);
|
|
2133
|
-
if (!res.headersSent
|
|
2213
|
+
if (!res.headersSent) {
|
|
2134
2214
|
res.status(status).json({ error: msg, status });
|
|
2135
2215
|
}
|
|
2136
2216
|
}
|
|
@@ -2148,8 +2228,9 @@ class Application extends Router {
|
|
|
2148
2228
|
// CORS 预检响应
|
|
2149
2229
|
this._handleCORS(req, res);
|
|
2150
2230
|
} else if (method === 'POST' || method === 'PUT' || method === 'DELETE' || method === 'PATCH') {
|
|
2151
|
-
// 已知方法但无匹配路由,返回 405 Method Not Allowed
|
|
2152
|
-
|
|
2231
|
+
// 已知方法但无匹配路由,返回 405 Method Not Allowed(RFC 7231 要求必须包含 Allow 头)
|
|
2232
|
+
const allowed = this._getAllowedMethods(req.path);
|
|
2233
|
+
res.set('Allow', allowed.join(', ')).status(405).json({ error: 'Method Not Allowed', status: 405 });
|
|
2153
2234
|
} else {
|
|
2154
2235
|
// 其他未知方法返回 404
|
|
2155
2236
|
res.status(404).json({ error: 'Not Found', status: 404 });
|
|
@@ -2157,7 +2238,7 @@ class Application extends Router {
|
|
|
2157
2238
|
}
|
|
2158
2239
|
|
|
2159
2240
|
/**
|
|
2160
|
-
* CORS
|
|
2241
|
+
* CORS 预检响应(动态查询该路径支持的 HTTP 方法)
|
|
2161
2242
|
*/
|
|
2162
2243
|
_handleCORS(req, res) {
|
|
2163
2244
|
const cors = this.settings.cors;
|
|
@@ -2165,17 +2246,53 @@ class Application extends Router {
|
|
|
2165
2246
|
res.status(204)._send('');
|
|
2166
2247
|
return;
|
|
2167
2248
|
}
|
|
2249
|
+
// 动态查找该路径匹配的所有 HTTP 方法
|
|
2250
|
+
const allowedMethods = this._getAllowedMethods(req.path);
|
|
2168
2251
|
const origin = typeof cors.origin === 'string' ? cors.origin : '*';
|
|
2169
2252
|
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
2170
|
-
|
|
2253
|
+
// 动态设置允许的方法列表,而非硬编码
|
|
2254
|
+
res.setHeader('Access-Control-Allow-Methods', allowedMethods.join(', '));
|
|
2171
2255
|
res.setHeader('Access-Control-Allow-Headers', cors.headers || 'Content-Type, Authorization');
|
|
2172
2256
|
res.setHeader('Access-Control-Max-Age', parseInt(cors.maxAge, 10) || 86400);
|
|
2257
|
+
// Allow 头告知客户端该路径实际支持的方法
|
|
2258
|
+
res.setHeader('Allow', allowedMethods.join(', '));
|
|
2173
2259
|
if (cors.credentials) {
|
|
2174
2260
|
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
|
2175
2261
|
}
|
|
2176
2262
|
res.status(204)._send('');
|
|
2177
2263
|
}
|
|
2178
2264
|
|
|
2265
|
+
/**
|
|
2266
|
+
* 查询指定路径支持的所有 HTTP 方法(含隐式 HEAD)
|
|
2267
|
+
* 用于 OPTIONS Allow 头和 405 Method Not Allowed 响应
|
|
2268
|
+
*/
|
|
2269
|
+
_getAllowedMethods(pathname) {
|
|
2270
|
+
const methods = new Set();
|
|
2271
|
+
// 遍历所有已注册方法的路由,查找匹配该路径的方法
|
|
2272
|
+
for (const method of ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']) {
|
|
2273
|
+
const routes = this.routes[method] || [];
|
|
2274
|
+
for (const route of routes) {
|
|
2275
|
+
if (route.pattern.exec(pathname)) {
|
|
2276
|
+
methods.add(method);
|
|
2277
|
+
break;
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
}
|
|
2281
|
+
// 检查 ALL 中间件路由
|
|
2282
|
+
const allRoutes = this.routes['ALL'] || [];
|
|
2283
|
+
for (const route of allRoutes) {
|
|
2284
|
+
if (route.pattern.exec(pathname)) {
|
|
2285
|
+
['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'].forEach(m => methods.add(m));
|
|
2286
|
+
break;
|
|
2287
|
+
}
|
|
2288
|
+
}
|
|
2289
|
+
// GET 路由隐式支持 HEAD
|
|
2290
|
+
if (methods.has('GET')) methods.add('HEAD');
|
|
2291
|
+
// OPTIONS 始终可用(CORS 预检)
|
|
2292
|
+
methods.add('OPTIONS');
|
|
2293
|
+
return [...methods].sort();
|
|
2294
|
+
}
|
|
2295
|
+
|
|
2179
2296
|
/**
|
|
2180
2297
|
* 静态文件服务
|
|
2181
2298
|
*/
|
|
@@ -2267,10 +2384,14 @@ class Application extends Router {
|
|
|
2267
2384
|
*/
|
|
2268
2385
|
_renderDirectoryHTML(requestPath, items) {
|
|
2269
2386
|
// requestPath 已去掉前导 /,空字符串表示根目录
|
|
2270
|
-
|
|
2271
|
-
let
|
|
2387
|
+
// path.dirname('subdir') 返回 '.',应映射为根目录 ''
|
|
2388
|
+
let parentPath = requestPath ? path.dirname(requestPath) : '/';
|
|
2389
|
+
if (parentPath === '.') parentPath = '';
|
|
2390
|
+
// 根目录时显示 '/',否则显示请求路径
|
|
2391
|
+
const displayPath = requestPath || '/';
|
|
2392
|
+
let html = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>Directory: ${escapeHtml(displayPath)}</title>`;
|
|
2272
2393
|
html += `<style>body{font-family:-apple-system,sans-serif;margin:20px;background:#f5f5f5}h1{font-size:18px;color:#333}table{width:100%;border-collapse:collapse;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.1)}th{text-align:left;padding:10px 12px;background:#f8f8f8;border-bottom:2px solid #ddd;font-size:13px;color:#666}td{padding:8px 12px;border-bottom:1px solid #eee;font-size:13px}a{color:#0066cc;text-decoration:none}a:hover{text-decoration:underline}.dir{font-weight:bold}.size{color:#999}</style>`;
|
|
2273
|
-
html += `</head><body><h1>Directory: ${escapeHtml(
|
|
2394
|
+
html += `</head><body><h1>Directory: ${escapeHtml(displayPath)}</h1><table><tr><th>Name</th><th>Size</th><th>Modified</th></tr>`;
|
|
2274
2395
|
|
|
2275
2396
|
// 父目录链接(根目录时不显示)
|
|
2276
2397
|
if (requestPath && requestPath !== '/') {
|
|
@@ -2278,7 +2399,8 @@ class Application extends Router {
|
|
|
2278
2399
|
}
|
|
2279
2400
|
|
|
2280
2401
|
for (const item of items) {
|
|
2281
|
-
|
|
2402
|
+
// 使用 '/' 拼接路径,防止 Windows 上 path.join 产生反斜杠导致 href 失效
|
|
2403
|
+
const href = requestPath ? requestPath + '/' + item.name : item.name;
|
|
2282
2404
|
const name = item.isDirectory ? item.name + '/' : item.name;
|
|
2283
2405
|
const size = item.isDirectory ? '-' : fmtSize(item.size);
|
|
2284
2406
|
const cls = item.isDirectory ? 'dir' : '';
|
|
@@ -2297,28 +2419,23 @@ class Application extends Router {
|
|
|
2297
2419
|
if (!ws) return;
|
|
2298
2420
|
|
|
2299
2421
|
// 匹配 app.ws() 注册的处理器(支持动态参数路径)
|
|
2422
|
+
// _compilePath 始终返回 pattern,静态和动态路径均通过正则精确匹配
|
|
2300
2423
|
if (this._wsHandlers) {
|
|
2301
2424
|
const pathname = parseUrl(req.url).pathname;
|
|
2302
2425
|
for (const entry of this._wsHandlers) {
|
|
2303
2426
|
let matched = false;
|
|
2304
2427
|
let params = {};
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
}
|
|
2317
|
-
});
|
|
2318
|
-
}
|
|
2319
|
-
} else {
|
|
2320
|
-
// 静态路径:精确或前缀匹配
|
|
2321
|
-
matched = pathname === entry.path || pathname.startsWith(entry.path + '/');
|
|
2428
|
+
const m = entry.pattern.exec(pathname);
|
|
2429
|
+
if (m) {
|
|
2430
|
+
matched = true;
|
|
2431
|
+
entry.params.forEach((name, i) => {
|
|
2432
|
+
try {
|
|
2433
|
+
params[name] = decodeURIComponent(m[i + 1]);
|
|
2434
|
+
} catch (e) {
|
|
2435
|
+
// 非法 URI 编码,保留原始值
|
|
2436
|
+
params[name] = m[i + 1];
|
|
2437
|
+
}
|
|
2438
|
+
});
|
|
2322
2439
|
}
|
|
2323
2440
|
if (matched) {
|
|
2324
2441
|
// 将动态参数挂载到 req 上
|
|
@@ -2506,7 +2623,8 @@ httpm.fmtTime = fmtTime;
|
|
|
2506
2623
|
httpm.isPathSafe = isPathSafe;
|
|
2507
2624
|
httpm.generateETag = generateETag;
|
|
2508
2625
|
httpm.parseRange = parseRange;
|
|
2509
|
-
httpm.
|
|
2626
|
+
httpm.WebSocketHandShak = WebSocketHandShak;
|
|
2627
|
+
httpm.escapeHtml = escapeHtml;
|
|
2510
2628
|
|
|
2511
2629
|
/**
|
|
2512
2630
|
* parseQuery:独立导出的 Query 解析函数(复用内部 _parseQueryString)
|