@hile/micro 4.0.0 → 4.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AI.md +6 -0
- package/README.md +2 -0
- package/dist/application.d.ts +18 -6
- package/dist/application.js +64 -15
- package/dist/client.d.ts +7 -3
- package/dist/registry.d.ts +6 -0
- package/dist/registry.js +10 -2
- package/dist/server.d.ts +4 -1
- package/dist/server.js +57 -10
- package/package.json +5 -5
package/AI.md
CHANGED
|
@@ -133,6 +133,7 @@ Use the message packages for request/response messaging over WebSocket, process
|
|
|
133
133
|
- Do not use `stream()` for normal single-result calls.
|
|
134
134
|
- Do not rely on message IDs for business idempotency. They are transport IDs.
|
|
135
135
|
- Do not bypass `defineMessage()` for file-loaded handlers.
|
|
136
|
+
- Do not pass zero, fractional, non-finite, or oversized message timeouts. Explicit timeout values must be safe integers from `1` through `2_147_483_647` milliseconds.
|
|
136
137
|
|
|
137
138
|
## Install
|
|
138
139
|
|
|
@@ -163,7 +164,11 @@ import { MessageWorkerThread } from '@hile/message-worker-thread'
|
|
|
163
164
|
- `MessageLoader` maps `*.msg.*` files to routes using `@hile/loader`.
|
|
164
165
|
- `MessageLoader.dispatch(path, data, extras?)` invokes the matched handler.
|
|
165
166
|
- `MessageModem._send()` returns a `Promise`.
|
|
167
|
+
- `MessageModem._send()` and `_push()` use a `30_000` ms timeout when none is provided. An explicit timeout must be a safe integer from `1` through `2_147_483_647`; invalid values throw `TypeError` before a message is sent.
|
|
166
168
|
- `MessageModem._stream()` returns a Node `Readable` in object mode.
|
|
169
|
+
- Stream `timeout` and `idleTimeout` values use the same `1` through `2_147_483_647` ms range. The stream `window` must be a safe integer from `1` through `64` and defaults to `1`.
|
|
170
|
+
- Each modem schedules request, total-stream, and idle-stream deadlines through one internal deadline scheduler. This reduces active Node.js timers without changing timeout, cancellation, ordering, or error semantics.
|
|
171
|
+
- `@hile/message-ws` keeps public `decodeMessageFrame()` payloads isolated from caller-owned input by default. Its owned WebSocket `RawData` path uses a zero-copy binary Flight payload view internally.
|
|
167
172
|
- A stream request requires `exec()` to return an async iterable.
|
|
168
173
|
- `Application.call(namespace, url, data, options?)` returns a promise.
|
|
169
174
|
- `Application.stream(namespace, url, data, options?)` returns a readable stream.
|
|
@@ -183,6 +188,7 @@ import { MessageWorkerThread } from '@hile/message-worker-thread'
|
|
|
183
188
|
- Message files default-export `defineMessage(...)`.
|
|
184
189
|
- RPC callers use `await app.call(...)`.
|
|
185
190
|
- Streaming handlers are async generators.
|
|
191
|
+
- Custom modem timeout values use the documented safe-integer range.
|
|
186
192
|
- Registry is started before application nodes need discovery.
|
|
187
193
|
- Micro apps use stable namespaces and advertise reachable hosts.
|
|
188
194
|
|
package/README.md
CHANGED
|
@@ -69,6 +69,7 @@ const result = await app.call('example.service', '/ping', { hello: 'world' })
|
|
|
69
69
|
- Do not use `stream()` for normal single-result calls.
|
|
70
70
|
- Do not rely on message IDs for business idempotency. They are transport IDs.
|
|
71
71
|
- Do not bypass `defineMessage()` for file-loaded handlers.
|
|
72
|
+
- Do not pass zero, fractional, non-finite, or oversized message timeouts. Explicit timeout values must be safe integers from `1` through `2_147_483_647` milliseconds.
|
|
72
73
|
|
|
73
74
|
- Appending a secondary response getter to `client.request('/x', data)`
|
|
74
75
|
- Returning a plain object from a handler called through `stream()`.
|
|
@@ -80,6 +81,7 @@ const result = await app.call('example.service', '/ping', { hello: 'world' })
|
|
|
80
81
|
- Message files default-export `defineMessage(...)`.
|
|
81
82
|
- RPC callers use `await app.call(...)`.
|
|
82
83
|
- Streaming handlers are async generators.
|
|
84
|
+
- Custom modem timeout values use the documented safe-integer range.
|
|
83
85
|
- Registry is started before application nodes need discovery.
|
|
84
86
|
- Micro apps use stable namespaces and advertise reachable hosts.
|
|
85
87
|
|
package/dist/application.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { Client } from './client.js';
|
|
1
|
+
import { Client, type ClientStreamOptions } from './client.js';
|
|
2
2
|
import { Server, type MicroServerProps } from './server.js';
|
|
3
|
-
import type { RegistryAddress, RegistryTopicSnapshot, RegistryTopicSummary } from './registry';
|
|
3
|
+
import type { RegistryAddress, RegistryTopicSnapshot, RegistryTopicSnapshotsResult, RegistryTopicSummary } from './registry';
|
|
4
4
|
type UnionToIntersection<U> = (U extends any ? (x: U) => void : never) extends (x: infer I) => void ? I : never;
|
|
5
5
|
type EnvRequest<T extends Record<string, Record<string, any>>> = {
|
|
6
6
|
[N in keyof T]: {
|
|
@@ -68,6 +68,7 @@ export declare class Application extends Server {
|
|
|
68
68
|
private readonly publishedTopicDirty;
|
|
69
69
|
private readonly publishedTopicVersions;
|
|
70
70
|
private readonly publishedTopicSignatures;
|
|
71
|
+
private readonly pendingUnpublishes;
|
|
71
72
|
private readonly topicSyncs;
|
|
72
73
|
private readonly topicUpdateVersions;
|
|
73
74
|
private publishIntentVersion;
|
|
@@ -112,18 +113,29 @@ export declare class Application extends Server {
|
|
|
112
113
|
retries?: number;
|
|
113
114
|
signal?: AbortSignal;
|
|
114
115
|
}): Promise<T>;
|
|
115
|
-
stream(namespace: string, url: string, data: any, options?: {
|
|
116
|
-
signal?: AbortSignal;
|
|
116
|
+
stream(namespace: string, url: string, data: any, options?: ClientStreamOptions & {
|
|
117
117
|
retries?: number;
|
|
118
118
|
}): Promise<import('stream').Readable>;
|
|
119
|
+
/** Opens a stream against one exact service instance without registry selection. */
|
|
120
|
+
streamPeer(address: RegistryAddress, url: string, data: any, options?: ClientStreamOptions): Promise<import('stream').Readable>;
|
|
119
121
|
publish<T = any>(topic: string, data: T): Promise<{
|
|
120
122
|
update: (payload: T) => Promise</*elided*/ any>;
|
|
121
123
|
unpublish: () => Promise</*elided*/ any>;
|
|
122
124
|
}>;
|
|
125
|
+
/** Removes a publication intent and retries its Registry tombstone until acknowledged. */
|
|
126
|
+
unpublish(topic: string, expectedVersion?: number): Promise<void>;
|
|
123
127
|
/** Reads Registry topic metadata without creating a pub/sub subscription. */
|
|
124
|
-
listRegistryTopics(prefix?: string
|
|
128
|
+
listRegistryTopics(prefix?: string, options?: {
|
|
129
|
+
signal?: AbortSignal;
|
|
130
|
+
}): Promise<RegistryTopicSummary[]>;
|
|
131
|
+
/** Reads current topic payloads in one Registry round trip. */
|
|
132
|
+
listRegistryTopicSnapshots(prefix?: string, options?: {
|
|
133
|
+
signal?: AbortSignal;
|
|
134
|
+
}): Promise<RegistryTopicSnapshotsResult['topics']>;
|
|
125
135
|
/** Reads one retained/current Registry topic payload without subscribing to it. */
|
|
126
|
-
getRegistryTopic<T = unknown>(topic: string
|
|
136
|
+
getRegistryTopic<T = unknown>(topic: string, options?: {
|
|
137
|
+
signal?: AbortSignal;
|
|
138
|
+
}): Promise<RegistryTopicSnapshot & {
|
|
127
139
|
payload: T;
|
|
128
140
|
} | undefined>;
|
|
129
141
|
/**
|
package/dist/application.js
CHANGED
|
@@ -113,6 +113,7 @@ export class Application extends Server {
|
|
|
113
113
|
publishedTopicDirty = new Set();
|
|
114
114
|
publishedTopicVersions = new Map();
|
|
115
115
|
publishedTopicSignatures = new Map();
|
|
116
|
+
pendingUnpublishes = new Set();
|
|
116
117
|
topicSyncs = new Map();
|
|
117
118
|
topicUpdateVersions = new Map();
|
|
118
119
|
publishIntentVersion = 0;
|
|
@@ -245,6 +246,7 @@ export class Application extends Server {
|
|
|
245
246
|
return { timeout: this._registryLookupTimeoutMs };
|
|
246
247
|
}
|
|
247
248
|
recordPublishedTopic(topic, payload) {
|
|
249
|
+
this.pendingUnpublishes.delete(topic);
|
|
248
250
|
const signature = stablePayloadSignature(payload);
|
|
249
251
|
const previousSignature = this.publishedTopicSignatures.get(topic);
|
|
250
252
|
const version = this.publishedTopics.has(topic) && previousSignature === signature
|
|
@@ -380,6 +382,11 @@ export class Application extends Server {
|
|
|
380
382
|
});
|
|
381
383
|
});
|
|
382
384
|
this.registry = registry;
|
|
385
|
+
for (const topic of [...this.pendingUnpublishes]) {
|
|
386
|
+
await this.syncUnpublishedTopic(topic, { propagateError: true });
|
|
387
|
+
if (!this.publishedTopics.has(topic))
|
|
388
|
+
this.pendingUnpublishes.delete(topic);
|
|
389
|
+
}
|
|
383
390
|
// 重新声明所有仍处于发布状态的 topic
|
|
384
391
|
for (const topic of [...this.publishedTopics.keys()]) {
|
|
385
392
|
if (this.stopped || this.listenGeneration !== generation) {
|
|
@@ -784,7 +791,7 @@ export class Application extends Server {
|
|
|
784
791
|
}
|
|
785
792
|
}
|
|
786
793
|
async stream(namespace, url, data, options) {
|
|
787
|
-
const { signal, retries = 1 } = options || {};
|
|
794
|
+
const { signal, retries = 1, timeout, idleTimeout, window } = options || {};
|
|
788
795
|
let remainingRetries = retries;
|
|
789
796
|
let retrySourceError;
|
|
790
797
|
let hasRetrySourceError = false;
|
|
@@ -800,7 +807,7 @@ export class Application extends Server {
|
|
|
800
807
|
}
|
|
801
808
|
const { client, probe } = selected;
|
|
802
809
|
try {
|
|
803
|
-
const readable = client.stream(url, data, { signal });
|
|
810
|
+
const readable = client.stream(url, data, { signal, timeout, idleTimeout, window });
|
|
804
811
|
return this.trackCircuitStream(namespace, client.host, client.port, probe, readable);
|
|
805
812
|
}
|
|
806
813
|
catch (err) {
|
|
@@ -816,6 +823,19 @@ export class Application extends Server {
|
|
|
816
823
|
}
|
|
817
824
|
}
|
|
818
825
|
}
|
|
826
|
+
/** Opens a stream against one exact service instance without registry selection. */
|
|
827
|
+
async streamPeer(address, url, data, options) {
|
|
828
|
+
assertValidRegistrySocket('peer address', address.host, address.port);
|
|
829
|
+
if (options?.timeout !== undefined && (!Number.isSafeInteger(options.timeout) || options.timeout < 1 || options.timeout > 2_147_483_647)) {
|
|
830
|
+
throw new TypeError('Stream timeout must be a positive safe integer not exceeding 2147483647');
|
|
831
|
+
}
|
|
832
|
+
const startedAt = Date.now();
|
|
833
|
+
const client = await this.connect(address.host, address.port, options?.timeout, options?.signal);
|
|
834
|
+
const timeout = options?.timeout === undefined
|
|
835
|
+
? undefined
|
|
836
|
+
: Math.max(1, options.timeout - (Date.now() - startedAt));
|
|
837
|
+
return client.stream(url, data, { ...options, timeout });
|
|
838
|
+
}
|
|
819
839
|
async publish(topic, data) {
|
|
820
840
|
this.assertCanUsePubSub();
|
|
821
841
|
const snapshot = createPubSubPayloadSnapshot(topic, data);
|
|
@@ -834,32 +854,61 @@ export class Application extends Server {
|
|
|
834
854
|
return ref;
|
|
835
855
|
},
|
|
836
856
|
unpublish: async () => {
|
|
837
|
-
this.
|
|
838
|
-
if (this.publishedTopicVersions.get(topic) !== refVersion)
|
|
839
|
-
return ref;
|
|
840
|
-
this.publishedTopics.delete(topic);
|
|
841
|
-
this.publishedTopicRevisions.delete(topic);
|
|
842
|
-
this.publishedTopicDirty.delete(topic);
|
|
843
|
-
this.publishedTopicVersions.delete(topic);
|
|
844
|
-
this.publishedTopicSignatures.delete(topic);
|
|
845
|
-
await this.syncUnpublishedTopic(topic);
|
|
857
|
+
await this.unpublish(topic, refVersion);
|
|
846
858
|
return ref;
|
|
847
859
|
}
|
|
848
860
|
};
|
|
849
861
|
return ref;
|
|
850
862
|
}
|
|
863
|
+
/** Removes a publication intent and retries its Registry tombstone until acknowledged. */
|
|
864
|
+
async unpublish(topic, expectedVersion) {
|
|
865
|
+
const currentVersion = this.publishedTopicVersions.get(topic);
|
|
866
|
+
if (expectedVersion !== undefined && currentVersion !== expectedVersion && !this.pendingUnpublishes.has(topic))
|
|
867
|
+
return;
|
|
868
|
+
if (expectedVersion === undefined || currentVersion === expectedVersion) {
|
|
869
|
+
this.publishedTopics.delete(topic);
|
|
870
|
+
this.publishedTopicRevisions.delete(topic);
|
|
871
|
+
this.publishedTopicDirty.delete(topic);
|
|
872
|
+
this.publishedTopicVersions.delete(topic);
|
|
873
|
+
this.publishedTopicSignatures.delete(topic);
|
|
874
|
+
this.pendingUnpublishes.add(topic);
|
|
875
|
+
}
|
|
876
|
+
if (!this.canUsePubSub()) {
|
|
877
|
+
this.pendingUnpublishes.delete(topic);
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
if (!this.registry) {
|
|
881
|
+
this.ensureRegistryReconnectScheduled();
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
if (!this.pendingUnpublishes.has(topic))
|
|
885
|
+
return;
|
|
886
|
+
await this.syncUnpublishedTopic(topic, { propagateError: true });
|
|
887
|
+
if (!this.publishedTopics.has(topic))
|
|
888
|
+
this.pendingUnpublishes.delete(topic);
|
|
889
|
+
}
|
|
851
890
|
/** Reads Registry topic metadata without creating a pub/sub subscription. */
|
|
852
|
-
async listRegistryTopics(prefix) {
|
|
891
|
+
async listRegistryTopics(prefix, options) {
|
|
892
|
+
const registry = this.registry;
|
|
893
|
+
if (!registry) {
|
|
894
|
+
this.ensureRegistryReconnectScheduled();
|
|
895
|
+
throw new Error('Registry is not connected');
|
|
896
|
+
}
|
|
897
|
+
const result = await registry.request('/-/topics', prefix === undefined ? {} : { prefix }, { ...this.registryRequestOptions(), signal: options?.signal });
|
|
898
|
+
return structuredClone(result.topics);
|
|
899
|
+
}
|
|
900
|
+
/** Reads current topic payloads in one Registry round trip. */
|
|
901
|
+
async listRegistryTopicSnapshots(prefix, options) {
|
|
853
902
|
const registry = this.registry;
|
|
854
903
|
if (!registry) {
|
|
855
904
|
this.ensureRegistryReconnectScheduled();
|
|
856
905
|
throw new Error('Registry is not connected');
|
|
857
906
|
}
|
|
858
|
-
const result = await registry.request('/-/
|
|
907
|
+
const result = await registry.request('/-/topic/snapshots', prefix === undefined ? {} : { prefix }, { ...this.registryRequestOptions(), signal: options?.signal });
|
|
859
908
|
return structuredClone(result.topics);
|
|
860
909
|
}
|
|
861
910
|
/** Reads one retained/current Registry topic payload without subscribing to it. */
|
|
862
|
-
async getRegistryTopic(topic) {
|
|
911
|
+
async getRegistryTopic(topic, options) {
|
|
863
912
|
if (typeof topic !== 'string' || topic.length === 0) {
|
|
864
913
|
throw new TypeError('Registry topic must not be empty');
|
|
865
914
|
}
|
|
@@ -868,7 +917,7 @@ export class Application extends Server {
|
|
|
868
917
|
this.ensureRegistryReconnectScheduled();
|
|
869
918
|
throw new Error('Registry is not connected');
|
|
870
919
|
}
|
|
871
|
-
const snapshot = await registry.request('/-/topic/get', { topic }, this.registryRequestOptions());
|
|
920
|
+
const snapshot = await registry.request('/-/topic/get', { topic }, { ...this.registryRequestOptions(), signal: options?.signal });
|
|
872
921
|
return snapshot === undefined ? undefined : structuredClone(snapshot);
|
|
873
922
|
}
|
|
874
923
|
/**
|
package/dist/client.d.ts
CHANGED
|
@@ -9,6 +9,12 @@ export interface ClientProps {
|
|
|
9
9
|
server: Server;
|
|
10
10
|
ws: WebSocket;
|
|
11
11
|
}
|
|
12
|
+
export interface ClientStreamOptions {
|
|
13
|
+
signal?: AbortSignal;
|
|
14
|
+
timeout?: number;
|
|
15
|
+
idleTimeout?: number;
|
|
16
|
+
window?: number;
|
|
17
|
+
}
|
|
12
18
|
export type MicroMessageMetadata = {
|
|
13
19
|
context?: ContextInput<ContextData>;
|
|
14
20
|
[key: string]: unknown;
|
|
@@ -39,8 +45,6 @@ export declare class Client extends MessageWs {
|
|
|
39
45
|
timeout?: number;
|
|
40
46
|
signal?: AbortSignal;
|
|
41
47
|
}): void;
|
|
42
|
-
stream(url: string, data: any, options?:
|
|
43
|
-
signal?: AbortSignal;
|
|
44
|
-
}): import("node:stream").Readable;
|
|
48
|
+
stream(url: string, data: any, options?: ClientStreamOptions): import("node:stream").Readable;
|
|
45
49
|
dispose(): void;
|
|
46
50
|
}
|
package/dist/registry.d.ts
CHANGED
|
@@ -42,6 +42,11 @@ export interface RegistryTopicSummary {
|
|
|
42
42
|
export interface RegistryTopicsResult {
|
|
43
43
|
topics: RegistryTopicSummary[];
|
|
44
44
|
}
|
|
45
|
+
export interface RegistryTopicSnapshotsResult {
|
|
46
|
+
topics: Array<RegistryTopicSnapshot & {
|
|
47
|
+
publishers: RegistryAddress[];
|
|
48
|
+
}>;
|
|
49
|
+
}
|
|
45
50
|
export interface RegistryTopicGetData {
|
|
46
51
|
topic: string;
|
|
47
52
|
}
|
|
@@ -96,6 +101,7 @@ export declare class Registry extends Server {
|
|
|
96
101
|
private createTopicSummary;
|
|
97
102
|
private createTopicSnapshot;
|
|
98
103
|
private listTopicSummaries;
|
|
104
|
+
private listTopicEntries;
|
|
99
105
|
private registerReadApis;
|
|
100
106
|
private cleanupTopicIfUnused;
|
|
101
107
|
private clearTopicData;
|
package/dist/registry.js
CHANGED
|
@@ -182,11 +182,13 @@ export class Registry extends Server {
|
|
|
182
182
|
};
|
|
183
183
|
}
|
|
184
184
|
listTopicSummaries(prefix) {
|
|
185
|
+
return this.listTopicEntries(prefix).map(([topic, entry]) => this.createTopicSummary(topic, entry));
|
|
186
|
+
}
|
|
187
|
+
listTopicEntries(prefix) {
|
|
185
188
|
const hasPrefix = typeof prefix === 'string' && prefix.length > 0;
|
|
186
189
|
return [...this.topics.entries()]
|
|
187
190
|
.filter(([topic]) => !hasPrefix || topic.startsWith(prefix))
|
|
188
|
-
.sort(([a], [b]) => a.localeCompare(b))
|
|
189
|
-
.map(([topic, entry]) => this.createTopicSummary(topic, entry));
|
|
191
|
+
.sort(([a], [b]) => a.localeCompare(b));
|
|
190
192
|
}
|
|
191
193
|
registerReadApis() {
|
|
192
194
|
this.fallbacks.add(this.register('/-/namespaces', async () => {
|
|
@@ -215,6 +217,12 @@ export class Registry extends Server {
|
|
|
215
217
|
this.fallbacks.add(this.register('/-/topics', async ({ data }) => ({
|
|
216
218
|
topics: this.listTopicSummaries(data?.prefix),
|
|
217
219
|
})));
|
|
220
|
+
this.fallbacks.add(this.register('/-/topic/snapshots', async ({ data }) => ({
|
|
221
|
+
topics: this.listTopicEntries(data?.prefix).map(([topic, entry]) => ({
|
|
222
|
+
...this.createTopicSnapshot(topic, entry),
|
|
223
|
+
publishers: registryAddressesFromKeys(entry.publishers),
|
|
224
|
+
})),
|
|
225
|
+
})));
|
|
218
226
|
this.fallbacks.add(this.register('/-/topic/get', async ({ data }) => {
|
|
219
227
|
if (typeof data?.topic !== 'string')
|
|
220
228
|
return;
|
package/dist/server.d.ts
CHANGED
|
@@ -23,13 +23,16 @@ export declare class Server extends MessageLoader {
|
|
|
23
23
|
readonly logger: Logger | Console;
|
|
24
24
|
readonly clients: Map<string, Client>;
|
|
25
25
|
private readonly clientExtras;
|
|
26
|
+
private readonly pendingConnections;
|
|
26
27
|
private readonly announceHost;
|
|
27
28
|
readonly events: EventEmitter<any>;
|
|
28
29
|
get host(): string;
|
|
29
30
|
constructor(namespace: string, props?: MicroServerProps);
|
|
30
31
|
private upstream;
|
|
31
32
|
private createClient;
|
|
32
|
-
protected connect(host: string, port: number, timeout?: number): Promise<Client>;
|
|
33
|
+
protected connect(host: string, port: number, timeout?: number, signal?: AbortSignal): Promise<Client>;
|
|
34
|
+
private openConnection;
|
|
35
|
+
private waitForConnection;
|
|
33
36
|
listen(port?: number): Promise<() => Promise<void>>;
|
|
34
37
|
setPort(port: number): this;
|
|
35
38
|
handleUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer): this;
|
package/dist/server.js
CHANGED
|
@@ -12,6 +12,7 @@ export class Server extends MessageLoader {
|
|
|
12
12
|
logger;
|
|
13
13
|
clients = new Map();
|
|
14
14
|
clientExtras = new Map();
|
|
15
|
+
pendingConnections = new Map();
|
|
15
16
|
announceHost;
|
|
16
17
|
events = new EventEmitter();
|
|
17
18
|
get host() {
|
|
@@ -79,41 +80,83 @@ export class Server extends MessageLoader {
|
|
|
79
80
|
this.events.emit('connect', client, extras);
|
|
80
81
|
return client;
|
|
81
82
|
}
|
|
82
|
-
async connect(host, port, timeout = DEFAULT_CONNECT_TIMEOUT) {
|
|
83
|
+
async connect(host, port, timeout = DEFAULT_CONNECT_TIMEOUT, signal) {
|
|
83
84
|
const key = `${host}:${port}`;
|
|
84
85
|
if (this.clients.has(key)) {
|
|
85
86
|
return this.clients.get(key);
|
|
86
87
|
}
|
|
87
88
|
if (!this.port)
|
|
88
89
|
throw new Error('You can not connect to a server without a local port, please use `.setPort(port)` for local port.');
|
|
89
|
-
|
|
90
|
+
let pending = this.pendingConnections.get(key);
|
|
91
|
+
if (!pending) {
|
|
92
|
+
const controller = new AbortController();
|
|
93
|
+
const promise = this.openConnection(host, port, controller.signal)
|
|
94
|
+
.then(ws => this.createClient(ws, host, port))
|
|
95
|
+
.finally(() => { this.pendingConnections.delete(key); });
|
|
96
|
+
promise.catch(() => undefined);
|
|
97
|
+
pending = { promise, controller, waiters: 0 };
|
|
98
|
+
this.pendingConnections.set(key, pending);
|
|
99
|
+
}
|
|
100
|
+
pending.waiters++;
|
|
101
|
+
try {
|
|
102
|
+
return await this.waitForConnection(pending.promise, timeout, signal);
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
pending.waiters--;
|
|
106
|
+
if (pending.waiters === 0 && this.pendingConnections.get(key) === pending)
|
|
107
|
+
pending.controller.abort();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
openConnection(host, port, signal) {
|
|
111
|
+
return new Promise((resolve, reject) => {
|
|
90
112
|
const ws = new WebSocket(`ws://${host}:${port}/${this.announceHost}/${this.port}/${this.namespace}`);
|
|
91
|
-
const
|
|
113
|
+
const clear = () => {
|
|
114
|
+
ws.off('open', onopen);
|
|
115
|
+
ws.off('error', onerror);
|
|
116
|
+
signal.removeEventListener('abort', onabort);
|
|
117
|
+
};
|
|
118
|
+
const terminate = () => {
|
|
92
119
|
clear();
|
|
93
120
|
ws.on('error', () => { });
|
|
94
121
|
try {
|
|
95
122
|
ws.terminate();
|
|
96
123
|
}
|
|
97
124
|
catch { }
|
|
98
|
-
reject(new Error('Connection timeout'));
|
|
99
|
-
}, timeout).unref();
|
|
100
|
-
const clear = () => {
|
|
101
|
-
clearTimeout(timer);
|
|
102
|
-
ws.off('open', onopen);
|
|
103
|
-
ws.off('error', onerror);
|
|
104
125
|
};
|
|
105
126
|
const onerror = (err) => {
|
|
106
127
|
clear();
|
|
107
128
|
reject(err);
|
|
108
129
|
};
|
|
130
|
+
const onabort = () => {
|
|
131
|
+
reject(signal.reason ?? new Error('Connection aborted'));
|
|
132
|
+
terminate();
|
|
133
|
+
};
|
|
109
134
|
const onopen = () => {
|
|
110
135
|
clear();
|
|
111
136
|
resolve(ws);
|
|
112
137
|
};
|
|
113
138
|
ws.on('open', onopen);
|
|
114
139
|
ws.on('error', onerror);
|
|
140
|
+
signal.addEventListener('abort', onabort, { once: true });
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
waitForConnection(promise, timeout, signal) {
|
|
144
|
+
if (signal?.aborted)
|
|
145
|
+
return Promise.reject(signal.reason ?? new Error('Connection aborted'));
|
|
146
|
+
return new Promise((resolve, reject) => {
|
|
147
|
+
const timer = Number.isFinite(timeout) && timeout > 0
|
|
148
|
+
? setTimeout(() => finish(reject, new Error('Connection timeout')), timeout).unref()
|
|
149
|
+
: undefined;
|
|
150
|
+
const onabort = () => finish(reject, signal?.reason ?? new Error('Connection aborted'));
|
|
151
|
+
const finish = (settle, value) => {
|
|
152
|
+
if (timer)
|
|
153
|
+
clearTimeout(timer);
|
|
154
|
+
signal?.removeEventListener('abort', onabort);
|
|
155
|
+
settle(value);
|
|
156
|
+
};
|
|
157
|
+
signal?.addEventListener('abort', onabort, { once: true });
|
|
158
|
+
promise.then(client => finish(resolve, client), error => finish(reject, error));
|
|
115
159
|
});
|
|
116
|
-
return this.createClient(ws, host, port);
|
|
117
160
|
}
|
|
118
161
|
async listen(port = 0) {
|
|
119
162
|
if (port > 0) {
|
|
@@ -141,6 +184,10 @@ export class Server extends MessageLoader {
|
|
|
141
184
|
this.wss = new WebSocketServer({ noServer: true });
|
|
142
185
|
}
|
|
143
186
|
return async () => {
|
|
187
|
+
for (const pending of this.pendingConnections.values())
|
|
188
|
+
pending.controller.abort();
|
|
189
|
+
await Promise.allSettled([...this.pendingConnections.values()].map(item => item.promise));
|
|
190
|
+
this.pendingConnections.clear();
|
|
144
191
|
if (this.wss) {
|
|
145
192
|
// terminate 立即销毁 socket,不等待对端 close frame
|
|
146
193
|
// 避免 graceful close 时对端无响应导致 HTTP server 无法关闭
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hile/micro",
|
|
3
|
-
"version": "4.0.
|
|
3
|
+
"version": "4.0.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -24,13 +24,13 @@
|
|
|
24
24
|
"vitest": "^4.0.18"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@hile/context": "^4.0.
|
|
27
|
+
"@hile/context": "^4.0.1",
|
|
28
28
|
"@hile/logger": "^4.0.0",
|
|
29
|
-
"@hile/message-loader": "^4.0.
|
|
30
|
-
"@hile/message-ws": "^4.0.
|
|
29
|
+
"@hile/message-loader": "^4.0.2",
|
|
30
|
+
"@hile/message-ws": "^4.0.2",
|
|
31
31
|
"internal-ip": "^9.0.0",
|
|
32
32
|
"ws": "^8.21.0",
|
|
33
33
|
"yaml": "^2.9.0"
|
|
34
34
|
},
|
|
35
|
-
"gitHead": "
|
|
35
|
+
"gitHead": "46d7bcfc78a914aa2af8cd96e41b08511f5af38e"
|
|
36
36
|
}
|