@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 +398 -94
- package/package.json +1 -1
- package/src/ZNL.js +327 -38
- package/src/constants.js +3 -0
- package/src/protocol.js +47 -12
package/README.md
CHANGED
|
@@ -1,22 +1,22 @@
|
|
|
1
1
|
# ZNL
|
|
2
2
|
|
|
3
|
-
基于 ZeroMQ `ROUTER/DEALER` 模式的 Node.js
|
|
3
|
+
基于 ZeroMQ `ROUTER / DEALER` 模式的 Node.js 通信库,提供双向 RPC、广播、在线状态感知,以及可选的签名认证与透明加密能力。
|
|
4
4
|
|
|
5
5
|
## 特性
|
|
6
6
|
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
19
|
-
- `authKeyMap`
|
|
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: {
|
|
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
|
-
//
|
|
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
|
-
//
|
|
56
|
-
master.on("slave_connected",
|
|
57
|
-
|
|
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
|
-
//
|
|
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: {
|
|
81
|
+
endpoints: {
|
|
82
|
+
router: "tcp://127.0.0.1:6003",
|
|
83
|
+
},
|
|
75
84
|
authKey: "your-shared-key",
|
|
76
|
-
encrypted: true,
|
|
85
|
+
encrypted: true,
|
|
77
86
|
});
|
|
78
87
|
|
|
79
|
-
//
|
|
88
|
+
// 订阅指定 topic
|
|
80
89
|
slave.subscribe("news", ({ payload }) => {
|
|
81
90
|
console.log("收到新闻:", payload.toString());
|
|
82
91
|
});
|
|
83
92
|
|
|
84
|
-
//
|
|
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
|
-
|
|
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` | ✓ |
|
|
121
|
-
| `endpoints.router` |
|
|
122
|
-
| `maxPending` |
|
|
123
|
-
| `authKey` |
|
|
124
|
-
| `authKeyMap` |
|
|
125
|
-
| `heartbeatInterval` |
|
|
126
|
-
| `heartbeatTimeoutMs` |
|
|
127
|
-
| `encrypted` |
|
|
128
|
-
| `enablePayloadDigest` |
|
|
129
|
-
| `maxTimeSkewMs` |
|
|
130
|
-
| `replayWindowMs` |
|
|
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
|
-
###
|
|
152
|
+
### 生命周期
|
|
153
|
+
|
|
154
|
+
#### `start()`
|
|
155
|
+
|
|
156
|
+
启动节点。
|
|
135
157
|
|
|
136
|
-
|
|
158
|
+
`master` 侧:
|
|
137
159
|
|
|
138
|
-
-
|
|
139
|
-
-
|
|
160
|
+
- 绑定 ROUTER socket
|
|
161
|
+
- 启动在线节点心跳检测
|
|
140
162
|
|
|
141
|
-
|
|
163
|
+
`slave` 侧:
|
|
142
164
|
|
|
143
|
-
|
|
165
|
+
- 连接 DEALER socket
|
|
166
|
+
- 自动尝试发送注册帧
|
|
167
|
+
- 启动心跳流程
|
|
168
|
+
- 发送 `heartbeat` 后等待 `heartbeat_ack`
|
|
169
|
+
- 收到 `heartbeat_ack` 后调度下一次心跳
|
|
144
170
|
|
|
145
|
-
|
|
171
|
+
#### `stop()`
|
|
146
172
|
|
|
147
|
-
|
|
148
|
-
- `master`:清空在线节点表,关闭 socket,立即 reject 所有 pending RPC 请求
|
|
173
|
+
停止节点。
|
|
149
174
|
|
|
150
|
-
|
|
175
|
+
`slave` 侧:
|
|
151
176
|
|
|
152
|
-
|
|
177
|
+
- 停止心跳
|
|
178
|
+
- 发送注销帧
|
|
179
|
+
- 关闭 socket
|
|
153
180
|
|
|
154
|
-
|
|
155
|
-
- `payloadOrHandler` 为函数时:注册 slave 侧自动回复处理器(Master 主动发来 RPC 请求时触发)
|
|
181
|
+
`master` 侧:
|
|
156
182
|
|
|
157
|
-
|
|
183
|
+
- 清空在线节点表
|
|
184
|
+
- 关闭 socket
|
|
185
|
+
- reject 所有 pending RPC 请求
|
|
158
186
|
|
|
159
|
-
|
|
187
|
+
### 双向 RPC
|
|
160
188
|
|
|
161
|
-
|
|
162
|
-
- `identityOrHandler` 为 identity(slave ID)时:Master 主动向指定 Slave 发送 RPC 请求并等待响应,返回 `Promise<Buffer | Array>`
|
|
189
|
+
#### `DEALER(payloadOrHandler, options?)`
|
|
163
190
|
|
|
164
|
-
|
|
191
|
+
仅 `slave` 侧使用。
|
|
165
192
|
|
|
166
|
-
|
|
193
|
+
- 当 `payloadOrHandler` 为 payload 时,向 `Master` 发起 RPC 请求,返回 `Promise<Buffer | Array>`
|
|
194
|
+
- 当 `payloadOrHandler` 为函数时,注册 `slave` 侧自动回复处理器,用于处理 `Master` 主动发来的请求
|
|
167
195
|
|
|
168
|
-
|
|
196
|
+
#### `ROUTER(identityOrHandler, payload?, options?)`
|
|
169
197
|
|
|
170
|
-
|
|
198
|
+
仅 `master` 侧使用。
|
|
171
199
|
|
|
172
|
-
|
|
200
|
+
- 当 `identityOrHandler` 为函数时,注册 `master` 侧自动回复处理器,用于处理 `Slave` 发来的请求
|
|
201
|
+
- 当 `identityOrHandler` 为某个 `slaveId` 时,`Master` 主动向指定 `Slave` 发起 RPC 请求,返回 `Promise<Buffer | Array>`
|
|
173
202
|
|
|
174
|
-
|
|
203
|
+
#### `options.timeoutMs`
|
|
175
204
|
|
|
176
|
-
|
|
205
|
+
单次 RPC 请求超时时间,默认 `5000` 毫秒。
|
|
177
206
|
|
|
178
|
-
|
|
207
|
+
### 广播与订阅
|
|
179
208
|
|
|
180
|
-
|
|
209
|
+
#### `publish(topic, payload)`
|
|
181
210
|
|
|
182
|
-
|
|
211
|
+
仅 `master` 侧使用。
|
|
183
212
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
-
|
|
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
|
-
|
|
224
|
+
#### `subscribe(topic, handler)`
|
|
225
|
+
|
|
226
|
+
仅 `slave` 侧使用。
|
|
194
227
|
|
|
195
|
-
|
|
228
|
+
订阅指定 topic。
|
|
196
229
|
|
|
197
|
-
- 可在 `start()`
|
|
230
|
+
- 可在 `start()` 前后调用
|
|
231
|
+
- 订阅关系跨 `stop()/start()` 周期保留
|
|
198
232
|
- 同一 topic 重复订阅会覆盖旧 handler
|
|
199
|
-
-
|
|
233
|
+
- 返回 `this`,支持链式调用
|
|
200
234
|
|
|
201
235
|
```js
|
|
202
236
|
slave
|
|
203
|
-
.subscribe("news",
|
|
204
|
-
|
|
237
|
+
.subscribe("news", ({ topic, payload }) => {
|
|
238
|
+
// ...
|
|
239
|
+
})
|
|
240
|
+
.subscribe("metrics", ({ topic, payload }) => {
|
|
241
|
+
// ...
|
|
242
|
+
});
|
|
205
243
|
```
|
|
206
244
|
|
|
207
|
-
|
|
245
|
+
#### `unsubscribe(topic)`
|
|
208
246
|
|
|
209
|
-
|
|
247
|
+
仅 `slave` 侧使用。
|
|
248
|
+
|
|
249
|
+
取消订阅指定 topic。
|
|
210
250
|
|
|
211
251
|
```js
|
|
212
252
|
slave.unsubscribe("news");
|
|
213
253
|
```
|
|
214
254
|
|
|
215
|
-
###
|
|
255
|
+
### 在线状态与节点管理
|
|
256
|
+
|
|
257
|
+
#### `slaves`
|
|
216
258
|
|
|
217
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
288
|
-
2. 确认 README 中的包名与 import 路径
|
|
289
|
-
3. 按需更新 `LICENSE` 中的版权信息
|
|
593
|
+
不是。该值表示最近一次链路确认成功,适合作为业务层在线状态参考,但不是一次即时网络探针。
|
package/package.json
CHANGED
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
615
|
-
|
|
616
|
-
this.#
|
|
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
|
-
|
|
628
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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.#
|
|
1203
|
-
|
|
1397
|
+
this.#heartbeatWaitingAck = false;
|
|
1398
|
+
clearTimeout(this.#heartbeatAckTimer);
|
|
1399
|
+
this.#heartbeatAckTimer = null;
|
|
1400
|
+
this.#scheduleNextHeartbeat(0);
|
|
1401
|
+
}
|
|
1204
1402
|
|
|
1205
|
-
|
|
1206
|
-
|
|
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
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
: [CONTROL_PREFIX, CONTROL_HEARTBEAT];
|
|
1417
|
+
const proof = this.#secureEnabled
|
|
1418
|
+
? this.#createAuthProof("heartbeat", "", [])
|
|
1419
|
+
: "";
|
|
1213
1420
|
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
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
|
-
* 注册帧:
|
|
8
|
-
* 注销帧:
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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"
|
|
190
|
-
* - "unregister"
|
|
191
|
-
* - "
|
|
192
|
-
* - "
|
|
193
|
-
* - "
|
|
194
|
-
* - "
|
|
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() ?? "";
|