@djangocfg/centrifugo 2.1.53 → 2.1.55
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/README.md +218 -7
- package/package.json +5 -5
- package/src/core/client/CentrifugoRPCClient.ts +146 -532
- package/src/core/client/connection.ts +229 -0
- package/src/core/client/index.ts +47 -2
- package/src/core/client/rpc.ts +290 -0
- package/src/core/client/subscriptions.ts +176 -0
- package/src/core/client/types.ts +44 -0
- package/src/core/client/version.ts +92 -0
- package/src/events.ts +160 -47
|
@@ -1,38 +1,62 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Centrifugo RPC Client
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Facade for WebSocket connection and RPC call handling.
|
|
5
5
|
* Provides both RPC (request/response) and Subscription (pub/sub) patterns.
|
|
6
|
+
*
|
|
7
|
+
* Architecture:
|
|
8
|
+
* - connection.ts - Connection lifecycle management
|
|
9
|
+
* - subscriptions.ts - Channel subscription management
|
|
10
|
+
* - rpc.ts - RPC methods (namedRPC, namedRPCNoWait)
|
|
11
|
+
* - version.ts - API version checking
|
|
12
|
+
* - types.ts - Type definitions
|
|
6
13
|
*/
|
|
7
14
|
|
|
8
|
-
import { Centrifuge } from 'centrifuge';
|
|
9
|
-
|
|
10
|
-
import { getConsolaLogger } from '../logger/consolaLogger';
|
|
11
|
-
import { dispatchCentrifugoError } from '../../events';
|
|
15
|
+
import type { Centrifuge, Subscription } from 'centrifuge';
|
|
12
16
|
|
|
13
17
|
import type { Logger } from '../logger';
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
18
|
+
import type {
|
|
19
|
+
CentrifugoClientOptions,
|
|
20
|
+
PendingRequest,
|
|
21
|
+
RPCOptions,
|
|
22
|
+
RetryOptions,
|
|
23
|
+
VersionCheckResult,
|
|
24
|
+
} from './types';
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
createConnectionState,
|
|
28
|
+
setupConnectionHandlers,
|
|
29
|
+
connect as connectToServer,
|
|
30
|
+
disconnect as disconnectFromServer,
|
|
31
|
+
type ConnectionState,
|
|
32
|
+
} from './connection';
|
|
33
|
+
|
|
34
|
+
import {
|
|
35
|
+
subscribe as subscribeToChannel,
|
|
36
|
+
unsubscribe as unsubscribeFromChannel,
|
|
37
|
+
unsubscribeAll as unsubscribeFromAllChannels,
|
|
38
|
+
getActiveSubscriptions as getClientSubscriptions,
|
|
39
|
+
getServerSideSubscriptions as getServerSubscriptions,
|
|
40
|
+
getAllSubscriptions as getCombinedSubscriptions,
|
|
41
|
+
type SubscriptionManager,
|
|
42
|
+
} from './subscriptions';
|
|
43
|
+
|
|
44
|
+
import {
|
|
45
|
+
handleResponse,
|
|
46
|
+
call as legacyCall,
|
|
47
|
+
rpc as legacyRpc,
|
|
48
|
+
namedRPC as nativeNamedRPC,
|
|
49
|
+
namedRPCNoWait as nativeNamedRPCNoWait,
|
|
50
|
+
type RPCManager,
|
|
51
|
+
} from './rpc';
|
|
52
|
+
|
|
53
|
+
import { checkApiVersion as checkVersion } from './version';
|
|
54
|
+
|
|
55
|
+
// Re-export types for backwards compatibility
|
|
56
|
+
export type { CentrifugoClientOptions } from './types';
|
|
26
57
|
|
|
27
58
|
export class CentrifugoRPCClient {
|
|
28
|
-
private
|
|
29
|
-
private subscription: any;
|
|
30
|
-
private channelSubscriptions: Map<string, any> = new Map();
|
|
31
|
-
private pendingRequests: Map<string, { resolve: Function; reject: Function }> = new Map();
|
|
32
|
-
private readonly userId: string;
|
|
33
|
-
private readonly replyChannel: string;
|
|
34
|
-
private readonly timeout: number;
|
|
35
|
-
private readonly logger: Logger;
|
|
59
|
+
private state: ConnectionState;
|
|
36
60
|
|
|
37
61
|
constructor(options: CentrifugoClientOptions);
|
|
38
62
|
/** @deprecated Use options object instead */
|
|
@@ -50,558 +74,148 @@ export class CentrifugoRPCClient {
|
|
|
50
74
|
timeout: number = 30000,
|
|
51
75
|
logger?: Logger
|
|
52
76
|
) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
let actualToken: string;
|
|
56
|
-
let actualUserId: string;
|
|
57
|
-
let actualTimeout: number;
|
|
58
|
-
let actualLogger: Logger | undefined;
|
|
59
|
-
let getToken: (() => Promise<string>) | undefined;
|
|
60
|
-
|
|
61
|
-
if (typeof urlOrOptions === 'object') {
|
|
62
|
-
// New options-based API
|
|
63
|
-
url = urlOrOptions.url;
|
|
64
|
-
actualToken = urlOrOptions.token;
|
|
65
|
-
actualUserId = urlOrOptions.userId;
|
|
66
|
-
actualTimeout = urlOrOptions.timeout ?? 30000;
|
|
67
|
-
actualLogger = urlOrOptions.logger;
|
|
68
|
-
getToken = urlOrOptions.getToken;
|
|
69
|
-
} else {
|
|
70
|
-
// Legacy positional arguments API
|
|
71
|
-
url = urlOrOptions;
|
|
72
|
-
actualToken = token!;
|
|
73
|
-
actualUserId = userId!;
|
|
74
|
-
actualTimeout = timeout;
|
|
75
|
-
actualLogger = logger;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
this.userId = actualUserId;
|
|
79
|
-
this.replyChannel = `user#${actualUserId}`;
|
|
80
|
-
this.timeout = actualTimeout;
|
|
81
|
-
this.logger = actualLogger || getConsolaLogger('client');
|
|
82
|
-
|
|
83
|
-
// Build Centrifuge options
|
|
84
|
-
const centrifugeOptions: any = {
|
|
85
|
-
token: actualToken,
|
|
86
|
-
};
|
|
87
|
-
|
|
88
|
-
// Add getToken callback for automatic token refresh
|
|
89
|
-
if (getToken) {
|
|
90
|
-
centrifugeOptions.getToken = async () => {
|
|
91
|
-
this.logger.info('Token expired, refreshing...');
|
|
92
|
-
try {
|
|
93
|
-
const newToken = await getToken();
|
|
94
|
-
this.logger.success('Token refreshed successfully');
|
|
95
|
-
return newToken;
|
|
96
|
-
} catch (error) {
|
|
97
|
-
this.logger.error('Failed to refresh token', error);
|
|
98
|
-
throw error;
|
|
99
|
-
}
|
|
100
|
-
};
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
this.centrifuge = new Centrifuge(url, centrifugeOptions);
|
|
104
|
-
|
|
105
|
-
this.centrifuge.on('disconnected', (ctx) => {
|
|
106
|
-
// Reject all pending requests
|
|
107
|
-
this.pendingRequests.forEach(({ reject }) => {
|
|
108
|
-
reject(new Error('Disconnected from Centrifugo'));
|
|
109
|
-
});
|
|
110
|
-
this.pendingRequests.clear();
|
|
111
|
-
});
|
|
77
|
+
this.state = createConnectionState(urlOrOptions, token, userId, timeout, logger);
|
|
78
|
+
setupConnectionHandlers(this.state, (data) => this.handleResponse(data));
|
|
112
79
|
}
|
|
113
80
|
|
|
81
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
82
|
+
// Connection API
|
|
83
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
84
|
+
|
|
114
85
|
async connect(): Promise<void> {
|
|
115
|
-
return
|
|
116
|
-
let resolved = false;
|
|
117
|
-
|
|
118
|
-
// Listen to Centrifuge connection events
|
|
119
|
-
const onConnected = () => {
|
|
120
|
-
if (!resolved) {
|
|
121
|
-
resolved = true;
|
|
122
|
-
resolve();
|
|
123
|
-
}
|
|
124
|
-
};
|
|
125
|
-
|
|
126
|
-
const onError = (ctx: any) => {
|
|
127
|
-
if (!resolved) {
|
|
128
|
-
resolved = true;
|
|
129
|
-
reject(new Error(ctx.message || 'Connection error'));
|
|
130
|
-
}
|
|
131
|
-
};
|
|
132
|
-
|
|
133
|
-
this.centrifuge.on('connected', onConnected);
|
|
134
|
-
this.centrifuge.on('error', onError);
|
|
135
|
-
|
|
136
|
-
// Start connection
|
|
137
|
-
this.centrifuge.connect();
|
|
138
|
-
|
|
139
|
-
// Subscribe to reply channel
|
|
140
|
-
this.subscription = this.centrifuge.newSubscription(this.replyChannel);
|
|
141
|
-
|
|
142
|
-
this.subscription.on('publication', (ctx: any) => {
|
|
143
|
-
this.handleResponse(ctx.data);
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
this.subscription.on('subscribed', () => {
|
|
147
|
-
// Subscription successful (optional, we already resolved on 'connected')
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
this.subscription.on('error', (ctx: any) => {
|
|
151
|
-
// Error code 105 = "already subscribed" (server-side subscription from JWT)
|
|
152
|
-
// This is not an error - the channel is already active via server-side subscription
|
|
153
|
-
if (ctx.error?.code === 105) {
|
|
154
|
-
// This is fine, server-side subscription exists
|
|
155
|
-
} else {
|
|
156
|
-
this.logger.error(`Subscription error for ${this.replyChannel}:`, ctx.error);
|
|
157
|
-
}
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
this.subscription.subscribe();
|
|
161
|
-
});
|
|
86
|
+
return connectToServer(this.state, (data) => this.handleResponse(data));
|
|
162
87
|
}
|
|
163
88
|
|
|
164
89
|
async disconnect(): Promise<void> {
|
|
165
|
-
|
|
166
|
-
|
|
90
|
+
disconnectFromServer(this.state);
|
|
91
|
+
}
|
|
167
92
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
93
|
+
getCentrifuge(): Centrifuge {
|
|
94
|
+
return this.state.centrifuge;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
98
|
+
// Subscription API
|
|
99
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
172
100
|
|
|
173
|
-
|
|
101
|
+
subscribe(channel: string, callback: (data: any) => void): () => void {
|
|
102
|
+
return subscribeToChannel(this.subscriptionManager, channel, callback);
|
|
174
103
|
}
|
|
175
104
|
|
|
176
|
-
|
|
177
|
-
|
|
105
|
+
unsubscribe(channel: string): void {
|
|
106
|
+
unsubscribeFromChannel(this.subscriptionManager, channel);
|
|
107
|
+
}
|
|
178
108
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
correlation_id: correlationId,
|
|
183
|
-
reply_to: this.replyChannel,
|
|
184
|
-
};
|
|
109
|
+
unsubscribeAll(): void {
|
|
110
|
+
unsubscribeFromAllChannels(this.subscriptionManager);
|
|
111
|
+
}
|
|
185
112
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
const timeoutId = setTimeout(() => {
|
|
189
|
-
this.pendingRequests.delete(correlationId);
|
|
190
|
-
reject(new Error(`RPC timeout: ${method}`));
|
|
191
|
-
}, this.timeout);
|
|
192
|
-
|
|
193
|
-
this.pendingRequests.set(correlationId, {
|
|
194
|
-
resolve: (result: T) => {
|
|
195
|
-
clearTimeout(timeoutId);
|
|
196
|
-
resolve(result);
|
|
197
|
-
},
|
|
198
|
-
reject: (error: Error) => {
|
|
199
|
-
clearTimeout(timeoutId);
|
|
200
|
-
reject(error);
|
|
201
|
-
},
|
|
202
|
-
});
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
// Publish request
|
|
206
|
-
await this.centrifuge.publish('rpc.requests', message);
|
|
207
|
-
|
|
208
|
-
return promise;
|
|
113
|
+
getActiveSubscriptions(): string[] {
|
|
114
|
+
return getClientSubscriptions(this.subscriptionManager);
|
|
209
115
|
}
|
|
210
116
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
if (!correlationId) {
|
|
214
|
-
return;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
const pending = this.pendingRequests.get(correlationId);
|
|
218
|
-
if (!pending) {
|
|
219
|
-
return;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
this.pendingRequests.delete(correlationId);
|
|
223
|
-
|
|
224
|
-
if (data.error) {
|
|
225
|
-
pending.reject(new Error(data.error.message || 'RPC error'));
|
|
226
|
-
} else {
|
|
227
|
-
pending.resolve(data.result);
|
|
228
|
-
}
|
|
117
|
+
getServerSideSubscriptions(): string[] {
|
|
118
|
+
return getServerSubscriptions(this.subscriptionManager);
|
|
229
119
|
}
|
|
230
120
|
|
|
231
|
-
|
|
232
|
-
return
|
|
121
|
+
getAllSubscriptions(): string[] {
|
|
122
|
+
return getCombinedSubscriptions(this.subscriptionManager);
|
|
233
123
|
}
|
|
234
124
|
|
|
235
125
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
236
|
-
// RPC Pattern API (
|
|
126
|
+
// RPC Pattern API (Legacy - Correlation ID)
|
|
237
127
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
238
128
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
* 1. Generate unique correlation_id
|
|
244
|
-
* 2. Subscribe to personal reply channel (user#{userId})
|
|
245
|
-
* 3. Publish request with correlation_id
|
|
246
|
-
* 4. Wait for response with matching correlation_id
|
|
247
|
-
* 5. Return response or timeout
|
|
248
|
-
*
|
|
249
|
-
* @param method - RPC method name (e.g., 'tasks.get_stats')
|
|
250
|
-
* @param params - Request parameters
|
|
251
|
-
* @param options - RPC options
|
|
252
|
-
* @returns Promise that resolves with response data
|
|
253
|
-
*
|
|
254
|
-
* @example
|
|
255
|
-
* const result = await client.rpc('tasks.get_stats', { bot_id: '123' });
|
|
256
|
-
* console.log('Stats:', result);
|
|
257
|
-
*
|
|
258
|
-
* @example
|
|
259
|
-
* // With custom timeout and reply channel
|
|
260
|
-
* const result = await client.rpc('bot.command',
|
|
261
|
-
* { command: 'start' },
|
|
262
|
-
* { timeout: 30000, replyChannel: 'bot#123#replies' }
|
|
263
|
-
* );
|
|
264
|
-
*/
|
|
129
|
+
async call<T = any>(method: string, params: any): Promise<T> {
|
|
130
|
+
return legacyCall(this.rpcManager, method, params, this.state.replyChannel);
|
|
131
|
+
}
|
|
132
|
+
|
|
265
133
|
async rpc<TRequest = any, TResponse = any>(
|
|
266
134
|
method: string,
|
|
267
135
|
params: TRequest,
|
|
268
|
-
options: {
|
|
269
|
-
timeout?: number;
|
|
270
|
-
replyChannel?: string;
|
|
271
|
-
} = {}
|
|
136
|
+
options: RPCOptions = {}
|
|
272
137
|
): Promise<TResponse> {
|
|
273
|
-
|
|
274
|
-
timeout = 10000,
|
|
275
|
-
replyChannel = `user#${this.userId}`,
|
|
276
|
-
} = options;
|
|
277
|
-
|
|
278
|
-
const correlationId = this.generateCorrelationId();
|
|
279
|
-
|
|
280
|
-
this.logger.info(`RPC request: ${method}`, { correlationId, params });
|
|
281
|
-
|
|
282
|
-
return new Promise<TResponse>((resolve, reject) => {
|
|
283
|
-
let timeoutId: NodeJS.Timeout | null = null;
|
|
284
|
-
let unsubscribe: (() => void) | null = null;
|
|
285
|
-
|
|
286
|
-
// Cleanup function
|
|
287
|
-
const cleanup = () => {
|
|
288
|
-
if (timeoutId) {
|
|
289
|
-
clearTimeout(timeoutId);
|
|
290
|
-
timeoutId = null;
|
|
291
|
-
}
|
|
292
|
-
if (unsubscribe) {
|
|
293
|
-
unsubscribe();
|
|
294
|
-
unsubscribe = null;
|
|
295
|
-
}
|
|
296
|
-
};
|
|
297
|
-
|
|
298
|
-
// Set timeout
|
|
299
|
-
timeoutId = setTimeout(() => {
|
|
300
|
-
cleanup();
|
|
301
|
-
const error = new Error(`RPC timeout: ${method} (${timeout}ms)`);
|
|
302
|
-
this.logger.error(`RPC timeout: ${method}`, { correlationId, timeout });
|
|
303
|
-
reject(error);
|
|
304
|
-
}, timeout);
|
|
305
|
-
|
|
306
|
-
// Subscribe to reply channel
|
|
307
|
-
try {
|
|
308
|
-
unsubscribe = this.subscribe(replyChannel, (data: any) => {
|
|
309
|
-
try {
|
|
310
|
-
// Check if this is our response
|
|
311
|
-
if (data.correlation_id === correlationId) {
|
|
312
|
-
cleanup();
|
|
313
|
-
|
|
314
|
-
// Check for error in response
|
|
315
|
-
if (data.error) {
|
|
316
|
-
this.logger.error(`RPC error: ${method}`, {
|
|
317
|
-
correlationId,
|
|
318
|
-
error: data.error
|
|
319
|
-
});
|
|
320
|
-
reject(new Error(data.error.message || 'RPC error'));
|
|
321
|
-
return;
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
this.logger.success(`RPC response: ${method}`, {
|
|
325
|
-
correlationId,
|
|
326
|
-
hasResult: !!data.result
|
|
327
|
-
});
|
|
328
|
-
resolve(data.result as TResponse);
|
|
329
|
-
}
|
|
330
|
-
} catch (error) {
|
|
331
|
-
cleanup();
|
|
332
|
-
this.logger.error(`Error processing RPC response: ${method}`, error);
|
|
333
|
-
reject(error);
|
|
334
|
-
}
|
|
335
|
-
});
|
|
336
|
-
|
|
337
|
-
// Publish request to RPC channel
|
|
338
|
-
// Note: This uses Centrifuge's publish, not our HTTP publish
|
|
339
|
-
// The backend should be subscribed to this channel
|
|
340
|
-
const rpcChannel = `rpc#${method}`;
|
|
341
|
-
const sub = this.centrifuge.getSubscription(rpcChannel);
|
|
342
|
-
|
|
343
|
-
if (sub) {
|
|
344
|
-
// If we have a subscription, use it to publish
|
|
345
|
-
sub.publish({
|
|
346
|
-
correlation_id: correlationId,
|
|
347
|
-
method,
|
|
348
|
-
params,
|
|
349
|
-
reply_to: replyChannel,
|
|
350
|
-
timestamp: Date.now(),
|
|
351
|
-
}).catch((error: any) => {
|
|
352
|
-
cleanup();
|
|
353
|
-
this.logger.error(`Failed to publish RPC request: ${method}`, error);
|
|
354
|
-
reject(error);
|
|
355
|
-
});
|
|
356
|
-
} else {
|
|
357
|
-
// Otherwise, we need to publish via HTTP API
|
|
358
|
-
// This requires the backend to expose a publish endpoint
|
|
359
|
-
cleanup();
|
|
360
|
-
reject(new Error(
|
|
361
|
-
`Cannot publish RPC request: no subscription to ${rpcChannel}. ` +
|
|
362
|
-
`Backend should provide a publish endpoint or use server-side subscriptions.`
|
|
363
|
-
));
|
|
364
|
-
}
|
|
365
|
-
} catch (error) {
|
|
366
|
-
cleanup();
|
|
367
|
-
this.logger.error(`Failed to setup RPC: ${method}`, error);
|
|
368
|
-
reject(error);
|
|
369
|
-
}
|
|
370
|
-
});
|
|
138
|
+
return legacyRpc(this.rpcManager, method, params, options);
|
|
371
139
|
}
|
|
372
140
|
|
|
373
141
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
374
|
-
//
|
|
142
|
+
// Native Centrifugo RPC (via RPC Proxy)
|
|
375
143
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
376
144
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
* @example
|
|
385
|
-
* const unsubscribe = client.subscribe('bot#bot-123#heartbeat', (data) => {
|
|
386
|
-
* console.log('Heartbeat:', data);
|
|
387
|
-
* });
|
|
388
|
-
*
|
|
389
|
-
* // Later: unsubscribe when done
|
|
390
|
-
* unsubscribe();
|
|
391
|
-
*/
|
|
392
|
-
subscribe(channel: string, callback: (data: any) => void): () => void {
|
|
393
|
-
// Check if already subscribed - reuse existing subscription
|
|
394
|
-
const existingSub = this.channelSubscriptions.get(channel);
|
|
395
|
-
if (existingSub) {
|
|
396
|
-
this.logger.warning(`Already subscribed to ${channel}, reusing existing subscription`);
|
|
397
|
-
|
|
398
|
-
// Add new callback handler to existing subscription
|
|
399
|
-
const handler = (ctx: any) => {
|
|
400
|
-
try {
|
|
401
|
-
callback(ctx.data);
|
|
402
|
-
} catch (error) {
|
|
403
|
-
this.logger.error(`Error in subscription callback for ${channel}`, error);
|
|
404
|
-
}
|
|
405
|
-
};
|
|
406
|
-
|
|
407
|
-
existingSub.on('publication', handler);
|
|
408
|
-
|
|
409
|
-
// Return unsubscribe that only removes this specific handler
|
|
410
|
-
return () => {
|
|
411
|
-
existingSub.off('publication', handler);
|
|
412
|
-
};
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
// Create new subscription using Centrifuge's getSubscription or newSubscription
|
|
416
|
-
let sub = this.centrifuge.getSubscription(channel);
|
|
417
|
-
if (!sub) {
|
|
418
|
-
sub = this.centrifuge.newSubscription(channel);
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
// Handle publications with error handling
|
|
422
|
-
const publicationHandler = (ctx: any) => {
|
|
423
|
-
try {
|
|
424
|
-
callback(ctx.data);
|
|
425
|
-
} catch (error) {
|
|
426
|
-
this.logger.error(`Error in subscription callback for ${channel}`, error);
|
|
427
|
-
}
|
|
428
|
-
};
|
|
429
|
-
|
|
430
|
-
sub.on('publication', publicationHandler);
|
|
431
|
-
|
|
432
|
-
// Handle subscription lifecycle
|
|
433
|
-
sub.on('subscribed', () => {
|
|
434
|
-
this.logger.success(`Subscribed to ${channel}`);
|
|
435
|
-
});
|
|
436
|
-
|
|
437
|
-
sub.on('error', (ctx: any) => {
|
|
438
|
-
this.logger.error(`Subscription error for ${channel}`, ctx.error);
|
|
439
|
-
});
|
|
145
|
+
async namedRPC<TRequest = any, TResponse = any>(
|
|
146
|
+
method: string,
|
|
147
|
+
data: TRequest,
|
|
148
|
+
options?: { timeout?: number }
|
|
149
|
+
): Promise<TResponse> {
|
|
150
|
+
return nativeNamedRPC(this.rpcManager, method, data, options);
|
|
151
|
+
}
|
|
440
152
|
|
|
441
|
-
|
|
442
|
-
|
|
153
|
+
namedRPCNoWait<TRequest = any>(
|
|
154
|
+
method: string,
|
|
155
|
+
data: TRequest,
|
|
156
|
+
options?: RetryOptions
|
|
157
|
+
): void {
|
|
158
|
+
nativeNamedRPCNoWait(this.rpcManager, method, data, options);
|
|
159
|
+
}
|
|
443
160
|
|
|
444
|
-
|
|
445
|
-
|
|
161
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
162
|
+
// API Version Checking
|
|
163
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
446
164
|
|
|
447
|
-
|
|
448
|
-
return (
|
|
165
|
+
async checkApiVersion(clientVersion: string): Promise<VersionCheckResult> {
|
|
166
|
+
return checkVersion(
|
|
167
|
+
{
|
|
168
|
+
namedRPC: (method, data) => this.namedRPC(method, data),
|
|
169
|
+
logger: this.state.logger,
|
|
170
|
+
},
|
|
171
|
+
clientVersion
|
|
172
|
+
);
|
|
449
173
|
}
|
|
450
174
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
*
|
|
455
|
-
* @param channel - Channel name
|
|
456
|
-
*/
|
|
457
|
-
unsubscribe(channel: string): void {
|
|
458
|
-
const sub = this.channelSubscriptions.get(channel);
|
|
459
|
-
if (!sub) {
|
|
460
|
-
return;
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
try {
|
|
464
|
-
// Unsubscribe from channel
|
|
465
|
-
sub.unsubscribe();
|
|
466
|
-
|
|
467
|
-
// Remove subscription from Centrifuge client registry
|
|
468
|
-
this.centrifuge.removeSubscription(sub);
|
|
469
|
-
|
|
470
|
-
// Remove from our internal map
|
|
471
|
-
this.channelSubscriptions.delete(channel);
|
|
472
|
-
|
|
473
|
-
this.logger.info(`Unsubscribed from ${channel}`);
|
|
474
|
-
} catch (error) {
|
|
475
|
-
this.logger.error(`Error unsubscribing from ${channel}`, error);
|
|
476
|
-
// Still remove from our map even if there was an error
|
|
477
|
-
this.channelSubscriptions.delete(channel);
|
|
478
|
-
}
|
|
479
|
-
}
|
|
175
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
176
|
+
// Internal Helpers
|
|
177
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
480
178
|
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
* Properly removes all subscriptions from both internal map and Centrifuge client.
|
|
484
|
-
*/
|
|
485
|
-
unsubscribeAll(): void {
|
|
486
|
-
if (this.channelSubscriptions.size === 0) {
|
|
487
|
-
return;
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
this.channelSubscriptions.forEach((sub, channel) => {
|
|
491
|
-
try {
|
|
492
|
-
sub.unsubscribe();
|
|
493
|
-
this.centrifuge.removeSubscription(sub);
|
|
494
|
-
} catch (error) {
|
|
495
|
-
this.logger.error(`Error unsubscribing from ${channel}`, error);
|
|
496
|
-
}
|
|
497
|
-
});
|
|
498
|
-
|
|
499
|
-
this.channelSubscriptions.clear();
|
|
500
|
-
this.logger.info('Unsubscribed from all channels');
|
|
179
|
+
private handleResponse(data: any): void {
|
|
180
|
+
handleResponse(this.state.pendingRequests, data);
|
|
501
181
|
}
|
|
502
182
|
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
183
|
+
private get subscriptionManager(): SubscriptionManager {
|
|
184
|
+
return {
|
|
185
|
+
channelSubscriptions: this.state.channelSubscriptions,
|
|
186
|
+
centrifuge: this.state.centrifuge,
|
|
187
|
+
logger: this.state.logger,
|
|
188
|
+
};
|
|
508
189
|
}
|
|
509
190
|
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
const serverSubs = this.centrifuge._serverSubs || {};
|
|
521
|
-
return Object.keys(serverSubs);
|
|
522
|
-
} catch (error) {
|
|
523
|
-
return [];
|
|
524
|
-
}
|
|
191
|
+
private get rpcManager(): RPCManager {
|
|
192
|
+
return {
|
|
193
|
+
centrifuge: this.state.centrifuge,
|
|
194
|
+
pendingRequests: this.state.pendingRequests,
|
|
195
|
+
channelSubscriptions: this.state.channelSubscriptions,
|
|
196
|
+
userId: this.state.userId,
|
|
197
|
+
timeout: this.state.timeout,
|
|
198
|
+
logger: this.state.logger,
|
|
199
|
+
subscribe: (channel, callback) => this.subscribe(channel, callback),
|
|
200
|
+
};
|
|
525
201
|
}
|
|
526
202
|
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
getAllSubscriptions(): string[] {
|
|
531
|
-
const clientSubs = this.getActiveSubscriptions();
|
|
532
|
-
const serverSubs = this.getServerSideSubscriptions();
|
|
203
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
204
|
+
// Getters for Testing/Debugging
|
|
205
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
533
206
|
|
|
534
|
-
|
|
535
|
-
|
|
207
|
+
/** @internal */
|
|
208
|
+
get logger(): Logger {
|
|
209
|
+
return this.state.logger;
|
|
536
210
|
}
|
|
537
211
|
|
|
538
|
-
/**
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
*/
|
|
542
|
-
getCentrifuge(): Centrifuge {
|
|
543
|
-
return this.centrifuge;
|
|
212
|
+
/** @internal */
|
|
213
|
+
get userId(): string {
|
|
214
|
+
return this.state.userId;
|
|
544
215
|
}
|
|
545
216
|
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
/**
|
|
551
|
-
* Call RPC method via native Centrifugo RPC proxy.
|
|
552
|
-
*
|
|
553
|
-
* This uses Centrifugo's built-in RPC mechanism which proxies
|
|
554
|
-
* requests to the backend (Django) via HTTP.
|
|
555
|
-
*
|
|
556
|
-
* Flow:
|
|
557
|
-
* 1. Client calls namedRPC('terminal.input', data)
|
|
558
|
-
* 2. Centrifuge.js sends RPC over WebSocket
|
|
559
|
-
* 3. Centrifugo proxies to Django: POST /centrifugo/rpc/
|
|
560
|
-
* 4. Django routes to @websocket_rpc handler
|
|
561
|
-
* 5. Response returned to client
|
|
562
|
-
*
|
|
563
|
-
* @param method - RPC method name (e.g., 'terminal.input')
|
|
564
|
-
* @param data - Request data
|
|
565
|
-
* @returns Promise that resolves with response data
|
|
566
|
-
*
|
|
567
|
-
* @example
|
|
568
|
-
* const result = await client.namedRPC('terminal.input', {
|
|
569
|
-
* session_id: 'abc-123',
|
|
570
|
-
* data: btoa('ls -la')
|
|
571
|
-
* });
|
|
572
|
-
* console.log('Result:', result); // { success: true }
|
|
573
|
-
*/
|
|
574
|
-
async namedRPC<TRequest = any, TResponse = any>(
|
|
575
|
-
method: string,
|
|
576
|
-
data: TRequest,
|
|
577
|
-
options?: { timeout?: number }
|
|
578
|
-
): Promise<TResponse> {
|
|
579
|
-
this.logger.info(`Native RPC: ${method}`, { data });
|
|
580
|
-
|
|
581
|
-
try {
|
|
582
|
-
// Note: centrifuge.rpc does not support timeout parameter directly
|
|
583
|
-
// Timeout should be handled at higher level or via AbortController
|
|
584
|
-
const result = await this.centrifuge.rpc(method, data);
|
|
585
|
-
|
|
586
|
-
this.logger.success(`Native RPC success: ${method}`, {
|
|
587
|
-
hasData: !!result.data
|
|
588
|
-
});
|
|
589
|
-
|
|
590
|
-
return result.data as TResponse;
|
|
591
|
-
} catch (error) {
|
|
592
|
-
this.logger.error(`Native RPC failed: ${method}`, error);
|
|
593
|
-
|
|
594
|
-
// Dispatch error event for ErrorsTracker
|
|
595
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
596
|
-
const errorCode = (error as any)?.code;
|
|
597
|
-
dispatchCentrifugoError({
|
|
598
|
-
method,
|
|
599
|
-
error: errorMessage,
|
|
600
|
-
code: errorCode,
|
|
601
|
-
data,
|
|
602
|
-
});
|
|
603
|
-
|
|
604
|
-
throw error;
|
|
605
|
-
}
|
|
217
|
+
/** @internal */
|
|
218
|
+
get replyChannel(): string {
|
|
219
|
+
return this.state.replyChannel;
|
|
606
220
|
}
|
|
607
221
|
}
|