@hile/micro 1.0.9 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # @hile/micro
2
2
 
3
- 基于 `@hile/message-loader` 与 `@hile/message-ws` 的轻量级 **WebSocket 微服务框架**。提供服务注册与发现、心跳保活、熔断、请求超时、自动重试、调用链路追踪等功能。
3
+ 基于 `@hile/message-loader` 与 `@hile/message-ws` 的轻量级 **WebSocket 微服务框架**。提供服务注册与发现、心跳保活、熔断、请求超时、自动重试等功能。
4
4
 
5
5
  ## 架构分层
6
6
 
@@ -16,7 +16,7 @@ MessageLoader (路由) + MessageWs (请求/响应传输)
16
16
  | **Server** | WebSocket 监听、连接管理、消息路由。不关心注册中心 |
17
17
  | **Client** | 远端 Server 的代理,提供 `request()` / `push()` 通信接口 |
18
18
  | **Registry** | 注册中心。维护 namespace → 实例列表,心跳检测剔除死实例 |
19
- | **Application** | 应用服务。集成注册发现、熔断、重试、追踪等功能 |
19
+ | **Application** | 应用服务。集成注册发现、熔断、重试等功能 |
20
20
 
21
21
  一个 `Application` 实例 **同时** 扮演 provider(`register` 暴露接口)和 consumer(`get` / `call` 调用其它服务)。
22
22
 
@@ -176,28 +176,6 @@ await app.call('svc', '/api', data, 5000, 0); // 超时 5s, 不重试
176
176
 
177
177
  重试策略:失败 → `recordFailure`(peer 被排除)→ 递归 `call(retries-1)` → `getActiveExcludes` 排除已失败的 peer → Registry `/‑/find` 返回其他 peer。
178
178
 
179
- ### Correlation ID 链路追踪
180
-
181
- `call()` 自动为每次调用注入唯一 `_correlationId`:
182
-
183
- ```typescript
184
- provider.register('/api', async ({ data }) => {
185
- console.log(data._correlationId); // 自动注入的 UUID
186
- });
187
- ```
188
-
189
- 行为规则:
190
-
191
- | 入参 data | 结果 |
192
- |-----------|------|
193
- | `null / undefined` | 包装为 `{ _correlationId, data: null }` |
194
- | 字符串 / 数字 | 包装为 `{ _correlationId, data: '原始值' }` |
195
- | 数组 | 包装为 `{ _correlationId, data: [原始数组] }` |
196
- | `{ value: 1 }` (无 `_correlationId`) | 扩展为 `{ value: 1, _correlationId: 'uuid' }` |
197
- | `{ _correlationId: 'trace-1' }` | 保留已有 ID,**不覆盖** |
198
-
199
- > **注意:** 原 data 对象不会被修改(使用浅拷贝 `{ ...data, _correlationId }`)。
200
-
201
179
  ### 健康检查
202
180
 
203
181
  每个 `Application` 自动注册 `/-/health` 端点:
@@ -380,7 +358,7 @@ class Application extends Server {
380
358
  // 获取 namespace 对应的远端 Client(缓存 + 自动发现)
381
359
  get(namespace: string, exclude?: string[]): Promise<Client>;
382
360
 
383
- // 一站式调用:get + request + response + 熔断 + 重试 + 追踪
361
+ // 一站式调用:get + request + response + 熔断 + 重试
384
362
  call<T = any>(
385
363
  namespace: string,
386
364
  url: string,
package/SKILL.md CHANGED
@@ -30,7 +30,7 @@ description: Code generation and contribution rules for @hile/micro. Use when ed
30
30
  | `Server` | `server.ts` | WebSocketServer 生命周期, 出入站连接, Client Map | 不感知 Registry |
31
31
  | `Client` | `client.ts` | 远端 Server 的 WebSocket 会话代理 | `dispose()` 必须关闭底层 socket |
32
32
  | `Registry` | `registry.ts` | namespace → Set\<host:port\>, 心跳检测, /-/find 随机返回, 环境变量管理 | 自动创建 `~/.registry/` 工作目录 |
33
- | `Application` | `application.ts` | 注册发现 + 熔断 + 重试 + 追踪 + 心跳 + 远程环境变量读取 | `listen()` 后自动连 Registry |
33
+ | `Application` | `application.ts` | 注册发现 + 熔断 + 重试 + 心跳 + 远程环境变量读取 | `listen()` 后自动连 Registry |
34
34
 
35
35
  ### 应用模型
36
36
 
@@ -142,7 +142,7 @@ export class Application extends Server {
142
142
  listen(port: number): Promise<() => Promise<void>>; // 自动连 Registry + 启心跳
143
143
 
144
144
  get(namespace: string, exclude?: string[]): Promise<Client>;
145
- // call() = get + request + response + correlationId + 熔断 + 重试 + 超时
145
+ // call() = get + request + response + 熔断 + 重试 + 超时
146
146
  call<T = any>(
147
147
  namespace: string,
148
148
  url: string,
@@ -226,27 +226,7 @@ peer 首次失败
226
226
 
227
227
  **冷卻期:** `CB_COOLDOWN_MS = 30000` (30 秒)。到期后 `getActiveExcludes` 自动清除旧条目。
228
228
 
229
- ### 3.3 Correlation ID
230
-
231
- `call()` 自动处理 `_correlationId`:
232
-
233
- ```typescript
234
- // 非对象/假值/数组 → 包装
235
- !data || typeof data !== 'object' || Array.isArray(data)
236
- → data = { _correlationId: randomUUID(), data }
237
-
238
- // 对象无 _correlationId → 浅拷贝注入
239
- else if (!data._correlationId)
240
- → data = { ...data, _correlationId: randomUUID() }
241
-
242
- // 对象已有 _correlationId → 保留(透传)
243
- ```
244
-
245
- - 使用 `import { randomUUID } from 'node:crypto'`(Node >= 14.17)
246
- - 永远不修改原始 data 对象(浅拷贝 `{ ...data }`)
247
- - retry 递归时 data 已包含 `_correlationId`,自动透传
248
-
249
- ### 3.4 请求超时
229
+ ### 3.3 请求超时
250
230
 
251
231
  **配置链:**
252
232
 
@@ -547,8 +527,6 @@ pnpm --filter @hile/micro test # 必须全部通过
547
527
  | call() 基本调用 | `circuit breaker > call() returns data on success` |
548
528
  | 熔断排除 | `circuit breaker > excludes a failing peer and selects a different one` |
549
529
  | 全排除重置 | `circuit breaker > resets breaker when all peers are excluded` |
550
- | Correlation ID 注入 | `correlation ID > injects _correlationId into call() data` |
551
- | Correlation ID 透传 | `correlation ID > preserves existing _correlationId` |
552
530
  | 健康检查 | `health endpoint > /-/health returns status and registry state` |
553
531
  | 超时 reject | `request timeout > rejects when request exceeds the timeout` |
554
532
  | 超时充足则成功 | `request timeout > succeeds when timeout is long enough` |
@@ -585,9 +563,8 @@ pnpm --filter @hile/micro test # 必须全部通过
585
563
  3. **不要在 `Client.dispose()` 中删除 `socket.close()`**(会导致 WebSocketServer.close 长时间等待)
586
564
  4. **不要假设 `host:port` 可无损表达 IPv6** — 使用 `[IPv6]:port` 格式,`parseAddressKey` 按最后一个 `:` 切分
587
565
  5. **不要传错 Registry 端口** — 丢失 Registry 连接时依赖 `reconnectToRegistry`,不要在外部缓存 registry Client
588
- 6. **不要在 call() 中修改原始 data 对象**必须使用浅拷贝 `{ ...data, _correlationId }`
589
- 7. **不要在其他文件中重复 Registry helper 函数** — `selectRandomRegistryAddress`、`parseAddressKey`、`getRegistryConfigsDir`、`namespaceToConfigFile`、`parseConfigFilename` 都在 `registry.ts` 中导出复用
590
- 8. **不要给 call() 增加非可选参数** — `timeout` 和 `retries` 都在尾部且保持可选,不影响现有调用
566
+ 6. **不要在其他文件中重复 Registry helper 函数** — `selectRandomRegistryAddress`、`parseAddressKey`、`getRegistryConfigsDir`、`namespaceToConfigFile`、`parseConfigFilename` 都在 `registry.ts` 中导出复用
567
+ 7. **不要给 call() 增加非可选参数** — `timeout` `retries` 都在尾部且保持可选,不影响现有调用
591
568
 
592
569
  ---
593
570
 
@@ -617,7 +594,7 @@ pnpm --filter @hile/micro test # 必须全部通过
617
594
  |------|------|
618
595
  | `packages/micro/src/registry.ts` | 注册中心(含配置管理、路径工具函数) |
619
596
  | `packages/micro/src/application.ts` | 应用服务(含 getEnvVariables) |
620
- | `packages/micro/src/index.test.ts` | 主测试文件(28 个用例) |
597
+ | `packages/micro/src/index.test.ts` | 主测试文件(27 个用例) |
621
598
  | `packages/micro/src/env-config.test.ts` | 配置管理测试文件(13 个用例) |
622
599
  | `packages/cli/src/index.ts` | CLI 入口(含 registry configs 子命令组) |
623
600
  | `packages/cli/src/configs.ts` | CLI 配置管理 handler(list/get/set/del) |
@@ -36,6 +36,8 @@ export declare class Application extends Server {
36
36
  private readonly namespaces;
37
37
  private static readonly CB_COOLDOWN_MS;
38
38
  private readonly circuitBreakers;
39
+ private readonly fallbacks;
40
+ private readonly topics;
39
41
  constructor(props: ApplicationProps);
40
42
  listen(port?: number): Promise<() => Promise<void>>;
41
43
  private scheduleRegistryRetry;
@@ -45,7 +47,20 @@ export declare class Application extends Server {
45
47
  private getActiveExcludes;
46
48
  private findFromRegistry;
47
49
  get(namespace: string, exclude?: string[]): Promise<Client>;
48
- call<T = any>(namespace: string, url: string, data: any, timeout?: number, retries?: number): Promise<T>;
49
- getEnvVariables<T extends Record<string, Record<string, any>>, const Requests extends readonly EnvRequest<T>[] = readonly EnvRequest<T>[]>(...data: Requests): Promise<GetEnvVariablesResult<T, Requests>>;
50
+ call<T = any>(namespace: string, url: string, data: any, options?: {
51
+ timeout?: number;
52
+ retries?: number;
53
+ signal?: AbortSignal;
54
+ }): Promise<T>;
55
+ stream(namespace: string, url: string, data: any, options?: {
56
+ signal?: AbortSignal;
57
+ retries?: number;
58
+ }): Promise<import('stream').Readable>;
59
+ publish<T = any>(topic: string, data: T): Promise<{
60
+ update: (payload: T) => Promise</*elided*/ any>;
61
+ unpublish: () => Promise</*elided*/ any>;
62
+ }>;
63
+ /** 对同一 topic 重复 subscribe 是幂等的:第二次调用只返回 unsubscribe 函数,不会注册第二个 callback */
64
+ subscribe<T = any>(topic: string, callback: (data: T) => any, isReconnect?: boolean): Promise<() => Promise<void>>;
50
65
  }
51
66
  export {};
@@ -1,4 +1,3 @@
1
- import { randomUUID } from 'node:crypto';
2
1
  import { Server } from './server.js';
3
2
  var RegistryLookupStatus;
4
3
  (function (RegistryLookupStatus) {
@@ -48,6 +47,8 @@ export class Application extends Server {
48
47
  namespaces = new Map();
49
48
  static CB_COOLDOWN_MS = 30_000;
50
49
  circuitBreakers = new Map();
50
+ fallbacks = new Set();
51
+ topics = new Map();
51
52
  constructor(props) {
52
53
  const { namespace, registry, registryLookupTimeoutMs = 10_000, requestTimeoutMs = 30_000, ...microAndLoader } = props;
53
54
  super(namespace, microAndLoader);
@@ -55,11 +56,15 @@ export class Application extends Server {
55
56
  this._registry_address = registry;
56
57
  this._registryLookupTimeoutMs = registryLookupTimeoutMs;
57
58
  this._requestTimeoutMs = requestTimeoutMs;
58
- this.register('/-/health', async () => ({
59
+ this.fallbacks.add(this.register('/-/health', async () => ({
59
60
  status: 'ok',
60
61
  registry: !!this.registry,
61
62
  uptime: process.uptime(),
62
63
  namespaces: [...this.namespaces.keys()],
64
+ })));
65
+ this.fallbacks.add(this.register('/-/topic/update', async ({ data }) => {
66
+ this.events.emit('topic:' + data.topic, data.payload);
67
+ return Date.now();
63
68
  }));
64
69
  }
65
70
  async listen(port = 0) {
@@ -77,7 +82,13 @@ export class Application extends Server {
77
82
  }
78
83
  throw err;
79
84
  }
85
+ // 这里不清理 topics 由业务方自己清理
86
+ // 这里也不清理 declare 和 undeclare 由业务方自己清理
80
87
  return async () => {
88
+ for (const fallback of this.fallbacks) {
89
+ fallback();
90
+ }
91
+ this.fallbacks.clear();
81
92
  this.stopped = true;
82
93
  if (this.reconnectTimeout) {
83
94
  clearTimeout(this.reconnectTimeout);
@@ -125,6 +136,10 @@ export class Application extends Server {
125
136
  });
126
137
  });
127
138
  this.registry = registry;
139
+ // 重新订阅所有 topic
140
+ for (const [topic, callback] of this.topics) {
141
+ await this.subscribe(topic, callback, true);
142
+ }
128
143
  })().finally(() => {
129
144
  this.registryReconnectPromise = undefined;
130
145
  });
@@ -167,8 +182,8 @@ export class Application extends Server {
167
182
  async findFromRegistry(namespace, exclude) {
168
183
  if (!this.registry)
169
184
  throw new Error('Registry not found');
170
- const { response } = this.registry.request('/-/find', { namespace, exclude });
171
- return await withTimeout(response(), this._registryLookupTimeoutMs, 'Registry /-/find');
185
+ const promise = this.registry.request('/-/find', { namespace, exclude });
186
+ return await withTimeout(promise, this._registryLookupTimeoutMs, 'Registry /-/find');
172
187
  }
173
188
  get(namespace, exclude) {
174
189
  if (!this.namespaces.has(namespace)) {
@@ -239,14 +254,35 @@ export class Application extends Server {
239
254
  }
240
255
  });
241
256
  }
242
- async call(namespace, url, data, timeout, retries = 1) {
243
- // Inject or preserve correlation ID (no mutation of original data)
244
- if (!data || typeof data !== 'object' || Array.isArray(data)) {
245
- data = { _correlationId: randomUUID(), data };
257
+ async call(namespace, url, data, options) {
258
+ const { timeout = this._requestTimeoutMs, retries = 1, signal } = options || {};
259
+ const exclude = this.getActiveExcludes(namespace);
260
+ let client;
261
+ try {
262
+ client = await this.get(namespace, exclude);
246
263
  }
247
- else if (!data._correlationId) {
248
- data = { ...data, _correlationId: randomUUID() };
264
+ catch {
265
+ this.circuitBreakers.delete(namespace);
266
+ client = await this.get(namespace);
249
267
  }
268
+ try {
269
+ const result = await client.request(url, data, {
270
+ timeout: timeout ?? this._requestTimeoutMs,
271
+ signal,
272
+ });
273
+ this.recordSuccess(namespace, client.host, client.port);
274
+ return result;
275
+ }
276
+ catch (err) {
277
+ this.recordFailure(namespace, client.host, client.port);
278
+ if (retries > 0) {
279
+ return this.call(namespace, url, data, { timeout, retries: retries - 1, signal });
280
+ }
281
+ throw err;
282
+ }
283
+ }
284
+ async stream(namespace, url, data, options) {
285
+ const { signal, retries = 1 } = options || {};
250
286
  const exclude = this.getActiveExcludes(namespace);
251
287
  let client;
252
288
  try {
@@ -257,28 +293,60 @@ export class Application extends Server {
257
293
  client = await this.get(namespace);
258
294
  }
259
295
  try {
260
- const { response } = client.request(url, data, timeout ?? this._requestTimeoutMs);
261
- const result = await response();
296
+ const readable = client.stream(url, data, { signal });
262
297
  this.recordSuccess(namespace, client.host, client.port);
263
- return result;
298
+ return readable;
264
299
  }
265
300
  catch (err) {
266
301
  this.recordFailure(namespace, client.host, client.port);
267
302
  if (retries > 0) {
268
- return this.call(namespace, url, data, timeout, retries - 1);
303
+ return this.stream(namespace, url, data, { signal, retries: retries - 1 });
269
304
  }
270
305
  throw err;
271
306
  }
272
307
  }
273
- async getEnvVariables(...data) {
308
+ async publish(topic, data) {
274
309
  if (!this.registry)
275
310
  throw new Error('Registry not found');
276
- const { response } = this.registry.request('/-/env/variables', data);
277
- const configs = await response();
278
- const out = {};
279
- for (const { namespace, value } of configs) {
280
- out[namespace] = value;
311
+ await this.registry.request('/-/declare', { topic, payload: data });
312
+ const ref = {
313
+ update: async (payload) => {
314
+ if (!this.registry)
315
+ throw new Error('Registry not found');
316
+ await this.registry.request('/-/topic/update', { topic, payload });
317
+ return ref;
318
+ },
319
+ unpublish: async () => {
320
+ if (!this.registry)
321
+ throw new Error('Registry not found');
322
+ await this.registry.request('/-/undeclare', { topic });
323
+ return ref;
324
+ }
325
+ };
326
+ return ref;
327
+ }
328
+ /** 对同一 topic 重复 subscribe 是幂等的:第二次调用只返回 unsubscribe 函数,不会注册第二个 callback */
329
+ async subscribe(topic, callback, isReconnect = false) {
330
+ if (!this.registry)
331
+ throw new Error('Registry not found');
332
+ const fallback = async () => {
333
+ if (!this.registry)
334
+ throw new Error('Registry not found');
335
+ await this.registry.request('/-/unsubscribe', { topic });
336
+ if (this.topics.has(topic)) {
337
+ const _callback = this.topics.get(topic);
338
+ this.events.off('topic:' + topic, _callback);
339
+ this.topics.delete(topic);
340
+ }
341
+ };
342
+ if (this.topics.has(topic) && !isReconnect)
343
+ return fallback;
344
+ const payload = await this.registry.request('/-/subscribe', { topic });
345
+ if (!isReconnect) {
346
+ this.events.on('topic:' + topic, callback);
347
+ this.topics.set(topic, callback);
348
+ callback(payload);
281
349
  }
282
- return out;
350
+ return fallback;
283
351
  }
284
352
  }
package/dist/client.d.ts CHANGED
@@ -24,10 +24,16 @@ export declare class Client extends MessageWs {
24
24
  url: string;
25
25
  data: any;
26
26
  }): Promise<any>;
27
- request(url: string, data: any, timeout?: number): {
28
- abort: () => void;
29
- response: <U = any>() => Promise<U>;
30
- };
31
- push(url: string, data: any, timeout?: number): void;
27
+ request<T = any>(url: string, data: any, options?: {
28
+ timeout?: number;
29
+ signal?: AbortSignal;
30
+ }): Promise<T>;
31
+ push(url: string, data: any, options?: {
32
+ timeout?: number;
33
+ signal?: AbortSignal;
34
+ }): void;
35
+ stream(url: string, data: any, options?: {
36
+ signal?: AbortSignal;
37
+ }): import("node:stream").Readable;
32
38
  dispose(): void;
33
39
  }
package/dist/client.js CHANGED
@@ -52,15 +52,20 @@ export class Client extends MessageWs {
52
52
  client: this,
53
53
  });
54
54
  }
55
- request(url, data, timeout) {
55
+ request(url, data, options) {
56
56
  if (!this._online)
57
57
  throw new Error('Client is not online');
58
- return this._send({ url, data }, timeout);
58
+ return this._send({ url, data }, options);
59
59
  }
60
- push(url, data, timeout) {
60
+ push(url, data, options) {
61
61
  if (!this._online)
62
62
  throw new Error('Client is not online');
63
- return this._push({ url, data }, timeout);
63
+ return this._push({ url, data }, options);
64
+ }
65
+ stream(url, data, options) {
66
+ if (!this._online)
67
+ throw new Error('Client is not online');
68
+ return this._stream({ url, data }, options);
64
69
  }
65
70
  dispose() {
66
71
  if (this.heartbeatTimer)
@@ -15,15 +15,19 @@ export declare function namespaceToConfigFile(ns: string): string;
15
15
  export declare function parseConfigFilename(filename: string): string | null;
16
16
  export declare class Registry extends Server {
17
17
  private readonly namespaces;
18
- private unregisterFind?;
19
18
  private readonly workspace;
20
19
  private readonly configFileSuffix;
21
20
  private readonly configs;
21
+ private readonly fallbacks;
22
+ private readonly topics;
22
23
  constructor(props?: MicroServerProps);
23
24
  watchEnvFile(): import("node:fs").FSWatcher | undefined;
24
25
  listen(port?: number): Promise<() => Promise<void>>;
25
- /** 幂等:重复调用会先注销上一条 `/-/find` 再注册,避免叠多条路由 */
26
- onFind(): void;
27
- private mountFindHandler;
28
- private registerEnvVariables;
26
+ private registerFindApplication;
27
+ private registerDeclare;
28
+ private registerUndeclare;
29
+ private registerSubscribe;
30
+ private registerUnsubscribe;
31
+ private registerReceiveTopicUpdate;
32
+ private publish;
29
33
  }
package/dist/registry.js CHANGED
@@ -41,10 +41,11 @@ export function parseConfigFilename(filename) {
41
41
  }
42
42
  export class Registry extends Server {
43
43
  namespaces = new Map();
44
- unregisterFind;
45
44
  workspace;
46
45
  configFileSuffix = '.config.yaml';
47
46
  configs = new Map();
47
+ fallbacks = new Set();
48
+ topics = new Map();
48
49
  constructor(props = {}) {
49
50
  const workspace = resolve(homedir(), '.registry');
50
51
  if (!existsSync(workspace)) {
@@ -62,6 +63,17 @@ export class Registry extends Server {
62
63
  });
63
64
  this.events.on('disconnect', (client, extras) => {
64
65
  const key = client.host + ':' + client.port;
66
+ // 清理 topic 中的关联
67
+ for (const [topic, { publishers, subscribers }] of this.topics) {
68
+ if (publishers.has(key))
69
+ publishers.delete(key);
70
+ if (subscribers.has(key))
71
+ subscribers.delete(key);
72
+ if (publishers.size === 0 && subscribers.size === 0) {
73
+ this.topics.delete(topic);
74
+ }
75
+ }
76
+ // 清理 namespace 中的关联
65
77
  const namespace = extras.join('/');
66
78
  if (this.namespaces.has(namespace)) {
67
79
  const keys = this.namespaces.get(namespace);
@@ -73,8 +85,12 @@ export class Registry extends Server {
73
85
  }
74
86
  }
75
87
  });
76
- this.mountFindHandler();
77
- this.registerEnvVariables();
88
+ this.registerFindApplication();
89
+ this.registerDeclare();
90
+ this.registerUndeclare();
91
+ this.registerSubscribe();
92
+ this.registerUnsubscribe();
93
+ this.registerReceiveTopicUpdate();
78
94
  }
79
95
  watchEnvFile() {
80
96
  const configFile = resolve(this.workspace, 'configs');
@@ -86,18 +102,42 @@ export class Registry extends Server {
86
102
  const config = YAML.parse(readFileSync(resolve(configFile, filename), 'utf8'));
87
103
  if (typeof config !== 'object' || config === null)
88
104
  continue;
89
- this.configs.set(parseConfigFilename(filename), config);
105
+ const namespace = parseConfigFilename(filename);
106
+ this.configs.set(namespace, config);
107
+ const keys = Object.keys(config);
108
+ for (const key of keys) {
109
+ const _key = `registry:${namespace}/${key}`;
110
+ if (!this.topics.has(_key)) {
111
+ this.topics.set(_key, { publishers: new Set(), subscribers: new Set(), data: config[key] });
112
+ }
113
+ }
90
114
  }
91
115
  catch { }
92
116
  }
93
117
  return watch(configFile, (_, filename) => {
94
118
  if (!filename?.endsWith(this.configFileSuffix))
95
119
  return;
120
+ const fullPath = resolve(configFile, filename);
121
+ if (!existsSync(fullPath)) {
122
+ const namespace = parseConfigFilename(filename);
123
+ this.configs.delete(namespace);
124
+ return;
125
+ }
96
126
  try {
97
- const config = YAML.parse(readFileSync(resolve(configFile, filename), 'utf8'));
127
+ const config = YAML.parse(readFileSync(fullPath, 'utf8'));
98
128
  if (typeof config !== 'object' || config === null)
99
129
  return;
100
- this.configs.set(parseConfigFilename(filename), config);
130
+ const namespace = parseConfigFilename(filename);
131
+ this.configs.set(namespace, config);
132
+ const keys = Object.keys(config);
133
+ // 只加不减
134
+ for (const key of keys) {
135
+ const _key = `registry:${namespace}/${key}`;
136
+ if (!this.topics.has(_key)) {
137
+ this.topics.set(_key, { publishers: new Set(), subscribers: new Set(), data: config[key] });
138
+ }
139
+ this.publish(_key, config[key]);
140
+ }
101
141
  }
102
142
  catch { /* vim 替换文件时的中间态读错误,忽略 */ }
103
143
  });
@@ -112,19 +152,15 @@ export class Registry extends Server {
112
152
  return async () => {
113
153
  if (watcher)
114
154
  watcher.close();
155
+ for (const fallback of this.fallbacks) {
156
+ fallback();
157
+ }
158
+ this.fallbacks.clear();
115
159
  await teardown();
116
160
  };
117
161
  }
118
- /** 幂等:重复调用会先注销上一条 `/-/find` 再注册,避免叠多条路由 */
119
- onFind() {
120
- this.mountFindHandler();
121
- }
122
- mountFindHandler() {
123
- if (this.unregisterFind) {
124
- this.unregisterFind();
125
- this.unregisterFind = undefined;
126
- }
127
- this.unregisterFind = this.register('/-/find', async ({ data }) => {
162
+ registerFindApplication() {
163
+ this.fallbacks.add(this.register('/-/find', async ({ data }) => {
128
164
  const namespace = data.namespace;
129
165
  let keys = this.namespaces.get(namespace);
130
166
  if (!keys)
@@ -137,23 +173,92 @@ export class Registry extends Server {
137
173
  keys = new Set(filtered);
138
174
  }
139
175
  return selectRandomRegistryAddress(keys);
140
- });
176
+ }));
177
+ }
178
+ registerDeclare() {
179
+ this.fallbacks.add(this.register('/-/declare', async ({ data, client }) => {
180
+ const key = `${client.host}:${client.port}`;
181
+ if (!this.topics.has(data.topic)) {
182
+ this.topics.set(data.topic, { publishers: new Set(), subscribers: new Set(), data: data.payload });
183
+ }
184
+ const entry = this.topics.get(data.topic);
185
+ const publishers = entry.publishers;
186
+ entry.data = data.payload;
187
+ publishers.add(key);
188
+ this.publish(data.topic, data.payload);
189
+ return Date.now();
190
+ }));
141
191
  }
142
- registerEnvVariables() {
143
- this.register('/-/env/variables', async ({ data }) => {
144
- return data.map(({ namespace, fields }) => {
145
- if (!this.configs.has(namespace)) {
146
- return { namespace, value: null };
192
+ registerUndeclare() {
193
+ this.fallbacks.add(this.register('/-/undeclare', async ({ data, client }) => {
194
+ const key = `${client.host}:${client.port}`;
195
+ if (!this.topics.has(data.topic))
196
+ return 0;
197
+ const entry = this.topics.get(data.topic);
198
+ const publishers = entry.publishers;
199
+ const subscribers = entry.subscribers;
200
+ const i = publishers.size;
201
+ if (publishers.has(key)) {
202
+ publishers.delete(key);
203
+ if (publishers.size === 0 && subscribers.size === 0) {
204
+ this.topics.delete(data.topic);
147
205
  }
148
- if (!fields?.length)
149
- return { namespace, value: this.configs.get(namespace) };
150
- const config = this.configs.get(namespace);
151
- const value = {};
152
- for (const field of fields) {
153
- value[field] = config[field];
206
+ }
207
+ return i - publishers.size;
208
+ }));
209
+ }
210
+ registerSubscribe() {
211
+ this.fallbacks.add(this.register('/-/subscribe', async ({ data, client }) => {
212
+ const key = `${client.host}:${client.port}`;
213
+ if (!this.topics.has(data.topic)) {
214
+ this.topics.set(data.topic, { publishers: new Set(), subscribers: new Set(), data: undefined });
215
+ }
216
+ const entry = this.topics.get(data.topic);
217
+ const subscribers = entry.subscribers;
218
+ subscribers.add(key);
219
+ return entry.data;
220
+ }));
221
+ }
222
+ registerUnsubscribe() {
223
+ this.fallbacks.add(this.register('/-/unsubscribe', async ({ data, client }) => {
224
+ const key = `${client.host}:${client.port}`;
225
+ if (!this.topics.has(data.topic))
226
+ return 0;
227
+ const entry = this.topics.get(data.topic);
228
+ const subscribers = entry.subscribers;
229
+ const publishers = entry.publishers;
230
+ const i = subscribers.size;
231
+ if (subscribers.has(key)) {
232
+ subscribers.delete(key);
233
+ if (subscribers.size === 0 && publishers.size === 0) {
234
+ this.topics.delete(data.topic);
154
235
  }
155
- return { namespace, value };
156
- });
157
- });
236
+ }
237
+ return i - subscribers.size;
238
+ }));
239
+ }
240
+ registerReceiveTopicUpdate() {
241
+ this.fallbacks.add(this.register('/-/topic/update', async ({ data }) => {
242
+ // 转发
243
+ this.publish(data.topic, data.payload);
244
+ return Date.now();
245
+ }));
246
+ }
247
+ publish(topic, payload) {
248
+ if (!this.topics.has(topic))
249
+ return;
250
+ const entry = this.topics.get(topic);
251
+ const subscribers = entry.subscribers;
252
+ entry.data = payload;
253
+ for (const key of subscribers.values()) {
254
+ try {
255
+ if (this.clients.has(key)) {
256
+ this.clients.get(key).push(`/-/topic/update`, { topic, payload });
257
+ }
258
+ }
259
+ catch {
260
+ // 推送失败,disconnect 事件中会清理
261
+ }
262
+ }
158
263
  }
159
264
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hile/micro",
3
- "version": "1.0.9",
3
+ "version": "2.0.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
@@ -23,11 +23,11 @@
23
23
  "vitest": "^4.0.18"
24
24
  },
25
25
  "dependencies": {
26
- "@hile/message-loader": "^1.0.8",
27
- "@hile/message-ws": "^1.0.6",
26
+ "@hile/message-loader": "^1.0.9",
27
+ "@hile/message-ws": "^2.0.0",
28
28
  "internal-ip": "^9.0.0",
29
29
  "ws": "^8.19.0",
30
30
  "yaml": "^2.9.0"
31
31
  },
32
- "gitHead": "fa7d6d442ab080e6a395ee2a1dacac9eae340cf6"
32
+ "gitHead": "d28b20bc22ee08c45cbf4596672705cb96e8e461"
33
33
  }