@djangocfg/centrifugo 1.0.2 → 1.0.4

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.
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Subscriptions List Component
3
+ *
4
+ * Displays active Centrifugo subscriptions with status and controls
5
+ */
6
+
7
+ 'use client';
8
+
9
+ import React, { useState, useEffect } from 'react';
10
+ import {
11
+ Card,
12
+ CardContent,
13
+ CardHeader,
14
+ CardTitle,
15
+ Badge,
16
+ Button,
17
+ ScrollArea,
18
+ } from '@djangocfg/ui';
19
+ import { Radio, RefreshCw, Trash2 } from 'lucide-react';
20
+ import { Subscription, SubscriptionState } from 'centrifuge';
21
+ import { useCentrifugo } from '../../providers/CentrifugoProvider';
22
+ import { createLogger } from '../../core/logger/createLogger';
23
+
24
+ const logger = createLogger('SubscriptionsList');
25
+
26
+ // ─────────────────────────────────────────────────────────────────────────
27
+ // Types
28
+ // ─────────────────────────────────────────────────────────────────────────
29
+
30
+ interface SubscriptionItem {
31
+ channel: string;
32
+ state: SubscriptionState;
33
+ }
34
+
35
+ export interface SubscriptionsListProps {
36
+ showControls?: boolean;
37
+ onSubscriptionClick?: (channel: string) => void;
38
+ className?: string;
39
+ }
40
+
41
+ // ─────────────────────────────────────────────────────────────────────────
42
+ // Component
43
+ // ─────────────────────────────────────────────────────────────────────────
44
+
45
+ export function SubscriptionsList({
46
+ showControls = true,
47
+ onSubscriptionClick,
48
+ className = '',
49
+ }: SubscriptionsListProps) {
50
+ const { isConnected, client } = useCentrifugo();
51
+ const [subscriptions, setSubscriptions] = useState<SubscriptionItem[]>([]);
52
+
53
+ // Update subscriptions list
54
+ const updateSubscriptions = () => {
55
+ if (!client || !isConnected) {
56
+ setSubscriptions([]);
57
+ return;
58
+ }
59
+
60
+ try {
61
+ const centrifuge = client.getCentrifuge();
62
+ const subs = centrifuge.subscriptions();
63
+ const subscriptionsList: SubscriptionItem[] = [];
64
+
65
+ for (const [channel, sub] of Object.entries(subs)) {
66
+ subscriptionsList.push({
67
+ channel,
68
+ state: sub.state,
69
+ });
70
+ }
71
+
72
+ setSubscriptions(subscriptionsList);
73
+ } catch (error) {
74
+ logger.error('Failed to get subscriptions', error);
75
+ setSubscriptions([]);
76
+ }
77
+ };
78
+
79
+ // Auto-update subscriptions
80
+ useEffect(() => {
81
+ updateSubscriptions();
82
+
83
+ if (!client) return;
84
+
85
+ const centrifuge = client.getCentrifuge();
86
+
87
+ const handleSubscribed = () => updateSubscriptions();
88
+ const handleUnsubscribed = () => updateSubscriptions();
89
+
90
+ centrifuge.on('subscribed', handleSubscribed);
91
+ centrifuge.on('unsubscribed', handleUnsubscribed);
92
+
93
+ const interval = setInterval(updateSubscriptions, 3000);
94
+
95
+ return () => {
96
+ centrifuge.off('subscribed', handleSubscribed);
97
+ centrifuge.off('unsubscribed', handleUnsubscribed);
98
+ clearInterval(interval);
99
+ };
100
+ }, [client, isConnected]);
101
+
102
+ // Unsubscribe from channel
103
+ const handleUnsubscribe = async (channel: string) => {
104
+ if (!client) return;
105
+
106
+ try {
107
+ await client.unsubscribe(channel);
108
+ updateSubscriptions();
109
+ } catch (error) {
110
+ logger.error('Failed to unsubscribe', error);
111
+ }
112
+ };
113
+
114
+ return (
115
+ <Card className={className}>
116
+ <CardHeader>
117
+ <div className="flex items-center justify-between">
118
+ <CardTitle className="flex items-center gap-2">
119
+ <Radio className="h-5 w-5" />
120
+ Active Subscriptions
121
+ <Badge variant="outline">{subscriptions.length}</Badge>
122
+ </CardTitle>
123
+
124
+ {showControls && (
125
+ <Button size="sm" variant="outline" onClick={updateSubscriptions}>
126
+ <RefreshCw className="h-4 w-4" />
127
+ </Button>
128
+ )}
129
+ </div>
130
+ </CardHeader>
131
+
132
+ <CardContent>
133
+ {!isConnected ? (
134
+ <div className="text-center py-8 text-sm text-muted-foreground">
135
+ Not connected to Centrifugo
136
+ </div>
137
+ ) : subscriptions.length === 0 ? (
138
+ <div className="text-center py-8 text-sm text-muted-foreground">
139
+ No active subscriptions
140
+ </div>
141
+ ) : (
142
+ <ScrollArea className="h-[300px]">
143
+ <div className="space-y-2">
144
+ {subscriptions.map((sub) => (
145
+ <div
146
+ key={sub.channel}
147
+ className="flex items-center justify-between p-3 rounded border hover:bg-muted/50 transition-colors"
148
+ >
149
+ <div
150
+ className="flex-1 min-w-0 cursor-pointer"
151
+ onClick={() => onSubscriptionClick?.(sub.channel)}
152
+ >
153
+ <div className="flex items-center gap-2">
154
+ <Badge variant="outline" className="text-xs">
155
+ {sub.state}
156
+ </Badge>
157
+ <span className="text-sm font-mono truncate">{sub.channel}</span>
158
+ </div>
159
+ </div>
160
+
161
+ {showControls && (
162
+ <Button
163
+ size="sm"
164
+ variant="ghost"
165
+ onClick={() => handleUnsubscribe(sub.channel)}
166
+ >
167
+ <Trash2 className="h-4 w-4 text-destructive" />
168
+ </Button>
169
+ )}
170
+ </div>
171
+ ))}
172
+ </div>
173
+ </ScrollArea>
174
+ )}
175
+ </CardContent>
176
+ </Card>
177
+ );
178
+ }
179
+
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Subscriptions List Components
3
+ */
4
+
5
+ export { SubscriptionsList } from './SubscriptionsList';
6
+ export type { SubscriptionsListProps } from './SubscriptionsList';
7
+
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Centrifugo Components
3
+ *
4
+ * Universal, composable components for Centrifugo monitoring and debugging
5
+ */
6
+
7
+ // Connection Status
8
+ export * from './ConnectionStatus';
9
+
10
+ // Messages Feed
11
+ export * from './MessagesFeed';
12
+
13
+ // Subscriptions List
14
+ export * from './SubscriptionsList';
15
+
16
+ // Main Monitor (all-in-one)
17
+ export * from './CentrifugoMonitor';
18
+
@@ -14,6 +14,7 @@ export class CentrifugoRPCClient {
14
14
  private subscription: any;
15
15
  private channelSubscriptions: Map<string, any> = new Map();
16
16
  private pendingRequests: Map<string, { resolve: Function; reject: Function }> = new Map();
17
+ private readonly userId: string;
17
18
  private readonly replyChannel: string;
18
19
  private readonly timeout: number;
19
20
  private readonly logger: Logger;
@@ -25,6 +26,7 @@ export class CentrifugoRPCClient {
25
26
  timeout: number = 30000,
26
27
  logger?: Logger
27
28
  ) {
29
+ this.userId = userId;
28
30
  this.replyChannel = `user#${userId}`;
29
31
  this.timeout = timeout;
30
32
  this.logger = logger || createLogger({ source: 'client' });
@@ -160,7 +162,145 @@ export class CentrifugoRPCClient {
160
162
  }
161
163
 
162
164
  private generateCorrelationId(): string {
163
- return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
165
+ return `rpc-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
166
+ }
167
+
168
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
169
+ // RPC Pattern API (Request-Response via Correlation ID)
170
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
171
+
172
+ /**
173
+ * Send RPC request and wait for response.
174
+ *
175
+ * Uses correlation ID pattern to match request with response:
176
+ * 1. Generate unique correlation_id
177
+ * 2. Subscribe to personal reply channel (user#{userId})
178
+ * 3. Publish request with correlation_id
179
+ * 4. Wait for response with matching correlation_id
180
+ * 5. Return response or timeout
181
+ *
182
+ * @param method - RPC method name (e.g., 'tasks.get_stats')
183
+ * @param params - Request parameters
184
+ * @param options - RPC options
185
+ * @returns Promise that resolves with response data
186
+ *
187
+ * @example
188
+ * const result = await client.rpc('tasks.get_stats', { bot_id: '123' });
189
+ * console.log('Stats:', result);
190
+ *
191
+ * @example
192
+ * // With custom timeout and reply channel
193
+ * const result = await client.rpc('bot.command',
194
+ * { command: 'start' },
195
+ * { timeout: 30000, replyChannel: 'bot#123#replies' }
196
+ * );
197
+ */
198
+ async rpc<TRequest = any, TResponse = any>(
199
+ method: string,
200
+ params: TRequest,
201
+ options: {
202
+ timeout?: number;
203
+ replyChannel?: string;
204
+ } = {}
205
+ ): Promise<TResponse> {
206
+ const {
207
+ timeout = 10000,
208
+ replyChannel = `user#${this.userId}`,
209
+ } = options;
210
+
211
+ const correlationId = this.generateCorrelationId();
212
+
213
+ this.logger.info(`RPC request: ${method}`, { correlationId, params });
214
+
215
+ return new Promise<TResponse>((resolve, reject) => {
216
+ let timeoutId: NodeJS.Timeout | null = null;
217
+ let unsubscribe: (() => void) | null = null;
218
+
219
+ // Cleanup function
220
+ const cleanup = () => {
221
+ if (timeoutId) {
222
+ clearTimeout(timeoutId);
223
+ timeoutId = null;
224
+ }
225
+ if (unsubscribe) {
226
+ unsubscribe();
227
+ unsubscribe = null;
228
+ }
229
+ };
230
+
231
+ // Set timeout
232
+ timeoutId = setTimeout(() => {
233
+ cleanup();
234
+ const error = new Error(`RPC timeout: ${method} (${timeout}ms)`);
235
+ this.logger.error(`RPC timeout: ${method}`, { correlationId, timeout });
236
+ reject(error);
237
+ }, timeout);
238
+
239
+ // Subscribe to reply channel
240
+ try {
241
+ unsubscribe = this.subscribe(replyChannel, (data: any) => {
242
+ try {
243
+ // Check if this is our response
244
+ if (data.correlation_id === correlationId) {
245
+ cleanup();
246
+
247
+ // Check for error in response
248
+ if (data.error) {
249
+ this.logger.error(`RPC error: ${method}`, {
250
+ correlationId,
251
+ error: data.error
252
+ });
253
+ reject(new Error(data.error.message || 'RPC error'));
254
+ return;
255
+ }
256
+
257
+ this.logger.success(`RPC response: ${method}`, {
258
+ correlationId,
259
+ hasResult: !!data.result
260
+ });
261
+ resolve(data.result as TResponse);
262
+ }
263
+ } catch (error) {
264
+ cleanup();
265
+ this.logger.error(`Error processing RPC response: ${method}`, error);
266
+ reject(error);
267
+ }
268
+ });
269
+
270
+ // Publish request to RPC channel
271
+ // Note: This uses Centrifuge's publish, not our HTTP publish
272
+ // The backend should be subscribed to this channel
273
+ const rpcChannel = `rpc#${method}`;
274
+ const sub = this.centrifuge.getSubscription(rpcChannel);
275
+
276
+ if (sub) {
277
+ // If we have a subscription, use it to publish
278
+ sub.publish({
279
+ correlation_id: correlationId,
280
+ method,
281
+ params,
282
+ reply_to: replyChannel,
283
+ timestamp: Date.now(),
284
+ }).catch((error: any) => {
285
+ cleanup();
286
+ this.logger.error(`Failed to publish RPC request: ${method}`, error);
287
+ reject(error);
288
+ });
289
+ } else {
290
+ // Otherwise, we need to publish via HTTP API
291
+ // This requires the backend to expose a publish endpoint
292
+ cleanup();
293
+ reject(new Error(
294
+ `Cannot publish RPC request: no subscription to ${rpcChannel}. ` +
295
+ `Backend should provide a publish endpoint or use server-side subscriptions.`
296
+ ));
297
+ }
298
+ } catch (error) {
299
+ cleanup();
300
+ this.logger.error(`Failed to setup RPC: ${method}`, error);
301
+ reject(error);
302
+ }
303
+ });
164
304
  }
165
305
 
166
306
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -183,26 +323,52 @@ export class CentrifugoRPCClient {
183
323
  * unsubscribe();
184
324
  */
185
325
  subscribe(channel: string, callback: (data: any) => void): () => void {
186
- // Check if already subscribed
187
- if (this.channelSubscriptions.has(channel)) {
188
- return () => {}; // Return no-op unsubscribe
326
+ // Check if already subscribed - reuse existing subscription
327
+ const existingSub = this.channelSubscriptions.get(channel);
328
+ if (existingSub) {
329
+ this.logger.warning(`Already subscribed to ${channel}, reusing existing subscription`);
330
+
331
+ // Add new callback handler to existing subscription
332
+ const handler = (ctx: any) => {
333
+ try {
334
+ callback(ctx.data);
335
+ } catch (error) {
336
+ this.logger.error(`Error in subscription callback for ${channel}`, error);
337
+ }
338
+ };
339
+
340
+ existingSub.on('publication', handler);
341
+
342
+ // Return unsubscribe that only removes this specific handler
343
+ return () => {
344
+ existingSub.off('publication', handler);
345
+ };
189
346
  }
190
347
 
191
- // Create new subscription
192
- const sub = this.centrifuge.newSubscription(channel);
348
+ // Create new subscription using Centrifuge's getSubscription or newSubscription
349
+ let sub = this.centrifuge.getSubscription(channel);
350
+ if (!sub) {
351
+ sub = this.centrifuge.newSubscription(channel);
352
+ }
193
353
 
194
- // Handle publications
195
- sub.on('publication', (ctx: any) => {
196
- callback(ctx.data);
197
- });
354
+ // Handle publications with error handling
355
+ const publicationHandler = (ctx: any) => {
356
+ try {
357
+ callback(ctx.data);
358
+ } catch (error) {
359
+ this.logger.error(`Error in subscription callback for ${channel}`, error);
360
+ }
361
+ };
362
+
363
+ sub.on('publication', publicationHandler);
198
364
 
199
365
  // Handle subscription lifecycle
200
366
  sub.on('subscribed', () => {
201
- // Subscription successful
367
+ this.logger.success(`Subscribed to ${channel}`);
202
368
  });
203
369
 
204
370
  sub.on('error', (ctx: any) => {
205
- this.logger.error(`Subscription error for ${channel}:`, ctx.error);
371
+ this.logger.error(`Subscription error for ${channel}`, ctx.error);
206
372
  });
207
373
 
208
374
  // Start subscription
@@ -217,6 +383,7 @@ export class CentrifugoRPCClient {
217
383
 
218
384
  /**
219
385
  * Unsubscribe from a channel.
386
+ * Properly removes subscription from both internal map and Centrifuge client.
220
387
  *
221
388
  * @param channel - Channel name
222
389
  */
@@ -226,12 +393,27 @@ export class CentrifugoRPCClient {
226
393
  return;
227
394
  }
228
395
 
229
- sub.unsubscribe();
230
- this.channelSubscriptions.delete(channel);
396
+ try {
397
+ // Unsubscribe from channel
398
+ sub.unsubscribe();
399
+
400
+ // Remove subscription from Centrifuge client registry
401
+ this.centrifuge.removeSubscription(sub);
402
+
403
+ // Remove from our internal map
404
+ this.channelSubscriptions.delete(channel);
405
+
406
+ this.logger.info(`Unsubscribed from ${channel}`);
407
+ } catch (error) {
408
+ this.logger.error(`Error unsubscribing from ${channel}`, error);
409
+ // Still remove from our map even if there was an error
410
+ this.channelSubscriptions.delete(channel);
411
+ }
231
412
  }
232
413
 
233
414
  /**
234
415
  * Unsubscribe from all channels.
416
+ * Properly removes all subscriptions from both internal map and Centrifuge client.
235
417
  */
236
418
  unsubscribeAll(): void {
237
419
  if (this.channelSubscriptions.size === 0) {
@@ -239,9 +421,16 @@ export class CentrifugoRPCClient {
239
421
  }
240
422
 
241
423
  this.channelSubscriptions.forEach((sub, channel) => {
242
- sub.unsubscribe();
424
+ try {
425
+ sub.unsubscribe();
426
+ this.centrifuge.removeSubscription(sub);
427
+ } catch (error) {
428
+ this.logger.error(`Error unsubscribing from ${channel}`, error);
429
+ }
243
430
  });
431
+
244
432
  this.channelSubscriptions.clear();
433
+ this.logger.info('Unsubscribed from all channels');
245
434
  }
246
435
 
247
436
  /**
@@ -278,4 +467,12 @@ export class CentrifugoRPCClient {
278
467
  // Combine and deduplicate
279
468
  return Array.from(new Set([...clientSubs, ...serverSubs]));
280
469
  }
470
+
471
+ /**
472
+ * Get native Centrifuge client for direct access to low-level APIs.
473
+ * Use with caution - prefer using the high-level methods when possible.
474
+ */
475
+ getCentrifuge(): Centrifuge {
476
+ return this.centrifuge;
477
+ }
281
478
  }
@@ -23,12 +23,35 @@ export interface LoggerConfig {
23
23
  tag?: string;
24
24
  }
25
25
 
26
- export function createLogger(config: LoggerConfig): Logger {
26
+ /**
27
+ * Create a logger instance
28
+ *
29
+ * @param configOrPrefix - Either a full LoggerConfig object or a simple string prefix
30
+ * @returns Logger instance
31
+ *
32
+ * @example
33
+ * // Simple usage with prefix
34
+ * const logger = createLogger('MyComponent');
35
+ *
36
+ * @example
37
+ * // Advanced usage with full config
38
+ * const logger = createLogger({
39
+ * source: 'client',
40
+ * tag: 'MyComponent',
41
+ * isDevelopment: true
42
+ * });
43
+ */
44
+ export function createLogger(configOrPrefix: LoggerConfig | string): Logger {
45
+ // Normalize input to LoggerConfig
46
+ const config: LoggerConfig = typeof configOrPrefix === 'string'
47
+ ? { source: 'client', tag: configOrPrefix }
48
+ : configOrPrefix;
49
+
27
50
  const { source, isDevelopment = process.env.NODE_ENV === 'development', tag = 'Centrifugo' } = config;
28
51
 
29
52
  const logsStore = getGlobalLogsStore();
30
53
 
31
- // Console logger (consola)
54
+ // Console logger (consola) with prefix tag
32
55
  const consola = createConsola({
33
56
  level: isDevelopment ? 4 : 3,
34
57
  formatOptions: {
@@ -36,7 +59,7 @@ export function createLogger(config: LoggerConfig): Logger {
36
59
  date: false,
37
60
  compact: !isDevelopment,
38
61
  },
39
- }).withTag(tag);
62
+ }).withTag(`[${tag}]`);
40
63
 
41
64
  const log = (level: LogLevel, message: string, data?: unknown) => {
42
65
  // Add to LogsStore (always)
@@ -2,7 +2,7 @@
2
2
  * Debug Panel
3
3
  *
4
4
  * Main debug UI with FAB button + Sheet modal + Tabs.
5
- * Only visible in development mode.
5
+ * Visibility controlled by CentrifugoProvider (dev mode OR admin users).
6
6
  */
7
7
 
8
8
  'use client';
@@ -24,15 +24,6 @@ import { ConnectionTab } from '../ConnectionTab';
24
24
  import { LogsTab } from '../LogsTab';
25
25
  import { SubscriptionsTab } from '../SubscriptionsTab';
26
26
 
27
- // ─────────────────────────────────────────────────────────────────────────
28
- // Config
29
- // ─────────────────────────────────────────────────────────────────────────
30
-
31
- const isDevelopment = process.env.NODE_ENV === 'development';
32
- const isStaticBuild = process.env.NEXT_PHASE === 'phase-production-build';
33
-
34
- const showDebugPanel = isDevelopment && !isStaticBuild;
35
-
36
27
  // ─────────────────────────────────────────────────────────────────────────
37
28
  // Component
38
29
  // ─────────────────────────────────────────────────────────────────────────
@@ -41,11 +32,6 @@ export function DebugPanel() {
41
32
  const [isOpen, setIsOpen] = useState(false);
42
33
  const [activeTab, setActiveTab] = useState('connection');
43
34
 
44
- // Don't render in production
45
- if (!showDebugPanel) {
46
- return null;
47
- }
48
-
49
35
  return (
50
36
  <>
51
37
  {/* FAB Button (fixed bottom-left) */}
@@ -4,3 +4,6 @@
4
4
 
5
5
  export { useSubscription } from './useSubscription';
6
6
  export type { UseSubscriptionOptions, UseSubscriptionResult } from './useSubscription';
7
+
8
+ export { useRPC } from './useRPC';
9
+ export type { UseRPCOptions, UseRPCResult } from './useRPC';