@lyrify/znl 0.5.1 → 0.5.2

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,6 +13,8 @@
13
13
  - 加密开关 `encrypted`:
14
14
  - `false`:明文模式(不签名/不加密)
15
15
  - `true`:签名 + 防重放 + payload 透明加密(AES-256-GCM)
16
+ - 可选关闭 payload 摘要校验(`enablePayloadDigest=false`)以提升性能
17
+ - 建议 master/slave 两端保持一致配置,避免认证不一致
16
18
  - `authKey` 仅在 `encrypted=true` 时必填
17
19
  - Payload 支持 `string`、`Buffer`、`Uint8Array` 及其数组(多帧)
18
20
 
@@ -99,9 +101,12 @@ new ZNL({
99
101
  endpoints: {
100
102
  router: "tcp://127.0.0.1:6003",
101
103
  },
102
- maxPending: 0,
104
+ maxPending: 1000,
103
105
  authKey: "",
106
+ heartbeatInterval: 3000,
107
+ heartbeatTimeoutMs: 0,
104
108
  encrypted: false,
109
+ enablePayloadDigest: true,
105
110
  maxTimeSkewMs: 30000,
106
111
  replayWindowMs: 120000,
107
112
  });
@@ -112,9 +117,12 @@ new ZNL({
112
117
  | `role` | ✓ | 节点角色,`"master"` 或 `"slave"` |
113
118
  | `id` | ✓ | 节点唯一标识;slave 端同时作为 ZMQ `routingId` |
114
119
  | `endpoints.router` | | ROUTER 端点,默认 `tcp://127.0.0.1:6003` |
115
- | `maxPending` | | 最大并发 RPC 请求数,`0` 表示不限制 |
120
+ | `maxPending` | | 最大并发 RPC 请求数,默认 `1000`;`0` 表示不限制 |
116
121
  | `authKey` | | 共享认证 Key;仅在 `encrypted=true` 时必填(用于签名/加密) |
122
+ | `heartbeatInterval` | | 心跳间隔(毫秒),默认 `3000`,`0` 表示禁用心跳 |
123
+ | `heartbeatTimeoutMs` | | 心跳超时时间(毫秒),默认 `0` 表示使用 `heartbeatInterval × 3` |
117
124
  | `encrypted` | | 是否启用加密:`false`(默认,明文) / `true`(签名+防重放+透明加密) |
125
+ | `enablePayloadDigest` | | 是否启用 payload 摘要校验,默认 `true`(关闭可提升性能) |
118
126
  | `maxTimeSkewMs` | | 时间戳最大允许偏移(毫秒),默认 `30000`,用于防重放校验 |
119
127
  | `replayWindowMs` | | nonce 重放缓存窗口(毫秒),默认 `120000` |
120
128
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lyrify/znl",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
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,6 +121,12 @@ 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
+
121
130
  // ─── PUB/SUB 状态 ────────────────────────────────────────────────────────
122
131
 
123
132
  /**
@@ -174,22 +183,26 @@ export class ZNL extends EventEmitter {
174
183
  * role : "master"|"slave",
175
184
  * id : string,
176
185
  * endpoints? : { router?: string },
177
- * maxPending? : number,
178
- * authKey? : string,
179
- * heartbeatInterval? : number,
180
- * encrypted? : boolean,
181
- * maxTimeSkewMs? : number,
182
- * replayWindowMs? : number,
186
+ * maxPending? : number,
187
+ * authKey? : string,
188
+ * heartbeatInterval? : number,
189
+ * heartbeatTimeoutMs? : number,
190
+ * encrypted? : boolean,
191
+ * enablePayloadDigest? : boolean,
192
+ * maxTimeSkewMs? : number,
193
+ * replayWindowMs? : number,
183
194
  * }} options
184
195
  */
185
196
  constructor({
186
197
  role,
187
198
  id,
188
199
  endpoints = {},
189
- maxPending = 0,
200
+ maxPending = DEFAULT_MAX_PENDING,
190
201
  authKey = "",
191
202
  heartbeatInterval = DEFAULT_HEARTBEAT_INTERVAL,
203
+ heartbeatTimeoutMs = DEFAULT_HEARTBEAT_TIMEOUT_MS,
192
204
  encrypted = false,
205
+ enablePayloadDigest = DEFAULT_ENABLE_PAYLOAD_DIGEST,
193
206
  maxTimeSkewMs = MAX_TIME_SKEW_MS,
194
207
  replayWindowMs = REPLAY_WINDOW_MS,
195
208
  } = {}) {
@@ -224,6 +237,15 @@ export class ZNL extends EventEmitter {
224
237
  this.#heartbeatInterval =
225
238
  this.#normalizeHeartbeatInterval(heartbeatInterval);
226
239
 
240
+ // 是否启用 payload 摘要校验(安全模式用)
241
+ this.#enablePayloadDigest = Boolean(enablePayloadDigest);
242
+
243
+ // 心跳超时:<=0 则由 heartbeatInterval 推导
244
+ this.#heartbeatTimeoutMs = this.#normalizePositiveInt(
245
+ heartbeatTimeoutMs,
246
+ DEFAULT_HEARTBEAT_TIMEOUT_MS,
247
+ );
248
+
227
249
  this.#maxTimeSkewMs = this.#normalizePositiveInt(
228
250
  maxTimeSkewMs,
229
251
  MAX_TIME_SKEW_MS,
@@ -546,8 +568,8 @@ export class ZNL extends EventEmitter {
546
568
  }
547
569
  }
548
570
 
549
- const entry = this.#slaves.get(identityText);
550
- if (entry) entry.lastSeen = Date.now();
571
+ // 心跳视为在线确认:必要时自动补注册
572
+ this.#ensureSlaveOnline(identityText, identity, { touch: true });
551
573
  return;
552
574
  }
553
575
 
@@ -597,6 +619,9 @@ export class ZNL extends EventEmitter {
597
619
  return;
598
620
  }
599
621
 
622
+ // 认证通过后允许补注册(避免 master 重启后丢失在线表)
623
+ this.#ensureSlaveOnline(identityText, identity, { touch: true });
624
+
600
625
  if (this.encrypted) {
601
626
  try {
602
627
  finalFrames = this.#openPayloadFrames(
@@ -613,6 +638,9 @@ export class ZNL extends EventEmitter {
613
638
  return;
614
639
  }
615
640
  }
641
+ } else {
642
+ // 明文模式同样补注册
643
+ this.#ensureSlaveOnline(identityText, identity, { touch: true });
616
644
  }
617
645
 
618
646
  const finalPayload = payloadFromFrames(finalFrames);
@@ -647,6 +675,9 @@ export class ZNL extends EventEmitter {
647
675
  return;
648
676
  }
649
677
 
678
+ // 认证通过后允许补注册(避免 master 重启后丢失在线表)
679
+ this.#ensureSlaveOnline(identityText, identity, { touch: true });
680
+
650
681
  if (this.encrypted) {
651
682
  try {
652
683
  finalFrames = this.#openPayloadFrames(
@@ -663,6 +694,9 @@ export class ZNL extends EventEmitter {
663
694
  return;
664
695
  }
665
696
  }
697
+ } else {
698
+ // 明文模式同样补注册
699
+ this.#ensureSlaveOnline(identityText, identity, { touch: true });
666
700
  }
667
701
 
668
702
  const finalPayload = payloadFromFrames(finalFrames);
@@ -998,7 +1032,11 @@ export class ZNL extends EventEmitter {
998
1032
  #startHeartbeatCheck() {
999
1033
  if (this.#heartbeatInterval <= 0) return;
1000
1034
 
1001
- const timeout = this.#heartbeatInterval * 3;
1035
+ // 心跳超时:优先使用配置值,<=0 则回退到 interval × 3
1036
+ const timeout =
1037
+ this.#heartbeatTimeoutMs > 0
1038
+ ? this.#heartbeatTimeoutMs
1039
+ : this.#heartbeatInterval * 3;
1002
1040
 
1003
1041
  this.#heartbeatCheckTimer = setInterval(() => {
1004
1042
  const now = Date.now();
@@ -1032,7 +1070,10 @@ export class ZNL extends EventEmitter {
1032
1070
  requestId: String(requestId ?? ""),
1033
1071
  timestamp: nowMs(),
1034
1072
  nonce: generateNonce(),
1035
- payloadDigest: digestFrames(payloadFrames),
1073
+ // 可选 payload 摘要:关闭时写空串以提升性能
1074
+ payloadDigest: this.#enablePayloadDigest
1075
+ ? digestFrames(payloadFrames)
1076
+ : "",
1036
1077
  };
1037
1078
 
1038
1079
  // 保留 canonical 文本,便于后续排障(签名实际在 token 内完成)
@@ -1100,12 +1141,15 @@ export class ZNL extends EventEmitter {
1100
1141
  };
1101
1142
  }
1102
1143
 
1103
- const currentDigest = digestFrames(payloadFrames);
1104
- if (String(envelope.payloadDigest) !== currentDigest) {
1105
- return { ok: false, error: "payload 摘要不一致,疑似篡改。" };
1144
+ if (this.#enablePayloadDigest) {
1145
+ const currentDigest = digestFrames(payloadFrames);
1146
+ if (String(envelope.payloadDigest) !== currentDigest) {
1147
+ return { ok: false, error: "payload 摘要不一致,疑似篡改。" };
1148
+ }
1106
1149
  }
1107
1150
 
1108
- const replayKey = `${envelope.kind}|${envelope.nodeId}|${envelope.nonce}`;
1151
+ // 重放 key 加入 requestId,降低 nonce 碰撞误杀风险
1152
+ const replayKey = `${envelope.kind}|${envelope.nodeId}|${envelope.requestId}|${envelope.nonce}`;
1109
1153
  if (this.#replayGuard.seenOrAdd(replayKey)) {
1110
1154
  return { ok: false, error: "检测到重放请求(nonce 重复)。" };
1111
1155
  }
@@ -1194,6 +1238,33 @@ export class ZNL extends EventEmitter {
1194
1238
  });
1195
1239
  }
1196
1240
 
1241
+ /**
1242
+ * master 侧确保某个 slave 已登记在线
1243
+ * - 仅在认证通过后调用
1244
+ * - 支持心跳/请求/响应等路径的自动补注册
1245
+ *
1246
+ * @param {string} identityText
1247
+ * @param {Buffer|string|Uint8Array} identity
1248
+ * @param {{ touch?: boolean }} [options]
1249
+ */
1250
+ #ensureSlaveOnline(identityText, identity, { touch = true } = {}) {
1251
+ const id = String(identityText ?? "");
1252
+ if (!id) return;
1253
+
1254
+ const now = Date.now();
1255
+ const entry = this.#slaves.get(id);
1256
+ if (entry) {
1257
+ if (touch) entry.lastSeen = now;
1258
+ return;
1259
+ }
1260
+
1261
+ this.#slaves.set(id, {
1262
+ identity: identityToBuffer(identity),
1263
+ lastSeen: now,
1264
+ });
1265
+ this.emit("slave_connected", id);
1266
+ }
1267
+
1197
1268
  // ═══════════════════════════════════════════════════════════════════════════
1198
1269
  // 工具方法
1199
1270
  // ═══════════════════════════════════════════════════════════════════════════
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
  /**