@hile/micro 1.0.4 → 1.0.6

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.
@@ -1,11 +1,28 @@
1
1
  import { Client } from './client.js';
2
2
  import { Server, type MicroServerProps } from './server.js';
3
3
  import { RegistryAddress } from './registry.js';
4
+ type UnionToIntersection<U> = (U extends any ? (x: U) => void : never) extends (x: infer I) => void ? I : never;
5
+ type EnvRequest<T extends Record<string, Record<string, any>>> = {
6
+ [N in keyof T]: {
7
+ namespace: N;
8
+ fields?: readonly (keyof T[N])[];
9
+ };
10
+ }[keyof T];
11
+ type EnvFieldsForRequest<T extends Record<string, Record<string, any>>, N extends keyof T, F> = F extends readonly (infer K extends keyof T[N])[] ? Pick<T[N], K> : T[N];
12
+ type EnvRequestResult<T extends Record<string, Record<string, any>>, R> = R extends {
13
+ namespace: infer N extends keyof T;
14
+ fields?: infer F;
15
+ } ? {
16
+ [K in N]: EnvFieldsForRequest<T, N, F>;
17
+ } : never;
18
+ export type GetEnvVariablesResult<T extends Record<string, Record<string, any>>, Requests extends readonly EnvRequest<T>[]> = UnionToIntersection<EnvRequestResult<T, Requests[number]>>;
4
19
  export type ApplicationProps = {
5
20
  namespace: string;
6
21
  registry: RegistryAddress;
7
22
  /** `/-/find` 等待响应的上限(毫秒),默认 `10000` */
8
23
  registryLookupTimeoutMs?: number;
24
+ /** 单次 request() 等待响应的上限(毫秒),默认 `30000` */
25
+ requestTimeoutMs?: number;
9
26
  } & MicroServerProps;
10
27
  export declare class Application extends Server {
11
28
  private registry?;
@@ -15,11 +32,24 @@ export declare class Application extends Server {
15
32
  private stopped;
16
33
  private readonly _registry_address;
17
34
  private readonly _registryLookupTimeoutMs;
35
+ private readonly _requestTimeoutMs;
18
36
  private readonly namespaces;
37
+ private static readonly HEARTBEAT_INTERVAL;
38
+ private heartbeatTimer?;
39
+ private static readonly CB_COOLDOWN_MS;
40
+ private readonly circuitBreakers;
19
41
  constructor(props: ApplicationProps);
20
42
  listen(port?: number): Promise<() => Promise<void>>;
21
43
  private scheduleRegistryRetry;
22
44
  private reconnectToRegistry;
45
+ private startHeartbeat;
46
+ private stopHeartbeat;
47
+ private recordSuccess;
48
+ private recordFailure;
49
+ private getActiveExcludes;
23
50
  private findFromRegistry;
24
- get(namespace: string): Promise<Client>;
51
+ get(namespace: string, exclude?: string[]): Promise<Client>;
52
+ call<T = any>(namespace: string, url: string, data: any, timeout?: number, retries?: number): Promise<T>;
53
+ getEnvVariables<T extends Record<string, Record<string, any>>, const Requests extends readonly EnvRequest<T>[] = readonly EnvRequest<T>[]>(...data: Requests): Promise<GetEnvVariablesResult<T, Requests>>;
25
54
  }
55
+ export {};
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import { Server } from './server.js';
2
3
  var RegistryLookupStatus;
3
4
  (function (RegistryLookupStatus) {
@@ -43,19 +44,32 @@ export class Application extends Server {
43
44
  stopped = false;
44
45
  _registry_address;
45
46
  _registryLookupTimeoutMs;
47
+ _requestTimeoutMs;
46
48
  namespaces = new Map();
49
+ static HEARTBEAT_INTERVAL = 10000;
50
+ heartbeatTimer;
51
+ static CB_COOLDOWN_MS = 30_000;
52
+ circuitBreakers = new Map();
47
53
  constructor(props) {
48
- const { namespace, registry, registryLookupTimeoutMs = 10_000, ...microAndLoader } = props;
54
+ const { namespace, registry, registryLookupTimeoutMs = 10_000, requestTimeoutMs = 30_000, ...microAndLoader } = props;
49
55
  super(namespace, microAndLoader);
50
56
  assertValidRegistrySocket('registry address', registry.host, registry.port);
51
57
  this._registry_address = registry;
52
58
  this._registryLookupTimeoutMs = registryLookupTimeoutMs;
59
+ this._requestTimeoutMs = requestTimeoutMs;
60
+ this.register('/-/health', async () => ({
61
+ status: 'ok',
62
+ registry: !!this.registry,
63
+ uptime: process.uptime(),
64
+ namespaces: [...this.namespaces.keys()],
65
+ }));
53
66
  }
54
67
  async listen(port = 0) {
55
68
  this.stopped = false;
56
69
  const callback = await super.listen(port);
57
70
  try {
58
71
  await this.reconnectToRegistry();
72
+ this.startHeartbeat();
59
73
  }
60
74
  catch (err) {
61
75
  try {
@@ -68,6 +82,7 @@ export class Application extends Server {
68
82
  }
69
83
  return async () => {
70
84
  this.stopped = true;
85
+ this.stopHeartbeat();
71
86
  if (this.reconnectTimeout) {
72
87
  clearTimeout(this.reconnectTimeout);
73
88
  this.reconnectTimeout = undefined;
@@ -119,14 +134,66 @@ export class Application extends Server {
119
134
  });
120
135
  return this.registryReconnectPromise;
121
136
  }
122
- async findFromRegistry(namespace) {
137
+ startHeartbeat() {
138
+ this.stopHeartbeat();
139
+ this.heartbeatTimer = setInterval(() => {
140
+ if (!this.registry)
141
+ return;
142
+ try {
143
+ this.registry.push('/-/heartbeat', {});
144
+ }
145
+ catch {
146
+ // registry connection may have dropped between null-check and push
147
+ }
148
+ }, Application.HEARTBEAT_INTERVAL);
149
+ }
150
+ stopHeartbeat() {
151
+ if (this.heartbeatTimer) {
152
+ clearInterval(this.heartbeatTimer);
153
+ this.heartbeatTimer = undefined;
154
+ }
155
+ }
156
+ recordSuccess(ns, host, port) {
157
+ const excludes = this.circuitBreakers.get(ns);
158
+ if (excludes) {
159
+ excludes.delete(`${host}:${port}`);
160
+ if (excludes.size === 0)
161
+ this.circuitBreakers.delete(ns);
162
+ }
163
+ }
164
+ recordFailure(ns, host, port) {
165
+ let excludes = this.circuitBreakers.get(ns);
166
+ if (!excludes) {
167
+ excludes = new Map();
168
+ this.circuitBreakers.set(ns, excludes);
169
+ }
170
+ excludes.set(`${host}:${port}`, Date.now());
171
+ }
172
+ getActiveExcludes(ns) {
173
+ const excludes = this.circuitBreakers.get(ns);
174
+ if (!excludes)
175
+ return [];
176
+ const now = Date.now();
177
+ const active = [];
178
+ for (const [key, openedAt] of excludes) {
179
+ if (now - openedAt >= Application.CB_COOLDOWN_MS) {
180
+ excludes.delete(key);
181
+ }
182
+ else {
183
+ active.push(key);
184
+ }
185
+ }
186
+ if (excludes.size === 0)
187
+ this.circuitBreakers.delete(ns);
188
+ return active;
189
+ }
190
+ async findFromRegistry(namespace, exclude) {
123
191
  if (!this.registry)
124
192
  throw new Error('Registry not found');
125
- const { response } = this.registry.request('/-/find', { namespace });
126
- const p = response();
127
- return await withTimeout(p, this._registryLookupTimeoutMs, 'Registry /-/find');
193
+ const { response } = this.registry.request('/-/find', { namespace, exclude });
194
+ return await withTimeout(response(), this._registryLookupTimeoutMs, 'Registry /-/find');
128
195
  }
129
- get(namespace) {
196
+ get(namespace, exclude) {
130
197
  if (!this.namespaces.has(namespace)) {
131
198
  this.namespaces.set(namespace, {
132
199
  host: '',
@@ -136,8 +203,12 @@ export class Application extends Server {
136
203
  });
137
204
  }
138
205
  const stack = this.namespaces.get(namespace);
206
+ // Save old cache info before potential invalidation (used for cache degradation)
207
+ const cachedHost = stack.host;
208
+ const cachedPort = stack.port;
139
209
  if (stack.status === RegistryLookupStatus.READY &&
140
- !this.clients.has(`${stack.host}:${stack.port}`)) {
210
+ (!this.clients.has(`${stack.host}:${stack.port}`) ||
211
+ (exclude?.length && exclude.includes(`${stack.host}:${stack.port}`)))) {
141
212
  stack.status = RegistryLookupStatus.IDLE;
142
213
  stack.host = '';
143
214
  stack.port = 0;
@@ -150,7 +221,7 @@ export class Application extends Server {
150
221
  stack.handlers.add([resolve, reject]);
151
222
  if (stack.status === RegistryLookupStatus.IDLE) {
152
223
  stack.status = RegistryLookupStatus.PENDING;
153
- this.findFromRegistry(namespace).then(data => {
224
+ this.findFromRegistry(namespace, exclude).then(data => {
154
225
  if (!data)
155
226
  return Promise.reject(new Error('Namespace not found'));
156
227
  assertValidRegistrySocket('peer address from registry', data.host, data.port);
@@ -170,6 +241,19 @@ export class Application extends Server {
170
241
  }
171
242
  });
172
243
  }).catch(e => {
244
+ // Registry unavailable but previously cached client still valid -> degrade
245
+ const cachedKey = `${cachedHost}:${cachedPort}`;
246
+ if (cachedHost && this.clients.has(cachedKey)) {
247
+ const client = this.clients.get(cachedKey);
248
+ // Restore cache so subsequent calls hit the fast path
249
+ stack.host = cachedHost;
250
+ stack.port = cachedPort;
251
+ stack.status = RegistryLookupStatus.READY;
252
+ for (const [resolve] of stack.handlers.values()) {
253
+ resolve(client);
254
+ }
255
+ return;
256
+ }
173
257
  this.namespaces.delete(namespace);
174
258
  for (const [_, reject] of stack.handlers.values()) {
175
259
  reject(e);
@@ -178,4 +262,46 @@ export class Application extends Server {
178
262
  }
179
263
  });
180
264
  }
265
+ async call(namespace, url, data, timeout, retries = 1) {
266
+ // Inject or preserve correlation ID (no mutation of original data)
267
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
268
+ data = { _correlationId: randomUUID(), data };
269
+ }
270
+ else if (!data._correlationId) {
271
+ data = { ...data, _correlationId: randomUUID() };
272
+ }
273
+ const exclude = this.getActiveExcludes(namespace);
274
+ let client;
275
+ try {
276
+ client = await this.get(namespace, exclude);
277
+ }
278
+ catch {
279
+ this.circuitBreakers.delete(namespace);
280
+ client = await this.get(namespace);
281
+ }
282
+ try {
283
+ const { response } = client.request(url, data, timeout ?? this._requestTimeoutMs);
284
+ const result = await response();
285
+ this.recordSuccess(namespace, client.host, client.port);
286
+ return result;
287
+ }
288
+ catch (err) {
289
+ this.recordFailure(namespace, client.host, client.port);
290
+ if (retries > 0) {
291
+ return this.call(namespace, url, data, timeout, retries - 1);
292
+ }
293
+ throw err;
294
+ }
295
+ }
296
+ async getEnvVariables(...data) {
297
+ if (!this.registry)
298
+ throw new Error('Registry not found');
299
+ const { response } = this.registry.request('/-/env/variables', data);
300
+ const configs = await response();
301
+ const out = {};
302
+ for (const { namespace, value } of configs) {
303
+ out[namespace] = value;
304
+ }
305
+ return out;
306
+ }
181
307
  }
@@ -1,6 +1,7 @@
1
1
  import { Server, type MicroServerProps } from './server.js';
2
2
  export interface RegistryFindData {
3
3
  namespace: string;
4
+ exclude?: string[];
4
5
  }
5
6
  export interface RegistryAddress {
6
7
  host: string;
@@ -9,11 +10,23 @@ export interface RegistryAddress {
9
10
  /** 将 `host:port` 或 `[ipv6]:port` 形式的 key 解析为地址(端口取最后一个 `:` 之后) */
10
11
  export declare function parseAddressKey(key: string): RegistryAddress | undefined;
11
12
  export declare function selectRandomRegistryAddress(keys: Iterable<string>): RegistryAddress | undefined;
13
+ export declare function getRegistryConfigsDir(): string;
14
+ export declare function namespaceToConfigFile(ns: string): string;
15
+ export declare function parseConfigFilename(filename: string): string | null;
12
16
  export declare class Registry extends Server {
13
17
  private readonly namespaces;
14
18
  private unregisterFind?;
19
+ private static readonly HEARTBEAT_INTERVAL;
20
+ private static readonly HEARTBEAT_TIMEOUT;
21
+ private readonly heartbeats;
22
+ private readonly workspace;
23
+ private readonly configFileSuffix;
24
+ private readonly configs;
15
25
  constructor(props?: MicroServerProps);
26
+ watchEnvFile(): import("node:fs").FSWatcher | undefined;
27
+ listen(port?: number): Promise<() => Promise<void>>;
16
28
  /** 幂等:重复调用会先注销上一条 `/-/find` 再注册,避免叠多条路由 */
17
29
  onFind(): void;
18
30
  private mountFindHandler;
31
+ private registerEnvVariables;
19
32
  }
package/dist/registry.js CHANGED
@@ -1,4 +1,8 @@
1
1
  import { Server } from './server.js';
2
+ import { homedir } from 'node:os';
3
+ import { resolve, join } from 'node:path';
4
+ import { existsSync, mkdirSync, readdirSync, readFileSync, watch } from 'node:fs';
5
+ import YAML from 'yaml';
2
6
  /** 将 `host:port` 或 `[ipv6]:port` 形式的 key 解析为地址(端口取最后一个 `:` 之后) */
3
7
  export function parseAddressKey(key) {
4
8
  const i = key.lastIndexOf(':');
@@ -24,13 +28,36 @@ export function selectRandomRegistryAddress(keys) {
24
28
  const index = Math.floor(Math.random() * addresses.length);
25
29
  return addresses[index];
26
30
  }
31
+ export function getRegistryConfigsDir() {
32
+ return resolve(homedir(), '.registry', 'configs');
33
+ }
34
+ export function namespaceToConfigFile(ns) {
35
+ return join(getRegistryConfigsDir(), `${ns}.config.yaml`);
36
+ }
37
+ export function parseConfigFilename(filename) {
38
+ if (!filename.endsWith('.config.yaml'))
39
+ return null;
40
+ return filename.slice(0, -'.config.yaml'.length);
41
+ }
27
42
  export class Registry extends Server {
28
43
  namespaces = new Map();
29
44
  unregisterFind;
30
- constructor(props) {
31
- super('registry', props ?? {});
45
+ static HEARTBEAT_INTERVAL = 1000;
46
+ static HEARTBEAT_TIMEOUT = 20000;
47
+ heartbeats = new Map();
48
+ workspace;
49
+ configFileSuffix = '.config.yaml';
50
+ configs = new Map();
51
+ constructor(props = {}) {
52
+ const workspace = resolve(homedir(), '.registry');
53
+ if (!existsSync(workspace)) {
54
+ mkdirSync(workspace, { recursive: true });
55
+ }
56
+ super('registry', props);
57
+ this.workspace = workspace;
32
58
  this.events.on('connect', (client, extras) => {
33
59
  const key = client.host + ':' + client.port;
60
+ this.heartbeats.set(key, Date.now());
34
61
  const namespace = extras.join('/');
35
62
  if (!this.namespaces.has(namespace)) {
36
63
  this.namespaces.set(namespace, new Set());
@@ -39,6 +66,7 @@ export class Registry extends Server {
39
66
  });
40
67
  this.events.on('disconnect', (client, extras) => {
41
68
  const key = client.host + ':' + client.port;
69
+ this.heartbeats.delete(key);
42
70
  const namespace = extras.join('/');
43
71
  if (this.namespaces.has(namespace)) {
44
72
  const keys = this.namespaces.get(namespace);
@@ -51,6 +79,65 @@ export class Registry extends Server {
51
79
  }
52
80
  });
53
81
  this.mountFindHandler();
82
+ this.registerEnvVariables();
83
+ this.register('/-/heartbeat', async ({ client }) => {
84
+ if (!client)
85
+ return;
86
+ const key = client.host + ':' + client.port;
87
+ this.heartbeats.set(key, Date.now());
88
+ });
89
+ }
90
+ watchEnvFile() {
91
+ const configFile = resolve(this.workspace, 'configs');
92
+ if (!existsSync(configFile))
93
+ return;
94
+ const configFiles = readdirSync(configFile).filter(filename => filename.endsWith(this.configFileSuffix));
95
+ for (const filename of configFiles) {
96
+ try {
97
+ const config = YAML.parse(readFileSync(resolve(configFile, filename), 'utf8'));
98
+ if (typeof config !== 'object' || config === null)
99
+ continue;
100
+ this.configs.set(parseConfigFilename(filename), config);
101
+ }
102
+ catch { }
103
+ }
104
+ return watch(configFile, (_, filename) => {
105
+ if (!filename?.endsWith(this.configFileSuffix))
106
+ return;
107
+ try {
108
+ const config = YAML.parse(readFileSync(resolve(configFile, filename), 'utf8'));
109
+ if (typeof config !== 'object' || config === null)
110
+ return;
111
+ this.configs.set(parseConfigFilename(filename), config);
112
+ }
113
+ catch { /* vim 替换文件时的中间态读错误,忽略 */ }
114
+ });
115
+ }
116
+ async listen(port = 0) {
117
+ const registry_port = process.env.REGISTRY_PORT ? Number(process.env.REGISTRY_PORT) : 0;
118
+ const _port = port || registry_port;
119
+ if (!_port || _port <= 0)
120
+ throw new Error('Unable to resolve registry port: pass `port` in constructor options, or ensure process.env.REGISTRY_PORT is set.');
121
+ const teardown = await super.listen(_port);
122
+ const timer = setInterval(() => {
123
+ const now = Date.now();
124
+ for (const [key, lastTime] of this.heartbeats) {
125
+ if (now - lastTime >= Registry.HEARTBEAT_TIMEOUT) {
126
+ const client = this.clients.get(key);
127
+ if (client) {
128
+ this.heartbeats.delete(key);
129
+ client.dispose();
130
+ }
131
+ }
132
+ }
133
+ }, Registry.HEARTBEAT_INTERVAL);
134
+ const watcher = this.watchEnvFile();
135
+ return async () => {
136
+ if (watcher)
137
+ watcher.close();
138
+ clearInterval(timer);
139
+ await teardown();
140
+ };
54
141
  }
55
142
  /** 幂等:重复调用会先注销上一条 `/-/find` 再注册,避免叠多条路由 */
56
143
  onFind() {
@@ -63,10 +150,34 @@ export class Registry extends Server {
63
150
  }
64
151
  this.unregisterFind = this.register('/-/find', async ({ data }) => {
65
152
  const namespace = data.namespace;
66
- const keys = this.namespaces.get(namespace);
153
+ let keys = this.namespaces.get(namespace);
67
154
  if (!keys)
68
155
  return;
156
+ if (data.exclude?.length) {
157
+ const excludeSet = new Set(data.exclude);
158
+ const filtered = [...keys].filter(k => !excludeSet.has(k));
159
+ if (filtered.length === 0)
160
+ return;
161
+ keys = new Set(filtered);
162
+ }
69
163
  return selectRandomRegistryAddress(keys);
70
164
  });
71
165
  }
166
+ registerEnvVariables() {
167
+ this.register('/-/env/variables', async ({ data }) => {
168
+ return data.map(({ namespace, fields }) => {
169
+ if (!this.configs.has(namespace)) {
170
+ return { namespace, value: null };
171
+ }
172
+ if (!fields?.length)
173
+ return { namespace, value: this.configs.get(namespace) };
174
+ const config = this.configs.get(namespace);
175
+ const value = {};
176
+ for (const field of fields) {
177
+ value[field] = config[field];
178
+ }
179
+ return { namespace, value };
180
+ });
181
+ });
182
+ }
72
183
  }
package/dist/server.d.ts CHANGED
@@ -18,6 +18,7 @@ export declare class Server extends MessageLoader {
18
18
  protected readonly clients: Map<string, Client>;
19
19
  private readonly announceHost;
20
20
  readonly events: EventEmitter<any>;
21
+ get host(): string;
21
22
  constructor(namespace: string, props?: MicroServerProps);
22
23
  private upstream;
23
24
  private createClient;
package/dist/server.js CHANGED
@@ -12,6 +12,9 @@ export class Server extends MessageLoader {
12
12
  clients = new Map();
13
13
  announceHost;
14
14
  events = new EventEmitter();
15
+ get host() {
16
+ return this.announceHost;
17
+ }
15
18
  constructor(namespace, props = {}) {
16
19
  const { advertiseHost, ...loaderProps } = props;
17
20
  super(loaderProps);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hile/micro",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
@@ -26,7 +26,8 @@
26
26
  "@hile/message-loader": "^1.0.8",
27
27
  "@hile/message-ws": "^1.0.6",
28
28
  "internal-ip": "^9.0.0",
29
- "ws": "^8.19.0"
29
+ "ws": "^8.19.0",
30
+ "yaml": "^2.9.0"
30
31
  },
31
- "gitHead": "46d4b998c1fd2fd725bf484ca634f25b61f19cd5"
32
+ "gitHead": "b2272b434848ff9d93a88796e25a66665afcaa0a"
32
33
  }