@astropods/messaging 0.0.1 → 0.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.
@@ -135,6 +135,20 @@ export interface ConversationRequest {
135
135
  agentConfig?: AgentConfig;
136
136
  agentResponse?: AgentResponse;
137
137
  }
138
+ export interface ReconnectOptions {
139
+ /** Maximum number of reconnect attempts. Default: Infinity */
140
+ maxRetries?: number;
141
+ /** Initial delay before first retry in ms. Default: 500 */
142
+ initialDelayMs?: number;
143
+ /** Maximum delay between retries in ms. Default: 30_000 */
144
+ maxDelayMs?: number;
145
+ /** Apply full jitter to backoff delay. Default: true */
146
+ jitter?: boolean;
147
+ /** Maximum number of writes to queue during reconnect. Default: 1000 */
148
+ maxBufferSize?: number;
149
+ /** gRPC status codes that trigger a reconnect attempt. Default: UNAVAILABLE, DEADLINE_EXCEEDED, INTERNAL, RESOURCE_EXHAUSTED */
150
+ retryableStatusCodes?: number[];
151
+ }
138
152
  /**
139
153
  * MessagingClient provides a TypeScript interface to the Astro Messaging gRPC service
140
154
  */
@@ -149,9 +163,14 @@ export declare class MessagingClient extends EventEmitter {
149
163
  */
150
164
  connect(): Promise<void>;
151
165
  /**
152
- * Create a bidirectional conversation stream
166
+ * Connect with automatic retry on failure (exponential backoff).
167
+ * Emits 'reconnecting' before each retry and 'reconnected' on success after failures.
168
+ */
169
+ connectWithRetry(options?: ReconnectOptions): Promise<void>;
170
+ /**
171
+ * Create a bidirectional conversation stream with optional reconnect support
153
172
  */
154
- createConversationStream(): ConversationStream;
173
+ createConversationStream(options?: ReconnectOptions): ConversationStream;
155
174
  /**
156
175
  * Process a single message (server-side streaming)
157
176
  */
@@ -176,11 +195,31 @@ export declare class MessagingClient extends EventEmitter {
176
195
  close(): void;
177
196
  }
178
197
  /**
179
- * ConversationStream wraps a bidirectional gRPC stream
198
+ * ConversationStream wraps a bidirectional gRPC stream with automatic reconnection.
199
+ *
200
+ * Events:
201
+ * - 'response' — AgentResponse received from server
202
+ * - 'reconnecting' — { attempt, reason, delayMs } — before each retry delay
203
+ * - 'reconnected' — { attempt } — after a successful stream recreation
204
+ * - 'error' — non-retryable error OR max retries exceeded
205
+ * - 'end' — only on intentional close(), not on unexpected stream drop
180
206
  */
181
207
  export declare class ConversationStream extends EventEmitter {
208
+ private streamFactory;
182
209
  private stream;
183
- constructor(stream: any);
210
+ private writeBuffer;
211
+ private reconnecting;
212
+ private closed;
213
+ private retryCount;
214
+ private readonly opts;
215
+ constructor(streamFactory: () => any, options?: ReconnectOptions);
216
+ private attachHandlers;
217
+ private isRetryable;
218
+ private calculateDelay;
219
+ private scheduleReconnect;
220
+ private doReconnect;
221
+ private flushBuffer;
222
+ private write;
184
223
  /**
185
224
  * Send a message through the stream
186
225
  */
@@ -206,7 +245,7 @@ export declare class ConversationStream extends EventEmitter {
206
245
  */
207
246
  sendStatusUpdate(conversationId: string, status: StatusUpdate): void;
208
247
  /**
209
- * End the stream
248
+ * End the stream intentionally. Emits 'end' and prevents any further reconnects.
210
249
  */
211
250
  end(): void;
212
251
  }
@@ -38,6 +38,18 @@ const grpc = __importStar(require("@grpc/grpc-js"));
38
38
  const protoLoader = __importStar(require("@grpc/proto-loader"));
39
39
  const path_1 = require("path");
40
40
  const events_1 = require("events");
41
+ // gRPC status codes: DEADLINE_EXCEEDED=4, INTERNAL=13, UNAVAILABLE=14, RESOURCE_EXHAUSTED=8
42
+ const DEFAULT_RETRYABLE_STATUS_CODES = [4, 8, 13, 14];
43
+ function resolveReconnectOptions(options) {
44
+ return {
45
+ maxRetries: options.maxRetries ?? Infinity,
46
+ initialDelayMs: options.initialDelayMs ?? 500,
47
+ maxDelayMs: options.maxDelayMs ?? 30000,
48
+ jitter: options.jitter ?? true,
49
+ maxBufferSize: options.maxBufferSize ?? 1000,
50
+ retryableStatusCodes: options.retryableStatusCodes ?? DEFAULT_RETRYABLE_STATUS_CODES,
51
+ };
52
+ }
41
53
  /**
42
54
  * MessagingClient provides a TypeScript interface to the Astro Messaging gRPC service
43
55
  */
@@ -45,6 +57,7 @@ class MessagingClient extends events_1.EventEmitter {
45
57
  constructor(serverAddress) {
46
58
  super();
47
59
  this.serverAddress = serverAddress;
60
+ this.conversationStream = null;
48
61
  this.isConnected = false;
49
62
  }
50
63
  /**
@@ -67,14 +80,42 @@ class MessagingClient extends events_1.EventEmitter {
67
80
  this.emit('connected');
68
81
  }
69
82
  /**
70
- * Create a bidirectional conversation stream
83
+ * Connect with automatic retry on failure (exponential backoff).
84
+ * Emits 'reconnecting' before each retry and 'reconnected' on success after failures.
85
+ */
86
+ async connectWithRetry(options = {}) {
87
+ const opts = resolveReconnectOptions(options);
88
+ let retryCount = 0;
89
+ while (true) {
90
+ try {
91
+ await this.connect();
92
+ if (retryCount > 0) {
93
+ this.emit('reconnected', { attempt: retryCount });
94
+ }
95
+ return;
96
+ }
97
+ catch (err) {
98
+ if (retryCount >= opts.maxRetries) {
99
+ throw err;
100
+ }
101
+ const base = Math.min(opts.initialDelayMs * Math.pow(2, retryCount), opts.maxDelayMs);
102
+ const delayMs = opts.jitter ? base * (0.5 + Math.random() * 0.5) : base;
103
+ this.emit('reconnecting', { attempt: retryCount + 1, reason: err, delayMs });
104
+ await new Promise(resolve => setTimeout(resolve, delayMs));
105
+ retryCount++;
106
+ }
107
+ }
108
+ }
109
+ /**
110
+ * Create a bidirectional conversation stream with optional reconnect support
71
111
  */
72
- createConversationStream() {
112
+ createConversationStream(options) {
73
113
  if (!this.isConnected) {
74
114
  throw new Error('Client not connected. Call connect() first.');
75
115
  }
76
- this.conversationStream = this.client.ProcessConversation();
77
- return new ConversationStream(this.conversationStream);
116
+ const factory = () => this.client.ProcessConversation();
117
+ this.conversationStream = new ConversationStream(factory, options);
118
+ return this.conversationStream;
78
119
  }
79
120
  /**
80
121
  * Process a single message (server-side streaming)
@@ -169,57 +210,122 @@ class MessagingClient extends events_1.EventEmitter {
169
210
  }
170
211
  exports.MessagingClient = MessagingClient;
171
212
  /**
172
- * ConversationStream wraps a bidirectional gRPC stream
213
+ * ConversationStream wraps a bidirectional gRPC stream with automatic reconnection.
214
+ *
215
+ * Events:
216
+ * - 'response' — AgentResponse received from server
217
+ * - 'reconnecting' — { attempt, reason, delayMs } — before each retry delay
218
+ * - 'reconnected' — { attempt } — after a successful stream recreation
219
+ * - 'error' — non-retryable error OR max retries exceeded
220
+ * - 'end' — only on intentional close(), not on unexpected stream drop
173
221
  */
174
222
  class ConversationStream extends events_1.EventEmitter {
175
- constructor(stream) {
223
+ constructor(streamFactory, options = {}) {
176
224
  super();
177
- this.stream = stream;
178
- this.stream.on('data', (response) => {
225
+ this.streamFactory = streamFactory;
226
+ this.writeBuffer = [];
227
+ this.reconnecting = false;
228
+ this.closed = false;
229
+ this.retryCount = 0;
230
+ this.opts = resolveReconnectOptions(options);
231
+ this.stream = this.streamFactory();
232
+ this.attachHandlers(this.stream);
233
+ }
234
+ attachHandlers(stream) {
235
+ stream.on('data', (response) => {
236
+ this.retryCount = 0;
179
237
  this.emit('response', response);
180
238
  });
181
- this.stream.on('end', () => {
182
- this.emit('end');
239
+ stream.on('error', (error) => {
240
+ if (!this.closed && this.isRetryable(error)) {
241
+ this.scheduleReconnect(error);
242
+ }
243
+ else {
244
+ this.emit('error', error);
245
+ }
183
246
  });
184
- this.stream.on('error', (error) => {
185
- this.emit('error', error);
247
+ stream.on('end', () => {
248
+ if (!this.closed) {
249
+ this.scheduleReconnect(new Error('Stream ended unexpectedly'));
250
+ }
251
+ // If closed, 'end' was already emitted by end() — do nothing
186
252
  });
187
253
  }
254
+ isRetryable(error) {
255
+ return this.opts.retryableStatusCodes.includes(error.code);
256
+ }
257
+ calculateDelay() {
258
+ const base = Math.min(this.opts.initialDelayMs * Math.pow(2, this.retryCount), this.opts.maxDelayMs);
259
+ return this.opts.jitter ? base * (0.5 + Math.random() * 0.5) : base;
260
+ }
261
+ scheduleReconnect(reason) {
262
+ if (this.reconnecting || this.closed)
263
+ return;
264
+ if (this.retryCount >= this.opts.maxRetries) {
265
+ this.emit('error', new Error(`Max reconnection attempts (${this.opts.maxRetries}) exceeded`));
266
+ return;
267
+ }
268
+ const delayMs = this.calculateDelay();
269
+ this.reconnecting = true;
270
+ this.emit('reconnecting', { attempt: this.retryCount + 1, reason, delayMs });
271
+ setTimeout(() => this.doReconnect(), delayMs);
272
+ }
273
+ doReconnect() {
274
+ if (this.closed)
275
+ return;
276
+ this.retryCount++;
277
+ try {
278
+ this.stream = this.streamFactory();
279
+ this.attachHandlers(this.stream);
280
+ this.reconnecting = false;
281
+ this.emit('reconnected', { attempt: this.retryCount });
282
+ this.flushBuffer();
283
+ }
284
+ catch (err) {
285
+ this.reconnecting = false;
286
+ this.scheduleReconnect(err);
287
+ }
288
+ }
289
+ flushBuffer() {
290
+ const toFlush = this.writeBuffer.splice(0);
291
+ for (const request of toFlush) {
292
+ this.stream.write(request);
293
+ }
294
+ }
295
+ write(request) {
296
+ if (this.reconnecting || this.closed) {
297
+ if (this.writeBuffer.length >= this.opts.maxBufferSize) {
298
+ this.writeBuffer.shift(); // drop oldest
299
+ }
300
+ this.writeBuffer.push(request);
301
+ }
302
+ else {
303
+ this.stream.write(request);
304
+ }
305
+ }
188
306
  /**
189
307
  * Send a message through the stream
190
308
  */
191
309
  sendMessage(message) {
192
- const request = {
193
- message,
194
- };
195
- this.stream.write(request);
310
+ this.write({ message });
196
311
  }
197
312
  /**
198
313
  * Send platform feedback through the stream
199
314
  */
200
315
  sendFeedback(feedback) {
201
- const request = {
202
- feedback,
203
- };
204
- this.stream.write(request);
316
+ this.write({ feedback });
205
317
  }
206
318
  /**
207
319
  * Send agent configuration through the stream
208
320
  */
209
321
  sendAgentConfig(config) {
210
- const request = {
211
- agentConfig: config,
212
- };
213
- this.stream.write(request);
322
+ this.write({ agentConfig: config });
214
323
  }
215
324
  /**
216
325
  * Send a typed AgentResponse through the stream
217
326
  */
218
327
  sendAgentResponse(response) {
219
- const request = {
220
- agentResponse: response,
221
- };
222
- this.stream.write(request);
328
+ this.write({ agentResponse: response });
223
329
  }
224
330
  /**
225
331
  * Send a content chunk (START/DELTA/END) for a conversation
@@ -240,10 +346,13 @@ class ConversationStream extends events_1.EventEmitter {
240
346
  });
241
347
  }
242
348
  /**
243
- * End the stream
349
+ * End the stream intentionally. Emits 'end' and prevents any further reconnects.
244
350
  */
245
351
  end() {
352
+ this.closed = true;
353
+ this.writeBuffer = [];
246
354
  this.stream.end();
355
+ this.emit('end');
247
356
  }
248
357
  }
249
358
  exports.ConversationStream = ConversationStream;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@astropods/messaging",
3
3
  "license": "Apache-2.0",
4
- "version": "0.0.1",
4
+ "version": "0.0.2",
5
5
  "description": "TypeScript SDK for Astro Messaging",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
@@ -9,7 +9,7 @@
9
9
  "dist"
10
10
  ],
11
11
  "scripts": {
12
- "postinstall": "ln -sf ../../proto proto",
12
+ "postinstall": "rm -rf proto && ln -sf ../../proto proto",
13
13
  "build": "tsc && cp -r ../../proto dist/proto",
14
14
  "watch": "tsc --watch",
15
15
  "test": "bun test",