mm_socket_server 2.0.0 → 3.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # mm_socket_server
2
2
 
3
- 最薄的 TCP/UDP 套接字服务器入站适配器。继承 `mm_mod` 的 `Mod`,把每条报文直接交给 `mm_event` 的事件管理器处理,不做路由表、不做插件、不加载脚本。
3
+ 最薄的 TCP/UDP 套接字服务器入站适配器。继承 `mm_mod` 的 `Mod`,把按 topic 分帧后的报文以 topic 为 `target` 交给 `mm_event` 的事件管理器处理,不做路由表、不做插件、不加载脚本。`protocol` 可选 `tcp`、`udp` 或 `both`(同端口同时监听)。
4
4
 
5
5
  - 入口:`index.js`(CommonJS)
6
6
  - 导出:`SocketServer`、`admin`、`socketServerAdmin`
@@ -9,12 +9,27 @@
9
9
  ## 消息路径
10
10
 
11
11
  ```
12
- net / dgram → connection / message → ctx = { client_id, protocol, address, socket | rinfo }
13
- → eventManager.run(protocol, params, ctx, options)
14
- → 结果非空时回发来源客户端
12
+ net / dgram → connection / data / message → 按 framing 切出 { topic, payload }
13
+ → ctx = { client_id, topic, protocol, address, scope, socket | rinfo }
14
+ → eventManager.run(topic, params, ctx, options)(同一连接的消息串行执行)
15
+ → 结果非空时按同一帧格式回发来源客户端
15
16
  ```
16
17
 
17
- 事件 `target` 取协议名:`tcp` 或 `udp`。`params` 为 `{ client_id, data }`,`data` 在 `json: true` 时尝试 `JSON.parse`(失败回落原文)。
18
+ 事件 `target` 取帧内的 topic(如 `chat/1`),因此可按主题精细分发,也支持 `mm_event` 的 `*` 通配(前缀、后缀、包含)。`params` 为 `{ client_id, topic, data }`,`data` 在 `json: true` 时尝试 `JSON.parse`(失败回落原文)。
19
+
20
+ ### 帧格式
21
+
22
+ | `framing` | 帧格式 | 说明 |
23
+ | --- | --- | --- |
24
+ | `line`(默认) | `topic<空格>payload\n` | 以 `0x0a` 分帧,兼容剥除 `\r\n`;payload 不能含换行,否则发送时报错 |
25
+ | `length` | `[uint16BE topic 长度][uint32BE payload 长度][topic][payload]` | 二进制安全,TCP 按长度拼包,UDP 用于校验完整性 |
26
+ | `none` | 不分帧 | `target` 回落为协议名 `tcp` / `udp`,保持旧行为 |
27
+
28
+ topic 一律按 utf8 编解码,`encoding` 只作用于 payload。缺少 topic 的帧会被丢弃并通过 `error` 事件上报 `invalid frame: missing topic`;UDP 长度帧不完整时上报 `invalid frame: truncated`。
29
+
30
+ > **TCP 是字节流,没有天然消息边界。** 模块按 `framing` 做连接级缓冲与拼包/拆包,因此 `line` / `length` 下可安全处理粘包与拆包;UDP 保留数据报边界,一次报文即一帧。
31
+
32
+ 同一连接的消息按到达顺序串行执行:事件处理函数返回 Promise 时,后一条消息会等前一条处理完再开始,避免响应乱序。
18
33
 
19
34
  ## 快速开始
20
35
 
@@ -23,18 +38,15 @@ net / dgram → connection / message → ctx = { client_id, protocol, address, s
23
38
 
24
39
  const { SocketServer } = require('mm_socket_server');
25
40
 
26
- const socket = new SocketServer({ name: 'socket', host: '127.0.0.1', port: 9000 });
41
+ const socket = new SocketServer({ name: 'socket', protocol: 'both', host: '127.0.0.1', port: 9000 });
27
42
  const events = socket.getEventManager();
28
43
 
29
- events.add({ name: 'echo', target: 'tcp', end: true }).main = async (params, ctx) => ({
30
- client_id: ctx.client_id,
31
- data: params.data
32
- });
44
+ events.add({ name: 'echo', target: 'chat/echo', end: true }).main = async (params) => params.data;
33
45
 
34
46
  await socket.init();
35
47
  await socket.start();
36
48
 
37
- console.log(socket.getAddress());
49
+ console.log(socket.getAddresses());
38
50
  ```
39
51
 
40
52
  ## 服务器配置
@@ -44,13 +56,16 @@ console.log(socket.getAddress());
44
56
  | `name` | string | `'os'` | 模块名称(必填) |
45
57
  | `title` | string | `'Socket服务器模块'` | 模块标题 |
46
58
  | `description` | string | — | 模块描述 |
47
- | `version` | string | `'2.0.0'` | 版本号 |
48
- | `protocol` | string | `'tcp'` | 传输协议:`tcp` 或 `udp` |
59
+ | `version` | string | `'3.0.0'` | 版本号 |
60
+ | `protocol` | string | `'tcp'` | 监听协议,接受 `tcp` / `udp` / `both`,其他值在 `init()` 时抛错 |
61
+ | `framing` | string | `'line'` | 帧格式,接受 `line` / `length` / `none`,其他值在 `init()` 时抛错 |
49
62
  | `host` | string | `'0.0.0.0'` | 监听地址 |
50
63
  | `port` | number | `9000` | 监听端口 |
51
- | `encoding` | string | `'utf8'` | 入站解码字符集 |
64
+ | `encoding` | string | `'utf8'` | payload 解码字符集(topic 始终 utf8) |
52
65
  | `json` | boolean | `false` | 入站尝试 JSON 解析,出站对象自动序列化 |
53
- | `params` | object | `{ action }` | `main` 的参数约定 |
66
+ | `scope` | string | `'socket'` | 事件管理器作用域,多实例可用不同 scope 隔离事件 |
67
+ | `udp_ttl` | number | `300000` | UDP 客户端空闲多久(毫秒)后移出连接表,`<= 0` 关闭清理 |
68
+ | `params` | object | `{ action }` | `main` 的参数约定(`action` / `client_id` / `topic` / `data`) |
54
69
 
55
70
  ## API
56
71
 
@@ -58,12 +73,13 @@ console.log(socket.getAddress());
58
73
  | --- | --- |
59
74
  | `init()` / `start()` / `stop()` / `dispose()` | `Mod` 生命周期模板方法 |
60
75
  | `onInit()` / `onStart()` / `onStop()` / `onDispose()` | 生命周期钩子,子类可覆盖 |
61
- | `getEventManager()` | 获取 `mm_event` 的 `socket` 作用域事件管理器(与其他实例共享),用于注册事件 |
76
+ | `getEventManager()` | 获取 `mm_event` 上 `scope` 作用域的事件管理器(默认 `socket`,其他实例共享同一作用域),用于注册事件 |
62
77
  | `setEventManager(manager)` | 注入外部事件管理器,需提供 `run()` |
63
- | `getAddress()` | 实际监听地址,未启动为 `null` |
78
+ | `getAddress(protocol)` | 指定协议的实际监听地址,未启动为 `null`;省略 `protocol` 时取首个已启用协议 |
79
+ | `getAddresses()` | 返回 `{ tcp, udp }` 两个协议各自的监听地址 |
64
80
  | `run(params, ctx, options)` | 主动操作入口,`params.action` 派发 `send` / `broadcast` / `close` / `list` |
65
- | `send(client_id, data)` | 向指定客户端发送数据,返回是否成功 |
66
- | `broadcast(data)` | 向全部在线客户端广播,返回送达数 |
81
+ | `send(client_id, data, topic)` | 向指定客户端发送数据,`framing` 非 `none` 时 `topic` 必填,返回是否成功 |
82
+ | `broadcast(data, topic)` | 向全部在线客户端广播,`framing` 非 `none` 时 `topic` 必填,返回送达数 |
67
83
  | `closeClient(client_id)` | 关闭指定客户端(TCP 销毁套接字,UDP 仅移出连接表) |
68
84
  | `listClients()` | 在线客户端列表 `{ id, protocol, address }` |
69
85
  | `hasClient(client_id)` | 是否在线 |
@@ -74,8 +90,10 @@ console.log(socket.getAddress());
74
90
  | 字段 | 说明 |
75
91
  | --- | --- |
76
92
  | `client_id` | 客户端编号,TCP 形如 `tcp_1`,UDP 形如 `udp_127.0.0.1:5000` |
93
+ | `topic` | 当前帧的 topic,即事件的 `target` |
77
94
  | `protocol` | 当前协议,`tcp` 或 `udp` |
78
95
  | `address` | 远端地址,形如 `ip:port` |
96
+ | `scope` | 当前实例的事件管理器作用域 |
79
97
  | `socket` | TCP 连接(UDP 报文无此字段),事件可直接写出 |
80
98
  | `rinfo` | UDP 远端地址信息(TCP 消息无此字段) |
81
99
 
@@ -84,25 +102,31 @@ console.log(socket.getAddress());
84
102
  签名固定为 `(params, ctx, options)`:
85
103
 
86
104
  ```js
87
- events.add({ name: 'echo', target: 'tcp', end: true }).main = async (params, ctx, options) => {
88
- return { data: params.data };
105
+ events.add({ name: 'echo', target: 'chat/echo', end: true }).main = async (params, ctx, options) => {
106
+ return params.data;
89
107
  };
90
108
  ```
91
109
 
92
- 返回非 `undefined` / `null` 的结果会按 `json` 配置编码后回发来源客户端;返回 `undefined` 则不回发。事件也可直接用 `ctx.socket` 写出。
110
+ 返回非 `undefined` / `null` 的结果会按 `framing` 与原 topic 编码后回发来源客户端;返回 `{ topic, data }` 则改发到指定 topic;返回 `undefined` 则不回发。事件也可直接用 `ctx.socket` 写出。
93
111
 
94
112
  ## 主动操作
95
113
 
96
114
  ```js
97
- await socket.run({ action: 'send', client_id: 'tcp_1', data: 'hi' });
98
- await socket.run({ action: 'broadcast', data: { type: 'notice' } });
115
+ await socket.run({ action: 'send', client_id: 'tcp_1', topic: 'chat/echo', data: 'hi' });
116
+ await socket.run({ action: 'broadcast', topic: 'notice', data: { type: 'notice' } });
99
117
  await socket.run({ action: 'close', client_id: 'tcp_1' });
100
118
  await socket.run({ action: 'list' });
101
119
  ```
102
120
 
121
+ `action` 仅支持上述四种,其他值直接抛 `unsupported action` 错误。`framing` 非 `none` 时 `send` / `broadcast` 必须给出 `topic`。
122
+
103
123
  ## 生命周期
104
124
 
105
- `onStart` 按 `protocol` 创建并监听 `net.Server` 或 `dgram.Socket`,`onStop` 断开全部连接并关闭服务器。事件管理器取自 `mm_event` 的 `socket` 作用域,由全局 `Admin` 共享,停止时不清理。端口被占用时 `start()` 抛错,模块进入 `error` 状态,可 `dispose()` 释放。
125
+ `onInit` 校验 `protocol` 只能是 `tcp` / `udp` / `both`、`framing` 只能是 `line` / `length` / `none`;`onStart` 按 `protocol` 创建并监听 `net.Server` 或 `dgram.Socket`(`both` 时两者同端口并行,TCP 与 UDP 端口空间互不影响),`onStop` 断开全部连接并关闭服务器。启动失败(如端口被占用)时会先回滚已创建的服务器与连接表再抛错,模块进入 `error` 状态,可 `dispose()` 释放;`both` 模式下任一协议启动失败也会回滚另一协议。
126
+
127
+ 事件管理器取自 `mm_event` 上 `scope` 指定的作用域(默认 `socket`),由全局 `Admin` 共享,停止时不清理;多个实例需要不同作用域时,配置不同的 `scope` 即可。
128
+
129
+ UDP 没有连接概念,首次收到某远端地址的报文时登记为客户端,之后超过 `udp_ttl` 未再收到报文会被定时清理。
106
130
 
107
131
  ## 示例
108
132
 
package/example/demo.js CHANGED
@@ -6,22 +6,35 @@ const { SocketServer } = require('..');
6
6
  const DEMO_PORT = 9000;
7
7
 
8
8
  /**
9
- * 注册演示事件:协议名即事件 target
9
+ * 注册演示事件:帧内 topic 即事件 target
10
10
  * @param {Object} events 事件管理器
11
11
  * @returns {void}
12
12
  */
13
13
  function bindEvents(events) {
14
- events.add({ name: 'echo', target: 'tcp', end: true }).main = async (params, ctx) => ({
14
+ events.add({ name: 'echo', target: 'chat/echo', end: true }).main = async (params, ctx) => ({
15
15
  type: 'echo',
16
16
  client_id: ctx.client_id,
17
17
  data: params.data
18
18
  });
19
19
 
20
- events.add({ name: 'time', target: 'udp', end: true }).main = async () => ({
20
+ events.add({ name: 'time', target: 'chat/time', end: true }).main = async () => ({
21
21
  time: new Date().toISOString()
22
22
  });
23
23
  }
24
24
 
25
+ /**
26
+ * 打印两种协议的监听地址
27
+ * @param {Object} socket 套接字服务器实例
28
+ * @returns {void}
29
+ */
30
+ function printAddresses(socket) {
31
+ const addresses = socket.getAddresses();
32
+ for (const protocol of Object.keys(addresses)) {
33
+ const address = addresses[protocol];
34
+ if (address) console.log(`${protocol}://127.0.0.1:${address.port}`);
35
+ }
36
+ }
37
+
25
38
  /**
26
39
  * 演示入口:启动服务器并打印监听地址
27
40
  * @returns {Promise<void>} 无返回值
@@ -31,7 +44,7 @@ async function main() {
31
44
  name: 'demo',
32
45
  host: '127.0.0.1',
33
46
  port: DEMO_PORT,
34
- protocol: 'tcp'
47
+ protocol: 'both'
35
48
  });
36
49
 
37
50
  bindEvents(socket.getEventManager());
@@ -40,9 +53,8 @@ async function main() {
40
53
  await socket.init();
41
54
  await socket.start();
42
55
 
43
- const address = socket.getAddress();
44
- console.log(`tcp://127.0.0.1:${address.port}`);
45
- console.log('发送任意内容,结果由 target 为 tcp 的事件返回并自动回发');
56
+ printAddresses(socket);
57
+ console.log('发送 `chat/echo hello\\n`,事件返回结果会以同一 topic 回发');
46
58
  }
47
59
 
48
60
  main().catch((err) => {
package/lib/socket.js CHANGED
@@ -6,17 +6,63 @@ const { Mod } = require('mm_mod');
6
6
  const { eventAdmin } = require('mm_event');
7
7
 
8
8
  /**
9
- * SocketServer —— 最薄的 TCP/UDP 传输层(入站适配器模块)。
9
+ * 长度前缀帧的头部字节数:2 字节 topic 长度 + 4 字节 payload 长度
10
+ */
11
+ const LENGTH_HEADER_SIZE = 6;
12
+
13
+ /**
14
+ * topic 长度的字节数
15
+ */
16
+ const TOPIC_LENGTH_SIZE = 2;
17
+
18
+ /**
19
+ * 换行符字节
20
+ */
21
+ const LINE_FEED = 0x0a;
22
+
23
+ /**
24
+ * 回车符字节
25
+ */
26
+ const CARRIAGE_RETURN = 0x0d;
27
+
28
+ /**
29
+ * 空格字节,行协议中分隔 topic 与 payload
30
+ */
31
+ const SPACE = 0x20;
32
+
33
+ /**
34
+ * 支持的协议
35
+ */
36
+ const PROTOCOLS = ['tcp', 'udp'];
37
+
38
+ /**
39
+ * 支持的协议配置值
40
+ */
41
+ const PROTOCOL_MODES = ['tcp', 'udp', 'both'];
42
+
43
+ /**
44
+ * 支持的帧格式
45
+ */
46
+ const FRAMINGS = ['line', 'length', 'none'];
47
+
48
+ /**
49
+ * SocketServer —— TCP/UDP 传输层(入站适配器模块)。
50
+ *
51
+ * 职责:
52
+ * 1. 生命周期:由 Mod 的 init / start / stop 驱动 net.Server / dgram.Socket,
53
+ * protocol 支持 tcp、udp 或 both(同端口同时监听两种传输层);
54
+ * 2. 消息转交:按 framing 把字节流还原成 { topic, payload } 帧,以 topic 为 target
55
+ * 调用 EventManager.run(topic, params, ctx, options),支持 mm_event 的 `*` 通配;
56
+ * 3. 结果回发:事件返回非空结果时按同一帧格式回发来源客户端。
10
57
  *
11
- * 只做三件事:
12
- * 1. 生命周期:由 Mod 的 init / start / stop 驱动 net.Server 或 dgram.Socket;
13
- * 2. 消息转交:以协议名(tcp / udp)为 target,直接调用
14
- * EventManager.run(target, params, ctx, options),不做路由、不做插件;
15
- * 3. 结果回发:事件返回非空结果时回发来源客户端,事件也可用 ctx.socket 自行写出。
58
+ * 帧格式:
59
+ * line —— `topic<空格>payload\n`,TCP 按换行分帧,payload 不能含换行;
60
+ * length —— `[2字节 topic 长度][4字节 payload 长度][topic][payload]`,二进制安全;
61
+ * none —— 不分帧,target 回落为连接协议名(tcp / udp),保持旧行为。
16
62
  *
17
63
  * 用法:
18
- * const socket = new SocketServer({ name: 'socket', protocol: 'tcp', port: 9000 });
19
- * socket.getEventManager().add({ name: 'echo', target: 'tcp' }).main = async (params) => params.data;
64
+ * const socket = new SocketServer({ name: 'socket', protocol: 'both', port: 9000 });
65
+ * socket.getEventManager().add({ name: 'echo', target: 'chat/*' }).main = async (params) => params.data;
20
66
  * await socket.init();
21
67
  * await socket.start();
22
68
  */
@@ -27,28 +73,32 @@ class SocketServer extends Mod {
27
73
  static config = {
28
74
  name: 'os',
29
75
  title: 'Socket服务器模块',
30
- description: 'TCP/UDP 传输层:消息直达事件管理器,不做路由与插件',
31
- version: '2.0.0',
76
+ description: 'TCP/UDP 传输层:按 topic 帧直达事件管理器,不做插件',
77
+ version: '3.0.0',
32
78
  protocol: 'tcp',
33
79
  host: '0.0.0.0',
34
80
  port: 9000,
35
81
  encoding: 'utf8',
36
82
  json: false,
83
+ scope: 'socket',
84
+ framing: 'line',
85
+ udp_ttl: 300000,
37
86
  params: {
38
87
  type: 'object',
39
88
  required: ['action'],
40
89
  properties: {
41
90
  action: { type: 'string', enum: ['send', 'broadcast', 'close', 'list'] },
42
91
  client_id: { type: 'string', min_length: 1 },
92
+ topic: { type: 'string', min_length: 1 },
43
93
  data: {}
44
94
  }
45
95
  }
46
96
  };
47
97
 
48
98
  /**
49
- * 底层服务器实例(net.Server 或 dgram.Socket)
99
+ * 底层服务器实例,按协议分槽存放
50
100
  */
51
- #server = null;
101
+ #servers = { tcp: null, udp: null };
52
102
 
53
103
  /**
54
104
  * 事件管理器(取自 mm_event 的 socket 作用域,与其他实例共享)
@@ -65,6 +115,11 @@ class SocketServer extends Mod {
65
115
  */
66
116
  #counter = 0;
67
117
 
118
+ /**
119
+ * UDP 过期连接清理定时器
120
+ */
121
+ #udp_timer = null;
122
+
68
123
  /**
69
124
  * 构造函数
70
125
  * @param {Object} config 模块配置
@@ -72,20 +127,81 @@ class SocketServer extends Mod {
72
127
  */
73
128
  constructor(config, server) {
74
129
  super(config, server);
75
- this.#events = eventAdmin('socket');
130
+ this.#events = eventAdmin(this.config.scope);
76
131
  }
77
132
 
78
133
  // ---------- 生命周期钩子 ----------
79
134
  /**
80
- * 启动钩子:按协议创建底层服务器并开始监听
135
+ * 初始化钩子:校验协议与帧格式配置
136
+ * @returns {Promise<void>} 无返回值
137
+ * @throws {Error} protocol 或 framing 不受支持时
138
+ */
139
+ async onInit() {
140
+ this._validateConfig();
141
+ }
142
+
143
+ /**
144
+ * 校验配置项
145
+ * @returns {void}
146
+ * @throws {Error} protocol 或 framing 不受支持时
147
+ */
148
+ _validateConfig() {
149
+ const protocol = this.config.protocol;
150
+ if (!PROTOCOL_MODES.includes(protocol)) {
151
+ throw new Error(`${this._label()} unsupported protocol: ${protocol}`);
152
+ }
153
+ const framing = this.config.framing;
154
+ if (!FRAMINGS.includes(framing)) {
155
+ throw new Error(`${this._label()} unsupported framing: ${framing}`);
156
+ }
157
+ }
158
+
159
+ /**
160
+ * 启动钩子:按 protocol 逐个创建底层服务器,任一失败则全部回滚
81
161
  * @returns {Promise<void>} 无返回值
82
162
  */
83
163
  async onStart() {
84
- if (this.config.protocol === 'udp') {
85
- await this._startUdp();
86
- return;
164
+ try {
165
+ for (const protocol of this._enabledProtocols()) {
166
+ await this._startServer(protocol);
167
+ }
168
+ } catch (err) {
169
+ await this._rollbackStart();
170
+ throw err;
171
+ }
172
+ }
173
+
174
+ /**
175
+ * 列出本次需要监听的协议
176
+ * @returns {Array<string>} 协议名列表
177
+ */
178
+ _enabledProtocols() {
179
+ const protocol = this.config.protocol;
180
+ if (protocol === 'both') return PROTOCOLS.slice();
181
+ return [protocol];
182
+ }
183
+
184
+ /**
185
+ * 按协议启动对应的底层服务器
186
+ * @param {string} protocol 协议名,tcp 或 udp
187
+ * @returns {Promise<void>} 无返回值
188
+ */
189
+ async _startServer(protocol) {
190
+ if (protocol === 'udp') return this._startUdp();
191
+ return this._startTcp();
192
+ }
193
+
194
+ /**
195
+ * 启动失败时回滚已创建的资源
196
+ * @returns {Promise<void>} 无返回值
197
+ */
198
+ async _rollbackStart() {
199
+ try {
200
+ this._closeAllClients();
201
+ await this._closeServers();
202
+ } catch (err) {
203
+ this._emitError(err);
87
204
  }
88
- await this._startTcp();
89
205
  }
90
206
 
91
207
  /**
@@ -94,7 +210,7 @@ class SocketServer extends Mod {
94
210
  */
95
211
  async onStop() {
96
212
  this._closeAllClients();
97
- await this._closeServer();
213
+ await this._closeServers();
98
214
  }
99
215
 
100
216
  // ---------- 执行入口 ----------
@@ -104,33 +220,35 @@ class SocketServer extends Mod {
104
220
  * @param {Object} ctx 执行上下文
105
221
  * @param {Object} options 合并后的选项
106
222
  * @returns {Promise<Object>} 操作结果
223
+ * @throws {Error} action 不受支持时
107
224
  */
108
225
  async main(params, ctx, options) {
109
226
  const action = params.action;
110
227
  if (action === 'send') return this._actionSend(params);
111
228
  if (action === 'broadcast') return this._actionBroadcast(params);
112
229
  if (action === 'close') return this._actionClose(params);
113
- return this._actionList();
230
+ if (action === 'list') return this._actionList();
231
+ throw new Error(`${this._label()} unsupported action: ${action}`);
114
232
  }
115
233
 
116
234
  /**
117
235
  * 执行发送操作
118
- * @param {Object} params 调用参数,含 client_id 与 data
236
+ * @param {Object} params 调用参数,含 client_id、data 与 topic
119
237
  * @returns {Object} 操作结果
120
238
  */
121
239
  _actionSend(params) {
122
240
  this._requireClientId(params);
123
- const success = this.send(params.client_id, params.data);
241
+ const success = this.send(params.client_id, params.data, params.topic);
124
242
  return { action: 'send', client_id: params.client_id, success };
125
243
  }
126
244
 
127
245
  /**
128
246
  * 执行广播操作
129
- * @param {Object} params 调用参数,含 data
247
+ * @param {Object} params 调用参数,含 data 与 topic
130
248
  * @returns {Object} 操作结果,count 为送达客户端数
131
249
  */
132
250
  _actionBroadcast(params) {
133
- const count = this.broadcast(params.data);
251
+ const count = this.broadcast(params.data, params.topic);
134
252
  return { action: 'broadcast', count };
135
253
  }
136
254
 
@@ -178,33 +296,47 @@ class SocketServer extends Mod {
178
296
 
179
297
  /**
180
298
  * 获取实际监听地址
181
- * @returns {Object|null} 监听地址信息,未启动时为 null
299
+ * @param {string} [protocol] 协议名,省略时取首个已启用协议
300
+ * @returns {Object|null} 监听地址信息,未监听时为 null
182
301
  */
183
- getAddress() {
184
- const server = this.#server;
302
+ getAddress(protocol) {
303
+ const key = protocol || this._enabledProtocols()[0];
304
+ const server = this.#servers[key];
185
305
  return server ? server.address() : null;
186
306
  }
187
307
 
308
+ /**
309
+ * 获取各协议的监听地址
310
+ * @returns {Object} 形如 { tcp, udp } 的地址表,未监听为 null
311
+ */
312
+ getAddresses() {
313
+ return { tcp: this.getAddress('tcp'), udp: this.getAddress('udp') };
314
+ }
315
+
188
316
  // ---------- 公开操作 ----------
189
317
  /**
190
318
  * 向指定客户端发送数据
191
319
  * @param {string} client_id 客户端 ID
192
- * @param {string|Object} data 待发送数据
320
+ * @param {string|Object|Buffer} data 待发送数据
321
+ * @param {string} [topic] 目标主题,framing 非 none 时必填
193
322
  * @returns {boolean} 是否发送成功
323
+ * @throws {Error} framing 非 none 且缺少 topic 时
194
324
  */
195
- send(client_id, data) {
325
+ send(client_id, data, topic) {
196
326
  const client = this.#clients.get(client_id);
197
327
  if (!client) return false;
198
- return this._sendToClient(client, this._encodeData(data));
328
+ return this._sendToClient(client, this._encodeFrame(topic, data));
199
329
  }
200
330
 
201
331
  /**
202
332
  * 向全部在线客户端广播数据
203
- * @param {string|Object} data 待广播数据
333
+ * @param {string|Object|Buffer} data 待广播数据
334
+ * @param {string} [topic] 目标主题,framing 非 none 时必填
204
335
  * @returns {number} 送达的客户端数量
336
+ * @throws {Error} framing 非 none 且缺少 topic 时
205
337
  */
206
- broadcast(data) {
207
- const payload = this._encodeData(data);
338
+ broadcast(data, topic) {
339
+ const payload = this._encodeFrame(topic, data);
208
340
  let count = 0;
209
341
  for (const client of this.#clients.values()) {
210
342
  if (this._sendToClient(client, payload)) count += 1;
@@ -276,7 +408,7 @@ class SocketServer extends Mod {
276
408
  */
277
409
  async _startTcp() {
278
410
  const server = net.createServer((socket) => this._handleConnection(socket));
279
- this.#server = server;
411
+ this.#servers.tcp = server;
280
412
  await this._listenTcp(server);
281
413
  server.on('error', (err) => this._emitError(err));
282
414
  }
@@ -311,7 +443,9 @@ class SocketServer extends Mod {
311
443
  address: this._remoteAddress(socket),
312
444
  socket,
313
445
  rinfo: null,
314
- options: null
446
+ options: null,
447
+ buffer: Buffer.alloc(0),
448
+ queue: Promise.resolve()
315
449
  };
316
450
  this.#clients.set(client_id, client);
317
451
  socket.on('data', (buf) => this._handleData(client_id, buf));
@@ -320,32 +454,285 @@ class SocketServer extends Mod {
320
454
  }
321
455
 
322
456
  /**
323
- * 处理 TCP 收到的数据:解码后交给事件管理器,返回非空结果时回发来源客户端
457
+ * 接收 TCP 数据并排入该连接的串行队列
324
458
  * @param {string} client_id 客户端 ID
325
459
  * @param {Object} buf 数据缓冲区
326
- * @returns {Promise<void>} 无返回值
460
+ * @returns {void}
327
461
  */
328
- async _handleData(client_id, buf) {
462
+ _handleData(client_id, buf) {
329
463
  const client = this.#clients.get(client_id);
330
464
  if (!client) return;
331
- const params = { client_id, data: this._decodeData(buf) };
332
- const ctx = {
333
- client_id,
334
- protocol: client.protocol,
335
- address: client.address,
336
- socket: client.socket
337
- };
465
+ this._enqueue(client, () => this._processTcpData(client, buf));
466
+ }
467
+
468
+ /**
469
+ * 把任务追加到连接的串行队列,保证同一连接的消息顺序执行
470
+ * @param {Object} client 连接信息
471
+ * @param {Function} task 待执行任务
472
+ * @returns {void}
473
+ */
474
+ _enqueue(client, task) {
475
+ client.queue = client.queue
476
+ .then(() => task())
477
+ .catch((err) => this._emitError(err));
478
+ }
479
+
480
+ /**
481
+ * 处理 TCP 数据:数据是字节流,需按帧格式累积切分
482
+ * @param {Object} client 连接信息
483
+ * @param {Object} buf 数据缓冲区
484
+ * @returns {Promise<void>} 无返回值
485
+ */
486
+ async _processTcpData(client, buf) {
487
+ await this._processInbound(client, buf, false);
488
+ }
489
+
490
+ // ---------- 分帧与派发 ----------
491
+ /**
492
+ * 统一入站处理:按 framing 还原帧,逐帧派发给事件管理器
493
+ * @param {Object} client 连接信息
494
+ * @param {Object} buf 本次收到的数据
495
+ * @param {boolean} is_complete 数据是否自带边界(UDP 为 true,TCP 为 false)
496
+ * @returns {Promise<void>} 无返回值
497
+ */
498
+ async _processInbound(client, buf, is_complete) {
499
+ if (this.config.framing === 'none') {
500
+ await this._dispatchMessage(client, client.protocol, this._decodeData(buf));
501
+ return;
502
+ }
503
+ const buffer = is_complete ? buf : Buffer.concat([client.buffer, buf]);
504
+ const { frames, rest } = this._extractFrames(buffer);
505
+ if (!is_complete) client.buffer = Buffer.from(rest);
506
+ for (const frame of frames) {
507
+ await this._dispatchMessage(client, frame.topic, this._decodeData(frame.payload));
508
+ }
509
+ if (is_complete && rest.length > 0) {
510
+ this._emitError(new Error(`${this._label()} invalid frame: truncated`));
511
+ }
512
+ }
513
+
514
+ /**
515
+ * 按当前帧格式切分缓冲区
516
+ * @param {Object} buffer 数据缓冲区
517
+ * @returns {Object} 形如 { frames, rest } 的结果,rest 为未成帧的剩余字节
518
+ */
519
+ _extractFrames(buffer) {
520
+ if (this.config.framing === 'length') return this._extractLengthFrames(buffer);
521
+ return this._extractLineFrames(buffer);
522
+ }
523
+
524
+ /**
525
+ * 按换行切分帧
526
+ * @param {Object} buffer 数据缓冲区
527
+ * @returns {Object} 形如 { frames, rest } 的结果
528
+ */
529
+ _extractLineFrames(buffer) {
530
+ const frames = [];
531
+ let start = 0;
532
+ let index = buffer.indexOf(LINE_FEED, start);
533
+ while (index !== -1) {
534
+ let end = index;
535
+ if (end > start && buffer[end - 1] === CARRIAGE_RETURN) end -= 1;
536
+ this._appendLineFrame(frames, buffer.subarray(start, end));
537
+ start = index + 1;
538
+ index = buffer.indexOf(LINE_FEED, start);
539
+ }
540
+ return { frames, rest: buffer.subarray(start) };
541
+ }
542
+
543
+ /**
544
+ * 解析单行帧并追加到结果集,缺少 topic 时丢弃并上报错误
545
+ * @param {Array<Object>} frames 帧结果集
546
+ * @param {Object} line 行数据
547
+ * @returns {void}
548
+ */
549
+ _appendLineFrame(frames, line) {
550
+ if (line.length === 0) return;
551
+ const frame = this._splitLineFrame(line);
552
+ if (!frame) {
553
+ this._emitError(new Error(`${this._label()} invalid frame: missing topic`));
554
+ return;
555
+ }
556
+ frames.push(frame);
557
+ }
558
+
559
+ /**
560
+ * 把一行拆成 topic 与 payload
561
+ * @param {Object} line 行数据
562
+ * @returns {Object|null} 形如 { topic, payload } 的帧,缺少 topic 时为 null
563
+ */
564
+ _splitLineFrame(line) {
565
+ const index = line.indexOf(SPACE);
566
+ if (index <= 0) return null;
567
+ const topic = line.subarray(0, index).toString('utf8');
568
+ return { topic, payload: line.subarray(index + 1) };
569
+ }
570
+
571
+ /**
572
+ * 按长度前缀切分帧
573
+ * @param {Object} buffer 数据缓冲区
574
+ * @returns {Object} 形如 { frames, rest } 的结果
575
+ */
576
+ _extractLengthFrames(buffer) {
577
+ const frames = [];
578
+ let offset = 0;
579
+ while (buffer.length - offset >= LENGTH_HEADER_SIZE) {
580
+ const topic_length = buffer.readUInt16BE(offset);
581
+ const payload_length = buffer.readUInt32BE(offset + TOPIC_LENGTH_SIZE);
582
+ const total = LENGTH_HEADER_SIZE + topic_length + payload_length;
583
+ if (buffer.length - offset < total) break;
584
+ const topic_start = offset + LENGTH_HEADER_SIZE;
585
+ const payload_start = topic_start + topic_length;
586
+ const topic = buffer.subarray(topic_start, payload_start).toString('utf8');
587
+ const payload = buffer.subarray(payload_start, offset + total);
588
+ if (topic_length === 0) {
589
+ this._emitError(new Error(`${this._label()} invalid frame: missing topic`));
590
+ } else {
591
+ frames.push({ topic, payload });
592
+ }
593
+ offset += total;
594
+ }
595
+ return { frames, rest: buffer.subarray(offset) };
596
+ }
597
+
598
+ /**
599
+ * 把入站帧派发给事件管理器,以 topic 作为 target
600
+ * @param {Object} client 连接信息
601
+ * @param {string} topic 帧主题
602
+ * @param {string|Object} data 解码后的数据
603
+ * @returns {Promise<void>} 无返回值
604
+ */
605
+ async _dispatchMessage(client, topic, data) {
606
+ const params = this._buildParams(client, topic, data);
607
+ const ctx = this._buildContext(client, topic);
338
608
  const options = this._clientOptions(client);
339
609
  try {
340
- const result = await this.#events.run(this.config.protocol, params, ctx, options);
610
+ const result = await this.#events.run(topic, params, ctx, options);
341
611
  const error = ctx.error || options.error;
342
612
  if (error) this._emitError(error);
343
- if (result !== undefined && result !== null) this.send(client_id, result);
613
+ this._reply(client, topic, result);
344
614
  } catch (err) {
345
615
  this._emitError(err);
346
616
  }
347
617
  }
348
618
 
619
+ /**
620
+ * 构建事件执行参数
621
+ * @param {Object} client 连接信息
622
+ * @param {string} topic 帧主题
623
+ * @param {string|Object} data 解码后的数据
624
+ * @returns {Object} 参数对象
625
+ */
626
+ _buildParams(client, topic, data) {
627
+ return { client_id: client.id, topic, data };
628
+ }
629
+
630
+ /**
631
+ * 构建事件执行上下文
632
+ * @param {Object} client 连接信息
633
+ * @param {string} topic 帧主题
634
+ * @returns {Object} 上下文对象
635
+ */
636
+ _buildContext(client, topic) {
637
+ const ctx = {
638
+ client_id: client.id,
639
+ topic,
640
+ protocol: client.protocol,
641
+ address: client.address,
642
+ scope: this.config.scope
643
+ };
644
+ if (client.rinfo) ctx.rinfo = client.rinfo;
645
+ if (client.socket) ctx.socket = client.socket;
646
+ return ctx;
647
+ }
648
+
649
+ /**
650
+ * 按事件返回值回发数据:{ topic, data } 视为重发描述,其余沿用来源 topic
651
+ * @param {Object} client 连接信息
652
+ * @param {string} topic 来源主题
653
+ * @param {*} result 事件返回值
654
+ * @returns {void}
655
+ */
656
+ _reply(client, topic, result) {
657
+ if (result === undefined || result === null) return;
658
+ if (this._isReplyDescriptor(result)) {
659
+ this.send(client.id, result.data, result.topic);
660
+ return;
661
+ }
662
+ this.send(client.id, result, topic);
663
+ }
664
+
665
+ /**
666
+ * 判断事件返回值是否为重发描述
667
+ * @param {*} result 事件返回值
668
+ * @returns {boolean} 是否为形如 { topic, data } 的描述
669
+ */
670
+ _isReplyDescriptor(result) {
671
+ if (!result || typeof result !== 'object' || Buffer.isBuffer(result)) return false;
672
+ if (typeof result.topic !== 'string' || result.topic.length === 0) return false;
673
+ return Object.prototype.hasOwnProperty.call(result, 'data');
674
+ }
675
+
676
+ // ---------- 建帧与编码 ----------
677
+ /**
678
+ * 按当前帧格式把 topic 与数据打包成可发送的缓冲区
679
+ * @param {string} topic 目标主题,framing 非 none 时必填
680
+ * @param {string|Object|Buffer} data 待发送数据
681
+ * @returns {Object} 待发送缓冲区
682
+ * @throws {Error} framing 非 none 且缺少 topic 时
683
+ */
684
+ _encodeFrame(topic, data) {
685
+ const payload = this._encodePayload(data);
686
+ if (this.config.framing === 'none') return payload;
687
+ this._requireTopic(topic);
688
+ if (this.config.framing === 'length') return this._buildLengthFrame(topic, payload);
689
+ return this._buildLineFrame(topic, payload);
690
+ }
691
+
692
+ /**
693
+ * 把数据编码成缓冲区,Buffer 原样返回
694
+ * @param {string|Object|Buffer} data 待编码数据
695
+ * @returns {Object} 数据缓冲区
696
+ */
697
+ _encodePayload(data) {
698
+ if (Buffer.isBuffer(data)) return data;
699
+ return Buffer.from(this._encodeData(data), this.config.encoding);
700
+ }
701
+
702
+ /**
703
+ * 构建行帧:`topic<空格>payload\n`
704
+ * @param {string} topic 主题
705
+ * @param {Object} payload 数据缓冲区
706
+ * @returns {Object} 帧缓冲区
707
+ * @throws {Error} payload 含换行符时(行帧无法表达)
708
+ */
709
+ _buildLineFrame(topic, payload) {
710
+ if (payload.indexOf(LINE_FEED) !== -1) {
711
+ throw new Error(`${this._label()} line framing payload cannot contain newline`);
712
+ }
713
+ const head = Buffer.from(`${topic} `, 'utf8');
714
+ const tail = Buffer.from([LINE_FEED]);
715
+ return Buffer.concat([head, payload, tail]);
716
+ }
717
+
718
+ /**
719
+ * 构建长度前缀帧:`[2字节 topic 长度][4字节 payload 长度][topic][payload]`
720
+ * @param {string} topic 主题
721
+ * @param {Object} payload 数据缓冲区
722
+ * @returns {Object} 帧缓冲区
723
+ * @throws {Error} topic 超出 65535 字节时
724
+ */
725
+ _buildLengthFrame(topic, payload) {
726
+ const topic_buf = Buffer.from(topic, 'utf8');
727
+ if (topic_buf.length > 0xffff) {
728
+ throw new Error(`${this._label()} topic too long`);
729
+ }
730
+ const header = Buffer.alloc(LENGTH_HEADER_SIZE);
731
+ header.writeUInt16BE(topic_buf.length, 0);
732
+ header.writeUInt32BE(payload.length, TOPIC_LENGTH_SIZE);
733
+ return Buffer.concat([header, topic_buf, payload]);
734
+ }
735
+
349
736
  /**
350
737
  * 获取远端地址字符串
351
738
  * @param {Object} socket 套接字实例
@@ -362,10 +749,11 @@ class SocketServer extends Mod {
362
749
  */
363
750
  async _startUdp() {
364
751
  const socket = dgram.createSocket('udp4');
365
- this.#server = socket;
752
+ this.#servers.udp = socket;
366
753
  await this._bindUdp(socket);
367
754
  socket.on('message', (buf, rinfo) => this._handleUdpMessage(buf, rinfo));
368
755
  socket.on('error', (err) => this._emitError(err));
756
+ this._startUdpTimer();
369
757
  }
370
758
 
371
759
  /**
@@ -386,52 +774,88 @@ class SocketServer extends Mod {
386
774
  }
387
775
 
388
776
  /**
389
- * 处理 UDP 报文:首见远端地址登记为客户端,之后交给事件管理器
777
+ * 处理 UDP 报文:首见远端地址登记为客户端,之后排入该客户端的串行队列
390
778
  * @param {Object} buf 数据缓冲区
391
779
  * @param {Object} rinfo 远端地址信息
392
- * @returns {Promise<void>} 无返回值
780
+ * @returns {void}
393
781
  */
394
- async _handleUdpMessage(buf, rinfo) {
782
+ _handleUdpMessage(buf, rinfo) {
395
783
  const client = this._ensureUdpClient(rinfo);
396
- const params = { client_id: client.id, data: this._decodeData(buf) };
397
- const ctx = {
398
- client_id: client.id,
399
- protocol: client.protocol,
400
- address: client.address,
401
- rinfo
402
- };
403
- const options = this._clientOptions(client);
404
- try {
405
- const result = await this.#events.run(this.config.protocol, params, ctx, options);
406
- const error = ctx.error || options.error;
407
- if (error) this._emitError(error);
408
- if (result !== undefined && result !== null) this.send(client.id, result);
409
- } catch (err) {
410
- this._emitError(err);
411
- }
784
+ this._enqueue(client, () => this._processUdpData(client, buf));
412
785
  }
413
786
 
414
787
  /**
415
- * 登记 UDP 客户端(同一远端地址复用同一 client_id)
788
+ * 处理 UDP 报文:数据报自带边界,按帧格式解析后派发
789
+ * @param {Object} client 连接信息
790
+ * @param {Object} buf 数据缓冲区
791
+ * @returns {Promise<void>} 无返回值
792
+ */
793
+ async _processUdpData(client, buf) {
794
+ await this._processInbound(client, buf, true);
795
+ }
796
+
797
+ /**
798
+ * 登记 UDP 客户端(同一远端地址复用同一 client_id),并刷新活跃时间
416
799
  * @param {Object} rinfo 远端地址信息
417
800
  * @returns {Object} 客户端信息
418
801
  */
419
802
  _ensureUdpClient(rinfo) {
420
803
  const client_id = `udp_${rinfo.address}:${rinfo.port}`;
421
804
  const existed = this.#clients.get(client_id);
422
- if (existed) return existed;
805
+ if (existed) {
806
+ existed.last_seen = Date.now();
807
+ return existed;
808
+ }
423
809
  const client = {
424
810
  id: client_id,
425
811
  protocol: 'udp',
426
812
  address: client_id,
427
813
  socket: null,
428
814
  rinfo,
429
- options: null
815
+ options: null,
816
+ last_seen: Date.now(),
817
+ queue: Promise.resolve()
430
818
  };
431
819
  this.#clients.set(client_id, client);
432
820
  return client;
433
821
  }
434
822
 
823
+ /**
824
+ * 启动 UDP 过期连接清理定时器,udp_ttl <= 0 时关闭清理
825
+ * @returns {void}
826
+ */
827
+ _startUdpTimer() {
828
+ const ttl = this.config.udp_ttl;
829
+ if (!ttl || ttl <= 0) return;
830
+ const interval = Math.max(1000, Math.min(ttl, 60000));
831
+ this.#udp_timer = setInterval(() => this._pruneUdpClients(), interval);
832
+ if (typeof this.#udp_timer.unref === 'function') this.#udp_timer.unref();
833
+ }
834
+
835
+ /**
836
+ * 清理超过 udp_ttl 未活动的 UDP 客户端
837
+ * @returns {void}
838
+ */
839
+ _pruneUdpClients() {
840
+ const ttl = this.config.udp_ttl;
841
+ if (!ttl || ttl <= 0) return;
842
+ const deadline = Date.now() - ttl;
843
+ for (const [client_id, client] of this.#clients) {
844
+ if (client.protocol !== 'udp') continue;
845
+ if (client.last_seen <= deadline) this.#clients.delete(client_id);
846
+ }
847
+ }
848
+
849
+ /**
850
+ * 停止 UDP 过期连接清理定时器
851
+ * @returns {void}
852
+ */
853
+ _clearUdpTimer() {
854
+ if (!this.#udp_timer) return;
855
+ clearInterval(this.#udp_timer);
856
+ this.#udp_timer = null;
857
+ }
858
+
435
859
  // ---------- 数据编解码 ----------
436
860
  /**
437
861
  * 编码待发送数据
@@ -491,7 +915,7 @@ class SocketServer extends Mod {
491
915
  * @returns {boolean} 是否发送成功
492
916
  */
493
917
  _sendUdp(client, payload) {
494
- const socket = this.#server;
918
+ const socket = this.#servers.udp;
495
919
  if (!socket || !client.rinfo) return false;
496
920
  socket.send(payload, client.rinfo.port, client.rinfo.address);
497
921
  return true;
@@ -508,19 +932,46 @@ class SocketServer extends Mod {
508
932
  }
509
933
 
510
934
  /**
511
- * 关闭底层服务器
935
+ * 关闭全部底层服务器并停止 UDP 清理定时器
512
936
  * @returns {Promise<void>} 无返回值
513
937
  */
514
- _closeServer() {
515
- const server = this.#server;
938
+ async _closeServers() {
939
+ this._clearUdpTimer();
940
+ const servers = this.#servers;
941
+ this.#servers = { tcp: null, udp: null };
942
+ await this._closeOne(servers.tcp);
943
+ await this._closeOne(servers.udp);
944
+ }
945
+
946
+ /**
947
+ * 关闭单个底层服务器
948
+ * @param {Object|null} server net.Server 或 dgram.Socket 实例
949
+ * @returns {Promise<void>} 无返回值
950
+ */
951
+ _closeOne(server) {
516
952
  if (!server) return Promise.resolve();
517
- this.#server = null;
518
953
  return new Promise((resolve) => {
519
- server.close(() => resolve());
954
+ try {
955
+ server.close(() => resolve());
956
+ } catch {
957
+ resolve();
958
+ }
520
959
  });
521
960
  }
522
961
 
523
962
  // ---------- 校验辅助 ----------
963
+ /**
964
+ * 断言 topic 非空(framing 非 none 时帧必须携带 topic)
965
+ * @param {string} topic 目标主题
966
+ * @returns {void}
967
+ * @throws {Error} topic 为空时
968
+ */
969
+ _requireTopic(topic) {
970
+ if (!topic) {
971
+ throw new Error(`${this._label()} topic required when framing is ${this.config.framing}`);
972
+ }
973
+ }
974
+
524
975
  /**
525
976
  * 断言调用参数含 client_id
526
977
  * @param {Object} params 调用参数
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mm_socket_server",
3
- "version": "2.0.0",
4
- "description": "最薄的 TCP/UDP 套接字服务器入站适配器:消息直达 mm_event 事件管理器,不做路由与插件。",
3
+ "version": "3.0.0",
4
+ "description": "最薄的 TCP/UDP 套接字服务器入站适配器:按 topic 分帧后直达 mm_event 事件管理器,不做路由与插件。",
5
5
  "keywords": [
6
6
  "超级美眉",
7
7
  "入站适配器",