@ioka-technologies/asyncapi-ts-client-template 0.0.11 → 0.0.16

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/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@ioka-technologies/asyncapi-ts-client-template",
3
- "version": "0.0.11",
3
+ "version": "0.0.16",
4
4
  "description": "TypeScript AsyncAPI client generator template compatible with rust-asyncapi patterns",
5
5
  "main": "template/index.js",
6
6
  "scripts": {
7
7
  "test": "npm run test:generate",
8
- "test:generate": "asyncapi generate fromTemplate examples/auth-retry/asyncapi.yaml . -o test-output-auth-retry --force-write -p enableAuth=true && cd test-output-auth-retry && npm install && npm run build",
9
- "test:realworld": "asyncapi generate fromTemplate ../examples/realworld/asyncapi.yaml . -o test-output-realworld --force-write -p enableAuth=true",
8
+ "test:generate": "asyncapi generate fromTemplate examples/auth-retry/asyncapi.yaml . -o test-output-auth-retry --force-write && cd test-output-auth-retry && npm install && npm run build",
9
+ "test:realworld": "asyncapi generate fromTemplate ../examples/realworld/asyncapi.yaml . -o test-output-realworld --force-write",
10
+ "test:simple": "asyncapi generate fromTemplate ../examples/simple/asyncapi.yaml . -o test-output-simple --force-write",
10
11
  "clean": "rm -rf test-output-*"
11
12
  },
12
13
  "keywords": [
@@ -59,11 +60,6 @@
59
60
  "default": "Apache-2.0",
60
61
  "required": false
61
62
  },
62
- "enableAuth": {
63
- "description": "Enable authentication middleware",
64
- "default": true,
65
- "required": false
66
- },
67
63
  "transports": {
68
64
  "description": "Comma-separated list of transports to include",
69
65
  "default": "websocket,http",
@@ -2,6 +2,99 @@
2
2
  import { File } from '@asyncapi/generator-react-sdk';
3
3
  import { operationRequiresAuth, extractOperationSecurityMap } from '../helpers/security.js';
4
4
 
5
+ // Helper functions for dynamic channels
6
+ function isDynamicChannel(address) {
7
+ if (!address || typeof address !== 'string') return false;
8
+ return /\{[^}]+\}/.test(address);
9
+ }
10
+
11
+ function extractChannelVariables(address) {
12
+ if (!address || typeof address !== 'string') return [];
13
+ const matches = address.match(/\{([^}]+)\}/g);
14
+ if (!matches) return [];
15
+ return matches.map(match => match.slice(1, -1)); // Remove { and }
16
+ }
17
+
18
+ function getChannelParameters(channel) {
19
+ try {
20
+ const parameters = [];
21
+
22
+ // Try to get parameters from the channel
23
+ let channelParams = null;
24
+ if (channel.parameters && typeof channel.parameters === 'function') {
25
+ channelParams = channel.parameters();
26
+ } else if (channel.parameters) {
27
+ channelParams = channel.parameters;
28
+ } else if (channel._json && channel._json.parameters) {
29
+ channelParams = channel._json.parameters;
30
+ }
31
+
32
+ if (channelParams) {
33
+ // Handle different parameter formats
34
+ if (typeof channelParams === 'object') {
35
+ for (const [paramName, paramDef] of Object.entries(channelParams)) {
36
+ // Skip internal AsyncAPI parser properties
37
+ if (paramName.startsWith('_') || paramName === 'collections' || paramName === 'meta') {
38
+ continue;
39
+ }
40
+
41
+ let description = 'Channel parameter';
42
+
43
+ if (paramDef && typeof paramDef === 'object') {
44
+ if (typeof paramDef.description === 'string') {
45
+ description = paramDef.description;
46
+ } else if (typeof paramDef.description === 'function') {
47
+ try {
48
+ description = paramDef.description();
49
+ } catch (e) {
50
+ description = 'Channel parameter';
51
+ }
52
+ } else if (paramDef._json && paramDef._json.description) {
53
+ description = paramDef._json.description;
54
+ }
55
+ } else if (typeof paramDef === 'string') {
56
+ description = paramDef;
57
+ }
58
+
59
+ // Create a valid TypeScript identifier
60
+ let tsName = paramName.replace(/[^a-zA-Z0-9]/g, '_');
61
+ if (/^[0-9]/.test(tsName)) {
62
+ tsName = 'param_' + tsName;
63
+ }
64
+ if (!tsName || tsName === '_') {
65
+ tsName = 'param';
66
+ }
67
+
68
+ parameters.push({
69
+ name: paramName,
70
+ description: description,
71
+ tsName: tsName,
72
+ tsType: 'string' // For now, assume all parameters are strings
73
+ });
74
+ }
75
+ }
76
+ }
77
+
78
+ return parameters;
79
+ } catch (e) {
80
+ console.warn('Error extracting channel parameters:', e.message);
81
+ return [];
82
+ }
83
+ }
84
+
85
+ function resolveChannelAddress(address, variables) {
86
+ if (!address || typeof address !== 'string') return address;
87
+ if (!variables || typeof variables !== 'object') return address;
88
+
89
+ let resolved = address;
90
+ for (const [varName, varValue] of Object.entries(variables)) {
91
+ const placeholder = `{${varName}}`;
92
+ resolved = resolved.replace(new RegExp(placeholder.replace(/[{}]/g, '\\$&'), 'g'), varValue);
93
+ }
94
+
95
+ return resolved;
96
+ }
97
+
5
98
  function generateClient(asyncapi, clientName) {
6
99
  // Method name sanitization function
7
100
  function sanitizeMethodName(operationId) {
@@ -95,6 +188,83 @@ export class ${clientName} {
95
188
  // Generated operation methods
96
189
  `;
97
190
 
191
+ // Extract channels and their operations for dynamic channel support
192
+ const channelServices = [];
193
+ if (asyncapi.channels) {
194
+ const channels = asyncapi.channels();
195
+ if (channels) {
196
+ for (const [channelKey, channel] of Object.entries(channels)) {
197
+ try {
198
+ const channelName = channel.id ? channel.id() : channelKey;
199
+ let channelAddress = null;
200
+ if (channel.address && typeof channel.address === 'function') {
201
+ channelAddress = channel.address();
202
+ } else if (channel.address) {
203
+ channelAddress = channel.address;
204
+ }
205
+
206
+ if (channelAddress) {
207
+ const isDynamic = isDynamicChannel(channelAddress);
208
+ const parameters = isDynamic ? getChannelParameters(channel) : [];
209
+
210
+ channelServices.push({
211
+ channelName,
212
+ channelAddress,
213
+ isDynamic,
214
+ parameters,
215
+ channel
216
+ });
217
+ }
218
+ } catch (e) {
219
+ console.warn(`Error processing channel ${channelKey}:`, e.message);
220
+ }
221
+ }
222
+ }
223
+ }
224
+
225
+ // We'll generate channel services after processing operations
226
+
227
+ // Generate client accessor methods for dynamic channels
228
+ const dynamicChannelServices = channelServices.filter(cs => cs.isDynamic);
229
+ if (dynamicChannelServices.length > 0) {
230
+ dynamicChannelServices.forEach(channelService => {
231
+ const serviceName = `${channelService.channelName.charAt(0).toUpperCase() + channelService.channelName.slice(1)}Service`;
232
+
233
+ // Extract variables from the channel address to get the actual parameter names
234
+ const variables = extractChannelVariables(channelService.channelAddress);
235
+ const actualParams = variables.map(varName => {
236
+ const existingParam = channelService.parameters.find(p => p.name === varName);
237
+ return {
238
+ name: varName,
239
+ tsName: varName.replace(/[^a-zA-Z0-9]/g, '_'),
240
+ tsType: 'string',
241
+ description: existingParam?.description || `${varName} parameter`
242
+ };
243
+ });
244
+
245
+ const paramInterface = actualParams.map(p => `${p.tsName}: ${p.tsType}`).join(', ');
246
+ const methodName = channelService.channelName.replace(/[^a-zA-Z0-9]/g, '');
247
+
248
+ content += `
249
+ /**
250
+ * Access ${channelService.channelName} channel operations with parameters
251
+ *
252
+ * Returns a service instance configured for the specific channel parameters.
253
+ *
254
+ * @param ${actualParams.map(p => `${p.tsName} ${p.description}`).join('\n * @param ')}
255
+ * @returns ${serviceName} instance for the resolved channel
256
+ *
257
+ * @example
258
+ * const service = client.${methodName}(${actualParams.map(p => `'${p.name.replace('_', '-')}'`).join(', ')});
259
+ * await service.someOperation(payload);
260
+ */
261
+ ${methodName}(${paramInterface}): ${serviceName} {
262
+ return new ${serviceName}(this.transport, ${actualParams.map(p => p.tsName).join(', ')});
263
+ }
264
+ `;
265
+ });
266
+ }
267
+
98
268
  // Generate methods for each operation (AsyncAPI v3.0.0)
99
269
  if (asyncapi.operations) {
100
270
  const operations = asyncapi.operations();
@@ -238,6 +408,12 @@ export class ${clientName} {
238
408
  }
239
409
 
240
410
  if (channelAddress) {
411
+ // Skip operations for dynamic channels - they'll be handled by channel services
412
+ const channelService = channelServices.find(cs => cs.channelAddress === channelAddress);
413
+ if (channelService && channelService.isDynamic) {
414
+ return; // Skip this operation for the main client
415
+ }
416
+
241
417
  if (action === 'send') {
242
418
  // Check if this is a request/response pattern
243
419
  let hasReply = false;
@@ -336,6 +512,27 @@ export class ${clientName} {
336
512
  await this.transport.send('${channelAddress}', envelope, options);
337
513
  }
338
514
  `;
515
+
516
+ // Also generate a subscription method for notification-type operations
517
+ // (send operations without replies are typically notifications/events)
518
+ const subscribeMethodName = `on${methodName.charAt(0).toUpperCase() + methodName.slice(1)}`;
519
+ content += `
520
+ /**
521
+ * ${subscribeMethodName} - Subscribe to ${operationId} notifications
522
+ * Original operation: ${operationId}
523
+ * Channel: ${channelAddress}
524
+ * @param callback Function to call when a notification is received
525
+ * @returns Unsubscribe function to stop listening for notifications
526
+ */
527
+ ${subscribeMethodName}(callback: (payload: ${payloadType}) => void): () => void {
528
+ return this.transport.subscribe('${channelAddress}', '${operationId}', (envelope: MessageEnvelope) => {
529
+ // Filter by operation to ensure we only handle messages for this specific operation
530
+ if (envelope.operation === '${operationId}') {
531
+ callback(envelope.payload);
532
+ }
533
+ });
534
+ }
535
+ `;
339
536
  }
340
537
  } else if (action === 'receive') {
341
538
  // Sanitize the method name
@@ -352,7 +549,7 @@ export class ${clientName} {
352
549
  * @returns Unsubscribe function to stop listening for messages
353
550
  */
354
551
  ${methodName}(callback: (payload: ${payloadType}) => void): () => void {
355
- return this.transport.subscribe('${channelAddress}', (envelope: MessageEnvelope) => {
552
+ return this.transport.subscribe('${channelAddress}', '${operationId}', (envelope: MessageEnvelope) => {
356
553
  // Filter by operation to ensure we only handle messages for this specific operation
357
554
  if (envelope.operation === '${operationId}') {
358
555
  callback(envelope.payload);
@@ -371,7 +568,238 @@ export class ${clientName} {
371
568
 
372
569
  content += `
373
570
  }
571
+ `;
572
+
573
+ // Generate channel service classes for dynamic channels
574
+ if (dynamicChannelServices.length > 0) {
575
+ dynamicChannelServices.forEach(channelService => {
576
+ const serviceName = `${channelService.channelName.charAt(0).toUpperCase() + channelService.channelName.slice(1)}Service`;
577
+
578
+ // Extract variables from the channel address to get the actual parameter names
579
+ const variables = extractChannelVariables(channelService.channelAddress);
580
+ const actualParams = variables.map(varName => {
581
+ const existingParam = channelService.parameters.find(p => p.name === varName);
582
+ return {
583
+ name: varName,
584
+ tsName: varName.replace(/[^a-zA-Z0-9]/g, '_'),
585
+ tsType: 'string',
586
+ description: existingParam?.description || `${varName} parameter`
587
+ };
588
+ });
589
+
590
+ const paramInterface = actualParams.map(p => `${p.tsName}: ${p.tsType}`).join(', ');
591
+
592
+ content += `
593
+ /**
594
+ * ${channelService.channelName} channel service with resolved parameters
595
+ *
596
+ * This service provides access to operations on the ${channelService.channelName} channel
597
+ * with resolved channel parameters for dynamic routing.
598
+ */
599
+ export class ${serviceName} {
600
+ private transport: Transport;
601
+ private resolvedChannel: string;
602
+
603
+ constructor(transport: Transport, ${paramInterface}) {
604
+ this.transport = transport;
605
+ this.resolvedChannel = '${channelService.channelAddress}'${actualParams.map(p => `
606
+ .replace('{${p.name}}', ${p.tsName})`).join('')};
607
+ }
608
+
609
+ /**
610
+ * Get the resolved channel address for this service
611
+ */
612
+ getChannel(): string {
613
+ return this.resolvedChannel;
614
+ }
615
+ `;
616
+
617
+ // Add operations for this dynamic channel
618
+ if (asyncapi.operations) {
619
+ const operations = asyncapi.operations();
620
+ if (operations) {
621
+ const operationArray = operations.all ? operations.all() : Object.values(operations);
622
+ operationArray.forEach((operation) => {
623
+ let operationId = null;
624
+ if (operation._meta && operation._meta.id) {
625
+ operationId = operation._meta.id;
626
+ } else if (operation.id && typeof operation.id === 'function') {
627
+ operationId = operation.id();
628
+ } else if (operation.id) {
629
+ operationId = operation.id;
630
+ }
631
+
632
+ if (!operationId) return;
633
+
634
+ try {
635
+ // Check if this operation belongs to this dynamic channel
636
+ let channelAddress = null;
637
+ const embeddedChannel = operation._json && operation._json.channel;
638
+
639
+ if (embeddedChannel) {
640
+ const embeddedChannelId = embeddedChannel['x-parser-unique-object-id'];
641
+ if (embeddedChannelId) {
642
+ const channels = asyncapi.channels();
643
+ for (const [channelKey, channel] of Object.entries(channels || {})) {
644
+ if (channel.id && channel.id() === embeddedChannelId) {
645
+ if (channel.address && typeof channel.address === 'function') {
646
+ channelAddress = channel.address();
647
+ } else if (channel.address) {
648
+ channelAddress = channel.address;
649
+ }
650
+ break;
651
+ }
652
+ }
653
+ }
654
+ }
655
+
656
+ if (channelAddress === channelService.channelAddress) {
657
+ // This operation belongs to this dynamic channel
658
+ let action = 'send';
659
+ if (operation.action && typeof operation.action === 'function') {
660
+ action = operation.action();
661
+ } else if (operation.action) {
662
+ action = operation.action;
663
+ }
664
+
665
+ // Get message types
666
+ let messageTypes = [];
667
+ if (operation.messages && typeof operation.messages === 'function') {
668
+ const messages = operation.messages();
669
+ if (messages && typeof messages.all === 'function') {
670
+ const messageArray = messages.all();
671
+ messageTypes = messageArray.map(msg => {
672
+ if (msg.name && typeof msg.name === 'function') {
673
+ return msg.name();
674
+ } else if (msg.name) {
675
+ return msg.name;
676
+ }
677
+ return null;
678
+ }).filter(Boolean);
679
+ }
680
+ }
681
+
682
+ const methodName = sanitizeMethodName(operationId);
683
+
684
+ if (action === 'send') {
685
+ // Check for reply
686
+ let hasReply = false;
687
+ let replyMessageTypes = [];
688
+
689
+ if (operation.reply && typeof operation.reply === 'function') {
690
+ const reply = operation.reply();
691
+ if (reply) {
692
+ hasReply = true;
693
+ if (reply.messages && typeof reply.messages === 'function') {
694
+ const replyMessages = reply.messages();
695
+ if (replyMessages && typeof replyMessages.all === 'function') {
696
+ const messageArray = replyMessages.all();
697
+ replyMessageTypes = messageArray.map(msg => {
698
+ if (msg.name && typeof msg.name === 'function') {
699
+ return msg.name();
700
+ } else if (msg.name) {
701
+ return msg.name;
702
+ }
703
+ return null;
704
+ }).filter(Boolean);
705
+ }
706
+ }
707
+ }
708
+ }
709
+
710
+ if (hasReply) {
711
+ const requestPayloadType = messageTypes.length > 0 ? `Models.${messageTypes[0]}Payload` : 'any';
712
+ const responseType = replyMessageTypes.length > 0 ? `Models.${replyMessageTypes[0]}Payload` : 'any';
713
+
714
+ content += `
715
+ /**
716
+ * ${methodName} - Request/Response operation
717
+ * Original operation: ${operationId}
718
+ * @param payload Request payload
719
+ * @param options Request options
720
+ * @returns Promise that resolves with the response
721
+ */
722
+ async ${methodName}(payload: ${requestPayloadType}, options?: RequestOptions): Promise<${responseType}> {
723
+ const envelope: MessageEnvelope = {
724
+ operation: '${operationId}',
725
+ payload,
726
+ channel: this.resolvedChannel
727
+ };
728
+ return this.transport.send(this.resolvedChannel, envelope, options);
729
+ }
730
+ `;
731
+ } else {
732
+ const payloadType = messageTypes.length > 0 ? `Models.${messageTypes[0]}Payload` : 'any';
733
+ content += `
734
+ /**
735
+ * ${methodName} - Send operation (fire and forget)
736
+ * Original operation: ${operationId}
737
+ */
738
+ async ${methodName}(payload: ${payloadType}, options?: RequestOptions): Promise<void> {
739
+ const envelope: MessageEnvelope = {
740
+ operation: '${operationId}',
741
+ payload,
742
+ channel: this.resolvedChannel
743
+ };
744
+ await this.transport.send(this.resolvedChannel, envelope, options);
745
+ }
746
+ `;
747
+
748
+ // Also generate a subscription method for notification-type operations
749
+ // (send operations without replies are typically notifications/events)
750
+ const subscribeMethodName = `on${methodName.charAt(0).toUpperCase() + methodName.slice(1)}`;
751
+ content += `
752
+ /**
753
+ * ${subscribeMethodName} - Subscribe to ${operationId} notifications
754
+ * Original operation: ${operationId}
755
+ * @param callback Function to call when a notification is received
756
+ * @returns Unsubscribe function to stop listening for notifications
757
+ */
758
+ ${subscribeMethodName}(callback: (payload: ${payloadType}) => void): () => void {
759
+ return this.transport.subscribe(this.resolvedChannel, '${operationId}', (envelope: MessageEnvelope) => {
760
+ // Filter by operation to ensure we only handle messages for this specific operation
761
+ if (envelope.operation === '${operationId}') {
762
+ callback(envelope.payload);
763
+ }
764
+ });
765
+ }
766
+ `;
767
+ }
768
+ } else if (action === 'receive') {
769
+ // Generate receive method (event listener setup) for dynamic channels
770
+ const payloadType = messageTypes.length > 0 ? `Models.${messageTypes[0]}Payload` : 'any';
771
+ content += `
772
+ /**
773
+ * ${methodName} - Receive operation
774
+ * Original operation: ${operationId}
775
+ * @param callback Function to call when a message is received
776
+ * @returns Unsubscribe function to stop listening for messages
777
+ */
778
+ ${methodName}(callback: (payload: ${payloadType}) => void): () => void {
779
+ return this.transport.subscribe(this.resolvedChannel, '${operationId}', (envelope: MessageEnvelope) => {
780
+ // Filter by operation to ensure we only handle messages for this specific operation
781
+ if (envelope.operation === '${operationId}') {
782
+ callback(envelope.payload);
783
+ }
784
+ });
785
+ }
786
+ `;
787
+ }
788
+ }
789
+ } catch (error) {
790
+ // Skip operations that can't be processed
791
+ }
792
+ });
793
+ }
794
+ }
795
+
796
+ content += `
797
+ }
798
+ `;
799
+ });
800
+ }
374
801
 
802
+ content += `
375
803
  export default ${clientName};
376
804
  `;
377
805
 
@@ -13,7 +13,7 @@ function generateModels(asyncapi) {
13
13
  .replace(/_+/g, '_')
14
14
  .replace(/^_+|_+$/g, '');
15
15
  if (/^[0-9]/.test(identifier)) {
16
- identifier = 'Item' + identifier;
16
+ identifier = 'item_' + identifier;
17
17
  }
18
18
  if (!identifier) {
19
19
  identifier = 'unknown';
@@ -21,6 +21,7 @@ function generateModels(asyncapi) {
21
21
  return identifier;
22
22
  }
23
23
 
24
+
24
25
  function toTypeScriptTypeName(str) {
25
26
  if (!str) return 'Unknown';
26
27
  // Handle camelCase and PascalCase properly
@@ -311,9 +312,17 @@ function generateModels(asyncapi) {
311
312
  componentSchemas.forEach(schema => {
312
313
  const doc = schema.description ? `/** ${schema.description} */\n` : `/** ${schema.name} */\n`;
313
314
 
314
- content += `${doc}export interface ${schema.typeName} {\n`;
315
- content += generateMessageInterface(schema.schema, schema.typeName);
316
- content += '\n}\n\n';
315
+ // Check if this is a standalone enum schema
316
+ if (schema.schema.type === 'string' && schema.schema.enum && Array.isArray(schema.schema.enum)) {
317
+ // Generate union type for enum
318
+ const enumValues = schema.schema.enum.map(val => `'${val}'`).join(' | ');
319
+ content += `${doc}export type ${schema.typeName} = ${enumValues};\n\n`;
320
+ } else {
321
+ // Generate interface for object schema
322
+ content += `${doc}export interface ${schema.typeName} {\n`;
323
+ content += generateMessageInterface(schema.schema, schema.typeName);
324
+ content += '\n}\n\n';
325
+ }
317
326
 
318
327
  // Track generated types to avoid duplicates
319
328
  generatedTypes.add(schema.typeName);
@@ -195,10 +195,10 @@ export class HttpTransport implements Transport {
195
195
  }
196
196
  }
197
197
 
198
- subscribe(channel: string, callback: (envelope: MessageEnvelope) => void): () => void {
198
+ subscribe(channel: string, operation: string, callback: (envelope: MessageEnvelope) => void): () => void {
199
199
  // HTTP transport doesn't support real-time subscriptions
200
200
  // This is a placeholder implementation that logs a warning
201
- console.warn(\`HTTP transport does not support subscriptions. Channel '\${channel}' subscription ignored.\`);
201
+ console.warn(\`HTTP transport does not support subscriptions. Operation '\${operation}' on channel '\${channel}' subscription ignored.\`);
202
202
  console.warn('Consider using WebSocket transport for real-time message subscriptions.');
203
203
 
204
204
  // Return a no-op unsubscribe function
@@ -52,6 +52,7 @@ export class WebSocketTransport implements Transport {
52
52
  private responseHandlers: Map<string, ResponseHandler> = new Map();
53
53
  private subscriptions: Map<string, Set<EnvelopeCallback>> = new Map();
54
54
  private operationSubscriptions: Map<string, Set<(payload: any) => void>> = new Map();
55
+ private channelOperations: Map<string, string> = new Map(); // Track operation for each channel
55
56
  private reconnectAttempts = 0;
56
57
  private maxReconnectAttempts = 5;
57
58
  private reconnectDelay = 1000;
@@ -162,22 +163,44 @@ export class WebSocketTransport implements Transport {
162
163
  });
163
164
  }
164
165
 
165
- subscribe(channel: string, callback: (envelope: MessageEnvelope) => void): () => void {
166
+ subscribe(channel: string, operation: string, callback: (envelope: MessageEnvelope) => void): () => void {
167
+ // When an operation is provided, use operation-based routing to extract the payload
168
+ if (operation) {
169
+ // Create a wrapper that extracts the payload from the envelope
170
+ const payloadCallback = (payload: any) => {
171
+ // Reconstruct the envelope for backward compatibility
172
+ const envelope: MessageEnvelope = {
173
+ operation: operation,
174
+ channel: channel,
175
+ payload: payload,
176
+ timestamp: new Date().toISOString()
177
+ };
178
+ callback(envelope);
179
+ };
180
+
181
+ // Use operation-based subscription which correctly extracts the payload
182
+ return this.subscribeToOperation(channel, operation, payloadCallback);
183
+ }
184
+
185
+ // Fallback to channel-based subscription for backward compatibility
166
186
  if (!this.subscriptions.has(channel)) {
167
187
  this.subscriptions.set(channel, new Set());
168
188
  }
169
189
 
170
190
  this.subscriptions.get(channel)!.add(callback);
171
191
 
192
+ // Track the operation for this channel for reconnection purposes
193
+ this.channelOperations.set(channel, operation);
194
+
172
195
  // Send subscription message to server using envelope format
173
196
  if (this.ws && this.ws.readyState === this.ws.OPEN) {
174
197
  // Generate auth headers if credentials are available
175
198
  const authHeaders = this.config.auth ? generateAuthHeaders(this.config.auth) : {};
176
199
 
177
200
  const subscribeEnvelope: MessageEnvelope = {
178
- operation: 'subscribe',
201
+ operation: operation, // ✅ Use the actual operation name
179
202
  channel,
180
- payload: { channel },
203
+ payload: { channel, operation },
181
204
  timestamp: new Date().toISOString(),
182
205
  headers: authHeaders
183
206
  };
@@ -200,11 +223,13 @@ export class WebSocketTransport implements Transport {
200
223
  channelSubscriptions.delete(callback);
201
224
  if (channelSubscriptions.size === 0) {
202
225
  this.subscriptions.delete(channel);
226
+ this.channelOperations.delete(channel); // Clean up operation tracking
203
227
  this.sendUnsubscribeMessage(channel);
204
228
  }
205
229
  } else {
206
230
  // Unsubscribe all callbacks for this channel
207
231
  this.subscriptions.delete(channel);
232
+ this.channelOperations.delete(channel); // Clean up operation tracking
208
233
  this.sendUnsubscribeMessage(channel);
209
234
  }
210
235
  }
@@ -222,11 +247,28 @@ export class WebSocketTransport implements Transport {
222
247
 
223
248
  this.operationSubscriptions.get(operationKey)!.add(callback);
224
249
 
225
- // Subscribe to the channel if not already subscribed
250
+ // Subscribe to the channel if not already subscribed (use direct channel subscription to avoid circular call)
226
251
  if (!this.subscriptions.has(channel)) {
227
- this.subscribe(channel, (envelope: MessageEnvelope) => {
228
- this.handleOperationMessage(envelope);
229
- });
252
+ // Direct channel subscription without going through the subscribe method
253
+ this.subscriptions.set(channel, new Set());
254
+
255
+ // Track the operation for this channel for reconnection purposes
256
+ this.channelOperations.set(channel, operation);
257
+
258
+ // Send subscription message to server using envelope format
259
+ if (this.ws && this.ws.readyState === this.ws.OPEN) {
260
+ // Generate auth headers if credentials are available
261
+ const authHeaders = this.config.auth ? generateAuthHeaders(this.config.auth) : {};
262
+
263
+ const subscribeEnvelope: MessageEnvelope = {
264
+ operation: operation,
265
+ channel,
266
+ payload: { channel, operation },
267
+ timestamp: new Date().toISOString(),
268
+ headers: authHeaders
269
+ };
270
+ this.ws.send(JSON.stringify(subscribeEnvelope));
271
+ }
230
272
  }
231
273
 
232
274
  // Return unsubscribe function
@@ -275,21 +317,22 @@ export class WebSocketTransport implements Transport {
275
317
  try {
276
318
  const envelope: MessageEnvelope = JSON.parse(data);
277
319
 
278
- // Handle response messages (with ID for request/response correlation)
279
- if (envelope.id) {
280
- const handler = this.responseHandlers.get(envelope.id);
281
- if (handler) {
282
- this.responseHandlers.delete(envelope.id);
283
- if (envelope.error) {
284
- handler.reject(new TransportError(\`\${envelope.error.code}: \${envelope.error.message}\`));
285
- } else {
286
- handler.resolve(envelope.payload);
287
- }
320
+ // Check if this is actually a response to a pending request
321
+ const isResponse = envelope.id && this.responseHandlers.has(envelope.id);
322
+
323
+ if (isResponse) {
324
+ // Handle as response message
325
+ const handler = this.responseHandlers.get(envelope.id!)!;
326
+ this.responseHandlers.delete(envelope.id!);
327
+ if (envelope.error) {
328
+ handler.reject(new TransportError(\`\${envelope.error.code}: \${envelope.error.message}\`));
329
+ } else {
330
+ handler.resolve(envelope.payload);
288
331
  }
289
332
  return;
290
333
  }
291
334
 
292
- // Handle subscription messages (broadcast messages without correlation ID)
335
+ // Handle as subscription message (even if it has an ID)
293
336
  if (envelope.channel) {
294
337
  const channelSubscriptions = this.subscriptions.get(envelope.channel);
295
338
  if (channelSubscriptions) {
@@ -326,13 +369,21 @@ export class WebSocketTransport implements Transport {
326
369
 
327
370
  private resubscribeAll(): void {
328
371
  if (this.ws && this.ws.readyState === this.ws.OPEN) {
372
+ // Generate auth headers if credentials are available
373
+ const authHeaders = this.config.auth ? generateAuthHeaders(this.config.auth) : {};
374
+
329
375
  for (const channel of this.subscriptions.keys()) {
330
- const subscribeMessage = {
331
- type: 'subscribe',
332
- channel,
333
- timestamp: new Date().toISOString()
334
- };
335
- this.ws.send(JSON.stringify(subscribeMessage));
376
+ const operation = this.channelOperations.get(channel);
377
+ if (operation) {
378
+ const subscribeEnvelope: MessageEnvelope = {
379
+ operation: operation,
380
+ channel,
381
+ payload: { channel, operation },
382
+ timestamp: new Date().toISOString(),
383
+ headers: authHeaders
384
+ };
385
+ this.ws.send(JSON.stringify(subscribeEnvelope));
386
+ }
336
387
  }
337
388
  }
338
389
  }
@@ -30,7 +30,7 @@ export interface Transport {
30
30
  connect(): Promise<void>;
31
31
  disconnect(): Promise<void>;
32
32
  send(channel: string, envelope: MessageEnvelope, options?: RequestOptions): Promise<any>;
33
- subscribe(channel: string, callback: (envelope: MessageEnvelope) => void): () => void;
33
+ subscribe(channel: string, operation: string, callback: (envelope: MessageEnvelope) => void): () => void;
34
34
  unsubscribe(channel: string, callback?: (envelope: MessageEnvelope) => void): void;
35
35
  }
36
36