@ioka-technologies/asyncapi-ts-client-template 0.0.35 → 0.0.41

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.
@@ -11,7 +11,7 @@ export default function ({ asyncapi, params }) {
11
11
  return (
12
12
  <File name="websocket.ts">
13
13
  {`import { v4 as uuidv4 } from 'uuid';
14
- import { Transport, TransportConfig, RequestOptions, ResponseHandler, MessageEnvelope, EnvelopeCallback } from '../types';
14
+ import { Transport, TransportConfig, RequestOptions, ResponseHandler, MessageEnvelope, EnvelopeCallback, ReconnectConfig } from '../types';
15
15
  import { TransportError, ConnectionError, TimeoutError } from '../errors';
16
16
  import { generateAuthHeaders, generateAuthQueryParams, hasAuthCredentials, AuthError, UnauthorizedError, AuthCredentials } from '../auth';
17
17
  import { createRetryManager, getRetryConfig } from '../retry';
@@ -54,74 +54,212 @@ export class WebSocketTransport implements Transport {
54
54
  private subscriptions: Map<string, Set<EnvelopeCallback>> = new Map();
55
55
  private operationSubscriptions: Map<string, Set<(payload: any) => void>> = new Map();
56
56
  private channelOperations: Map<string, string> = new Map(); // Track operation for each channel
57
+
58
+ // Reconnect state
59
+ private intentionalDisconnect = false;
60
+ private _isConnected = false;
57
61
  private reconnectAttempts = 0;
58
- private maxReconnectAttempts = 5;
59
- private reconnectDelay = 1000;
62
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
63
+ private connectPromise: Promise<void> | null = null;
64
+ private pendingConnectionReject: ((error: Error) => void) | null = null;
65
+ private connectionGeneration = 0;
66
+ private hasConnectedSuccessfully = false;
67
+ private reconnectConfig: Required<ReconnectConfig>;
68
+
69
+ // Event listener registries (for application-layer listeners beyond config callbacks)
70
+ private reconnectedListeners = new Set<() => void>();
71
+ private disconnectedListeners = new Set<(reason: string) => void>();
60
72
 
61
73
  constructor(config: TransportConfig) {
62
74
  this.config = config;
75
+ this.reconnectConfig = {
76
+ enabled: config.reconnect?.enabled ?? true,
77
+ maxAttempts: config.reconnect?.maxAttempts ?? 0, // 0 = unlimited
78
+ baseDelay: config.reconnect?.baseDelay ?? 1000,
79
+ maxDelay: config.reconnect?.maxDelay ?? 30000,
80
+ backoffMultiplier: config.reconnect?.backoffMultiplier ?? 2,
81
+ jitter: config.reconnect?.jitter ?? true,
82
+ };
63
83
  // WebSocketImpl will be set during connect()
64
84
  }
65
85
 
66
- async connect(): Promise<void> {
86
+ /**
87
+ * Check if the transport is currently connected
88
+ */
89
+ isConnected(): boolean {
90
+ return this._isConnected && this.ws !== null && this.ws.readyState === this.ws.OPEN;
91
+ }
92
+
93
+ /**
94
+ * Register a callback for when the transport reconnects after a disconnect.
95
+ * Returns an unsubscribe function.
96
+ */
97
+ onReconnected(callback: () => void): () => void {
98
+ this.reconnectedListeners.add(callback);
99
+ return () => { this.reconnectedListeners.delete(callback); };
100
+ }
101
+
102
+ /**
103
+ * Register a callback for when the transport disconnects unexpectedly.
104
+ * Returns an unsubscribe function.
105
+ */
106
+ onDisconnected(callback: (reason: string) => void): () => void {
107
+ this.disconnectedListeners.add(callback);
108
+ return () => { this.disconnectedListeners.delete(callback); };
109
+ }
110
+
111
+ connect(): Promise<void> {
112
+ if (this.isConnected()) return Promise.resolve();
113
+ if (this.connectPromise) return this.connectPromise;
114
+ this.intentionalDisconnect = false;
115
+ this.connectPromise = this.createConnection().finally(() => {
116
+ this.connectPromise = null;
117
+ if (!this.isConnected()) this.attemptReconnect();
118
+ });
119
+ return this.connectPromise;
120
+ }
121
+
122
+ private async createConnection(): Promise<void> {
67
123
  if (!this.WebSocketImpl) {
68
124
  this.WebSocketImpl = await getWebSocketImpl();
69
125
  }
70
126
 
71
127
  return new Promise((resolve, reject) => {
128
+ let settled = false;
129
+ this.pendingConnectionReject = reject;
130
+ const generation = ++this.connectionGeneration;
131
+ const fail = (error: Error): void => {
132
+ if (!settled) { settled = true; reject(error); }
133
+ this.pendingConnectionReject = null;
134
+ };
72
135
  try {
73
- this.ws = new this.WebSocketImpl(this.config.url) as WebSocketLike;
136
+ const socket = new this.WebSocketImpl(this.config.url) as WebSocketLike;
137
+ this.ws = socket;
138
+ const isCurrent = (): boolean => generation === this.connectionGeneration && this.ws === socket;
74
139
 
75
140
  // Handle both browser and Node.js WebSocket APIs
76
- if (typeof window !== 'undefined' && this.ws.addEventListener) {
141
+ if (typeof window !== 'undefined' && socket.addEventListener) {
77
142
  // Browser WebSocket API
78
- this.ws.addEventListener('open', () => {
143
+ socket.addEventListener('open', () => {
144
+ if (!isCurrent()) return;
145
+ settled = true;
146
+ this.pendingConnectionReject = null;
147
+ const wasReconnect = this.hasConnectedSuccessfully;
148
+ this.hasConnectedSuccessfully = true;
79
149
  this.reconnectAttempts = 0;
150
+ this._isConnected = true;
151
+ if (wasReconnect) {
152
+ this.config.connectionCallbacks?.onReconnected?.();
153
+ this.reconnectedListeners.forEach(cb => cb());
154
+ this.resubscribeAll();
155
+ } else {
156
+ this.config.connectionCallbacks?.onConnected?.();
157
+ }
80
158
  resolve();
81
159
  });
82
160
 
83
- this.ws.addEventListener('message', (event: MessageEvent) => {
84
- this.handleMessage(event.data);
161
+ socket.addEventListener('message', (event: MessageEvent) => {
162
+ if (isCurrent()) this.handleMessage(event.data);
85
163
  });
86
164
 
87
- this.ws.addEventListener('close', () => {
165
+ socket.addEventListener('close', () => {
166
+ if (!isCurrent()) return;
167
+ this._isConnected = false;
168
+ this.ws = null;
169
+ fail(new ConnectionError('WebSocket connection closed'));
88
170
  this.handleDisconnect();
89
171
  });
90
172
 
91
- this.ws.addEventListener('error', (event: Event) => {
92
- reject(new ConnectionError(\`WebSocket connection failed: \${event.type}\`));
173
+ socket.addEventListener('error', (event: Event) => {
174
+ if (!isCurrent()) return;
175
+ if (!this._isConnected) {
176
+ // Error during initial connection
177
+ fail(new ConnectionError(\`WebSocket connection failed: \${event.type}\`));
178
+ } else {
179
+ // Error on established connection — handleDisconnect will be called by 'close'
180
+ console.error('WebSocket error on established connection:', event);
181
+ this.config.connectionCallbacks?.onError?.(
182
+ new ConnectionError('WebSocket error')
183
+ );
184
+ }
93
185
  });
94
- } else if (this.ws.on) {
186
+ } else if (socket.on) {
95
187
  // Node.js ws library API
96
- this.ws.on('open', () => {
188
+ socket.on('open', () => {
189
+ if (!isCurrent()) return;
190
+ settled = true;
191
+ this.pendingConnectionReject = null;
192
+ const wasReconnect = this.hasConnectedSuccessfully;
193
+ this.hasConnectedSuccessfully = true;
97
194
  this.reconnectAttempts = 0;
195
+ this._isConnected = true;
196
+ if (wasReconnect) {
197
+ this.config.connectionCallbacks?.onReconnected?.();
198
+ this.reconnectedListeners.forEach(cb => cb());
199
+ this.resubscribeAll();
200
+ } else {
201
+ this.config.connectionCallbacks?.onConnected?.();
202
+ }
98
203
  resolve();
99
204
  });
100
205
 
101
- this.ws.on('message', (data: any) => {
102
- this.handleMessage(data.toString());
206
+ socket.on('message', (data: any) => {
207
+ if (isCurrent()) this.handleMessage(data.toString());
103
208
  });
104
209
 
105
- this.ws.on('close', () => {
210
+ socket.on('close', () => {
211
+ if (!isCurrent()) return;
212
+ this._isConnected = false;
213
+ this.ws = null;
214
+ fail(new ConnectionError('WebSocket connection closed'));
106
215
  this.handleDisconnect();
107
216
  });
108
217
 
109
- this.ws.on('error', (error: any) => {
110
- reject(new ConnectionError(\`WebSocket connection failed: \${error.message}\`));
218
+ socket.on('error', (error: any) => {
219
+ if (!isCurrent()) return;
220
+ if (!this._isConnected) {
221
+ // Error during initial connection
222
+ fail(new ConnectionError(\`WebSocket connection failed: \${error.message}\`));
223
+ } else {
224
+ // Error on established connection — handleDisconnect will be called by 'close'
225
+ console.error('WebSocket error on established connection:', error);
226
+ this.config.connectionCallbacks?.onError?.(
227
+ new ConnectionError(\`WebSocket error: \${error.message}\`)
228
+ );
229
+ }
111
230
  });
112
231
  } else {
113
- reject(new ConnectionError('WebSocket implementation does not support required event handling'));
232
+ fail(new ConnectionError('WebSocket implementation does not support required event handling'));
114
233
  }
115
234
  } catch (error: any) {
116
- reject(new ConnectionError(\`Failed to create WebSocket: \${error.message}\`));
235
+ fail(new ConnectionError(\`Failed to create WebSocket: \${error.message}\`));
117
236
  }
118
237
  });
119
238
  }
120
239
 
121
240
  async disconnect(): Promise<void> {
122
- if (this.ws) {
123
- this.ws.close();
124
- this.ws = null;
241
+ this.intentionalDisconnect = true;
242
+ this._isConnected = false;
243
+
244
+ // Cancel any pending reconnect timer
245
+ if (this.reconnectTimer) {
246
+ clearTimeout(this.reconnectTimer);
247
+ this.reconnectTimer = null;
248
+ }
249
+
250
+ this.connectionGeneration++;
251
+ if (this.pendingConnectionReject) {
252
+ this.pendingConnectionReject(new ConnectionError('Connection closed intentionally'));
253
+ this.pendingConnectionReject = null;
254
+ }
255
+
256
+ // Reject all pending requests
257
+ this.rejectPendingRequests('Connection closed intentionally');
258
+
259
+ const socket = this.ws;
260
+ this.ws = null;
261
+ if (socket) {
262
+ socket.close();
125
263
  }
126
264
  }
127
265
 
@@ -203,7 +341,7 @@ export class WebSocketTransport implements Transport {
203
341
  const authHeaders = this.config.auth ? generateAuthHeaders(this.config.auth) : {};
204
342
 
205
343
  const subscribeEnvelope: MessageEnvelope = {
206
- operation: operation, // Use the actual operation name
344
+ operation: operation, // Use the actual operation name
207
345
  channel,
208
346
  payload: { channel, operation },
209
347
  timestamp: new Date().toISOString(),
@@ -244,7 +382,7 @@ export class WebSocketTransport implements Transport {
244
382
  * This provides operation-based filtering on the client side
245
383
  */
246
384
  subscribeToOperation(channel: string, operation: string, callback: (payload: any) => void): () => void {
247
- const operationKey = \`\${channel}:\${operation}\`;
385
+ const operationKey = \`\${channel}::\${operation}\`;
248
386
 
249
387
  if (!this.operationSubscriptions.has(operationKey)) {
250
388
  this.operationSubscriptions.set(operationKey, new Set());
@@ -265,7 +403,9 @@ export class WebSocketTransport implements Transport {
265
403
  // Generate auth headers if credentials are available
266
404
  const authHeaders = this.config.auth ? generateAuthHeaders(this.config.auth) : {};
267
405
 
406
+ const requestId = uuidv4();
268
407
  const subscribeEnvelope: MessageEnvelope = {
408
+ id: requestId,
269
409
  operation: operation,
270
410
  channel,
271
411
  payload: { channel, operation },
@@ -293,7 +433,7 @@ export class WebSocketTransport implements Transport {
293
433
  return;
294
434
  }
295
435
 
296
- const operationKey = \`\${envelope.channel}:\${envelope.operation}\`;
436
+ const operationKey = \`\${envelope.channel}::\${envelope.operation}\`;
297
437
  const operationCallbacks = this.operationSubscriptions.get(operationKey);
298
438
 
299
439
  if (operationCallbacks) {
@@ -359,36 +499,130 @@ export class WebSocketTransport implements Transport {
359
499
  }
360
500
 
361
501
  private handleDisconnect(): void {
362
- if (this.reconnectAttempts < this.maxReconnectAttempts) {
363
- this.reconnectAttempts++;
364
- setTimeout(() => {
365
- this.connect().then(() => {
366
- // Re-subscribe to all channels after reconnection
367
- this.resubscribeAll();
368
- }).catch(() => {
369
- // Failed to reconnect
370
- });
371
- }, this.reconnectDelay * this.reconnectAttempts);
502
+ const wasConnected = this._isConnected;
503
+ this._isConnected = false;
504
+
505
+ // Reject all pending request/response handlers immediately
506
+ this.rejectPendingRequests('WebSocket connection lost');
507
+
508
+ // Don't reconnect if this was intentional
509
+ if (this.intentionalDisconnect) return;
510
+
511
+ // Notify listeners of unexpected disconnect
512
+ const reason = 'WebSocket connection lost unexpectedly';
513
+ this.config.connectionCallbacks?.onDisconnected?.(reason);
514
+ this.disconnectedListeners.forEach(cb => {
515
+ try { cb(reason); } catch (e) { console.error('Error in disconnect listener:', e); }
516
+ });
517
+
518
+ // Start reconnection if enabled
519
+ if (!this.reconnectConfig.enabled) {
520
+ return;
521
+ }
522
+
523
+ this.attemptReconnect();
524
+ }
525
+
526
+ private attemptReconnect(): void {
527
+ if (this.intentionalDisconnect || this.reconnectTimer || this.connectPromise) {
528
+ return;
529
+ }
530
+ // Check if we've exceeded max attempts (0 = unlimited)
531
+ if (this.reconnectConfig.maxAttempts > 0 &&
532
+ this.reconnectAttempts >= this.reconnectConfig.maxAttempts) {
533
+ console.error(\`WebSocket reconnect failed after \${this.reconnectAttempts} attempts\`);
534
+ this.config.connectionCallbacks?.onReconnectFailed?.(this.reconnectAttempts);
535
+ return;
372
536
  }
537
+
538
+ this.reconnectAttempts++;
539
+ const delay = this.calculateReconnectDelay(this.reconnectAttempts);
540
+
541
+ console.log(\`WebSocket reconnecting in \${delay}ms (attempt \${this.reconnectAttempts})\`);
542
+ this.config.connectionCallbacks?.onReconnecting?.(this.reconnectAttempts, delay);
543
+
544
+ this.reconnectTimer = setTimeout(async () => {
545
+ this.reconnectTimer = null;
546
+ try {
547
+ // Reset the intentional disconnect flag before reconnecting
548
+ this.intentionalDisconnect = false;
549
+ await this.connect();
550
+
551
+ } catch (error) {
552
+ // Try again
553
+ this.attemptReconnect();
554
+ }
555
+ }, delay);
556
+ }
557
+
558
+ private calculateReconnectDelay(attempt: number): number {
559
+ let delay = this.reconnectConfig.baseDelay *
560
+ Math.pow(this.reconnectConfig.backoffMultiplier, attempt - 1);
561
+
562
+ // Cap at max delay
563
+ delay = Math.min(delay, this.reconnectConfig.maxDelay);
564
+
565
+ // Add jitter
566
+ if (this.reconnectConfig.jitter) {
567
+ delay = delay * (0.5 + Math.random() * 0.5);
568
+ }
569
+
570
+ return Math.floor(delay);
571
+ }
572
+
573
+ private rejectPendingRequests(reason: string): void {
574
+ for (const [requestId, handler] of this.responseHandlers) {
575
+ handler.reject(new ConnectionError(reason));
576
+ }
577
+ this.responseHandlers.clear();
373
578
  }
374
579
 
375
580
  private resubscribeAll(): void {
376
- if (this.ws && this.ws.readyState === this.ws.OPEN) {
377
- // Generate auth headers if credentials are available
378
- const authHeaders = this.config.auth ? generateAuthHeaders(this.config.auth) : {};
581
+ if (!this.ws || this.ws.readyState !== this.ws.OPEN) {
582
+ return;
583
+ }
379
584
 
380
- for (const channel of this.subscriptions.keys()) {
381
- const operation = this.channelOperations.get(channel);
382
- if (operation) {
383
- const subscribeEnvelope: MessageEnvelope = {
384
- operation: operation,
385
- channel,
386
- payload: { channel, operation },
387
- timestamp: new Date().toISOString(),
388
- headers: authHeaders
389
- };
390
- this.ws.send(JSON.stringify(subscribeEnvelope));
391
- }
585
+ const authHeaders = this.config.auth ? generateAuthHeaders(this.config.auth) : {};
586
+
587
+ // Collect all unique channel+operation pairs that need resubscription
588
+ const subscriptionsToRestore = new Set<string>();
589
+
590
+ // From channel-based subscriptions
591
+ for (const channel of this.subscriptions.keys()) {
592
+ const operation = this.channelOperations.get(channel);
593
+ if (operation) {
594
+ subscriptionsToRestore.add(\`\${channel}::\${operation}\`);
595
+ }
596
+ }
597
+
598
+ // From operation-based subscriptions (these may have additional operations
599
+ // on channels already tracked above)
600
+ for (const operationKey of this.operationSubscriptions.keys()) {
601
+ // operationKey format is "channel::operation"
602
+ const separatorIndex = operationKey.indexOf('::');
603
+ if (separatorIndex !== -1) {
604
+ subscriptionsToRestore.add(operationKey);
605
+ }
606
+ }
607
+
608
+ // Send subscribe messages for all unique channel+operation pairs
609
+ for (const key of subscriptionsToRestore) {
610
+ const separatorIndex = key.indexOf('::');
611
+ const channel = key.substring(0, separatorIndex);
612
+ const operation = key.substring(separatorIndex + 2);
613
+ try {
614
+ const subscribeEnvelope: MessageEnvelope = {
615
+ id: uuidv4(),
616
+ operation: operation,
617
+ channel: channel,
618
+ payload: { channel, operation },
619
+ timestamp: new Date().toISOString(),
620
+ headers: authHeaders
621
+ };
622
+ this.ws.send(JSON.stringify(subscribeEnvelope));
623
+ console.log(\`Resubscribed to \${channel} (operation: \${operation})\`);
624
+ } catch (error) {
625
+ console.error(\`Failed to resubscribe to \${channel}:\`, error);
392
626
  }
393
627
  }
394
628
  }
@@ -23,6 +23,47 @@ export interface MessageEnvelope {
23
23
  };
24
24
  }
25
25
 
26
+ /**
27
+ * Connection lifecycle event callbacks
28
+ */
29
+ export interface ConnectionEventCallbacks {
30
+ /** Called when the WebSocket connection is established (including reconnections) */
31
+ onConnected?: () => void;
32
+
33
+ /** Called when the WebSocket connection is lost unexpectedly */
34
+ onDisconnected?: (reason: string) => void;
35
+
36
+ /** Called when a reconnection attempt starts */
37
+ onReconnecting?: (attempt: number, delay: number) => void;
38
+
39
+ /** Called when reconnection succeeds after a disconnection */
40
+ onReconnected?: () => void;
41
+
42
+ /** Called when all reconnection attempts are exhausted (if a limit is set) */
43
+ onReconnectFailed?: (attempts: number) => void;
44
+
45
+ /** Called on any WebSocket error */
46
+ onError?: (error: Error) => void;
47
+ }
48
+
49
+ /**
50
+ * Configuration for WebSocket reconnection behavior
51
+ */
52
+ export interface ReconnectConfig {
53
+ /** Whether to automatically reconnect on disconnect. Default: true */
54
+ enabled?: boolean;
55
+ /** Maximum number of reconnect attempts. 0 = unlimited. Default: 0 (unlimited) */
56
+ maxAttempts?: number;
57
+ /** Initial delay in ms before first reconnect attempt. Default: 1000 */
58
+ baseDelay?: number;
59
+ /** Maximum delay in ms between reconnect attempts. Default: 30000 */
60
+ maxDelay?: number;
61
+ /** Backoff multiplier. Default: 2 */
62
+ backoffMultiplier?: number;
63
+ /** Add jitter to prevent thundering herd. Default: true */
64
+ jitter?: boolean;
65
+ }
66
+
26
67
  /**
27
68
  * Transport interface for sending and receiving messages
28
69
  */
@@ -32,6 +73,15 @@ export interface Transport {
32
73
  send(channel: string, envelope: MessageEnvelope, options?: RequestOptions): Promise<any>;
33
74
  subscribe(channel: string, operation: string, callback: (envelope: MessageEnvelope) => void): () => void;
34
75
  unsubscribe(channel: string, callback?: (envelope: MessageEnvelope) => void): void;
76
+
77
+ /** Check if the transport is currently connected */
78
+ isConnected(): boolean;
79
+
80
+ /** Register a callback for when the transport reconnects after a disconnect */
81
+ onReconnected(callback: () => void): () => void;
82
+
83
+ /** Register a callback for when the transport disconnects unexpectedly */
84
+ onDisconnected(callback: (reason: string) => void): () => void;
35
85
  }
36
86
 
37
87
  /**
@@ -46,6 +96,8 @@ export interface TransportConfig {
46
96
  retry?: RetryConfig | RetryPreset; // Retry configuration
47
97
  authCallbacks?: AuthEventCallbacks; // Auth event callbacks
48
98
  retryCallbacks?: RetryEventCallbacks; // Retry event callbacks
99
+ reconnect?: ReconnectConfig; // Reconnection configuration
100
+ connectionCallbacks?: ConnectionEventCallbacks; // Connection lifecycle callbacks
49
101
  }
50
102
 
51
103
  /**