@hile/micro 1.0.3 → 1.0.5

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,3 +1,4 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import { Server } from './server.js';
2
3
  var RegistryLookupStatus;
3
4
  (function (RegistryLookupStatus) {
@@ -5,45 +6,194 @@ var RegistryLookupStatus;
5
6
  RegistryLookupStatus[RegistryLookupStatus["PENDING"] = 1] = "PENDING";
6
7
  RegistryLookupStatus[RegistryLookupStatus["READY"] = 2] = "READY";
7
8
  })(RegistryLookupStatus || (RegistryLookupStatus = {}));
9
+ function assertValidRegistrySocket(meta, host, port) {
10
+ if (typeof host !== 'string' || !host || host.length > 253) {
11
+ throw new Error(`Invalid ${meta}: empty or oversized host`);
12
+ }
13
+ if (/[\s\r\n\0]/.test(host))
14
+ throw new Error(`Invalid ${meta}: host contains whitespace`);
15
+ if (!Number.isFinite(port) || port !== Math.trunc(port) || port < 1 || port > 65535) {
16
+ throw new Error(`Invalid ${meta}: port must be integer 1..65535`);
17
+ }
18
+ if (host.includes(':') && !host.startsWith('[')) {
19
+ throw new Error(`Invalid ${meta}: IPv6 host must be bracketed (e.g. [::1])`);
20
+ }
21
+ if (host.includes('/') || host.includes('?')) {
22
+ throw new Error(`Invalid ${meta}: illegal host characters`);
23
+ }
24
+ }
25
+ function withTimeout(promise, ms, label) {
26
+ if (!Number.isFinite(ms) || ms <= 0)
27
+ return promise;
28
+ let timer;
29
+ return Promise.race([
30
+ promise,
31
+ new Promise((_, reject) => {
32
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
33
+ }),
34
+ ]).finally(() => {
35
+ if (timer)
36
+ clearTimeout(timer);
37
+ });
38
+ }
8
39
  export class Application extends Server {
9
40
  registry;
10
41
  reconnectTimeout;
42
+ registryReconnectPromise;
43
+ /** 为 true 时不再向 Registry 重连(listen 返回的 teardown 已触发) */
44
+ stopped = false;
11
45
  _registry_address;
46
+ _registryLookupTimeoutMs;
47
+ _requestTimeoutMs;
12
48
  namespaces = new Map();
49
+ static HEARTBEAT_INTERVAL = 10000;
50
+ heartbeatTimer;
51
+ static CB_COOLDOWN_MS = 30_000;
52
+ circuitBreakers = new Map();
13
53
  constructor(props) {
14
- const { namespace, registry, ...loaderProps } = props;
15
- super(namespace, loaderProps);
54
+ const { namespace, registry, registryLookupTimeoutMs = 10_000, requestTimeoutMs = 30_000, ...microAndLoader } = props;
55
+ super(namespace, microAndLoader);
56
+ assertValidRegistrySocket('registry address', registry.host, registry.port);
16
57
  this._registry_address = registry;
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
+ }));
17
66
  }
18
67
  async listen(port = 0) {
68
+ this.stopped = false;
19
69
  const callback = await super.listen(port);
20
- await this.reconnectToRegistry();
70
+ try {
71
+ await this.reconnectToRegistry();
72
+ this.startHeartbeat();
73
+ }
74
+ catch (err) {
75
+ try {
76
+ await callback();
77
+ }
78
+ catch {
79
+ // ignore secondary errors from teardown
80
+ }
81
+ throw err;
82
+ }
21
83
  return async () => {
22
- if (this.reconnectTimeout)
84
+ this.stopped = true;
85
+ this.stopHeartbeat();
86
+ if (this.reconnectTimeout) {
23
87
  clearTimeout(this.reconnectTimeout);
88
+ this.reconnectTimeout = undefined;
89
+ }
90
+ this.registry = undefined;
24
91
  await callback();
25
92
  };
26
93
  }
94
+ scheduleRegistryRetry() {
95
+ if (this.stopped)
96
+ return;
97
+ if (this.reconnectTimeout)
98
+ clearTimeout(this.reconnectTimeout);
99
+ this.reconnectTimeout = setTimeout(() => {
100
+ this.reconnectTimeout = undefined;
101
+ void this.reconnectToRegistry().catch(() => {
102
+ if (this.stopped)
103
+ return;
104
+ this.scheduleRegistryRetry();
105
+ });
106
+ }, 3000);
107
+ }
27
108
  async reconnectToRegistry() {
28
- const registry = await this.connect(this._registry_address.host, this._registry_address.port);
29
- registry.events.on('disconnect', () => {
30
- this.registry = undefined;
31
- const reconnect = () => {
32
- this.reconnectToRegistry().catch(e => {
33
- this.reconnectTimeout = setTimeout(reconnect, 3000);
109
+ if (this.stopped)
110
+ return;
111
+ if (this.registryReconnectPromise)
112
+ return this.registryReconnectPromise;
113
+ this.registryReconnectPromise = (async () => {
114
+ const registry = await this.connect(this._registry_address.host, this._registry_address.port);
115
+ if (this.stopped) {
116
+ registry.dispose();
117
+ return;
118
+ }
119
+ registry.events.once('disconnect', () => {
120
+ if (this.registry !== registry)
121
+ return;
122
+ this.registry = undefined;
123
+ if (this.stopped)
124
+ return;
125
+ void this.reconnectToRegistry().catch(() => {
126
+ if (this.stopped)
127
+ return;
128
+ this.scheduleRegistryRetry();
34
129
  });
35
- };
36
- reconnect();
130
+ });
131
+ this.registry = registry;
132
+ })().finally(() => {
133
+ this.registryReconnectPromise = undefined;
37
134
  });
38
- this.registry = registry;
135
+ return this.registryReconnectPromise;
136
+ }
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
+ }
39
155
  }
40
- async findFromRegistry(namespace) {
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) {
41
191
  if (!this.registry)
42
192
  throw new Error('Registry not found');
43
- const { response } = this.registry.request('/-/find', { namespace });
44
- return await response();
193
+ const { response } = this.registry.request('/-/find', { namespace, exclude });
194
+ return await withTimeout(response(), this._registryLookupTimeoutMs, 'Registry /-/find');
45
195
  }
46
- get(namespace) {
196
+ get(namespace, exclude) {
47
197
  if (!this.namespaces.has(namespace)) {
48
198
  this.namespaces.set(namespace, {
49
199
  host: '',
@@ -53,6 +203,16 @@ export class Application extends Server {
53
203
  });
54
204
  }
55
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;
209
+ if (stack.status === RegistryLookupStatus.READY &&
210
+ (!this.clients.has(`${stack.host}:${stack.port}`) ||
211
+ (exclude?.length && exclude.includes(`${stack.host}:${stack.port}`)))) {
212
+ stack.status = RegistryLookupStatus.IDLE;
213
+ stack.host = '';
214
+ stack.port = 0;
215
+ }
56
216
  const key = `${stack.host}:${stack.port}`;
57
217
  if (stack.status === RegistryLookupStatus.READY && this.clients.has(key)) {
58
218
  return Promise.resolve(this.clients.get(key));
@@ -61,9 +221,10 @@ export class Application extends Server {
61
221
  stack.handlers.add([resolve, reject]);
62
222
  if (stack.status === RegistryLookupStatus.IDLE) {
63
223
  stack.status = RegistryLookupStatus.PENDING;
64
- this.findFromRegistry(namespace).then(data => {
224
+ this.findFromRegistry(namespace, exclude).then(data => {
65
225
  if (!data)
66
226
  return Promise.reject(new Error('Namespace not found'));
227
+ assertValidRegistrySocket('peer address from registry', data.host, data.port);
67
228
  return this.connect(data.host, data.port).then(client => {
68
229
  stack.host = data.host;
69
230
  stack.port = data.port;
@@ -80,6 +241,19 @@ export class Application extends Server {
80
241
  }
81
242
  });
82
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
+ }
83
257
  this.namespaces.delete(namespace);
84
258
  for (const [_, reject] of stack.handlers.values()) {
85
259
  reject(e);
@@ -88,4 +262,35 @@ export class Application extends Server {
88
262
  }
89
263
  });
90
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
+ }
91
296
  }
@@ -1,15 +1,24 @@
1
- import { MessageLoaderProps } from '@hile/message-loader';
2
- import { Server } from './server.js';
1
+ import { Server, type MicroServerProps } from './server.js';
3
2
  export interface RegistryFindData {
4
3
  namespace: string;
4
+ exclude?: string[];
5
5
  }
6
6
  export interface RegistryAddress {
7
7
  host: string;
8
8
  port: number;
9
9
  }
10
+ /** 将 `host:port` 或 `[ipv6]:port` 形式的 key 解析为地址(端口取最后一个 `:` 之后) */
11
+ export declare function parseAddressKey(key: string): RegistryAddress | undefined;
10
12
  export declare function selectRandomRegistryAddress(keys: Iterable<string>): RegistryAddress | undefined;
11
13
  export declare class Registry extends Server {
12
14
  private readonly namespaces;
13
- constructor(props?: MessageLoaderProps);
15
+ private unregisterFind?;
16
+ private static readonly HEARTBEAT_INTERVAL;
17
+ private static readonly HEARTBEAT_TIMEOUT;
18
+ private readonly heartbeats;
19
+ constructor(props?: MicroServerProps);
20
+ listen(port?: number): Promise<() => Promise<void>>;
21
+ /** 幂等:重复调用会先注销上一条 `/-/find` 再注册,避免叠多条路由 */
14
22
  onFind(): void;
23
+ private mountFindHandler;
15
24
  }
package/dist/registry.js CHANGED
@@ -1,9 +1,24 @@
1
1
  import { Server } from './server.js';
2
+ /** 将 `host:port` 或 `[ipv6]:port` 形式的 key 解析为地址(端口取最后一个 `:` 之后) */
3
+ export function parseAddressKey(key) {
4
+ const i = key.lastIndexOf(':');
5
+ if (i <= 0 || i >= key.length - 1)
6
+ return undefined;
7
+ const host = key.slice(0, i);
8
+ const port = Number(key.slice(i + 1));
9
+ if (!host ||
10
+ !Number.isFinite(port) ||
11
+ port !== Math.trunc(port) ||
12
+ port < 1 ||
13
+ port > 65535) {
14
+ return undefined;
15
+ }
16
+ return { host, port };
17
+ }
2
18
  export function selectRandomRegistryAddress(keys) {
3
- const addresses = Array.from(keys).map((key) => {
4
- const [host, port] = key.split(':');
5
- return { host, port: Number(port) };
6
- });
19
+ const addresses = Array.from(keys)
20
+ .map(parseAddressKey)
21
+ .filter((a) => a !== undefined);
7
22
  if (addresses.length === 0)
8
23
  return;
9
24
  const index = Math.floor(Math.random() * addresses.length);
@@ -11,10 +26,15 @@ export function selectRandomRegistryAddress(keys) {
11
26
  }
12
27
  export class Registry extends Server {
13
28
  namespaces = new Map();
29
+ unregisterFind;
30
+ static HEARTBEAT_INTERVAL = 1000;
31
+ static HEARTBEAT_TIMEOUT = 20000;
32
+ heartbeats = new Map();
14
33
  constructor(props) {
15
- super('registry', props);
34
+ super('registry', props ?? {});
16
35
  this.events.on('connect', (client, extras) => {
17
36
  const key = client.host + ':' + client.port;
37
+ this.heartbeats.set(key, Date.now());
18
38
  const namespace = extras.join('/');
19
39
  if (!this.namespaces.has(namespace)) {
20
40
  this.namespaces.set(namespace, new Set());
@@ -23,6 +43,7 @@ export class Registry extends Server {
23
43
  });
24
44
  this.events.on('disconnect', (client, extras) => {
25
45
  const key = client.host + ':' + client.port;
46
+ this.heartbeats.delete(key);
26
47
  const namespace = extras.join('/');
27
48
  if (this.namespaces.has(namespace)) {
28
49
  const keys = this.namespaces.get(namespace);
@@ -34,14 +55,54 @@ export class Registry extends Server {
34
55
  }
35
56
  }
36
57
  });
37
- this.onFind();
58
+ this.mountFindHandler();
59
+ this.register('/-/heartbeat', async ({ client }) => {
60
+ if (!client)
61
+ return;
62
+ const key = client.host + ':' + client.port;
63
+ this.heartbeats.set(key, Date.now());
64
+ });
38
65
  }
66
+ async listen(port = 0) {
67
+ const teardown = await super.listen(port);
68
+ const timer = setInterval(() => {
69
+ const now = Date.now();
70
+ for (const [key, lastTime] of this.heartbeats) {
71
+ if (now - lastTime >= Registry.HEARTBEAT_TIMEOUT) {
72
+ const client = this.clients.get(key);
73
+ if (client) {
74
+ this.heartbeats.delete(key);
75
+ client.dispose();
76
+ }
77
+ }
78
+ }
79
+ }, Registry.HEARTBEAT_INTERVAL);
80
+ return async () => {
81
+ clearInterval(timer);
82
+ await teardown();
83
+ };
84
+ }
85
+ /** 幂等:重复调用会先注销上一条 `/-/find` 再注册,避免叠多条路由 */
39
86
  onFind() {
40
- this.register('/-/find', async ({ data }) => {
87
+ this.mountFindHandler();
88
+ }
89
+ mountFindHandler() {
90
+ if (this.unregisterFind) {
91
+ this.unregisterFind();
92
+ this.unregisterFind = undefined;
93
+ }
94
+ this.unregisterFind = this.register('/-/find', async ({ data }) => {
41
95
  const namespace = data.namespace;
42
- const keys = this.namespaces.get(namespace);
96
+ let keys = this.namespaces.get(namespace);
43
97
  if (!keys)
44
98
  return;
99
+ if (data.exclude?.length) {
100
+ const excludeSet = new Set(data.exclude);
101
+ const filtered = [...keys].filter(k => !excludeSet.has(k));
102
+ if (filtered.length === 0)
103
+ return;
104
+ keys = new Set(filtered);
105
+ }
45
106
  return selectRandomRegistryAddress(keys);
46
107
  });
47
108
  }
package/dist/server.d.ts CHANGED
@@ -3,14 +3,22 @@ import { Client } from './client.js';
3
3
  import { IncomingMessage } from 'http';
4
4
  import { EventEmitter } from 'node:events';
5
5
  import type { Duplex } from "node:stream";
6
+ /** {@link MessageLoaderProps} 加上出站 WebSocket 宣告地址 */
7
+ export type MicroServerProps = MessageLoaderProps & {
8
+ /**
9
+ * 出站连接 URL 中 `ws://{host}:{port}/{本段}/...` 的「本段」宣告地址。
10
+ * 缺省使用 `getLocalIPv4()`;若仍为 `undefined`(无可用 IPv4)则构造 {@link Server} 时抛错。
11
+ */
12
+ advertiseHost?: string;
13
+ };
6
14
  export declare class Server extends MessageLoader {
7
15
  private readonly namespace;
8
16
  private wss?;
9
17
  port?: number;
10
18
  protected readonly clients: Map<string, Client>;
11
- private readonly ipv4;
19
+ private readonly announceHost;
12
20
  readonly events: EventEmitter<any>;
13
- constructor(namespace: string, props?: MessageLoaderProps);
21
+ constructor(namespace: string, props?: MicroServerProps);
14
22
  private upstream;
15
23
  private createClient;
16
24
  protected connect(host: string, port: number, timeout?: number): Promise<Client>;
package/dist/server.js CHANGED
@@ -10,11 +10,17 @@ export class Server extends MessageLoader {
10
10
  wss;
11
11
  port;
12
12
  clients = new Map();
13
- ipv4 = getLocalIPv4();
13
+ announceHost;
14
14
  events = new EventEmitter();
15
15
  constructor(namespace, props = {}) {
16
- super(props);
16
+ const { advertiseHost, ...loaderProps } = props;
17
+ super(loaderProps);
17
18
  this.namespace = namespace;
19
+ const resolved = advertiseHost?.trim() || getLocalIPv4();
20
+ if (!resolved) {
21
+ throw new Error('Unable to resolve advertise host for @hile/micro Server: pass `advertiseHost` (e.g. "127.0.0.1") in constructor options, or ensure getLocalIPv4() returns an address.');
22
+ }
23
+ this.announceHost = resolved;
18
24
  this.events.on('connect', (client, extras) => {
19
25
  client.events.emit('connect', extras);
20
26
  });
@@ -34,13 +40,25 @@ export class Server extends MessageLoader {
34
40
  const [host, port, ...extras] = sp;
35
41
  if (!host || !port)
36
42
  return ws.close();
37
- this.createClient(ws, host, Number(port), extras);
43
+ const portNum = Number(port);
44
+ if (!Number.isFinite(portNum) ||
45
+ portNum !== Math.trunc(portNum) ||
46
+ portNum < 1 ||
47
+ portNum > 65535) {
48
+ return ws.close();
49
+ }
50
+ this.createClient(ws, host, portNum, extras);
38
51
  }
39
52
  createClient(ws, host, port, extras = []) {
40
53
  const key = `${host}:${port}`;
54
+ const previous = this.clients.get(key);
55
+ if (previous) {
56
+ this.clients.delete(key);
57
+ previous.dispose();
58
+ }
41
59
  const client = new Client({ server: this, ws, host, port });
42
60
  ws.on('close', () => {
43
- if (this.clients.has(key)) {
61
+ if (this.clients.get(key) === client) {
44
62
  this.clients.delete(key);
45
63
  }
46
64
  client.dispose();
@@ -58,7 +76,7 @@ export class Server extends MessageLoader {
58
76
  if (!this.port)
59
77
  throw new Error('You can not connect to a server without a local port, please use `.setPort(port)` for local port.');
60
78
  const ws = await new Promise((resolve, reject) => {
61
- const ws = new WebSocket(`ws://${host}:${port}/${this.ipv4}/${this.port}/${this.namespace}`);
79
+ const ws = new WebSocket(`ws://${host}:${port}/${this.announceHost}/${this.port}/${this.namespace}`);
62
80
  const timer = setTimeout(() => {
63
81
  clear();
64
82
  ws.on('error', () => { });
@@ -112,7 +130,8 @@ export class Server extends MessageLoader {
112
130
  this.wss = new WebSocketServer({ noServer: true });
113
131
  }
114
132
  return async () => {
115
- for (const client of this.clients.values()) {
133
+ const toDispose = [...this.clients.values()];
134
+ for (const client of toDispose) {
116
135
  client.dispose();
117
136
  }
118
137
  this.clients.clear();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hile/micro",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
@@ -28,5 +28,5 @@
28
28
  "internal-ip": "^9.0.0",
29
29
  "ws": "^8.19.0"
30
30
  },
31
- "gitHead": "fc98c3e1ecf57f6cb4aded9caef28da0a35b7f30"
31
+ "gitHead": "ddc82850295d26358cbffbb695e642cdb748339d"
32
32
  }