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 CHANGED
@@ -1,3 +1,150 @@
1
- # Temporary Holding Version
1
+ # mm_response
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ 邱文武
@@ -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
@@ -0,0 +1,9 @@
1
+ 'use strict';
2
+
3
+ const Response = require('./lib/response');
4
+ const reply = require('./lib/reply');
5
+
6
+ module.exports = {
7
+ Response,
8
+ ...reply
9
+ };
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
+ };
@@ -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
- "name": "mm_response",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }