@lyrify/znl 0.4.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lyrify Cloud
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,244 @@
1
+ # ZNL
2
+
3
+ 基于 ZeroMQ `ROUTER/DEALER` 模式的 Node.js 通信库,提供开箱即用的双向 RPC 与 PUB/SUB 广播能力。
4
+
5
+ ## 特性
6
+
7
+ - 基于 `ROUTER/DEALER` 同时实现 RPC 请求-响应与 PUB/SUB 广播,一套连接两种模式
8
+ - 内置请求 ID 匹配、超时控制、最大并发限制
9
+ - 支持 Master → Slave 主动发起请求(双向 RPC)
10
+ - 基于 ROUTER 实现 PUB/SUB 广播,无需额外 socket 或端口
11
+ - Slave 自动注册/注销,Master 实时感知在线节点
12
+ - 可选的认证 Key 校验(注册与 RPC 请求均校验)
13
+ - Payload 支持 `string`、`Buffer`、`Uint8Array` 及其数组(多帧)
14
+
15
+ ## 安装
16
+
17
+ ```bash
18
+ pnpm add @lyrify/znl
19
+ ```
20
+
21
+ 本地开发克隆仓库后:
22
+
23
+ ```bash
24
+ pnpm install
25
+ ```
26
+
27
+ ## 快速开始
28
+
29
+ ### Master 节点
30
+
31
+ ```js
32
+ import { ZNL } from "@lyrify/znl";
33
+
34
+ const master = new ZNL({
35
+ role: "master",
36
+ id: "master-1",
37
+ endpoints: { router: "tcp://127.0.0.1:6003" },
38
+ authKey: "your-shared-key",
39
+ });
40
+
41
+ // RPC:自动回复 slave 的请求
42
+ master.ROUTER(async ({ identityText, payload }) => {
43
+ const text = Buffer.isBuffer(payload) ? payload.toString() : String(payload);
44
+ return `已收到来自 ${identityText} 的消息:${text}`;
45
+ });
46
+
47
+ // PUB/SUB:感知节点上下线
48
+ master.on("slave_connected", (id) => console.log(`${id} 上线,在线:${master.slaves}`));
49
+ master.on("slave_disconnected", (id) => console.log(`${id} 下线,在线:${master.slaves}`));
50
+
51
+ await master.start();
52
+
53
+ // PUB/SUB:广播消息(fire-and-forget)
54
+ master.publish("news", "今日头条:ZNL 正式发布");
55
+ master.publish("system", JSON.stringify({ status: "ok", time: Date.now() }));
56
+ ```
57
+
58
+ ### Slave 节点
59
+
60
+ ```js
61
+ import { ZNL } from "@lyrify/znl";
62
+
63
+ const slave = new ZNL({
64
+ role: "slave",
65
+ id: "slave-001",
66
+ endpoints: { router: "tcp://127.0.0.1:6003" },
67
+ authKey: "your-shared-key",
68
+ });
69
+
70
+ // PUB/SUB:精确订阅(可在 start 前调用)
71
+ slave.subscribe("news", ({ payload }) => {
72
+ console.log("收到新闻:", payload.toString());
73
+ });
74
+
75
+ // PUB/SUB:兜底监听所有 topic
76
+ slave.on("publish", ({ topic, payload }) => {
77
+ console.log(`[${topic}]`, payload.toString());
78
+ });
79
+
80
+ await slave.start();
81
+
82
+ // RPC:向 master 发请求并等待响应
83
+ const reply = await slave.DEALER("hello master", { timeoutMs: 4000 });
84
+ console.log(reply.toString());
85
+ ```
86
+
87
+ ## 构造函数
88
+
89
+ ```js
90
+ new ZNL({
91
+ role: "master" | "slave",
92
+ id: "node-id",
93
+ endpoints: {
94
+ router: "tcp://127.0.0.1:6003",
95
+ },
96
+ maxPending: 0,
97
+ authKey: "",
98
+ });
99
+ ```
100
+
101
+ | 参数 | 必填 | 说明 |
102
+ |------|------|------|
103
+ | `role` | ✓ | 节点角色,`"master"` 或 `"slave"` |
104
+ | `id` | ✓ | 节点唯一标识;slave 端同时作为 ZMQ `routingId` |
105
+ | `endpoints.router` | | ROUTER 端点,默认 `tcp://127.0.0.1:6003` |
106
+ | `maxPending` | | 最大并发 RPC 请求数,`0` 表示不限制 |
107
+ | `authKey` | | 可选共享认证 Key;master 开启后校验注册帧与 RPC 请求 |
108
+
109
+ ## API
110
+
111
+ ### `start()`
112
+
113
+ 启动节点:
114
+
115
+ - `master`:绑定(bind)ROUTER socket
116
+ - `slave`:连接(connect)DEALER socket,并自动向 master 发送注册帧
117
+
118
+ 重复调用安全,若正在启动中则等待同一个 Promise。
119
+
120
+ ### `stop()`
121
+
122
+ 停止节点:
123
+
124
+ - `slave`:先向 master 发送注销帧,再关闭 socket
125
+ - `master`:清空在线节点表,关闭 socket,立即 reject 所有 pending RPC 请求
126
+
127
+ ### `DEALER(payloadOrHandler, options?)`
128
+
129
+ **Slave 侧调用:**
130
+
131
+ - `payloadOrHandler` 为 payload 时:向 Master 发送 RPC 请求并等待响应,返回 `Promise<Buffer | Array>`
132
+ - `payloadOrHandler` 为函数时:注册 slave 侧自动回复处理器(Master 主动发来 RPC 请求时触发)
133
+
134
+ ### `ROUTER(identityOrHandler, payload?, options?)`
135
+
136
+ **Master 侧调用:**
137
+
138
+ - `identityOrHandler` 为函数时:注册 master 侧自动回复处理器(Slave 发来 RPC 请求时触发)
139
+ - `identityOrHandler` 为 identity(slave ID)时:Master 主动向指定 Slave 发送 RPC 请求并等待响应,返回 `Promise<Buffer | Array>`
140
+
141
+ ### `options.timeoutMs`
142
+
143
+ 单次 RPC 请求超时时间,默认 `5000` ms。
144
+
145
+ ### `publish(topic, payload)`
146
+
147
+ **Master 侧调用**,向所有当前在线的 slave 广播消息(fire-and-forget,无需 await)。
148
+
149
+ - `topic`:消息主题字符串,slave 侧可按 topic 精确过滤
150
+ - `payload`:同 RPC,支持 `string`、`Buffer`、`Uint8Array` 或其数组
151
+ - 若某个 slave 发送失败,自动将其从在线列表移除并触发 `slave_disconnected`
152
+
153
+ ```js
154
+ master.publish("news", "breaking news!");
155
+ master.publish("metrics", JSON.stringify({ cpu: 0.42 }));
156
+ ```
157
+
158
+ ### `subscribe(topic, handler)`
159
+
160
+ **Slave 侧调用**,订阅指定 topic,master 广播时触发 handler。
161
+
162
+ - 可在 `start()` 前后任意时刻调用,订阅信息跨 stop/start 周期保留
163
+ - 同一 topic 重复订阅会覆盖旧 handler
164
+ - 支持链式调用(返回 `this`)
165
+
166
+ ```js
167
+ slave
168
+ .subscribe("news", ({ topic, payload }) => { /* ... */ })
169
+ .subscribe("metrics", ({ topic, payload }) => { /* ... */ });
170
+ ```
171
+
172
+ ### `unsubscribe(topic)`
173
+
174
+ **Slave 侧调用**,取消订阅指定 topic,支持链式调用。
175
+
176
+ ```js
177
+ slave.unsubscribe("news");
178
+ ```
179
+
180
+ ### `slaves`
181
+
182
+ **Master 侧只读属性**,返回当前所有在线 slave ID 的快照数组。
183
+
184
+ ```js
185
+ console.log(master.slaves); // ["slave-001", "slave-002"]
186
+ ```
187
+
188
+ ## 事件
189
+
190
+ 通过 `node.on(eventName, handler)` 监听:
191
+
192
+ | 事件 | 触发方 | 说明 |
193
+ |------|--------|------|
194
+ | `router` | Master | Router socket 收到原始帧(所有类型) |
195
+ | `dealer` | Slave | Dealer socket 收到原始帧(所有类型) |
196
+ | `request` | 两者 | 解析出 RPC 请求帧(认证通过后) |
197
+ | `response` | 两者 | 解析出 RPC 响应帧 |
198
+ | `message` | 两者 | 所有解析消息的统一事件 |
199
+ | `publish` | Slave | 收到 master 广播,携带 `{ topic, payload }` |
200
+ | `slave_connected` | Master | slave 注册成功上线,携带 `slaveId` |
201
+ | `slave_disconnected` | Master | slave 注销或发送失败下线,携带 `slaveId` |
202
+ | `auth_failed` | Master | 认证失败(注册或 RPC),请求已被丢弃 |
203
+ | `error` | 两者 | 内部错误 |
204
+
205
+ ## 本地示例
206
+
207
+ ```bash
208
+ # 终端 1:启动 Master
209
+ pnpm example:master
210
+
211
+ # 终端 2:启动 Slave(可指定 ID)
212
+ pnpm example:slave
213
+ node test/slave/index.js slave-001
214
+ ```
215
+
216
+ ## 集成测试
217
+
218
+ 在同一进程内启动 Master / Slave,自动验证 RPC、并发、认证、超时、PUB/SUB 等全部功能:
219
+
220
+ ```bash
221
+ pnpm test
222
+ ```
223
+
224
+ ## 并发压测
225
+
226
+ ```bash
227
+ # 终端 1:启动 Echo 服务端
228
+ pnpm test:echo
229
+
230
+ # 终端 2:发起并发压测
231
+ pnpm test:100 -- 100 10000 slave-001
232
+ ```
233
+
234
+ 参数说明:
235
+
236
+ - 总请求数
237
+ - 超时时间(毫秒)
238
+ - Slave 节点 ID
239
+
240
+ ## 发布前检查
241
+
242
+ 1. 更新 `package.json` 中的 `name`、`version`、`author`、`repository`
243
+ 2. 确认 README 中的包名与 import 路径
244
+ 3. 按需更新 `LICENSE` 中的版权信息
package/index.js ADDED
@@ -0,0 +1 @@
1
+ export { ZNL } from "./src/ZNL.js";
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@lyrify/znl",
3
+ "version": "0.4.1",
4
+ "description": "ZNL - ZeroMQ Node Link",
5
+ "type": "module",
6
+ "main": "./index.js",
7
+ "exports": {
8
+ ".": "./index.js"
9
+ },
10
+ "files": [
11
+ "index.js",
12
+ "src",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "keywords": [
17
+ "zeromq",
18
+ "router",
19
+ "dealer",
20
+ "nodejs",
21
+ "rpc"
22
+ ],
23
+ "license": "MIT",
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "https://github.com/Lyrify-Cloud/ZNL.git"
27
+ },
28
+ "homepage": "https://github.com/Lyrify-Cloud/ZNL#readme",
29
+ "bugs": {
30
+ "url": "https://github.com/Lyrify-Cloud/ZNL/issues"
31
+ },
32
+ "engines": {
33
+ "node": ">=18"
34
+ },
35
+ "dependencies": {
36
+ "zeromq": "^6.5.0"
37
+ },
38
+ "scripts": {
39
+ "example:master": "node test/master/index.js",
40
+ "example:slave": "node test/slave/index.js",
41
+ "test": "node test/run.js",
42
+ "test:echo": "node test/master/test-echo-server.js",
43
+ "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/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"
45
+ }
46
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * 待处理请求管理器
3
+ *
4
+ * 负责管理所有 in-flight(已发送、等待响应)的请求:
5
+ * - 创建 Promise 并存储 resolve/reject 引用
6
+ * - 超时后自动 reject(定时器在实际发送后才启动,避免队列等待被计入超时)
7
+ * - 最大并发数限制
8
+ * - 批量 reject(节点停止时调用)
9
+ */
10
+
11
+ import { DEFAULT_TIMEOUT_MS } from "./constants.js";
12
+
13
+ export class PendingManager {
14
+ /**
15
+ * key → { resolve, reject, timer }
16
+ * @type {Map<string, { resolve: Function, reject: Function, timer: ReturnType<typeof setTimeout>|null }>}
17
+ */
18
+ #map = new Map();
19
+
20
+ /** 最大并发数(0 = 不限制) */
21
+ #maxPending;
22
+
23
+ /**
24
+ * @param {number} maxPending - 0 表示不限制
25
+ */
26
+ constructor(maxPending = 0) {
27
+ this.#maxPending = this.#normalizeMax(maxPending);
28
+ }
29
+
30
+ // ─── 公开 API ─────────────────────────────────────────────────────────────
31
+
32
+ /**
33
+ * 生成 pending Map 的 key
34
+ *
35
+ * 设计说明:
36
+ * - slave → master 方向:仅用 requestId(唯一)
37
+ * - master → slave 方向:用 "identityText::requestId"
38
+ * 防止多个 slave 使用相同 requestId 时发生 key 碰撞
39
+ *
40
+ * @param {string} requestId
41
+ * @param {string} [identityText]
42
+ * @returns {string}
43
+ */
44
+ key(requestId, identityText = "") {
45
+ return identityText ? `${identityText}::${requestId}` : requestId;
46
+ }
47
+
48
+ /**
49
+ * 检查并发容量,超限时抛出错误
50
+ * @throws {Error}
51
+ */
52
+ ensureCapacity() {
53
+ if (this.#maxPending === 0) return; // 0 = 不限制
54
+ if (this.#map.size < this.#maxPending) return;
55
+ throw new Error(
56
+ `并发请求数已达上限:pending=${this.#map.size}, maxPending=${this.#maxPending}`
57
+ );
58
+ }
59
+
60
+ /**
61
+ * 创建一个 pending 记录
62
+ *
63
+ * 返回 startTimer 而不是立即启动计时器,原因:
64
+ * 发送任务在队列中等待时不应该占用超时时间,
65
+ * 应在消息真正写入 socket 后才开始计时。
66
+ *
67
+ * @param {string} key
68
+ * @param {number|undefined} timeoutMs
69
+ * @param {string} requestId - 用于错误信息
70
+ * @param {string} [identityText] - 用于错误信息
71
+ * @returns {{ promise: Promise, startTimer: () => void }}
72
+ */
73
+ create(key, timeoutMs, requestId, identityText = "") {
74
+ const ms = this.#normalizeTimeout(timeoutMs);
75
+ const entry = { resolve: null, reject: null, timer: null };
76
+
77
+ const promise = new Promise((resolve, reject) => {
78
+ entry.resolve = resolve;
79
+ entry.reject = reject;
80
+ });
81
+
82
+ const startTimer = () => {
83
+ if (entry.timer !== null) return; // 防止重复启动
84
+ entry.timer = setTimeout(() => {
85
+ this.#map.delete(key);
86
+ entry.reject(
87
+ new Error(
88
+ identityText
89
+ ? `请求超时:requestId=${requestId}, identity=${identityText}`
90
+ : `请求超时:requestId=${requestId}`
91
+ )
92
+ );
93
+ }, ms);
94
+ };
95
+
96
+ this.#map.set(key, entry);
97
+ return { promise, startTimer };
98
+ }
99
+
100
+ /**
101
+ * 以成功结果 resolve 一个 pending 请求
102
+ * @param {string} key
103
+ * @param {*} value
104
+ * @returns {boolean} 是否命中(key 不存在时返回 false)
105
+ */
106
+ resolve(key, value) {
107
+ const entry = this.#map.get(key);
108
+ if (!entry) return false;
109
+ clearTimeout(entry.timer);
110
+ this.#map.delete(key);
111
+ entry.resolve(value);
112
+ return true;
113
+ }
114
+
115
+ /**
116
+ * 以错误 reject 一个 pending 请求
117
+ * @param {string} key
118
+ * @param {Error} error
119
+ * @returns {boolean} 是否命中
120
+ */
121
+ reject(key, error) {
122
+ const entry = this.#map.get(key);
123
+ if (!entry) return false;
124
+ clearTimeout(entry.timer);
125
+ this.#map.delete(key);
126
+ entry.reject(error);
127
+ return true;
128
+ }
129
+
130
+ /**
131
+ * 批量 reject 所有 pending 请求(节点停止时调用)
132
+ * @param {Error} error
133
+ */
134
+ rejectAll(error) {
135
+ for (const [key, entry] of this.#map) {
136
+ clearTimeout(entry.timer);
137
+ entry.reject(error);
138
+ this.#map.delete(key);
139
+ }
140
+ }
141
+
142
+ /** 当前待处理请求数 */
143
+ get size() {
144
+ return this.#map.size;
145
+ }
146
+
147
+ // ─── 私有辅助 ─────────────────────────────────────────────────────────────
148
+
149
+ #normalizeMax(n) {
150
+ const v = Number(n);
151
+ return Number.isFinite(v) && v > 0 ? Math.floor(v) : 0;
152
+ }
153
+
154
+ #normalizeTimeout(ms) {
155
+ const v = Number(ms);
156
+ return Number.isFinite(v) && v > 0 ? v : DEFAULT_TIMEOUT_MS;
157
+ }
158
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * 串行发送队列管理器
3
+ *
4
+ * 问题背景:
5
+ * ZMQ socket 不支持并发写入——同时调用 socket.send() 会导致帧交叉。
6
+ * 虽然 zeromq.js v6 在 JS 层做了一定保护,但显式串行化更安全可靠。
7
+ *
8
+ * 实现原理:
9
+ * 为每个 socket 通道维护一条 Promise 链(队列尾指针)。
10
+ * 每次入队时,新任务追加在当前链尾,前一个任务完成后自动触发。
11
+ * 无论前一个任务成功或失败,队列都会继续执行下一个任务。
12
+ */
13
+
14
+ export class SendQueue {
15
+ /**
16
+ * 各通道的队列尾指针
17
+ * @type {Map<string, Promise<void>>}
18
+ */
19
+ #tails = new Map();
20
+
21
+ /**
22
+ * 将发送任务追加到指定通道的队列尾部
23
+ *
24
+ * @param {string} channel - 通道标识(如 "dealer" | "router")
25
+ * @param {() => Promise<void>} task - 实际发送操作
26
+ * @returns {Promise<void>} 本次任务的 Promise(可用于错误捕获)
27
+ */
28
+ enqueue(channel, task) {
29
+ const tail = this.#tails.get(channel) ?? Promise.resolve();
30
+
31
+ // 无论前一个任务成功或失败,都继续执行当前任务
32
+ const run = tail.then(task, task);
33
+
34
+ // 更新队尾(吞掉错误,防止产生 UnhandledRejection)
35
+ this.#tails.set(channel, run.catch(() => {}));
36
+
37
+ return run;
38
+ }
39
+
40
+ /**
41
+ * 清空所有通道的队列引用(节点停止时调用)
42
+ * 注意:已入队但未执行的任务不会被取消,仅释放尾指针引用
43
+ */
44
+ clear() {
45
+ this.#tails.clear();
46
+ }
47
+ }