@djangocfg/centrifugo 2.1.54 → 2.1.56

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,176 @@
1
+ /**
2
+ * Subscription Management Module
3
+ *
4
+ * Handles Centrifugo channel subscriptions (pub/sub pattern).
5
+ */
6
+
7
+ import type { Centrifuge, Subscription } from 'centrifuge';
8
+ import type { Logger } from '../logger';
9
+
10
+ export interface SubscriptionManager {
11
+ channelSubscriptions: Map<string, Subscription>;
12
+ centrifuge: Centrifuge;
13
+ logger: Logger;
14
+ }
15
+
16
+ /**
17
+ * Subscribe to a Centrifugo channel for real-time events.
18
+ *
19
+ * @param manager - Subscription manager state
20
+ * @param channel - Channel name (e.g., 'bot#bot-123#heartbeat')
21
+ * @param callback - Callback for received messages
22
+ * @returns Unsubscribe function
23
+ *
24
+ * @example
25
+ * const unsubscribe = subscribe(manager, 'bot#bot-123#heartbeat', (data) => {
26
+ * console.log('Heartbeat:', data);
27
+ * });
28
+ *
29
+ * // Later: unsubscribe when done
30
+ * unsubscribe();
31
+ */
32
+ export function subscribe(
33
+ manager: SubscriptionManager,
34
+ channel: string,
35
+ callback: (data: any) => void
36
+ ): () => void {
37
+ const { channelSubscriptions, centrifuge, logger } = manager;
38
+
39
+ // Check if already subscribed - reuse existing subscription
40
+ const existingSub = channelSubscriptions.get(channel);
41
+ if (existingSub) {
42
+ logger.warning(`Already subscribed to ${channel}, reusing existing subscription`);
43
+
44
+ // Add new callback handler to existing subscription
45
+ const handler = (ctx: any) => {
46
+ try {
47
+ callback(ctx.data);
48
+ } catch (error) {
49
+ logger.error(`Error in subscription callback for ${channel}`, error);
50
+ }
51
+ };
52
+
53
+ existingSub.on('publication', handler);
54
+
55
+ // Return unsubscribe that only removes this specific handler
56
+ return () => {
57
+ existingSub.off('publication', handler);
58
+ };
59
+ }
60
+
61
+ // Create new subscription using Centrifuge's getSubscription or newSubscription
62
+ let sub = centrifuge.getSubscription(channel);
63
+ if (!sub) {
64
+ sub = centrifuge.newSubscription(channel);
65
+ }
66
+
67
+ // Handle publications with error handling
68
+ const publicationHandler = (ctx: any) => {
69
+ try {
70
+ callback(ctx.data);
71
+ } catch (error) {
72
+ logger.error(`Error in subscription callback for ${channel}`, error);
73
+ }
74
+ };
75
+
76
+ sub.on('publication', publicationHandler);
77
+
78
+ // Handle subscription lifecycle
79
+ sub.on('subscribed', () => {
80
+ logger.success(`Subscribed to ${channel}`);
81
+ });
82
+
83
+ sub.on('error', (ctx: any) => {
84
+ logger.error(`Subscription error for ${channel}`, ctx.error);
85
+ });
86
+
87
+ // Start subscription
88
+ sub.subscribe();
89
+
90
+ // Store subscription
91
+ channelSubscriptions.set(channel, sub);
92
+
93
+ // Return unsubscribe function
94
+ return () => unsubscribe(manager, channel);
95
+ }
96
+
97
+ /**
98
+ * Unsubscribe from a channel.
99
+ * Properly removes subscription from both internal map and Centrifuge client.
100
+ */
101
+ export function unsubscribe(manager: SubscriptionManager, channel: string): void {
102
+ const { channelSubscriptions, centrifuge, logger } = manager;
103
+ const sub = channelSubscriptions.get(channel);
104
+
105
+ if (!sub) {
106
+ return;
107
+ }
108
+
109
+ try {
110
+ sub.unsubscribe();
111
+ centrifuge.removeSubscription(sub);
112
+ channelSubscriptions.delete(channel);
113
+ logger.info(`Unsubscribed from ${channel}`);
114
+ } catch (error) {
115
+ logger.error(`Error unsubscribing from ${channel}`, error);
116
+ channelSubscriptions.delete(channel);
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Unsubscribe from all channels.
122
+ */
123
+ export function unsubscribeAll(manager: SubscriptionManager): void {
124
+ const { channelSubscriptions, centrifuge, logger } = manager;
125
+
126
+ if (channelSubscriptions.size === 0) {
127
+ return;
128
+ }
129
+
130
+ channelSubscriptions.forEach((sub, channel) => {
131
+ try {
132
+ sub.unsubscribe();
133
+ centrifuge.removeSubscription(sub);
134
+ } catch (error) {
135
+ logger.error(`Error unsubscribing from ${channel}`, error);
136
+ }
137
+ });
138
+
139
+ channelSubscriptions.clear();
140
+ logger.info('Unsubscribed from all channels');
141
+ }
142
+
143
+ /**
144
+ * Get list of active client-side subscriptions.
145
+ */
146
+ export function getActiveSubscriptions(manager: SubscriptionManager): string[] {
147
+ return Array.from(manager.channelSubscriptions.keys());
148
+ }
149
+
150
+ /**
151
+ * Get list of server-side subscriptions (from JWT token).
152
+ *
153
+ * These are channels automatically subscribed by Centrifugo server
154
+ * based on the 'channels' claim in the JWT token.
155
+ */
156
+ export function getServerSideSubscriptions(manager: SubscriptionManager): string[] {
157
+ try {
158
+ // Access Centrifuge.js internal state for server-side subs
159
+ // @ts-ignore - accessing internal property
160
+ const serverSubs = manager.centrifuge._serverSubs || {};
161
+ return Object.keys(serverSubs);
162
+ } catch {
163
+ return [];
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Get all active subscriptions (both client-side and server-side).
169
+ */
170
+ export function getAllSubscriptions(manager: SubscriptionManager): string[] {
171
+ const clientSubs = getActiveSubscriptions(manager);
172
+ const serverSubs = getServerSideSubscriptions(manager);
173
+
174
+ // Combine and deduplicate
175
+ return Array.from(new Set([...clientSubs, ...serverSubs]));
176
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Centrifugo Client Types
3
+ */
4
+
5
+ import type { Logger } from '../logger';
6
+
7
+ export interface CentrifugoClientOptions {
8
+ url: string;
9
+ token: string;
10
+ userId: string;
11
+ timeout?: number;
12
+ logger?: Logger;
13
+ /**
14
+ * Callback to get fresh token when current token expires.
15
+ * Centrifuge-js calls this automatically on token expiration.
16
+ */
17
+ getToken?: () => Promise<string>;
18
+ }
19
+
20
+ export interface RPCOptions {
21
+ timeout?: number;
22
+ replyChannel?: string;
23
+ }
24
+
25
+ export interface RetryOptions {
26
+ /** Max retry attempts (default: 3) */
27
+ maxRetries?: number;
28
+ /** Base delay in ms for exponential backoff (default: 100) */
29
+ baseDelayMs?: number;
30
+ /** Max delay cap in ms (default: 2000) */
31
+ maxDelayMs?: number;
32
+ }
33
+
34
+ export interface VersionCheckResult {
35
+ compatible: boolean;
36
+ clientVersion: string;
37
+ serverVersion: string;
38
+ message: string;
39
+ }
40
+
41
+ export interface PendingRequest {
42
+ resolve: (result: any) => void;
43
+ reject: (error: Error) => void;
44
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * API Version Checking Module
3
+ *
4
+ * Validates that client and server API versions are compatible.
5
+ */
6
+
7
+ import { dispatchVersionMismatch } from '../../events';
8
+
9
+ import type { Logger } from '../logger';
10
+ import type { VersionCheckResult } from './types';
11
+
12
+ export interface VersionChecker {
13
+ namedRPC: <TRequest, TResponse>(method: string, data: TRequest) => Promise<TResponse>;
14
+ logger: Logger;
15
+ }
16
+
17
+ interface CheckVersionResponse {
18
+ compatible: boolean;
19
+ client_version: string;
20
+ server_version: string;
21
+ message: string;
22
+ }
23
+
24
+ /**
25
+ * Check if client API version matches server version.
26
+ *
27
+ * Calls system.check_version RPC and dispatches a CustomEvent
28
+ * if versions don't match. Listen to 'centrifugo' event to handle globally.
29
+ *
30
+ * @example
31
+ * ```ts
32
+ * import { API_VERSION } from '@/_ws';
33
+ *
34
+ * // Check version after connect
35
+ * const result = await checkApiVersion(checker, API_VERSION);
36
+ * if (!result.compatible) {
37
+ * // Event already dispatched, but you can handle here too
38
+ * console.warn('Please refresh the page');
39
+ * }
40
+ *
41
+ * // Listen to event globally
42
+ * window.addEventListener('centrifugo', (e) => {
43
+ * if (e.detail.type === 'version_mismatch') {
44
+ * toast.warning('New version available. Please refresh.');
45
+ * }
46
+ * });
47
+ * ```
48
+ */
49
+ export async function checkApiVersion(
50
+ checker: VersionChecker,
51
+ clientVersion: string
52
+ ): Promise<VersionCheckResult> {
53
+ const { namedRPC, logger } = checker;
54
+
55
+ try {
56
+ const result = await namedRPC<{ client_version: string }, CheckVersionResponse>(
57
+ 'system.check_version',
58
+ { client_version: clientVersion }
59
+ );
60
+
61
+ if (!result.compatible) {
62
+ logger.warning(
63
+ `API version mismatch: client=${clientVersion}, server=${result.server_version}`
64
+ );
65
+
66
+ // Dispatch event for global handling
67
+ dispatchVersionMismatch({
68
+ clientVersion,
69
+ serverVersion: result.server_version,
70
+ message: result.message || 'API version mismatch. Please refresh the page.',
71
+ });
72
+ } else {
73
+ logger.success(`API version check passed: ${clientVersion}`);
74
+ }
75
+
76
+ return {
77
+ compatible: result.compatible,
78
+ clientVersion,
79
+ serverVersion: result.server_version,
80
+ message: result.message,
81
+ };
82
+ } catch (error) {
83
+ // If endpoint doesn't exist (old server), assume compatible
84
+ logger.warning('API version check endpoint not available');
85
+ return {
86
+ compatible: true,
87
+ clientVersion,
88
+ serverVersion: 'unknown',
89
+ message: 'Version check endpoint not available',
90
+ };
91
+ }
92
+ }
package/src/events.ts CHANGED
@@ -1,40 +1,50 @@
1
1
  /**
2
2
  * Centrifugo Package Events
3
3
  *
4
- * Event-driven communication for Centrifugo monitor dialog
5
- * and error tracking via CustomEvents
4
+ * Unified event system for Centrifugo client.
5
+ * All events use single 'centrifugo' CustomEvent with type discriminator.
6
6
  */
7
7
 
8
8
  import { events } from '@djangocfg/ui-nextjs';
9
9
 
10
10
  // ─────────────────────────────────────────────────────────────────────────
11
- // Event Types
11
+ // Event Constants
12
12
  // ─────────────────────────────────────────────────────────────────────────
13
13
 
14
+ /**
15
+ * Single event name for all Centrifugo events.
16
+ * Use `type` field in payload to distinguish event types.
17
+ */
18
+ export const CENTRIFUGO_EVENT = 'centrifugo' as const;
19
+
20
+ /**
21
+ * Internal events for UI components (via events.publish)
22
+ */
14
23
  export const CENTRIFUGO_MONITOR_EVENTS = {
15
24
  OPEN_MONITOR_DIALOG: 'CENTRIFUGO_OPEN_MONITOR_DIALOG',
16
25
  CLOSE_MONITOR_DIALOG: 'CENTRIFUGO_CLOSE_MONITOR_DIALOG',
17
26
  } as const;
18
27
 
19
- /**
20
- * Error event name for ErrorsTracker integration
21
- */
22
- export const CENTRIFUGO_ERROR_EVENT = 'centrifugo-error' as const;
28
+ // Legacy exports for backwards compatibility
29
+ export const CENTRIFUGO_ERROR_EVENT = CENTRIFUGO_EVENT;
30
+ export const CENTRIFUGO_VERSION_MISMATCH_EVENT = CENTRIFUGO_EVENT;
23
31
 
24
32
  // ─────────────────────────────────────────────────────────────────────────
25
- // Event Payload Types
33
+ // Event Types
26
34
  // ─────────────────────────────────────────────────────────────────────────
27
35
 
28
- export interface OpenMonitorDialogPayload {
29
- variant?: 'compact' | 'full' | 'minimal';
30
- defaultTab?: 'connection' | 'messages' | 'subscriptions';
31
- }
36
+ export type CentrifugoEventType =
37
+ | 'error'
38
+ | 'version_mismatch'
39
+ | 'connected'
40
+ | 'disconnected'
41
+ | 'reconnecting';
32
42
 
33
- /**
34
- * Payload for centrifugo-error CustomEvent
35
- * Compatible with ErrorsTracker CentrifugoErrorDetail
36
- */
37
- export interface CentrifugoErrorPayload {
43
+ // ─────────────────────────────────────────────────────────────────────────
44
+ // Event Payloads
45
+ // ─────────────────────────────────────────────────────────────────────────
46
+
47
+ export interface CentrifugoErrorData {
38
48
  /** RPC method that failed */
39
49
  method: string;
40
50
  /** Error message */
@@ -45,60 +55,163 @@ export interface CentrifugoErrorPayload {
45
55
  data?: any;
46
56
  }
47
57
 
58
+ export interface CentrifugoVersionMismatchData {
59
+ /** Client API version hash */
60
+ clientVersion: string;
61
+ /** Server API version hash */
62
+ serverVersion: string;
63
+ /** Human-readable message */
64
+ message: string;
65
+ }
66
+
67
+ export interface CentrifugoConnectionData {
68
+ /** User ID */
69
+ userId?: string;
70
+ /** Reconnect attempt number (for reconnecting) */
71
+ attempt?: number;
72
+ /** Reason for disconnect */
73
+ reason?: string;
74
+ }
75
+
76
+ /**
77
+ * Unified Centrifugo event payload
78
+ */
79
+ export type CentrifugoEventPayload =
80
+ | { type: 'error'; data: CentrifugoErrorData }
81
+ | { type: 'version_mismatch'; data: CentrifugoVersionMismatchData }
82
+ | { type: 'connected'; data: CentrifugoConnectionData }
83
+ | { type: 'disconnected'; data: CentrifugoConnectionData }
84
+ | { type: 'reconnecting'; data: CentrifugoConnectionData };
85
+
86
+ /**
87
+ * Full event detail (includes timestamp)
88
+ */
89
+ export type CentrifugoEventDetail = CentrifugoEventPayload & {
90
+ timestamp: Date;
91
+ };
92
+
48
93
  // ─────────────────────────────────────────────────────────────────────────
49
- // Event Emitters
94
+ // Monitor Dialog Payloads (internal events)
50
95
  // ─────────────────────────────────────────────────────────────────────────
51
96
 
52
- export const emitOpenMonitorDialog = (payload?: OpenMonitorDialogPayload) => {
53
- events.publish({
54
- type: CENTRIFUGO_MONITOR_EVENTS.OPEN_MONITOR_DIALOG,
55
- payload: payload || {},
56
- });
57
- };
97
+ export interface OpenMonitorDialogPayload {
98
+ variant?: 'compact' | 'full' | 'minimal';
99
+ defaultTab?: 'connection' | 'messages' | 'subscriptions';
100
+ }
58
101
 
59
- export const emitCloseMonitorDialog = () => {
60
- events.publish({
61
- type: CENTRIFUGO_MONITOR_EVENTS.CLOSE_MONITOR_DIALOG,
62
- payload: {},
63
- });
64
- };
102
+ // ─────────────────────────────────────────────────────────────────────────
103
+ // Legacy Types (for backwards compatibility)
104
+ // ─────────────────────────────────────────────────────────────────────────
105
+
106
+ /** @deprecated Use CentrifugoErrorData */
107
+ export type CentrifugoErrorPayload = CentrifugoErrorData;
108
+
109
+ /** @deprecated Use CentrifugoVersionMismatchData */
110
+ export type VersionMismatchPayload = CentrifugoVersionMismatchData;
65
111
 
66
112
  // ─────────────────────────────────────────────────────────────────────────
67
- // Error Event Dispatcher (CustomEvent for ErrorsTracker)
113
+ // Event Dispatcher
68
114
  // ─────────────────────────────────────────────────────────────────────────
69
115
 
70
116
  /**
71
- * Dispatch Centrifugo error event for ErrorsTracker
72
- *
73
- * Uses window.dispatchEvent with CustomEvent to integrate with
74
- * ErrorsTracker from @djangocfg/layouts package.
117
+ * Dispatch unified Centrifugo event
75
118
  *
76
119
  * @example
77
120
  * ```ts
78
- * dispatchCentrifugoError({
79
- * method: 'terminal.input',
80
- * error: 'timeout',
81
- * code: 1,
82
- * data: { session_id: 'abc-123' },
121
+ * // Dispatch error
122
+ * dispatchCentrifugoEvent({
123
+ * type: 'error',
124
+ * data: { method: 'terminal.input', error: 'timeout' }
125
+ * });
126
+ *
127
+ * // Listen to all Centrifugo events
128
+ * window.addEventListener('centrifugo', (e) => {
129
+ * const { type, data, timestamp } = e.detail;
130
+ * switch (type) {
131
+ * case 'error':
132
+ * console.error('RPC error:', data.method, data.error);
133
+ * break;
134
+ * case 'version_mismatch':
135
+ * toast.warning('Please refresh the page');
136
+ * break;
137
+ * }
83
138
  * });
84
139
  * ```
85
140
  */
86
- export const dispatchCentrifugoError = (payload: CentrifugoErrorPayload): void => {
141
+ export const dispatchCentrifugoEvent = (payload: CentrifugoEventPayload): void => {
87
142
  if (typeof window === 'undefined') {
88
143
  return;
89
144
  }
90
145
 
91
146
  try {
92
- window.dispatchEvent(new CustomEvent(CENTRIFUGO_ERROR_EVENT, {
93
- detail: {
94
- ...payload,
95
- timestamp: new Date(),
96
- },
147
+ const detail: CentrifugoEventDetail = {
148
+ ...payload,
149
+ timestamp: new Date(),
150
+ };
151
+
152
+ window.dispatchEvent(new CustomEvent(CENTRIFUGO_EVENT, {
153
+ detail,
97
154
  bubbles: true,
98
155
  cancelable: false,
99
156
  }));
100
- } catch (eventError) {
157
+ } catch (error) {
101
158
  // Silently fail - event dispatch should never crash the app
102
159
  }
103
160
  };
104
161
 
162
+ // ─────────────────────────────────────────────────────────────────────────
163
+ // Convenience Dispatchers
164
+ // ─────────────────────────────────────────────────────────────────────────
165
+
166
+ /**
167
+ * Dispatch error event
168
+ */
169
+ export const dispatchCentrifugoError = (data: CentrifugoErrorData): void => {
170
+ dispatchCentrifugoEvent({ type: 'error', data });
171
+ };
172
+
173
+ /**
174
+ * Dispatch version mismatch event
175
+ */
176
+ export const dispatchVersionMismatch = (data: CentrifugoVersionMismatchData): void => {
177
+ dispatchCentrifugoEvent({ type: 'version_mismatch', data });
178
+ };
179
+
180
+ /**
181
+ * Dispatch connected event
182
+ */
183
+ export const dispatchConnected = (data: CentrifugoConnectionData = {}): void => {
184
+ dispatchCentrifugoEvent({ type: 'connected', data });
185
+ };
186
+
187
+ /**
188
+ * Dispatch disconnected event
189
+ */
190
+ export const dispatchDisconnected = (data: CentrifugoConnectionData = {}): void => {
191
+ dispatchCentrifugoEvent({ type: 'disconnected', data });
192
+ };
193
+
194
+ /**
195
+ * Dispatch reconnecting event
196
+ */
197
+ export const dispatchReconnecting = (data: CentrifugoConnectionData = {}): void => {
198
+ dispatchCentrifugoEvent({ type: 'reconnecting', data });
199
+ };
200
+
201
+ // ─────────────────────────────────────────────────────────────────────────
202
+ // Monitor Dialog Emitters (internal, uses events.publish)
203
+ // ─────────────────────────────────────────────────────────────────────────
204
+
205
+ export const emitOpenMonitorDialog = (payload?: OpenMonitorDialogPayload) => {
206
+ events.publish({
207
+ type: CENTRIFUGO_MONITOR_EVENTS.OPEN_MONITOR_DIALOG,
208
+ payload: payload || {},
209
+ });
210
+ };
211
+
212
+ export const emitCloseMonitorDialog = () => {
213
+ events.publish({
214
+ type: CENTRIFUGO_MONITOR_EVENTS.CLOSE_MONITOR_DIALOG,
215
+ payload: {},
216
+ });
217
+ };