@lzpong/httpm 1.2.1 → 1.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +7 -4
  2. package/httpm.js +109 -82
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -122,9 +122,11 @@ app.use((err, req, res, next) => {
122
122
  | `req.body` | 请求体(JSON/URL-encoded 自动解析) |
123
123
  | `req.formData` | 表单数据(multipart 解析后) |
124
124
  | `req.cookies` | Cookie 对象 |
125
+ | `req.signedCookies` | 签名 Cookie 对象(需配置 `cookieParserSecret`) |
125
126
  | `req.get(name)` | 获取请求头(不区分大小写) |
126
127
  | `req.headers` | 请求头对象 |
127
128
  | `req.ip` | 客户端 IP |
129
+ | `req.hostname` | 请求域名 |
128
130
  | `req.protocol` | 协议(http/https) |
129
131
 
130
132
  ## 响应对象 (res)
@@ -134,13 +136,14 @@ app.use((err, req, res, next) => {
134
136
  | `res.status(code)` | 设置状态码,链式调用 |
135
137
  | `res.json(obj)` | 发送 JSON 响应 |
136
138
  | `res.send(data)` | 发送响应(自动识别类型;`null`→`"null"`,`undefined`→空响应) |
137
- | `res.sendFile(path, opts)` | 发送文件 |
138
- | `res.download(path, name)` | 下载文件 |
139
+ | `res.sendFile(path, opts)` | 发送文件,opts 支持 `{ root, contentType }` |
140
+ | `res.download(path, [name], [opts])` | 下载文件,兼容 Express 签名:opts 传递给 sendFile |
139
141
  | `res.redirect([code,] url)` | 重定向,兼容 Express 签名:`redirect(url)` 默认 302,`redirect(status, url)` 指定状态码 |
140
142
  | `res.cookie(name, value, opts)` | 设置 Cookie |
141
143
  | `res.clearCookie(name, opts)` | 清除 Cookie |
142
- | `res.set(name, value)` / `res.setHeader()` | 设置响应头(set 支持对象批量) |
143
- | `res.get(name)` / `res.getHeader()` | 获取响应头 |
144
+ | `res.set(name, value)` / `res.setHeader()` | 设置响应头(set 支持对象批量;setHeader 为底层方法,功能相同) |
145
+ | `res.get(name)` / `res.getHeader()` | 获取响应头(get 为 Express 兼容方法,功能相同) |
146
+ | `res.removeHeader(name)` | 移除已设置的响应头 |
144
147
  | `res.type(type)` | 设置 Content-Type(支持简写:html→text/html) |
145
148
  | `res.on(event, fn)` | 监听响应事件(finish/close 等) |
146
149
  | `res.sse()` | 创建 SSE 实例 |
package/httpm.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * httpm - 基于 Node.js 原生模块的单文件、零依赖 HTTP 服务库
3
3
  *
4
4
  * @name httpm
5
- * @version 1.2.1
5
+ * @version 1.2.3
6
6
  * @description 兼容 Express API,内置路由、中间件、静态文件服务、
7
7
  * WebSocket、SSE、流式上传、日志系统等功能
8
8
  * @license MIT
@@ -164,8 +164,7 @@ const MIME_TYPES = {
164
164
  * 根据文件后缀匹配标准 MIME 类型
165
165
  */
166
166
  function getMimeType(ext) {
167
- if (!ext) return 'application/octet-stream';
168
- const lower = ext.toLowerCase();
167
+ const lower = ext?.toString().toLowerCase();
169
168
  return MIME_TYPES[lower] || 'application/octet-stream';
170
169
  }
171
170
 
@@ -773,7 +772,12 @@ class Response {
773
772
  }
774
773
 
775
774
  const mime = getMimeType(path.extname(fullPath));
776
- this.setHeader('Content-Type', mime);
775
+ // Content-Type 优先级:options.contentType > 已设置的 Content-Type > 自动检测
776
+ if (options.contentType) {
777
+ this.setHeader('Content-Type', options.contentType);
778
+ } else if (!this.getHeader('Content-Type')) {
779
+ this.setHeader('Content-Type', mime);
780
+ }
777
781
  this.setHeader('Accept-Ranges', 'bytes');
778
782
 
779
783
  // ETag 缓存校验
@@ -872,10 +876,19 @@ class Response {
872
876
  /**
873
877
  * 触发浏览器文件下载,支持大文件进度展示
874
878
  */
875
- download(filePath, filename) {
876
- const name = filename || path.basename(filePath);
879
+ download(filePath, filename, options) {
880
+ // Express 兼容签名:download(path), download(path, filename), download(path, filename, options), download(path, options)
881
+ let name = filename;
882
+ let opts = options;
883
+ if (typeof filename === 'object' && filename !== null) {
884
+ // download(path, options) 形式
885
+ opts = filename;
886
+ name = null;
887
+ }
888
+ name = name || path.basename(filePath);
889
+ opts = opts || {};
877
890
  this.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(name)}"`);
878
- this.sendFile(filePath);
891
+ this.sendFile(filePath, opts);
879
892
  }
880
893
 
881
894
  /**
@@ -1045,8 +1058,6 @@ class WebSocket {
1045
1058
  this._handlers = {};
1046
1059
  // 最大帧负载大小(默认 100MB,防止恶意超大帧耗尽内存)
1047
1060
  this._maxPayload = options.maxPayload || 100 * 1024 * 1024;
1048
- // 写入背压标记
1049
- this._writeBacklogged = false;
1050
1061
  // 帧解析状态:缓存不完整帧数据
1051
1062
  this._frameBuffer = Buffer.alloc(0);
1052
1063
  // 分片帧状态
@@ -1090,7 +1101,7 @@ class WebSocket {
1090
1101
  while (this._frameBuffer.length >= 2) {
1091
1102
  const result = this._decodeFrame(this._frameBuffer);
1092
1103
  if (result === null) break; // 数据不完整,等待更多数据
1093
- this._frameBuffer = this._frameBuffer.slice(result.bytesConsumed);
1104
+ this._frameBuffer = this._frameBuffer.subarray(result.bytesConsumed);
1094
1105
  this._processFrame(result.opcode, result.payload, result.fin, result.oversize);
1095
1106
  }
1096
1107
  }
@@ -1129,7 +1140,7 @@ class WebSocket {
1129
1140
  let mask = null;
1130
1141
  if (isMasked) {
1131
1142
  if (buf.length < offset + 4) return null;
1132
- mask = buf.slice(offset, offset + 4);
1143
+ mask = buf.subarray(offset, offset + 4);
1133
1144
  offset += 4;
1134
1145
  }
1135
1146
 
@@ -1142,7 +1153,7 @@ class WebSocket {
1142
1153
  }
1143
1154
 
1144
1155
  // 提取负载
1145
- let payload = buf.slice(offset, offset + payloadLength);
1156
+ let payload = buf.subarray(offset, offset + payloadLength);
1146
1157
  if (isMasked && mask) {
1147
1158
  for (let i = 0; i < payload.length; i++) {
1148
1159
  payload[i] ^= mask[i % 4];
@@ -1174,7 +1185,7 @@ class WebSocket {
1174
1185
  code = payload.readUInt16BE(0);
1175
1186
  // RFC 6455 Section 7.4: 状态码 0-999 为非法,1005 表示无状态码
1176
1187
  if (code < 1000) code = 1005;
1177
- reason = payload.length > 2 ? payload.slice(2).toString('utf8') : '';
1188
+ reason = payload.length > 2 ? payload.subarray(2).toString('utf8') : '';
1178
1189
  }
1179
1190
  // 如果正在关闭握手中,对端已回复 Close 帧,完成握手
1180
1191
  if (this._closing) {
@@ -1282,11 +1293,7 @@ class WebSocket {
1282
1293
  frames.push(payload);
1283
1294
  const frame = Buffer.concat(frames);
1284
1295
  try {
1285
- const canWrite = this.socket.write(frame);
1286
- // 写入缓冲区满时记录警告(WebSocket 不像 HTTP 可暂停请求流,只能记录)
1287
- if (!canWrite) {
1288
- this._writeBacklogged = true;
1289
- }
1296
+ this.socket.write(frame);
1290
1297
  } catch (e) {
1291
1298
  this.connected = false;
1292
1299
  // 发送失败时触发 error 事件,便于用户感知和处理
@@ -1692,10 +1699,8 @@ function _parseMultipart(req, boundary, maxFileSize, maxFieldSize, next) {
1692
1699
  let cleanupOnError = false;
1693
1700
  let paused = false;
1694
1701
 
1695
- // 确保临时目录存在
1696
- if (!fs.existsSync(tempDir)) {
1697
- fs.mkdirSync(tempDir, { recursive: true });
1698
- }
1702
+ // 确保临时目录存在(recursive: true 时目录已存在不报错,无需 existsSync)
1703
+ fs.mkdirSync(tempDir, { recursive: true });
1699
1704
 
1700
1705
  function processBuffer() {
1701
1706
  while (buffer.length > 0) {
@@ -1705,14 +1710,14 @@ function _parseMultipart(req, boundary, maxFileSize, maxFieldSize, next) {
1705
1710
  if (idx === -1) {
1706
1711
  // 保留尾部回看字节,防止分隔符跨 chunk 截断
1707
1712
  if (buffer.length > lookBehind) {
1708
- buffer = buffer.slice(buffer.length - lookBehind);
1713
+ buffer = buffer.subarray(buffer.length - lookBehind);
1709
1714
  }
1710
1715
  break;
1711
1716
  }
1712
- buffer = buffer.slice(idx + delimiter.length);
1717
+ buffer = buffer.subarray(idx + delimiter.length);
1713
1718
  // 跳过 \r\n
1714
1719
  if (buffer.length >= 2 && buffer[0] === 0x0D && buffer[1] === 0x0A) {
1715
- buffer = buffer.slice(2);
1720
+ buffer = buffer.subarray(2);
1716
1721
  }
1717
1722
  state = 'HEADERS';
1718
1723
  partHeadersBuf = Buffer.alloc(0);
@@ -1725,8 +1730,8 @@ function _parseMultipart(req, boundary, maxFileSize, maxFieldSize, next) {
1725
1730
  buffer = Buffer.alloc(0);
1726
1731
  break;
1727
1732
  }
1728
- partHeadersBuf = Buffer.concat([partHeadersBuf, buffer.slice(0, headerEnd)]);
1729
- buffer = buffer.slice(headerEnd + 4);
1733
+ partHeadersBuf = Buffer.concat([partHeadersBuf, buffer.subarray(0, headerEnd)]);
1734
+ buffer = buffer.subarray(headerEnd + 4);
1730
1735
  const partHeaders = partHeadersBuf.toString('utf8');
1731
1736
 
1732
1737
  // 解析 Content-Disposition
@@ -1761,7 +1766,7 @@ function _parseMultipart(req, boundary, maxFileSize, maxFieldSize, next) {
1761
1766
  // 保留尾部回看字节,防止分隔符跨 chunk 截断
1762
1767
  const safeLen = Math.max(0, buffer.length - lookBehind);
1763
1768
  const chunk = buffer.toString('utf8', 0, safeLen);
1764
- fieldSize += chunk.length;
1769
+ fieldSize += safeLen;
1765
1770
  if (fieldSize > maxFieldSize) {
1766
1771
  const err = new Error(`Field exceeds maximum size of ${fmtSize(maxFieldSize)}`, { cause: { actual: fieldSize, maxSize: maxFieldSize } });
1767
1772
  err.status = 413;
@@ -1769,12 +1774,12 @@ function _parseMultipart(req, boundary, maxFileSize, maxFieldSize, next) {
1769
1774
  return;
1770
1775
  }
1771
1776
  currentField.value += chunk;
1772
- buffer = buffer.slice(safeLen);
1777
+ buffer = buffer.subarray(safeLen);
1773
1778
  break;
1774
1779
  }
1775
1780
  // 字段结束
1776
1781
  const chunk = buffer.toString('utf8', 0, idx);
1777
- fieldSize += chunk.length;
1782
+ fieldSize += idx;
1778
1783
  if (fieldSize > maxFieldSize) {
1779
1784
  const err = new Error(`Field exceeds maximum size of ${fmtSize(maxFieldSize)}`, { cause: { actual: fieldSize, maxSize: maxFieldSize } });
1780
1785
  err.status = 413;
@@ -1787,10 +1792,10 @@ function _parseMultipart(req, boundary, maxFileSize, maxFieldSize, next) {
1787
1792
  currentField.value = currentField.value.slice(0, -2);
1788
1793
  }
1789
1794
  req.formData.fields[currentField.name] = currentField.value;
1790
- buffer = buffer.slice(idx + delimiter.length);
1795
+ buffer = buffer.subarray(idx + delimiter.length);
1791
1796
  // 跳过 \r\n
1792
1797
  if (buffer.length >= 2 && buffer[0] === 0x0D && buffer[1] === 0x0A) {
1793
- buffer = buffer.slice(2);
1798
+ buffer = buffer.subarray(2);
1794
1799
  }
1795
1800
  state = 'HEADERS';
1796
1801
  partHeadersBuf = Buffer.alloc(0);
@@ -1801,7 +1806,7 @@ function _parseMultipart(req, boundary, maxFileSize, maxFieldSize, next) {
1801
1806
  // 还没结束,写入临时文件
1802
1807
  // 保留尾部回看字节,防止分隔符跨 chunk 截断
1803
1808
  const safeLen = Math.max(0, buffer.length - lookBehind);
1804
- const writeData = buffer.slice(0, safeLen);
1809
+ const writeData = buffer.subarray(0, safeLen);
1805
1810
  if (!currentFile.stream) {
1806
1811
  currentFile.stream = fs.createWriteStream(currentFile.path);
1807
1812
  // 背压处理:写入流满时暂停请求读取,drain 后恢复
@@ -1827,14 +1832,14 @@ function _parseMultipart(req, boundary, maxFileSize, maxFieldSize, next) {
1827
1832
  next(err);
1828
1833
  return;
1829
1834
  }
1830
- buffer = buffer.slice(safeLen);
1835
+ buffer = buffer.subarray(safeLen);
1831
1836
  break;
1832
1837
  }
1833
1838
  // 文件结束
1834
- const fileData = buffer.slice(0, idx);
1839
+ const fileData = buffer.subarray(0, idx);
1835
1840
  // 去掉文件数据前的 \r\n
1836
1841
  const trimmedData = fileData.length >= 2 && fileData.at(-2) === 0x0D && fileData.at(-1) === 0x0A
1837
- ? fileData.slice(0, -2)
1842
+ ? fileData.subarray(0, -2)
1838
1843
  : fileData;
1839
1844
 
1840
1845
  if (!currentFile.stream) {
@@ -1862,10 +1867,10 @@ function _parseMultipart(req, boundary, maxFileSize, maxFieldSize, next) {
1862
1867
 
1863
1868
  // 清理上传进度条目
1864
1869
  cleanupOnError = false;
1865
- buffer = buffer.slice(idx + delimiter.length);
1870
+ buffer = buffer.subarray(idx + delimiter.length);
1866
1871
  // 跳过 \r\n
1867
1872
  if (buffer.length >= 2 && buffer[0] === 0x0D && buffer[1] === 0x0A) {
1868
- buffer = buffer.slice(2);
1873
+ buffer = buffer.subarray(2);
1869
1874
  }
1870
1875
  state = 'HEADERS';
1871
1876
  partHeadersBuf = Buffer.alloc(0);
@@ -2318,18 +2323,20 @@ class Application extends Router {
2318
2323
  }
2319
2324
 
2320
2325
  if (stat.isDirectory()) {
2321
- // 目录:查找 index.html
2326
+ // 目录:查找 index.html(异步检查避免阻塞事件循环)
2322
2327
  const indexPath = path.join(fullPath, 'index.html');
2323
- if (fs.existsSync(indexPath)) {
2324
- res.sendFile(path.relative(rootPath, indexPath), { root: rootPath });
2325
- return;
2326
- }
2327
- // 展示目录列表
2328
- if (this.settings.showDir) {
2329
- this._serveDirectory(req, res, fullPath, requestPath);
2330
- } else {
2331
- res.status(404).json({ error: 'Not Found', status: 404 });
2332
- }
2328
+ fs.stat(indexPath, (idxErr) => {
2329
+ if (!idxErr) {
2330
+ res.sendFile(path.relative(rootPath, indexPath), { root: rootPath });
2331
+ return;
2332
+ }
2333
+ // 展示目录列表
2334
+ if (this.settings.showDir) {
2335
+ this._serveDirectory(req, res, fullPath, requestPath);
2336
+ } else {
2337
+ res.status(404).json({ error: 'Not Found', status: 404 });
2338
+ }
2339
+ });
2333
2340
  return;
2334
2341
  }
2335
2342
 
@@ -2349,35 +2356,37 @@ class Application extends Router {
2349
2356
  return;
2350
2357
  }
2351
2358
 
2352
- const items = entries.map(entry => {
2353
- try {
2354
- // 目录只需名称和类型,文件需要额外 stat 获取大小和修改时间
2355
- if (entry.isDirectory()) {
2356
- return { name: entry.name, isDirectory: true, size: 0, modified: '' };
2357
- }
2358
- const stat = fs.statSync(path.join(dirPath, entry.name));
2359
- return {
2359
+ // 异步获取文件信息,避免同步阻塞事件循环
2360
+ const tasks = entries.map(entry => {
2361
+ if (entry.isDirectory()) {
2362
+ return Promise.resolve({ name: entry.name, isDirectory: true, size: 0, modified: '' });
2363
+ }
2364
+ return fs.promises.stat(path.join(dirPath, entry.name))
2365
+ .then(stat => ({
2360
2366
  name: entry.name,
2361
2367
  isDirectory: false,
2362
2368
  size: stat.size,
2363
2369
  modified: stat.mtime.toISOString()
2364
- };
2365
- } catch (e) {
2366
- return null;
2367
- }
2368
- }).filter(Boolean);
2369
-
2370
- // 排序:目录在前
2371
- items.sort((a, b) => {
2372
- if (a.isDirectory && !b.isDirectory) return -1;
2373
- if (!a.isDirectory && b.isDirectory) return 1;
2374
- return a.name.localeCompare(b.name);
2370
+ }))
2371
+ .catch(() => null);
2375
2372
  });
2376
2373
 
2377
- // 生成 HTML 目录列表
2378
- const html = this._renderDirectoryHTML(requestPath, items);
2379
- res.setHeader('Content-Type', 'text/html; charset=utf-8');
2380
- res.send(html);
2374
+ Promise.all(tasks).then(results => {
2375
+ const items = results.filter(Boolean);
2376
+ // 排序:目录在前
2377
+ items.sort((a, b) => {
2378
+ if (a.isDirectory && !b.isDirectory) return -1;
2379
+ if (!a.isDirectory && b.isDirectory) return 1;
2380
+ return a.name.localeCompare(b.name);
2381
+ });
2382
+
2383
+ // 生成 HTML 目录列表
2384
+ const html = this._renderDirectoryHTML(requestPath, items);
2385
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
2386
+ res.send(html);
2387
+ }).catch(() => {
2388
+ res.status(500).send('Internal Server Error');
2389
+ });
2381
2390
  });
2382
2391
  }
2383
2392
 
@@ -2388,18 +2397,21 @@ class Application extends Router {
2388
2397
  // requestPath 已去掉前导 /,空字符串表示根目录
2389
2398
  // 根目录时显示 '/',否则显示请求路径
2390
2399
  const displayPath = requestPath || '/';
2400
+ // 构建链接前缀:确保以 / 开头并以 / 结尾,用于生成绝对路径 href
2401
+ const prefix = '/' + (requestPath ? requestPath.replace(/\/+$/, '') + '/' : '');
2391
2402
  let html = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>Directory: ${escapeHtml(displayPath)}</title>`;
2392
2403
  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>`;
2393
2404
  html += `</head><body><h1>Directory: ${escapeHtml(displayPath)}</h1><table><tr><th>Name</th><th>Size</th><th>Modified</th></tr>`;
2394
2405
 
2395
- // 父目录链接:使用相对路径 .. 返回上一级(根目录时不显示)
2406
+ // 父目录链接:使用绝对路径返回上一级(根目录时不显示)
2396
2407
  if (requestPath && requestPath !== '/') {
2397
- html += `<tr><td><a href=".." class="dir">../</a></td><td class="size">-</td><td>-</td></tr>`;
2408
+ const parentHref = prefix + '..';
2409
+ html += `<tr><td><a href="${escapeHtml(parentHref)}" class="dir">../</a></td><td class="size">-</td><td>-</td></tr>`;
2398
2410
  }
2399
2411
 
2400
2412
  for (const item of items) {
2401
- // 仅使用文件名作为相对 href,页面 URL 本身已包含目录路径
2402
- const href = item.name;
2413
+ // 使用绝对路径 href,避免 URL 缺少尾部斜杠时解析错误
2414
+ const href = prefix + item.name;
2403
2415
  const name = item.isDirectory ? item.name + '/' : item.name;
2404
2416
  const size = item.isDirectory ? '-' : fmtSize(item.size);
2405
2417
  const cls = item.isDirectory ? 'dir' : '';
@@ -2414,6 +2426,18 @@ class Application extends Router {
2414
2426
  * 处理 WebSocket 升级请求
2415
2427
  */
2416
2428
  _handleUpgrade(req, socket, head) {
2429
+ // 防御性检查:_wss 在 listen() 中初始化,正常流程不会为 null
2430
+ if (!this._wss) {
2431
+ this._logger.warn('WebSocket server not initialized (call listen() first)');
2432
+ socket.destroy();
2433
+ return;
2434
+ }
2435
+ // 校验 Upgrade 头必须为 websocket
2436
+ if (req.headers['upgrade']?.toLowerCase() !== 'websocket') {
2437
+ this._logger.warn('Invalid upgrade header:', req.headers['upgrade'], 'expected: websocket');
2438
+ socket.destroy();
2439
+ return;
2440
+ }
2417
2441
  const ws = this._wss.handleUpgrade(req, socket, head);
2418
2442
  if (!ws) return;
2419
2443
 
@@ -2599,13 +2623,16 @@ function staticMiddleware(rootPath, options = {}) {
2599
2623
  return next();
2600
2624
  }
2601
2625
  if (stat.isDirectory()) {
2602
- // 目录:尝试 index.html
2626
+ // 目录:尝试 index.html(异步检查避免阻塞事件循环)
2603
2627
  const indexPath = path.join(fullPath, 'index.html');
2604
- if (fs.existsSync(indexPath)) {
2605
- res.sendFile(path.relative(root, indexPath), { root });
2606
- return;
2607
- }
2608
- return next();
2628
+ fs.stat(indexPath, (idxErr) => {
2629
+ if (!idxErr) {
2630
+ res.sendFile(path.relative(root, indexPath), { root });
2631
+ return;
2632
+ }
2633
+ return next();
2634
+ });
2635
+ return;
2609
2636
  }
2610
2637
  res.sendFile(requestPath, { root });
2611
2638
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lzpong/httpm",
3
- "version": "1.2.1",
3
+ "version": "1.2.3",
4
4
  "description": "基于 Node.js 原生模块的单文件、零依赖 HTTP 服务库,兼容 Express API",
5
5
  "main": "httpm.js",
6
6
  "files": [