@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,229 @@
1
+ /**
2
+ * Connection Management Module
3
+ *
4
+ * Handles Centrifuge WebSocket connection lifecycle.
5
+ */
6
+
7
+ import { Centrifuge, type Subscription } from 'centrifuge';
8
+
9
+ import { dispatchConnected, dispatchDisconnected, dispatchReconnecting } from '../../events';
10
+ import { getConsolaLogger } from '../logger/consolaLogger';
11
+
12
+ import type { Logger } from '../logger';
13
+ import type { CentrifugoClientOptions, PendingRequest } from './types';
14
+
15
+ export interface ConnectionState {
16
+ centrifuge: Centrifuge;
17
+ replySubscription: Subscription | null;
18
+ pendingRequests: Map<string, PendingRequest>;
19
+ channelSubscriptions: Map<string, Subscription>;
20
+ userId: string;
21
+ replyChannel: string;
22
+ timeout: number;
23
+ logger: Logger;
24
+ }
25
+
26
+ /**
27
+ * Create connection state from options.
28
+ * Handles both old positional args and new options-based API.
29
+ */
30
+ export function createConnectionState(
31
+ urlOrOptions: string | CentrifugoClientOptions,
32
+ token?: string,
33
+ userId?: string,
34
+ timeout: number = 30000,
35
+ logger?: Logger
36
+ ): ConnectionState {
37
+ // Parse arguments
38
+ let url: string;
39
+ let actualToken: string;
40
+ let actualUserId: string;
41
+ let actualTimeout: number;
42
+ let actualLogger: Logger | undefined;
43
+ let getToken: (() => Promise<string>) | undefined;
44
+
45
+ if (typeof urlOrOptions === 'object') {
46
+ url = urlOrOptions.url;
47
+ actualToken = urlOrOptions.token;
48
+ actualUserId = urlOrOptions.userId;
49
+ actualTimeout = urlOrOptions.timeout ?? 30000;
50
+ actualLogger = urlOrOptions.logger;
51
+ getToken = urlOrOptions.getToken;
52
+ } else {
53
+ url = urlOrOptions;
54
+ actualToken = token!;
55
+ actualUserId = userId!;
56
+ actualTimeout = timeout;
57
+ actualLogger = logger;
58
+ }
59
+
60
+ const log = actualLogger || getConsolaLogger('client');
61
+ const replyChannel = `user#${actualUserId}`;
62
+
63
+ // Build Centrifuge options
64
+ const centrifugeOptions: any = {
65
+ token: actualToken,
66
+ };
67
+
68
+ // Add getToken callback for automatic token refresh
69
+ if (getToken) {
70
+ centrifugeOptions.getToken = async () => {
71
+ log.info('Token expired, refreshing...');
72
+ try {
73
+ const newToken = await getToken();
74
+ log.success('Token refreshed successfully');
75
+ return newToken;
76
+ } catch (error) {
77
+ log.error('Failed to refresh token', error);
78
+ throw error;
79
+ }
80
+ };
81
+ }
82
+
83
+ const centrifuge = new Centrifuge(url, centrifugeOptions);
84
+
85
+ return {
86
+ centrifuge,
87
+ replySubscription: null,
88
+ pendingRequests: new Map(),
89
+ channelSubscriptions: new Map(),
90
+ userId: actualUserId,
91
+ replyChannel,
92
+ timeout: actualTimeout,
93
+ logger: log,
94
+ };
95
+ }
96
+
97
+ /**
98
+ * Setup connection event handlers.
99
+ */
100
+ export function setupConnectionHandlers(
101
+ state: ConnectionState,
102
+ onResponse: (data: any) => void
103
+ ): void {
104
+ const { centrifuge, pendingRequests, logger, userId } = state;
105
+
106
+ // Handle disconnection - reject all pending requests
107
+ centrifuge.on('disconnected', (ctx) => {
108
+ pendingRequests.forEach(({ reject }) => {
109
+ reject(new Error('Disconnected from Centrifugo'));
110
+ });
111
+ pendingRequests.clear();
112
+
113
+ dispatchDisconnected({
114
+ userId,
115
+ reason: ctx.reason,
116
+ });
117
+
118
+ logger.info('Disconnected from Centrifugo', { reason: ctx.reason });
119
+ });
120
+
121
+ // Handle successful connection
122
+ centrifuge.on('connected', () => {
123
+ dispatchConnected({ userId });
124
+ logger.success('Connected to Centrifugo');
125
+ });
126
+
127
+ // Handle reconnecting state
128
+ centrifuge.on('connecting', (ctx) => {
129
+ if (ctx.reason === 'transport closed') {
130
+ dispatchReconnecting({
131
+ userId,
132
+ attempt: 1,
133
+ reason: ctx.reason,
134
+ });
135
+ logger.info('Reconnecting to Centrifugo...', { reason: ctx.reason });
136
+ }
137
+ });
138
+ }
139
+
140
+ /**
141
+ * Connect to Centrifugo server.
142
+ */
143
+ export async function connect(
144
+ state: ConnectionState,
145
+ onResponse: (data: any) => void
146
+ ): Promise<void> {
147
+ const { centrifuge, replyChannel, logger } = state;
148
+
149
+ return new Promise((resolve, reject) => {
150
+ let resolved = false;
151
+
152
+ const onConnected = () => {
153
+ if (!resolved) {
154
+ resolved = true;
155
+ resolve();
156
+ }
157
+ };
158
+
159
+ const onError = (ctx: any) => {
160
+ if (!resolved) {
161
+ resolved = true;
162
+ reject(new Error(ctx.message || 'Connection error'));
163
+ }
164
+ };
165
+
166
+ centrifuge.on('connected', onConnected);
167
+ centrifuge.on('error', onError);
168
+
169
+ // Start connection
170
+ centrifuge.connect();
171
+
172
+ // Subscribe to reply channel for RPC responses
173
+ const subscription = centrifuge.newSubscription(replyChannel);
174
+ state.replySubscription = subscription;
175
+
176
+ subscription.on('publication', (ctx: any) => {
177
+ onResponse(ctx.data);
178
+ });
179
+
180
+ subscription.on('subscribed', () => {
181
+ logger.success(`Subscribed to reply channel: ${replyChannel}`);
182
+ });
183
+
184
+ subscription.on('error', (ctx: any) => {
185
+ // Error code 105 = "already subscribed" (server-side subscription from JWT)
186
+ if (ctx.error?.code === 105) {
187
+ // This is fine, server-side subscription exists
188
+ } else {
189
+ logger.error(`Subscription error for ${replyChannel}:`, ctx.error);
190
+ }
191
+ });
192
+
193
+ subscription.subscribe();
194
+ });
195
+ }
196
+
197
+ /**
198
+ * Disconnect from Centrifugo server.
199
+ */
200
+ export function disconnect(state: ConnectionState): void {
201
+ const { centrifuge, replySubscription, channelSubscriptions, logger } = state;
202
+
203
+ // Unsubscribe from all event channels
204
+ channelSubscriptions.forEach((sub, channel) => {
205
+ try {
206
+ sub.unsubscribe();
207
+ centrifuge.removeSubscription(sub);
208
+ } catch (error) {
209
+ logger.error(`Error unsubscribing from ${channel}`, error);
210
+ }
211
+ });
212
+ channelSubscriptions.clear();
213
+
214
+ // Unsubscribe from RPC reply channel
215
+ if (replySubscription) {
216
+ replySubscription.unsubscribe();
217
+ state.replySubscription = null;
218
+ }
219
+
220
+ centrifuge.disconnect();
221
+ logger.info('Disconnected from Centrifugo');
222
+ }
223
+
224
+ /**
225
+ * Generate unique correlation ID for RPC requests.
226
+ */
227
+ export function generateCorrelationId(): string {
228
+ return `rpc-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
229
+ }
@@ -1,6 +1,51 @@
1
1
  /**
2
- * Client Module
2
+ * Centrifugo Client Module
3
+ *
4
+ * Exports the main client and its supporting modules.
3
5
  */
4
6
 
7
+ // Main client (facade)
5
8
  export { CentrifugoRPCClient } from './CentrifugoRPCClient';
6
- export type { CentrifugoClientOptions } from './CentrifugoRPCClient';
9
+
10
+ // Types
11
+ export type {
12
+ CentrifugoClientOptions,
13
+ RPCOptions,
14
+ RetryOptions,
15
+ VersionCheckResult,
16
+ PendingRequest,
17
+ } from './types';
18
+
19
+ // Connection module (for advanced use cases)
20
+ export {
21
+ createConnectionState,
22
+ setupConnectionHandlers,
23
+ connect,
24
+ disconnect,
25
+ generateCorrelationId,
26
+ type ConnectionState,
27
+ } from './connection';
28
+
29
+ // Subscription module (for advanced use cases)
30
+ export {
31
+ subscribe,
32
+ unsubscribe,
33
+ unsubscribeAll,
34
+ getActiveSubscriptions,
35
+ getServerSideSubscriptions,
36
+ getAllSubscriptions,
37
+ type SubscriptionManager,
38
+ } from './subscriptions';
39
+
40
+ // RPC module (for advanced use cases)
41
+ export {
42
+ handleResponse,
43
+ call,
44
+ rpc,
45
+ namedRPC,
46
+ namedRPCNoWait,
47
+ type RPCManager,
48
+ } from './rpc';
49
+
50
+ // Version module (for advanced use cases)
51
+ export { checkApiVersion, type VersionChecker } from './version';
@@ -0,0 +1,290 @@
1
+ /**
2
+ * RPC Module
3
+ *
4
+ * Handles Centrifugo RPC patterns (request-response and fire-and-forget).
5
+ */
6
+
7
+ import type { Centrifuge, Subscription } from 'centrifuge';
8
+
9
+ import { dispatchCentrifugoError } from '../../events';
10
+
11
+ import type { Logger } from '../logger';
12
+ import type { PendingRequest, RPCOptions, RetryOptions } from './types';
13
+ import { generateCorrelationId } from './connection';
14
+
15
+ export interface RPCManager {
16
+ centrifuge: Centrifuge;
17
+ pendingRequests: Map<string, PendingRequest>;
18
+ channelSubscriptions: Map<string, Subscription>;
19
+ userId: string;
20
+ timeout: number;
21
+ logger: Logger;
22
+ subscribe: (channel: string, callback: (data: any) => void) => () => void;
23
+ }
24
+
25
+ /**
26
+ * Handle RPC response from reply channel.
27
+ */
28
+ export function handleResponse(
29
+ pendingRequests: Map<string, PendingRequest>,
30
+ data: any
31
+ ): void {
32
+ const correlationId = data.correlation_id;
33
+ if (!correlationId) {
34
+ return;
35
+ }
36
+
37
+ const pending = pendingRequests.get(correlationId);
38
+ if (!pending) {
39
+ return;
40
+ }
41
+
42
+ pendingRequests.delete(correlationId);
43
+
44
+ if (data.error) {
45
+ pending.reject(new Error(data.error.message || 'RPC error'));
46
+ } else {
47
+ pending.resolve(data.result);
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Legacy RPC call via publish/subscribe pattern.
53
+ *
54
+ * @deprecated Use namedRPC for Centrifugo RPC Proxy
55
+ */
56
+ export async function call<T = any>(
57
+ manager: RPCManager,
58
+ method: string,
59
+ params: any,
60
+ replyChannel: string
61
+ ): Promise<T> {
62
+ const { centrifuge, pendingRequests, timeout, logger } = manager;
63
+ const correlationId = generateCorrelationId();
64
+
65
+ const message = {
66
+ method,
67
+ params,
68
+ correlation_id: correlationId,
69
+ reply_to: replyChannel,
70
+ };
71
+
72
+ const promise = new Promise<T>((resolve, reject) => {
73
+ const timeoutId = setTimeout(() => {
74
+ pendingRequests.delete(correlationId);
75
+ reject(new Error(`RPC timeout: ${method}`));
76
+ }, timeout);
77
+
78
+ pendingRequests.set(correlationId, {
79
+ resolve: (result: T) => {
80
+ clearTimeout(timeoutId);
81
+ resolve(result);
82
+ },
83
+ reject: (error: Error) => {
84
+ clearTimeout(timeoutId);
85
+ reject(error);
86
+ },
87
+ });
88
+ });
89
+
90
+ await centrifuge.publish('rpc.requests', message);
91
+
92
+ return promise;
93
+ }
94
+
95
+ /**
96
+ * RPC Pattern API (Request-Response via Correlation ID).
97
+ *
98
+ * @deprecated Use namedRPC for Centrifugo RPC Proxy
99
+ */
100
+ export async function rpc<TRequest = any, TResponse = any>(
101
+ manager: RPCManager,
102
+ method: string,
103
+ params: TRequest,
104
+ options: RPCOptions = {}
105
+ ): Promise<TResponse> {
106
+ const { centrifuge, logger, subscribe, userId } = manager;
107
+ const { timeout = 10000, replyChannel = `user#${userId}` } = options;
108
+
109
+ const correlationId = generateCorrelationId();
110
+
111
+ logger.info(`RPC request: ${method}`, { correlationId, params });
112
+
113
+ return new Promise<TResponse>((resolve, reject) => {
114
+ let timeoutId: NodeJS.Timeout | null = null;
115
+ let unsubscribe: (() => void) | null = null;
116
+
117
+ const cleanup = () => {
118
+ if (timeoutId) {
119
+ clearTimeout(timeoutId);
120
+ timeoutId = null;
121
+ }
122
+ if (unsubscribe) {
123
+ unsubscribe();
124
+ unsubscribe = null;
125
+ }
126
+ };
127
+
128
+ timeoutId = setTimeout(() => {
129
+ cleanup();
130
+ const error = new Error(`RPC timeout: ${method} (${timeout}ms)`);
131
+ logger.error(`RPC timeout: ${method}`, { correlationId, timeout });
132
+ reject(error);
133
+ }, timeout);
134
+
135
+ try {
136
+ unsubscribe = subscribe(replyChannel, (data: any) => {
137
+ try {
138
+ if (data.correlation_id === correlationId) {
139
+ cleanup();
140
+
141
+ if (data.error) {
142
+ logger.error(`RPC error: ${method}`, {
143
+ correlationId,
144
+ error: data.error,
145
+ });
146
+ reject(new Error(data.error.message || 'RPC error'));
147
+ return;
148
+ }
149
+
150
+ logger.success(`RPC response: ${method}`, {
151
+ correlationId,
152
+ hasResult: !!data.result,
153
+ });
154
+ resolve(data.result as TResponse);
155
+ }
156
+ } catch (error) {
157
+ cleanup();
158
+ logger.error(`Error processing RPC response: ${method}`, error);
159
+ reject(error);
160
+ }
161
+ });
162
+
163
+ const rpcChannel = `rpc#${method}`;
164
+ const sub = centrifuge.getSubscription(rpcChannel);
165
+
166
+ if (sub) {
167
+ sub
168
+ .publish({
169
+ correlation_id: correlationId,
170
+ method,
171
+ params,
172
+ reply_to: replyChannel,
173
+ timestamp: Date.now(),
174
+ })
175
+ .catch((error: any) => {
176
+ cleanup();
177
+ logger.error(`Failed to publish RPC request: ${method}`, error);
178
+ reject(error);
179
+ });
180
+ } else {
181
+ cleanup();
182
+ reject(
183
+ new Error(
184
+ `Cannot publish RPC request: no subscription to ${rpcChannel}. ` +
185
+ `Backend should provide a publish endpoint or use server-side subscriptions.`
186
+ )
187
+ );
188
+ }
189
+ } catch (error) {
190
+ cleanup();
191
+ logger.error(`Failed to setup RPC: ${method}`, error);
192
+ reject(error);
193
+ }
194
+ });
195
+ }
196
+
197
+ /**
198
+ * Call RPC method via native Centrifugo RPC proxy.
199
+ *
200
+ * This uses Centrifugo's built-in RPC mechanism which proxies
201
+ * requests to the backend (Django) via HTTP.
202
+ *
203
+ * Flow:
204
+ * 1. Client calls namedRPC('terminal.input', data)
205
+ * 2. Centrifuge.js sends RPC over WebSocket
206
+ * 3. Centrifugo proxies to Django: POST /centrifugo/rpc/
207
+ * 4. Django routes to @websocket_rpc handler
208
+ * 5. Response returned to client
209
+ */
210
+ export async function namedRPC<TRequest = any, TResponse = any>(
211
+ manager: RPCManager,
212
+ method: string,
213
+ data: TRequest,
214
+ options?: { timeout?: number }
215
+ ): Promise<TResponse> {
216
+ const { centrifuge, logger } = manager;
217
+
218
+ logger.info(`Native RPC: ${method}`, { data });
219
+
220
+ try {
221
+ const result = await centrifuge.rpc(method, data);
222
+
223
+ logger.success(`Native RPC success: ${method}`, {
224
+ hasData: !!result.data,
225
+ });
226
+
227
+ return result.data as TResponse;
228
+ } catch (error) {
229
+ logger.error(`Native RPC failed: ${method}`, error);
230
+
231
+ // Dispatch error event for ErrorsTracker
232
+ const errorMessage = error instanceof Error ? error.message : String(error);
233
+ const errorCode = (error as any)?.code;
234
+ dispatchCentrifugoError({
235
+ method,
236
+ error: errorMessage,
237
+ code: errorCode,
238
+ data,
239
+ });
240
+
241
+ throw error;
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Fire-and-forget RPC call - sends without waiting for response.
247
+ * Use for latency-sensitive operations like terminal input.
248
+ *
249
+ * Features:
250
+ * - Returns immediately (non-blocking)
251
+ * - Automatic retry with exponential backoff on failure
252
+ * - Configurable max retries and delays
253
+ */
254
+ export function namedRPCNoWait<TRequest = any>(
255
+ manager: RPCManager,
256
+ method: string,
257
+ data: TRequest,
258
+ options?: RetryOptions
259
+ ): void {
260
+ const { centrifuge, logger } = manager;
261
+
262
+ const maxRetries = options?.maxRetries ?? 3;
263
+ const baseDelayMs = options?.baseDelayMs ?? 100;
264
+ const maxDelayMs = options?.maxDelayMs ?? 2000;
265
+
266
+ const attemptSend = (attempt: number): void => {
267
+ centrifuge.rpc(method, data).catch((error) => {
268
+ if (attempt < maxRetries) {
269
+ // Exponential backoff: 100ms, 200ms, 400ms... capped at maxDelayMs
270
+ const delay = Math.min(baseDelayMs * Math.pow(2, attempt), maxDelayMs);
271
+
272
+ logger.warning(
273
+ `Fire-and-forget RPC failed (attempt ${attempt + 1}/${maxRetries + 1}), retrying in ${delay}ms: ${method}`,
274
+ error
275
+ );
276
+
277
+ setTimeout(() => attemptSend(attempt + 1), delay);
278
+ } else {
279
+ // All retries exhausted
280
+ logger.error(
281
+ `Fire-and-forget RPC failed after ${maxRetries + 1} attempts: ${method}`,
282
+ error
283
+ );
284
+ }
285
+ });
286
+ };
287
+
288
+ // Start first attempt immediately
289
+ attemptSend(0);
290
+ }