@lzpong/httpm 1.1.0 → 1.2.1
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 +40 -20
- package/httpm.js +158 -41
- 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,29 @@ 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.
|
|
226
|
-
wss.getConnections(); // 获取所有连接
|
|
225
|
+
app.wss.broadcast('/chat', 'Hello everyone'); // 按路径分组广播
|
|
226
|
+
app.wss.broadcast('/chat', 'Hello', ws); // 排除指定连接(传 ws 对象)
|
|
227
|
+
app.wss.broadcastAll('Hello everyone'); // 全局广播
|
|
228
|
+
app.wss.getConnections(); // 获取所有连接
|
|
227
229
|
```
|
|
228
230
|
|
|
229
231
|
### WebSocket 事件
|
|
@@ -233,9 +235,16 @@ wss.getConnections(); // 获取所有连接
|
|
|
233
235
|
| `data` | 接收消息(`{ type: 'text'/'binary', data }`) |
|
|
234
236
|
| `text` | 接收文本消息 |
|
|
235
237
|
| `binary` | 接收二进制消息 |
|
|
236
|
-
| `close` |
|
|
238
|
+
| `close` | 连接关闭,回调参数 `(code, reason)` |
|
|
237
239
|
| `error` | 连接错误 |
|
|
238
240
|
|
|
241
|
+
### WebSocket 方法
|
|
242
|
+
|
|
243
|
+
| 方法 | 说明 |
|
|
244
|
+
|------|------|
|
|
245
|
+
| `ws.send(data)` | 发送消息(自动区分文本/JSON/二进制) |
|
|
246
|
+
| `ws.close(code, reason)` | 关闭连接,可选状态码和原因 |
|
|
247
|
+
|
|
239
248
|
### data 事件使用
|
|
240
249
|
|
|
241
250
|
```javascript
|
|
@@ -357,6 +366,8 @@ const app = httpm({ svrPort: 3000 }); // svrPort=3000, enableGzip=true(来自app
|
|
|
357
366
|
| `cookieParserSecret` | `null` | Cookie 签名密钥 |
|
|
358
367
|
| `wsMaxPayload` | `104857600` | WebSocket 最大帧负载(100MB) |
|
|
359
368
|
| `wsAllowedOrigins` | `null` | WebSocket 允许的 Origin 列表 |
|
|
369
|
+
| `wsHeartbeatInterval` | `30000` | WebSocket 心跳检测间隔(毫秒) |
|
|
370
|
+
| `wsHeartbeatTimeout` | `30000` | WebSocket 心跳超时时间(毫秒) |
|
|
360
371
|
|
|
361
372
|
## HTTPS / HTTP2
|
|
362
373
|
|
|
@@ -379,6 +390,15 @@ const app = httpm({
|
|
|
379
390
|
});
|
|
380
391
|
```
|
|
381
392
|
|
|
393
|
+
## 关闭服务
|
|
394
|
+
|
|
395
|
+
```javascript
|
|
396
|
+
// 关闭 HTTP/HTTPS 服务器并断开所有 WebSocket 连接
|
|
397
|
+
app.close(() => {
|
|
398
|
+
console.log('Server closed');
|
|
399
|
+
});
|
|
400
|
+
```
|
|
401
|
+
|
|
382
402
|
## 导出接口
|
|
383
403
|
|
|
384
404
|
```javascript
|
|
@@ -410,7 +430,7 @@ httpm.isPathSafe
|
|
|
410
430
|
httpm.generateETag
|
|
411
431
|
httpm.parseRange
|
|
412
432
|
httpm.escapeHtml
|
|
413
|
-
httpm.
|
|
433
|
+
httpm.WebSocketHandShak
|
|
414
434
|
```
|
|
415
435
|
|
|
416
436
|
## 运行要求
|
package/httpm.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* httpm - 基于 Node.js 原生模块的单文件、零依赖 HTTP 服务库
|
|
3
3
|
*
|
|
4
4
|
* @name httpm
|
|
5
|
-
* @version 1.1
|
|
5
|
+
* @version 1.2.1
|
|
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;
|
|
@@ -2111,6 +2191,8 @@ class Application extends Router {
|
|
|
2111
2191
|
* 错误处理:查找错误处理中间件(4 个参数)
|
|
2112
2192
|
*/
|
|
2113
2193
|
_handleError(err, req, res, stack, startIdx) {
|
|
2194
|
+
// 无错误时直接返回(错误处理中间件调用 next() 无参数表示错误已处理)
|
|
2195
|
+
if (!err) return;
|
|
2114
2196
|
// 从当前栈中查找错误处理中间件
|
|
2115
2197
|
for (let i = startIdx; i < stack.length; i++) {
|
|
2116
2198
|
const handler = stack[i].handler;
|
|
@@ -2130,7 +2212,7 @@ class Application extends Router {
|
|
|
2130
2212
|
const status = err.status || 500;
|
|
2131
2213
|
const msg = err.message || 'Internal Server Error';
|
|
2132
2214
|
this._logger.error(`[${status}] ${req.method} ${req.path} - ${msg}`);
|
|
2133
|
-
if (!res.headersSent
|
|
2215
|
+
if (!res.headersSent) {
|
|
2134
2216
|
res.status(status).json({ error: msg, status });
|
|
2135
2217
|
}
|
|
2136
2218
|
}
|
|
@@ -2148,8 +2230,9 @@ class Application extends Router {
|
|
|
2148
2230
|
// CORS 预检响应
|
|
2149
2231
|
this._handleCORS(req, res);
|
|
2150
2232
|
} else if (method === 'POST' || method === 'PUT' || method === 'DELETE' || method === 'PATCH') {
|
|
2151
|
-
// 已知方法但无匹配路由,返回 405 Method Not Allowed
|
|
2152
|
-
|
|
2233
|
+
// 已知方法但无匹配路由,返回 405 Method Not Allowed(RFC 7231 要求必须包含 Allow 头)
|
|
2234
|
+
const allowed = this._getAllowedMethods(req.path);
|
|
2235
|
+
res.set('Allow', allowed.join(', ')).status(405).json({ error: 'Method Not Allowed', status: 405 });
|
|
2153
2236
|
} else {
|
|
2154
2237
|
// 其他未知方法返回 404
|
|
2155
2238
|
res.status(404).json({ error: 'Not Found', status: 404 });
|
|
@@ -2157,7 +2240,7 @@ class Application extends Router {
|
|
|
2157
2240
|
}
|
|
2158
2241
|
|
|
2159
2242
|
/**
|
|
2160
|
-
* CORS
|
|
2243
|
+
* CORS 预检响应(动态查询该路径支持的 HTTP 方法)
|
|
2161
2244
|
*/
|
|
2162
2245
|
_handleCORS(req, res) {
|
|
2163
2246
|
const cors = this.settings.cors;
|
|
@@ -2165,17 +2248,53 @@ class Application extends Router {
|
|
|
2165
2248
|
res.status(204)._send('');
|
|
2166
2249
|
return;
|
|
2167
2250
|
}
|
|
2251
|
+
// 动态查找该路径匹配的所有 HTTP 方法
|
|
2252
|
+
const allowedMethods = this._getAllowedMethods(req.path);
|
|
2168
2253
|
const origin = typeof cors.origin === 'string' ? cors.origin : '*';
|
|
2169
2254
|
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
2170
|
-
|
|
2255
|
+
// 动态设置允许的方法列表,而非硬编码
|
|
2256
|
+
res.setHeader('Access-Control-Allow-Methods', allowedMethods.join(', '));
|
|
2171
2257
|
res.setHeader('Access-Control-Allow-Headers', cors.headers || 'Content-Type, Authorization');
|
|
2172
2258
|
res.setHeader('Access-Control-Max-Age', parseInt(cors.maxAge, 10) || 86400);
|
|
2259
|
+
// Allow 头告知客户端该路径实际支持的方法
|
|
2260
|
+
res.setHeader('Allow', allowedMethods.join(', '));
|
|
2173
2261
|
if (cors.credentials) {
|
|
2174
2262
|
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
|
2175
2263
|
}
|
|
2176
2264
|
res.status(204)._send('');
|
|
2177
2265
|
}
|
|
2178
2266
|
|
|
2267
|
+
/**
|
|
2268
|
+
* 查询指定路径支持的所有 HTTP 方法(含隐式 HEAD)
|
|
2269
|
+
* 用于 OPTIONS Allow 头和 405 Method Not Allowed 响应
|
|
2270
|
+
*/
|
|
2271
|
+
_getAllowedMethods(pathname) {
|
|
2272
|
+
const methods = new Set();
|
|
2273
|
+
// 遍历所有已注册方法的路由,查找匹配该路径的方法
|
|
2274
|
+
for (const method of ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']) {
|
|
2275
|
+
const routes = this.routes[method] || [];
|
|
2276
|
+
for (const route of routes) {
|
|
2277
|
+
if (route.pattern.exec(pathname)) {
|
|
2278
|
+
methods.add(method);
|
|
2279
|
+
break;
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
2283
|
+
// 检查 ALL 中间件路由
|
|
2284
|
+
const allRoutes = this.routes['ALL'] || [];
|
|
2285
|
+
for (const route of allRoutes) {
|
|
2286
|
+
if (route.pattern.exec(pathname)) {
|
|
2287
|
+
['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'].forEach(m => methods.add(m));
|
|
2288
|
+
break;
|
|
2289
|
+
}
|
|
2290
|
+
}
|
|
2291
|
+
// GET 路由隐式支持 HEAD
|
|
2292
|
+
if (methods.has('GET')) methods.add('HEAD');
|
|
2293
|
+
// OPTIONS 始终可用(CORS 预检)
|
|
2294
|
+
methods.add('OPTIONS');
|
|
2295
|
+
return [...methods].sort();
|
|
2296
|
+
}
|
|
2297
|
+
|
|
2179
2298
|
/**
|
|
2180
2299
|
* 静态文件服务
|
|
2181
2300
|
*/
|
|
@@ -2267,18 +2386,20 @@ class Application extends Router {
|
|
|
2267
2386
|
*/
|
|
2268
2387
|
_renderDirectoryHTML(requestPath, items) {
|
|
2269
2388
|
// requestPath 已去掉前导 /,空字符串表示根目录
|
|
2270
|
-
|
|
2271
|
-
|
|
2389
|
+
// 根目录时显示 '/',否则显示请求路径
|
|
2390
|
+
const displayPath = requestPath || '/';
|
|
2391
|
+
let html = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>Directory: ${escapeHtml(displayPath)}</title>`;
|
|
2272
2392
|
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(
|
|
2393
|
+
html += `</head><body><h1>Directory: ${escapeHtml(displayPath)}</h1><table><tr><th>Name</th><th>Size</th><th>Modified</th></tr>`;
|
|
2274
2394
|
|
|
2275
|
-
//
|
|
2395
|
+
// 父目录链接:使用相对路径 .. 返回上一级(根目录时不显示)
|
|
2276
2396
|
if (requestPath && requestPath !== '/') {
|
|
2277
|
-
html += `<tr><td><a href="
|
|
2397
|
+
html += `<tr><td><a href=".." class="dir">../</a></td><td class="size">-</td><td>-</td></tr>`;
|
|
2278
2398
|
}
|
|
2279
2399
|
|
|
2280
2400
|
for (const item of items) {
|
|
2281
|
-
|
|
2401
|
+
// 仅使用文件名作为相对 href,页面 URL 本身已包含目录路径
|
|
2402
|
+
const href = item.name;
|
|
2282
2403
|
const name = item.isDirectory ? item.name + '/' : item.name;
|
|
2283
2404
|
const size = item.isDirectory ? '-' : fmtSize(item.size);
|
|
2284
2405
|
const cls = item.isDirectory ? 'dir' : '';
|
|
@@ -2297,28 +2418,23 @@ class Application extends Router {
|
|
|
2297
2418
|
if (!ws) return;
|
|
2298
2419
|
|
|
2299
2420
|
// 匹配 app.ws() 注册的处理器(支持动态参数路径)
|
|
2421
|
+
// _compilePath 始终返回 pattern,静态和动态路径均通过正则精确匹配
|
|
2300
2422
|
if (this._wsHandlers) {
|
|
2301
2423
|
const pathname = parseUrl(req.url).pathname;
|
|
2302
2424
|
for (const entry of this._wsHandlers) {
|
|
2303
2425
|
let matched = false;
|
|
2304
2426
|
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 + '/');
|
|
2427
|
+
const m = entry.pattern.exec(pathname);
|
|
2428
|
+
if (m) {
|
|
2429
|
+
matched = true;
|
|
2430
|
+
entry.params.forEach((name, i) => {
|
|
2431
|
+
try {
|
|
2432
|
+
params[name] = decodeURIComponent(m[i + 1]);
|
|
2433
|
+
} catch (e) {
|
|
2434
|
+
// 非法 URI 编码,保留原始值
|
|
2435
|
+
params[name] = m[i + 1];
|
|
2436
|
+
}
|
|
2437
|
+
});
|
|
2322
2438
|
}
|
|
2323
2439
|
if (matched) {
|
|
2324
2440
|
// 将动态参数挂载到 req 上
|
|
@@ -2506,7 +2622,8 @@ httpm.fmtTime = fmtTime;
|
|
|
2506
2622
|
httpm.isPathSafe = isPathSafe;
|
|
2507
2623
|
httpm.generateETag = generateETag;
|
|
2508
2624
|
httpm.parseRange = parseRange;
|
|
2509
|
-
httpm.
|
|
2625
|
+
httpm.WebSocketHandShak = WebSocketHandShak;
|
|
2626
|
+
httpm.escapeHtml = escapeHtml;
|
|
2510
2627
|
|
|
2511
2628
|
/**
|
|
2512
2629
|
* parseQuery:独立导出的 Query 解析函数(复用内部 _parseQueryString)
|