@hile/micro 3.0.6 → 4.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.
@@ -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 { RegistryAddress } from './registry.js';
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,14 +113,31 @@ 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>;
127
+ /** Reads Registry topic metadata without creating a pub/sub subscription. */
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']>;
135
+ /** Reads one retained/current Registry topic payload without subscribing to it. */
136
+ getRegistryTopic<T = unknown>(topic: string, options?: {
137
+ signal?: AbortSignal;
138
+ }): Promise<RegistryTopicSnapshot & {
139
+ payload: T;
140
+ } | undefined>;
123
141
  /**
124
142
  * 对同一 topic 可多次 subscribe,各自独立回调。
125
143
  * 传入同一个 callback 引用第二次调用时幂等返回 unsubscribe,不重复注册。
@@ -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,20 +854,72 @@ export class Application extends Server {
834
854
  return ref;
835
855
  },
836
856
  unpublish: async () => {
837
- this.assertCanUsePubSub();
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
+ }
890
+ /** Reads Registry topic metadata without creating a pub/sub subscription. */
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) {
902
+ const registry = this.registry;
903
+ if (!registry) {
904
+ this.ensureRegistryReconnectScheduled();
905
+ throw new Error('Registry is not connected');
906
+ }
907
+ const result = await registry.request('/-/topic/snapshots', prefix === undefined ? {} : { prefix }, { ...this.registryRequestOptions(), signal: options?.signal });
908
+ return structuredClone(result.topics);
909
+ }
910
+ /** Reads one retained/current Registry topic payload without subscribing to it. */
911
+ async getRegistryTopic(topic, options) {
912
+ if (typeof topic !== 'string' || topic.length === 0) {
913
+ throw new TypeError('Registry topic must not be empty');
914
+ }
915
+ const registry = this.registry;
916
+ if (!registry) {
917
+ this.ensureRegistryReconnectScheduled();
918
+ throw new Error('Registry is not connected');
919
+ }
920
+ const snapshot = await registry.request('/-/topic/get', { topic }, { ...this.registryRequestOptions(), signal: options?.signal });
921
+ return snapshot === undefined ? undefined : structuredClone(snapshot);
922
+ }
851
923
  /**
852
924
  * 对同一 topic 可多次 subscribe,各自独立回调。
853
925
  * 传入同一个 callback 引用第二次调用时幂等返回 unsubscribe,不重复注册。
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;
@@ -30,7 +36,7 @@ export declare class Client extends MessageWs {
30
36
  readonly events: EventEmitter<any>;
31
37
  constructor(props: ClientProps);
32
38
  private startHeartbeat;
33
- protected exec(data: MicroMessage): Promise<any>;
39
+ protected exec(data: MicroMessage, signal?: AbortSignal): Promise<any>;
34
40
  request<T = any>(url: string, data: any, options?: {
35
41
  timeout?: number;
36
42
  signal?: AbortSignal;
@@ -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/client.js CHANGED
@@ -87,7 +87,7 @@ export class Client extends MessageWs {
87
87
  }
88
88
  }, checkInterval);
89
89
  }
90
- async exec(data) {
90
+ async exec(data, signal) {
91
91
  if (data.url === '/-/heartbeat') {
92
92
  this.lastHeartbeat = Date.now();
93
93
  return;
@@ -99,6 +99,7 @@ export class Client extends MessageWs {
99
99
  const result = await this.server.dispatch(data.url, data.data, {
100
100
  client: this,
101
101
  metadata: data.metadata,
102
+ signal,
102
103
  });
103
104
  if (context && isAsyncIterable(result)) {
104
105
  return bindAsyncIterableToContext(result, context);
@@ -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
- const ws = await new Promise((resolve, reject) => {
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 timer = setTimeout(() => {
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": "3.0.6",
3
+ "version": "4.0.1",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
@@ -18,18 +18,19 @@
18
18
  "access": "public"
19
19
  },
20
20
  "devDependencies": {
21
+ "@types/node": "^26.2.0",
21
22
  "@types/ws": "^8.18.1",
22
23
  "fix-esm-import-path": "^1.10.3",
23
24
  "vitest": "^4.0.18"
24
25
  },
25
26
  "dependencies": {
26
- "@hile/context": "^3.0.2",
27
- "@hile/logger": "^3.0.0",
28
- "@hile/message-loader": "^3.0.0",
29
- "@hile/message-ws": "^3.0.0",
27
+ "@hile/context": "^4.0.1",
28
+ "@hile/logger": "^4.0.0",
29
+ "@hile/message-loader": "^4.0.1",
30
+ "@hile/message-ws": "^4.0.1",
30
31
  "internal-ip": "^9.0.0",
31
32
  "ws": "^8.21.0",
32
33
  "yaml": "^2.9.0"
33
34
  },
34
- "gitHead": "f0ca6e772602138dd50bf5acd2f9d940b4fe505e"
35
+ "gitHead": "fe98c6f8cca2860ccbb213c575cfda44588cbd06"
35
36
  }