@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.
- package/README.md +284 -39
- package/SKILL.md +447 -86
- package/dist/application.d.ts +23 -4
- package/dist/application.js +223 -18
- package/dist/registry.d.ts +12 -3
- package/dist/registry.js +69 -8
- package/dist/server.d.ts +10 -2
- package/dist/server.js +25 -6
- package/package.json +2 -2
package/dist/application.js
CHANGED
|
@@ -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, ...
|
|
15
|
-
super(namespace,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
130
|
+
});
|
|
131
|
+
this.registry = registry;
|
|
132
|
+
})().finally(() => {
|
|
133
|
+
this.registryReconnectPromise = undefined;
|
|
37
134
|
});
|
|
38
|
-
this.
|
|
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
|
-
|
|
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
|
}
|
package/dist/registry.d.ts
CHANGED
|
@@ -1,15 +1,24 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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)
|
|
4
|
-
|
|
5
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
19
|
+
private readonly announceHost;
|
|
12
20
|
readonly events: EventEmitter<any>;
|
|
13
|
-
constructor(namespace: string, props?:
|
|
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
|
-
|
|
13
|
+
announceHost;
|
|
14
14
|
events = new EventEmitter();
|
|
15
15
|
constructor(namespace, props = {}) {
|
|
16
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
+
"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": "
|
|
31
|
+
"gitHead": "ddc82850295d26358cbffbb695e642cdb748339d"
|
|
32
32
|
}
|