@hile/micro 1.0.10 → 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/dist/application.d.ts +17 -2
- package/dist/application.js +90 -14
- package/dist/client.d.ts +11 -5
- package/dist/client.js +9 -4
- package/dist/registry.d.ts +9 -5
- package/dist/registry.js +131 -32
- package/package.json +4 -4
- package/SKILL.md +0 -600
package/dist/application.d.ts
CHANGED
|
@@ -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,
|
|
49
|
-
|
|
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 {};
|
package/dist/application.js
CHANGED
|
@@ -47,6 +47,8 @@ export class Application extends Server {
|
|
|
47
47
|
namespaces = new Map();
|
|
48
48
|
static CB_COOLDOWN_MS = 30_000;
|
|
49
49
|
circuitBreakers = new Map();
|
|
50
|
+
fallbacks = new Set();
|
|
51
|
+
topics = new Map();
|
|
50
52
|
constructor(props) {
|
|
51
53
|
const { namespace, registry, registryLookupTimeoutMs = 10_000, requestTimeoutMs = 30_000, ...microAndLoader } = props;
|
|
52
54
|
super(namespace, microAndLoader);
|
|
@@ -54,11 +56,15 @@ export class Application extends Server {
|
|
|
54
56
|
this._registry_address = registry;
|
|
55
57
|
this._registryLookupTimeoutMs = registryLookupTimeoutMs;
|
|
56
58
|
this._requestTimeoutMs = requestTimeoutMs;
|
|
57
|
-
this.register('/-/health', async () => ({
|
|
59
|
+
this.fallbacks.add(this.register('/-/health', async () => ({
|
|
58
60
|
status: 'ok',
|
|
59
61
|
registry: !!this.registry,
|
|
60
62
|
uptime: process.uptime(),
|
|
61
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();
|
|
62
68
|
}));
|
|
63
69
|
}
|
|
64
70
|
async listen(port = 0) {
|
|
@@ -76,7 +82,13 @@ export class Application extends Server {
|
|
|
76
82
|
}
|
|
77
83
|
throw err;
|
|
78
84
|
}
|
|
85
|
+
// 这里不清理 topics 由业务方自己清理
|
|
86
|
+
// 这里也不清理 declare 和 undeclare 由业务方自己清理
|
|
79
87
|
return async () => {
|
|
88
|
+
for (const fallback of this.fallbacks) {
|
|
89
|
+
fallback();
|
|
90
|
+
}
|
|
91
|
+
this.fallbacks.clear();
|
|
80
92
|
this.stopped = true;
|
|
81
93
|
if (this.reconnectTimeout) {
|
|
82
94
|
clearTimeout(this.reconnectTimeout);
|
|
@@ -124,6 +136,10 @@ export class Application extends Server {
|
|
|
124
136
|
});
|
|
125
137
|
});
|
|
126
138
|
this.registry = registry;
|
|
139
|
+
// 重新订阅所有 topic
|
|
140
|
+
for (const [topic, callback] of this.topics) {
|
|
141
|
+
await this.subscribe(topic, callback, true);
|
|
142
|
+
}
|
|
127
143
|
})().finally(() => {
|
|
128
144
|
this.registryReconnectPromise = undefined;
|
|
129
145
|
});
|
|
@@ -166,8 +182,8 @@ export class Application extends Server {
|
|
|
166
182
|
async findFromRegistry(namespace, exclude) {
|
|
167
183
|
if (!this.registry)
|
|
168
184
|
throw new Error('Registry not found');
|
|
169
|
-
const
|
|
170
|
-
return await withTimeout(
|
|
185
|
+
const promise = this.registry.request('/-/find', { namespace, exclude });
|
|
186
|
+
return await withTimeout(promise, this._registryLookupTimeoutMs, 'Registry /-/find');
|
|
171
187
|
}
|
|
172
188
|
get(namespace, exclude) {
|
|
173
189
|
if (!this.namespaces.has(namespace)) {
|
|
@@ -238,7 +254,8 @@ export class Application extends Server {
|
|
|
238
254
|
}
|
|
239
255
|
});
|
|
240
256
|
}
|
|
241
|
-
async call(namespace, url, data,
|
|
257
|
+
async call(namespace, url, data, options) {
|
|
258
|
+
const { timeout = this._requestTimeoutMs, retries = 1, signal } = options || {};
|
|
242
259
|
const exclude = this.getActiveExcludes(namespace);
|
|
243
260
|
let client;
|
|
244
261
|
try {
|
|
@@ -249,28 +266,87 @@ export class Application extends Server {
|
|
|
249
266
|
client = await this.get(namespace);
|
|
250
267
|
}
|
|
251
268
|
try {
|
|
252
|
-
const
|
|
253
|
-
|
|
269
|
+
const result = await client.request(url, data, {
|
|
270
|
+
timeout: timeout ?? this._requestTimeoutMs,
|
|
271
|
+
signal,
|
|
272
|
+
});
|
|
254
273
|
this.recordSuccess(namespace, client.host, client.port);
|
|
255
274
|
return result;
|
|
256
275
|
}
|
|
257
276
|
catch (err) {
|
|
258
277
|
this.recordFailure(namespace, client.host, client.port);
|
|
259
278
|
if (retries > 0) {
|
|
260
|
-
return this.call(namespace, url, data, timeout, retries - 1);
|
|
279
|
+
return this.call(namespace, url, data, { timeout, retries: retries - 1, signal });
|
|
261
280
|
}
|
|
262
281
|
throw err;
|
|
263
282
|
}
|
|
264
283
|
}
|
|
265
|
-
async
|
|
284
|
+
async stream(namespace, url, data, options) {
|
|
285
|
+
const { signal, retries = 1 } = options || {};
|
|
286
|
+
const exclude = this.getActiveExcludes(namespace);
|
|
287
|
+
let client;
|
|
288
|
+
try {
|
|
289
|
+
client = await this.get(namespace, exclude);
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
this.circuitBreakers.delete(namespace);
|
|
293
|
+
client = await this.get(namespace);
|
|
294
|
+
}
|
|
295
|
+
try {
|
|
296
|
+
const readable = client.stream(url, data, { signal });
|
|
297
|
+
this.recordSuccess(namespace, client.host, client.port);
|
|
298
|
+
return readable;
|
|
299
|
+
}
|
|
300
|
+
catch (err) {
|
|
301
|
+
this.recordFailure(namespace, client.host, client.port);
|
|
302
|
+
if (retries > 0) {
|
|
303
|
+
return this.stream(namespace, url, data, { signal, retries: retries - 1 });
|
|
304
|
+
}
|
|
305
|
+
throw err;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
async publish(topic, data) {
|
|
266
309
|
if (!this.registry)
|
|
267
310
|
throw new Error('Registry not found');
|
|
268
|
-
|
|
269
|
-
const
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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);
|
|
273
349
|
}
|
|
274
|
-
return
|
|
350
|
+
return fallback;
|
|
275
351
|
}
|
|
276
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,
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
}
|
|
31
|
-
push(url: string, data: any,
|
|
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,
|
|
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 },
|
|
58
|
+
return this._send({ url, data }, options);
|
|
59
59
|
}
|
|
60
|
-
push(url, data,
|
|
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 },
|
|
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)
|
package/dist/registry.d.ts
CHANGED
|
@@ -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
|
-
|
|
26
|
-
|
|
27
|
-
private
|
|
28
|
-
private
|
|
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.
|
|
77
|
-
this.
|
|
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,7 +102,15 @@ 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
|
-
|
|
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
|
}
|
|
@@ -94,16 +118,26 @@ export class Registry extends Server {
|
|
|
94
118
|
if (!filename?.endsWith(this.configFileSuffix))
|
|
95
119
|
return;
|
|
96
120
|
const fullPath = resolve(configFile, filename);
|
|
97
|
-
// 文件被删除(或重命名):移除对应配置
|
|
98
121
|
if (!existsSync(fullPath)) {
|
|
99
|
-
|
|
122
|
+
const namespace = parseConfigFilename(filename);
|
|
123
|
+
this.configs.delete(namespace);
|
|
100
124
|
return;
|
|
101
125
|
}
|
|
102
126
|
try {
|
|
103
127
|
const config = YAML.parse(readFileSync(fullPath, 'utf8'));
|
|
104
128
|
if (typeof config !== 'object' || config === null)
|
|
105
129
|
return;
|
|
106
|
-
|
|
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
|
+
}
|
|
107
141
|
}
|
|
108
142
|
catch { /* vim 替换文件时的中间态读错误,忽略 */ }
|
|
109
143
|
});
|
|
@@ -118,19 +152,15 @@ export class Registry extends Server {
|
|
|
118
152
|
return async () => {
|
|
119
153
|
if (watcher)
|
|
120
154
|
watcher.close();
|
|
155
|
+
for (const fallback of this.fallbacks) {
|
|
156
|
+
fallback();
|
|
157
|
+
}
|
|
158
|
+
this.fallbacks.clear();
|
|
121
159
|
await teardown();
|
|
122
160
|
};
|
|
123
161
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
this.mountFindHandler();
|
|
127
|
-
}
|
|
128
|
-
mountFindHandler() {
|
|
129
|
-
if (this.unregisterFind) {
|
|
130
|
-
this.unregisterFind();
|
|
131
|
-
this.unregisterFind = undefined;
|
|
132
|
-
}
|
|
133
|
-
this.unregisterFind = this.register('/-/find', async ({ data }) => {
|
|
162
|
+
registerFindApplication() {
|
|
163
|
+
this.fallbacks.add(this.register('/-/find', async ({ data }) => {
|
|
134
164
|
const namespace = data.namespace;
|
|
135
165
|
let keys = this.namespaces.get(namespace);
|
|
136
166
|
if (!keys)
|
|
@@ -143,23 +173,92 @@ export class Registry extends Server {
|
|
|
143
173
|
keys = new Set(filtered);
|
|
144
174
|
}
|
|
145
175
|
return selectRandomRegistryAddress(keys);
|
|
146
|
-
});
|
|
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
|
+
}));
|
|
191
|
+
}
|
|
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);
|
|
205
|
+
}
|
|
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
|
+
}));
|
|
147
221
|
}
|
|
148
|
-
|
|
149
|
-
this.register('/-/
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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);
|
|
153
235
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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 });
|
|
160
257
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
// 推送失败,disconnect 事件中会清理
|
|
261
|
+
}
|
|
262
|
+
}
|
|
164
263
|
}
|
|
165
264
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hile/micro",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.1",
|
|
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": "^
|
|
27
|
-
"@hile/message-ws": "^
|
|
26
|
+
"@hile/message-loader": "^2.0.0",
|
|
27
|
+
"@hile/message-ws": "^2.0.1",
|
|
28
28
|
"internal-ip": "^9.0.0",
|
|
29
29
|
"ws": "^8.19.0",
|
|
30
30
|
"yaml": "^2.9.0"
|
|
31
31
|
},
|
|
32
|
-
"gitHead": "
|
|
32
|
+
"gitHead": "8e0fd1f78b5a8abd21218d1f596ada2533a0c8e7"
|
|
33
33
|
}
|
package/SKILL.md
DELETED
|
@@ -1,600 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: micro
|
|
3
|
-
description: Code generation and contribution rules for @hile/micro. Use when editing this package or when the user asks about @hile/micro API, types, patterns, or features.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# @hile/micro — AI Skill Reference
|
|
7
|
-
|
|
8
|
-
本文档面向 **AI 编码模型**。在生成或修改 `@hile/micro` 代码前必读,保证与现有架构、API 约定、状态机、测试模式一致。
|
|
9
|
-
|
|
10
|
-
---
|
|
11
|
-
|
|
12
|
-
## 1. 架构总览
|
|
13
|
-
|
|
14
|
-
### 依赖链
|
|
15
|
-
|
|
16
|
-
```
|
|
17
|
-
@hile/message-loader (路由: register/dispatch)
|
|
18
|
-
└── @hile/message-ws (WebSocket 请求/响应, MessageModem timeout/abort)
|
|
19
|
-
└── packages/micro
|
|
20
|
-
├── Server — WebSocket 监听 + 连接管理 + Client 生命周期
|
|
21
|
-
├── Client — 远端代理, request/push/dispose
|
|
22
|
-
├── Registry — 注册中心 (extends Server)
|
|
23
|
-
└── Application — 应用服务 (extends Server)
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
### 分层职责
|
|
27
|
-
|
|
28
|
-
| 类 | 文件 | 职责 | 关键约束 |
|
|
29
|
-
|----|------|------|---------|
|
|
30
|
-
| `Server` | `server.ts` | WebSocketServer 生命周期, 出入站连接, Client Map | 不感知 Registry |
|
|
31
|
-
| `Client` | `client.ts` | 远端 Server 的 WebSocket 会话代理 | `dispose()` 必须关闭底层 socket |
|
|
32
|
-
| `Registry` | `registry.ts` | namespace → Set\<host:port\>, 心跳检测, /-/find 随机返回, 环境变量管理 | 自动创建 `~/.registry/` 工作目录 |
|
|
33
|
-
| `Application` | `application.ts` | 注册发现 + 熔断 + 重试 + 心跳 + 远程环境变量读取 | `listen()` 后自动连 Registry |
|
|
34
|
-
|
|
35
|
-
### 应用模型
|
|
36
|
-
|
|
37
|
-
一个 `Application` 实例同时是 provider 和 consumer。不要创建两个类来区分角色。
|
|
38
|
-
|
|
39
|
-
```
|
|
40
|
-
Application:
|
|
41
|
-
└─ register(url, handler) → provider 侧
|
|
42
|
-
└─ get(namespace) → consumer 侧, 返回 Client
|
|
43
|
-
└─ call(namespace, url, data) → consumer 侧, get+request+熔断+重试 一站式
|
|
44
|
-
```
|
|
45
|
-
|
|
46
|
-
---
|
|
47
|
-
|
|
48
|
-
## 2. 类型定义(代码生成时必须一致)
|
|
49
|
-
|
|
50
|
-
### Server (`server.ts`)
|
|
51
|
-
|
|
52
|
-
```typescript
|
|
53
|
-
export type MicroServerProps = MessageLoaderProps & {
|
|
54
|
-
advertiseHost?: string; // 缺省 getLocalIPv4(), 皆无则构造抛错
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
export class Server extends MessageLoader {
|
|
58
|
-
protected readonly clients = new Map<string, Client>(); // key: "host:port"
|
|
59
|
-
public readonly events = new EventEmitter(); // connect/disconnect
|
|
60
|
-
|
|
61
|
-
constructor(namespace: string, props?: MicroServerProps);
|
|
62
|
-
public listen(port: number): Promise<() => Promise<void>>; // 返回 teardown
|
|
63
|
-
public setPort(port: number): this;
|
|
64
|
-
protected connect(host: string, port: number, timeout?: number): Promise<Client>;
|
|
65
|
-
// 继承自 MessageLoader:
|
|
66
|
-
// register<T, E>(url, handler): () => void;
|
|
67
|
-
// dispatch(url, data, extras): Promise<any>;
|
|
68
|
-
}
|
|
69
|
-
```
|
|
70
|
-
|
|
71
|
-
### Client (`client.ts`)
|
|
72
|
-
|
|
73
|
-
```typescript
|
|
74
|
-
export class Client extends MessageWs {
|
|
75
|
-
public readonly host: string;
|
|
76
|
-
public readonly port: number;
|
|
77
|
-
public readonly events = new EventEmitter(); // connect/disconnect
|
|
78
|
-
|
|
79
|
-
request(url: string, data: any, timeout?: number): {
|
|
80
|
-
abort(): void;
|
|
81
|
-
response<T = any>(): Promise<T>; // 超时或 abort 时 reject
|
|
82
|
-
};
|
|
83
|
-
push(url: string, data: any, timeout?: number): void;
|
|
84
|
-
dispose(): void; // 关闭底层 WebSocket
|
|
85
|
-
}
|
|
86
|
-
```
|
|
87
|
-
|
|
88
|
-
### Registry (`registry.ts`)
|
|
89
|
-
|
|
90
|
-
```typescript
|
|
91
|
-
export interface RegistryFindData {
|
|
92
|
-
namespace: string;
|
|
93
|
-
exclude?: string[];
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
export function parseAddressKey(key: string): RegistryAddress | undefined;
|
|
97
|
-
export function selectRandomRegistryAddress(keys: Iterable<string>): RegistryAddress | undefined;
|
|
98
|
-
// 配置文件路径工具:
|
|
99
|
-
export function getRegistryConfigsDir(): string; // 返回 ~/.registry/configs/
|
|
100
|
-
export function namespaceToConfigFile(ns: string): string; // 返回 ~/.registry/configs/{ns}.config.yaml
|
|
101
|
-
export function parseConfigFilename(filename: string): string | null; // 解析 ".config.yaml" 后缀,提取 namespace
|
|
102
|
-
|
|
103
|
-
export class Registry extends Server {
|
|
104
|
-
// heartbeat 常量:
|
|
105
|
-
// HEARTBEAT_INTERVAL = 1000 (1s 轮询)
|
|
106
|
-
// HEARTBEAT_TIMEOUT = 20000 (20s 未收到心跳则剔除)
|
|
107
|
-
// 工作目录: ~/.registry/ (自动创建)
|
|
108
|
-
// - configs/ 目录存放 *.config.yaml 配置文件
|
|
109
|
-
// - watchEnvFile() 监听 configs/ 目录,兼容 vim 原子写入
|
|
110
|
-
// - configs Map<string, any> 按 namespace 存储解析后的 YAML 内容
|
|
111
|
-
// 内部路由:
|
|
112
|
-
// /-/find — 按 namespace 随机返回地址 (支持 exclude)
|
|
113
|
-
// /-/heartbeat — 更新实例心跳时间戳
|
|
114
|
-
// /-/env/variables — 按 namespace + fields 返回配置 (通过 getEnvVariables 调用)
|
|
115
|
-
|
|
116
|
-
constructor(props?: MicroServerProps);
|
|
117
|
-
listen(port: number): Promise<() => Promise<void>>;
|
|
118
|
-
onFind(): void; // 幂等,可重复调用
|
|
119
|
-
watchEnvFile(): fs.FSWatcher | undefined; // 监听 ~/.registry/configs/ 目录
|
|
120
|
-
}
|
|
121
|
-
```
|
|
122
|
-
|
|
123
|
-
### Application (`application.ts`)
|
|
124
|
-
|
|
125
|
-
```typescript
|
|
126
|
-
export type ApplicationProps = {
|
|
127
|
-
namespace: string;
|
|
128
|
-
registry: RegistryAddress;
|
|
129
|
-
registryLookupTimeoutMs?: number; // /-/find 超时, 默认 10_000
|
|
130
|
-
requestTimeoutMs?: number; // 单次请求超时, 默认 30_000
|
|
131
|
-
} & MicroServerProps;
|
|
132
|
-
|
|
133
|
-
export class Application extends Server {
|
|
134
|
-
// 内部常量:
|
|
135
|
-
// HEARTBEAT_INTERVAL = 10000 (10s 向 Registry 推送心跳)
|
|
136
|
-
// CB_COOLDOWN_MS = 30000 (熔断冷卻期 30s)
|
|
137
|
-
// 内部状态:
|
|
138
|
-
// namespaces: Map<ns, { host, port, status: IDLE|PENDING|READY, handlers }>
|
|
139
|
-
// circuitBreakers: Map<ns, Map<peerKey, openedAt>>
|
|
140
|
-
|
|
141
|
-
constructor(props: ApplicationProps);
|
|
142
|
-
listen(port: number): Promise<() => Promise<void>>; // 自动连 Registry + 启心跳
|
|
143
|
-
|
|
144
|
-
get(namespace: string, exclude?: string[]): Promise<Client>;
|
|
145
|
-
// call() = get + request + response + 熔断 + 重试 + 超时
|
|
146
|
-
call<T = any>(
|
|
147
|
-
namespace: string,
|
|
148
|
-
url: string,
|
|
149
|
-
data: any,
|
|
150
|
-
timeout?: number, // 单次超时, 默认 requestTimeoutMs
|
|
151
|
-
retries?: number, // 重试次数, 默认 1
|
|
152
|
-
): Promise<T>;
|
|
153
|
-
|
|
154
|
-
// 远程读取 Registry 的配置(强类型,按 namespace + fields)
|
|
155
|
-
getEnvVariables<
|
|
156
|
-
T extends Record<string, Record<string, any>>,
|
|
157
|
-
const Requests extends readonly EnvRequest<T>[],
|
|
158
|
-
>(...data: Requests): Promise<GetEnvVariablesResult<T, Requests>>;
|
|
159
|
-
|
|
160
|
-
// 继承自 Server/MessageLoader:
|
|
161
|
-
// register<T, E>(url, handler): () => void;
|
|
162
|
-
// dispatch(url, data, extras): Promise<any>;
|
|
163
|
-
}
|
|
164
|
-
```
|
|
165
|
-
|
|
166
|
-
---
|
|
167
|
-
|
|
168
|
-
## 3. 功能详解(含状态机与逻辑流)
|
|
169
|
-
|
|
170
|
-
### 3.1 服务发现 `get(namespace, exclude?)`
|
|
171
|
-
|
|
172
|
-
```
|
|
173
|
-
get(ns, exclude?)
|
|
174
|
-
├─ namespaces 无此 ns → 创建 IDLE 条目
|
|
175
|
-
├─ status=READY + (client 已断连 or peer 被 exclude)
|
|
176
|
-
│ └─ 重置为 IDLE, 清空 host/port
|
|
177
|
-
├─ status=READY + client 有效 + 未被 exclude
|
|
178
|
-
│ └─ 直接返回缓存 Client (快路径)
|
|
179
|
-
└─ 否则:
|
|
180
|
-
└─ 新建 Promise, handler 入队
|
|
181
|
-
└─ status=IDLE → PENDING → findFromRegistry(ns, exclude)
|
|
182
|
-
├─ Registry 返回地址 → connect → status=READY
|
|
183
|
-
│ └─ 注册 disconnect → 清理 namespace 缓存
|
|
184
|
-
│ └─ resolve 所有 handler
|
|
185
|
-
└─ Registry 失败 or 无数据 →
|
|
186
|
-
└─ catch: 检查旧缓存 (cachedHost/cachedPort)
|
|
187
|
-
├─ 缓存有效 → 降级: restore status=READY, resolve
|
|
188
|
-
└─ 无缓存 → delete namespace, reject 所有 handler
|
|
189
|
-
└─ finally: handlers.clear()
|
|
190
|
-
```
|
|
191
|
-
|
|
192
|
-
**关键点:**
|
|
193
|
-
- `cachedHost` / `cachedPort` 在缓存失效前保存,用于降级路径
|
|
194
|
-
- 降级时恢复 `stack.host/port/status=READY`,使后续调用走快路径
|
|
195
|
-
- 多并发 `get()` 共享同一个 Promise,handler 入队后统一 resolve/reject
|
|
196
|
-
|
|
197
|
-
### 3.2 熔断器 (Circuit Breaker)
|
|
198
|
-
|
|
199
|
-
**数据结构:** `Map<namespace, Map<peerKey, openedAt>>`
|
|
200
|
-
|
|
201
|
-
```
|
|
202
|
-
call() 失败:
|
|
203
|
-
recordFailure(ns, host, port)
|
|
204
|
-
→ excludes.set("host:port", Date.now())
|
|
205
|
-
|
|
206
|
-
call() 成功:
|
|
207
|
-
recordSuccess(ns, host, port)
|
|
208
|
-
→ excludes.delete("host:port")
|
|
209
|
-
|
|
210
|
-
getActiveExcludes(ns):
|
|
211
|
-
→ 遍历 excludes, 移除 Date.now() - openedAt >= 30000 的过期条目
|
|
212
|
-
→ 返回活跃的 exclude key 数组
|
|
213
|
-
```
|
|
214
|
-
|
|
215
|
-
**生命周期:**
|
|
216
|
-
|
|
217
|
-
```
|
|
218
|
-
peer 首次失败
|
|
219
|
-
→ circuitBreakers: { svc: { "127.0.0.1:8080": now } }
|
|
220
|
-
→ getActiveExcludes("svc") → ["127.0.0.1:8080"]
|
|
221
|
-
→ next call() 的 get() 带上 exclude, 排除该 peer
|
|
222
|
-
→ Registry 返回其他 peer (有则) 或 undefined (无则)
|
|
223
|
-
→ 如果所有 peer 都被排除, catch 块 delete circuitBreakers, 无 exclude 重试
|
|
224
|
-
=> "全排除 → 重置" 策略: 当 Registry 找不到未被排除的 peer, 熔断器清空, 从不安全全量重试
|
|
225
|
-
```
|
|
226
|
-
|
|
227
|
-
**冷卻期:** `CB_COOLDOWN_MS = 30000` (30 秒)。到期后 `getActiveExcludes` 自动清除旧条目。
|
|
228
|
-
|
|
229
|
-
### 3.3 请求超时
|
|
230
|
-
|
|
231
|
-
**配置链:**
|
|
232
|
-
|
|
233
|
-
```
|
|
234
|
-
ApplicationProps.requestTimeoutMs (default 30_000)
|
|
235
|
-
→ call(timeout?) // 可选 override
|
|
236
|
-
→ client.request(url, data, timeout ?? this._requestTimeoutMs)
|
|
237
|
-
→ MessageModem._send({ url, data }, timeout)
|
|
238
|
-
→ MessageModem._write(data, timeout, twoway=true)
|
|
239
|
-
→ setTimeout(reject, timeout)
|
|
240
|
-
→ 超时: 发送 ABORT 消息 → 对端取消执行
|
|
241
|
-
```
|
|
242
|
-
|
|
243
|
-
- 超时默认 30 秒(MessageModem 默认值)
|
|
244
|
-
- 超时触发时向对端发送 **ABORT** 消息(不是仅仅本地 reject)
|
|
245
|
-
- 支持每个调用单独覆盖
|
|
246
|
-
|
|
247
|
-
### 3.5 手动取消(abort)
|
|
248
|
-
|
|
249
|
-
`client.request()` 返回的 `abort()` 函数可主动取消请求,**与超时共享同一套 ABORT 机制**:
|
|
250
|
-
|
|
251
|
-
```typescript
|
|
252
|
-
const client = await app.get('svc');
|
|
253
|
-
const { response, abort } = client.request('/api', data);
|
|
254
|
-
|
|
255
|
-
// 主动取消
|
|
256
|
-
abort();
|
|
257
|
-
await response(); // → reject (AbortException)
|
|
258
|
-
|
|
259
|
-
// 典型场景:竞态淘汰
|
|
260
|
-
const req = client.request('/slow', data);
|
|
261
|
-
// 如果其他条件满足,提前取消
|
|
262
|
-
if (cached) abort();
|
|
263
|
-
```
|
|
264
|
-
|
|
265
|
-
**实现原理:**
|
|
266
|
-
- `request()` 内部通过 `MessageModem._write()` 创建 `AbortController`
|
|
267
|
-
- `abort()` 调用 `controller.abort()` 触发 ABORT 消息发送
|
|
268
|
-
- 超时到期也调用同一个 `controller.abort()`,机制完全相同
|
|
269
|
-
- 区别仅在于触发源头:手动调用 vs 定时器到期
|
|
270
|
-
|
|
271
|
-
**适用场景:** 用户取消操作、页面/组件卸载、竞态条件(先发请求后发先至时取消旧请求)
|
|
272
|
-
|
|
273
|
-
```typescript
|
|
274
|
-
call(ns, url, data, timeout, retries = 1):
|
|
275
|
-
get(ns, exclude) → client
|
|
276
|
-
try:
|
|
277
|
-
client.request(url, data, timeout) → response() → result
|
|
278
|
-
recordSuccess(ns, host, port)
|
|
279
|
-
return result
|
|
280
|
-
catch err:
|
|
281
|
-
recordFailure(ns, host, port)
|
|
282
|
-
if retries > 0:
|
|
283
|
-
return call(ns, url, data, timeout, retries - 1) // 递归
|
|
284
|
-
throw err
|
|
285
|
-
```
|
|
286
|
-
|
|
287
|
-
**重试语义:**
|
|
288
|
-
- 首次失败 → peer 被排除
|
|
289
|
-
- 递归 `call(retries-1)` → `getActiveExcludes` 包含已失败的 peer
|
|
290
|
-
- `get()` 带上 exclude → Registry 返回其他 peer
|
|
291
|
-
- 所有 peer 都失败 → 熔断全排除 → catch 块重置 → 无 exclude 重试
|
|
292
|
-
- 每个 retry 共享同一个 timeout 值(不会延长总时间)
|
|
293
|
-
|
|
294
|
-
### 3.6 健康检查
|
|
295
|
-
|
|
296
|
-
在 `Application` 构造函数中注册:
|
|
297
|
-
|
|
298
|
-
```typescript
|
|
299
|
-
this.register('/-/health', async () => ({
|
|
300
|
-
status: 'ok' as const,
|
|
301
|
-
registry: !!this.registry, // 是否连上 Registry
|
|
302
|
-
uptime: process.uptime(), // 进程运行秒数
|
|
303
|
-
namespaces: [...this.namespaces.keys()], // 已缓存的 namespace
|
|
304
|
-
}));
|
|
305
|
-
```
|
|
306
|
-
|
|
307
|
-
只在 Application 上注册,Server 和 Registry 没有。
|
|
308
|
-
|
|
309
|
-
### 3.7 Registry 心跳
|
|
310
|
-
|
|
311
|
-
```
|
|
312
|
-
Application (10s 间隔):
|
|
313
|
-
startHeartbeat():
|
|
314
|
-
setInterval(10000):
|
|
315
|
-
registry.push('/-/heartbeat', {})
|
|
316
|
-
|
|
317
|
-
Registry (1s 间隔检查, 20s 超时):
|
|
318
|
-
构造函数:
|
|
319
|
-
events.on('connect', ...) → heartbeats.set(key, Date.now())
|
|
320
|
-
register('/-/heartbeat', ...) → heartbeats.set(key, Date.now())
|
|
321
|
-
listen():
|
|
322
|
-
setInterval(1000):
|
|
323
|
-
for each heartbeat entry:
|
|
324
|
-
if now - lastTime >= 20000:
|
|
325
|
-
clients.get(key).dispose() // 剔除死实例
|
|
326
|
-
```
|
|
327
|
-
|
|
328
|
-
### 3.8 Registry 重连
|
|
329
|
-
|
|
330
|
-
```
|
|
331
|
-
listen():
|
|
332
|
-
reconnectToRegistry() → connect + events.on('disconnect')
|
|
333
|
-
|
|
334
|
-
disconnect 触发:
|
|
335
|
-
registry = undefined
|
|
336
|
-
reconnectToRegistry():
|
|
337
|
-
├─ 成功 → 恢复正常
|
|
338
|
-
└─ 失败 → scheduleRegistryRetry():
|
|
339
|
-
└─ setTimeout(3000) → reconnectToRegistry()
|
|
340
|
-
└─ 失败 → scheduleRegistryRetry() (循环)
|
|
341
|
-
|
|
342
|
-
listen() teardown 触发:
|
|
343
|
-
stopped = true → 停止所有重连尝试
|
|
344
|
-
```
|
|
345
|
-
|
|
346
|
-
### 3.9 缓存降级
|
|
347
|
-
|
|
348
|
-
**触发条件:** `get()` 的 registry lookup 失败,但 `this.clients` 中仍然有之前缓存的 Client(WebSocket 未断开)。
|
|
349
|
-
|
|
350
|
-
**处理流程:**
|
|
351
|
-
1. `cachedHost` / `cachedPort` 在缓存失效前保存
|
|
352
|
-
2. Registry lookup 失败 → catch 块
|
|
353
|
-
3. `cachedHost && this.clients.has(cachedKey)` → 有效缓存
|
|
354
|
-
4. 恢复 `stack.host/port/status=READY`,resolve 所有 handler
|
|
355
|
-
5. 后续 `get()` 命中快路径,直接返回该 Client
|
|
356
|
-
|
|
357
|
-
**不处理的情况:** 全新 namespace(无缓存)、缓存 Client 已断连。
|
|
358
|
-
|
|
359
|
-
### 3.10 配置管理
|
|
360
|
-
|
|
361
|
-
**存储结构:**
|
|
362
|
-
|
|
363
|
-
```
|
|
364
|
-
~/.registry/
|
|
365
|
-
└── configs/
|
|
366
|
-
├── service-a.config.yaml
|
|
367
|
-
├── service-b.config.yaml
|
|
368
|
-
└── global.config.yaml
|
|
369
|
-
```
|
|
370
|
-
|
|
371
|
-
**Registry 侧:**
|
|
372
|
-
|
|
373
|
-
```
|
|
374
|
-
Registry 构造:
|
|
375
|
-
1. 创建 ~/.registry/ 目录 (自动)
|
|
376
|
-
|
|
377
|
-
Registry listen():
|
|
378
|
-
1. 调用 watchEnvFile()
|
|
379
|
-
└─ 读取 ~/.registry/configs/ 下所有 *.config.yaml → YAML.parse
|
|
380
|
-
└─ 按 namespace 存入 this.configs Map
|
|
381
|
-
└─ 监听 configs/ 目录 (兼容 vim 原子写入)
|
|
382
|
-
└─ 文件变化 → 重新 YAML.parse 并更新 this.configs
|
|
383
|
-
|
|
384
|
-
/-/env/variables 端点:
|
|
385
|
-
register('/-/env/variables', async ({ data }) => {
|
|
386
|
-
data.map(({ namespace, fields }) => {
|
|
387
|
-
if (!this.configs.has(namespace))
|
|
388
|
-
return { namespace, value: null }
|
|
389
|
-
if (!fields?.length)
|
|
390
|
-
return { namespace, value: this.configs.get(namespace) }
|
|
391
|
-
// 按 fields 过滤
|
|
392
|
-
return { namespace, value: filteredConfig }
|
|
393
|
-
})
|
|
394
|
-
})
|
|
395
|
-
```
|
|
396
|
-
|
|
397
|
-
**Application 侧:**
|
|
398
|
-
|
|
399
|
-
```typescript
|
|
400
|
-
// 强类型查询
|
|
401
|
-
type EnvRequest<T> = {
|
|
402
|
-
[N in keyof T]: { namespace: N; fields?: readonly (keyof T[N])[] };
|
|
403
|
-
}[keyof T];
|
|
404
|
-
|
|
405
|
-
type GetEnvVariablesResult<T, Requests> = UnionToIntersection<...>;
|
|
406
|
-
|
|
407
|
-
getEnvVariables(...data: EnvRequest<T>[]): Promise<GetEnvVariablesResult<T, Requests>>
|
|
408
|
-
```
|
|
409
|
-
|
|
410
|
-
**CLI 管理配置:**
|
|
411
|
-
|
|
412
|
-
通过 `hile registry configs` 子命令直接管理 `~/.registry/configs/` 下的 YAML 文件:
|
|
413
|
-
|
|
414
|
-
```
|
|
415
|
-
hile registry configs # 列出所有 namespace
|
|
416
|
-
hile registry configs get <namespace> # 查看配置(YAML/--json)
|
|
417
|
-
hile registry configs set <namespace> <key>=<value> # 设置配置项
|
|
418
|
-
hile registry configs del <namespace> [key] # 删除(带确认,-y 跳过)
|
|
419
|
-
```
|
|
420
|
-
|
|
421
|
-
- CLI 直接读写 YAML 文件,运行中的 Registry 通过 `fs.watch` 自动感知
|
|
422
|
-
- 值类型自动推断:`true/false` → boolean, `null` → null, 纯数字 → number, 以 `{`/`[` 开头 → JSON 解析为 object/array, 其余 → string
|
|
423
|
-
- 实现代码在 `packages/cli/src/configs.ts`,工具函数在 `packages/micro/src/registry.ts`
|
|
424
|
-
|
|
425
|
-
---
|
|
426
|
-
|
|
427
|
-
## 4. 代码生成模板
|
|
428
|
-
|
|
429
|
-
### 4.1 三节点测试拓扑(所有 test 必须使用)
|
|
430
|
-
|
|
431
|
-
```typescript
|
|
432
|
-
const registryPort = await getAvailablePort();
|
|
433
|
-
const providerPort = await getAvailablePort();
|
|
434
|
-
const consumerPort = await getAvailablePort();
|
|
435
|
-
|
|
436
|
-
const registry = new Registry(testAdvertise);
|
|
437
|
-
const provider = new Application({
|
|
438
|
-
namespace: 'svc',
|
|
439
|
-
registry: { host: '127.0.0.1', port: registryPort },
|
|
440
|
-
...testAdvertise,
|
|
441
|
-
});
|
|
442
|
-
const consumer = new Application({
|
|
443
|
-
namespace: 'consumer',
|
|
444
|
-
registry: { host: '127.0.0.1', port: registryPort },
|
|
445
|
-
...testAdvertise,
|
|
446
|
-
});
|
|
447
|
-
|
|
448
|
-
const disposeRegistry = await registry.listen(registryPort);
|
|
449
|
-
const disposeProvider = await provider.listen(providerPort);
|
|
450
|
-
const disposeConsumer = await consumer.listen(consumerPort);
|
|
451
|
-
const unregister = provider.register('/echo', async ({ data }) => {
|
|
452
|
-
return { value: data.value };
|
|
453
|
-
});
|
|
454
|
-
|
|
455
|
-
try {
|
|
456
|
-
// ... test logic ...
|
|
457
|
-
} finally {
|
|
458
|
-
unregister();
|
|
459
|
-
await disposeConsumer();
|
|
460
|
-
await disposeProvider();
|
|
461
|
-
await disposeRegistry();
|
|
462
|
-
}
|
|
463
|
-
```
|
|
464
|
-
|
|
465
|
-
### 4.2 call() 的 timeout + retries 参数顺序
|
|
466
|
-
|
|
467
|
-
```typescript
|
|
468
|
-
// signature: call(ns, url, data, timeout?, retries?)
|
|
469
|
-
await app.call('svc', '/api', data); // 默认超时 + 1 次重试
|
|
470
|
-
await app.call('svc', '/api', data, 5000); // 5s 超时 + 1 次重试
|
|
471
|
-
await app.call('svc', '/api', data, 5000, 0); // 5s 超时 + 不重试
|
|
472
|
-
await app.call('svc', '/api', data, undefined, 0); // 默认超时 + 不重试
|
|
473
|
-
```
|
|
474
|
-
|
|
475
|
-
### 4.3 超时测试 Handler
|
|
476
|
-
|
|
477
|
-
```typescript
|
|
478
|
-
// handler 延迟 500ms, call 超时 50ms → 应 reject
|
|
479
|
-
provider.register('/slow', async () => {
|
|
480
|
-
await new Promise(resolve => setTimeout(resolve, 500));
|
|
481
|
-
return { value: 'too-late' };
|
|
482
|
-
});
|
|
483
|
-
await expect(consumer.call('svc', '/slow', {}, 50, 0)).rejects.toThrow('Abort');
|
|
484
|
-
```
|
|
485
|
-
|
|
486
|
-
### 4.4 熔断测试模板
|
|
487
|
-
|
|
488
|
-
```typescript
|
|
489
|
-
// 两个 provider 同 namespace, 一个失败, 排除后应选另一个
|
|
490
|
-
// 见 circuit breaker test "excludes a failing peer and selects a different one"
|
|
491
|
-
|
|
492
|
-
// 单个 peer 全排除后应重置并重试该 peer
|
|
493
|
-
// 见 circuit breaker test "resets breaker when all peers are excluded"
|
|
494
|
-
```
|
|
495
|
-
|
|
496
|
-
### 4.5 缓存降级测试模板
|
|
497
|
-
|
|
498
|
-
```typescript
|
|
499
|
-
// 1. 首次 call → 建立缓存
|
|
500
|
-
// 2. 切换为失败 handler → call 失败 → peer 被排除
|
|
501
|
-
// 3. 切回成功 handler → call 带 exclude → Registry find 返回 undefined
|
|
502
|
-
// 4. catch 块降级 → 返回缓存 Client → 请求成功
|
|
503
|
-
```
|
|
504
|
-
|
|
505
|
-
---
|
|
506
|
-
|
|
507
|
-
## 5. 测试门禁(修改时必须遵守)
|
|
508
|
-
|
|
509
|
-
### 5.1 运行命令
|
|
510
|
-
|
|
511
|
-
```bash
|
|
512
|
-
pnpm --filter @hile/micro build # 必须通过
|
|
513
|
-
pnpm --filter @hile/micro test # 必须全部通过
|
|
514
|
-
```
|
|
515
|
-
|
|
516
|
-
### 5.2 测试覆盖要求
|
|
517
|
-
|
|
518
|
-
修改行为时至少覆盖:
|
|
519
|
-
|
|
520
|
-
| 场景 | 测试位置 |
|
|
521
|
-
|------|----------|
|
|
522
|
-
| 服务发现端到端 | `application discovery > resolves a provider through the registry` |
|
|
523
|
-
| listen teardown 后可重新 listen | `application discovery > allows listen again after teardown` |
|
|
524
|
-
| Registry 不可达时 listen 回滚 | `application discovery > rolls back listen when registry is unreachable` |
|
|
525
|
-
| 心跳保活 | `heartbeat > keeps client alive when heartbeats arrive on time` |
|
|
526
|
-
| 心跳超时剔除 | `heartbeat > disconnects client that stops sending heartbeats` |
|
|
527
|
-
| call() 基本调用 | `circuit breaker > call() returns data on success` |
|
|
528
|
-
| 熔断排除 | `circuit breaker > excludes a failing peer and selects a different one` |
|
|
529
|
-
| 全排除重置 | `circuit breaker > resets breaker when all peers are excluded` |
|
|
530
|
-
| 健康检查 | `health endpoint > /-/health returns status and registry state` |
|
|
531
|
-
| 超时 reject | `request timeout > rejects when request exceeds the timeout` |
|
|
532
|
-
| 超时充足则成功 | `request timeout > succeeds when timeout is long enough` |
|
|
533
|
-
| 缓存降级 | `cache degradation > uses cached client when registry lookup fails due to exclusion` |
|
|
534
|
-
| YAML 配置加载 | `config file loading > loads yaml config files on watchEnvFile` |
|
|
535
|
-
| YAML 配置热加载 | `config file loading > reloads config when yaml file changes` |
|
|
536
|
-
| configs 目录不存在 | `config file loading > does not crash when configs directory does not exist` |
|
|
537
|
-
| /-/env/variables 按字段过滤 | `/-/env/variables endpoint > returns requested config by namespace and fields` |
|
|
538
|
-
| /-/env/variables 全字段 | `/-/env/variables endpoint > returns all config when fields not specified` |
|
|
539
|
-
| 不存在的 namespace | `/-/env/variables endpoint > returns null value for non-existent namespace` |
|
|
540
|
-
| /-/env/variables 空列表 | `/-/env/variables endpoint > handles empty data list` |
|
|
541
|
-
| getEnvVariables 集成 | `Application.getEnvVariables > fetches config from Registry` |
|
|
542
|
-
| getEnvVariables 不存在 namespace | `Application.getEnvVariables > returns null when namespace config does not exist` |
|
|
543
|
-
| getRegistryConfigsDir | `config file utilities > getRegistryConfigsDir returns path ending with .registry/configs` |
|
|
544
|
-
| namespaceToConfigFile | `config file utilities > namespaceToConfigFile returns path with .config.yaml suffix` |
|
|
545
|
-
| parseConfigFilename 正例 | `config file utilities > parseConfigFilename extracts namespace from valid filename` |
|
|
546
|
-
| parseConfigFilename 反例 | `config file utilities > parseConfigFilename returns null for non-config file` |
|
|
547
|
-
|
|
548
|
-
### 5.3 测试规范(必须遵守)
|
|
549
|
-
|
|
550
|
-
- 端口必须使用 `getAvailablePort()` 获取
|
|
551
|
-
- 所有清理必须放在 `finally` 块中
|
|
552
|
-
- 清理顺序:`unregister()` → `disposeConsumer` → `disposeProvider` → `disposeRegistry`
|
|
553
|
-
- 禁止使用真实定时等待代替事件驱动(心跳测试是唯一例外,因其依赖实时时钟)
|
|
554
|
-
- 禁止 mock `Application`、`Registry`、`Server`、`Client` 的内部方法
|
|
555
|
-
- 禁止共享可变状态(每个测试独立端口)
|
|
556
|
-
|
|
557
|
-
---
|
|
558
|
-
|
|
559
|
-
## 6. 反模式(禁止)
|
|
560
|
-
|
|
561
|
-
1. **不要修改 WebSocket URL 三段式约定**不同时更新 `Server.onConnected` 和 `Server.connect` 的拼接格式
|
|
562
|
-
2. **不要在 Registry 中按 Set 迭代顺序固定返回第一个**(破坏负载分散)
|
|
563
|
-
3. **不要在 `Client.dispose()` 中删除 `socket.close()`**(会导致 WebSocketServer.close 长时间等待)
|
|
564
|
-
4. **不要假设 `host:port` 可无损表达 IPv6** — 使用 `[IPv6]:port` 格式,`parseAddressKey` 按最后一个 `:` 切分
|
|
565
|
-
5. **不要传错 Registry 端口** — 丢失 Registry 连接时依赖 `reconnectToRegistry`,不要在外部缓存 registry Client
|
|
566
|
-
6. **不要在其他文件中重复 Registry 的 helper 函数** — `selectRandomRegistryAddress`、`parseAddressKey`、`getRegistryConfigsDir`、`namespaceToConfigFile`、`parseConfigFilename` 都在 `registry.ts` 中导出复用
|
|
567
|
-
7. **不要给 call() 增加非可选参数** — `timeout` 和 `retries` 都在尾部且保持可选,不影响现有调用
|
|
568
|
-
|
|
569
|
-
---
|
|
570
|
-
|
|
571
|
-
## 7. 文件改动范围
|
|
572
|
-
|
|
573
|
-
| 文件 | 可修改 | 说明 |
|
|
574
|
-
|------|--------|------|
|
|
575
|
-
| `packages/micro/src/application.ts` | ✅ | 核心业务逻辑 |
|
|
576
|
-
| `packages/micro/src/index.test.ts` | ✅ | 测试(主测试文件) |
|
|
577
|
-
| `packages/micro/src/env-config.test.ts` | ✅ | 测试(环境变量配置测试) |
|
|
578
|
-
| `packages/micro/src/server.ts` | ❌ | 底层协议,不动 |
|
|
579
|
-
| `packages/micro/src/client.ts` | ❌ | 底层协议,不动 |
|
|
580
|
-
| `packages/micro/src/registry.ts` | ✅ | 注册中心(配置管理、路径工具函数) |
|
|
581
|
-
| `packages/micro/src/utils.ts` | ❌ | 工具函数,不动 |
|
|
582
|
-
| `packages/micro/README.md` | ✅ | 用户文档 |
|
|
583
|
-
| `packages/micro/SKILL.md` | ✅ | AI 参考文档 |
|
|
584
|
-
| `packages/cli/src/index.ts` | ✅ | CLI 入口(registry configs 子命令组) |
|
|
585
|
-
| `packages/cli/src/configs.ts` | ✅ | CLI 配置管理 handler(list/get/set/del) |
|
|
586
|
-
| `packages/cli/src/start.ts` | ❌ | 启动逻辑,不动 |
|
|
587
|
-
| `packages/cli/src/exitHook.ts` | ❌ | 退出钩子,不动 |
|
|
588
|
-
|
|
589
|
-
---
|
|
590
|
-
|
|
591
|
-
## 8. 参考文件
|
|
592
|
-
|
|
593
|
-
| 文件 | 用途 |
|
|
594
|
-
|------|------|
|
|
595
|
-
| `packages/micro/src/registry.ts` | 注册中心(含配置管理、路径工具函数) |
|
|
596
|
-
| `packages/micro/src/application.ts` | 应用服务(含 getEnvVariables) |
|
|
597
|
-
| `packages/micro/src/index.test.ts` | 主测试文件(27 个用例) |
|
|
598
|
-
| `packages/micro/src/env-config.test.ts` | 配置管理测试文件(13 个用例) |
|
|
599
|
-
| `packages/cli/src/index.ts` | CLI 入口(含 registry configs 子命令组) |
|
|
600
|
-
| `packages/cli/src/configs.ts` | CLI 配置管理 handler(list/get/set/del) |
|