mm_mqtt_server 0.0.0-stage → 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 CHANGED
@@ -1,3 +1,160 @@
1
- # Temporary Holding Version
1
+ # mm_mqtt_server
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
+ MQTT 代理服务器入站端口模块。继承 [mm_corejs](https://gitee.com/qiuwenwu91/mm_corejs) 的 `Mod` 基类,基于 [aedes](https://www.npmjs.com/package/aedes) 实现 MQTT Broker,提供连接管理、消息处理管道与 JSON-RPC 2.0 消息插件,可脱离完整框架独立运行。
4
+
5
+ - 入口:`index.js`(CommonJS)
6
+ - 导出:`MqttServer`(代理模块)、`MessagePlugin`(JSON-RPC 消息插件)
7
+ - 运行时依赖:`mm_corejs`、`aedes`
8
+ - 测试覆盖率:100%(`jest.config.js` 四项阈值)
9
+
10
+ ## 特性
11
+
12
+ - **标准 Broker**:`net.Server` + `aedes` 监听 TCP 1883,支持订阅、发布、QoS 0-2 与保留消息。
13
+ - **认证策略**:`allow_anonymous` 放行匿名连接;配置 `username` 后校验用户名与口令,失败以 `returnCode 5` 拒绝。
14
+ - **消息管道**:入站发布消息经 `Handle` 的 `before / check / main / render` 阶段处理,管道产出 `{ topic, payload }` 时由代理重新发布(可用于改写、应答);`getHandle()` 可注册主题守卫等钩子。
15
+ - **连接管理**:`Store` 登记在线客户端,支持 `publish / closeClient / listClients(条件查询与分页)/ hasClient / setClientOptions`。
16
+ - **JSON-RPC 2.0 插件**:`MessagePlugin` 挂载后接管 RPC 形态报文,应答发布到「请求主题 + 后缀」,支持通知回推、主动推送与服务器反向调用客户端方法。
17
+ - **选项透传**:连接级 `options`(user_id / device_id / agent_id 等)随每条入站消息透传给管道钩子与 RPC 处理函数,适配多用户 / 多设备 / 多智能体场景。
18
+
19
+ ## 安装
20
+
21
+ ```bash
22
+ npm install mm_mqtt_server
23
+ ```
24
+
25
+ ## 环境要求
26
+
27
+ - Node.js >= 22.5.0
28
+ - 模块规范:CommonJS
29
+
30
+ ## 快速开始
31
+
32
+ ```js
33
+ 'use strict';
34
+
35
+ const { MqttServer, MessagePlugin } = require('mm_mqtt_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 mqtt = new MqttServer(server, { name: 'mqtt_server', host: '127.0.0.1', port: 1883 });
48
+
49
+ // 主题守卫:check 阶段产出即短路
50
+ mqtt.getHandle().on('check', (ctx, params) => {
51
+ if (params.topic.startsWith('blocked/')) return { dropped: true };
52
+ }, 100, 'guard');
53
+
54
+ // JSON-RPC 插件
55
+ const rpc = new MessagePlugin({ reply_suffix: '/response', qos: 0 });
56
+ mqtt.use(rpc);
57
+ rpc.addMethod('sum', (params, extra) => ({ sum: params.reduce((a, b) => a + b, 0), from: extra.topic }));
58
+
59
+ // 事件
60
+ mqtt.on('connection', (client) => mqtt.setClientOptions(client.id, { device_id: 'd1' }));
61
+ mqtt.on('message', (client_id, info) => console.log(client_id, info.topic, info.payload));
62
+ mqtt.on('subscribe', (client_id, topics) => console.log(client_id, topics));
63
+ mqtt.on('close', (client_id) => console.log('断开', client_id));
64
+ mqtt.on('error', (err) => console.error(err.message));
65
+
66
+ await mqtt.init();
67
+ await mqtt.start();
68
+
69
+ // 主动操作
70
+ await mqtt.run({}, { action: 'publish', topic: 'mm/test', payload: 'hello' });
71
+ await mqtt.run({}, { action: 'list_clients' });
72
+
73
+ await mqtt.stop();
74
+ await mqtt.dispose();
75
+ ```
76
+
77
+ ## 服务器配置(config)
78
+
79
+ 顶层 `config` 字段即服务器设置,`init` 阶段统一校验。
80
+
81
+ | 字段 | 类型 | 默认值 | 说明 |
82
+ | --- | --- | --- | --- |
83
+ | `name` | string | `'mqtt_server'` | 模块名称(必填) |
84
+ | `host` | string | `'0.0.0.0'` | 监听地址 |
85
+ | `port` | integer | `1883` | 监听端口(1-65535) |
86
+ | `allow_anonymous` | boolean | `true` | 未配置 `username` 时放行匿名连接 |
87
+ | `username` | string | `''` | 认证用户名,非空时启用口令认证 |
88
+ | `password` | string | `''` | 认证口令 |
89
+
90
+ ## API
91
+
92
+ ### MqttServer
93
+
94
+ | 成员 | 说明 |
95
+ | --- | --- |
96
+ | `run(ctx, params)` | 主动操作入口,`params.action` 派发:`publish`(需 `topic`,可带 `payload` / `qos` / `retain`)、`close_client`(需 `client_id`)、`list_clients`(可选 `query` 条件查询) |
97
+ | `publish(topic, payload, opts)` | 代理端发布消息(进入正常路由,订阅者可收到),返回是否成功 |
98
+ | `closeClient(client_id)` | 关闭指定客户端连接,返回是否发起 |
99
+ | `listClients(query)` | 在线客户端列表(`{ id, address }`),支持 Store 条件 / 分页 / keyword |
100
+ | `hasClient(client_id)` | 是否在线 |
101
+ | `setClientOptions(client_id, options)` | 绑定连接级选项(`null` 清空),随入站消息透传 |
102
+ | `use(plugin)` | 挂载插件(需实现 `bind(server)`) |
103
+ | `getHandle()` | 消息管道,注册 `before / check / main / render` 等钩子;产出 `{ topic, payload }` 时代理重发 |
104
+ | `getAddress()` | 实际监听地址,未启动为 `null` |
105
+
106
+ ### 事件
107
+
108
+ | 事件 | 参数 | 触发时机 |
109
+ | --- | --- | --- |
110
+ | `connection` | `(client)` | 客户端接入,`client` 为 `{ id, address }` |
111
+ | `message` | `(client_id, info)` | 入站发布消息经管道内置 `emit_message` 钩子分发,`info` 为 `{ topic, payload, qos, retain }` |
112
+ | `subscribe` | `(client_id, topics)` | 客户端订阅主题 |
113
+ | `unsubscribe` | `(client_id, topics)` | 客户端取消订阅 |
114
+ | `close` | `(client_id)` | 客户端断开 |
115
+ | `error` | `(err)` | TCP / broker / 管道钩子错误 |
116
+
117
+ ### 消息管道
118
+
119
+ 每条入站发布消息以 `{ client_id, topic, payload, qos, retain }` 为 `params`、连接级 `options` 为第三参进入管道:
120
+
121
+ ```
122
+ before → check → main(业务钩子按 sort 依次执行)→ render
123
+ ```
124
+
125
+ 产出 `{ topic, payload }` 时由代理重新发布该消息;代理自身重发的报文 `client` 为空,不再进入管道,因此无回环。
126
+
127
+ ## MessagePlugin(JSON-RPC 2.0)
128
+
129
+ 挂载后接管 RPC 形态报文(非 RPC 消息透传给业务钩子):
130
+
131
+ ```js
132
+ const rpc = new MessagePlugin({ reply_suffix: '/response', push_prefix: 'rpc/push', call_prefix: 'rpc/call', qos: 0, retain: false });
133
+ mqtt.use(rpc);
134
+ rpc.addMethod('echo', (params, extra, options) => params); // extra.client_id / extra.topic
135
+ ```
136
+
137
+ | 成员 | 说明 |
138
+ | --- | --- |
139
+ | `addMethod(name, handler)` / `delMethod(name)` / `listMethods(query)` | 方法表管理,处理函数签名 `(params, extra, options)` |
140
+ | `push(client_id, data, options)` | 发布数据到 `options.topic`(缺省 `push_prefix/client_id`),客户端订阅即接收 |
141
+ | `call(client_id, method, params, options)` | 发布 JSON-RPC 请求到 `options.topic`(缺省 `call_prefix/client_id`)并等待客户端反馈,`options.timeout_ms` 缺省 30000 |
142
+ | `bind(server)` / `isBound()` | 挂载与状态查询 |
143
+
144
+ 协议行为:请求(含 `id`)应答发布到「请求主题 + `reply_suffix`」;通知(无 `id`)处理函数有返回值时,把服务器发起的通知 `{ jsonrpc, method, params }` 发布到应答主题;客户端把响应形态报文(含 `result` / `error`、无 `method`)发布回 RPC 主题即结算对应的反向调用。
145
+
146
+ ## 示例
147
+
148
+ ```bash
149
+ npm run demo # 启动 MQTT 代理演示服务器(127.0.0.1:1883),见 example/demo.js
150
+ ```
151
+
152
+ ## 测试
153
+
154
+ ```bash
155
+ npm test # jest --coverage,四项覆盖率阈值均为 100%
156
+ ```
157
+
158
+ ## 许可证
159
+
160
+ ISC
package/index.js ADDED
@@ -0,0 +1,9 @@
1
+ 'use strict';
2
+
3
+ const MqttServer = require('./lib/mqtt_server');
4
+ const MessagePlugin = require('./lib/message');
5
+
6
+ module.exports = {
7
+ MqttServer,
8
+ MessagePlugin
9
+ };
package/lib/message.js ADDED
@@ -0,0 +1,197 @@
1
+ 'use strict';
2
+
3
+ const { JsonRpc } = require('mm_corejs');
4
+
5
+ /**
6
+ * 默认应答主题后缀
7
+ */
8
+ const DEFAULT_REPLY_SUFFIX = '/response';
9
+
10
+ /**
11
+ * 默认主动推送主题前缀(完整主题为 前缀/客户端ID)
12
+ */
13
+ const DEFAULT_PUSH_PREFIX = 'rpc/push';
14
+
15
+ /**
16
+ * 默认反向调用主题前缀(完整主题为 前缀/客户端ID,客户端订阅后接收调用请求)
17
+ */
18
+ const DEFAULT_CALL_PREFIX = 'rpc/call';
19
+
20
+ /**
21
+ * MessagePlugin —— JSON-RPC 2.0 消息插件(mm_mqtt_server 的可选业务组件)。
22
+ *
23
+ * 职责:
24
+ * 1. 方法表:内部持有 JsonRpc 核心,addMethod / delMethod / listMethods 登记可调用方法;
25
+ * 2. 管道挂载:bind(mqtt) 后向消息管道注册 main 钩子 "json_rpc"(sort 100,
26
+ * 先于内置 emit_message 执行),命中 JSON-RPC 请求时产出重发描述
27
+ * { topic: 请求主题 + reply_suffix, payload: 响应 JSON },由代理发布应答;
28
+ * 3. 通知回推:通知(无 id)处理函数返回非 undefined 结果时,产出服务器发起的
29
+ * 通知对象 { jsonrpc, method, params: result } 并发布到应答主题;无结果或出错时
30
+ * 不产出;非 JSON-RPC 消息(解析失败、缺 jsonrpc 字段)透传,控制权交还管道;
31
+ * 4. 主动推送:push(client_id, data, options) 发布到 options.topic
32
+ * (缺省为 push_prefix/client_id),处理函数或客户端可在任意时刻
33
+ * (如异步任务完成后)向订阅该主题的客户端回推结果;
34
+ * 5. 反向调用:call(client_id, method, params, options) 发布 JSON-RPC 请求到
35
+ * options.topic(缺省为 call_prefix/client_id)并等待反馈;客户端把响应
36
+ * (含 result/error、无 method)发布回 RPC 服务主题,入站时被优先识别并结算;
37
+ * 6. 无回环:代理重发的应答 client 为空,不进入消息管道。
38
+ *
39
+ * 用法:
40
+ * const mqtt = new MqttServer(server, { name: 'mqtt_server', port: 1883 });
41
+ * const rpc = new MessagePlugin({ reply_suffix: '/response', push_prefix: 'rpc/push' });
42
+ * mqtt.use(rpc);
43
+ * rpc.addMethod('echo', (params) => params);
44
+ * await mqtt.init();
45
+ * await mqtt.start();
46
+ */
47
+ class MessagePlugin {
48
+ /**
49
+ * JSON-RPC 协议核心
50
+ */
51
+ #rpc = new JsonRpc();
52
+
53
+ /**
54
+ * 已挂载的 MqttServer 实例,未挂载时为 null
55
+ */
56
+ #server = null;
57
+
58
+ /**
59
+ * 应答策略配置
60
+ */
61
+ #config = {
62
+ reply_suffix: DEFAULT_REPLY_SUFFIX,
63
+ push_prefix: DEFAULT_PUSH_PREFIX,
64
+ call_prefix: DEFAULT_CALL_PREFIX,
65
+ qos: 0,
66
+ retain: false
67
+ };
68
+
69
+ /**
70
+ * 构造函数
71
+ * @param {Object} [config] 应答策略配置,含 reply_suffix / push_prefix / call_prefix / qos / retain
72
+ */
73
+ constructor(config = {}) {
74
+ this.#config = { ...this.#config, ...config };
75
+ }
76
+
77
+ /**
78
+ * 挂载到 MqttServer 的消息管道,注册内置 main 钩子
79
+ * @param {Object} server 具备 getHandle() 的 MqttServer 实例
80
+ * @returns {MessagePlugin} 当前实例
81
+ * @throws {TypeError} server 不具备 getHandle 方法时
82
+ */
83
+ bind(server) {
84
+ if (!server || typeof server.getHandle !== 'function') {
85
+ throw new TypeError('[MessagePlugin] server must provide getHandle()');
86
+ }
87
+ this.#server = server;
88
+ server.getHandle().on('main', (ctx, params, options) => {
89
+ return this._execute(ctx, params, options);
90
+ }, 100, 'json_rpc');
91
+ return this;
92
+ }
93
+
94
+ /**
95
+ * 判断是否已挂载
96
+ * @returns {boolean} 是否已挂载到 MqttServer
97
+ */
98
+ isBound() {
99
+ return this.#server !== null;
100
+ }
101
+
102
+ /**
103
+ * 登记 RPC 方法
104
+ * @param {string} name 方法名
105
+ * @param {Function} handler 处理函数,签名 handler(params, extra, options),extra 含 client_id 与 topic,options 为管道请求选项
106
+ * @returns {MessagePlugin} 当前实例
107
+ */
108
+ addMethod(name, handler) {
109
+ this.#rpc.addMethod(name, handler);
110
+ return this;
111
+ }
112
+
113
+ /**
114
+ * 删除 RPC 方法
115
+ * @param {string} name 方法名
116
+ * @returns {boolean} 是否删除成功
117
+ */
118
+ delMethod(name) {
119
+ return this.#rpc.delMethod(name);
120
+ }
121
+
122
+ /**
123
+ * 列出 RPC 方法(支持 Store 条件查询与分页)
124
+ * @param {Object} [query] 查询条件,缺省时列出全部
125
+ * @returns {Array<Object>} 方法列表,含 name
126
+ */
127
+ listMethods(query) {
128
+ return this.#rpc.listMethods(query);
129
+ }
130
+
131
+ /**
132
+ * 主动推送:发布数据到指定主题(缺省为 push_prefix/client_id,客户端订阅后接收)
133
+ * @param {string} client_id 目标客户端 ID,用于组装缺省主题
134
+ * @param {string|Object} data 待推送数据,对象时 JSON 序列化
135
+ * @param {Object} [options] 推送选项,含 topic(覆盖缺省主题)
136
+ * @returns {Promise<boolean>} 是否发布成功
137
+ * @throws {Error} 插件尚未挂载到服务器时
138
+ */
139
+ push(client_id, data, options = {}) {
140
+ if (!this.#server) throw new Error('[MessagePlugin] not bound to a server');
141
+ const config = this.#config;
142
+ const topic = options.topic || `${config.push_prefix}/${client_id}`;
143
+ const payload = typeof data === 'string' ? data : JSON.stringify(data);
144
+ return this.#server.publish(topic, payload, { qos: config.qos, retain: config.retain });
145
+ }
146
+
147
+ /**
148
+ * 实时调用客户端方法:发布 JSON-RPC 请求并等待客户端反馈 result
149
+ * @param {string} client_id 目标客户端 ID,用于组装缺省调用主题
150
+ * @param {string} method 客户端方法名
151
+ * @param {*} [params] 调用参数
152
+ * @param {Object} [options] 选项,含 topic(覆盖缺省调用主题)与 timeout_ms(缺省 30000)
153
+ * @returns {Promise<*>} 客户端反馈的 result;错误响应、送达失败或超时时 reject
154
+ * @throws {Error} 插件尚未挂载到服务器时
155
+ */
156
+ call(client_id, method, params, options = {}) {
157
+ if (!this.#server) throw new Error('[MessagePlugin] not bound to a server');
158
+ const config = this.#config;
159
+ const topic = options.topic || `${config.call_prefix}/${client_id}`;
160
+ /** 发送函数:把调用请求发布到调用主题 */
161
+ const send = (request) => this.#server.publish(topic, JSON.stringify(request), { qos: config.qos, retain: false });
162
+ return this.#rpc.call(send, method, params, options);
163
+ }
164
+
165
+ /**
166
+ * 内置 main 钩子逻辑:优先识别客户端反馈,其次执行 RPC 请求并产出应答重发描述
167
+ * @param {Object} ctx 执行上下文
168
+ * @param {Object} params 消息参数,含 client_id / topic / payload
169
+ * @param {Object} [options] 管道请求选项(连接级,如 user_id / device_id / agent_id),透传给处理函数
170
+ * @returns {Promise<Object|undefined>} 重发描述 { topic, payload, qos, retain };反馈、透传与无结果通知时为 undefined
171
+ */
172
+ async _execute(ctx, params, options) {
173
+ if (this.#rpc.acceptResponse(params.payload)) return undefined;
174
+ const extra = { client_id: params.client_id, topic: params.topic };
175
+ const response = await this.#rpc.handle(params.payload, extra, options);
176
+ if (response === undefined) return undefined;
177
+ return this._buildReply(params.topic, response);
178
+ }
179
+
180
+ /**
181
+ * 组装应答重发描述
182
+ * @param {string} topic 请求主题
183
+ * @param {Object} response 响应对象
184
+ * @returns {Object} 重发描述,payload 为响应 JSON 字符串
185
+ */
186
+ _buildReply(topic, response) {
187
+ const config = this.#config;
188
+ return {
189
+ topic: `${topic}${config.reply_suffix}`,
190
+ payload: JSON.stringify(response),
191
+ qos: config.qos,
192
+ retain: config.retain
193
+ };
194
+ }
195
+ }
196
+
197
+ module.exports = MessagePlugin;
@@ -0,0 +1,572 @@
1
+ 'use strict';
2
+
3
+ const net = require('net');
4
+ const { Aedes } = require('aedes');
5
+ const { Mod, Handle, Store } = 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
+ allow_anonymous: { type: 'boolean' },
16
+ username: { type: 'string' },
17
+ password: { type: 'string' }
18
+ }
19
+ };
20
+
21
+ /**
22
+ * MqttServer —— MQTT 代理服务器(入站端口模块)。
23
+ *
24
+ * 继承 mm_corejs 的 Mod:生命周期由 Lifecycle 模板方法驱动,
25
+ * 调用参数校验由 config.params 声明式定义;
26
+ * 服务器设置(host / port / allow_anonymous / username / password)为顶层 config 字段,
27
+ * 在 onInit 阶段按 SERVER_SCHEMA 统一校验。
28
+ *
29
+ * 职责:
30
+ * 1. 监听:onStart 创建 aedes broker 与 net.Server,按 host / port 监听 MQTT 连接;
31
+ * 2. 连接管理:#clients 使用 Store 登记在线客户端(主键 id,支持条件查询与分页);
32
+ * 3. 消息管道:入站发布消息经 Handle 的 before/check/main/render 阶段处理,
33
+ * 内置 main 钩子发 message 事件保持兼容;管道产出 { topic, payload } 时
34
+ * 由代理重新发布该消息;getHandle() 可注册自定义钩子(如主题黑名单);
35
+ * 4. 事件分发:connection / message / subscribe / unsubscribe / close / error;
36
+ * 5. 主动操作:run(ctx, params) 按 params.action 派发 publish / close_client / list_clients。
37
+ *
38
+ * 用法:
39
+ * const mqtt = new MqttServer(server, { name: 'mqtt_server', host: '127.0.0.1', port: 1883 });
40
+ * mqtt.getHandle().on('check', (ctx, params) => {
41
+ * if (params.topic.startsWith('blocked/')) return { dropped: true };
42
+ * }, 100, 'guard');
43
+ * mqtt.on('message', (client_id, info) => console.log(client_id, info.topic, info.payload));
44
+ * await mqtt.init();
45
+ * await mqtt.start();
46
+ * await mqtt.run({}, { action: 'publish', topic: 'mm/test', payload: 'hello' });
47
+ */
48
+ class MqttServer extends Mod {
49
+ /**
50
+ * 模块默认配置
51
+ */
52
+ static config = {
53
+ name: 'mqtt_server',
54
+ title: 'MQTT代理服务器模块',
55
+ description: '基于 aedes 的 MQTT 代理:管理客户端连接、主题订阅与消息路由',
56
+ version: '1.0.0',
57
+ host: '0.0.0.0',
58
+ port: 1883,
59
+ allow_anonymous: true,
60
+ username: '',
61
+ password: '',
62
+ options: {},
63
+ params: {
64
+ type: 'object',
65
+ required: ['action'],
66
+ properties: {
67
+ action: { type: 'string', enum: ['publish', 'close_client', 'list_clients'] },
68
+ client_id: { type: 'string', min_length: 1 },
69
+ topic: { type: 'string', min_length: 1 },
70
+ payload: { type: 'string' },
71
+ qos: { type: 'integer', minimum: 0, maximum: 2 },
72
+ retain: { type: 'boolean' },
73
+ query: { type: 'object' }
74
+ }
75
+ }
76
+ };
77
+
78
+ /**
79
+ * aedes broker 实例
80
+ */
81
+ #broker = null;
82
+
83
+ /**
84
+ * 底层 TCP 服务器实例
85
+ */
86
+ #server = null;
87
+
88
+ /**
89
+ * 连接注册表:Store 存储,主键 id、名称字段 name
90
+ */
91
+ #clients = new Store({ key: 'id', name: 'name' });
92
+
93
+ /**
94
+ * 消息处理管道(before/check/main/render/error/success/after)
95
+ */
96
+ #handle = new Handle();
97
+
98
+ // ---------- 生命周期钩子 ----------
99
+ /**
100
+ * 初始化钩子:校验服务器设置并装配消息管道
101
+ */
102
+ async onInit() {
103
+ await this._validateConfig();
104
+ this._initHandle();
105
+ }
106
+
107
+ /**
108
+ * 启动钩子:创建 broker 与 TCP 服务器并开始监听
109
+ */
110
+ async onStart() {
111
+ const broker = await this._createBroker();
112
+ this.#broker = broker;
113
+ this._bindBrokerEvents(broker);
114
+ const server = net.createServer((stream) => broker.handle(stream));
115
+ this.#server = server;
116
+ await this._listen(server);
117
+ server.on('error', (err) => this._emitError(err));
118
+ }
119
+
120
+ /**
121
+ * 停止钩子:断开全部客户端,关闭 TCP 服务器与 broker
122
+ */
123
+ async onStop() {
124
+ this._closeAllClients();
125
+ await this._closeServer();
126
+ await this._closeBroker();
127
+ }
128
+
129
+ /**
130
+ * 释放钩子:清空连接注册表
131
+ */
132
+ async onDispose() {
133
+ this.#clients.clear();
134
+ }
135
+
136
+ // ---------- 执行入口 ----------
137
+ /**
138
+ * 模块主逻辑:按 action 派发操作
139
+ * @param {Object} ctx 执行上下文
140
+ * @param {Object} params 调用参数,含 action 及操作所需字段
141
+ * @param {Object} options 合并后的选项
142
+ * @returns {Promise<Object>} 操作结果
143
+ */
144
+ async main(ctx, params, options) {
145
+ const action = params.action;
146
+ if (action === 'publish') return this._actionPublish(params);
147
+ if (action === 'close_client') return this._actionCloseClient(params);
148
+ return this._actionListClients(params);
149
+ }
150
+
151
+ /**
152
+ * 执行代理发布操作:消息进入正常路由,订阅者会收到
153
+ * @param {Object} params 调用参数,含 topic / payload / qos / retain
154
+ * @returns {Promise<Object>} 操作结果
155
+ */
156
+ async _actionPublish(params) {
157
+ this._requireTopic(params);
158
+ const success = await this.publish(params.topic, params.payload, params);
159
+ return { action: 'publish', topic: params.topic, success };
160
+ }
161
+
162
+ /**
163
+ * 执行关闭客户端操作
164
+ * @param {Object} params 调用参数,含 client_id
165
+ * @returns {Object} 操作结果
166
+ */
167
+ _actionCloseClient(params) {
168
+ this._requireClientId(params);
169
+ const success = this.closeClient(params.client_id);
170
+ return { action: 'close_client', client_id: params.client_id, success };
171
+ }
172
+
173
+ /**
174
+ * 执行列出客户端操作
175
+ * @param {Object} params 调用参数,可含 query 查询条件(Store 条件/分页)
176
+ * @returns {Object} 操作结果,clients 为在线客户端列表
177
+ */
178
+ _actionListClients(params) {
179
+ return { action: 'list_clients', clients: this.listClients(params.query) };
180
+ }
181
+
182
+ // ---------- 公开操作 ----------
183
+ /**
184
+ * 代理端发布消息到指定主题
185
+ * @param {string} topic 主题
186
+ * @param {string|Buffer} payload 消息内容
187
+ * @param {Object} opts 发布选项,含 qos 与 retain
188
+ * @returns {Promise<boolean>} 是否发布成功
189
+ */
190
+ publish(topic, payload, opts = {}) {
191
+ const broker = this.#broker;
192
+ if (!broker) return Promise.resolve(false);
193
+ const packet = this._buildPacket(topic, payload, opts);
194
+ return new Promise((resolve, reject) => {
195
+ broker.publish(packet, (err) => {
196
+ if (err) {
197
+ reject(err);
198
+ return;
199
+ }
200
+ resolve(true);
201
+ });
202
+ });
203
+ }
204
+
205
+ /**
206
+ * 关闭指定客户端连接
207
+ * @param {string} client_id 客户端 ID
208
+ * @returns {boolean} 是否发起关闭
209
+ */
210
+ closeClient(client_id) {
211
+ const entry = this.#clients.getById(client_id);
212
+ if (!entry) return false;
213
+ entry.client.close();
214
+ return true;
215
+ }
216
+
217
+ /**
218
+ * 列出在线客户端(支持 Store 条件查询与分页)
219
+ * @param {Object} [query] 查询条件,缺省时列出全部
220
+ * @returns {Array<Object>} 客户端信息列表,含 id 与 address
221
+ */
222
+ listClients(query) {
223
+ return this.#clients.list(query).map((entry) => ({ id: entry.id, address: entry.address }));
224
+ }
225
+
226
+ /**
227
+ * 绑定连接级选项(如 user_id / device_id / agent_id),入站消息将作为管道 options 透传给钩子与 RPC 处理函数
228
+ * @param {string} client_id 客户端 ID
229
+ * @param {Object|null} options 选项对象,null 时清空
230
+ * @returns {boolean} 是否设置成功(客户端不在线时失败)
231
+ */
232
+ setClientOptions(client_id, options) {
233
+ const entry = this.#clients.getById(client_id);
234
+ if (!entry) return false;
235
+ entry.options = options || null;
236
+ return true;
237
+ }
238
+
239
+ /**
240
+ * 读取连接级选项,未绑定时返回空对象
241
+ * @param {string} client_id 客户端 ID
242
+ * @returns {Object} 连接级选项
243
+ */
244
+ _clientOptions(client_id) {
245
+ const entry = this.#clients.getById(client_id);
246
+ if (!entry || !entry.options) return {};
247
+ return entry.options;
248
+ }
249
+
250
+ /**
251
+ * 判断客户端是否在线
252
+ * @param {string} client_id 客户端 ID
253
+ * @returns {boolean} 是否在线
254
+ */
255
+ hasClient(client_id) {
256
+ return !!this.#clients.getById(client_id);
257
+ }
258
+
259
+ /**
260
+ * 挂载插件:调用 plugin.bind(this),由插件自行向管道注册钩子;
261
+ * 如 MessagePlugin 挂载后接管 JSON-RPC 请求并应答到请求主题 + 后缀
262
+ * @param {Object} plugin 具备 bind(server) 方法的插件实例
263
+ * @returns {MqttServer} 当前实例
264
+ * @throws {TypeError} plugin 不具备 bind 方法时
265
+ */
266
+ use(plugin) {
267
+ if (!plugin || typeof plugin.bind !== 'function') {
268
+ throw new TypeError(`${this._label()} plugin must provide bind()`);
269
+ }
270
+ plugin.bind(this);
271
+ return this;
272
+ }
273
+
274
+ /**
275
+ * 获取消息处理管道,供外部注册 before/check/main 等钩子;
276
+ * 钩子产出 { topic, payload } 时由代理重新发布该消息
277
+ * @returns {Handle} 消息管道实例
278
+ */
279
+ getHandle() {
280
+ return this.#handle;
281
+ }
282
+
283
+ /**
284
+ * 获取实际监听地址
285
+ * @returns {Object|null} 监听地址信息,未启动时为 null
286
+ */
287
+ getAddress() {
288
+ const server = this.#server;
289
+ return server ? server.address() : null;
290
+ }
291
+
292
+ // ---------- broker 装配 ----------
293
+ /**
294
+ * 创建 aedes broker 并装配认证器
295
+ * @returns {Promise<Object>} broker 实例
296
+ */
297
+ async _createBroker() {
298
+ const broker = await Aedes.createBroker({
299
+ authenticate: (client, username, password, done) => {
300
+ this._authenticate(client, username, password, done);
301
+ }
302
+ });
303
+ return broker;
304
+ }
305
+
306
+ /**
307
+ * 把 broker 事件映射为模块事件,并维护连接注册表
308
+ * @param {Object} broker aedes broker 实例
309
+ */
310
+ _bindBrokerEvents(broker) {
311
+ broker.on('client', (client) => {
312
+ this.#clients.delById(client.id);
313
+ this.#clients.add({
314
+ id: client.id,
315
+ name: client.id,
316
+ address: this._remoteAddress(client),
317
+ client
318
+ });
319
+ this._safeEmit('connection', this._clientInfo(client));
320
+ });
321
+ broker.on('clientDisconnect', (client) => {
322
+ const existed = this.#clients.delById(client.id);
323
+ if (existed) this._safeEmit('close', client.id);
324
+ });
325
+ broker.on('publish', (packet, client) => {
326
+ if (!client) return;
327
+ this._handlePublish(client, packet).catch((err) => this._emitError(err));
328
+ });
329
+ broker.on('subscribe', (subscriptions, client) => {
330
+ const topics = subscriptions.map((item) => item.topic);
331
+ this._safeEmit('subscribe', client.id, topics);
332
+ });
333
+ broker.on('unsubscribe', (topics, client) => {
334
+ this._safeEmit('unsubscribe', client.id, topics);
335
+ });
336
+ broker.on('clientError', (client, err) => this._emitError(err));
337
+ broker.on('connectionError', (client, err) => this._emitError(err));
338
+ }
339
+
340
+ /**
341
+ * 入站发布消息经管道处理:错误上抛,产出 { topic, payload } 时代理重发
342
+ * @param {Object} client aedes client 实例
343
+ * @param {Object} packet aedes 发布报文
344
+ * @returns {Promise<void>} 无返回值
345
+ */
346
+ async _handlePublish(client, packet) {
347
+ const params = {
348
+ client_id: client.id,
349
+ topic: packet.topic,
350
+ payload: packet.payload.toString('utf8'),
351
+ qos: packet.qos,
352
+ retain: packet.retain
353
+ };
354
+ const ctx = { client_id: client.id };
355
+ const done = await this.#handle.run(ctx, params, this._clientOptions(client.id));
356
+ if (done.error) this._emitError(done.error);
357
+ if (this._isRepublish(done.result)) {
358
+ await this.publish(done.result.topic, done.result.payload, done.result);
359
+ }
360
+ }
361
+
362
+ /**
363
+ * 判断管道产出是否为待重发的发布描述(须含 topic 与 payload)
364
+ * @param {*} result 管道产出的结果
365
+ * @returns {boolean} 是否为发布描述
366
+ */
367
+ _isRepublish(result) {
368
+ if (result === null || typeof result !== 'object') return false;
369
+ return !!result.topic && result.payload !== undefined;
370
+ }
371
+
372
+ // ---------- 认证 ----------
373
+ /**
374
+ * 认证回调:匿名开关与用户名口令校验
375
+ * @param {Object} client aedes client 实例
376
+ * @param {string|undefined} username 用户名
377
+ * @param {Buffer|undefined} password 口令
378
+ * @param {Function} done 回调,签名 done(err, success)
379
+ */
380
+ _authenticate(client, username, password, done) {
381
+ if (this._checkAnonymous()) {
382
+ done(null, true);
383
+ return;
384
+ }
385
+ if (!username) {
386
+ done(this._authError('username required'), false);
387
+ return;
388
+ }
389
+ if (this._checkCredential(username, password)) {
390
+ done(null, true);
391
+ return;
392
+ }
393
+ done(this._authError('bad username or password'), false);
394
+ }
395
+
396
+ /**
397
+ * 判断是否放行匿名连接
398
+ * @returns {boolean} 是否放行
399
+ */
400
+ _checkAnonymous() {
401
+ const anonymous = this.config.allow_anonymous && !this.config.username;
402
+ return anonymous;
403
+ }
404
+
405
+ /**
406
+ * 校验用户名与口令
407
+ * @param {string} username 用户名
408
+ * @param {Buffer|undefined} password 口令
409
+ * @returns {boolean} 是否匹配
410
+ */
411
+ _checkCredential(username, password) {
412
+ const user_ok = username === this.config.username;
413
+ const pass_ok = String(password ?? '') === this.config.password;
414
+ return user_ok && pass_ok;
415
+ }
416
+
417
+ /**
418
+ * 构造认证错误对象
419
+ * @param {string} message 错误信息
420
+ * @returns {Error} 带 returnCode 的错误
421
+ */
422
+ _authError(message) {
423
+ const err = new Error(message);
424
+ err.returnCode = 5;
425
+ return err;
426
+ }
427
+
428
+ // ---------- 数据组装 ----------
429
+ /**
430
+ * 构造 broker 发布报文
431
+ * @param {string} topic 主题
432
+ * @param {string|Buffer} payload 消息内容
433
+ * @param {Object} opts 发布选项,含 qos 与 retain
434
+ * @returns {Object} aedes 发布报文
435
+ */
436
+ _buildPacket(topic, payload, opts) {
437
+ return {
438
+ cmd: 'publish',
439
+ topic,
440
+ payload: Buffer.from(String(payload)),
441
+ qos: opts.qos || 0,
442
+ retain: opts.retain || false
443
+ };
444
+ }
445
+
446
+ /**
447
+ * 提取客户端信息
448
+ * @param {Object} client aedes client 实例
449
+ * @returns {Object} 含 id 与 address 的信息
450
+ */
451
+ _clientInfo(client) {
452
+ return {
453
+ id: client.id,
454
+ address: this._remoteAddress(client)
455
+ };
456
+ }
457
+
458
+ /**
459
+ * 获取远端地址字符串
460
+ * @param {Object} client aedes client 实例
461
+ * @returns {string} 形如 ip:port 的地址
462
+ */
463
+ _remoteAddress(client) {
464
+ const conn = client.conn;
465
+ if (!conn || !conn.remoteAddress) return '';
466
+ return `${conn.remoteAddress}:${conn.remotePort}`;
467
+ }
468
+
469
+ // ---------- 监听与清理 ----------
470
+ /**
471
+ * 等待 TCP 服务器监听成功
472
+ * @param {Object} server net.Server 实例
473
+ * @returns {Promise<void>} 无返回值
474
+ */
475
+ _listen(server) {
476
+ const host = this.config.host;
477
+ const port = this.config.port;
478
+ return new Promise((resolve, reject) => {
479
+ server.once('error', reject);
480
+ server.listen({ host, port }, () => {
481
+ server.removeListener('error', reject);
482
+ resolve();
483
+ });
484
+ });
485
+ }
486
+
487
+ /**
488
+ * 断开全部客户端连接并清空注册表
489
+ */
490
+ _closeAllClients() {
491
+ for (const entry of this.#clients.list()) {
492
+ entry.client.close();
493
+ }
494
+ this.#clients.clear();
495
+ }
496
+
497
+ /**
498
+ * 关闭底层 TCP 服务器
499
+ * @returns {Promise<void>} 无返回值
500
+ */
501
+ _closeServer() {
502
+ const server = this.#server;
503
+ this.#server = null;
504
+ if (!server) return Promise.resolve();
505
+ return new Promise((resolve) => {
506
+ server.close(resolve);
507
+ });
508
+ }
509
+
510
+ /**
511
+ * 关闭 aedes broker
512
+ * @returns {Promise<void>} 无返回值
513
+ */
514
+ _closeBroker() {
515
+ const broker = this.#broker;
516
+ this.#broker = null;
517
+ if (!broker) return Promise.resolve();
518
+ return new Promise((resolve) => {
519
+ broker.close(resolve);
520
+ });
521
+ }
522
+
523
+ // ---------- 消息管道 ----------
524
+ /**
525
+ * 装配内置管道钩子:main 发 message 事件保持向后兼容
526
+ */
527
+ _initHandle() {
528
+ this.#handle.on('main', (ctx, params) => {
529
+ this._safeEmit('message', params.client_id, {
530
+ topic: params.topic,
531
+ payload: params.payload,
532
+ qos: params.qos,
533
+ retain: params.retain
534
+ });
535
+ }, 1000, 'emit_message');
536
+ }
537
+
538
+ // ---------- 校验辅助 ----------
539
+ /**
540
+ * 校验顶层 config 中的服务器设置
541
+ * @returns {Promise<void>} 无返回值
542
+ * @throws {Error} 校验不通过时
543
+ */
544
+ async _validateConfig() {
545
+ const tip = await this._validate(this.config, SERVER_SCHEMA);
546
+ if (tip) throw new Error(`${this._label()} invalid config: ${tip}`);
547
+ }
548
+
549
+ /**
550
+ * 断言调用参数含 client_id
551
+ * @param {Object} params 调用参数
552
+ * @throws {Error} 缺少 client_id 时
553
+ */
554
+ _requireClientId(params) {
555
+ if (!params.client_id) {
556
+ throw new Error(`${this._label()} params.client_id required`);
557
+ }
558
+ }
559
+
560
+ /**
561
+ * 断言调用参数含 topic
562
+ * @param {Object} params 调用参数
563
+ * @throws {Error} 缺少 topic 时
564
+ */
565
+ _requireTopic(params) {
566
+ if (!params.topic) {
567
+ throw new Error(`${this._label()} params.topic required`);
568
+ }
569
+ }
570
+ }
571
+
572
+ module.exports = MqttServer;
package/package.json CHANGED
@@ -1,6 +1,47 @@
1
1
  {
2
2
  "name": "mm_mqtt_server",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "MQTT 代理服务器入站端口模块,基于 aedes,继承 mm_corejs 的 Mod 基类,支持独立运行。",
5
+ "keywords": [
6
+ "超级美眉",
7
+ "入站端口",
8
+ "mm_mod",
9
+ "mm_in_port",
10
+ "mm_receiver",
11
+ "mqtt",
12
+ "broker",
13
+ "aedes",
14
+ "server"
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_mqtt_server.git"
30
+ },
31
+ "homepage": "https://gitee.com/qiuwenwu91/mm_mqtt_server",
32
+ "bugs": {
33
+ "url": "https://gitee.com/qiuwenwu91/mm_mqtt_server/issues"
34
+ },
35
+ "scripts": {
36
+ "start": "node example/demo.js",
37
+ "demo": "node example/demo.js",
38
+ "test": "jest --coverage"
39
+ },
40
+ "dependencies": {
41
+ "aedes": "^1.2.0",
42
+ "mm_corejs": "^1.1.0"
43
+ },
44
+ "devDependencies": {
45
+ "jest": "^30.5.2"
46
+ }
47
+ }