@lyrify/znl 0.5.6 → 0.6.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,22 +1,22 @@
1
1
  # ZNL
2
2
 
3
- 基于 ZeroMQ `ROUTER/DEALER` 模式的 Node.js 通信库,提供开箱即用的双向 RPC 与 PUB/SUB 广播能力。
3
+ 基于 ZeroMQ `ROUTER / DEALER` 模式的 Node.js 通信库,提供双向 RPC、广播、在线状态感知,以及可选的签名认证与透明加密能力。
4
4
 
5
5
  ## 特性
6
6
 
7
- - 基于 `ROUTER/DEALER` 同时实现 RPC 请求-响应与 PUB/SUB 广播,一套连接两种模式
8
- - 自动处理并发消息匹配、自动处理心跳包、超时控制、最大并发限制
9
- - 支持 Master Slave 主动发起请求(双向 RPC)
10
- - 基于 ROUTER 实现 PUB/SUB 广播,无需额外 socket 或端口
11
- - Slave 自动注册/注销,Master 实时感知在线节点
12
- - 支持可选加密认证(签名 + 防重放 + AES-256-GCM 透明加密)
13
- - 加密开关 `encrypted`:
14
- - `false`:明文模式(不签名/不加密)
15
- - `true`:签名 + 防重放 + payload 透明加密(AES-256-GCM)
16
- - 可选关闭 payload 摘要校验(`enablePayloadDigest=false`)以提升性能
17
- - 建议 master/slave 两端保持一致配置,避免认证不一致
18
- - `authKey` `authKeyMap` 仅在 `encrypted=true` 时必填
19
- - `authKeyMap` 支持 master 按 `slaveId` 配置不同 key(未命中时会回退到 `authKey`)
7
+ - 基于单连接实现双向 RPC 与广播
8
+ - 支持 `Master -> Slave` 与 `Slave -> Master` 双向主动请求
9
+ - `Slave` 自动注册 / 注销,`Master` 实时维护在线节点列表
10
+ - 支持请求超时控制与最大并发限制
11
+ - 心跳采用 `heartbeat -> heartbeat_ack` 应答机制
12
+ - `Slave` 提供主节点在线状态查询 API:`masterOnline` / `isMasterOnline()`
13
+ - 支持安全模式:
14
+ - HMAC 签名
15
+ - 时间戳校验
16
+ - nonce 防重放
17
+ - AES-256-GCM 透明加密
18
+ - 可选 payload 摘要校验
19
+ - 支持 `authKeyMap`,允许 `Master` 按 `slaveId` 使用不同密钥
20
20
  - Payload 支持 `string`、`Buffer`、`Uint8Array` 及其数组(多帧)
21
21
 
22
22
  ## 安装
@@ -25,7 +25,7 @@
25
25
  pnpm add @lyrify/znl
26
26
  ```
27
27
 
28
- 本地开发克隆仓库后:
28
+ 本地开发:
29
29
 
30
30
  ```bash
31
31
  pnpm install
@@ -33,7 +33,7 @@ pnpm install
33
33
 
34
34
  ## 快速开始
35
35
 
36
- ### Master 节点
36
+ ### Master
37
37
 
38
38
  ```js
39
39
  import { ZNL } from "@lyrify/znl";
@@ -41,29 +41,36 @@ import { ZNL } from "@lyrify/znl";
41
41
  const master = new ZNL({
42
42
  role: "master",
43
43
  id: "master-1",
44
- endpoints: { router: "tcp://127.0.0.1:6003" },
44
+ endpoints: {
45
+ router: "tcp://127.0.0.1:6003",
46
+ },
45
47
  authKey: "your-shared-key",
46
- encrypted: true, // 推荐:透明加密 + 防重放
48
+ encrypted: true,
47
49
  });
48
50
 
49
- // RPC:自动回复 slave 的请求
51
+ // 注册自动回复处理器
50
52
  master.ROUTER(async ({ identityText, payload }) => {
51
53
  const text = Buffer.isBuffer(payload) ? payload.toString() : String(payload);
52
54
  return `已收到来自 ${identityText} 的消息:${text}`;
53
55
  });
54
56
 
55
- // PUB/SUB:感知节点上下线
56
- master.on("slave_connected", (id) => console.log(`${id} 上线,在线:${master.slaves}`));
57
- master.on("slave_disconnected", (id) => console.log(`${id} 下线,在线:${master.slaves}`));
57
+ // 监听节点上下线
58
+ master.on("slave_connected", (id) => {
59
+ console.log(`${id} 上线,当前在线:${master.slaves.join(", ")}`);
60
+ });
61
+
62
+ master.on("slave_disconnected", (id) => {
63
+ console.log(`${id} 下线,当前在线:${master.slaves.join(", ")}`);
64
+ });
58
65
 
59
66
  await master.start();
60
67
 
61
- // PUB/SUB:广播消息(fire-and-forget)
68
+ // 广播消息
62
69
  master.publish("news", "今日头条:ZNL 正式发布");
63
70
  master.publish("system", JSON.stringify({ status: "ok", time: Date.now() }));
64
71
  ```
65
72
 
66
- ### Slave 节点
73
+ ### Slave
67
74
 
68
75
  ```js
69
76
  import { ZNL } from "@lyrify/znl";
@@ -71,26 +78,29 @@ import { ZNL } from "@lyrify/znl";
71
78
  const slave = new ZNL({
72
79
  role: "slave",
73
80
  id: "slave-001",
74
- endpoints: { router: "tcp://127.0.0.1:6003" },
81
+ endpoints: {
82
+ router: "tcp://127.0.0.1:6003",
83
+ },
75
84
  authKey: "your-shared-key",
76
- encrypted: true, // 需与 master 一致
85
+ encrypted: true,
77
86
  });
78
87
 
79
- // PUB/SUB:精确订阅(可在 start 前调用)
88
+ // 订阅指定 topic
80
89
  slave.subscribe("news", ({ payload }) => {
81
90
  console.log("收到新闻:", payload.toString());
82
91
  });
83
92
 
84
- // PUB/SUB:兜底监听所有 topic
93
+ // 兜底监听所有广播
85
94
  slave.on("publish", ({ topic, payload }) => {
86
95
  console.log(`[${topic}]`, payload.toString());
87
96
  });
88
97
 
89
98
  await slave.start();
90
99
 
91
- // RPC:向 master 发请求并等待响应
92
- const reply = await slave.DEALER("hello master", { timeoutMs: 4000 });
93
- console.log(reply.toString());
100
+ if (slave.isMasterOnline()) {
101
+ const reply = await slave.DEALER("hello master", { timeoutMs: 4000 });
102
+ console.log(reply.toString());
103
+ }
94
104
  ```
95
105
 
96
106
  ## 构造函数
@@ -114,112 +124,384 @@ new ZNL({
114
124
  });
115
125
  ```
116
126
 
127
+ ## 参数说明
128
+
117
129
  | 参数 | 必填 | 说明 |
118
130
  |------|------|------|
119
131
  | `role` | ✓ | 节点角色,`"master"` 或 `"slave"` |
120
- | `id` | ✓ | 节点唯一标识;slave 端同时作为 ZMQ `routingId` |
121
- | `endpoints.router` | | ROUTER 端点,默认 `tcp://127.0.0.1:6003` |
122
- | `maxPending` | | 最大并发 RPC 请求数,默认 `1000`;`0` 表示不限制 |
123
- | `authKey` | | 共享认证 Key;与 `authKeyMap` 二选一(`encrypted=true` 时至少提供一个) |
124
- | `authKeyMap` | | master 侧 slaveId authKey 映射;未命中时回退到 `authKey` |
125
- | `heartbeatInterval` | | 心跳间隔(毫秒),默认 `3000`,`0` 表示禁用心跳 |
126
- | `heartbeatTimeoutMs` | | 心跳超时时间(毫秒),默认 `0` 表示使用 `heartbeatInterval × 3` |
127
- | `encrypted` | | 是否启用加密:`false`(默认,明文) / `true`(签名+防重放+透明加密) |
128
- | `enablePayloadDigest` | | 是否启用 payload 摘要校验,默认 `true`(关闭可提升性能) |
129
- | `maxTimeSkewMs` | | 时间戳最大允许偏移(毫秒),默认 `30000`,用于防重放校验 |
130
- | `replayWindowMs` | | nonce 重放缓存窗口(毫秒),默认 `120000` |
132
+ | `id` | ✓ | 节点唯一标识;`slave` 侧同时作为 ZMQ `routingId` |
133
+ | `endpoints.router` | | ROUTER 端点,默认 `tcp://127.0.0.1:6003` |
134
+ | `maxPending` | | 最大并发 RPC 请求数,默认 `1000`;`0` 表示不限制 |
135
+ | `authKey` | | 共享认证 Key;与 `authKeyMap` 二选一(`encrypted=true` 时至少提供一个) |
136
+ | `authKeyMap` | | `master``slaveId -> authKey` 映射;未命中时回退到 `authKey` |
137
+ | `heartbeatInterval` | | 心跳间隔(毫秒),默认 `3000`;`0` 表示禁用心跳 |
138
+ | `heartbeatTimeoutMs` | | 心跳超时时间(毫秒),默认 `0` 表示使用 `heartbeatInterval × 3` |
139
+ | `encrypted` | | 是否启用安全模式:`false`(默认,明文) / `true`(签名、防重放、透明加密) |
140
+ | `enablePayloadDigest` | | 是否启用 payload 摘要校验,默认 `true`;关闭可提升性能 |
141
+ | `maxTimeSkewMs` | | 时间戳最大允许偏移(毫秒),默认 `30000` |
142
+ | `replayWindowMs` | | nonce 重放缓存窗口(毫秒),默认 `120000` |
143
+
144
+ ## 使用建议
145
+
146
+ - `master` 与 `slave` 两端应保持一致的 `encrypted` 配置
147
+ - 若使用 `enablePayloadDigest=false`,两端也应保持一致
148
+ - `encrypted=true` 时必须提供非空 `authKey`,或在 `master` 侧提供 `authKeyMap`
131
149
 
132
150
  ## API
133
151
 
134
- ### `start()`
152
+ ### 生命周期
153
+
154
+ #### `start()`
155
+
156
+ 启动节点。
135
157
 
136
- 启动节点:
158
+ `master` 侧:
137
159
 
138
- - `master`:绑定(bind)ROUTER socket
139
- - `slave`:连接(connect)DEALER socket,并自动向 master 发送注册帧
160
+ - 绑定 ROUTER socket
161
+ - 启动在线节点心跳检测
140
162
 
141
- 重复调用安全,若正在启动中则等待同一个 Promise。
163
+ `slave` 侧:
142
164
 
143
- ### `stop()`
165
+ - 连接 DEALER socket
166
+ - 自动尝试发送注册帧
167
+ - 启动心跳流程
168
+ - 发送 `heartbeat` 后等待 `heartbeat_ack`
169
+ - 收到 `heartbeat_ack` 后调度下一次心跳
144
170
 
145
- 停止节点:
171
+ #### `stop()`
146
172
 
147
- - `slave`:先向 master 发送注销帧,再关闭 socket
148
- - `master`:清空在线节点表,关闭 socket,立即 reject 所有 pending RPC 请求
173
+ 停止节点。
149
174
 
150
- ### `DEALER(payloadOrHandler, options?)`
175
+ `slave` 侧:
151
176
 
152
- **Slave 侧调用:**
177
+ - 停止心跳
178
+ - 发送注销帧
179
+ - 关闭 socket
153
180
 
154
- - `payloadOrHandler` 为 payload 时:向 Master 发送 RPC 请求并等待响应,返回 `Promise<Buffer | Array>`
155
- - `payloadOrHandler` 为函数时:注册 slave 侧自动回复处理器(Master 主动发来 RPC 请求时触发)
181
+ `master` 侧:
156
182
 
157
- ### `ROUTER(identityOrHandler, payload?, options?)`
183
+ - 清空在线节点表
184
+ - 关闭 socket
185
+ - reject 所有 pending RPC 请求
158
186
 
159
- **Master 侧调用:**
187
+ ### 双向 RPC
160
188
 
161
- - `identityOrHandler` 为函数时:注册 master 侧自动回复处理器(Slave 发来 RPC 请求时触发)
162
- - `identityOrHandler` 为 identity(slave ID)时:Master 主动向指定 Slave 发送 RPC 请求并等待响应,返回 `Promise<Buffer | Array>`
189
+ #### `DEALER(payloadOrHandler, options?)`
163
190
 
164
- ### `addAuthKey(slaveId, authKey)`
191
+ `slave` 侧使用。
165
192
 
166
- **Master 侧调用:**
193
+ - 当 `payloadOrHandler` 为 payload 时,向 `Master` 发起 RPC 请求,返回 `Promise<Buffer | Array>`
194
+ - 当 `payloadOrHandler` 为函数时,注册 `slave` 侧自动回复处理器,用于处理 `Master` 主动发来的请求
167
195
 
168
- - 动态添加/更新某个 slave 的 authKey(立即生效)
196
+ #### `ROUTER(identityOrHandler, payload?, options?)`
169
197
 
170
- ### `removeAuthKey(slaveId)`
198
+ `master` 侧使用。
171
199
 
172
- **Master 侧调用:**
200
+ - 当 `identityOrHandler` 为函数时,注册 `master` 侧自动回复处理器,用于处理 `Slave` 发来的请求
201
+ - 当 `identityOrHandler` 为某个 `slaveId` 时,`Master` 主动向指定 `Slave` 发起 RPC 请求,返回 `Promise<Buffer | Array>`
173
202
 
174
- - 移除某个 slave 的 authKey(立即生效),并触发 `slave_disconnected`
203
+ #### `options.timeoutMs`
175
204
 
176
- ### `options.timeoutMs`
205
+ 单次 RPC 请求超时时间,默认 `5000` 毫秒。
177
206
 
178
- 单次 RPC 请求超时时间,默认 `5000` ms。
207
+ ### 广播与订阅
179
208
 
180
- ### `publish(topic, payload)`
209
+ #### `publish(topic, payload)`
181
210
 
182
- **Master 侧调用**,向所有当前在线的 slave 广播消息(fire-and-forget,无需 await)。
211
+ `master` 侧使用。
183
212
 
184
- - `topic`:消息主题字符串,slave 侧可按 topic 精确过滤
185
- - `payload`:同 RPC,支持 `string`、`Buffer`、`Uint8Array` 或其数组
186
- - 若某个 slave 发送失败,自动将其从在线列表移除并触发 `slave_disconnected`
213
+ 向所有当前在线的 `slave` 广播消息(fire-and-forget,无需 `await`)。
214
+
215
+ - `topic`:消息主题
216
+ - `payload`:支持 `string`、`Buffer`、`Uint8Array` 或其数组
217
+ - 若某个 `slave` 发送失败,会自动将其移出在线列表并触发 `slave_disconnected`
187
218
 
188
219
  ```js
189
220
  master.publish("news", "breaking news!");
190
221
  master.publish("metrics", JSON.stringify({ cpu: 0.42 }));
191
222
  ```
192
223
 
193
- ### `subscribe(topic, handler)`
224
+ #### `subscribe(topic, handler)`
225
+
226
+ 仅 `slave` 侧使用。
194
227
 
195
- **Slave 侧调用**,订阅指定 topic,master 广播时触发 handler
228
+ 订阅指定 topic。
196
229
 
197
- - 可在 `start()` 前后任意时刻调用,订阅信息跨 stop/start 周期保留
230
+ - 可在 `start()` 前后调用
231
+ - 订阅关系跨 `stop()/start()` 周期保留
198
232
  - 同一 topic 重复订阅会覆盖旧 handler
199
- - 支持链式调用(返回 `this`)
233
+ - 返回 `this`,支持链式调用
200
234
 
201
235
  ```js
202
236
  slave
203
- .subscribe("news", ({ topic, payload }) => { /* ... */ })
204
- .subscribe("metrics", ({ topic, payload }) => { /* ... */ });
237
+ .subscribe("news", ({ topic, payload }) => {
238
+ // ...
239
+ })
240
+ .subscribe("metrics", ({ topic, payload }) => {
241
+ // ...
242
+ });
205
243
  ```
206
244
 
207
- ### `unsubscribe(topic)`
245
+ #### `unsubscribe(topic)`
208
246
 
209
- **Slave 侧调用**,取消订阅指定 topic,支持链式调用。
247
+ `slave` 侧使用。
248
+
249
+ 取消订阅指定 topic。
210
250
 
211
251
  ```js
212
252
  slave.unsubscribe("news");
213
253
  ```
214
254
 
215
- ### `slaves`
255
+ ### 在线状态与节点管理
256
+
257
+ #### `slaves`
216
258
 
217
- **Master 侧只读属性**,返回当前所有在线 slave ID 的快照数组。
259
+ `master` 侧只读属性。
260
+
261
+ 返回当前所有在线 `slaveId` 的快照数组。
262
+
263
+ ```js
264
+ console.log(master.slaves);
265
+ ```
266
+
267
+ #### `masterOnline`
268
+
269
+ 仅 `slave` 侧只读属性。
270
+
271
+ 表示最近一次链路确认结果。
272
+
273
+ - `true`:最近收到合法的 `heartbeat_ack`,或收到来自 `master` 的合法业务帧
274
+ - `false`:尚未建立有效链路、心跳应答超时、或节点已停止
218
275
 
219
276
  ```js
220
- console.log(master.slaves); // ["slave-001", "slave-002"]
277
+ console.log(slave.masterOnline);
278
+ ```
279
+
280
+ #### `isMasterOnline()`
281
+
282
+ 仅 `slave` 侧方法。
283
+
284
+ 返回当前主节点在线状态。该值基于最近一次链路确认结果,不会主动发起实时网络探测。
285
+
286
+ ```js
287
+ if (slave.isMasterOnline()) {
288
+ console.log("master 在线");
289
+ }
290
+ ```
291
+
292
+ #### `addAuthKey(slaveId, authKey)`
293
+
294
+ 仅 `master` 侧使用。
295
+
296
+ 动态添加或更新某个 `slave` 的 `authKey`,立即生效。
297
+
298
+ #### `removeAuthKey(slaveId)`
299
+
300
+ 仅 `master` 侧使用。
301
+
302
+ 移除某个 `slave` 的 `authKey`,立即生效,并触发 `slave_disconnected`。
303
+
304
+ ## 心跳机制
305
+
306
+ ZNL 使用 `heartbeat -> heartbeat_ack` 进行链路确认。
307
+
308
+ 流程如下:
309
+
310
+ 1. `slave` 发送一条 `heartbeat`
311
+ 2. `master` 收到并验证通过后,立即返回 `heartbeat_ack`
312
+ 3. `slave` 收到 `heartbeat_ack` 后,调度下一次 heartbeat
313
+ 4. 若超过应答超时时间仍未收到 `heartbeat_ack``
314
+ - `slave` 将 `masterOnline` 置为 `false`
315
+ - 主动重建 `Dealer`
316
+ - 丢弃旧连接残留消息
317
+ - 尝试恢复连接与注册
318
+
319
+ ## 安全机制
320
+
321
+ 当 `encrypted=true` 时,ZNL 启用安全模式:
322
+
323
+ - HMAC 签名
324
+ - 时间戳校验
325
+ - nonce 防重放
326
+ - AES-256-GCM 透明加密
327
+ - 可选 payload 摘要校验
328
+
329
+ ### 安全建议
330
+
331
+ - `authKey` 不要硬编码到公开仓库
332
+ - `master` 与 `slave` 必须使用匹配的密钥配置
333
+ - 若使用 `authKeyMap`,建议按 `slaveId` 做最小权限配置
334
+ - 生产环境建议开启:
335
+ - `encrypted: true`
336
+ - `enablePayloadDigest: true`
337
+
338
+ ## 底层帧协议
339
+
340
+ 本节说明 ZNL 在 ZeroMQ 上层定义的控制帧结构,用于协议对接、调试与抓包分析。
341
+
342
+ ### 总体说明
343
+
344
+ `master` 侧 ROUTER 收包结构:
345
+
346
+ ```text
347
+ [identity, ...控制帧]
348
+ ```
349
+
350
+ `slave` 侧 DEALER 收包结构:
351
+
352
+ ```text
353
+ [...控制帧]
354
+ ```
355
+
356
+ 控制帧统一前缀:
357
+
358
+ ```text
359
+ __znl_v1__
360
+ ```
361
+
362
+ 认证字段标记:
363
+
364
+ ```text
365
+ __znl_v1_auth__
366
+ ```
367
+
368
+ ### 控制帧类型
369
+
370
+ | 类型 | 方向 | 作用 |
371
+ |------|------|------|
372
+ | `register` | `slave -> master` | 注册上线 |
373
+ | `unregister` | `slave -> master` | 主动下线 |
374
+ | `heartbeat` | `slave -> master` | 心跳请求 |
375
+ | `heartbeat_ack` | `master -> slave` | 心跳应答 |
376
+ | `req` | 双向 | RPC 请求 |
377
+ | `res` | 双向 | RPC 响应 |
378
+ | `pub` | `master -> slave` | 广播消息 |
379
+
380
+ ### 各类帧结构
381
+
382
+ #### `register`
383
+
384
+ ```text
385
+ [PREFIX, "register", (AUTH_MARKER, authProof)?]
386
+ ```
387
+
388
+ 示例:
389
+
390
+ ```text
391
+ ["__znl_v1__", "register"]
392
+ ["__znl_v1__", "register", "__znl_v1_auth__", "<proof-token>"]
393
+ ```
394
+
395
+ #### `unregister`
396
+
397
+ ```text
398
+ [PREFIX, "unregister"]
399
+ ```
400
+
401
+ 示例:
402
+
403
+ ```text
404
+ ["__znl_v1__", "unregister"]
405
+ ```
406
+
407
+ #### `heartbeat`
408
+
409
+ ```text
410
+ [PREFIX, "heartbeat", (AUTH_MARKER, authProof)?]
411
+ ```
412
+
413
+ 示例:
414
+
415
+ ```text
416
+ ["__znl_v1__", "heartbeat"]
417
+ ["__znl_v1__", "heartbeat", "__znl_v1_auth__", "<proof-token>"]
418
+ ```
419
+
420
+ #### `heartbeat_ack`
421
+
422
+ ```text
423
+ [PREFIX, "heartbeat_ack", (AUTH_MARKER, authProof)?]
424
+ ```
425
+
426
+ 示例:
427
+
428
+ ```text
429
+ ["__znl_v1__", "heartbeat_ack"]
430
+ ["__znl_v1__", "heartbeat_ack", "__znl_v1_auth__", "<proof-token>"]
431
+ ```
432
+
433
+ #### `req`
434
+
435
+ ```text
436
+ [PREFIX, "req", requestId, (AUTH_MARKER, authProof)?, ...payloadFrames]
437
+ ```
438
+
439
+ 示例:
440
+
441
+ ```text
442
+ ["__znl_v1__", "req", "<requestId>", ...payloadFrames]
443
+ ["__znl_v1__", "req", "<requestId>", "__znl_v1_auth__", "<proof-token>", ...payloadFrames]
444
+ ```
445
+
446
+ #### `res`
447
+
448
+ ```text
449
+ [PREFIX, "res", requestId, (AUTH_MARKER, authProof)?, ...payloadFrames]
450
+ ```
451
+
452
+ 示例:
453
+
454
+ ```text
455
+ ["__znl_v1__", "res", "<requestId>", ...payloadFrames]
456
+ ["__znl_v1__", "res", "<requestId>", "__znl_v1_auth__", "<proof-token>", ...payloadFrames]
457
+ ```
458
+
459
+ #### `pub`
460
+
461
+ ```text
462
+ [PREFIX, "pub", topic, (AUTH_MARKER, authProof)?, ...payloadFrames]
463
+ ```
464
+
465
+ 示例:
466
+
467
+ ```text
468
+ ["__znl_v1__", "pub", "news", ...payloadFrames]
469
+ ["__znl_v1__", "pub", "news", "__znl_v1_auth__", "<proof-token>", ...payloadFrames]
221
470
  ```
222
471
 
472
+ ### 认证令牌字段
473
+
474
+ 安全模式下,认证令牌内部包含以下字段:
475
+
476
+ - `kind`
477
+ - `nodeId`
478
+ - `requestId`
479
+ - `timestamp`
480
+ - `nonce`
481
+ - `payloadDigest`
482
+
483
+ 校验时会验证:
484
+
485
+ - 帧类型是否匹配
486
+ - 节点 ID 是否匹配
487
+ - 请求 ID 是否匹配
488
+ - 时间戳是否在允许偏差内
489
+ - nonce 是否重复
490
+ - payload 摘要是否一致
491
+
492
+ ### 明文模式与安全模式
493
+
494
+ #### 明文模式 `encrypted=false`
495
+
496
+ - 不签名
497
+ - 不加密
498
+ - 帧结构更轻量
499
+
500
+ #### 安全模式 `encrypted=true`
501
+
502
+ - 控制帧会附带认证证明
503
+ - 业务 payload 会被透明加密
504
+
223
505
  ## 事件
224
506
 
225
507
  通过 `node.on(eventName, handler)` 监听:
@@ -231,9 +513,9 @@ console.log(master.slaves); // ["slave-001", "slave-002"]
231
513
  | `request` | 两者 | 解析出 RPC 请求帧(认证通过后) |
232
514
  | `response` | 两者 | 解析出 RPC 响应帧 |
233
515
  | `message` | 两者 | 所有解析消息的统一事件 |
234
- | `publish` | Slave | 收到 master 广播,携带 `{ topic, payload }` |
235
- | `slave_connected` | Master | slave 注册成功上线,携带 `slaveId` |
236
- | `slave_disconnected` | Master | slave 注销或发送失败下线,携带 `slaveId` |
516
+ | `publish` | Slave | 收到 `master` 广播,携带 `{ topic, payload }` |
517
+ | `slave_connected` | Master | `slave` 注册成功上线,携带 `slaveId` |
518
+ | `slave_disconnected` | Master | `slave` 注销或发送失败下线,携带 `slaveId` |
237
519
  | `auth_failed` | Master / Slave | 认证失败(签名校验失败、重放检测失败、解密失败等),请求已被丢弃 |
238
520
  | `error` | 两者 | 内部错误 |
239
521
 
@@ -250,7 +532,15 @@ node test/slave/index.js slave-001
250
532
 
251
533
  ## 集成测试
252
534
 
253
- 在同一进程内启动 Master / Slave,自动验证 RPC、并发、认证、超时、PUB/SUB 等全部功能:
535
+ 在同一进程内启动 `Master / Slave`,自动验证:
536
+
537
+ - RPC
538
+ - 并发
539
+ - 认证
540
+ - 超时
541
+ - PUB/SUB
542
+ - 心跳恢复
543
+ - 在线状态 API
254
544
 
255
545
  ```bash
256
546
  pnpm test
@@ -258,6 +548,8 @@ pnpm test
258
548
 
259
549
  ## 并发压测
260
550
 
551
+ ### 明文模式
552
+
261
553
  ```bash
262
554
  # 终端 1:启动 Echo 服务端(plain)
263
555
  pnpm test:echo
@@ -266,7 +558,7 @@ pnpm test:echo
266
558
  pnpm test:100 -- 100 10000 slave-001
267
559
  ```
268
560
 
269
- 启用安全模式示例:
561
+ ### 安全模式
270
562
 
271
563
  ```bash
272
564
  # 终端 1:加密模式启动 Echo 服务端
@@ -276,14 +568,26 @@ ZNL_AUTH_KEY=my-secret ZNL_ENCRYPTED=true pnpm test:echo
276
568
  pnpm test:100 -- 100 10000 slave-001 my-secret true
277
569
  ```
278
570
 
279
- 参数说明:
571
+ ### 参数说明
280
572
 
281
573
  - 总请求数
282
574
  - 超时时间(毫秒)
283
- - Slave 节点 ID
575
+ - `Slave` 节点 ID
576
+
577
+ ## 常见问题
578
+
579
+ ### 为什么 `slave.start()` 后立刻发送第一条请求可能失败?
580
+
581
+ 当前版本对 `Dealer` 的发送策略更严格。建议先等待 `slave.isMasterOnline() === true`,再发送首个业务请求。
582
+
583
+ ### 为什么会出现“令牌已过期或时间戳异常”?
584
+
585
+ 常见原因:
586
+
587
+ - 主从机器时间差过大
588
+ - 节点时间被手动修改
589
+ - 历史旧消息在较晚时间才被投递
284
590
 
285
- ## 发布前检查
591
+ ### `masterOnline=true` 是否表示此刻网络一定可用?
286
592
 
287
- 1. 更新 `package.json` 中的 `name`、`version`、`author`、`repository`
288
- 2. 确认 README 中的包名与 import 路径
289
- 3. 按需更新 `LICENSE` 中的版权信息
593
+ 不是。该值表示最近一次链路确认成功,适合作为业务层在线状态参考,但不是一次即时网络探针。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lyrify/znl",
3
- "version": "0.5.6",
3
+ "version": "0.6.0",
4
4
  "description": "ZNL - ZeroMQ Node Link",
5
5
  "type": "module",
6
6
  "main": "./index.js",
package/src/ZNL.js CHANGED
@@ -49,6 +49,7 @@ import {
49
49
  buildRegisterFrames,
50
50
  buildUnregisterFrames,
51
51
  buildHeartbeatFrames,
52
+ buildHeartbeatAckFrames,
52
53
  buildRequestFrames,
53
54
  buildResponseFrames,
54
55
  buildPublishFrames,
@@ -154,9 +155,24 @@ export class ZNL extends EventEmitter {
154
155
  /** 心跳间隔(ms),0 表示禁用;slave/master 共用同一配置 */
155
156
  #heartbeatInterval;
156
157
 
157
- /** slave 侧发送心跳的定时器句柄 */
158
+ /** slave 侧单次心跳调度定时器句柄 */
158
159
  #heartbeatTimer = null;
159
160
 
161
+ /** slave 侧等待心跳应答的超时定时器句柄 */
162
+ #heartbeatAckTimer = null;
163
+
164
+ /** slave 侧当前是否存在未确认的心跳 */
165
+ #heartbeatWaitingAck = false;
166
+
167
+ /** slave 侧最近一次确认主节点在线的时间戳 */
168
+ #lastMasterSeenAt = 0;
169
+
170
+ /** slave 侧缓存的主节点在线状态 */
171
+ #masterOnline = false;
172
+
173
+ /** slave 侧正在进行的 Dealer 重连 Promise(防止并发重连) */
174
+ #dealerReconnectPromise = null;
175
+
160
176
  /** master 侧扫描死节点的定时器句柄 */
161
177
  #heartbeatCheckTimer = null;
162
178
 
@@ -466,6 +482,27 @@ export class ZNL extends EventEmitter {
466
482
  return [...this.#slaves.keys()];
467
483
  }
468
484
 
485
+ /**
486
+ * 【Slave 侧】当前已确认的主节点在线状态
487
+ * - 仅在 slave 角色下有意义
488
+ * - 收到合法 heartbeat_ack / request / response / publish 时置为 true
489
+ * - 心跳应答超时、重连、stop 时置为 false
490
+ */
491
+ get masterOnline() {
492
+ return this.role === "slave" ? this.#masterOnline : false;
493
+ }
494
+
495
+ /**
496
+ * 【Slave 侧】查询当前主节点是否在线
497
+ * 说明:
498
+ * - 这是对外公开的轻量 API,适合业务层主动轮询
499
+ * - 返回值基于最近一次链路确认结果,而非一次实时网络探测
500
+ * @returns {boolean}
501
+ */
502
+ isMasterOnline() {
503
+ return this.masterOnline;
504
+ }
505
+
469
506
  /**
470
507
  * 【Master 侧】动态添加/更新某个 slave 的 authKey(立即生效)
471
508
  * @param {string} slaveId
@@ -549,8 +586,13 @@ export class ZNL extends EventEmitter {
549
586
  */
550
587
  async #doStop() {
551
588
  if (this.role === "slave" && this.#sockets.dealer) {
552
- clearInterval(this.#heartbeatTimer);
589
+ clearTimeout(this.#heartbeatTimer);
590
+ clearTimeout(this.#heartbeatAckTimer);
553
591
  this.#heartbeatTimer = null;
592
+ this.#heartbeatAckTimer = null;
593
+ this.#heartbeatWaitingAck = false;
594
+ this.#masterOnline = false;
595
+ this.#lastMasterSeenAt = 0;
554
596
 
555
597
  await this.#sendQueue
556
598
  .enqueue("dealer", () =>
@@ -569,9 +611,11 @@ export class ZNL extends EventEmitter {
569
611
  * 顺序:停止定时器 → 关闭 socket → 等待读循环退出 → 清空所有注册表
570
612
  */
571
613
  async #teardown() {
572
- clearInterval(this.#heartbeatTimer);
614
+ clearTimeout(this.#heartbeatTimer);
615
+ clearTimeout(this.#heartbeatAckTimer);
573
616
  clearInterval(this.#heartbeatCheckTimer);
574
617
  this.#heartbeatTimer = null;
618
+ this.#heartbeatAckTimer = null;
575
619
  this.#heartbeatCheckTimer = null;
576
620
 
577
621
  for (const socket of Object.values(this.#sockets)) {
@@ -587,6 +631,10 @@ export class ZNL extends EventEmitter {
587
631
  this.#slaves.clear();
588
632
  this.#slaveKeyCache.clear();
589
633
  this.#masterNodeId = null;
634
+ this.#masterOnline = false;
635
+ this.#lastMasterSeenAt = 0;
636
+ this.#heartbeatWaitingAck = false;
637
+ this.#dealerReconnectPromise = null;
590
638
  this.#replayGuard.clear();
591
639
  }
592
640
 
@@ -611,23 +659,17 @@ export class ZNL extends EventEmitter {
611
659
  * 连接后立即发送注册帧,再启动心跳定时器
612
660
  */
613
661
  async #startSlaveSockets() {
614
- const dealer = new zmq.Dealer({ routingId: this.id });
615
- dealer.connect(this.endpoints.router);
616
- this.#sockets.dealer = dealer;
617
-
618
- this.#consume(dealer, (frames) => this.#handleDealerFrames(frames));
619
-
620
- // 注册帧:
621
- // - encrypted=true : 发送签名证明令牌(放在 authKey 字段位)
622
- // - encrypted=false : 不携带认证信息
623
- const registerToken = this.#secureEnabled
624
- ? this.#createAuthProof("register", "", [])
625
- : "";
662
+ this.#masterOnline = false;
663
+ this.#lastMasterSeenAt = 0;
664
+ this.#heartbeatWaitingAck = false;
626
665
 
627
- await this.#sendQueue.enqueue("dealer", () =>
628
- dealer.send(buildRegisterFrames(registerToken)),
629
- );
666
+ const dealer = this.#createDealerSocket();
667
+ this.#sockets.dealer = dealer;
630
668
 
669
+ // 注册帧改为“尽力发送”:
670
+ // - master 尚未上线时无需阻塞启动流程
671
+ // - 后续 heartbeat_ack 成功后仍可自然恢复在线状态
672
+ this.#trySendRegister();
631
673
  this.#startHeartbeat();
632
674
  }
633
675
 
@@ -654,6 +696,8 @@ export class ZNL extends EventEmitter {
654
696
 
655
697
  // ── 心跳 ────────────────────────────────────────────────────────────────
656
698
  if (parsed.kind === "heartbeat") {
699
+ let ackFrames = buildHeartbeatAckFrames();
700
+
657
701
  if (this.#secureEnabled) {
658
702
  const keys = this.#resolveSlaveKeys(identityText);
659
703
  if (!keys) {
@@ -682,11 +726,20 @@ export class ZNL extends EventEmitter {
682
726
  signKey: keys.signKey,
683
727
  encryptKey: keys.encryptKey,
684
728
  });
685
- return;
729
+
730
+ ackFrames = buildHeartbeatAckFrames(
731
+ this.#createAuthProof("heartbeat_ack", "", [], keys.signKey),
732
+ );
733
+ } else {
734
+ // 心跳视为在线确认:必要时自动补注册
735
+ this.#ensureSlaveOnline(identityText, identity, { touch: true });
686
736
  }
687
737
 
688
- // 心跳视为在线确认:必要时自动补注册
689
- this.#ensureSlaveOnline(identityText, identity, { touch: true });
738
+ await this.#sendQueue
739
+ .enqueue("router", () =>
740
+ this.#sockets.router.send([identityToBuffer(identity), ...ackFrames]),
741
+ )
742
+ .catch(() => {});
690
743
  return;
691
744
  }
692
745
 
@@ -886,6 +939,28 @@ export class ZNL extends EventEmitter {
886
939
 
887
940
  const event = this.#buildAndEmit("dealer", frames, { payload, ...parsed });
888
941
 
942
+ // ── 心跳应答:master -> slave ──────────────────────────────────────────
943
+ if (parsed.kind === "heartbeat_ack") {
944
+ if (this.#secureEnabled) {
945
+ const v = this.#verifyIncomingProof({
946
+ kind: "heartbeat_ack",
947
+ proofToken: parsed.authProof,
948
+ requestId: "",
949
+ payloadFrames: [],
950
+ expectedNodeId: this.#masterNodeId,
951
+ });
952
+ if (!v.ok) {
953
+ this.#emitAuthFailed(event, v.error);
954
+ return;
955
+ }
956
+
957
+ if (!this.#masterNodeId) this.#masterNodeId = v.envelope.nodeId;
958
+ }
959
+
960
+ this.#confirmMasterReachable({ scheduleNextHeartbeat: true });
961
+ return;
962
+ }
963
+
889
964
  // ── PUB 广播:master -> slave ──────────────────────────────────────────
890
965
  if (parsed.kind === "publish") {
891
966
  let finalFrames = parsed.payloadFrames;
@@ -924,6 +999,8 @@ export class ZNL extends EventEmitter {
924
999
  }
925
1000
  }
926
1001
 
1002
+ this.#confirmMasterReachable({ scheduleNextHeartbeat: true });
1003
+
927
1004
  const finalPayload = payloadFromFrames(finalFrames);
928
1005
  const pubEvent = { topic: parsed.topic, payload: finalPayload };
929
1006
 
@@ -977,6 +1054,8 @@ export class ZNL extends EventEmitter {
977
1054
  }
978
1055
  }
979
1056
 
1057
+ this.#confirmMasterReachable({ scheduleNextHeartbeat: true });
1058
+
980
1059
  const finalPayload = payloadFromFrames(finalFrames);
981
1060
  const requestEvent = { ...event, payload: finalPayload };
982
1061
  this.emit("request", requestEvent);
@@ -1029,6 +1108,8 @@ export class ZNL extends EventEmitter {
1029
1108
  }
1030
1109
  }
1031
1110
 
1111
+ this.#confirmMasterReachable({ scheduleNextHeartbeat: true });
1112
+
1032
1113
  const finalPayload = payloadFromFrames(finalFrames);
1033
1114
  const responseEvent = { ...event, payload: finalPayload };
1034
1115
  const key = this.#pending.key(parsed.requestId);
@@ -1194,27 +1275,235 @@ export class ZNL extends EventEmitter {
1194
1275
  // ═══════════════════════════════════════════════════════════════════════════
1195
1276
 
1196
1277
  /**
1197
- * 启动 slave 侧心跳发送定时器
1278
+ * 创建并初始化 slave 侧 Dealer socket
1279
+ * 设计要点:
1280
+ * - immediate=true:仅向已完成连接的 pipe 发送,避免离线期间无限积压旧消息
1281
+ * - linger=0 :关闭旧 socket 时直接丢弃残留发送队列,避免旧心跳回灌
1282
+ * - sendTimeout=0:尽力发送,当前不可发时立即失败,不阻塞重连/启动流程
1283
+ *
1284
+ * @returns {import("zeromq").Dealer}
1285
+ */
1286
+ #createDealerSocket() {
1287
+ const dealer = new zmq.Dealer({
1288
+ routingId: this.id,
1289
+ immediate: true,
1290
+ linger: 0,
1291
+ sendTimeout: 0,
1292
+ reconnectInterval: 200,
1293
+ reconnectMaxInterval: 1000,
1294
+ });
1295
+
1296
+ dealer.connect(this.endpoints.router);
1297
+ this.#consume(dealer, (frames) => this.#handleDealerFrames(frames));
1298
+ return dealer;
1299
+ }
1300
+
1301
+ /**
1302
+ * 尝试发送注册帧
1303
+ * 说明:
1304
+ * - 该操作是“尽力发送”,不阻塞启动流程
1305
+ * - master 未上线时允许静默失败,由后续 heartbeat_ack 驱动恢复
1306
+ */
1307
+ #trySendRegister() {
1308
+ if (this.role !== "slave" || !this.running || !this.#sockets.dealer) return;
1309
+
1310
+ const registerToken = this.#secureEnabled
1311
+ ? this.#createAuthProof("register", "", [])
1312
+ : "";
1313
+
1314
+ this.#sendQueue
1315
+ .enqueue("dealer", () =>
1316
+ this.#sockets.dealer.send(buildRegisterFrames(registerToken)),
1317
+ )
1318
+ .catch(() => {});
1319
+ }
1320
+
1321
+ /**
1322
+ * 标记主节点已确认在线
1323
+ */
1324
+ #markMasterOnline() {
1325
+ this.#masterOnline = true;
1326
+ this.#lastMasterSeenAt = Date.now();
1327
+ }
1328
+
1329
+ /**
1330
+ * 标记主节点离线,并清理当前等待中的心跳状态
1331
+ */
1332
+ #markMasterOffline() {
1333
+ this.#masterOnline = false;
1334
+ this.#lastMasterSeenAt = 0;
1335
+ this.#heartbeatWaitingAck = false;
1336
+ clearTimeout(this.#heartbeatAckTimer);
1337
+ this.#heartbeatAckTimer = null;
1338
+ }
1339
+
1340
+ /**
1341
+ * 确认链路已打通
1342
+ * - heartbeat_ack 是最直接的确认信号
1343
+ * - 其他来自 master 的合法业务帧同样证明链路可达
1344
+ *
1345
+ * @param {{ scheduleNextHeartbeat?: boolean }} [options]
1346
+ */
1347
+ #confirmMasterReachable({ scheduleNextHeartbeat = false } = {}) {
1348
+ this.#markMasterOnline();
1349
+
1350
+ if (!this.#heartbeatWaitingAck) return;
1351
+
1352
+ this.#heartbeatWaitingAck = false;
1353
+ clearTimeout(this.#heartbeatAckTimer);
1354
+ this.#heartbeatAckTimer = null;
1355
+
1356
+ if (scheduleNextHeartbeat && this.#heartbeatInterval > 0) {
1357
+ this.#scheduleNextHeartbeat();
1358
+ }
1359
+ }
1360
+
1361
+ /**
1362
+ * 计算心跳应答超时时间
1363
+ * - 优先复用用户配置的 heartbeatTimeoutMs
1364
+ * - 未配置时回退到 interval × 2,且至少 1000ms
1365
+ */
1366
+ #resolveHeartbeatAckTimeoutMs() {
1367
+ if (this.#heartbeatTimeoutMs > 0) return this.#heartbeatTimeoutMs;
1368
+ return Math.max(this.#heartbeatInterval * 2, 1000);
1369
+ }
1370
+
1371
+ /**
1372
+ * 调度下一次心跳发送(单飞模式)
1373
+ * - 任意时刻最多只允许一个未确认心跳在飞
1374
+ * - 只有收到 heartbeat_ack 或其他合法回流后,才会进入下一轮
1375
+ *
1376
+ * @param {number} [delayMs=this.#heartbeatInterval]
1377
+ */
1378
+ #scheduleNextHeartbeat(delayMs = this.#heartbeatInterval) {
1379
+ clearTimeout(this.#heartbeatTimer);
1380
+ this.#heartbeatTimer = setTimeout(
1381
+ () => {
1382
+ this.#sendHeartbeatOnce().catch((error) => this.emit("error", error));
1383
+ },
1384
+ Math.max(0, Number(delayMs) || 0),
1385
+ );
1386
+ }
1387
+
1388
+ /**
1389
+ * 启动 slave 侧心跳发送流程
1390
+ * 实现方式:
1391
+ * - 启动时立即发送第一帧心跳
1392
+ * - 后续改为“发一条 → 等应答 → 再调度下一条”
1198
1393
  */
1199
1394
  #startHeartbeat() {
1200
- if (this.#heartbeatInterval <= 0) return;
1395
+ if (this.role !== "slave" || this.#heartbeatInterval <= 0) return;
1201
1396
 
1202
- this.#heartbeatTimer = setInterval(() => {
1203
- if (!this.running || !this.#sockets.dealer) return;
1397
+ this.#heartbeatWaitingAck = false;
1398
+ clearTimeout(this.#heartbeatAckTimer);
1399
+ this.#heartbeatAckTimer = null;
1400
+ this.#scheduleNextHeartbeat(0);
1401
+ }
1204
1402
 
1205
- const proof = this.#secureEnabled
1206
- ? this.#createAuthProof("heartbeat", "", [])
1207
- : "";
1403
+ /**
1404
+ * 发送单次心跳,并进入等待应答状态
1405
+ */
1406
+ async #sendHeartbeatOnce() {
1407
+ if (
1408
+ !this.running ||
1409
+ this.role !== "slave" ||
1410
+ this.#heartbeatInterval <= 0 ||
1411
+ !this.#sockets.dealer ||
1412
+ this.#heartbeatWaitingAck
1413
+ ) {
1414
+ return;
1415
+ }
1208
1416
 
1209
- // plain 模式仍保持历史帧结构 [CONTROL_PREFIX, CONTROL_HEARTBEAT]
1210
- const frames = this.#secureEnabled
1211
- ? buildHeartbeatFrames(proof)
1212
- : [CONTROL_PREFIX, CONTROL_HEARTBEAT];
1417
+ const proof = this.#secureEnabled
1418
+ ? this.#createAuthProof("heartbeat", "", [])
1419
+ : "";
1213
1420
 
1214
- this.#sendQueue
1215
- .enqueue("dealer", () => this.#sockets.dealer.send(frames))
1216
- .catch(() => {});
1217
- }, this.#heartbeatInterval);
1421
+ // plain 模式仍保持历史帧结构 [CONTROL_PREFIX, CONTROL_HEARTBEAT]
1422
+ const frames = this.#secureEnabled
1423
+ ? buildHeartbeatFrames(proof)
1424
+ : [CONTROL_PREFIX, CONTROL_HEARTBEAT];
1425
+
1426
+ this.#heartbeatWaitingAck = true;
1427
+ clearTimeout(this.#heartbeatAckTimer);
1428
+ this.#heartbeatAckTimer = setTimeout(() => {
1429
+ this.#onHeartbeatAckTimeout();
1430
+ }, this.#resolveHeartbeatAckTimeoutMs());
1431
+
1432
+ try {
1433
+ await this.#sendQueue.enqueue("dealer", () =>
1434
+ this.#sockets.dealer.send(frames),
1435
+ );
1436
+ } catch {
1437
+ this.#onHeartbeatAckTimeout();
1438
+ }
1439
+ }
1440
+
1441
+ /**
1442
+ * 心跳应答超时处理:
1443
+ * - 将主节点状态置为离线
1444
+ * - 主动重建 Dealer,丢弃旧连接残留消息
1445
+ */
1446
+ #onHeartbeatAckTimeout() {
1447
+ if (!this.running || this.role !== "slave" || !this.#heartbeatWaitingAck) {
1448
+ return;
1449
+ }
1450
+
1451
+ this.#markMasterOffline();
1452
+ this.#restartDealer("heartbeat_ack_timeout").catch((error) =>
1453
+ this.emit("error", error),
1454
+ );
1455
+ }
1456
+
1457
+ /**
1458
+ * 重建 slave 侧 Dealer 连接
1459
+ * 设计目标:
1460
+ * - 避免 master 重启后旧 socket 中残留的心跳/请求继续回灌
1461
+ * - 将重连控制为单次串行过程,避免并发 close/connect 造成状态抖动
1462
+ *
1463
+ * @param {string} reason
1464
+ * @returns {Promise<void>}
1465
+ */
1466
+ async #restartDealer(reason = "reconnect") {
1467
+ if (this.role !== "slave" || !this.running) return;
1468
+ if (this.#dealerReconnectPromise) return this.#dealerReconnectPromise;
1469
+
1470
+ this.#dealerReconnectPromise = (async () => {
1471
+ clearTimeout(this.#heartbeatTimer);
1472
+ clearTimeout(this.#heartbeatAckTimer);
1473
+ this.#heartbeatTimer = null;
1474
+ this.#heartbeatAckTimer = null;
1475
+ this.#heartbeatWaitingAck = false;
1476
+ this.#masterOnline = false;
1477
+ this.#lastMasterSeenAt = 0;
1478
+
1479
+ this.#pending.rejectAll(
1480
+ new Error(
1481
+ `与主节点的连接已重建(reason=${String(reason)}),待处理请求已取消。`,
1482
+ ),
1483
+ );
1484
+
1485
+ const oldDealer = this.#sockets.dealer;
1486
+ delete this.#sockets.dealer;
1487
+
1488
+ // slave 侧发送队列只服务 dealer,重建时直接清空可避免旧任务继续写入旧 socket
1489
+ this.#sendQueue.clear();
1490
+
1491
+ if (oldDealer) {
1492
+ try {
1493
+ oldDealer.close();
1494
+ } catch {}
1495
+ }
1496
+
1497
+ const dealer = this.#createDealerSocket();
1498
+ this.#sockets.dealer = dealer;
1499
+
1500
+ this.#trySendRegister();
1501
+ this.#startHeartbeat();
1502
+ })().finally(() => {
1503
+ this.#dealerReconnectPromise = null;
1504
+ });
1505
+
1506
+ return this.#dealerReconnectPromise;
1218
1507
  }
1219
1508
 
1220
1509
  /**
@@ -1247,7 +1536,7 @@ export class ZNL extends EventEmitter {
1247
1536
  /**
1248
1537
  * 生成签名证明令牌
1249
1538
  *
1250
- * @param {"register"|"heartbeat"|"request"|"response"|"publish"} kind
1539
+ * @param {"register"|"heartbeat"|"heartbeat_ack"|"request"|"response"|"publish"} kind
1251
1540
  * @param {string} requestId
1252
1541
  * @param {Buffer[]} payloadFrames
1253
1542
  * @returns {string}
@@ -1278,7 +1567,7 @@ export class ZNL extends EventEmitter {
1278
1567
  * 校验入站签名证明 + 防重放 + 摘要一致性
1279
1568
  *
1280
1569
  * @param {{
1281
- * kind: "register"|"heartbeat"|"request"|"response"|"publish",
1570
+ * kind: "register"|"heartbeat"|"heartbeat_ack"|"request"|"response"|"publish",
1282
1571
  * proofToken: string|null,
1283
1572
  * requestId: string|null,
1284
1573
  * payloadFrames: Buffer[],
package/src/constants.js CHANGED
@@ -18,6 +18,9 @@ export const CONTROL_AUTH = "__znl_v1_auth__";
18
18
  /** slave 保活心跳帧标识符(slave → master,定时发送) */
19
19
  export const CONTROL_HEARTBEAT = "heartbeat";
20
20
 
21
+ /** master 心跳应答帧标识符(master → slave,用于确认链路可达) */
22
+ export const CONTROL_HEARTBEAT_ACK = "heartbeat_ack";
23
+
21
24
  /** slave 上线注册帧标识符(slave → master,start 时自动发送) */
22
25
  export const CONTROL_REGISTER = "register";
23
26
 
package/src/protocol.js CHANGED
@@ -4,11 +4,13 @@
4
4
  * 负责所有帧的构建与解析,全部为纯函数,无副作用。
5
5
  *
6
6
  * 控制帧格式:
7
- * 注册帧: [PREFIX, "register", (AUTH_MARKER, authKey)?]
8
- * 注销帧: [PREFIX, "unregister"]
9
- * 请求帧: [PREFIX, "req", requestId, (AUTH_MARKER, authKey)?, ...payload]
10
- * 响应帧: [PREFIX, "res", requestId, ...payload]
11
- * 广播帧: [PREFIX, "pub", topic, ...payload]
7
+ * 注册帧: [PREFIX, "register", (AUTH_MARKER, authKey)?]
8
+ * 注销帧: [PREFIX, "unregister"]
9
+ * 心跳帧: [PREFIX, "heartbeat", (AUTH_MARKER, authProof)?]
10
+ * 心跳应答帧: [PREFIX, "heartbeat_ack", (AUTH_MARKER, authProof)?]
11
+ * 请求帧: [PREFIX, "req", requestId, (AUTH_MARKER, authKey)?, ...payload]
12
+ * 响应帧: [PREFIX, "res", requestId, ...payload]
13
+ * 广播帧: [PREFIX, "pub", topic, ...payload]
12
14
  * Router 侧额外在最前面加一帧:[identity, ...]
13
15
  */
14
16
 
@@ -21,6 +23,7 @@ import {
21
23
  CONTROL_UNREGISTER,
22
24
  CONTROL_PUB,
23
25
  CONTROL_HEARTBEAT,
26
+ CONTROL_HEARTBEAT_ACK,
24
27
  EMPTY_BUFFER,
25
28
  } from "./constants.js";
26
29
 
@@ -180,22 +183,37 @@ export function buildHeartbeatFrames(authProof = "") {
180
183
  return frames;
181
184
  }
182
185
 
186
+ /**
187
+ * 构建心跳应答控制帧数组(master → slave)
188
+ * 帧结构:[PREFIX, "heartbeat_ack", (AUTH_MARKER, authProof)?]
189
+ *
190
+ * @param {string} [authProof] - 可选认证证明
191
+ * @returns {Array}
192
+ */
193
+ export function buildHeartbeatAckFrames(authProof = "") {
194
+ const frames = [CONTROL_PREFIX, CONTROL_HEARTBEAT_ACK];
195
+ if (authProof) frames.push(CONTROL_AUTH, authProof);
196
+ return frames;
197
+ }
198
+
183
199
  // ─── 帧解析 ───────────────────────────────────────────────────────────────────
184
200
 
185
201
  /**
186
202
  * 解析 ZMQ 原始帧,识别控制帧并提取语义字段
187
203
  *
188
204
  * 返回 kind 说明:
189
- * - "register" → slave 上线注册(携带可选 authKey)
190
- * - "unregister" → slave 主动下线注销
191
- * - "publish" master 广播消息(携带 topic)
192
- * - "request" 对端主动发起的 RPC 请求
193
- * - "response" 对端返回的 RPC 响应(匹配 pending 请求)
194
- * - "message" 非控制帧,普通消息透传
205
+ * - "register" → slave 上线注册(携带可选 authKey)
206
+ * - "unregister" → slave 主动下线注销
207
+ * - "heartbeat" slave 发起保活心跳
208
+ * - "heartbeat_ack" master 返回心跳应答
209
+ * - "publish" master 广播消息(携带 topic)
210
+ * - "request" 对端主动发起的 RPC 请求
211
+ * - "response" → 对端返回的 RPC 响应(匹配 pending 请求)
212
+ * - "message" → 非控制帧,普通消息透传
195
213
  *
196
214
  * @param {Array} frames - 不含 identity 帧的帧数组
197
215
  * @returns {{
198
- * kind : "register"|"unregister"|"heartbeat"|"publish"|"request"|"response"|"message",
216
+ * kind : "register"|"unregister"|"heartbeat"|"heartbeat_ack"|"publish"|"request"|"response"|"message",
199
217
  * requestId : string|null,
200
218
  * authKey : string|null,
201
219
  * authProof : string|null,
@@ -263,6 +281,23 @@ export function parseControlFrames(frames) {
263
281
  };
264
282
  }
265
283
 
284
+ // ── 心跳应答帧:[PREFIX, "heartbeat_ack"] ─────────────────────────────────
285
+ if (action === CONTROL_HEARTBEAT_ACK) {
286
+ let authProof = null;
287
+ if (frames.length >= 4 && frames[2]?.toString() === CONTROL_AUTH) {
288
+ authProof = frames[3]?.toString() ?? "";
289
+ }
290
+
291
+ return {
292
+ kind: "heartbeat_ack",
293
+ requestId: null,
294
+ authKey: null,
295
+ authProof,
296
+ topic: null,
297
+ payloadFrames: [],
298
+ };
299
+ }
300
+
266
301
  // ── 广播帧:[PREFIX, "pub", topic, ...payloadFrames] ─────────────────────
267
302
  if (action === CONTROL_PUB && frames.length >= 3) {
268
303
  const topic = frames[2]?.toString() ?? "";