@lyrify/znl 0.5.1 → 0.5.6

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
@@ -13,7 +13,10 @@
13
13
  - 加密开关 `encrypted`:
14
14
  - `false`:明文模式(不签名/不加密)
15
15
  - `true`:签名 + 防重放 + payload 透明加密(AES-256-GCM)
16
- - `authKey` 仅在 `encrypted=true` 时必填
16
+ - 可选关闭 payload 摘要校验(`enablePayloadDigest=false`)以提升性能
17
+ - 建议 master/slave 两端保持一致配置,避免认证不一致
18
+ - `authKey` 或 `authKeyMap` 仅在 `encrypted=true` 时必填
19
+ - `authKeyMap` 支持 master 按 `slaveId` 配置不同 key(未命中时会回退到 `authKey`)
17
20
  - Payload 支持 `string`、`Buffer`、`Uint8Array` 及其数组(多帧)
18
21
 
19
22
  ## 安装
@@ -99,9 +102,13 @@ new ZNL({
99
102
  endpoints: {
100
103
  router: "tcp://127.0.0.1:6003",
101
104
  },
102
- maxPending: 0,
105
+ maxPending: 1000,
103
106
  authKey: "",
107
+ authKeyMap: { "slave-001": "k1", "slave-002": "k2" },
108
+ heartbeatInterval: 3000,
109
+ heartbeatTimeoutMs: 0,
104
110
  encrypted: false,
111
+ enablePayloadDigest: true,
105
112
  maxTimeSkewMs: 30000,
106
113
  replayWindowMs: 120000,
107
114
  });
@@ -112,9 +119,13 @@ new ZNL({
112
119
  | `role` | ✓ | 节点角色,`"master"` 或 `"slave"` |
113
120
  | `id` | ✓ | 节点唯一标识;slave 端同时作为 ZMQ `routingId` |
114
121
  | `endpoints.router` | | ROUTER 端点,默认 `tcp://127.0.0.1:6003` |
115
- | `maxPending` | | 最大并发 RPC 请求数,`0` 表示不限制 |
116
- | `authKey` | | 共享认证 Key;仅在 `encrypted=true` 时必填(用于签名/加密) |
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` |
117
127
  | `encrypted` | | 是否启用加密:`false`(默认,明文) / `true`(签名+防重放+透明加密) |
128
+ | `enablePayloadDigest` | | 是否启用 payload 摘要校验,默认 `true`(关闭可提升性能) |
118
129
  | `maxTimeSkewMs` | | 时间戳最大允许偏移(毫秒),默认 `30000`,用于防重放校验 |
119
130
  | `replayWindowMs` | | nonce 重放缓存窗口(毫秒),默认 `120000` |
120
131
 
@@ -150,6 +161,18 @@ new ZNL({
150
161
  - `identityOrHandler` 为函数时:注册 master 侧自动回复处理器(Slave 发来 RPC 请求时触发)
151
162
  - `identityOrHandler` 为 identity(slave ID)时:Master 主动向指定 Slave 发送 RPC 请求并等待响应,返回 `Promise<Buffer | Array>`
152
163
 
164
+ ### `addAuthKey(slaveId, authKey)`
165
+
166
+ **Master 侧调用:**
167
+
168
+ - 动态添加/更新某个 slave 的 authKey(立即生效)
169
+
170
+ ### `removeAuthKey(slaveId)`
171
+
172
+ **Master 侧调用:**
173
+
174
+ - 移除某个 slave 的 authKey(立即生效),并触发 `slave_disconnected`
175
+
153
176
  ### `options.timeoutMs`
154
177
 
155
178
  单次 RPC 请求超时时间,默认 `5000` ms。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lyrify/znl",
3
- "version": "0.5.1",
3
+ "version": "0.5.6",
4
4
  "description": "ZNL - ZeroMQ Node Link",
5
5
  "type": "module",
6
6
  "main": "./index.js",
@@ -35,12 +35,24 @@
35
35
  "dependencies": {
36
36
  "zeromq": "^6.5.0"
37
37
  },
38
+ "devDependencies": {
39
+ "eslint": "^9.39.4",
40
+ "eslint-plugin-import": "^2.32.0",
41
+ "prettier": "^3.8.1"
42
+ },
38
43
  "scripts": {
39
44
  "example:master": "node test/master/index.js",
40
45
  "example:slave": "node test/slave/index.js",
41
46
  "test": "node test/run.js",
47
+ "test:unit": "node --test \"test/unit/**/*.test.js\"",
48
+ "test:all": "pnpm test && pnpm test:unit",
42
49
  "test:echo": "node test/master/test-echo-server.js",
43
50
  "test:100": "node test/slave/test-100-concurrent.js",
44
- "check": "node --check index.js && node --check src/constants.js && node --check src/protocol.js && node --check src/security.js && node --check src/PendingManager.js && node --check src/SendQueue.js && node --check src/ZNL.js && node --check test/master/index.js && node --check test/slave/index.js && node --check test/master/test-echo-server.js && node --check test/slave/test-100-concurrent.js && node --check test/run.js"
51
+ "test:capture": "node test/capture-frames.js",
52
+ "check": "node --check index.js && node --check src/constants.js && node --check src/protocol.js && node --check src/security.js && node --check src/PendingManager.js && node --check src/SendQueue.js && node --check src/ZNL.js && node --check test/master/index.js && node --check test/slave/index.js && node --check test/master/test-echo-server.js && node --check test/slave/test-100-concurrent.js && node --check test/run.js",
53
+ "lint": "eslint .",
54
+ "lint:fix": "eslint . --fix",
55
+ "format": "prettier . --check",
56
+ "format:write": "prettier . --write"
45
57
  }
46
58
  }
package/src/SendQueue.js CHANGED
@@ -6,17 +6,15 @@
6
6
  * 虽然 zeromq.js v6 在 JS 层做了一定保护,但显式串行化更安全可靠。
7
7
  *
8
8
  * 实现原理:
9
- * 为每个 socket 通道维护一条 Promise 链(队列尾指针)。
10
- * 每次入队时,新任务追加在当前链尾,前一个任务完成后自动触发。
11
- * 无论前一个任务成功或失败,队列都会继续执行下一个任务。
9
+ * 为每个 socket 通道维护一个任务队列与独立消费循环。
10
+ * 通过单线程 async loop 串行执行,避免长 Promise 链增长。
12
11
  */
13
-
14
12
  export class SendQueue {
15
13
  /**
16
- * 各通道的队列尾指针
17
- * @type {Map<string, Promise<void>>}
14
+ * 通道级队列与运行状态
15
+ * @type {Map<string, { queue: Array<{ task: Function, resolve: Function, reject: Function }>, running: boolean }>}
18
16
  */
19
- #tails = new Map();
17
+ #channels = new Map();
20
18
 
21
19
  /**
22
20
  * 将发送任务追加到指定通道的队列尾部
@@ -26,22 +24,58 @@ export class SendQueue {
26
24
  * @returns {Promise<void>} 本次任务的 Promise(可用于错误捕获)
27
25
  */
28
26
  enqueue(channel, task) {
29
- const tail = this.#tails.get(channel) ?? Promise.resolve();
30
-
31
- // 无论前一个任务成功或失败,都继续执行当前任务
32
- const run = tail.then(task, task);
27
+ const name = String(channel);
28
+ const entry = this.#ensureChannel(name);
33
29
 
34
- // 更新队尾(吞掉错误,防止产生 UnhandledRejection)
35
- this.#tails.set(channel, run.catch(() => {}));
36
-
37
- return run;
30
+ return new Promise((resolve, reject) => {
31
+ entry.queue.push({ task, resolve, reject });
32
+ if (!entry.running) this.#drain(name, entry);
33
+ });
38
34
  }
39
35
 
40
36
  /**
41
37
  * 清空所有通道的队列引用(节点停止时调用)
42
- * 注意:已入队但未执行的任务不会被取消,仅释放尾指针引用
38
+ * 注意:已入队但未执行的任务不会被取消,仅释放引用
43
39
  */
44
40
  clear() {
45
- this.#tails.clear();
41
+ this.#channels.clear();
42
+ }
43
+
44
+ /**
45
+ * 确保通道数据结构存在
46
+ * @param {string} name
47
+ * @returns {{ queue: Array, running: boolean }}
48
+ */
49
+ #ensureChannel(name) {
50
+ let entry = this.#channels.get(name);
51
+ if (!entry) {
52
+ entry = { queue: [], running: false };
53
+ this.#channels.set(name, entry);
54
+ }
55
+ return entry;
56
+ }
57
+
58
+ /**
59
+ * 串行消费队列
60
+ * @param {string} name
61
+ * @param {{ queue: Array, running: boolean }} entry
62
+ */
63
+ async #drain(name, entry) {
64
+ entry.running = true;
65
+
66
+ while (entry.queue.length > 0) {
67
+ const { task, resolve, reject } = entry.queue.shift();
68
+ try {
69
+ await task();
70
+ resolve();
71
+ } catch (error) {
72
+ reject(error);
73
+ }
74
+ }
75
+
76
+ entry.running = false;
77
+
78
+ // 清理空通道,避免 Map 无限增长
79
+ if (entry.queue.length === 0) this.#channels.delete(name);
46
80
  }
47
81
  }
package/src/ZNL.js CHANGED
@@ -20,7 +20,8 @@
20
20
  * 心跳机制:
21
21
  * - slave 每隔 heartbeatInterval ms 向 master 发送一帧心跳
22
22
  * - master 每隔 heartbeatInterval ms 扫描一次在线列表
23
- * - 超过 heartbeatInterval × 3 ms 未收到心跳,判定 slave 已崩溃并移除
23
+ * - 超过 heartbeatTimeoutMs 未收到心跳,判定 slave 已崩溃并移除
24
+ * (heartbeatTimeoutMs <= 0 时,默认按 heartbeatInterval × 3 计算)
24
25
  */
25
26
 
26
27
  import { randomUUID } from "node:crypto";
@@ -30,6 +31,9 @@ import * as zmq from "zeromq";
30
31
  import {
31
32
  DEFAULT_ENDPOINTS,
32
33
  DEFAULT_HEARTBEAT_INTERVAL,
34
+ DEFAULT_HEARTBEAT_TIMEOUT_MS,
35
+ DEFAULT_MAX_PENDING,
36
+ DEFAULT_ENABLE_PAYLOAD_DIGEST,
33
37
  CONTROL_PREFIX,
34
38
  CONTROL_HEARTBEAT,
35
39
  SECURITY_ENVELOPE_VERSION,
@@ -59,7 +63,6 @@ import {
59
63
  generateNonce,
60
64
  nowMs,
61
65
  canonicalSignInput,
62
- signText,
63
66
  encodeAuthProofToken,
64
67
  decodeAuthProofToken,
65
68
  encryptFrames,
@@ -118,13 +121,25 @@ export class ZNL extends EventEmitter {
118
121
  /** @type {Function|null} slave 侧收到 master 请求时的自动回复处理器 */
119
122
  #dealerAutoHandler = null;
120
123
 
124
+ /** @type {number} 心跳超时时间(ms),0 表示使用 interval × 3 */
125
+ #heartbeatTimeoutMs = DEFAULT_HEARTBEAT_TIMEOUT_MS;
126
+
127
+ /** @type {boolean} 是否启用 payload 摘要校验(安全模式用) */
128
+ #enablePayloadDigest = DEFAULT_ENABLE_PAYLOAD_DIGEST;
129
+
130
+ /** @type {Map<string, string>} master 侧 authKey 配置表(按 slaveId) */
131
+ #authKeyMap = new Map();
132
+
133
+ /** @type {Map<string, { signKey: Buffer, encryptKey: Buffer }>} master 侧派生密钥缓存 */
134
+ #slaveKeyCache = new Map();
135
+
121
136
  // ─── PUB/SUB 状态 ────────────────────────────────────────────────────────
122
137
 
123
138
  /**
124
139
  * master 侧:已注册的在线 slave 表
125
140
  * key = slaveId(字符串)
126
- * value = { identity: Buffer, lastSeen: number }
127
- * @type {Map<string, { identity: Buffer, lastSeen: number }>}
141
+ * value = { identity: Buffer, lastSeen: number, signKey: Buffer|null, encryptKey: Buffer|null }
142
+ * @type {Map<string, { identity: Buffer, lastSeen: number, signKey: Buffer|null, encryptKey: Buffer|null }>}
128
143
  */
129
144
  #slaves = new Map();
130
145
 
@@ -174,22 +189,28 @@ export class ZNL extends EventEmitter {
174
189
  * role : "master"|"slave",
175
190
  * id : string,
176
191
  * endpoints? : { router?: string },
177
- * maxPending? : number,
178
- * authKey? : string,
179
- * heartbeatInterval? : number,
180
- * encrypted? : boolean,
181
- * maxTimeSkewMs? : number,
182
- * replayWindowMs? : number,
192
+ * maxPending? : number,
193
+ * authKey? : string,
194
+ * authKeyMap? : Record<string, string>,
195
+ * heartbeatInterval? : number,
196
+ * heartbeatTimeoutMs? : number,
197
+ * encrypted? : boolean,
198
+ * enablePayloadDigest? : boolean,
199
+ * maxTimeSkewMs? : number,
200
+ * replayWindowMs? : number,
183
201
  * }} options
184
202
  */
185
203
  constructor({
186
204
  role,
187
205
  id,
188
206
  endpoints = {},
189
- maxPending = 0,
207
+ maxPending = DEFAULT_MAX_PENDING,
190
208
  authKey = "",
209
+ authKeyMap = null,
191
210
  heartbeatInterval = DEFAULT_HEARTBEAT_INTERVAL,
211
+ heartbeatTimeoutMs = DEFAULT_HEARTBEAT_TIMEOUT_MS,
192
212
  encrypted = false,
213
+ enablePayloadDigest = DEFAULT_ENABLE_PAYLOAD_DIGEST,
193
214
  maxTimeSkewMs = MAX_TIME_SKEW_MS,
194
215
  replayWindowMs = REPLAY_WINDOW_MS,
195
216
  } = {}) {
@@ -207,16 +228,35 @@ export class ZNL extends EventEmitter {
207
228
  this.endpoints = { ...DEFAULT_ENDPOINTS, ...endpoints };
208
229
  this.authKey = authKey == null ? "" : String(authKey);
209
230
 
231
+ if (this.role === "master") {
232
+ if (authKeyMap != null) {
233
+ if (typeof authKeyMap !== "object") {
234
+ throw new TypeError(
235
+ "`authKeyMap` 必须是对象(slaveId -> authKey)。",
236
+ );
237
+ }
238
+ for (const [slaveId, key] of Object.entries(authKeyMap)) {
239
+ if (key == null) continue;
240
+ this.#authKeyMap.set(String(slaveId), String(key));
241
+ }
242
+ }
243
+ }
244
+
210
245
  this.encrypted = Boolean(encrypted);
211
246
  this.#secureEnabled = this.encrypted;
212
247
 
213
248
  if (this.#secureEnabled) {
214
- if (!this.authKey) {
215
- throw new Error("启用 encrypted=true 时,必须提供非空 authKey。");
249
+ const hasAuthMap = this.role === "master" && authKeyMap != null;
250
+ if (!this.authKey && !hasAuthMap) {
251
+ throw new Error(
252
+ "启用 encrypted=true 时,必须提供非空 authKey 或 authKeyMap。",
253
+ );
254
+ }
255
+ if (this.authKey) {
256
+ const { signKey, encryptKey } = deriveKeys(this.authKey);
257
+ this.#signKey = signKey;
258
+ this.#encryptKey = encryptKey;
216
259
  }
217
- const { signKey, encryptKey } = deriveKeys(this.authKey);
218
- this.#signKey = signKey;
219
- this.#encryptKey = encryptKey;
220
260
  }
221
261
 
222
262
  this.#pending = new PendingManager(maxPending);
@@ -224,6 +264,15 @@ export class ZNL extends EventEmitter {
224
264
  this.#heartbeatInterval =
225
265
  this.#normalizeHeartbeatInterval(heartbeatInterval);
226
266
 
267
+ // 是否启用 payload 摘要校验(安全模式用)
268
+ this.#enablePayloadDigest = Boolean(enablePayloadDigest);
269
+
270
+ // 心跳超时:<=0 则由 heartbeatInterval 推导
271
+ this.#heartbeatTimeoutMs = this.#normalizePositiveInt(
272
+ heartbeatTimeoutMs,
273
+ DEFAULT_HEARTBEAT_TIMEOUT_MS,
274
+ );
275
+
227
276
  this.#maxTimeSkewMs = this.#normalizePositiveInt(
228
277
  maxTimeSkewMs,
229
278
  MAX_TIME_SKEW_MS,
@@ -334,19 +383,34 @@ export class ZNL extends EventEmitter {
334
383
  if (this.#slaves.size === 0) return;
335
384
 
336
385
  const topicText = String(topic);
337
- const payloadFrames = this.#sealPayloadFrames(
338
- "publish",
339
- topicText,
340
- payload,
341
- );
342
- const authProof = this.#secureEnabled
343
- ? this.#createAuthProof("publish", topicText, payloadFrames)
344
- : "";
345
-
346
- const frames = buildPublishFrames(topicText, payloadFrames, authProof);
347
386
 
348
387
  for (const [slaveId, entry] of this.#slaves) {
388
+ const keys = this.#secureEnabled ? this.#resolveSlaveKeys(slaveId) : null;
389
+ if (this.#secureEnabled && !keys) {
390
+ if (this.#slaves.delete(slaveId)) {
391
+ this.emit("slave_disconnected", slaveId);
392
+ }
393
+ continue;
394
+ }
395
+
396
+ const payloadFrames = this.#sealPayloadFrames(
397
+ "publish",
398
+ topicText,
399
+ payload,
400
+ keys?.encryptKey ?? null,
401
+ );
402
+ const authProof = this.#secureEnabled
403
+ ? this.#createAuthProof(
404
+ "publish",
405
+ topicText,
406
+ payloadFrames,
407
+ keys.signKey,
408
+ )
409
+ : "";
410
+
411
+ const frames = buildPublishFrames(topicText, payloadFrames, authProof);
349
412
  const idFrame = identityToBuffer(entry.identity);
413
+
350
414
  this.#sendQueue
351
415
  .enqueue("router", () => socket.send([idFrame, ...frames]))
352
416
  .catch(() => {
@@ -402,6 +466,63 @@ export class ZNL extends EventEmitter {
402
466
  return [...this.#slaves.keys()];
403
467
  }
404
468
 
469
+ /**
470
+ * 【Master 侧】动态添加/更新某个 slave 的 authKey(立即生效)
471
+ * @param {string} slaveId
472
+ * @param {string} authKey
473
+ * @returns {this}
474
+ */
475
+ addAuthKey(slaveId, authKey) {
476
+ if (this.role !== "master") {
477
+ throw new Error("addAuthKey() 只能在 master 侧调用。");
478
+ }
479
+ const id = String(slaveId ?? "");
480
+ if (!id) {
481
+ throw new Error("slaveId 不能为空。");
482
+ }
483
+ const key = authKey == null ? "" : String(authKey);
484
+ if (!key) {
485
+ throw new Error("authKey 不能为空。");
486
+ }
487
+
488
+ this.#authKeyMap.set(id, key);
489
+
490
+ if (this.#secureEnabled) {
491
+ const derived = deriveKeys(key);
492
+ this.#slaveKeyCache.set(id, derived);
493
+ const entry = this.#slaves.get(id);
494
+ if (entry) {
495
+ entry.signKey = derived.signKey;
496
+ entry.encryptKey = derived.encryptKey;
497
+ }
498
+ }
499
+ return this;
500
+ }
501
+
502
+ /**
503
+ * 【Master 侧】移除某个 slave 的 authKey(立即生效)
504
+ * @param {string} slaveId
505
+ * @returns {this}
506
+ */
507
+ removeAuthKey(slaveId) {
508
+ if (this.role !== "master") {
509
+ throw new Error("removeAuthKey() 只能在 master 侧调用。");
510
+ }
511
+ const id = String(slaveId ?? "");
512
+ if (!id) {
513
+ throw new Error("slaveId 不能为空。");
514
+ }
515
+
516
+ this.#authKeyMap.delete(id);
517
+ this.#slaveKeyCache.delete(id);
518
+
519
+ if (this.#slaves.has(id)) {
520
+ this.#slaves.delete(id);
521
+ this.emit("slave_disconnected", id);
522
+ }
523
+ return this;
524
+ }
525
+
405
526
  // ═══════════════════════════════════════════════════════════════════════════
406
527
  // 生命周期内部实现
407
528
  // ═══════════════════════════════════════════════════════════════════════════
@@ -464,6 +585,7 @@ export class ZNL extends EventEmitter {
464
585
  this.#sockets = {};
465
586
  this.#sendQueue.clear();
466
587
  this.#slaves.clear();
588
+ this.#slaveKeyCache.clear();
467
589
  this.#masterNodeId = null;
468
590
  this.#replayGuard.clear();
469
591
  }
@@ -476,7 +598,7 @@ export class ZNL extends EventEmitter {
476
598
  * 初始化 Master 侧 ROUTER socket(bind 模式),并启动读循环和死节点扫描定时器
477
599
  */
478
600
  async #startMasterSockets() {
479
- const router = new zmq.Router();
601
+ const router = new zmq.Router({ handover: true });
480
602
  await router.bind(this.endpoints.router);
481
603
  this.#sockets.router = router;
482
604
 
@@ -533,41 +655,80 @@ export class ZNL extends EventEmitter {
533
655
  // ── 心跳 ────────────────────────────────────────────────────────────────
534
656
  if (parsed.kind === "heartbeat") {
535
657
  if (this.#secureEnabled) {
658
+ const keys = this.#resolveSlaveKeys(identityText);
659
+ if (!keys) {
660
+ this.#emitAuthFailed(event, "未配置该 slave 的 authKey。");
661
+ if (this.#slaves.delete(identityText)) {
662
+ this.emit("slave_disconnected", identityText);
663
+ }
664
+ return;
665
+ }
666
+
536
667
  const v = this.#verifyIncomingProof({
537
668
  kind: "heartbeat",
538
669
  proofToken: parsed.authProof,
539
670
  requestId: "",
540
671
  payloadFrames: [],
541
672
  expectedNodeId: identityText,
673
+ signKey: keys.signKey,
542
674
  });
543
675
  if (!v.ok) {
544
676
  this.#emitAuthFailed(event, v.error);
545
677
  return;
546
678
  }
679
+
680
+ this.#ensureSlaveOnline(identityText, identity, {
681
+ touch: true,
682
+ signKey: keys.signKey,
683
+ encryptKey: keys.encryptKey,
684
+ });
685
+ return;
547
686
  }
548
687
 
549
- const entry = this.#slaves.get(identityText);
550
- if (entry) entry.lastSeen = Date.now();
688
+ // 心跳视为在线确认:必要时自动补注册
689
+ this.#ensureSlaveOnline(identityText, identity, { touch: true });
551
690
  return;
552
691
  }
553
692
 
554
693
  // ── 注册 ────────────────────────────────────────────────────────────────
555
694
  if (parsed.kind === "register") {
556
695
  if (this.#secureEnabled) {
696
+ const keys = this.#resolveSlaveKeys(identityText);
697
+ if (!keys) {
698
+ this.#emitAuthFailed(event, "未配置该 slave 的 authKey。");
699
+ if (this.#slaves.delete(identityText)) {
700
+ this.emit("slave_disconnected", identityText);
701
+ }
702
+ return;
703
+ }
704
+
557
705
  const v = this.#verifyIncomingProof({
558
706
  kind: "register",
559
707
  proofToken: parsed.authKey, // register 复用 authKey 字段承载 proof
560
708
  requestId: "",
561
709
  payloadFrames: [],
562
710
  expectedNodeId: identityText,
711
+ signKey: keys.signKey,
563
712
  });
564
713
  if (!v.ok) {
565
714
  this.#emitAuthFailed(event, v.error);
566
715
  return;
567
716
  }
717
+
718
+ this.#ensureSlaveOnline(identityText, identity, {
719
+ touch: true,
720
+ signKey: keys.signKey,
721
+ encryptKey: keys.encryptKey,
722
+ });
723
+ return;
568
724
  }
569
725
 
570
- this.#slaves.set(identityText, { identity, lastSeen: Date.now() });
726
+ this.#slaves.set(identityText, {
727
+ identity,
728
+ lastSeen: Date.now(),
729
+ signKey: null,
730
+ encryptKey: null,
731
+ });
571
732
  this.emit("slave_connected", identityText);
572
733
  return;
573
734
  }
@@ -585,18 +746,35 @@ export class ZNL extends EventEmitter {
585
746
  let finalFrames = parsed.payloadFrames;
586
747
 
587
748
  if (this.#secureEnabled) {
749
+ const keys = this.#resolveSlaveKeys(identityText);
750
+ if (!keys) {
751
+ this.#emitAuthFailed(event, "未配置该 slave 的 authKey。");
752
+ if (this.#slaves.delete(identityText)) {
753
+ this.emit("slave_disconnected", identityText);
754
+ }
755
+ return;
756
+ }
757
+
588
758
  const v = this.#verifyIncomingProof({
589
759
  kind: "request",
590
760
  proofToken: parsed.authKey, // request 复用 authKey 字段承载 proof
591
761
  requestId: parsed.requestId,
592
762
  payloadFrames: parsed.payloadFrames,
593
763
  expectedNodeId: identityText,
764
+ signKey: keys.signKey,
594
765
  });
595
766
  if (!v.ok) {
596
767
  this.#emitAuthFailed(event, v.error);
597
768
  return;
598
769
  }
599
770
 
771
+ // 认证通过后允许补注册(避免 master 重启后丢失在线表)
772
+ this.#ensureSlaveOnline(identityText, identity, {
773
+ touch: true,
774
+ signKey: keys.signKey,
775
+ encryptKey: keys.encryptKey,
776
+ });
777
+
600
778
  if (this.encrypted) {
601
779
  try {
602
780
  finalFrames = this.#openPayloadFrames(
@@ -604,6 +782,7 @@ export class ZNL extends EventEmitter {
604
782
  parsed.requestId,
605
783
  parsed.payloadFrames,
606
784
  identityText,
785
+ keys.encryptKey,
607
786
  );
608
787
  } catch (error) {
609
788
  this.#emitAuthFailed(
@@ -613,6 +792,9 @@ export class ZNL extends EventEmitter {
613
792
  return;
614
793
  }
615
794
  }
795
+ } else {
796
+ // 明文模式同样补注册
797
+ this.#ensureSlaveOnline(identityText, identity, { touch: true });
616
798
  }
617
799
 
618
800
  const finalPayload = payloadFromFrames(finalFrames);
@@ -635,18 +817,35 @@ export class ZNL extends EventEmitter {
635
817
  let finalFrames = parsed.payloadFrames;
636
818
 
637
819
  if (this.#secureEnabled) {
820
+ const keys = this.#resolveSlaveKeys(identityText);
821
+ if (!keys) {
822
+ this.#emitAuthFailed(event, "未配置该 slave 的 authKey。");
823
+ if (this.#slaves.delete(identityText)) {
824
+ this.emit("slave_disconnected", identityText);
825
+ }
826
+ return;
827
+ }
828
+
638
829
  const v = this.#verifyIncomingProof({
639
830
  kind: "response",
640
831
  proofToken: parsed.authProof,
641
832
  requestId: parsed.requestId,
642
833
  payloadFrames: parsed.payloadFrames,
643
834
  expectedNodeId: identityText,
835
+ signKey: keys.signKey,
644
836
  });
645
837
  if (!v.ok) {
646
838
  this.#emitAuthFailed(event, v.error);
647
839
  return;
648
840
  }
649
841
 
842
+ // 认证通过后允许补注册(避免 master 重启后丢失在线表)
843
+ this.#ensureSlaveOnline(identityText, identity, {
844
+ touch: true,
845
+ signKey: keys.signKey,
846
+ encryptKey: keys.encryptKey,
847
+ });
848
+
650
849
  if (this.encrypted) {
651
850
  try {
652
851
  finalFrames = this.#openPayloadFrames(
@@ -654,6 +853,7 @@ export class ZNL extends EventEmitter {
654
853
  parsed.requestId,
655
854
  parsed.payloadFrames,
656
855
  identityText,
856
+ keys.encryptKey,
657
857
  );
658
858
  } catch (error) {
659
859
  this.#emitAuthFailed(
@@ -663,6 +863,9 @@ export class ZNL extends EventEmitter {
663
863
  return;
664
864
  }
665
865
  }
866
+ } else {
867
+ // 明文模式同样补注册
868
+ this.#ensureSlaveOnline(identityText, identity, { touch: true });
666
869
  }
667
870
 
668
871
  const finalPayload = payloadFromFrames(finalFrames);
@@ -893,13 +1096,21 @@ export class ZNL extends EventEmitter {
893
1096
  identityText,
894
1097
  );
895
1098
 
1099
+ const keys = this.#secureEnabled
1100
+ ? this.#resolveSlaveKeys(identityText)
1101
+ : null;
1102
+ if (this.#secureEnabled && !keys) {
1103
+ throw new Error(`未配置该 slave 的 authKey:${identityText}`);
1104
+ }
1105
+
896
1106
  const payloadFrames = this.#sealPayloadFrames(
897
1107
  "request",
898
1108
  requestId,
899
1109
  payload,
1110
+ keys?.encryptKey ?? null,
900
1111
  );
901
1112
  const proofOrAuthKey = this.#secureEnabled
902
- ? this.#createAuthProof("request", requestId, payloadFrames)
1113
+ ? this.#createAuthProof("request", requestId, payloadFrames, keys.signKey)
903
1114
  : "";
904
1115
 
905
1116
  const frames = buildRequestFrames(requestId, payloadFrames, proofOrAuthKey);
@@ -947,15 +1158,29 @@ export class ZNL extends EventEmitter {
947
1158
  */
948
1159
  async #replyTo(identity, requestId, payload) {
949
1160
  const socket = this.#requireSocket("router", "ROUTER");
1161
+ const identityText = identityToString(identity);
950
1162
  const idFrame = identityToBuffer(identity);
951
1163
 
1164
+ const keys = this.#secureEnabled
1165
+ ? this.#resolveSlaveKeys(identityText)
1166
+ : null;
1167
+ if (this.#secureEnabled && !keys) {
1168
+ throw new Error(`未配置该 slave 的 authKey:${identityText}`);
1169
+ }
1170
+
952
1171
  const payloadFrames = this.#sealPayloadFrames(
953
1172
  "response",
954
1173
  requestId,
955
1174
  payload,
1175
+ keys?.encryptKey ?? null,
956
1176
  );
957
1177
  const authProof = this.#secureEnabled
958
- ? this.#createAuthProof("response", requestId, payloadFrames)
1178
+ ? this.#createAuthProof(
1179
+ "response",
1180
+ requestId,
1181
+ payloadFrames,
1182
+ keys.signKey,
1183
+ )
959
1184
  : "";
960
1185
 
961
1186
  const frames = buildResponseFrames(requestId, payloadFrames, authProof);
@@ -998,7 +1223,11 @@ export class ZNL extends EventEmitter {
998
1223
  #startHeartbeatCheck() {
999
1224
  if (this.#heartbeatInterval <= 0) return;
1000
1225
 
1001
- const timeout = this.#heartbeatInterval * 3;
1226
+ // 心跳超时:优先使用配置值,<=0 则回退到 interval × 3
1227
+ const timeout =
1228
+ this.#heartbeatTimeoutMs > 0
1229
+ ? this.#heartbeatTimeoutMs
1230
+ : this.#heartbeatInterval * 3;
1002
1231
 
1003
1232
  this.#heartbeatCheckTimer = setInterval(() => {
1004
1233
  const now = Date.now();
@@ -1023,8 +1252,9 @@ export class ZNL extends EventEmitter {
1023
1252
  * @param {Buffer[]} payloadFrames
1024
1253
  * @returns {string}
1025
1254
  */
1026
- #createAuthProof(kind, requestId, payloadFrames) {
1027
- if (!this.#secureEnabled || !this.#signKey) return "";
1255
+ #createAuthProof(kind, requestId, payloadFrames, signKeyOverride = null) {
1256
+ const signKey = signKeyOverride ?? this.#signKey;
1257
+ if (!this.#secureEnabled || !signKey) return "";
1028
1258
 
1029
1259
  const envelope = {
1030
1260
  kind: String(kind),
@@ -1032,13 +1262,16 @@ export class ZNL extends EventEmitter {
1032
1262
  requestId: String(requestId ?? ""),
1033
1263
  timestamp: nowMs(),
1034
1264
  nonce: generateNonce(),
1035
- payloadDigest: digestFrames(payloadFrames),
1265
+ // 可选 payload 摘要:关闭时写空串以提升性能
1266
+ payloadDigest: this.#enablePayloadDigest
1267
+ ? digestFrames(payloadFrames)
1268
+ : "",
1036
1269
  };
1037
1270
 
1038
1271
  // 保留 canonical 文本,便于后续排障(签名实际在 token 内完成)
1039
1272
  canonicalSignInput(envelope);
1040
1273
 
1041
- return encodeAuthProofToken(this.#signKey, envelope);
1274
+ return encodeAuthProofToken(signKey, envelope);
1042
1275
  }
1043
1276
 
1044
1277
  /**
@@ -1059,9 +1292,12 @@ export class ZNL extends EventEmitter {
1059
1292
  requestId,
1060
1293
  payloadFrames,
1061
1294
  expectedNodeId = null,
1295
+ signKey = null,
1062
1296
  }) {
1063
1297
  if (!this.#secureEnabled) return { ok: true, envelope: null };
1064
- if (!this.#signKey) {
1298
+
1299
+ const verifyKey = signKey ?? this.#signKey;
1300
+ if (!verifyKey) {
1065
1301
  return { ok: false, error: "签名密钥未初始化。" };
1066
1302
  }
1067
1303
 
@@ -1069,7 +1305,7 @@ export class ZNL extends EventEmitter {
1069
1305
  return { ok: false, error: "缺少认证证明(proofToken)。" };
1070
1306
  }
1071
1307
 
1072
- const decoded = decodeAuthProofToken(this.#signKey, proofToken, {
1308
+ const decoded = decodeAuthProofToken(verifyKey, proofToken, {
1073
1309
  maxSkewMs: this.#maxTimeSkewMs,
1074
1310
  now: Date.now(),
1075
1311
  });
@@ -1100,12 +1336,15 @@ export class ZNL extends EventEmitter {
1100
1336
  };
1101
1337
  }
1102
1338
 
1103
- const currentDigest = digestFrames(payloadFrames);
1104
- if (String(envelope.payloadDigest) !== currentDigest) {
1105
- return { ok: false, error: "payload 摘要不一致,疑似篡改。" };
1339
+ if (this.#enablePayloadDigest) {
1340
+ const currentDigest = digestFrames(payloadFrames);
1341
+ if (String(envelope.payloadDigest) !== currentDigest) {
1342
+ return { ok: false, error: "payload 摘要不一致,疑似篡改。" };
1343
+ }
1106
1344
  }
1107
1345
 
1108
- const replayKey = `${envelope.kind}|${envelope.nodeId}|${envelope.nonce}`;
1346
+ // 重放 key 加入 requestId,降低 nonce 碰撞误杀风险
1347
+ const replayKey = `${envelope.kind}|${envelope.nodeId}|${envelope.requestId}|${envelope.nonce}`;
1109
1348
  if (this.#replayGuard.seenOrAdd(replayKey)) {
1110
1349
  return { ok: false, error: "检测到重放请求(nonce 重复)。" };
1111
1350
  }
@@ -1123,13 +1362,14 @@ export class ZNL extends EventEmitter {
1123
1362
  * @param {*} payload
1124
1363
  * @returns {Buffer[]}
1125
1364
  */
1126
- #sealPayloadFrames(kind, requestId, payload) {
1365
+ #sealPayloadFrames(kind, requestId, payload, encryptKeyOverride = null) {
1127
1366
  if (!this.encrypted) {
1128
1367
  // 非加密模式保持历史行为:沿用协议层的原始帧类型(string/Buffer)
1129
1368
  return normalizeFrames(payload);
1130
1369
  }
1131
1370
 
1132
- if (!this.#encryptKey) {
1371
+ const encryptKey = encryptKeyOverride ?? this.#encryptKey;
1372
+ if (!encryptKey) {
1133
1373
  throw new Error("加密密钥未初始化。");
1134
1374
  }
1135
1375
 
@@ -1139,11 +1379,7 @@ export class ZNL extends EventEmitter {
1139
1379
  "utf8",
1140
1380
  );
1141
1381
 
1142
- const { iv, ciphertext, tag } = encryptFrames(
1143
- this.#encryptKey,
1144
- rawFrames,
1145
- aad,
1146
- );
1382
+ const { iv, ciphertext, tag } = encryptFrames(encryptKey, rawFrames, aad);
1147
1383
 
1148
1384
  // 用统一信封包装:version + iv + tag + ciphertext
1149
1385
  return [Buffer.from(SECURITY_ENVELOPE_VERSION), iv, tag, ciphertext];
@@ -1160,9 +1396,16 @@ export class ZNL extends EventEmitter {
1160
1396
  * @param {string} senderNodeId
1161
1397
  * @returns {Buffer[]}
1162
1398
  */
1163
- #openPayloadFrames(kind, requestId, payloadFrames, senderNodeId) {
1399
+ #openPayloadFrames(
1400
+ kind,
1401
+ requestId,
1402
+ payloadFrames,
1403
+ senderNodeId,
1404
+ encryptKeyOverride = null,
1405
+ ) {
1164
1406
  if (!this.encrypted) return payloadFrames;
1165
- if (!this.#encryptKey) throw new Error("加密密钥未初始化。");
1407
+ const encryptKey = encryptKeyOverride ?? this.#encryptKey;
1408
+ if (!encryptKey) throw new Error("加密密钥未初始化。");
1166
1409
 
1167
1410
  if (!Array.isArray(payloadFrames) || payloadFrames.length !== 4) {
1168
1411
  throw new Error("加密信封格式非法:期望 4 帧。");
@@ -1178,7 +1421,7 @@ export class ZNL extends EventEmitter {
1178
1421
  "utf8",
1179
1422
  );
1180
1423
 
1181
- return decryptFrames(this.#encryptKey, iv, ciphertext, tag, aad);
1424
+ return decryptFrames(encryptKey, iv, ciphertext, tag, aad);
1182
1425
  }
1183
1426
 
1184
1427
  /**
@@ -1194,6 +1437,76 @@ export class ZNL extends EventEmitter {
1194
1437
  });
1195
1438
  }
1196
1439
 
1440
+ /**
1441
+ * master 侧确保某个 slave 已登记在线
1442
+ * - 仅在认证通过后调用
1443
+ * - 支持心跳/请求/响应等路径的自动补注册
1444
+ *
1445
+ * @param {string} identityText
1446
+ * @param {Buffer|string|Uint8Array} identity
1447
+ * @param {{ touch?: boolean }} [options]
1448
+ */
1449
+ #ensureSlaveOnline(
1450
+ identityText,
1451
+ identity,
1452
+ { touch = true, signKey = null, encryptKey = null } = {},
1453
+ ) {
1454
+ const id = String(identityText ?? "");
1455
+ if (!id) return;
1456
+
1457
+ const now = Date.now();
1458
+ const entry = this.#slaves.get(id);
1459
+ if (entry) {
1460
+ if (touch) entry.lastSeen = now;
1461
+ if (signKey) entry.signKey = signKey;
1462
+ if (encryptKey) entry.encryptKey = encryptKey;
1463
+ return;
1464
+ }
1465
+
1466
+ this.#slaves.set(id, {
1467
+ identity: identityToBuffer(identity),
1468
+ lastSeen: now,
1469
+ signKey: signKey ?? null,
1470
+ encryptKey: encryptKey ?? null,
1471
+ });
1472
+ this.emit("slave_connected", id);
1473
+ }
1474
+
1475
+ /**
1476
+ * master 侧解析某个 slave 的签名/加密密钥
1477
+ * - 优先匹配 authKeyMap
1478
+ * - 未命中时回退到 this.authKey(若提供)
1479
+ *
1480
+ * @param {string} slaveId
1481
+ * @returns {{ signKey: Buffer, encryptKey: Buffer }|null}
1482
+ */
1483
+ #resolveSlaveKeys(slaveId) {
1484
+ if (!this.#secureEnabled) return null;
1485
+
1486
+ const id = String(slaveId ?? "");
1487
+ if (!id) return null;
1488
+
1489
+ const cached = this.#slaveKeyCache.get(id);
1490
+ if (cached) return cached;
1491
+
1492
+ let key = this.#authKeyMap.get(id);
1493
+ if (!key && this.authKey) {
1494
+ key = this.authKey;
1495
+ }
1496
+ if (!key) return null;
1497
+
1498
+ const derived = deriveKeys(key);
1499
+ this.#slaveKeyCache.set(id, derived);
1500
+
1501
+ const entry = this.#slaves.get(id);
1502
+ if (entry) {
1503
+ entry.signKey = derived.signKey;
1504
+ entry.encryptKey = derived.encryptKey;
1505
+ }
1506
+
1507
+ return derived;
1508
+ }
1509
+
1197
1510
  // ═══════════════════════════════════════════════════════════════════════════
1198
1511
  // 工具方法
1199
1512
  // ═══════════════════════════════════════════════════════════════════════════
package/src/constants.js CHANGED
@@ -36,6 +36,15 @@ export const DEFAULT_TIMEOUT_MS = 5000;
36
36
  /** 默认心跳间隔(毫秒),0 表示禁用心跳 */
37
37
  export const DEFAULT_HEARTBEAT_INTERVAL = 3000;
38
38
 
39
+ /** 默认心跳超时时间(毫秒),0 表示采用 heartbeatInterval × 3 */
40
+ export const DEFAULT_HEARTBEAT_TIMEOUT_MS = 0;
41
+
42
+ /** 默认最大并发请求数(0 表示不限制) */
43
+ export const DEFAULT_MAX_PENDING = 1000;
44
+
45
+ /** 默认是否启用 payload 摘要(安全模式用) */
46
+ export const DEFAULT_ENABLE_PAYLOAD_DIGEST = true;
47
+
39
48
  /** 默认端点配置 */
40
49
  export const DEFAULT_ENDPOINTS = {
41
50
  router: "tcp://127.0.0.1:6003",
package/src/security.js CHANGED
@@ -385,11 +385,29 @@ export function decodeAuthProofToken(
385
385
 
386
386
  /**
387
387
  * 计算 payload 帧摘要(sha256 hex)
388
+ * 说明:按协议顺序增量写入,避免一次性拼接大 Buffer
388
389
  * @param {Buffer[]} frames
389
390
  * @returns {string}
390
391
  */
391
392
  export function digestFrames(frames) {
392
- return createHash("sha256").update(encodeFrames(frames)).digest("hex");
393
+ const safeFrames = Array.isArray(frames) ? frames : [];
394
+ const hash = createHash("sha256");
395
+
396
+ // 写入帧数量(u32be)
397
+ const head = Buffer.allocUnsafe(4);
398
+ head.writeUInt32BE(safeFrames.length, 0);
399
+ hash.update(head);
400
+
401
+ // 逐帧写入长度与内容(u32be + bytes)
402
+ for (const frame of safeFrames) {
403
+ const buf = toBuffer(frame);
404
+ const len = Buffer.allocUnsafe(4);
405
+ len.writeUInt32BE(buf.length, 0);
406
+ hash.update(len);
407
+ if (buf.length > 0) hash.update(buf);
408
+ }
409
+
410
+ return hash.digest("hex");
393
411
  }
394
412
 
395
413
  /**