@hile/micro 3.0.1 → 3.0.3

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/AI.md CHANGED
@@ -186,6 +186,20 @@ import { MessageWorkerThread } from '@hile/message-worker-thread'
186
186
  - Registry is started before application nodes need discovery.
187
187
  - Micro apps use stable namespaces and advertise reachable hosts.
188
188
 
189
+ ## Registry Read APIs
190
+
191
+ `Registry` exposes read-only routes for diagnostics and admin tooling. These routes return
192
+ snapshots of current registry state; they do not change service discovery, topic declarations,
193
+ subscriptions, or retained config data.
194
+
195
+ - `/-/namespaces` returns all registered namespaces with peer counts and addresses.
196
+ - `/-/namespace/peers` accepts `{ namespace, exclude? }` and returns every peer for one namespace.
197
+ - `/-/registry/status` returns registry uptime and counts for clients, namespaces, topics, and config namespaces.
198
+ - `/-/topics` accepts `{ prefix? }` and returns topic summaries with publisher/subscriber counts and retained/data flags.
199
+ - `/-/topic/get` accepts `{ topic }` and returns the current topic payload snapshot when the topic exists.
200
+ - `/-/configs` returns loaded config namespaces and keys.
201
+ - `/-/config/get` accepts `{ namespace, key? }` and returns either a config snapshot or one config value.
202
+
189
203
 
190
204
 
191
205
  # Related Recipes
@@ -349,6 +363,122 @@ Use this recipe when config changes should persist to Redis and be pushed throug
349
363
  - Change handlers use `change:key`.
350
364
  - Cleanup from `initialize()` is registered.
351
365
 
366
+ # Stable Runtime Reload
367
+
368
+ ## Complete Example
369
+
370
+ ```ts
371
+ import { defineService, loadService } from '@hile/core'
372
+ import { Application } from '@hile/micro'
373
+ import { Http } from '@hile/http'
374
+ import { createConfigAggregator, createRuntimeReloader } from '@hile/reloader'
375
+ import appService from './micro.app.boot'
376
+ import httpService from './http.boot'
377
+
378
+ type RuntimeConfig = {
379
+ mysql: { host: string; port: number }
380
+ redis: { host: string; port: number }
381
+ flags: Record<string, boolean>
382
+ }
383
+
384
+ export default defineService('runtime.reload', async (shutdown) => {
385
+ const app = await loadService<Application>(appService)
386
+ const http = await loadService<Http>(httpService)
387
+
388
+ const reloader = createRuntimeReloader<RuntimeConfig, AppRuntime>({
389
+ debounceMs: 100,
390
+ normalize: config => ({
391
+ mysql: config.mysql,
392
+ redis: config.redis,
393
+ flags: config.flags,
394
+ }),
395
+ create: config => createAppRuntime(config),
396
+ dispose: runtime => runtime.close(),
397
+ onError: (error, context) => {
398
+ logger.error({ error, stage: context.stage }, 'runtime reload failed')
399
+ },
400
+ })
401
+
402
+ const configs = createConfigAggregator<RuntimeConfig>({
403
+ required: ['mysql', 'redis'],
404
+ defaults: { flags: {} },
405
+ debounceMs: 100,
406
+ onError: (error) => {
407
+ logger.error({ error }, 'runtime config emit failed')
408
+ },
409
+ })
410
+
411
+ configs.onChange(config => reloader.update(config))
412
+
413
+ const cleanupMysql = await app.subscribe('config:mysql', value => configs.set('mysql', value))
414
+ const cleanupRedis = await app.subscribe('config:redis', value => configs.set('redis', value))
415
+ const cleanupFlags = await app.subscribe('config:flags', value => configs.set('flags', value))
416
+
417
+ http.use(async (ctx, next) => {
418
+ const runtime = reloader.current()
419
+ if (!runtime) {
420
+ ctx.status = 503
421
+ ctx.body = { error: 'runtime is not ready' }
422
+ return
423
+ }
424
+ await runtime.handle(ctx, next)
425
+ })
426
+
427
+ shutdown(async () => {
428
+ await cleanupMysql()
429
+ await cleanupRedis()
430
+ await cleanupFlags()
431
+ configs.dispose()
432
+ await reloader.stop()
433
+ })
434
+
435
+ return reloader
436
+ })
437
+ ```
438
+
439
+ ## File Layout
440
+
441
+ ```text
442
+ src/services/runtime.reload.boot.ts
443
+ src/services/micro.app.boot.ts
444
+ src/services/http.boot.ts
445
+ ```
446
+
447
+ ## User Intent
448
+
449
+ Use this recipe when `app.subscribe()` receives config updates that should rebuild business runtime state without thrashing services or rebinding ports.
450
+
451
+ ## Packages To Use
452
+
453
+ - `@hile/reloader`
454
+ - `@hile/micro`
455
+ - `@hile/http`
456
+ - `@hile/core`
457
+
458
+ ## Implementation Steps
459
+
460
+ 1. Keep the HTTP listener outside the reloader.
461
+ 2. Create `RuntimeReloader` for the runtime object that can be safely swapped.
462
+ 3. Create `ConfigAggregator` for all required subscribe topics.
463
+ 4. Feed each `app.subscribe()` callback into `configs.set(key, value)`.
464
+ 5. Use `configs.onChange(config => reloader.update(config))`.
465
+ 6. Register subscribe cleanup, `configs.dispose()`, and `reloader.stop()` with shutdown.
466
+
467
+ ## Failure And Cleanup Behavior
468
+
469
+ - Config bursts collapse through aggregator debounce and reloader debounce.
470
+ - Reloads never run concurrently.
471
+ - If `create()` fails, the previous runtime stays active.
472
+ - Old runtime disposal happens after the new runtime becomes current.
473
+ - Same-port HTTP services are not restarted.
474
+
475
+ ## Verification Checklist
476
+
477
+ - Required config keys are received before the first runtime is created.
478
+ - Repeated equivalent configs do not reload.
479
+ - A failed create logs an error and leaves old traffic handling intact.
480
+ - The HTTP server is started once and reads `reloader.current()` per request.
481
+
352
482
 
353
483
 
354
484
  # Global Guardrails
@@ -7,6 +7,75 @@ export interface RegistryAddress {
7
7
  host: string;
8
8
  port: number;
9
9
  }
10
+ export interface RegistryNamespaceSnapshot {
11
+ namespace: string;
12
+ peerCount: number;
13
+ peers: RegistryAddress[];
14
+ }
15
+ export interface RegistryNamespacesResult {
16
+ namespaces: RegistryNamespaceSnapshot[];
17
+ }
18
+ export type RegistryNamespacePeersData = RegistryFindData;
19
+ export interface RegistryNamespacePeersResult {
20
+ namespace: string;
21
+ peers: RegistryAddress[];
22
+ }
23
+ export interface RegistryStatusSnapshot {
24
+ status: 'ok';
25
+ startedAt: number;
26
+ uptime: number;
27
+ clientCount: number;
28
+ namespaceCount: number;
29
+ topicCount: number;
30
+ configNamespaceCount: number;
31
+ }
32
+ export interface RegistryTopicsData {
33
+ prefix?: string;
34
+ }
35
+ export interface RegistryTopicSummary {
36
+ topic: string;
37
+ publisherCount: number;
38
+ subscriberCount: number;
39
+ hasData: boolean;
40
+ retained: boolean;
41
+ }
42
+ export interface RegistryTopicsResult {
43
+ topics: RegistryTopicSummary[];
44
+ }
45
+ export interface RegistryTopicGetData {
46
+ topic: string;
47
+ }
48
+ export interface RegistryTopicSnapshot extends RegistryTopicSummary {
49
+ payload: any;
50
+ }
51
+ export interface RegistryConfigSummary {
52
+ namespace: string;
53
+ keys: string[];
54
+ }
55
+ export interface RegistryConfigsResult {
56
+ configs: RegistryConfigSummary[];
57
+ }
58
+ export interface RegistryConfigGetData {
59
+ namespace: string;
60
+ key?: string;
61
+ }
62
+ export type RegistryConfigGetResult = {
63
+ namespace: string;
64
+ hasConfig: true;
65
+ config: any;
66
+ } | {
67
+ namespace: string;
68
+ hasConfig: false;
69
+ } | {
70
+ namespace: string;
71
+ key: string;
72
+ hasValue: true;
73
+ value: any;
74
+ } | {
75
+ namespace: string;
76
+ key: string;
77
+ hasValue: false;
78
+ };
10
79
  /** 将 `host:port` 或 `[ipv6]:port` 形式的 key 解析为地址(端口取最后一个 `:` 之后) */
11
80
  export declare function parseAddressKey(key: string): RegistryAddress | undefined;
12
81
  export declare function selectRandomRegistryAddress(keys: Iterable<string>): RegistryAddress | undefined;
@@ -21,7 +90,13 @@ export declare class Registry extends Server {
21
90
  private readonly fallbacks;
22
91
  private readonly topics;
23
92
  private topicRevision;
93
+ private readonly startedAt;
24
94
  constructor(props?: MicroServerProps);
95
+ private createNamespaceSnapshot;
96
+ private createTopicSummary;
97
+ private createTopicSnapshot;
98
+ private listTopicSummaries;
99
+ private registerReadApis;
25
100
  private cleanupTopicIfUnused;
26
101
  private clearTopicData;
27
102
  private rememberPublisherPayload;
package/dist/registry.js CHANGED
@@ -28,6 +28,28 @@ export function selectRandomRegistryAddress(keys) {
28
28
  const index = Math.floor(Math.random() * addresses.length);
29
29
  return addresses[index];
30
30
  }
31
+ function compareRegistryAddress(a, b) {
32
+ const byHost = a.host.localeCompare(b.host);
33
+ if (byHost !== 0)
34
+ return byHost;
35
+ return a.port - b.port;
36
+ }
37
+ function registryAddressesFromKeys(keys, exclude) {
38
+ const excludeSet = exclude?.length ? new Set(exclude) : undefined;
39
+ return Array.from(keys)
40
+ .filter(key => !excludeSet?.has(key))
41
+ .map(parseAddressKey)
42
+ .filter((address) => address !== undefined)
43
+ .sort(compareRegistryAddress);
44
+ }
45
+ function cloneRegistryValue(value) {
46
+ try {
47
+ return structuredClone(value);
48
+ }
49
+ catch {
50
+ return value;
51
+ }
52
+ }
31
53
  export function getRegistryConfigsDir() {
32
54
  return resolve(homedir(), '.registry', 'configs');
33
55
  }
@@ -81,6 +103,7 @@ export class Registry extends Server {
81
103
  fallbacks = new Set();
82
104
  topics = new Map();
83
105
  topicRevision = 0;
106
+ startedAt = Date.now();
84
107
  constructor(props = {}) {
85
108
  const workspace = resolve(homedir(), '.registry');
86
109
  if (!existsSync(workspace)) {
@@ -133,6 +156,112 @@ export class Registry extends Server {
133
156
  this.registerSubscribe();
134
157
  this.registerUnsubscribe();
135
158
  this.registerReceiveTopicUpdate();
159
+ this.registerReadApis();
160
+ }
161
+ createNamespaceSnapshot(namespace, keys) {
162
+ const peers = registryAddressesFromKeys(keys);
163
+ return {
164
+ namespace,
165
+ peerCount: peers.length,
166
+ peers,
167
+ };
168
+ }
169
+ createTopicSummary(topic, entry) {
170
+ return {
171
+ topic,
172
+ publisherCount: entry.publishers.size,
173
+ subscriberCount: entry.subscribers.size,
174
+ hasData: entry.hasData,
175
+ retained: entry.retained,
176
+ };
177
+ }
178
+ createTopicSnapshot(topic, entry) {
179
+ return {
180
+ ...this.createTopicSummary(topic, entry),
181
+ payload: cloneRegistryValue(entry.data),
182
+ };
183
+ }
184
+ listTopicSummaries(prefix) {
185
+ const hasPrefix = typeof prefix === 'string' && prefix.length > 0;
186
+ return [...this.topics.entries()]
187
+ .filter(([topic]) => !hasPrefix || topic.startsWith(prefix))
188
+ .sort(([a], [b]) => a.localeCompare(b))
189
+ .map(([topic, entry]) => this.createTopicSummary(topic, entry));
190
+ }
191
+ registerReadApis() {
192
+ this.fallbacks.add(this.register('/-/namespaces', async () => {
193
+ const namespaces = [...this.namespaces.entries()]
194
+ .sort(([a], [b]) => a.localeCompare(b))
195
+ .map(([namespace, keys]) => this.createNamespaceSnapshot(namespace, keys));
196
+ return { namespaces };
197
+ }));
198
+ this.fallbacks.add(this.register('/-/namespace/peers', async ({ data }) => {
199
+ const namespace = typeof data?.namespace === 'string' ? data.namespace : '';
200
+ const keys = this.namespaces.get(namespace);
201
+ return {
202
+ namespace,
203
+ peers: keys ? registryAddressesFromKeys(keys, data.exclude) : [],
204
+ };
205
+ }));
206
+ this.fallbacks.add(this.register('/-/registry/status', async () => ({
207
+ status: 'ok',
208
+ startedAt: this.startedAt,
209
+ uptime: Date.now() - this.startedAt,
210
+ clientCount: this.clients.size,
211
+ namespaceCount: this.namespaces.size,
212
+ topicCount: this.topics.size,
213
+ configNamespaceCount: this.configs.size,
214
+ })));
215
+ this.fallbacks.add(this.register('/-/topics', async ({ data }) => ({
216
+ topics: this.listTopicSummaries(data?.prefix),
217
+ })));
218
+ this.fallbacks.add(this.register('/-/topic/get', async ({ data }) => {
219
+ if (typeof data?.topic !== 'string')
220
+ return;
221
+ const entry = this.topics.get(data.topic);
222
+ if (!entry)
223
+ return;
224
+ return this.createTopicSnapshot(data.topic, entry);
225
+ }));
226
+ this.fallbacks.add(this.register('/-/configs', async () => {
227
+ const configs = [...this.configs.entries()]
228
+ .sort(([a], [b]) => a.localeCompare(b))
229
+ .map(([namespace, config]) => ({
230
+ namespace,
231
+ keys: Object.keys(config).sort(),
232
+ }));
233
+ return { configs };
234
+ }));
235
+ this.fallbacks.add(this.register('/-/config/get', async ({ data }) => {
236
+ const namespace = typeof data?.namespace === 'string' ? data.namespace : '';
237
+ const config = this.configs.get(namespace);
238
+ if (typeof data?.key === 'string') {
239
+ if (config && Object.prototype.hasOwnProperty.call(config, data.key)) {
240
+ return {
241
+ namespace,
242
+ key: data.key,
243
+ hasValue: true,
244
+ value: cloneRegistryValue(config[data.key]),
245
+ };
246
+ }
247
+ return {
248
+ namespace,
249
+ key: data.key,
250
+ hasValue: false,
251
+ };
252
+ }
253
+ if (!config) {
254
+ return {
255
+ namespace,
256
+ hasConfig: false,
257
+ };
258
+ }
259
+ return {
260
+ namespace,
261
+ hasConfig: true,
262
+ config: cloneRegistryValue(config),
263
+ };
264
+ }));
136
265
  }
137
266
  cleanupTopicIfUnused(topic, entry = this.topics.get(topic)) {
138
267
  if (!entry)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hile/micro",
3
- "version": "3.0.1",
3
+ "version": "3.0.3",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
@@ -31,5 +31,5 @@
31
31
  "ws": "^8.21.0",
32
32
  "yaml": "^2.9.0"
33
33
  },
34
- "gitHead": "0985b6f8abc1f4de0a36324063585fdc3ac1375b"
34
+ "gitHead": "eac3a74c71ec2c514b920015f1c0d3415d3b8057"
35
35
  }