@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 +10 -2
- package/package.json +14 -2
- package/src/SendQueue.js +51 -17
- package/src/ZNL.js +88 -17
- package/src/constants.js +9 -0
- package/src/security.js +19 -1
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:
|
|
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
|
|
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.
|
|
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
|
-
"
|
|
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
|
|
10
|
-
*
|
|
11
|
-
* 无论前一个任务成功或失败,队列都会继续执行下一个任务。
|
|
9
|
+
* 为每个 socket 通道维护一个任务队列与独立消费循环。
|
|
10
|
+
* 通过单线程 async loop 串行执行,避免长 Promise 链增长。
|
|
12
11
|
*/
|
|
13
|
-
|
|
14
12
|
export class SendQueue {
|
|
15
13
|
/**
|
|
16
|
-
*
|
|
17
|
-
* @type {Map<string,
|
|
14
|
+
* 通道级队列与运行状态
|
|
15
|
+
* @type {Map<string, { queue: Array<{ task: Function, resolve: Function, reject: Function }>, running: boolean }>}
|
|
18
16
|
*/
|
|
19
|
-
#
|
|
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
|
|
30
|
-
|
|
31
|
-
// 无论前一个任务成功或失败,都继续执行当前任务
|
|
32
|
-
const run = tail.then(task, task);
|
|
27
|
+
const name = String(channel);
|
|
28
|
+
const entry = this.#ensureChannel(name);
|
|
33
29
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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.#
|
|
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
|
-
* - 超过
|
|
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?
|
|
178
|
-
* authKey?
|
|
179
|
-
* heartbeatInterval?
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
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 =
|
|
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
|
-
|
|
550
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
/**
|