@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 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
 
@@ -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): Promise<RegistryTopicSummary[]>;
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): Promise<RegistryTopicSnapshot & {
136
+ getRegistryTopic<T = unknown>(topic: string, options?: {
137
+ signal?: AbortSignal;
138
+ }): Promise<RegistryTopicSnapshot & {
127
139
  payload: T;
128
140
  } | undefined>;
129
141
  /**
@@ -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.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
+ }
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('/-/topics', prefix === undefined ? {} : { prefix }, this.registryRequestOptions());
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
  }
@@ -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": "4.0.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.0",
27
+ "@hile/context": "^4.0.1",
28
28
  "@hile/logger": "^4.0.0",
29
- "@hile/message-loader": "^4.0.0",
30
- "@hile/message-ws": "^4.0.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": "b46cb7f3705a226f58e4d65a2ff985ea54b9a159"
35
+ "gitHead": "46d7bcfc78a914aa2af8cd96e41b08511f5af38e"
36
36
  }