mm_web_server 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2026 Admin
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,190 @@
1
+ # mm_web
2
+
3
+ HTTP Web 服务器入站端口模块。继承 [mm_corejs](https://gitee.com/qiuwenwu91/mm_corejs) 的 `Mod` 基类,只实现**传输机制**(监听、请求解析、管道执行、统一应答),路由、跨域、身份提取等**策略**由可选插件挂载,可脱离完整框架独立运行。
4
+
5
+ - 入口:`index.js`(CommonJS)
6
+ - 导出:`WebServer`(传输层)、`RoutePlugin`(路由)、`CorsPlugin`(跨域)、`IdentityPlugin`(身份提取)
7
+ - 运行时依赖:`mm_corejs`
8
+ - 测试覆盖率:100%(`jest.config.js` 四项阈值)
9
+
10
+ ## 特性
11
+
12
+ - **纯传输层**:零第三方依赖,基于 Node 内置 `http`;不感知路由与跨域约定,策略全部插件化。
13
+ - **请求管道**:每个请求(含 `OPTIONS`)经 `Handle` 的 `before / check / main / render` 阶段处理,内置 `after` 钩子统一写出响应;`getHandle()` 可注册日志(before)、鉴权(check)、接管路由(main + `end`)等钩子。
14
+ - **请求解析**:URL / 查询串 / 请求体(JSON、表单、原文)自动解析,`body_limit` 作为安全边界。
15
+ - **兜底行为**:管道无人产出结果时触发 `request` 事件,事件内也未接管则返回 404。
16
+ - **可选插件**:`RoutePlugin`(Store 路由表 + `dispatch_route` 主钩子)、`CorsPlugin`(`before` 钩子设跨域头并短路预检)、`IdentityPlugin`(`before` 钩子按请求头填充请求选项)。
17
+ - **选项透传**:每个请求携带独立的可变 `options` 对象进入管道,随第三参透传给钩子与路由处理函数,适配多用户 / 多设备 / 多智能体场景。
18
+
19
+ ## 安装
20
+
21
+ ```bash
22
+ npm install mm_web_server
23
+ ```
24
+
25
+ ## 环境要求
26
+
27
+ - Node.js >= 22.5.0
28
+ - 模块规范:CommonJS
29
+
30
+ ## 快速开始
31
+
32
+ ```js
33
+ 'use strict';
34
+
35
+ const { WebServer, RoutePlugin, CorsPlugin, IdentityPlugin } = require('mm_web_server');
36
+
37
+ /** 最小 Server 容器:满足 Base 契约即可(见 example/demo.js 完整实现) */
38
+ const server = {
39
+ services: new Map(),
40
+ getService(name) { return this.services.get(name); },
41
+ hasService(name) { return this.services.has(name); },
42
+ setService(name, instance) { this.services.set(name, instance); return this; },
43
+ getManager() { return undefined; },
44
+ hasManager() { return false; }
45
+ };
46
+
47
+ const web = new WebServer(server, { name: 'web', host: '127.0.0.1', port: 8080 });
48
+
49
+ web.use(new CorsPlugin({ origin: '*', max_age: 600 }));
50
+ web.use(new IdentityPlugin());
51
+
52
+ const router = new RoutePlugin();
53
+ web.use(router);
54
+ router.addRoute('GET', '/hello', (ctx, params) => ({ hello: params.query.name || 'world' }));
55
+ router.addRoute('POST', '/echo', (ctx, params) => ({ echo: params.body }));
56
+
57
+ // 鉴权:check 阶段产出即短路整条管道
58
+ web.getHandle().on('check', (ctx, params) => {
59
+ if (!params.path.startsWith('/secure')) return;
60
+ if (params.headers['x-token'] === 'secret') return;
61
+ return { status: 401, body: { error: 'unauthorized' } };
62
+ }, 100, 'auth');
63
+
64
+ web.on('request', (params) => console.log('未命中路由:', params.method, params.path));
65
+ web.on('response', (status) => console.log('响应:', status));
66
+ web.on('error', (err) => console.error(err.message));
67
+
68
+ await web.init();
69
+ await web.start();
70
+
71
+ console.log(await web.run({}, { action: 'status' }));
72
+
73
+ await web.stop();
74
+ await web.dispose();
75
+ ```
76
+
77
+ ## 服务器配置(config)
78
+
79
+ 顶层 `config` 字段即服务器设置,`init` 阶段统一校验。
80
+
81
+ | 字段 | 类型 | 默认值 | 说明 |
82
+ | --- | --- | --- | --- |
83
+ | `name` | string | `'web'` | 模块名称(必填) |
84
+ | `host` | string | `'0.0.0.0'` | 监听地址 |
85
+ | `port` | integer | `8080` | 监听端口(1-65535) |
86
+ | `body_limit` | integer | `1048576` | 请求体字节上限,超限抛错并销毁连接 |
87
+
88
+ ## API
89
+
90
+ ### WebServer
91
+
92
+ | 成员 | 说明 |
93
+ | --- | --- |
94
+ | `run(ctx, params)` | 主动操作入口,`params.action` 目前仅 `status`(返回 `{ running, address }`) |
95
+ | `use(plugin)` | 挂载插件(需实现 `bind(web)`) |
96
+ | `getHandle()` | 请求管道,注册 `before / check / main / render` 等钩子 |
97
+ | `getAddress()` | 实际监听地址,未启动为 `null` |
98
+
99
+ ### 请求参数(params)
100
+
101
+ 管道与路由处理函数第二参 `params`:
102
+
103
+ | 字段 | 说明 |
104
+ | --- | --- |
105
+ | `method` | 请求方法 |
106
+ | `path` | 路径(不含查询串) |
107
+ | `query` | 查询串解析结果(对象) |
108
+ | `headers` | 请求头 |
109
+ | `body` | 请求体:JSON / 表单自动解析,其余为原文,无请求体为 `null` |
110
+ | `remote` | 来源地址 |
111
+
112
+ ### 响应写法
113
+
114
+ 管道任一阶段产出的 `result` 由内置 `after` 钩子统一写出:
115
+
116
+ | `result` 形态 | 响应 |
117
+ | --- | --- |
118
+ | `{ status, body }` | 指定状态码;`body` 为字符串按 `text/plain`,对象按 `application/json`;`body` 缺省/为 `null` 时返回空响应(未给 `status` 则 204) |
119
+ | 字符串 | `200` + `text/plain` |
120
+ | 其余值 | `200` + `application/json` |
121
+ | 无人产出 | 触发 `request` 事件;`ctx.handled` 未置真则 `404 { error: 'not found' }` |
122
+ | 钩子抛错 | `500 { error: 错误信息 }`,并经 `error` 事件通知 |
123
+
124
+ ### 事件
125
+
126
+ | 事件 | 参数 | 触发时机 |
127
+ | --- | --- | --- |
128
+ | `request` | `(params, ctx)` | 内置兜底 `main` 钩子(sort 9999)触发;在事件内自行写响应并置 `ctx.handled = true` 即接管 |
129
+ | `response` | `(status, payload)` | 内置 `after` 钩子写出 JSON / 文本响应后 |
130
+ | `error` | `(err)` | 管道钩子错误、请求体超限、`clientError` |
131
+
132
+ ### 管道顺序
133
+
134
+ ```
135
+ IdentityPlugin.before(50) → CorsPlugin.before(100) → 用户钩子 → RoutePlugin.main(1000, end)
136
+ → 内置兜底 main(9999, end) → after(1000) 统一写响应
137
+ ```
138
+
139
+ ## 插件
140
+
141
+ ### RoutePlugin
142
+
143
+ ```js
144
+ const router = new RoutePlugin();
145
+ web.use(router);
146
+ router.addRoute('GET', '/hello', (ctx, params, options) => ({ hello: params.query.name }));
147
+ router.addRoute('*', '/any', () => ({ ok: true })); // '*' 通配方法
148
+ ```
149
+
150
+ | 成员 | 说明 |
151
+ | --- | --- |
152
+ | `addRoute(method, path, handler)` | 注册路由(同 `method + path` 重复注册覆盖),处理函数签名 `(ctx, params, options)` |
153
+ | `delRoute(method, path)` | 删除路由 |
154
+ | `listRoutes(query)` | 路由列表(`{ method, path }`),支持 Store 条件 / 分页 |
155
+ | `bind(web)` / `isBound()` | 挂载与状态查询 |
156
+
157
+ 匹配规则:先精确方法,再 `'*'` 通配;未命中返回 `undefined` 放行给后续钩子与兜底逻辑。
158
+
159
+ ### CorsPlugin
160
+
161
+ ```js
162
+ web.use(new CorsPlugin({ origin: '*', methods: ['GET', 'POST'], headers: 'content-type', max_age: 600, credentials: false }));
163
+ ```
164
+
165
+ `before` 钩子(sort 100)为每个响应设置 `access-control-*` 头;`OPTIONS` 请求短路返回 `204`。不挂载即无跨域头。
166
+
167
+ ### IdentityPlugin
168
+
169
+ ```js
170
+ web.use(new IdentityPlugin()); // x-user-id / x-device-id / x-agent-id
171
+ web.use(new IdentityPlugin({ headers: { 'x-tenant': 'tenant_id' } })); // 自定义映射
172
+ ```
173
+
174
+ `before` 钩子(sort 50,先于 CORS 与鉴权)把请求头中的身份原地写入管道 `options`,后续钩子与路由处理函数经第三参读取。不挂载即无身份约定。
175
+
176
+ ## 示例
177
+
178
+ ```bash
179
+ npm run demo # 启动 Web 演示服务器(127.0.0.1:8080),见 example/demo.js
180
+ ```
181
+
182
+ ## 测试
183
+
184
+ ```bash
185
+ npm test # jest --coverage,四项覆盖率阈值均为 100%
186
+ ```
187
+
188
+ ## 许可证
189
+
190
+ ISC
package/index.js ADDED
@@ -0,0 +1,13 @@
1
+ 'use strict';
2
+
3
+ const WebServer = require('./lib/web');
4
+ const RoutePlugin = require('./lib/route');
5
+ const CorsPlugin = require('./lib/cors');
6
+ const IdentityPlugin = require('./lib/identity');
7
+
8
+ module.exports = {
9
+ WebServer,
10
+ RoutePlugin,
11
+ CorsPlugin,
12
+ IdentityPlugin
13
+ };
package/lib/cors.js ADDED
@@ -0,0 +1,102 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 默认允许的跨域请求方法集合
5
+ */
6
+ const DEFAULT_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'];
7
+
8
+ /**
9
+ * CorsPlugin —— CORS 跨域策略插件(mm_web 的可选业务组件)。
10
+ *
11
+ * 职责:
12
+ * 1. 响应头:bind(web) 后向 WebServer 的请求管道注册 before 钩子 "cors",
13
+ * 为每个响应设置 access-control-* 头(在 check/main 短路前已生效);
14
+ * 2. 预检短路:OPTIONS 请求由 before 钩子产出 { status: 204 } 短路整条管道,
15
+ * 响应由 WebServer 内置 after 钩子统一写出;
16
+ * 3. 不挂载即无 CORS:传输层不感知跨域策略。
17
+ *
18
+ * 用法:
19
+ * const web = new WebServer(server, { name: 'web', port: 8080 });
20
+ * web.use(new CorsPlugin({ origin: '*', max_age: 600 }));
21
+ * await web.init();
22
+ * await web.start();
23
+ */
24
+ class CorsPlugin {
25
+ /**
26
+ * 跨域策略配置
27
+ */
28
+ #config = {
29
+ origin: '*',
30
+ methods: DEFAULT_METHODS,
31
+ headers: 'content-type',
32
+ max_age: 600,
33
+ credentials: false
34
+ };
35
+
36
+ /**
37
+ * 已挂载的 WebServer 实例,未挂载时为 null
38
+ */
39
+ #web = null;
40
+
41
+ /**
42
+ * 构造函数
43
+ * @param {Object} [config] 跨域策略配置,含 origin / methods / headers / max_age / credentials
44
+ */
45
+ constructor(config = {}) {
46
+ this.#config = { ...this.#config, ...config };
47
+ }
48
+
49
+ /**
50
+ * 挂载到 WebServer 的请求管道,注册 before 钩子
51
+ * @param {Object} web 具备 getHandle() 的 WebServer 实例
52
+ * @returns {CorsPlugin} 当前实例
53
+ * @throws {TypeError} web 不具备 getHandle 方法时
54
+ */
55
+ bind(web) {
56
+ if (!web || typeof web.getHandle !== 'function') {
57
+ throw new TypeError('[CorsPlugin] web must provide getHandle()');
58
+ }
59
+ this.#web = web;
60
+ web.getHandle().on('before', (ctx, params) => {
61
+ return this._handle(ctx, params);
62
+ }, 100, 'cors');
63
+ return this;
64
+ }
65
+
66
+ /**
67
+ * 判断是否已挂载
68
+ * @returns {boolean} 是否已挂载到 WebServer
69
+ */
70
+ isBound() {
71
+ return this.#web !== null;
72
+ }
73
+
74
+ /**
75
+ * 内置 before 钩子逻辑:设置跨域响应头;OPTIONS 预检短路返回 204
76
+ * @param {Object} ctx 执行上下文
77
+ * @param {Object} params 请求参数
78
+ * @returns {Object|undefined} 预检请求返回 204 描述对象;其余请求返回 undefined 放行
79
+ */
80
+ _handle(ctx, params) {
81
+ this._setHeaders(ctx.res);
82
+ if (params.method !== 'OPTIONS') return;
83
+ return { status: 204 };
84
+ }
85
+
86
+ /**
87
+ * 按配置设置 access-control-* 响应头
88
+ * @param {Object} res 响应对象
89
+ */
90
+ _setHeaders(res) {
91
+ const config = this.#config;
92
+ res.setHeader('access-control-allow-origin', config.origin);
93
+ res.setHeader('access-control-allow-methods', config.methods.join(', '));
94
+ res.setHeader('access-control-allow-headers', config.headers);
95
+ res.setHeader('access-control-max-age', String(config.max_age));
96
+ if (config.credentials) {
97
+ res.setHeader('access-control-allow-credentials', 'true');
98
+ }
99
+ }
100
+ }
101
+
102
+ module.exports = CorsPlugin;
@@ -0,0 +1,93 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 缺省的身份请求头到请求选项字段的映射
5
+ */
6
+ const DEFAULT_HEADERS = {
7
+ 'x-user-id': 'user_id',
8
+ 'x-device-id': 'device_id',
9
+ 'x-agent-id': 'agent_id'
10
+ };
11
+
12
+ /**
13
+ * 身份提取 before 钩子的排序(先于 CorsPlugin 的 100,保证鉴权钩子可读身份)
14
+ */
15
+ const HOOK_SORT = 50;
16
+
17
+ /**
18
+ * IdentityPlugin —— 请求身份提取插件(mm_web 的可选业务组件)。
19
+ *
20
+ * 职责:
21
+ * 1. 身份映射:按 headers 配置(请求头 → options 字段)从请求头提取调用方身份;
22
+ * 2. 选项写入:bind(web) 后向请求管道注册 before 钩子 "identity"(sort 50),
23
+ * 把提取结果原地写入管道 options,后续钩子与路由处理函数经第三参读取;
24
+ * 3. 不挂载即无身份:传输层不感知身份请求头约定,映射可由使用方自定义。
25
+ *
26
+ * 用法:
27
+ * const web = new WebServer(server, { name: 'web', port: 8080 });
28
+ * web.use(new IdentityPlugin());
29
+ * web.use(new IdentityPlugin({ headers: { 'x-tenant': 'tenant_id' } }));
30
+ * router.addRoute('GET', '/whoami', (ctx, params, options) => options);
31
+ * await web.init();
32
+ * await web.start();
33
+ */
34
+ class IdentityPlugin {
35
+ /**
36
+ * 身份映射配置:请求头名 → options 字段名
37
+ */
38
+ #headers = DEFAULT_HEADERS;
39
+
40
+ /**
41
+ * 已挂载的 WebServer 实例,未挂载时为 null
42
+ */
43
+ #web = null;
44
+
45
+ /**
46
+ * 构造函数
47
+ * @param {Object} [config] 配置,含 headers(请求头 → options 字段映射,缺省用 DEFAULT_HEADERS)
48
+ */
49
+ constructor(config = {}) {
50
+ if (config.headers) this.#headers = { ...config.headers };
51
+ }
52
+
53
+ /**
54
+ * 挂载到 WebServer 的请求管道,注册身份提取 before 钩子
55
+ * @param {Object} web 具备 getHandle() 的 WebServer 实例
56
+ * @returns {IdentityPlugin} 当前实例
57
+ * @throws {TypeError} web 不具备 getHandle 方法时
58
+ */
59
+ bind(web) {
60
+ if (!web || typeof web.getHandle !== 'function') {
61
+ throw new TypeError('[IdentityPlugin] web must provide getHandle()');
62
+ }
63
+ this.#web = web;
64
+ web.getHandle().on('before', (ctx, params, options) => {
65
+ this._extract(params, options);
66
+ }, HOOK_SORT, 'identity');
67
+ return this;
68
+ }
69
+
70
+ /**
71
+ * 判断是否已挂载
72
+ * @returns {boolean} 是否已挂载到 WebServer
73
+ */
74
+ isBound() {
75
+ return this.#web !== null;
76
+ }
77
+
78
+ /**
79
+ * 按映射从请求头提取身份,原地写入管道 options(不返回值,避免 before 短路)
80
+ * @param {Object} params 请求参数,含 headers
81
+ * @param {Object} options 请求选项对象(管道同一引用,后续钩子与处理函数可见)
82
+ */
83
+ _extract(params, options) {
84
+ const headers = params.headers;
85
+ const names = Object.keys(this.#headers);
86
+ for (const name of names) {
87
+ const header_value = headers[name];
88
+ if (header_value) options[this.#headers[name]] = header_value;
89
+ }
90
+ }
91
+ }
92
+
93
+ module.exports = IdentityPlugin;
package/lib/route.js ADDED
@@ -0,0 +1,136 @@
1
+ 'use strict';
2
+
3
+ const { Store } = require('mm_corejs');
4
+
5
+ /**
6
+ * RoutePlugin —— HTTP 路由插件(mm_web 的可选业务组件)。
7
+ *
8
+ * 职责:
9
+ * 1. 路由表:#routes 使用 Store 存储(主键 "METHOD /path"),支持条件查询与分页;
10
+ * 2. 管道挂载:bind(web) 后向 WebServer 的请求管道注册内置 main 钩子
11
+ * "dispatch_route"(sort 1000、end 标记),命中路由时以处理函数返回值作为响应;
12
+ * 3. 未命中放行:无匹配路由时钩子返回 undefined,控制权交还管道
13
+ * (由 WebServer 的 request 事件兜底或后续钩子接管);
14
+ * 4. 选项透传:管道第三参 options(身份请求头提取的请求选项)
15
+ * 原样透传给路由处理函数第三参。
16
+ *
17
+ * 用法:
18
+ * const web = new WebServer(server, { name: 'web', port: 8080 });
19
+ * const router = new RoutePlugin();
20
+ * web.use(router);
21
+ * router.addRoute('GET', '/hello', (ctx, params, options) => ({ hello: params.query.name }));
22
+ * await web.init();
23
+ * await web.start();
24
+ */
25
+ class RoutePlugin {
26
+ /**
27
+ * 路由表:Store 存储,主键 "METHOD /path"、名称字段 path
28
+ */
29
+ #routes = new Store({ key: 'id', name: 'path' });
30
+
31
+ /**
32
+ * 已挂载的 WebServer 实例,未挂载时为 null
33
+ */
34
+ #web = null;
35
+
36
+ /**
37
+ * 挂载到 WebServer 的请求管道,注册内置路由分发钩子
38
+ * @param {Object} web 具备 getHandle() 的 WebServer 实例
39
+ * @returns {RoutePlugin} 当前实例
40
+ * @throws {TypeError} web 不具备 getHandle 方法时
41
+ */
42
+ bind(web) {
43
+ if (!web || typeof web.getHandle !== 'function') {
44
+ throw new TypeError('[RoutePlugin] web must provide getHandle()');
45
+ }
46
+ this.#web = web;
47
+ web.getHandle().on('main', (ctx, params, options) => {
48
+ return this._dispatch(ctx, params, options);
49
+ }, 1000, 'dispatch_route', { end: true });
50
+ return this;
51
+ }
52
+
53
+ /**
54
+ * 判断是否已挂载
55
+ * @returns {boolean} 是否已挂载到 WebServer
56
+ */
57
+ isBound() {
58
+ return this.#web !== null;
59
+ }
60
+
61
+ /**
62
+ * 注册路由(同 method + path 重复注册时覆盖)
63
+ * @param {string} method 请求方法,'*' 表示通配
64
+ * @param {string} path 路径,以 / 开头
65
+ * @param {Function} handler 处理函数,签名 handler(ctx, params, options),返回值决定响应;options 为请求选项(身份请求头提取的 user_id / device_id / agent_id 等)
66
+ * @returns {RoutePlugin} 当前实例
67
+ * @throws {TypeError} handler 不是函数时
68
+ */
69
+ addRoute(method, path, handler) {
70
+ if (typeof handler !== 'function') {
71
+ throw new TypeError('[RoutePlugin] handler must be a function');
72
+ }
73
+ const key = this._routeKey(method, path);
74
+ this.#routes.delById(key);
75
+ this.#routes.add({ id: key, name: path, method, path, handler });
76
+ return this;
77
+ }
78
+
79
+ /**
80
+ * 删除路由
81
+ * @param {string} method 请求方法
82
+ * @param {string} path 路径
83
+ * @returns {boolean} 是否删除成功
84
+ */
85
+ delRoute(method, path) {
86
+ const key = this._routeKey(method, path);
87
+ return this.#routes.delById(key);
88
+ }
89
+
90
+ /**
91
+ * 列出路由(支持 Store 条件查询与分页)
92
+ * @param {Object} [query] 查询条件,缺省时列出全部
93
+ * @returns {Array<Object>} 路由列表,含 method 与 path
94
+ */
95
+ listRoutes(query) {
96
+ return this.#routes.list(query).map((route) => ({ method: route.method, path: route.path }));
97
+ }
98
+
99
+ /**
100
+ * 内置 main 钩子逻辑:命中路由调用处理函数,未命中返回 undefined 放行
101
+ * @param {Object} ctx 执行上下文
102
+ * @param {Object} params 请求参数
103
+ * @param {Object} options 请求选项,透传给处理函数第三参
104
+ * @returns {Promise<*>} 处理函数返回值(作为管道 result);未命中时为 undefined
105
+ */
106
+ async _dispatch(ctx, params, options) {
107
+ const route = this._matchRoute(params.method, params.path);
108
+ if (!route) return undefined;
109
+ return await route.handler(ctx, params, options);
110
+ }
111
+
112
+ /**
113
+ * 匹配路由:先精确方法,再 '*' 通配
114
+ * @param {string} method 请求方法
115
+ * @param {string} path 请求路径
116
+ * @returns {Object|null} 命中的路由项(含 handler),未命中返回 null
117
+ */
118
+ _matchRoute(method, path) {
119
+ const exact = this.#routes.getById(this._routeKey(method, path));
120
+ if (exact) return exact;
121
+ const wildcard = this.#routes.getById(this._routeKey('*', path));
122
+ return wildcard || null;
123
+ }
124
+
125
+ /**
126
+ * 组装路由表键
127
+ * @param {string} method 请求方法
128
+ * @param {string} path 请求路径
129
+ * @returns {string} 形如 "GET /hello" 的键
130
+ */
131
+ _routeKey(method, path) {
132
+ return `${method} ${path}`;
133
+ }
134
+ }
135
+
136
+ module.exports = RoutePlugin;
package/lib/web.js ADDED
@@ -0,0 +1,445 @@
1
+ 'use strict';
2
+
3
+ const http = require('http');
4
+ const { URL, URLSearchParams } = require('url');
5
+ const { Mod, Handle } = require('mm_corejs');
6
+
7
+ /**
8
+ * 服务器设置校验 schema(校验对象为顶层 config)
9
+ */
10
+ const SERVER_SCHEMA = {
11
+ type: 'object',
12
+ properties: {
13
+ host: { type: 'string', min_length: 1 },
14
+ port: { type: 'integer', minimum: 1, maximum: 65535 },
15
+ body_limit: { type: 'integer', minimum: 1 }
16
+ }
17
+ };
18
+
19
+ /**
20
+ * WebServer —— HTTP Web 传输层(入站端口模块)。
21
+ *
22
+ * 继承 mm_corejs 的 Mod:生命周期由 Lifecycle 模板方法驱动,
23
+ * 调用参数校验由 config.params 声明式定义;
24
+ * 服务器设置(host / port / body_limit)为顶层 config 字段,
25
+ * 在 onInit 阶段按 SERVER_SCHEMA 统一校验。
26
+ *
27
+ * 职责(仅传输机制,路由 / CORS 等策略由插件挂载):
28
+ * 1. 监听:onStart 创建 http.Server 并按 host / port 监听;
29
+ * 2. 请求解析:URL、查询串、请求体读取与解析(body_limit 为安全边界);
30
+ * 3. 请求管道:每个请求(含 OPTIONS)经 Handle 的 before/check/main/render
31
+ * 阶段处理,内置 after 钩子统一写响应(result 为 {status,body} 描述对象、
32
+ * 字符串或 JSON);getHandle() 可注册鉴权(check)、日志(before)等钩子;
33
+ * 4. 插件挂载:use(plugin) 调用 plugin.bind(this),
34
+ * 如 RoutePlugin 注册 main 钩子做路由分发、CorsPlugin 注册 before 钩子
35
+ * 设跨域头并短路 OPTIONS 预检、IdentityPlugin 注册 before 钩子提取身份;
36
+ * 5. 兜底行为:管道无人产出时触发 request 事件,仍无人接管则返回 404;
37
+ * 6. 主动操作:run(ctx, params) 按 params.action 派发 status;
38
+ * 7. 请求选项:每个请求以独立的可变 options 对象进入管道,随第三参透传给
39
+ * 钩子与路由处理函数;身份提取等填充逻辑由插件承担(如 IdentityPlugin
40
+ * 的 before 钩子按请求头写入 user_id / device_id / agent_id)。
41
+ *
42
+ * 用法:
43
+ * const web = new WebServer(server, { name: 'web', port: 8080 });
44
+ * web.use(new CorsPlugin({ origin: '*' }));
45
+ * web.use(new IdentityPlugin());
46
+ * const router = new RoutePlugin();
47
+ * web.use(router);
48
+ * router.addRoute('GET', '/hello', (ctx, params, options) => ({ hello: params.query.name || 'world' }));
49
+ * web.getHandle().on('check', (ctx, params) => {
50
+ * if (params.headers['x-token'] !== 'secret') return { status: 401, body: { error: 'unauthorized' } };
51
+ * }, 100, 'auth');
52
+ * await web.init();
53
+ * await web.start();
54
+ * await web.run({}, { action: 'status' });
55
+ */
56
+ class WebServer extends Mod {
57
+ /**
58
+ * 模块默认配置
59
+ */
60
+ static config = {
61
+ name: 'web',
62
+ title: 'Web服务器模块',
63
+ description: 'HTTP Web 传输层:解析请求、执行处理管道并统一应答,路由与跨域等策略由插件挂载',
64
+ version: '1.0.0',
65
+ host: '0.0.0.0',
66
+ port: 8080,
67
+ body_limit: 1048576,
68
+ options: {},
69
+ params: {
70
+ type: 'object',
71
+ required: ['action'],
72
+ properties: {
73
+ action: { type: 'string', enum: ['status'] }
74
+ }
75
+ }
76
+ };
77
+
78
+ /**
79
+ * 底层 HTTP 服务器实例
80
+ */
81
+ #server = null;
82
+
83
+ /**
84
+ * 请求处理管道(before/check/main/render/error/success/after)
85
+ */
86
+ #handle = new Handle();
87
+
88
+ // ---------- 生命周期钩子 ----------
89
+ /**
90
+ * 初始化钩子:校验服务器设置并装配请求管道
91
+ */
92
+ async onInit() {
93
+ await this._validateConfig();
94
+ this._initHandle();
95
+ }
96
+
97
+ /**
98
+ * 启动钩子:创建 HTTP 服务器并开始监听
99
+ */
100
+ async onStart() {
101
+ const server = http.createServer((req, res) => {
102
+ this._handleRequest(req, res).catch((err) => this._emitError(err));
103
+ });
104
+ this.#server = server;
105
+ await this._listen(server);
106
+ server.on('clientError', (err) => this._emitError(err));
107
+ }
108
+
109
+ /**
110
+ * 停止钩子:关闭服务器并断开残留连接
111
+ */
112
+ async onStop() {
113
+ await this._closeServer();
114
+ }
115
+
116
+ // ---------- 执行入口 ----------
117
+ /**
118
+ * 模块主逻辑:按 action 派发传输层操作
119
+ * @param {Object} ctx 执行上下文
120
+ * @param {Object} params 调用参数,含 action 及操作所需字段
121
+ * @param {Object} options 合并后的选项
122
+ * @returns {Promise<Object>} 操作结果
123
+ */
124
+ async main(ctx, params, options) {
125
+ return this._actionStatus();
126
+ }
127
+
128
+ /**
129
+ * 执行状态查询操作
130
+ * @returns {Object} 操作结果,含运行状态与监听地址
131
+ */
132
+ _actionStatus() {
133
+ const address = this.getAddress();
134
+ return {
135
+ action: 'status',
136
+ running: this.isRunning(),
137
+ address: address ? `${address.address}:${address.port}` : null
138
+ };
139
+ }
140
+
141
+ // ---------- 插件挂载 ----------
142
+ /**
143
+ * 挂载插件:调用 plugin.bind(this),由插件自行向管道注册钩子;
144
+ * 如 RoutePlugin 挂载后接管路由分发、IdentityPlugin 挂载后填充请求 options
145
+ * @param {Object} plugin 具备 bind(web) 方法的插件实例
146
+ * @returns {WebServer} 当前实例
147
+ * @throws {TypeError} plugin 不具备 bind 方法时
148
+ */
149
+ use(plugin) {
150
+ if (!plugin || typeof plugin.bind !== 'function') {
151
+ throw new TypeError(`${this._label()} plugin must provide bind()`);
152
+ }
153
+ plugin.bind(this);
154
+ return this;
155
+ }
156
+
157
+ /**
158
+ * 获取请求处理管道,供外部注册 before/check/main/render 等钩子;
159
+ * main 钩子产出 result 时接管响应(建议以 { end: true } 登记跳过后续处理器)
160
+ * @returns {Handle} 请求管道实例
161
+ */
162
+ getHandle() {
163
+ return this.#handle;
164
+ }
165
+
166
+ /**
167
+ * 获取实际监听地址
168
+ * @returns {Object|null} 监听地址信息,未启动时为 null
169
+ */
170
+ getAddress() {
171
+ const server = this.#server;
172
+ return server ? server.address() : null;
173
+ }
174
+
175
+ // ---------- 请求处理 ----------
176
+ /**
177
+ * 处理单个 HTTP 请求:解析后统一交由管道分发
178
+ * @param {Object} req 请求对象
179
+ * @param {Object} res 响应对象
180
+ * @returns {Promise<void>} 无返回值
181
+ */
182
+ async _handleRequest(req, res) {
183
+ const parsed = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
184
+ const body = await this._readBody(req);
185
+ const params = this._buildParams(req, parsed, body);
186
+ const ctx = this._buildCtx(req, res, parsed, params);
187
+ await this._dispatch(ctx, params, {});
188
+ }
189
+
190
+ /**
191
+ * 分发请求:整条请求经 Handle 管道处理,响应由内置 after 钩子统一写出
192
+ * @param {Object} ctx 执行上下文
193
+ * @param {Object} params 请求参数
194
+ * @param {Object} options 请求选项(管道同一引用,插件钩子可原地填充,透传给后续钩子与路由处理函数)
195
+ * @returns {Promise<void>} 无返回值
196
+ */
197
+ async _dispatch(ctx, params, options) {
198
+ const done = await this.#handle.run(ctx, params, options);
199
+ if (done.error) this._emitError(done.error);
200
+ }
201
+
202
+ /**
203
+ * 构建调用方参数对象
204
+ * @param {Object} req 请求对象
205
+ * @param {Object} parsed 解析后的 URL
206
+ * @param {string} body 请求体原文
207
+ * @returns {Object} 含 method / path / query / headers / body / remote 的参数
208
+ */
209
+ _buildParams(req, parsed, body) {
210
+ return {
211
+ method: req.method,
212
+ path: parsed.pathname,
213
+ query: Object.fromEntries(parsed.searchParams),
214
+ headers: req.headers,
215
+ body: this._parseBody(body, req.headers['content-type']),
216
+ remote: req.socket.remoteAddress
217
+ };
218
+ }
219
+
220
+ /**
221
+ * 构建处理函数上下文
222
+ * @param {Object} req 请求对象
223
+ * @param {Object} res 响应对象
224
+ * @param {Object} parsed 解析后的 URL
225
+ * @param {Object} params 调用方参数
226
+ * @returns {Object} 上下文对象
227
+ */
228
+ _buildCtx(req, res, parsed, params) {
229
+ return {
230
+ req,
231
+ res,
232
+ url: parsed.href,
233
+ params,
234
+ handled: false
235
+ };
236
+ }
237
+
238
+ /**
239
+ * 解析请求体:JSON 优先,表单其次,其余保持原文
240
+ * @param {string} body 请求体原文
241
+ * @param {string} contentType 内容类型头
242
+ * @returns {*} 解析结果
243
+ */
244
+ _parseBody(body, contentType) {
245
+ if (!body) return null;
246
+ if (contentType && contentType.includes('application/json')) {
247
+ return this._parseJson(body);
248
+ }
249
+ if (contentType && contentType.includes('application/x-www-form-urlencoded')) {
250
+ return Object.fromEntries(new URLSearchParams(body));
251
+ }
252
+ return body;
253
+ }
254
+
255
+ /**
256
+ * 尝试将文本解析为 JSON
257
+ * @param {string} text 文本内容
258
+ * @returns {Object|string} 解析结果,失败时返回原文
259
+ */
260
+ _parseJson(text) {
261
+ try {
262
+ return JSON.parse(text);
263
+ } catch {
264
+ return text;
265
+ }
266
+ }
267
+
268
+ /**
269
+ * 读取请求体全文
270
+ * @param {Object} req 请求对象
271
+ * @returns {Promise<string>} 请求体文本
272
+ */
273
+ _readBody(req) {
274
+ const limit = this.config.body_limit;
275
+ return new Promise((resolve, reject) => {
276
+ const chunks = [];
277
+ let length = 0;
278
+ req.on('data', (chunk) => {
279
+ length += chunk.length;
280
+ if (length > limit) {
281
+ reject(new Error(`${this._label()} body too large`));
282
+ req.destroy();
283
+ return;
284
+ }
285
+ chunks.push(chunk);
286
+ });
287
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
288
+ req.on('error', reject);
289
+ });
290
+ }
291
+
292
+ // ---------- 请求管道 ----------
293
+ /**
294
+ * 装配内置管道钩子:main 兜底触发 request 事件,after 统一写响应
295
+ */
296
+ _initHandle() {
297
+ this.#handle.on('main', (ctx, params) => {
298
+ return this._fallbackRequest(ctx, params);
299
+ }, 9999, 'fallback_request', { end: true });
300
+ this.#handle.on('after', (ctx) => {
301
+ this._writeResponse(ctx);
302
+ }, 1000, 'write_response');
303
+ }
304
+
305
+ /**
306
+ * 内置 main 兜底钩子逻辑:管道无人产出时触发 request 事件,仍未接管则 404
307
+ * @param {Object} ctx 执行上下文
308
+ * @param {Object} params 请求参数
309
+ * @returns {Object|undefined} 响应描述对象;事件已接管时为 undefined
310
+ */
311
+ _fallbackRequest(ctx, params) {
312
+ this._safeEmit('request', params, ctx);
313
+ if (ctx.handled) return undefined;
314
+ return { status: 404, body: { error: 'not found' } };
315
+ }
316
+
317
+ /**
318
+ * 内置 after 钩子逻辑:按 ctx.result / ctx.error 写出响应
319
+ * @param {Object} ctx 执行上下文
320
+ */
321
+ _writeResponse(ctx) {
322
+ const res = ctx.res;
323
+ if (ctx.handled || res.headersSent) return;
324
+ if (ctx.error) {
325
+ this._sendJson(res, 500, { error: ctx.error.message || 'internal error' });
326
+ return;
327
+ }
328
+ const result = ctx.result;
329
+ if (result === undefined) return;
330
+ if (!this._isResponse(result)) {
331
+ this._replyBody(res, 200, result);
332
+ return;
333
+ }
334
+ const status = result.status === undefined ? 200 : result.status;
335
+ if (result.body === undefined || result.body === null) {
336
+ this._sendEmpty(res, result.status === undefined ? 204 : status);
337
+ return;
338
+ }
339
+ this._replyBody(res, status, result.body);
340
+ }
341
+
342
+ /**
343
+ * 判断值是否为响应描述对象(含 status 或 body 字段)
344
+ * @param {*} value 待判断的值
345
+ * @returns {boolean} 是否为响应描述对象
346
+ */
347
+ _isResponse(value) {
348
+ if (value === null || typeof value !== 'object') return false;
349
+ return 'status' in value || 'body' in value;
350
+ }
351
+
352
+ /**
353
+ * 按响应体类型写出响应
354
+ * @param {Object} res 响应对象
355
+ * @param {number} status 状态码
356
+ * @param {*} body 响应体
357
+ */
358
+ _replyBody(res, status, body) {
359
+ if (typeof body === 'string') {
360
+ this._sendText(res, status, body);
361
+ return;
362
+ }
363
+ this._sendJson(res, status, body);
364
+ }
365
+
366
+ /**
367
+ * 发送 JSON 响应
368
+ * @param {Object} res 响应对象
369
+ * @param {number} status 状态码
370
+ * @param {Object} payload 响应体对象
371
+ */
372
+ _sendJson(res, status, payload) {
373
+ const text = JSON.stringify(payload);
374
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
375
+ res.end(text);
376
+ this._safeEmit('response', status, payload);
377
+ }
378
+
379
+ /**
380
+ * 发送纯文本响应
381
+ * @param {Object} res 响应对象
382
+ * @param {number} status 状态码
383
+ * @param {string} text 响应文本
384
+ */
385
+ _sendText(res, status, text) {
386
+ res.writeHead(status, { 'content-type': 'text/plain; charset=utf-8' });
387
+ res.end(text);
388
+ this._safeEmit('response', status, text);
389
+ }
390
+
391
+ /**
392
+ * 发送空响应
393
+ * @param {Object} res 响应对象
394
+ * @param {number} status 状态码
395
+ */
396
+ _sendEmpty(res, status) {
397
+ res.writeHead(status);
398
+ res.end();
399
+ }
400
+
401
+ // ---------- 监听与清理 ----------
402
+ /**
403
+ * 等待 HTTP 服务器监听成功
404
+ * @param {Object} server http.Server 实例
405
+ * @returns {Promise<void>} 无返回值
406
+ */
407
+ _listen(server) {
408
+ const host = this.config.host;
409
+ const port = this.config.port;
410
+ return new Promise((resolve, reject) => {
411
+ server.once('error', reject);
412
+ server.listen({ host, port }, () => {
413
+ server.removeListener('error', reject);
414
+ resolve();
415
+ });
416
+ });
417
+ }
418
+
419
+ /**
420
+ * 关闭 HTTP 服务器并断开残留连接
421
+ * @returns {Promise<void>} 无返回值
422
+ */
423
+ _closeServer() {
424
+ const server = this.#server;
425
+ if (!server) return Promise.resolve();
426
+ this.#server = null;
427
+ return new Promise((resolve) => {
428
+ if (typeof server.closeAllConnections === 'function') server.closeAllConnections();
429
+ server.close(resolve);
430
+ });
431
+ }
432
+
433
+ // ---------- 校验辅助 ----------
434
+ /**
435
+ * 校验顶层 config 中的服务器设置
436
+ * @returns {Promise<void>} 无返回值
437
+ * @throws {Error} 校验不通过时
438
+ */
439
+ async _validateConfig() {
440
+ const tip = await this._validate(this.config, SERVER_SCHEMA);
441
+ if (tip) throw new Error(`${this._label()} invalid config: ${tip}`);
442
+ }
443
+ }
444
+
445
+ module.exports = WebServer;
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "mm_web_server",
3
+ "version": "1.0.0",
4
+ "description": "HTTP Web 服务器入站端口模块,继承 mm_corejs 的 Mod 基类,支持独立运行。",
5
+ "keywords": [
6
+ "超级美眉",
7
+ "入站端口",
8
+ "mm_mod",
9
+ "mm_in_port",
10
+ "mm_receiver",
11
+ "web",
12
+ "http",
13
+ "server",
14
+ "路由"
15
+ ],
16
+ "license": "ISC",
17
+ "author": "qww",
18
+ "type": "commonjs",
19
+ "main": "index.js",
20
+ "files": [
21
+ "index.js",
22
+ "lib"
23
+ ],
24
+ "engines": {
25
+ "node": ">=22.5.0"
26
+ },
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://gitee.com/qiuwenwu91/mm_web.git"
30
+ },
31
+ "homepage": "https://gitee.com/qiuwenwu91/mm_web",
32
+ "bugs": {
33
+ "url": "https://gitee.com/qiuwenwu91/mm_web/issues"
34
+ },
35
+ "scripts": {
36
+ "start": "node example/demo.js",
37
+ "demo": "node example/demo.js",
38
+ "test": "jest --coverage"
39
+ },
40
+ "dependencies": {
41
+ "mm_corejs": "^1.1.0"
42
+ },
43
+ "devDependencies": {
44
+ "jest": "^30.5.2"
45
+ }
46
+ }