@hile/micro-dynamic-configs 1.0.3 → 2.0.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 ADDED
@@ -0,0 +1,85 @@
1
+ # @hile/micro-dynamic-configs
2
+
3
+ Dynamic configuration server for @hile/micro. Stores config in Redis, validates with Zod, and pushes changes to subscribers via micro's built-in pub/sub.
4
+
5
+ ## Usage
6
+
7
+ ```ts
8
+ import { Application } from '@hile/micro';
9
+ import { MicroDynamicConfigsServer } from '@hile/micro-dynamic-configs';
10
+ import Redis from 'ioredis';
11
+ import { z } from 'zod';
12
+
13
+ const schema = z.object({
14
+ name: z.string().default(''),
15
+ port: z.number().default(8080),
16
+ debug: z.boolean().default(false),
17
+ });
18
+
19
+ const app = new Application({
20
+ namespace: 'config-svc',
21
+ registry: { host: '127.0.0.1', port: 6379 },
22
+ });
23
+
24
+ const redis = new Redis({ host: '127.0.0.1', port: 6379 });
25
+
26
+ const configs = new MicroDynamicConfigsServer({
27
+ app,
28
+ redis,
29
+ schema,
30
+ redis_key: 'my-app:config',
31
+ });
32
+
33
+ await configs.initialize();
34
+
35
+ // Read current value
36
+ console.log(configs.value); // { name: '', port: 8080, debug: false }
37
+
38
+ // Update and persist — subscribers receive push
39
+ await configs.save({ name: 'production', port: 9090 });
40
+ ```
41
+
42
+ ## Topic Convention
43
+
44
+ Each schema field publishes to a separate topic:
45
+
46
+ ```
47
+ {namespace}:{field}
48
+ ```
49
+
50
+ For example, with namespace `config-svc` and schema fields `name`, `port`, `debug`:
51
+
52
+ - `config-svc:name`
53
+ - `config-svc:port`
54
+ - `config-svc:debug`
55
+
56
+ ## Subscribing
57
+
58
+ Use `app.subscribe()` directly on any micro Application:
59
+
60
+ ```ts
61
+ const values: Record<string, any> = {};
62
+ const unsub = await app.subscribe('config-svc:name', (v) => values.name = v);
63
+ ```
64
+
65
+ ## Local Events
66
+
67
+ The server emits `change:{field}` events locally:
68
+
69
+ ```ts
70
+ configs.on('change:name', (newValue, oldValue) => {
71
+ console.log(`name changed from ${oldValue} to ${newValue}`);
72
+ });
73
+ ```
74
+
75
+ ## Persistence
76
+
77
+ Config is persisted to Redis on every `save()`. On `initialize()`, the server loads the last saved state from Redis. Schema defaults apply when no value exists.
78
+
79
+ ## Teardown
80
+
81
+ ```ts
82
+ const teardown = await configs.initialize();
83
+ // Later:
84
+ await teardown(); // unpublishes all topics and cleans up listeners
85
+ ```
package/SKILL.md CHANGED
@@ -11,24 +11,24 @@ description: Code generation and contribution rules for @hile/micro-dynamic-conf
11
11
 
12
12
  ## 1. 架构总览
13
13
 
14
- 三个角色,通过 `@hile/micro` 的 WebSocket 通信:
14
+ 通过 `@hile/micro` 的 pub/sub 机制推送配置变更,每个 schema 字段一个独立 topic。
15
15
 
16
16
  ```
17
- APP_3 (Publisher) APP_1 (Config Server) APP_2 (Subscriber)
18
-
19
- save({ key: val })
20
- ├──────────────────────────►
21
- push /-/dynamic-configs/
22
- change { key, newValue }
23
- ├─────────────────────────────►│
24
-
25
- │◄── subscribe(fields) ───────┤
26
- │───── initial values ────────►│
17
+ Publisher Config Server Subscriber
18
+
19
+ save({ key: val })
20
+ ├──────────────────────────►
21
+ publisher.update(newValue)
22
+ ──────────────────────────────►
23
+ │ (通过 Registry 广播 topic) │
24
+
25
+ │ app.subscribe('ns:key', cb)
26
+ │ ◄──────────────────────────────
27
27
  ```
28
28
 
29
- - **Config Server** (`MicroDynamicConfigsServer`) — 持有配置数据,管理订阅者列表,推送变更
30
- - **Subscriber** (`MicroDynamicConfigClients`) 订阅某个 namespace 的配置字段,接收实时推送
31
- - **Publisher** — 调用 `server.save()` 修改配置,数据流向是:validate → Redis 持久化 → 内存更新 → 推送通知
29
+ - **Config Server** (`MicroDynamicConfigsServer`) — 持有配置数据,校验、持久化,通过 `app.publish` 注册 publisher
30
+ - **Subscriber** — 直接用 `app.subscribe(topic, callback)` 接收推送,无需客户端层
31
+ - **Publisher** — 调用 `server.save()` 修改配置,数据流:validate → Redis 持久化 → 内存更新 → `publisher.update()` → `change:key` 事件
32
32
 
33
33
  ---
34
34
 
@@ -49,8 +49,8 @@ constructor(options: {
49
49
  redis_key: string // Redis 存储键名
50
50
  })
51
51
 
52
- // 从 Redis 加载持久化数据,返回 teardown 函数
53
- initialize(): Promise<() => void>
52
+ // 从 Redis 加载持久化数据,为每个 schema 字段注册 publisher,返回 teardown 函数
53
+ initialize(): Promise<() => Promise<void>>
54
54
 
55
55
  // 持久化并推送变更
56
56
  save(value: Partial<z.infer<Z>>): Promise<number>
@@ -59,56 +59,35 @@ save(value: Partial<z.infer<Z>>): Promise<number>
59
59
  get value(): z.infer<Z>
60
60
  ```
61
61
 
62
- ### MicroDynamicConfigClients
62
+ ### 事件
63
63
 
64
- ```typescript
65
- class MicroDynamicConfigClients
66
-
67
- constructor(app: Application)
68
-
69
- subscribe<T extends Record<string, any>>(
70
- namespace: string,
71
- fields: (keyof T)[]
72
- ): Promise<T>
73
-
74
- close(): Promise<void>
75
- ```
76
-
77
- ### DynamicConfigClient (通常不直接使用)
78
-
79
- ```typescript
80
- class DynamicConfigClient<T extends Record<string, any>>
81
-
82
- getValue(): Promise<T>
83
- close(): Promise<void>
84
- setValue<K extends keyof T>(k: K, v: T[K]): this // 由 push handler 调用
85
- ```
64
+ | 事件 | 参数 | 说明 |
65
+ |------|------|------|
66
+ | `change:{field}` | `(newValue, oldValue)` | 字段变更时触发,每个字段独立事件名 |
86
67
 
87
68
  ---
88
69
 
89
- ## 3. 通信协议
70
+ ## 3. Topic 约定
90
71
 
91
- ### 路由端点
72
+ 每个 schema 字段一个 topic,格式为:
92
73
 
93
- | 方向 | 路径 | 数据 | 返回 |
94
- |------|------|------|------|
95
- | Subscriber → Server | `/-/dynamic-configs/subscribe` | `string[]` (字段名) | `Record<string, any>` (字段值) |
96
- | Subscriber → Server | `/-/dynamic-configs/unsubscribe` | `string[]` (字段名) | `number` (timestamp) |
97
- | Server → Subscriber | `/-/dynamic-configs/change` (push) | `{ key, newValue, oldValue, namespace }` | — |
74
+ ```
75
+ {namespace}:{field}
76
+ ```
98
77
 
99
- ### 订阅流程
78
+ 例如 namespace `config-svc`、字段 `name`、`port`、`debug`:
100
79
 
101
- 1. 客户端通过 Registry 发现目标 namespace 的 `host:port`
102
- 2. 建立 WebSocket 连接
103
- 3. 发送 subscribe 请求,服务端注册并返回初始值
104
- 4. 服务端数据变更时,遍历 `stacks` 中该字段的订阅者,逐个 push
105
- 5. 客户端收到 push 后更新本地缓存
80
+ - `config-svc:name`
81
+ - `config-svc:port`
82
+ - `config-svc:debug`
106
83
 
107
- ### 退订与断连
84
+ ### 订阅
108
85
 
109
- - 显式退订:调用 `close()` → 发送 unsubscribe 请求
110
- - 被动断连:WebSocket close 服务端 `onClientDisconnect` 清理所有 `stacks`
111
- - 客户端断连后 `_status = 0`,下次 `getValue()` 重新走完整订阅流程
86
+ ```typescript
87
+ const values: Record<string, any> = {};
88
+ const unsub = await app.subscribe('config-svc:name', (v) => values.name = v);
89
+ // unsub() 取消订阅
90
+ ```
112
91
 
113
92
  ---
114
93
 
@@ -124,10 +103,10 @@ for (const key of keys) {
124
103
  const parsed = schema.parse(value[key]);
125
104
  if (!deepEqual(old, parsed)) entries.push({ key, parsed });
126
105
  }
127
- // Pass 2: persist → memory → emit
106
+ // Pass 2: persist → memory → publish + emit
128
107
  await redis.set(key, JSON.stringify(next));
129
108
  update memory;
130
- emit events;
109
+ emit 'change:{key}' events;
131
110
 
132
111
  // ✗ 禁止:validate 和 mutate 混在一轮
133
112
  for (const key of keys) {
@@ -135,87 +114,47 @@ for (const key of keys) {
135
114
  }
136
115
  ```
137
116
 
138
- ### 4.2 客户端状态机
117
+ ### 4.2 initialize() 中注册 publisher
139
118
 
140
- ```
141
- _status: 0 (uninit) ──getValue()──→ 1 (loading)
142
- ↑ │
143
- │ subscribe()
144
- │ │
145
- │ ┌────┴────┐
146
- │ fail │ │ success
147
- │ ┌────┘ └────┐
148
- │ ↓ ↓
149
- │ 0 (retry) 2 (ready)
150
- │ │
151
- │ disconnect │
152
- │ ────────→ 0
153
- │ close()
154
- └────────────────────────────────── closed
155
- ```
119
+ ```typescript
120
+ // 正确:initialize 时调用 app.publish 注册所有字段
121
+ const publisher = await this.app.publish(`${namespace}:${key}`, initialValue);
122
+ this.publishers.set(key, publisher);
156
123
 
157
- - `close()` `_closed = true`,pending subscribe 完成时检测标记并 dispose 连接
158
- - `close()` 后 `getValue()` 直接 reject
124
+ // save 时通过 publisher.update 推送
125
+ await this.publishers.get(key)!.update(newValue);
159
126
 
160
- ### 4.3 生命周期
127
+ // teardown 时 unpublish
128
+ await publisher.unpublish();
161
129
 
162
- ```typescript
163
- // 服务端初始化
164
- const teardown = await server.initialize();
165
- // ... 运行 ...
166
- teardown(); // 清理所有监听器和订阅
167
-
168
- // 客户端创建
169
- const configs = new MicroDynamicConfigClients(app);
170
- const value = await configs.subscribe("ns", ["key1"]);
171
- // ... 使用 ...
172
- await configs.close(); // 清理所有订阅连接
130
+ // ✗ 禁止:save 中每次调用 app.publish(应复用 initialize 时注册的 publisher)
173
131
  ```
174
132
 
175
- ---
176
-
177
- ## 5. 反模式(禁止)
178
-
179
- ### 5.1 save() 中字段校验失败后保留脏状态
133
+ ### 4.3 teardown 必须清理所有资源
180
134
 
181
135
  ```typescript
182
- //
183
- for (const key of keys) {
184
- this._value[key] = parsed; // 后续失败 脏数据
185
- }
186
- await redis.set(...);
187
-
188
- // ✓ 先全部校验,再统一写入
136
+ // ✓ 正确:teardown 需 unpublish + clear + removeAllListeners
137
+ return async () => {
138
+ for (const publisher of this.publishers.values()) {
139
+ await publisher.unpublish();
140
+ }
141
+ this.publishers.clear();
142
+ this.removeAllListeners();
143
+ };
189
144
  ```
190
145
 
191
- ### 5.2 close() 前不检查 pending subscribe
146
+ ### 4.4 deep diff 避免无效推送
192
147
 
193
148
  ```typescript
194
- // close() 不设标记,pending subscribe 完成导致 client 复活
195
- async close() {
196
- await this.unsubscribe();
197
- this._client = undefined;
198
- }
199
-
200
- // ✓ close() 先设 _closed 标记
201
- async close() {
202
- this._closed = true;
203
- await this.unsubscribe();
204
- this._client = undefined;
149
+ // 正确:值未变化时跳过 publish
150
+ if (isDeepStrictEqual(oldValue, parsed)) {
151
+ continue; // 不写入 entries,不触发 publish 和事件
205
152
  }
206
153
  ```
207
154
 
208
- ### 5.3 多个 MicroDynamicConfigClients 实例共享同一 app
209
-
210
- rou3 `dispatch()` 只执行第一个匹配的 handler,第二个实例的 `/-/dynamic-configs/change` 不会触发。一个 app 只应创建一个 `MicroDynamicConfigClients` 实例。
211
-
212
- ### 5.4 在 push handler 中做耗时的同步操作
213
-
214
- push handler 在 message 调度线程中执行,不应包含耗时操作。`setValue()` 仅是内存写入,保持轻量。
215
-
216
155
  ---
217
156
 
218
- ## 6. 边界条件清单
157
+ ## 5. 边界条件清单
219
158
 
220
159
  - [ ] `save()` 传入空对象 `{}` — 返回 0,无操作
221
160
  - [ ] `save()` 传入 schema 中不存在的字段 — 跳过,不报错
@@ -223,10 +162,13 @@ push handler 在 message 调度线程中执行,不应包含耗时操作。`set
223
162
  - [ ] `save()` 中所有字段值与当前一致 — 返回 0,不写 Redis 不推送
224
163
  - [ ] `save()` Redis 写入失败 — throw,`_value` 不变
225
164
  - [ ] `initialize()` 时 Redis 无数据 — 使用 schema 默认值
226
- - [ ] `close()` subscribe 完成前调用 `_closed` 标记防止复活,新连接被 dispose
227
- - [ ] `close()` 后调用 `getValue()`直接 reject
228
- - [ ] 订阅不存在的字段 — 服务器静默跳过,不返回该字段
229
- - [ ] 同一 namespace 重复 subscribe — 复用已有 client,`fields` 不会合并
230
- - [ ] 断连后重新 subscribe — `_status = 0` → 全量重新拉取
231
- - [ ] 服务端推送时订阅者已断连 — `has(client)` 检查跳过,等待 `onClientDisconnect` 清理
232
- - [ ] 多个字段同时变更 — 一轮 `save()` 中多次 `change:key` 事件,订阅者逐个收到
165
+ - [ ] 多个字段同时变更 — 一轮 `save()` 中逐个 `publisher.update()` + `change:key` 事件
166
+ - [ ] Deep diff 检测嵌套对象变化嵌套字段内容不变时跳过 publish
167
+
168
+ ---
169
+
170
+ ## 6. 依赖
171
+
172
+ - `@hile/micro` — 提供 `Application`、`app.publish`/`app.subscribe`
173
+ - `ioredis` — Redis 持久化
174
+ - `zod` — Schema 定义与校验
package/dist/index.d.ts CHANGED
@@ -1,2 +1 @@
1
1
  export * from './server.js';
2
- export * from './client.js';
package/dist/index.js CHANGED
@@ -1,2 +1 @@
1
1
  export * from './server.js';
2
- export * from './client.js';
package/dist/server.d.ts CHANGED
@@ -4,21 +4,18 @@ import { Redis } from "ioredis";
4
4
  import { EventEmitter } from 'node:events';
5
5
  export declare class MicroDynamicConfigsServer<T extends Application, Z extends ZodObject<ZodRawShape>> extends EventEmitter {
6
6
  private _value;
7
- private readonly stacks;
8
7
  private readonly app;
9
8
  private readonly schema;
10
9
  private readonly redis;
11
10
  private readonly redis_key;
11
+ private readonly publishers;
12
12
  constructor(options: {
13
13
  app: T;
14
14
  redis: Redis;
15
15
  schema: Z;
16
16
  redis_key: string;
17
17
  });
18
- private onClientDisconnect;
19
18
  get value(): z.core.output<Z>;
20
19
  initialize(): Promise<() => Promise<void>>;
21
- private registerSubscribe;
22
- private registerUnsubscribe;
23
20
  save(value: Partial<z.infer<Z>>): Promise<number>;
24
21
  }
package/dist/server.js CHANGED
@@ -2,11 +2,11 @@ import { isDeepStrictEqual } from "node:util";
2
2
  import { EventEmitter } from 'node:events';
3
3
  export class MicroDynamicConfigsServer extends EventEmitter {
4
4
  _value;
5
- stacks = new Map();
6
5
  app;
7
6
  schema;
8
7
  redis;
9
8
  redis_key;
9
+ publishers = new Map();
10
10
  constructor(options) {
11
11
  super();
12
12
  this.app = options.app;
@@ -14,32 +14,7 @@ export class MicroDynamicConfigsServer extends EventEmitter {
14
14
  this.redis = options.redis;
15
15
  this.redis_key = options.redis_key;
16
16
  this._value = this.schema.parse({});
17
- for (const key of Object.keys(this.schema.shape)) {
18
- this.stacks.set(key, new Set());
19
- this.on('change:' + key, (newValue, oldValue) => {
20
- if (this.stacks.has(key)) {
21
- const pool = this.stacks.get(key);
22
- for (const client of pool) {
23
- if (this.app.clients.has(client)) {
24
- this.app.clients.get(client).push('/-/dynamic-configs/change', {
25
- key, newValue, oldValue,
26
- namespace: this.app.namespace,
27
- });
28
- }
29
- }
30
- }
31
- });
32
- }
33
- this.registerSubscribe();
34
- this.registerUnsubscribe();
35
- this.app.events.on('disconnect', this.onClientDisconnect);
36
17
  }
37
- onClientDisconnect = (client) => {
38
- const key = client.host + ':' + client.port;
39
- for (const [, pool] of this.stacks) {
40
- pool.delete(key);
41
- }
42
- };
43
18
  get value() {
44
19
  return this._value;
45
20
  }
@@ -50,38 +25,19 @@ export class MicroDynamicConfigsServer extends EventEmitter {
50
25
  this._value = this.schema.parse(JSON.parse(value));
51
26
  }
52
27
  }
28
+ const keys = Object.keys(this.schema.shape);
29
+ for (const key of keys) {
30
+ const publisher = await this.app.publish(`${this.app.namespace}:${key}`, this._value[key]);
31
+ this.publishers.set(key, publisher);
32
+ }
53
33
  return async () => {
34
+ for (const publisher of this.publishers.values()) {
35
+ await publisher.unpublish();
36
+ }
37
+ this.publishers.clear();
54
38
  this.removeAllListeners();
55
- this.app.events.off('disconnect', this.onClientDisconnect);
56
- this.stacks.clear();
57
39
  };
58
40
  }
59
- registerSubscribe() {
60
- this.app.register('/-/dynamic-configs/subscribe', async ({ data, client }) => {
61
- const out = {};
62
- for (const key of data) {
63
- if (this.stacks.has(key)) {
64
- this.stacks.get(key).add(client.host + ':' + client.port);
65
- out[key] = this._value[key];
66
- }
67
- }
68
- return out;
69
- });
70
- }
71
- registerUnsubscribe() {
72
- this.app.register('/-/dynamic-configs/unsubscribe', async ({ data, client }) => {
73
- for (const key of data) {
74
- if (this.stacks.has(key)) {
75
- const pool = this.stacks.get(key);
76
- const _key = client.host + ':' + client.port;
77
- if (pool.has(_key)) {
78
- pool.delete(_key);
79
- }
80
- }
81
- }
82
- return Date.now();
83
- });
84
- }
85
41
  async save(value) {
86
42
  const keys = Object.keys(value);
87
43
  if (!keys.length)
@@ -112,6 +68,9 @@ export class MicroDynamicConfigsServer extends EventEmitter {
112
68
  this._value[key] = parsed;
113
69
  }
114
70
  for (const { key, parsed: newValue, oldValue } of entries) {
71
+ if (this.publishers.has(key)) {
72
+ await this.publishers.get(key).update(newValue);
73
+ }
115
74
  this.emit('change:' + key, newValue, oldValue);
116
75
  }
117
76
  return entries.length;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hile/micro-dynamic-configs",
3
- "version": "1.0.3",
3
+ "version": "2.0.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
@@ -23,9 +23,9 @@
23
23
  },
24
24
  "dependencies": {
25
25
  "@hile/ioredis": "^1.1.2",
26
- "@hile/micro": "^1.0.10",
26
+ "@hile/micro": "^2.0.0",
27
27
  "ioredis": "^5.10.0",
28
28
  "zod": "^4.4.3"
29
29
  },
30
- "gitHead": "be31521d206109a1ed6e915ec231a849870b538c"
30
+ "gitHead": "d28b20bc22ee08c45cbf4596672705cb96e8e461"
31
31
  }