@hile/micro-dynamic-configs 1.0.3 → 2.0.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/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/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.1",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
@@ -22,10 +22,10 @@
22
22
  "vitest": "^4.0.18"
23
23
  },
24
24
  "dependencies": {
25
- "@hile/ioredis": "^1.1.2",
26
- "@hile/micro": "^1.0.10",
25
+ "@hile/ioredis": "^2.0.0",
26
+ "@hile/micro": "^2.0.1",
27
27
  "ioredis": "^5.10.0",
28
28
  "zod": "^4.4.3"
29
29
  },
30
- "gitHead": "be31521d206109a1ed6e915ec231a849870b538c"
30
+ "gitHead": "8e0fd1f78b5a8abd21218d1f596ada2533a0c8e7"
31
31
  }
package/SKILL.md DELETED
@@ -1,232 +0,0 @@
1
- ---
2
- name: micro-dynamic-configs
3
- description: Code generation and contribution rules for @hile/micro-dynamic-configs. Use when editing this package or when the user asks about dynamic config patterns or API.
4
- ---
5
-
6
- # @hile/micro-dynamic-configs
7
-
8
- 面向 AI 编码模型与维护者的代码生成规范。阅读后应能正确使用本库编写符合架构规则的代码。
9
-
10
- ---
11
-
12
- ## 1. 架构总览
13
-
14
- 三个角色,通过 `@hile/micro` 的 WebSocket 通信:
15
-
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 ────────►│
27
- ```
28
-
29
- - **Config Server** (`MicroDynamicConfigsServer`) — 持有配置数据,管理订阅者列表,推送变更
30
- - **Subscriber** (`MicroDynamicConfigClients`) — 订阅某个 namespace 的配置字段,接收实时推送
31
- - **Publisher** — 调用 `server.save()` 修改配置,数据流向是:validate → Redis 持久化 → 内存更新 → 推送通知
32
-
33
- ---
34
-
35
- ## 2. 类型签名
36
-
37
- ### MicroDynamicConfigsServer
38
-
39
- ```typescript
40
- class MicroDynamicConfigsServer<
41
- T extends Application,
42
- Z extends ZodObject<ZodRawShape>
43
- > extends EventEmitter
44
-
45
- constructor(options: {
46
- app: T // Application 实例
47
- redis: Redis // ioredis 实例
48
- schema: Z // Zod schema 定义配置结构
49
- redis_key: string // Redis 存储键名
50
- })
51
-
52
- // 从 Redis 加载持久化数据,返回 teardown 函数
53
- initialize(): Promise<() => void>
54
-
55
- // 持久化并推送变更
56
- save(value: Partial<z.infer<Z>>): Promise<number>
57
-
58
- // 当前配置快照(只读)
59
- get value(): z.infer<Z>
60
- ```
61
-
62
- ### MicroDynamicConfigClients
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
- ```
86
-
87
- ---
88
-
89
- ## 3. 通信协议
90
-
91
- ### 路由端点
92
-
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 }` | — |
98
-
99
- ### 订阅流程
100
-
101
- 1. 客户端通过 Registry 发现目标 namespace 的 `host:port`
102
- 2. 建立 WebSocket 连接
103
- 3. 发送 subscribe 请求,服务端注册并返回初始值
104
- 4. 服务端数据变更时,遍历 `stacks` 中该字段的订阅者,逐个 push
105
- 5. 客户端收到 push 后更新本地缓存
106
-
107
- ### 退订与断连
108
-
109
- - 显式退订:调用 `close()` → 发送 unsubscribe 请求
110
- - 被动断连:WebSocket close → 服务端 `onClientDisconnect` 清理所有 `stacks`
111
- - 客户端断连后 `_status = 0`,下次 `getValue()` 重新走完整订阅流程
112
-
113
- ---
114
-
115
- ## 4. 代码生成强制规则
116
-
117
- ### 4.1 save() 必须分两轮执行
118
-
119
- ```typescript
120
- // ✓ 正确:先 validate 再 apply
121
- // Pass 1: validate & diff
122
- const entries = [];
123
- for (const key of keys) {
124
- const parsed = schema.parse(value[key]);
125
- if (!deepEqual(old, parsed)) entries.push({ key, parsed });
126
- }
127
- // Pass 2: persist → memory → emit
128
- await redis.set(key, JSON.stringify(next));
129
- update memory;
130
- emit events;
131
-
132
- // ✗ 禁止:validate 和 mutate 混在一轮
133
- for (const key of keys) {
134
- this._value[key] = parsed; // ❌ 后续字段失败时 _value 已脏
135
- }
136
- ```
137
-
138
- ### 4.2 客户端状态机
139
-
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
- ```
156
-
157
- - `close()` 设 `_closed = true`,pending subscribe 完成时检测标记并 dispose 连接
158
- - `close()` 后 `getValue()` 直接 reject
159
-
160
- ### 4.3 生命周期
161
-
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(); // 清理所有订阅连接
173
- ```
174
-
175
- ---
176
-
177
- ## 5. 反模式(禁止)
178
-
179
- ### 5.1 save() 中字段校验失败后保留脏状态
180
-
181
- ```typescript
182
- // ✗
183
- for (const key of keys) {
184
- this._value[key] = parsed; // 后续失败 → 脏数据
185
- }
186
- await redis.set(...);
187
-
188
- // ✓ 先全部校验,再统一写入
189
- ```
190
-
191
- ### 5.2 close() 前不检查 pending subscribe
192
-
193
- ```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;
205
- }
206
- ```
207
-
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
- ---
217
-
218
- ## 6. 边界条件清单
219
-
220
- - [ ] `save()` 传入空对象 `{}` — 返回 0,无操作
221
- - [ ] `save()` 传入 schema 中不存在的字段 — 跳过,不报错
222
- - [ ] `save()` 中单个字段校验失败 — 整个操作 reject,`_value` 不变
223
- - [ ] `save()` 中所有字段值与当前一致 — 返回 0,不写 Redis 不推送
224
- - [ ] `save()` Redis 写入失败 — throw,`_value` 不变
225
- - [ ] `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` 事件,订阅者逐个收到