@djangocfg/centrifugo 1.0.1 → 1.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.
Files changed (34) hide show
  1. package/README.md +345 -34
  2. package/package.json +6 -4
  3. package/src/config.ts +1 -1
  4. package/src/core/client/CentrifugoRPCClient.ts +281 -0
  5. package/src/core/client/index.ts +5 -0
  6. package/src/core/index.ts +15 -0
  7. package/src/core/logger/LogsStore.ts +101 -0
  8. package/src/core/logger/createLogger.ts +79 -0
  9. package/src/core/logger/index.ts +9 -0
  10. package/src/core/types/index.ts +68 -0
  11. package/src/debug/ConnectionTab/ConnectionTab.tsx +160 -0
  12. package/src/debug/ConnectionTab/index.ts +5 -0
  13. package/src/debug/DebugPanel/DebugPanel.tsx +102 -0
  14. package/src/debug/DebugPanel/index.ts +5 -0
  15. package/src/debug/LogsTab/LogsTab.tsx +236 -0
  16. package/src/debug/LogsTab/index.ts +5 -0
  17. package/src/debug/SubscriptionsTab/SubscriptionsTab.tsx +135 -0
  18. package/src/debug/SubscriptionsTab/index.ts +5 -0
  19. package/src/debug/index.ts +11 -0
  20. package/src/hooks/index.ts +2 -5
  21. package/src/hooks/useSubscription.ts +66 -65
  22. package/src/index.ts +94 -13
  23. package/src/providers/CentrifugoProvider/CentrifugoProvider.tsx +381 -0
  24. package/src/providers/CentrifugoProvider/index.ts +6 -0
  25. package/src/providers/LogsProvider/LogsProvider.tsx +107 -0
  26. package/src/providers/LogsProvider/index.ts +6 -0
  27. package/src/providers/index.ts +9 -0
  28. package/API_GENERATOR.md +0 -253
  29. package/src/components/CentrifugoDebug.tsx +0 -182
  30. package/src/components/index.ts +0 -5
  31. package/src/context/CentrifugoProvider.tsx +0 -228
  32. package/src/context/index.ts +0 -5
  33. package/src/hooks/useLogger.ts +0 -69
  34. package/src/types/index.ts +0 -45
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Base Centrifugo RPC Client
3
+ *
4
+ * Handles WebSocket connection and RPC call correlation.
5
+ * Provides both RPC (request/response) and Subscription (pub/sub) patterns.
6
+ */
7
+
8
+ import { Centrifuge } from 'centrifuge';
9
+ import type { Logger } from '../logger';
10
+ import { createLogger } from '../logger';
11
+
12
+ export class CentrifugoRPCClient {
13
+ private centrifuge: Centrifuge;
14
+ private subscription: any;
15
+ private channelSubscriptions: Map<string, any> = new Map();
16
+ private pendingRequests: Map<string, { resolve: Function; reject: Function }> = new Map();
17
+ private readonly replyChannel: string;
18
+ private readonly timeout: number;
19
+ private readonly logger: Logger;
20
+
21
+ constructor(
22
+ url: string,
23
+ token: string,
24
+ userId: string,
25
+ timeout: number = 30000,
26
+ logger?: Logger
27
+ ) {
28
+ this.replyChannel = `user#${userId}`;
29
+ this.timeout = timeout;
30
+ this.logger = logger || createLogger({ source: 'client' });
31
+
32
+ this.centrifuge = new Centrifuge(url, {
33
+ token,
34
+ });
35
+
36
+ this.centrifuge.on('disconnected', (ctx) => {
37
+ // Reject all pending requests
38
+ this.pendingRequests.forEach(({ reject }) => {
39
+ reject(new Error('Disconnected from Centrifugo'));
40
+ });
41
+ this.pendingRequests.clear();
42
+ });
43
+ }
44
+
45
+ async connect(): Promise<void> {
46
+ return new Promise((resolve, reject) => {
47
+ let resolved = false;
48
+
49
+ // Listen to Centrifuge connection events
50
+ const onConnected = () => {
51
+ if (!resolved) {
52
+ resolved = true;
53
+ resolve();
54
+ }
55
+ };
56
+
57
+ const onError = (ctx: any) => {
58
+ if (!resolved) {
59
+ resolved = true;
60
+ reject(new Error(ctx.message || 'Connection error'));
61
+ }
62
+ };
63
+
64
+ this.centrifuge.on('connected', onConnected);
65
+ this.centrifuge.on('error', onError);
66
+
67
+ // Start connection
68
+ this.centrifuge.connect();
69
+
70
+ // Subscribe to reply channel
71
+ this.subscription = this.centrifuge.newSubscription(this.replyChannel);
72
+
73
+ this.subscription.on('publication', (ctx: any) => {
74
+ this.handleResponse(ctx.data);
75
+ });
76
+
77
+ this.subscription.on('subscribed', () => {
78
+ // Subscription successful (optional, we already resolved on 'connected')
79
+ });
80
+
81
+ this.subscription.on('error', (ctx: any) => {
82
+ // Error code 105 = "already subscribed" (server-side subscription from JWT)
83
+ // This is not an error - the channel is already active via server-side subscription
84
+ if (ctx.error?.code === 105) {
85
+ // This is fine, server-side subscription exists
86
+ } else {
87
+ this.logger.error(`Subscription error for ${this.replyChannel}:`, ctx.error);
88
+ }
89
+ });
90
+
91
+ this.subscription.subscribe();
92
+ });
93
+ }
94
+
95
+ async disconnect(): Promise<void> {
96
+ // Unsubscribe from all event channels
97
+ this.unsubscribeAll();
98
+
99
+ // Unsubscribe from RPC reply channel
100
+ if (this.subscription) {
101
+ this.subscription.unsubscribe();
102
+ }
103
+
104
+ this.centrifuge.disconnect();
105
+ }
106
+
107
+ async call<T = any>(method: string, params: any): Promise<T> {
108
+ const correlationId = this.generateCorrelationId();
109
+
110
+ const message = {
111
+ method,
112
+ params,
113
+ correlation_id: correlationId,
114
+ reply_to: this.replyChannel,
115
+ };
116
+
117
+ // Create promise for response
118
+ const promise = new Promise<T>((resolve, reject) => {
119
+ const timeoutId = setTimeout(() => {
120
+ this.pendingRequests.delete(correlationId);
121
+ reject(new Error(`RPC timeout: ${method}`));
122
+ }, this.timeout);
123
+
124
+ this.pendingRequests.set(correlationId, {
125
+ resolve: (result: T) => {
126
+ clearTimeout(timeoutId);
127
+ resolve(result);
128
+ },
129
+ reject: (error: Error) => {
130
+ clearTimeout(timeoutId);
131
+ reject(error);
132
+ },
133
+ });
134
+ });
135
+
136
+ // Publish request
137
+ await this.centrifuge.publish('rpc.requests', message);
138
+
139
+ return promise;
140
+ }
141
+
142
+ private handleResponse(data: any): void {
143
+ const correlationId = data.correlation_id;
144
+ if (!correlationId) {
145
+ return;
146
+ }
147
+
148
+ const pending = this.pendingRequests.get(correlationId);
149
+ if (!pending) {
150
+ return;
151
+ }
152
+
153
+ this.pendingRequests.delete(correlationId);
154
+
155
+ if (data.error) {
156
+ pending.reject(new Error(data.error.message || 'RPC error'));
157
+ } else {
158
+ pending.resolve(data.result);
159
+ }
160
+ }
161
+
162
+ private generateCorrelationId(): string {
163
+ return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
164
+ }
165
+
166
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
167
+ // Channel Subscription API (for gRPC events)
168
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
169
+
170
+ /**
171
+ * Subscribe to a Centrifugo channel for real-time events.
172
+ *
173
+ * @param channel - Channel name (e.g., 'bot#bot-123#heartbeat')
174
+ * @param callback - Callback for received messages
175
+ * @returns Unsubscribe function
176
+ *
177
+ * @example
178
+ * const unsubscribe = client.subscribe('bot#bot-123#heartbeat', (data) => {
179
+ * console.log('Heartbeat:', data);
180
+ * });
181
+ *
182
+ * // Later: unsubscribe when done
183
+ * unsubscribe();
184
+ */
185
+ 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
189
+ }
190
+
191
+ // Create new subscription
192
+ const sub = this.centrifuge.newSubscription(channel);
193
+
194
+ // Handle publications
195
+ sub.on('publication', (ctx: any) => {
196
+ callback(ctx.data);
197
+ });
198
+
199
+ // Handle subscription lifecycle
200
+ sub.on('subscribed', () => {
201
+ // Subscription successful
202
+ });
203
+
204
+ sub.on('error', (ctx: any) => {
205
+ this.logger.error(`Subscription error for ${channel}:`, ctx.error);
206
+ });
207
+
208
+ // Start subscription
209
+ sub.subscribe();
210
+
211
+ // Store subscription
212
+ this.channelSubscriptions.set(channel, sub);
213
+
214
+ // Return unsubscribe function
215
+ return () => this.unsubscribe(channel);
216
+ }
217
+
218
+ /**
219
+ * Unsubscribe from a channel.
220
+ *
221
+ * @param channel - Channel name
222
+ */
223
+ unsubscribe(channel: string): void {
224
+ const sub = this.channelSubscriptions.get(channel);
225
+ if (!sub) {
226
+ return;
227
+ }
228
+
229
+ sub.unsubscribe();
230
+ this.channelSubscriptions.delete(channel);
231
+ }
232
+
233
+ /**
234
+ * Unsubscribe from all channels.
235
+ */
236
+ unsubscribeAll(): void {
237
+ if (this.channelSubscriptions.size === 0) {
238
+ return;
239
+ }
240
+
241
+ this.channelSubscriptions.forEach((sub, channel) => {
242
+ sub.unsubscribe();
243
+ });
244
+ this.channelSubscriptions.clear();
245
+ }
246
+
247
+ /**
248
+ * Get list of active client-side subscriptions.
249
+ */
250
+ getActiveSubscriptions(): string[] {
251
+ return Array.from(this.channelSubscriptions.keys());
252
+ }
253
+
254
+ /**
255
+ * Get list of server-side subscriptions (from JWT token).
256
+ *
257
+ * These are channels automatically subscribed by Centrifugo server
258
+ * based on the 'channels' claim in the JWT token.
259
+ */
260
+ getServerSideSubscriptions(): string[] {
261
+ try {
262
+ // Access Centrifuge.js internal state for server-side subs
263
+ // @ts-ignore - accessing internal property
264
+ const serverSubs = this.centrifuge._serverSubs || {};
265
+ return Object.keys(serverSubs);
266
+ } catch (error) {
267
+ return [];
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Get all active subscriptions (both client-side and server-side).
273
+ */
274
+ getAllSubscriptions(): string[] {
275
+ const clientSubs = this.getActiveSubscriptions();
276
+ const serverSubs = this.getServerSideSubscriptions();
277
+
278
+ // Combine and deduplicate
279
+ return Array.from(new Set([...clientSubs, ...serverSubs]));
280
+ }
281
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Client Module
3
+ */
4
+
5
+ export { CentrifugoRPCClient } from './CentrifugoRPCClient';
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Core Module
3
+ *
4
+ * Platform-agnostic core functionality (no React)
5
+ */
6
+
7
+ // Client
8
+ export { CentrifugoRPCClient } from './client';
9
+
10
+ // Logger
11
+ export { createLogger, LogsStore, getGlobalLogsStore } from './logger';
12
+ export type { Logger, LoggerConfig } from './logger';
13
+
14
+ // Types
15
+ export type * from './types';
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Logs Store
3
+ *
4
+ * In-memory store for accumulated logs with circular buffer.
5
+ * Thread-safe, max capacity, auto-cleanup.
6
+ */
7
+
8
+ import type { LogEntry, LogLevel } from '../types';
9
+
10
+ export class LogsStore {
11
+ private logs: LogEntry[] = [];
12
+ private listeners: Set<(logs: LogEntry[]) => void> = new Set();
13
+ private maxLogs: number;
14
+
15
+ constructor(maxLogs: number = 500) {
16
+ this.maxLogs = maxLogs;
17
+ }
18
+
19
+ /**
20
+ * Add log entry
21
+ */
22
+ add(entry: Omit<LogEntry, 'id' | 'timestamp'>): void {
23
+ const logEntry: LogEntry = {
24
+ ...entry,
25
+ id: `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
26
+ timestamp: new Date(),
27
+ };
28
+
29
+ this.logs.push(logEntry);
30
+
31
+ // Keep only last N logs (circular buffer)
32
+ if (this.logs.length > this.maxLogs) {
33
+ this.logs = this.logs.slice(-this.maxLogs);
34
+ }
35
+
36
+ // Notify listeners
37
+ this.notify();
38
+ }
39
+
40
+ /**
41
+ * Get all logs
42
+ */
43
+ getAll(): LogEntry[] {
44
+ return [...this.logs];
45
+ }
46
+
47
+ /**
48
+ * Get logs by level
49
+ */
50
+ getByLevel(level: LogLevel): LogEntry[] {
51
+ return this.logs.filter((log) => log.level === level);
52
+ }
53
+
54
+ /**
55
+ * Get logs by source
56
+ */
57
+ getBySource(source: LogEntry['source']): LogEntry[] {
58
+ return this.logs.filter((log) => log.source === source);
59
+ }
60
+
61
+ /**
62
+ * Clear all logs
63
+ */
64
+ clear(): void {
65
+ this.logs = [];
66
+ this.notify();
67
+ }
68
+
69
+ /**
70
+ * Subscribe to log changes
71
+ */
72
+ subscribe(listener: (logs: LogEntry[]) => void): () => void {
73
+ this.listeners.add(listener);
74
+ return () => this.listeners.delete(listener);
75
+ }
76
+
77
+ /**
78
+ * Notify all listeners
79
+ */
80
+ private notify(): void {
81
+ const logs = this.getAll();
82
+ this.listeners.forEach((listener) => listener(logs));
83
+ }
84
+
85
+ /**
86
+ * Get logs count
87
+ */
88
+ get count(): number {
89
+ return this.logs.length;
90
+ }
91
+ }
92
+
93
+ // Singleton instance
94
+ let globalStore: LogsStore | null = null;
95
+
96
+ export function getGlobalLogsStore(): LogsStore {
97
+ if (!globalStore) {
98
+ globalStore = new LogsStore();
99
+ }
100
+ return globalStore;
101
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Create Logger
3
+ *
4
+ * Creates logger that writes to both console (consola) and LogsStore.
5
+ * Dual output: developer console + UI logs viewer.
6
+ */
7
+
8
+ import { createConsola } from 'consola';
9
+ import type { LogEntry, LogLevel } from '../types';
10
+ import { getGlobalLogsStore } from './LogsStore';
11
+
12
+ export interface Logger {
13
+ debug: (message: string, data?: unknown) => void;
14
+ info: (message: string, data?: unknown) => void;
15
+ success: (message: string, data?: unknown) => void;
16
+ warning: (message: string, data?: unknown) => void;
17
+ error: (message: string, error?: Error | unknown) => void;
18
+ }
19
+
20
+ export interface LoggerConfig {
21
+ source: LogEntry['source'];
22
+ isDevelopment?: boolean;
23
+ tag?: string;
24
+ }
25
+
26
+ export function createLogger(config: LoggerConfig): Logger {
27
+ const { source, isDevelopment = process.env.NODE_ENV === 'development', tag = 'Centrifugo' } = config;
28
+
29
+ const logsStore = getGlobalLogsStore();
30
+
31
+ // Console logger (consola)
32
+ const consola = createConsola({
33
+ level: isDevelopment ? 4 : 3,
34
+ formatOptions: {
35
+ colors: true,
36
+ date: false,
37
+ compact: !isDevelopment,
38
+ },
39
+ }).withTag(tag);
40
+
41
+ const log = (level: LogLevel, message: string, data?: unknown) => {
42
+ // Add to LogsStore (always)
43
+ logsStore.add({
44
+ level,
45
+ source,
46
+ message,
47
+ data,
48
+ });
49
+
50
+ // Write to console (only in development)
51
+ if (!isDevelopment) return;
52
+
53
+ switch (level) {
54
+ case 'debug':
55
+ consola.debug(message, data || '');
56
+ break;
57
+ case 'info':
58
+ consola.info(message, data || '');
59
+ break;
60
+ case 'success':
61
+ consola.success(message, data || '');
62
+ break;
63
+ case 'warning':
64
+ consola.warn(message, data || '');
65
+ break;
66
+ case 'error':
67
+ consola.error(message, data || '');
68
+ break;
69
+ }
70
+ };
71
+
72
+ return {
73
+ debug: (message, data) => log('debug', message, data),
74
+ info: (message, data) => log('info', message, data),
75
+ success: (message, data) => log('success', message, data),
76
+ warning: (message, data) => log('warning', message, data),
77
+ error: (message, error) => log('error', message, error),
78
+ };
79
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Logger Module
3
+ *
4
+ * Exports logger utilities
5
+ */
6
+
7
+ export { createLogger } from './createLogger';
8
+ export type { Logger, LoggerConfig } from './createLogger';
9
+ export { LogsStore, getGlobalLogsStore } from './LogsStore';
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Core Types for Centrifugo Package
3
+ *
4
+ * Type-only exports, no runtime dependencies
5
+ */
6
+
7
+ // ─────────────────────────────────────────────────────────────────────────
8
+ // Log Types
9
+ // ─────────────────────────────────────────────────────────────────────────
10
+
11
+ export type LogLevel = 'debug' | 'info' | 'success' | 'warning' | 'error';
12
+
13
+ export interface LogEntry {
14
+ id: string;
15
+ timestamp: Date;
16
+ level: LogLevel;
17
+ source: 'client' | 'provider' | 'subscription' | 'system';
18
+ message: string;
19
+ data?: unknown;
20
+ }
21
+
22
+ // ─────────────────────────────────────────────────────────────────────────
23
+ // Connection Types
24
+ // ─────────────────────────────────────────────────────────────────────────
25
+
26
+ export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error';
27
+
28
+ export interface CentrifugoToken {
29
+ token: string;
30
+ centrifugo_url: string;
31
+ channels: string[];
32
+ expires_at: string;
33
+ }
34
+
35
+ export interface User {
36
+ id: number;
37
+ centrifugo?: CentrifugoToken;
38
+ }
39
+
40
+ // ─────────────────────────────────────────────────────────────────────────
41
+ // Subscription Types
42
+ // ─────────────────────────────────────────────────────────────────────────
43
+
44
+ export interface ActiveSubscription {
45
+ channel: string;
46
+ type: 'client' | 'server';
47
+ subscribedAt: number; // timestamp
48
+ data?: unknown;
49
+ }
50
+
51
+ // ─────────────────────────────────────────────────────────────────────────
52
+ // Client Types
53
+ // ─────────────────────────────────────────────────────────────────────────
54
+
55
+ export interface CentrifugoClientConfig {
56
+ url: string;
57
+ token: string;
58
+ userId: string;
59
+ timeout?: number;
60
+ onLog?: (entry: Omit<LogEntry, 'id' | 'timestamp'>) => void;
61
+ }
62
+
63
+ export interface CentrifugoClientState {
64
+ isConnected: boolean;
65
+ isConnecting: boolean;
66
+ error: Error | null;
67
+ connectionState: ConnectionState;
68
+ }