mm_response 0.0.0-stage → 1.0.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 +149 -2
- package/example/demo.js +140 -0
- package/example/static/logo.svg +5 -0
- package/index.js +9 -0
- package/lib/reply.js +388 -0
- package/lib/response.js +112 -0
- package/package.json +40 -5
package/README.md
CHANGED
|
@@ -1,3 +1,150 @@
|
|
|
1
|
-
#
|
|
1
|
+
# mm_response
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Web 响应渲染中间件。为 `mm_web_server` 提供可挂载的响应写出器,并导出无状态响应工具函数(由原 `mm_web_server/lib/reply.js` 迁移而来)。
|
|
4
|
+
|
|
5
|
+
## 功能特点
|
|
6
|
+
|
|
7
|
+
- `Response` 通过 `web.setResponder(...)` 挂载,统一写出事件处理结果
|
|
8
|
+
- 识别 HTML 文档字符串,以 `text/html; charset=utf-8` 输出
|
|
9
|
+
- 支持 XML 响应:`{ type: 'xml' }` 或 `{ type: 'application/xml' | 'text/xml' }`
|
|
10
|
+
- 支持图片、视频、音频响应:`body` 可为 Buffer / Uint8Array、可读流或物理文件路径
|
|
11
|
+
- 错误统一写出 `500 { "error": 错误信息 }`
|
|
12
|
+
- 导出 `isReply` / `getMediaType` / `sendHtml` / `sendText` / `sendJson` / `sendEmpty` / `sendXml` / `sendFile` / `sendMedia` / `sendImage` / `sendVideo` / `sendAudio` / `sendBody` / `sendResult`,可单独复用
|
|
13
|
+
|
|
14
|
+
## 安装
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install mm_response
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## 快速开始
|
|
21
|
+
|
|
22
|
+
```javascript
|
|
23
|
+
const { WebServer } = require('mm_web_server');
|
|
24
|
+
const { Response } = require('mm_response');
|
|
25
|
+
|
|
26
|
+
const web = new WebServer({ name: 'web', port: 8080 });
|
|
27
|
+
new Response().bind(web);
|
|
28
|
+
await web.init();
|
|
29
|
+
await web.start();
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## API 文档
|
|
33
|
+
|
|
34
|
+
### `Response`
|
|
35
|
+
|
|
36
|
+
| 方法 | 说明 |
|
|
37
|
+
| --- | --- |
|
|
38
|
+
| `constructor(config?)` | 创建实例,`config` 当前无配置项 |
|
|
39
|
+
| `bind(web)` | 调用 `web.setResponder(...)` 挂载响应写出器;`web` 缺少 `setResponder()` 时抛出 `TypeError` |
|
|
40
|
+
| `isBound()` | 是否已挂载 |
|
|
41
|
+
|
|
42
|
+
写出器签名为 `responder(ctx, result, options)`:
|
|
43
|
+
|
|
44
|
+
1. `ctx.handled === true`、`res.writableEnded` 或缺少 `res` 时跳过;
|
|
45
|
+
2. `res.headersSent` 为真时只结束响应;
|
|
46
|
+
3. `ctx.error` 或 `options.error` 存在时写出 `500`;
|
|
47
|
+
4. 其余结果交给 `sendResult`。
|
|
48
|
+
|
|
49
|
+
### 响应工具函数
|
|
50
|
+
|
|
51
|
+
| 函数 | 说明 |
|
|
52
|
+
| --- | --- |
|
|
53
|
+
| `isReply(value)` | 是否为响应描述对象(含 `status` 或 `body` 字段) |
|
|
54
|
+
| `getMediaType(file_path)` | 按扩展名推断图片 / 视频 / 音频内容类型 |
|
|
55
|
+
| `sendHtml(res, status, text)` | 写 `text/html` |
|
|
56
|
+
| `sendText(res, status, text)` | 写 `text/plain` |
|
|
57
|
+
| `sendJson(res, status, payload)` | 写 `application/json` |
|
|
58
|
+
| `sendEmpty(res, status)` | 写空响应 |
|
|
59
|
+
| `sendXml(res, status, text, content_type?)` | 写 XML,缺省 `application/xml; charset=utf-8` |
|
|
60
|
+
| `sendFile(res, status, file_path, content_type)` | 流式写文件并带 `content-length`,文件不存在时为 `404` |
|
|
61
|
+
| `sendMedia(res, status, body, content_type)` | 写二进制内容,支持 Buffer / Uint8Array、可读流、文件路径 |
|
|
62
|
+
| `sendImage(res, status, body, content_type?)` | 写图片,缺省 `image/png`,文件路径按扩展名推断 |
|
|
63
|
+
| `sendVideo(res, status, body, content_type?)` | 写视频,缺省 `video/mp4`,文件路径按扩展名推断 |
|
|
64
|
+
| `sendAudio(res, status, body, content_type?)` | 写音频,缺省 `audio/mpeg`,文件路径按扩展名推断 |
|
|
65
|
+
| `sendBody(res, status, body, type?, content_type?)` | 按 `type` / body 类型分派 |
|
|
66
|
+
| `sendResult(res, result)` | 按事件结果约定写出 |
|
|
67
|
+
|
|
68
|
+
支持的图片扩展名:`png / jpg / jpeg / gif / webp / svg / bmp / ico / avif`;视频扩展名:`mp4 / m4v / webm / ogv / mov / avi / mkv`;音频扩展名:`mp3 / wav / ogg / oga / m4a / aac / flac / opus / weba / mid / midi`。未知扩展名统一为 `application/octet-stream`。
|
|
69
|
+
|
|
70
|
+
## 响应约定
|
|
71
|
+
|
|
72
|
+
| 事件结果 | 响应 |
|
|
73
|
+
| --- | --- |
|
|
74
|
+
| `undefined` / `null` | `404 { "error": "not found" }` |
|
|
75
|
+
| HTML 文档字符串 | `200` + `text/html` |
|
|
76
|
+
| 普通字符串 | `200` + `text/plain` |
|
|
77
|
+
| 普通对象 | `200` + `application/json` |
|
|
78
|
+
| `{ status, body }` | 按 `status` 写出 |
|
|
79
|
+
| `{ status, body, type: 'html' }` | `text/html` |
|
|
80
|
+
| `{ body, type: 'xml' }` | `application/xml; charset=utf-8` |
|
|
81
|
+
| `{ body, type: 'application/xml' \| 'text/xml' }` | 使用给定内容类型并补 `charset=utf-8` |
|
|
82
|
+
| `{ body, type: 'image' }` | 图片,`content_type` 优先,否则按文件路径扩展名推断,缺省 `image/png` |
|
|
83
|
+
| `{ body, type: 'video' }` | 视频,`content_type` 优先,否则按文件路径扩展名推断,缺省 `video/mp4` |
|
|
84
|
+
| `{ body, type: 'audio' }` | 音频,`content_type` 优先,否则按文件路径扩展名推断,缺省 `audio/mpeg` |
|
|
85
|
+
| `{ body, type: 'image/webp' }` | 直接使用给定 `image/*` / `video/*` / `audio/*` 内容类型 |
|
|
86
|
+
| Buffer / Uint8Array(无 `type`) | `application/octet-stream` |
|
|
87
|
+
| `{ status }`(无 `body`) | 空响应,未给 `status` 时为 `204` |
|
|
88
|
+
|
|
89
|
+
图片、视频、音频的 `body` 支持三种形式:
|
|
90
|
+
|
|
91
|
+
```js
|
|
92
|
+
// Buffer
|
|
93
|
+
sendResult(res, { type: 'image', body: fs.readFileSync('logo.png') });
|
|
94
|
+
// 可读流(content_type 建议显式给出)
|
|
95
|
+
sendResult(res, { type: 'video', body: fs.createReadStream('a.webm'), content_type: 'video/webm' });
|
|
96
|
+
// 物理文件路径,按扩展名推断内容类型并流式写出
|
|
97
|
+
sendResult(res, { type: 'audio', body: '/var/www/a.mp3' });
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
不解析 Range,视频、音频统一整段返回 `200`;如需分段请求,应交给静态文件处理(`mm_statics`)。
|
|
101
|
+
|
|
102
|
+
## 静态文件
|
|
103
|
+
|
|
104
|
+
`mm_response` 不处理静态文件。静态资源按事件中间件接入,见 `mm_web_server` 的 `example/demo.js`:
|
|
105
|
+
|
|
106
|
+
```js
|
|
107
|
+
const { statics } = require('mm_statics');
|
|
108
|
+
|
|
109
|
+
const serve = statics({ name: 'static', root: path.join(__dirname, 'static'), path: '/' });
|
|
110
|
+
events.add({ name: 'static', target: '*', stage: 'before', end: true }).main = async (params, ctx) => serve(ctx, async () => {});
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## 使用示例
|
|
114
|
+
|
|
115
|
+
[example/demo.js](example/demo.js) 演示如何挂载 `Response` 并覆盖各类响应:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
npm start
|
|
119
|
+
# 或 npm run demo
|
|
120
|
+
# 打开 http://127.0.0.1:8080
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
| 路径 | 演示内容 |
|
|
124
|
+
| --- | --- |
|
|
125
|
+
| `/` | HTML 文档字符串 → `text/html` |
|
|
126
|
+
| `/json` | 对象 → `application/json` |
|
|
127
|
+
| `/text` | 普通字符串 → `text/plain` |
|
|
128
|
+
| `/xml` | `{ type: 'xml' }` |
|
|
129
|
+
| `/image` | `{ type: 'image', body: 文件路径 }`,按扩展名推断并流式写出 |
|
|
130
|
+
| `/audio` | `{ type: 'audio', body: Buffer }`,输出 WAV |
|
|
131
|
+
| `/empty` | `{ status: 204 }` 空响应 |
|
|
132
|
+
| `/raw` | 事件内自行写出响应 |
|
|
133
|
+
| `/error` | 抛出错误 → `500` |
|
|
134
|
+
|
|
135
|
+
## 测试
|
|
136
|
+
|
|
137
|
+
测试位于 `tests/` 目录,使用 Node.js 内置 `node:test`:
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
npm test # 运行全部测试
|
|
141
|
+
npm run coverage # 输出覆盖率,并要求 index.js 与 lib/ 的行、分支、函数覆盖率均为 100%
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## 许可证
|
|
145
|
+
|
|
146
|
+
ISC License
|
|
147
|
+
|
|
148
|
+
## 作者
|
|
149
|
+
|
|
150
|
+
邱文武
|
package/example/demo.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { WebServer } = require('mm_web_server');
|
|
5
|
+
const { Response } = require('..');
|
|
6
|
+
|
|
7
|
+
/** 演示监听端口 */
|
|
8
|
+
const DEMO_PORT = 8080;
|
|
9
|
+
|
|
10
|
+
/** 演示音频采样率 */
|
|
11
|
+
const SAMPLE_RATE = 8000;
|
|
12
|
+
|
|
13
|
+
/** 演示音频时长(秒) */
|
|
14
|
+
const DURATION_SECONDS = 0.5;
|
|
15
|
+
|
|
16
|
+
/** 演示音频频率(赫兹) */
|
|
17
|
+
const TONE_FREQUENCY = 440;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 生成一段 WAV 音频数据(单声道、16 位、440Hz)
|
|
21
|
+
* @returns {Buffer} WAV 文件内容
|
|
22
|
+
*/
|
|
23
|
+
function createWavBuffer() {
|
|
24
|
+
const sample_count = Math.floor(SAMPLE_RATE * DURATION_SECONDS);
|
|
25
|
+
const data_size = sample_count * 2;
|
|
26
|
+
const buffer = Buffer.alloc(44 + data_size);
|
|
27
|
+
|
|
28
|
+
buffer.write('RIFF', 0);
|
|
29
|
+
buffer.writeUInt32LE(36 + data_size, 4);
|
|
30
|
+
buffer.write('WAVE', 8);
|
|
31
|
+
buffer.write('fmt ', 12);
|
|
32
|
+
buffer.writeUInt32LE(16, 16);
|
|
33
|
+
buffer.writeUInt16LE(1, 20);
|
|
34
|
+
buffer.writeUInt16LE(1, 22);
|
|
35
|
+
buffer.writeUInt32LE(SAMPLE_RATE, 24);
|
|
36
|
+
buffer.writeUInt32LE(SAMPLE_RATE * 2, 28);
|
|
37
|
+
buffer.writeUInt16LE(2, 32);
|
|
38
|
+
buffer.writeUInt16LE(16, 34);
|
|
39
|
+
buffer.write('data', 36);
|
|
40
|
+
buffer.writeUInt32LE(data_size, 40);
|
|
41
|
+
|
|
42
|
+
for (let i = 0; i < sample_count; i += 1) {
|
|
43
|
+
const value = Math.round(Math.sin((2 * Math.PI * TONE_FREQUENCY * i) / SAMPLE_RATE) * 0x3fff);
|
|
44
|
+
buffer.writeInt16LE(value, 44 + i * 2);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return buffer;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 生成演示首页 HTML
|
|
52
|
+
* @returns {string} HTML 文档
|
|
53
|
+
*/
|
|
54
|
+
function createIndexHtml() {
|
|
55
|
+
return `<!DOCTYPE html>
|
|
56
|
+
<html lang="zh-CN">
|
|
57
|
+
<head>
|
|
58
|
+
<meta charset="utf-8">
|
|
59
|
+
<title>mm_response 示例</title>
|
|
60
|
+
</head>
|
|
61
|
+
<body>
|
|
62
|
+
<h1>mm_response 示例</h1>
|
|
63
|
+
<ul>
|
|
64
|
+
<li><a href="/json">/json</a> 对象 → application/json</li>
|
|
65
|
+
<li><a href="/text">/text</a> 普通字符串 → text/plain</li>
|
|
66
|
+
<li><a href="/xml">/xml</a> XML 响应</li>
|
|
67
|
+
<li><a href="/image">/image</a> 按文件路径流式输出 SVG</li>
|
|
68
|
+
<li><a href="/audio">/audio</a> Buffer 输出 WAV 音频</li>
|
|
69
|
+
<li><a href="/empty">/empty</a> 无 body 的空响应</li>
|
|
70
|
+
<li><a href="/raw">/raw</a> 事件内自行写出响应</li>
|
|
71
|
+
<li><a href="/error">/error</a> 抛出错误 → 500</li>
|
|
72
|
+
</ul>
|
|
73
|
+
</body>
|
|
74
|
+
</html>`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 注册演示事件
|
|
79
|
+
* @param {Object} events 事件管理器
|
|
80
|
+
* @returns {void}
|
|
81
|
+
*/
|
|
82
|
+
function bindEvents(events) {
|
|
83
|
+
events.add({ name: 'index', target: '/', end: true }).main = async () => createIndexHtml();
|
|
84
|
+
|
|
85
|
+
events.add({ name: 'json', target: '/json', end: true }).main = async () => ({
|
|
86
|
+
name: 'mm_response',
|
|
87
|
+
type: 'json',
|
|
88
|
+
items: [1, 2, 3]
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
events.add({ name: 'text', target: '/text', end: true }).main = async () => '纯文本响应';
|
|
92
|
+
|
|
93
|
+
events.add({ name: 'xml', target: '/xml', end: true }).main = async () => ({
|
|
94
|
+
type: 'xml',
|
|
95
|
+
body: '<?xml version="1.0" encoding="UTF-8"?><response><status>ok</status></response>'
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
events.add({ name: 'image', target: '/image', end: true }).main = async () => ({
|
|
99
|
+
type: 'image',
|
|
100
|
+
body: path.join(__dirname, 'static', 'logo.svg')
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
events.add({ name: 'audio', target: '/audio', end: true }).main = async () => ({
|
|
104
|
+
type: 'audio',
|
|
105
|
+
body: createWavBuffer(),
|
|
106
|
+
content_type: 'audio/wav'
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
events.add({ name: 'empty', target: '/empty', end: true }).main = async () => ({ status: 204 });
|
|
110
|
+
|
|
111
|
+
events.add({ name: 'raw', target: '/raw', end: true }).main = (params, ctx) => {
|
|
112
|
+
ctx.res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' });
|
|
113
|
+
ctx.res.end('事件自行写出的响应');
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
events.add({ name: 'error', target: '/error', end: true }).main = async () => {
|
|
117
|
+
throw new Error('示例错误');
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 演示入口:启动服务器并打印访问地址
|
|
123
|
+
* @returns {Promise<void>} 无返回值
|
|
124
|
+
*/
|
|
125
|
+
async function main() {
|
|
126
|
+
const web = new WebServer({ name: 'mm_response-demo', port: DEMO_PORT });
|
|
127
|
+
bindEvents(web.getEventManager());
|
|
128
|
+
new Response().bind(web);
|
|
129
|
+
|
|
130
|
+
await web.init();
|
|
131
|
+
await web.start();
|
|
132
|
+
|
|
133
|
+
const address = web.getAddress();
|
|
134
|
+
console.log(`http://127.0.0.1:${address.port}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
main().catch((err) => {
|
|
138
|
+
console.error(err);
|
|
139
|
+
process.exit(1);
|
|
140
|
+
});
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="240" height="120" viewBox="0 0 240 120" role="img" aria-label="mm_response">
|
|
2
|
+
<rect width="240" height="120" rx="12" fill="#0f172a" />
|
|
3
|
+
<text x="120" y="58" fill="#38bdf8" font-family="Consolas, monospace" font-size="28" text-anchor="middle">mm_response</text>
|
|
4
|
+
<text x="120" y="86" fill="#94a3b8" font-family="Consolas, monospace" font-size="14" text-anchor="middle">response response middleware</text>
|
|
5
|
+
</svg>
|
package/index.js
ADDED
package/lib/reply.js
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
|
|
5
|
+
// 媒体扩展名与内容类型映射(图片 / 视频 / 音频)
|
|
6
|
+
const MEDIA_TYPES = {
|
|
7
|
+
// 图片
|
|
8
|
+
png: 'image/png',
|
|
9
|
+
jpg: 'image/jpeg',
|
|
10
|
+
jpeg: 'image/jpeg',
|
|
11
|
+
gif: 'image/gif',
|
|
12
|
+
webp: 'image/webp',
|
|
13
|
+
svg: 'image/svg+xml',
|
|
14
|
+
bmp: 'image/bmp',
|
|
15
|
+
ico: 'image/x-icon',
|
|
16
|
+
avif: 'image/avif',
|
|
17
|
+
// 视频
|
|
18
|
+
mp4: 'video/mp4',
|
|
19
|
+
m4v: 'video/mp4',
|
|
20
|
+
webm: 'video/webm',
|
|
21
|
+
ogv: 'video/ogg',
|
|
22
|
+
mov: 'video/quicktime',
|
|
23
|
+
avi: 'video/x-msvideo',
|
|
24
|
+
mkv: 'video/x-matroska',
|
|
25
|
+
// 音频
|
|
26
|
+
mp3: 'audio/mpeg',
|
|
27
|
+
wav: 'audio/wav',
|
|
28
|
+
ogg: 'audio/ogg',
|
|
29
|
+
oga: 'audio/ogg',
|
|
30
|
+
m4a: 'audio/mp4',
|
|
31
|
+
aac: 'audio/aac',
|
|
32
|
+
flac: 'audio/flac',
|
|
33
|
+
opus: 'audio/opus',
|
|
34
|
+
weba: 'audio/webm',
|
|
35
|
+
mid: 'audio/midi',
|
|
36
|
+
midi: 'audio/midi'
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// 缺省图片内容类型
|
|
40
|
+
const DEFAULT_IMAGE_TYPE = 'image/png';
|
|
41
|
+
|
|
42
|
+
// 缺省视频内容类型
|
|
43
|
+
const DEFAULT_VIDEO_TYPE = 'video/mp4';
|
|
44
|
+
|
|
45
|
+
// 缺省音频内容类型
|
|
46
|
+
const DEFAULT_AUDIO_TYPE = 'audio/mpeg';
|
|
47
|
+
|
|
48
|
+
// 缺省 XML 内容类型
|
|
49
|
+
const XML_TYPE = 'application/xml; charset=utf-8';
|
|
50
|
+
|
|
51
|
+
// XML 内容类型集合
|
|
52
|
+
const XML_MIME_TYPES = ['application/xml', 'text/xml'];
|
|
53
|
+
|
|
54
|
+
// 未知二进制内容类型
|
|
55
|
+
const OCTET_STREAM_TYPE = 'application/octet-stream';
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 判断值是否为响应描述对象(含 status 或 body 字段)
|
|
59
|
+
* @param {*} value 待判断的值
|
|
60
|
+
* @returns {boolean} 是否为响应描述对象
|
|
61
|
+
*/
|
|
62
|
+
function isReply(value) {
|
|
63
|
+
if (value === null || typeof value !== 'object') return false;
|
|
64
|
+
return 'status' in value || 'body' in value;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 判断值是否为二进制数据
|
|
69
|
+
* @param {*} value 待判断的值
|
|
70
|
+
* @returns {boolean} 是否为二进制数据
|
|
71
|
+
*/
|
|
72
|
+
function isBinary(value) {
|
|
73
|
+
return Buffer.isBuffer(value) || value instanceof Uint8Array;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 判断值是否为可读流
|
|
78
|
+
* @param {*} value 待判断的值
|
|
79
|
+
* @returns {boolean} 是否为可读流
|
|
80
|
+
*/
|
|
81
|
+
function isStream(value) {
|
|
82
|
+
return value !== null && typeof value === 'object' && typeof value.pipe === 'function';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 取文件路径的扩展名
|
|
87
|
+
* @param {string} file_path 文件路径
|
|
88
|
+
* @returns {string} 小写扩展名(不含点号),无扩展名时为空串
|
|
89
|
+
*/
|
|
90
|
+
function getExtname(file_path) {
|
|
91
|
+
const name = String(file_path || '');
|
|
92
|
+
const index = name.lastIndexOf('.');
|
|
93
|
+
if (index === -1) return '';
|
|
94
|
+
return name.slice(index + 1).toLowerCase();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 根据文件路径推断媒体内容类型(图片 / 视频 / 音频)
|
|
99
|
+
* @param {string} file_path 文件路径
|
|
100
|
+
* @returns {string} 内容类型,未知扩展名时返回 application/octet-stream
|
|
101
|
+
*/
|
|
102
|
+
function getMediaType(file_path) {
|
|
103
|
+
return MEDIA_TYPES[getExtname(file_path)] || OCTET_STREAM_TYPE;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 判断是否为媒体内容类型标记(含 image / video / audio 三类)
|
|
108
|
+
* @param {*} type 内容类型标记
|
|
109
|
+
* @returns {boolean} 是否为媒体内容类型
|
|
110
|
+
*/
|
|
111
|
+
function isMediaType(type) {
|
|
112
|
+
if (typeof type !== 'string') return false;
|
|
113
|
+
return type.startsWith('image/') || type.startsWith('video/') || type.startsWith('audio/');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* 判断是否为 XML 内容类型
|
|
118
|
+
* @param {*} type 内容类型
|
|
119
|
+
* @returns {boolean} 是否为 XML 内容类型
|
|
120
|
+
*/
|
|
121
|
+
function isXmlType(type) {
|
|
122
|
+
return XML_MIME_TYPES.includes(type);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 发送 HTML 响应
|
|
127
|
+
* @param {Object} res 响应对象
|
|
128
|
+
* @param {number} status 状态码
|
|
129
|
+
* @param {string} text 响应文本
|
|
130
|
+
* @returns {void}
|
|
131
|
+
*/
|
|
132
|
+
function sendHtml(res, status, text) {
|
|
133
|
+
if (res.writableEnded) return;
|
|
134
|
+
res.writeHead(status, { 'content-type': 'text/html; charset=utf-8' });
|
|
135
|
+
res.end(text);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* 发送纯文本响应
|
|
140
|
+
* @param {Object} res 响应对象
|
|
141
|
+
* @param {number} status 状态码
|
|
142
|
+
* @param {string} text 响应文本
|
|
143
|
+
* @returns {void}
|
|
144
|
+
*/
|
|
145
|
+
function sendText(res, status, text) {
|
|
146
|
+
if (res.writableEnded) return;
|
|
147
|
+
res.writeHead(status, { 'content-type': 'text/plain; charset=utf-8' });
|
|
148
|
+
res.end(text);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 发送 JSON 响应
|
|
153
|
+
* @param {Object} res 响应对象
|
|
154
|
+
* @param {number} status 状态码
|
|
155
|
+
* @param {Object} payload 响应体对象
|
|
156
|
+
* @returns {void}
|
|
157
|
+
*/
|
|
158
|
+
function sendJson(res, status, payload) {
|
|
159
|
+
if (res.writableEnded) return;
|
|
160
|
+
const text = JSON.stringify(payload);
|
|
161
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
|
|
162
|
+
res.end(text);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* 发送空响应
|
|
167
|
+
* @param {Object} res 响应对象
|
|
168
|
+
* @param {number} status 状态码
|
|
169
|
+
* @returns {void}
|
|
170
|
+
*/
|
|
171
|
+
function sendEmpty(res, status) {
|
|
172
|
+
if (res.writableEnded) return;
|
|
173
|
+
res.writeHead(status);
|
|
174
|
+
res.end();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* 发送 XML 响应
|
|
179
|
+
* @param {Object} res 响应对象
|
|
180
|
+
* @param {number} status 状态码
|
|
181
|
+
* @param {string} text 响应文本
|
|
182
|
+
* @param {string} [content_type] 内容类型,缺省 application/xml; charset=utf-8
|
|
183
|
+
* @returns {void}
|
|
184
|
+
*/
|
|
185
|
+
function sendXml(res, status, text, content_type) {
|
|
186
|
+
if (res.writableEnded) return;
|
|
187
|
+
res.writeHead(status, { 'content-type': content_type || XML_TYPE });
|
|
188
|
+
res.end(text);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* 以流式方式发送文件,文件不存在时返回 404
|
|
193
|
+
* @param {Object} res 响应对象
|
|
194
|
+
* @param {number} status 状态码
|
|
195
|
+
* @param {string} file_path 物理文件路径
|
|
196
|
+
* @param {string} content_type 内容类型
|
|
197
|
+
* @returns {void}
|
|
198
|
+
*/
|
|
199
|
+
function sendFile(res, status, file_path, content_type) {
|
|
200
|
+
if (res.writableEnded) return;
|
|
201
|
+
|
|
202
|
+
let stat = null;
|
|
203
|
+
try {
|
|
204
|
+
stat = fs.statSync(file_path);
|
|
205
|
+
} catch {
|
|
206
|
+
sendJson(res, 404, { error: 'not found' });
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (!stat.isFile()) {
|
|
211
|
+
sendJson(res, 404, { error: 'not found' });
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
res.writeHead(status, {
|
|
216
|
+
'content-type': content_type,
|
|
217
|
+
'content-length': stat.size
|
|
218
|
+
});
|
|
219
|
+
fs.createReadStream(file_path)
|
|
220
|
+
.on('error', () => res.destroy())
|
|
221
|
+
.pipe(res);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* 发送二进制内容,支持二进制数据、可读流与物理文件路径
|
|
226
|
+
* @param {Object} res 响应对象
|
|
227
|
+
* @param {number} status 状态码
|
|
228
|
+
* @param {Buffer|Uint8Array|Object|string} body 二进制数据、可读流或文件路径
|
|
229
|
+
* @param {string} content_type 内容类型
|
|
230
|
+
* @returns {void}
|
|
231
|
+
*/
|
|
232
|
+
function sendMedia(res, status, body, content_type) {
|
|
233
|
+
if (res.writableEnded) return;
|
|
234
|
+
|
|
235
|
+
if (isBinary(body)) {
|
|
236
|
+
res.writeHead(status, {
|
|
237
|
+
'content-type': content_type,
|
|
238
|
+
'content-length': body.length
|
|
239
|
+
});
|
|
240
|
+
res.end(body);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (typeof body === 'string') {
|
|
245
|
+
sendFile(res, status, body, content_type);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (isStream(body)) {
|
|
250
|
+
res.writeHead(status, { 'content-type': content_type });
|
|
251
|
+
body.on('error', () => res.destroy());
|
|
252
|
+
body.pipe(res);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* 发送图片响应
|
|
258
|
+
* @param {Object} res 响应对象
|
|
259
|
+
* @param {number} status 状态码
|
|
260
|
+
* @param {Buffer|Uint8Array|Object|string} body 图片数据、可读流或文件路径
|
|
261
|
+
* @param {string} [content_type] 内容类型,缺省按文件路径推断或使用 image/png
|
|
262
|
+
* @returns {void}
|
|
263
|
+
*/
|
|
264
|
+
function sendImage(res, status, body, content_type) {
|
|
265
|
+
const type = content_type || (typeof body === 'string' ? getMediaType(body) : DEFAULT_IMAGE_TYPE);
|
|
266
|
+
sendMedia(res, status, body, type);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* 发送视频响应
|
|
271
|
+
* @param {Object} res 响应对象
|
|
272
|
+
* @param {number} status 状态码
|
|
273
|
+
* @param {Buffer|Uint8Array|Object|string} body 视频数据、可读流或文件路径
|
|
274
|
+
* @param {string} [content_type] 内容类型,缺省按文件路径推断或使用 video/mp4
|
|
275
|
+
* @returns {void}
|
|
276
|
+
*/
|
|
277
|
+
function sendVideo(res, status, body, content_type) {
|
|
278
|
+
const type = content_type || (typeof body === 'string' ? getMediaType(body) : DEFAULT_VIDEO_TYPE);
|
|
279
|
+
sendMedia(res, status, body, type);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* 发送音频响应
|
|
284
|
+
* @param {Object} res 响应对象
|
|
285
|
+
* @param {number} status 状态码
|
|
286
|
+
* @param {Buffer|Uint8Array|Object|string} body 音频数据、可读流或文件路径
|
|
287
|
+
* @param {string} [content_type] 内容类型,缺省按文件路径推断或使用 audio/mpeg
|
|
288
|
+
* @returns {void}
|
|
289
|
+
*/
|
|
290
|
+
function sendAudio(res, status, body, content_type) {
|
|
291
|
+
const type = content_type || (typeof body === 'string' ? getMediaType(body) : DEFAULT_AUDIO_TYPE);
|
|
292
|
+
sendMedia(res, status, body, type);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* 按响应体类型写出响应
|
|
297
|
+
* @param {Object} res 响应对象
|
|
298
|
+
* @param {number} status 状态码
|
|
299
|
+
* @param {*} body 响应体
|
|
300
|
+
* @param {string} [type] 内容类型标记(html / xml / image / video / audio,或对应 MIME 形式的内容类型),缺省按 body 类型推断
|
|
301
|
+
* @param {string} [content_type] 内容类型,type 为 image / video / audio 时优先使用
|
|
302
|
+
* @returns {void}
|
|
303
|
+
*/
|
|
304
|
+
function sendBody(res, status, body, type, content_type) {
|
|
305
|
+
if (type === 'html') {
|
|
306
|
+
sendHtml(res, status, body);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (type === 'xml') {
|
|
310
|
+
sendXml(res, status, body);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (isXmlType(type)) {
|
|
314
|
+
sendXml(res, status, body, `${type}; charset=utf-8`);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
if (type === 'image') {
|
|
318
|
+
sendImage(res, status, body, content_type);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (type === 'video') {
|
|
322
|
+
sendVideo(res, status, body, content_type);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (type === 'audio') {
|
|
326
|
+
sendAudio(res, status, body, content_type);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (isMediaType(type)) {
|
|
330
|
+
sendMedia(res, status, body, type);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
if (isBinary(body)) {
|
|
334
|
+
sendMedia(res, status, body, OCTET_STREAM_TYPE);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
if (typeof body === 'string') {
|
|
338
|
+
sendText(res, status, body);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
sendJson(res, status, body);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* 写出事件处理结果:
|
|
346
|
+
* 已写出则跳过;无结果为 404;非描述对象按 200 写出;描述对象按 status/body/type/content_type 写出
|
|
347
|
+
* @param {Object} res 响应对象
|
|
348
|
+
* @param {*} result 事件处理结果
|
|
349
|
+
* @returns {void}
|
|
350
|
+
*/
|
|
351
|
+
function sendResult(res, result) {
|
|
352
|
+
if (res.writableEnded) return;
|
|
353
|
+
|
|
354
|
+
if (result === undefined || result === null) {
|
|
355
|
+
sendJson(res, 404, { error: 'not found' });
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (!isReply(result)) {
|
|
360
|
+
sendBody(res, 200, result);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const status = result.status === undefined ? 200 : result.status;
|
|
365
|
+
if (result.body === undefined || result.body === null) {
|
|
366
|
+
sendEmpty(res, result.status === undefined ? 204 : status);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
sendBody(res, status, result.body, result.type, result.content_type);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
module.exports = {
|
|
374
|
+
isReply,
|
|
375
|
+
getMediaType,
|
|
376
|
+
sendHtml,
|
|
377
|
+
sendText,
|
|
378
|
+
sendJson,
|
|
379
|
+
sendEmpty,
|
|
380
|
+
sendXml,
|
|
381
|
+
sendFile,
|
|
382
|
+
sendMedia,
|
|
383
|
+
sendImage,
|
|
384
|
+
sendVideo,
|
|
385
|
+
sendAudio,
|
|
386
|
+
sendBody,
|
|
387
|
+
sendResult
|
|
388
|
+
};
|
package/lib/response.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { sendJson, sendResult } = require('./reply');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* HTML 文档开头标记正则(忽略前导空白,用于判定字符串是否为页面渲染结果)
|
|
7
|
+
* @type {RegExp}
|
|
8
|
+
*/
|
|
9
|
+
const HTML_PATTERN = /^\s*<(!doctype\s+html|html|head|body)(\s|>)/i;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Response —— 响应渲染中间件(mm_response)。
|
|
13
|
+
*
|
|
14
|
+
* 作用:作为 WebServer 的响应写出器,把事件处理结果按约定写成 HTTP 响应:
|
|
15
|
+
* - 错误:500 JSON;
|
|
16
|
+
* - HTML 文档字符串:text/html;
|
|
17
|
+
* - 普通字符串:text/plain;
|
|
18
|
+
* - 其他对象:application/json;
|
|
19
|
+
* - 无结果:404;响应描述对象无 body:204。
|
|
20
|
+
*
|
|
21
|
+
* 用法:
|
|
22
|
+
* const { Response } = require('mm_response');
|
|
23
|
+
* const web = new WebServer({ name: 'web', port: 8080 }, server);
|
|
24
|
+
* new Response().bind(web);
|
|
25
|
+
* await web.init();
|
|
26
|
+
* await web.start();
|
|
27
|
+
*/
|
|
28
|
+
class Response {
|
|
29
|
+
/**
|
|
30
|
+
* 已挂载的 WebServer 实例,未挂载时为 null
|
|
31
|
+
* @type {Object|null}
|
|
32
|
+
*/
|
|
33
|
+
#web = null;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 构造函数
|
|
37
|
+
* @param {Object} [config] 配置参数(预留,当前无配置项)
|
|
38
|
+
*/
|
|
39
|
+
constructor(config = {}) {
|
|
40
|
+
/**
|
|
41
|
+
* 渲染配置
|
|
42
|
+
* @type {Object}
|
|
43
|
+
*/
|
|
44
|
+
this.config = { ...config };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 挂载到 WebServer:注册响应写出器
|
|
49
|
+
* @param {Object} web 具备 setResponder(responder) 的 WebServer 实例
|
|
50
|
+
* @returns {Response} 当前实例
|
|
51
|
+
* @throws {TypeError} web 不具备 setResponder 方法时
|
|
52
|
+
*/
|
|
53
|
+
bind(web) {
|
|
54
|
+
if (!web || typeof web.setResponder !== 'function') {
|
|
55
|
+
throw new TypeError('[Response] web must provide setResponder()');
|
|
56
|
+
}
|
|
57
|
+
this.#web = web;
|
|
58
|
+
web.setResponder((ctx, result, options) => this._respond(ctx, result, options));
|
|
59
|
+
return this;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 判断是否已挂载
|
|
64
|
+
* @returns {boolean} 是否已挂载到 WebServer
|
|
65
|
+
*/
|
|
66
|
+
isBound() {
|
|
67
|
+
return this.#web !== null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 写出响应:跳过已处理请求,优先写出错误,否则按结果类型写出
|
|
72
|
+
* @param {Object} ctx 请求上下文
|
|
73
|
+
* @param {*} result 事件处理结果
|
|
74
|
+
* @param {Object} [options] 事件运行选项,可能含 error
|
|
75
|
+
* @returns {void}
|
|
76
|
+
*/
|
|
77
|
+
_respond(ctx, result, options) {
|
|
78
|
+
const res = ctx && ctx.res;
|
|
79
|
+
if (!res || ctx.handled === true || res.writableEnded) return;
|
|
80
|
+
if (res.headersSent) {
|
|
81
|
+
res.end();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const error = ctx.error || (options && options.error);
|
|
85
|
+
if (error) {
|
|
86
|
+
sendJson(res, 500, { error: error.message || 'internal error' });
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
sendResult(res, this._toReply(result));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 把符合 HTML 文档特征的字符串转为 html 响应描述对象
|
|
94
|
+
* @param {*} result 事件处理结果
|
|
95
|
+
* @returns {*} 转换后的结果;非 HTML 文档字符串原样返回
|
|
96
|
+
*/
|
|
97
|
+
_toReply(result) {
|
|
98
|
+
if (!this._isHtml(result)) return result;
|
|
99
|
+
return { status: 200, type: 'html', body: result };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 判断值是否为 HTML 文档字符串
|
|
104
|
+
* @param {*} value 待判断的值
|
|
105
|
+
* @returns {boolean} 是否为 HTML 文档字符串
|
|
106
|
+
*/
|
|
107
|
+
_isHtml(value) {
|
|
108
|
+
return typeof value === 'string' && HTML_PATTERN.test(value);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
module.exports = Response;
|
package/package.json
CHANGED
|
@@ -1,6 +1,41 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "mm_response",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "Web 响应渲染中间件:为 mm_web_server 提供可挂载的响应写出器与响应工具函数。",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"files": [
|
|
7
|
+
"index.js",
|
|
8
|
+
"lib",
|
|
9
|
+
"example"
|
|
10
|
+
],
|
|
11
|
+
"engines": {
|
|
12
|
+
"node": ">=22.5.0"
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"start": "node example/demo.js",
|
|
16
|
+
"demo": "node example/demo.js",
|
|
17
|
+
"test": "node --test \"tests/*.test.js\"",
|
|
18
|
+
"coverage": "node --test --experimental-test-coverage --test-coverage-include=\"index.js\" --test-coverage-include=\"lib/**\" --test-coverage-lines=100 --test-coverage-branches=100 --test-coverage-functions=100 \"tests/*.test.js\""
|
|
19
|
+
},
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://gitee.com/qiuwenwu91/mm_response.git"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"mm_mod",
|
|
26
|
+
"mm_middleware",
|
|
27
|
+
"web",
|
|
28
|
+
"response",
|
|
29
|
+
"html",
|
|
30
|
+
"content-type"
|
|
31
|
+
],
|
|
32
|
+
"layer": "middleware",
|
|
33
|
+
"group": "middleware",
|
|
34
|
+
"author": "邱文武",
|
|
35
|
+
"license": "ISC",
|
|
36
|
+
"bugs": {
|
|
37
|
+
"url": "https://gitee.com/qiuwenwu91/mm_response/issues"
|
|
38
|
+
},
|
|
39
|
+
"homepage": "https://gitee.com/qiuwenwu91/mm_response#readme",
|
|
40
|
+
"dependencies": {}
|
|
41
|
+
}
|