@volcano.dev/sdk 1.2.0-nightly.22454064035.1
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/LICENSE +22 -0
- package/README.md +73 -0
- package/dist/index.cjs.js +2906 -0
- package/dist/index.esm.js +2897 -0
- package/dist/index.js +2912 -0
- package/dist/next/middleware.esm.js +180 -0
- package/dist/next/middleware.js +186 -0
- package/dist/realtime.cjs.js +1019 -0
- package/dist/realtime.esm.js +1016 -0
- package/dist/realtime.js +1025 -0
- package/package.json +89 -0
|
@@ -0,0 +1,1016 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Volcano Realtime SDK - WebSocket client for real-time messaging
|
|
3
|
+
*
|
|
4
|
+
* This module provides real-time capabilities including:
|
|
5
|
+
* - Broadcast: Pub/sub messaging between clients
|
|
6
|
+
* - Presence: Track online users and their state
|
|
7
|
+
* - Postgres Changes: Subscribe to database INSERT/UPDATE/DELETE events
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```javascript
|
|
11
|
+
* import { VolcanoRealtime } from '@volcano.dev/sdk/realtime';
|
|
12
|
+
*
|
|
13
|
+
* const realtime = new VolcanoRealtime({
|
|
14
|
+
* apiUrl: 'https://api.yourapp.com',
|
|
15
|
+
* anonKey: 'your-anon-key',
|
|
16
|
+
* accessToken: 'your-access-token'
|
|
17
|
+
* });
|
|
18
|
+
*
|
|
19
|
+
* // Connect to realtime server
|
|
20
|
+
* await realtime.connect();
|
|
21
|
+
*
|
|
22
|
+
* // Subscribe to a broadcast channel
|
|
23
|
+
* const channel = realtime.channel('chat-room');
|
|
24
|
+
* channel.on('message', (payload) => console.log('New message:', payload));
|
|
25
|
+
* await channel.subscribe();
|
|
26
|
+
*
|
|
27
|
+
* // Send a message
|
|
28
|
+
* channel.send({ text: 'Hello, world!' });
|
|
29
|
+
*
|
|
30
|
+
* // Subscribe to database changes
|
|
31
|
+
* const dbChannel = realtime.channel('public:messages');
|
|
32
|
+
* dbChannel.onPostgresChanges('*', 'public', 'messages', (payload) => {
|
|
33
|
+
* console.log('Database change:', payload);
|
|
34
|
+
* });
|
|
35
|
+
* await dbChannel.subscribe();
|
|
36
|
+
*
|
|
37
|
+
* // Track presence
|
|
38
|
+
* const presenceChannel = realtime.channel('lobby', { type: 'presence' });
|
|
39
|
+
* presenceChannel.onPresenceSync((state) => {
|
|
40
|
+
* console.log('Online users:', Object.keys(state));
|
|
41
|
+
* });
|
|
42
|
+
* await presenceChannel.subscribe();
|
|
43
|
+
* presenceChannel.track({ status: 'online' });
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
// Centrifuge client - dynamically imported
|
|
48
|
+
let Centrifuge = null;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Dynamically imports the Centrifuge client
|
|
52
|
+
*/
|
|
53
|
+
async function loadCentrifuge() {
|
|
54
|
+
if (Centrifuge) return Centrifuge;
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
// Try ES module import
|
|
58
|
+
const module = await import('centrifuge');
|
|
59
|
+
Centrifuge = module.Centrifuge || module.default;
|
|
60
|
+
return Centrifuge;
|
|
61
|
+
} catch {
|
|
62
|
+
throw new Error(
|
|
63
|
+
'Centrifuge client not found. Please install it: npm install centrifuge'
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Load WebSocket for Node.js environments
|
|
69
|
+
let WebSocketImpl = null;
|
|
70
|
+
async function loadWebSocket() {
|
|
71
|
+
if (WebSocketImpl) return WebSocketImpl;
|
|
72
|
+
|
|
73
|
+
// Check if we're in a browser environment
|
|
74
|
+
if (typeof window !== 'undefined' && window.WebSocket) {
|
|
75
|
+
WebSocketImpl = window.WebSocket;
|
|
76
|
+
return WebSocketImpl;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Node.js environment - try to load ws package
|
|
80
|
+
try {
|
|
81
|
+
const ws = await import('ws');
|
|
82
|
+
WebSocketImpl = ws.default || ws.WebSocket || ws;
|
|
83
|
+
return WebSocketImpl;
|
|
84
|
+
} catch {
|
|
85
|
+
throw new Error(
|
|
86
|
+
'WebSocket implementation not found. In Node.js, please install: npm install ws'
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* VolcanoRealtime - Main realtime client
|
|
93
|
+
*
|
|
94
|
+
* Channel names use simple format: type:name (e.g., "broadcast:chat")
|
|
95
|
+
* The server automatically handles project isolation - clients never
|
|
96
|
+
* need to know about project IDs.
|
|
97
|
+
*
|
|
98
|
+
* Authentication options:
|
|
99
|
+
* 1. User token: anonKey (required) + accessToken (user JWT)
|
|
100
|
+
* 2. Service key: anonKey (optional) + accessToken (service role key)
|
|
101
|
+
*/
|
|
102
|
+
class VolcanoRealtime {
|
|
103
|
+
/**
|
|
104
|
+
* Create a new VolcanoRealtime client
|
|
105
|
+
* @param {Object} config - Configuration options
|
|
106
|
+
* @param {string} config.apiUrl - Volcano API URL
|
|
107
|
+
* @param {string} [config.anonKey] - Anon key (required for user tokens, optional for service keys)
|
|
108
|
+
* @param {string} config.accessToken - Access token (user JWT) or service role key (sk-...)
|
|
109
|
+
* @param {Function} [config.getToken] - Function to get/refresh token
|
|
110
|
+
* @param {Object} [config.volcanoClient] - VolcanoAuth client for auto-fetching lightweight notifications
|
|
111
|
+
* @param {string} [config.databaseName] - Database name for auto-fetch queries
|
|
112
|
+
* @param {Object} [config.fetchConfig] - Configuration for auto-fetch behavior
|
|
113
|
+
*/
|
|
114
|
+
constructor(config) {
|
|
115
|
+
if (!config.apiUrl) throw new Error('apiUrl is required');
|
|
116
|
+
// anonKey is optional for service role keys (they contain project ID)
|
|
117
|
+
// But we need either anonKey or accessToken
|
|
118
|
+
if (config.anonKey === undefined) throw new Error('anonKey is required');
|
|
119
|
+
|
|
120
|
+
this.apiUrl = config.apiUrl.replace(/\/$/, ''); // Remove trailing slash
|
|
121
|
+
this.anonKey = config.anonKey || ''; // Allow empty string for service keys
|
|
122
|
+
this.accessToken = config.accessToken;
|
|
123
|
+
this.getToken = config.getToken;
|
|
124
|
+
|
|
125
|
+
this._client = null;
|
|
126
|
+
this._channels = new Map();
|
|
127
|
+
this._connected = false;
|
|
128
|
+
this._connectionPromise = null;
|
|
129
|
+
|
|
130
|
+
// Callbacks
|
|
131
|
+
this._onConnect = [];
|
|
132
|
+
this._onDisconnect = [];
|
|
133
|
+
this._onError = [];
|
|
134
|
+
|
|
135
|
+
// Auto-fetch support (Phase 3)
|
|
136
|
+
this._volcanoClient = config.volcanoClient || null;
|
|
137
|
+
this._fetchConfig = {
|
|
138
|
+
batchWindowMs: config.fetchConfig?.batchWindowMs || 20,
|
|
139
|
+
maxBatchSize: config.fetchConfig?.maxBatchSize || 50,
|
|
140
|
+
enabled: config.fetchConfig?.enabled !== false,
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// Database name for auto-fetch queries (optional)
|
|
144
|
+
this._databaseName = config.databaseName || null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Set the VolcanoAuth client for auto-fetching
|
|
149
|
+
* @param {Object} volcanoClient - VolcanoAuth client instance
|
|
150
|
+
*/
|
|
151
|
+
setVolcanoClient(volcanoClient) {
|
|
152
|
+
this._volcanoClient = volcanoClient;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Get the configured VolcanoAuth client
|
|
157
|
+
* @returns {Object|null} The VolcanoAuth client or null
|
|
158
|
+
*/
|
|
159
|
+
getVolcanoClient() {
|
|
160
|
+
return this._volcanoClient;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Get the fetch configuration
|
|
165
|
+
* @returns {Object} The fetch configuration
|
|
166
|
+
*/
|
|
167
|
+
getFetchConfig() {
|
|
168
|
+
return { ...this._fetchConfig };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Set the database name for auto-fetch queries
|
|
173
|
+
* @param {string} databaseName
|
|
174
|
+
*/
|
|
175
|
+
setDatabaseName(databaseName) {
|
|
176
|
+
this._databaseName = databaseName;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Get the configured database name
|
|
181
|
+
* @returns {string|null}
|
|
182
|
+
*/
|
|
183
|
+
getDatabaseName() {
|
|
184
|
+
return this._databaseName;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Get the WebSocket URL for realtime connections
|
|
190
|
+
*/
|
|
191
|
+
get wsUrl() {
|
|
192
|
+
const url = new URL(this.apiUrl);
|
|
193
|
+
const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
194
|
+
return `${protocol}//${url.host}/realtime/v1/websocket`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Connect to the realtime server
|
|
199
|
+
*/
|
|
200
|
+
async connect() {
|
|
201
|
+
if (this._connected) return;
|
|
202
|
+
if (this._connectionPromise) return this._connectionPromise;
|
|
203
|
+
|
|
204
|
+
this._connectionPromise = this._doConnect();
|
|
205
|
+
try {
|
|
206
|
+
await this._connectionPromise;
|
|
207
|
+
} finally {
|
|
208
|
+
this._connectionPromise = null;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async _doConnect() {
|
|
213
|
+
const CentrifugeClient = await loadCentrifuge();
|
|
214
|
+
const WebSocket = await loadWebSocket();
|
|
215
|
+
|
|
216
|
+
const wsUrl = `${this.wsUrl}?apikey=${encodeURIComponent(this.anonKey)}`;
|
|
217
|
+
|
|
218
|
+
this._client = new CentrifugeClient(wsUrl, {
|
|
219
|
+
token: this.accessToken,
|
|
220
|
+
getToken: this.getToken ? async () => {
|
|
221
|
+
const token = await this.getToken();
|
|
222
|
+
this.accessToken = token;
|
|
223
|
+
return token;
|
|
224
|
+
} : undefined,
|
|
225
|
+
debug: false,
|
|
226
|
+
websocket: WebSocket,
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// Set up event handlers (store references for cleanup)
|
|
230
|
+
this._clientHandlers = {
|
|
231
|
+
connected: (ctx) => {
|
|
232
|
+
this._connected = true;
|
|
233
|
+
this._onConnect.forEach(cb => cb(ctx));
|
|
234
|
+
},
|
|
235
|
+
disconnected: (ctx) => {
|
|
236
|
+
this._connected = false;
|
|
237
|
+
this._onDisconnect.forEach(cb => cb(ctx));
|
|
238
|
+
},
|
|
239
|
+
error: (ctx) => {
|
|
240
|
+
this._onError.forEach(cb => cb(ctx));
|
|
241
|
+
},
|
|
242
|
+
publication: (ctx) => {
|
|
243
|
+
this._handleServerPublication(ctx);
|
|
244
|
+
},
|
|
245
|
+
join: (ctx) => {
|
|
246
|
+
this._handleServerJoin(ctx);
|
|
247
|
+
},
|
|
248
|
+
leave: (ctx) => {
|
|
249
|
+
this._handleServerLeave(ctx);
|
|
250
|
+
},
|
|
251
|
+
subscribed: (ctx) => {
|
|
252
|
+
this._handleServerSubscribed(ctx);
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
this._client.on('connected', this._clientHandlers.connected);
|
|
257
|
+
this._client.on('disconnected', this._clientHandlers.disconnected);
|
|
258
|
+
this._client.on('error', this._clientHandlers.error);
|
|
259
|
+
this._client.on('publication', this._clientHandlers.publication);
|
|
260
|
+
this._client.on('join', this._clientHandlers.join);
|
|
261
|
+
this._client.on('leave', this._clientHandlers.leave);
|
|
262
|
+
this._client.on('subscribed', this._clientHandlers.subscribed);
|
|
263
|
+
|
|
264
|
+
// Connect and wait for connected event
|
|
265
|
+
return new Promise((resolve, reject) => {
|
|
266
|
+
const timeout = setTimeout(() => {
|
|
267
|
+
reject(new Error('Connection timeout'));
|
|
268
|
+
}, 10000);
|
|
269
|
+
|
|
270
|
+
const onConnected = () => {
|
|
271
|
+
clearTimeout(timeout);
|
|
272
|
+
this._client.off('connected', onConnected);
|
|
273
|
+
this._client.off('error', onError);
|
|
274
|
+
resolve();
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
const onError = (ctx) => {
|
|
278
|
+
clearTimeout(timeout);
|
|
279
|
+
this._client.off('connected', onConnected);
|
|
280
|
+
this._client.off('error', onError);
|
|
281
|
+
reject(new Error(ctx.error?.message || 'Connection failed'));
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
this._client.on('connected', onConnected);
|
|
285
|
+
this._client.on('error', onError);
|
|
286
|
+
this._client.connect();
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Disconnect from the realtime server
|
|
292
|
+
*/
|
|
293
|
+
disconnect() {
|
|
294
|
+
// Unsubscribe all channels first to clean up their timers
|
|
295
|
+
for (const channel of this._channels.values()) {
|
|
296
|
+
try {
|
|
297
|
+
channel.unsubscribe();
|
|
298
|
+
} catch {
|
|
299
|
+
// Ignore errors during cleanup
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
this._channels.clear();
|
|
303
|
+
|
|
304
|
+
if (this._client) {
|
|
305
|
+
// Remove event handlers first to prevent memory leaks
|
|
306
|
+
if (this._clientHandlers) {
|
|
307
|
+
this._client.off('connected', this._clientHandlers.connected);
|
|
308
|
+
this._client.off('disconnected', this._clientHandlers.disconnected);
|
|
309
|
+
this._client.off('error', this._clientHandlers.error);
|
|
310
|
+
this._client.off('publication', this._clientHandlers.publication);
|
|
311
|
+
this._client.off('join', this._clientHandlers.join);
|
|
312
|
+
this._client.off('leave', this._clientHandlers.leave);
|
|
313
|
+
this._client.off('subscribed', this._clientHandlers.subscribed);
|
|
314
|
+
this._clientHandlers = null;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Manually trigger disconnect callbacks
|
|
318
|
+
this._onDisconnect.forEach(cb => cb({ reason: 'manual' }));
|
|
319
|
+
|
|
320
|
+
// Disconnect the client
|
|
321
|
+
this._client.disconnect();
|
|
322
|
+
this._client = null;
|
|
323
|
+
this._connected = false;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Check if connected to the realtime server
|
|
329
|
+
*/
|
|
330
|
+
isConnected() {
|
|
331
|
+
return this._connected;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Create or get a channel
|
|
336
|
+
* @param {string} name - Channel name
|
|
337
|
+
* @param {Object} [options] - Channel options
|
|
338
|
+
* @param {string} [options.type='broadcast'] - Channel type: 'broadcast', 'presence', 'postgres'
|
|
339
|
+
* @param {boolean} [options.autoFetch=true] - Enable auto-fetch for lightweight notifications
|
|
340
|
+
* @param {number} [options.fetchBatchWindowMs] - Batch window for fetch requests
|
|
341
|
+
* @param {number} [options.fetchMaxBatchSize] - Max batch size for fetch requests
|
|
342
|
+
*/
|
|
343
|
+
channel(name, options = {}) {
|
|
344
|
+
const type = options.type || 'broadcast';
|
|
345
|
+
const fullName = this._formatChannelName(name, type);
|
|
346
|
+
|
|
347
|
+
if (this._channels.has(fullName)) {
|
|
348
|
+
return this._channels.get(fullName);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const channel = new RealtimeChannel(this, fullName, type, options);
|
|
352
|
+
this._channels.set(fullName, channel);
|
|
353
|
+
return channel;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Format channel name for subscription
|
|
358
|
+
* Format: type:name
|
|
359
|
+
*
|
|
360
|
+
* The server automatically adds the project ID prefix based on
|
|
361
|
+
* the authenticated connection. Clients never need to know about project IDs.
|
|
362
|
+
*/
|
|
363
|
+
_formatChannelName(name, type) {
|
|
364
|
+
return `${type}:${name}`;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Handle publications from server-side subscriptions
|
|
369
|
+
* The server uses project-prefixed channels: "projectId:type:name"
|
|
370
|
+
* We extract the type:name portion and route to the SDK channel
|
|
371
|
+
*/
|
|
372
|
+
_handleServerPublication(ctx) {
|
|
373
|
+
const serverChannel = ctx.channel;
|
|
374
|
+
|
|
375
|
+
// Server channel format: projectId:type:name
|
|
376
|
+
// We need to extract type:name to match our SDK channel
|
|
377
|
+
const parts = serverChannel.split(':');
|
|
378
|
+
if (parts.length < 3) {
|
|
379
|
+
// Not a valid server channel format, ignore
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Skip projectId, reconstruct type:name
|
|
384
|
+
const sdkChannel = parts.slice(1).join(':');
|
|
385
|
+
|
|
386
|
+
// Find the SDK channel and deliver the message
|
|
387
|
+
const channel = this._channels.get(sdkChannel);
|
|
388
|
+
if (channel) {
|
|
389
|
+
channel._handlePublication(ctx);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Handle join events from server-side subscriptions
|
|
395
|
+
*/
|
|
396
|
+
_handleServerJoin(ctx) {
|
|
397
|
+
const serverChannel = ctx.channel;
|
|
398
|
+
const parts = serverChannel.split(':');
|
|
399
|
+
if (parts.length < 3) return;
|
|
400
|
+
|
|
401
|
+
const sdkChannel = parts.slice(1).join(':');
|
|
402
|
+
const channel = this._channels.get(sdkChannel);
|
|
403
|
+
if (channel && channel._type === 'presence') {
|
|
404
|
+
// Update presence state
|
|
405
|
+
if (ctx.info) {
|
|
406
|
+
channel._presenceState[ctx.info.client] = ctx.info;
|
|
407
|
+
}
|
|
408
|
+
channel._triggerPresenceSync();
|
|
409
|
+
channel._triggerEvent('join', ctx.info);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Handle leave events from server-side subscriptions
|
|
415
|
+
*/
|
|
416
|
+
_handleServerLeave(ctx) {
|
|
417
|
+
const serverChannel = ctx.channel;
|
|
418
|
+
const parts = serverChannel.split(':');
|
|
419
|
+
if (parts.length < 3) return;
|
|
420
|
+
|
|
421
|
+
const sdkChannel = parts.slice(1).join(':');
|
|
422
|
+
const channel = this._channels.get(sdkChannel);
|
|
423
|
+
if (channel && channel._type === 'presence') {
|
|
424
|
+
// Update presence state
|
|
425
|
+
if (ctx.info) {
|
|
426
|
+
delete channel._presenceState[ctx.info.client];
|
|
427
|
+
}
|
|
428
|
+
channel._triggerPresenceSync();
|
|
429
|
+
channel._triggerEvent('leave', ctx.info);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Handle subscribed events - includes initial presence state
|
|
435
|
+
*/
|
|
436
|
+
_handleServerSubscribed(ctx) {
|
|
437
|
+
const serverChannel = ctx.channel;
|
|
438
|
+
const parts = serverChannel.split(':');
|
|
439
|
+
if (parts.length < 3) return;
|
|
440
|
+
|
|
441
|
+
const sdkChannel = parts.slice(1).join(':');
|
|
442
|
+
const channel = this._channels.get(sdkChannel);
|
|
443
|
+
|
|
444
|
+
// For presence channels, populate initial state from subscribe response
|
|
445
|
+
if (channel && channel._type === 'presence' && ctx.data) {
|
|
446
|
+
// data contains initial presence information
|
|
447
|
+
if (ctx.data.presence) {
|
|
448
|
+
channel._presenceState = {};
|
|
449
|
+
for (const [clientId, info] of Object.entries(ctx.data.presence)) {
|
|
450
|
+
channel._presenceState[clientId] = info;
|
|
451
|
+
}
|
|
452
|
+
channel._triggerPresenceSync();
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Get the underlying Centrifuge client
|
|
459
|
+
*/
|
|
460
|
+
getClient() {
|
|
461
|
+
return this._client;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Register callback for connection events
|
|
466
|
+
*/
|
|
467
|
+
onConnect(callback) {
|
|
468
|
+
this._onConnect.push(callback);
|
|
469
|
+
return () => {
|
|
470
|
+
this._onConnect = this._onConnect.filter(cb => cb !== callback);
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Register callback for disconnection events
|
|
476
|
+
*/
|
|
477
|
+
onDisconnect(callback) {
|
|
478
|
+
this._onDisconnect.push(callback);
|
|
479
|
+
return () => {
|
|
480
|
+
this._onDisconnect = this._onDisconnect.filter(cb => cb !== callback);
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Register callback for error events
|
|
486
|
+
*/
|
|
487
|
+
onError(callback) {
|
|
488
|
+
this._onError.push(callback);
|
|
489
|
+
return () => {
|
|
490
|
+
this._onError = this._onError.filter(cb => cb !== callback);
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Remove a specific channel
|
|
496
|
+
* @param {string} name - Channel name
|
|
497
|
+
* @param {string} [type='broadcast'] - Channel type
|
|
498
|
+
*/
|
|
499
|
+
removeChannel(name, type = 'broadcast') {
|
|
500
|
+
const fullName = this._formatChannelName(name, type);
|
|
501
|
+
const channel = this._channels.get(fullName);
|
|
502
|
+
if (channel) {
|
|
503
|
+
channel.unsubscribe();
|
|
504
|
+
this._channels.delete(fullName);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Remove all channels and listeners
|
|
510
|
+
*/
|
|
511
|
+
removeAllChannels() {
|
|
512
|
+
for (const channel of this._channels.values()) {
|
|
513
|
+
channel.unsubscribe();
|
|
514
|
+
}
|
|
515
|
+
this._channels.clear();
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* RealtimeChannel - Represents a subscription to a realtime channel
|
|
521
|
+
*/
|
|
522
|
+
class RealtimeChannel {
|
|
523
|
+
constructor(realtime, name, type, options) {
|
|
524
|
+
this._realtime = realtime;
|
|
525
|
+
this._name = name;
|
|
526
|
+
this._type = type;
|
|
527
|
+
this._options = options;
|
|
528
|
+
this._subscription = null;
|
|
529
|
+
this._callbacks = new Map();
|
|
530
|
+
this._presenceState = {};
|
|
531
|
+
|
|
532
|
+
// Auto-fetch support (Phase 3)
|
|
533
|
+
const parentFetchConfig = realtime.getFetchConfig();
|
|
534
|
+
this._fetchConfig = {
|
|
535
|
+
batchWindowMs: options.fetchBatchWindowMs || parentFetchConfig.batchWindowMs,
|
|
536
|
+
maxBatchSize: options.fetchMaxBatchSize || parentFetchConfig.maxBatchSize,
|
|
537
|
+
enabled: options.autoFetch !== false && parentFetchConfig.enabled,
|
|
538
|
+
};
|
|
539
|
+
this._pendingFetches = new Map(); // table -> { ids: Map<id, {resolve, reject}>, timer }
|
|
540
|
+
|
|
541
|
+
// Event handler references for cleanup
|
|
542
|
+
this._eventHandlers = {};
|
|
543
|
+
this._presenceTimeoutId = null;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Get channel name
|
|
548
|
+
*/
|
|
549
|
+
get name() {
|
|
550
|
+
return this._name;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Subscribe to the channel
|
|
555
|
+
*/
|
|
556
|
+
async subscribe() {
|
|
557
|
+
if (this._subscription) return;
|
|
558
|
+
|
|
559
|
+
const client = this._realtime.getClient();
|
|
560
|
+
if (!client) {
|
|
561
|
+
throw new Error('Not connected to realtime server');
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
this._subscription = client.newSubscription(this._name, {
|
|
565
|
+
// Enable presence for presence channels
|
|
566
|
+
presence: this._type === 'presence',
|
|
567
|
+
joinLeave: this._type === 'presence',
|
|
568
|
+
// Enable recovery for all channels
|
|
569
|
+
recover: true,
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
// Set up message handler (store reference for cleanup)
|
|
573
|
+
this._eventHandlers.publication = (ctx) => {
|
|
574
|
+
const event = ctx.data?.event || 'message';
|
|
575
|
+
const callbacks = this._callbacks.get(event) || [];
|
|
576
|
+
callbacks.forEach(cb => cb(ctx.data, ctx));
|
|
577
|
+
|
|
578
|
+
// Also trigger wildcard listeners
|
|
579
|
+
const wildcardCallbacks = this._callbacks.get('*') || [];
|
|
580
|
+
wildcardCallbacks.forEach(cb => cb(ctx.data, ctx));
|
|
581
|
+
};
|
|
582
|
+
this._subscription.on('publication', this._eventHandlers.publication);
|
|
583
|
+
|
|
584
|
+
// Set up presence handlers for presence channels
|
|
585
|
+
if (this._type === 'presence') {
|
|
586
|
+
this._eventHandlers.presence = (ctx) => {
|
|
587
|
+
this._updatePresenceState(ctx);
|
|
588
|
+
this._triggerPresenceSync();
|
|
589
|
+
};
|
|
590
|
+
this._subscription.on('presence', this._eventHandlers.presence);
|
|
591
|
+
|
|
592
|
+
this._eventHandlers.join = (ctx) => {
|
|
593
|
+
this._presenceState[ctx.info.client] = ctx.info.data;
|
|
594
|
+
this._triggerPresenceSync();
|
|
595
|
+
this._triggerEvent('join', ctx.info);
|
|
596
|
+
};
|
|
597
|
+
this._subscription.on('join', this._eventHandlers.join);
|
|
598
|
+
|
|
599
|
+
this._eventHandlers.leave = (ctx) => {
|
|
600
|
+
delete this._presenceState[ctx.info.client];
|
|
601
|
+
this._triggerPresenceSync();
|
|
602
|
+
this._triggerEvent('leave', ctx.info);
|
|
603
|
+
};
|
|
604
|
+
this._subscription.on('leave', this._eventHandlers.leave);
|
|
605
|
+
|
|
606
|
+
// After subscribing, immediately fetch current presence for late joiners
|
|
607
|
+
// For server-side subscriptions, use client.presence() not subscription.presence()
|
|
608
|
+
this._eventHandlers.subscribed = async () => {
|
|
609
|
+
// Small delay to ensure subscription is fully active
|
|
610
|
+
this._presenceTimeoutId = setTimeout(async () => {
|
|
611
|
+
this._presenceTimeoutId = null;
|
|
612
|
+
try {
|
|
613
|
+
const client = this._realtime.getClient();
|
|
614
|
+
if (client && this._subscription) {
|
|
615
|
+
// Use client-level presence() for server-side subscriptions
|
|
616
|
+
const presence = await client.presence(this._name);
|
|
617
|
+
|
|
618
|
+
// Centrifuge returns presence data in `clients` field
|
|
619
|
+
if (presence && presence.clients) {
|
|
620
|
+
this._presenceState = {};
|
|
621
|
+
for (const [clientId, info] of Object.entries(presence.clients)) {
|
|
622
|
+
this._presenceState[clientId] = info;
|
|
623
|
+
}
|
|
624
|
+
this._triggerPresenceSync();
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
} catch (err) {
|
|
628
|
+
// Ignore errors - presence might not be available yet
|
|
629
|
+
}
|
|
630
|
+
}, 150);
|
|
631
|
+
};
|
|
632
|
+
this._subscription.on('subscribed', this._eventHandlers.subscribed);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
await this._subscription.subscribe();
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Unsubscribe from the channel
|
|
640
|
+
*/
|
|
641
|
+
unsubscribe() {
|
|
642
|
+
// Cancel pending presence fetch timeout
|
|
643
|
+
if (this._presenceTimeoutId) {
|
|
644
|
+
clearTimeout(this._presenceTimeoutId);
|
|
645
|
+
this._presenceTimeoutId = null;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// Clear all pending fetch timers to prevent memory leaks
|
|
649
|
+
if (this._pendingFetches) {
|
|
650
|
+
for (const batch of this._pendingFetches.values()) {
|
|
651
|
+
if (batch.timer) {
|
|
652
|
+
clearTimeout(batch.timer);
|
|
653
|
+
}
|
|
654
|
+
// Reject any pending promises
|
|
655
|
+
for (const { reject } of batch.ids.values()) {
|
|
656
|
+
reject(new Error('Channel unsubscribed'));
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
this._pendingFetches.clear();
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
if (this._subscription) {
|
|
663
|
+
// Remove event listeners before unsubscribing
|
|
664
|
+
for (const [event, handler] of Object.entries(this._eventHandlers)) {
|
|
665
|
+
try {
|
|
666
|
+
this._subscription.off(event, handler);
|
|
667
|
+
} catch {
|
|
668
|
+
// Ignore errors if listener already removed
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
this._eventHandlers = {};
|
|
672
|
+
|
|
673
|
+
this._subscription.unsubscribe();
|
|
674
|
+
// Also remove from Centrifuge client registry to allow re-subscription
|
|
675
|
+
const client = this._realtime.getClient();
|
|
676
|
+
if (client) {
|
|
677
|
+
try {
|
|
678
|
+
client.removeSubscription(this._subscription);
|
|
679
|
+
} catch {
|
|
680
|
+
// Ignore errors if subscription already removed
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
this._subscription = null;
|
|
684
|
+
}
|
|
685
|
+
this._callbacks.clear();
|
|
686
|
+
this._presenceState = {};
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Handle publication from server-side subscription
|
|
691
|
+
* Called by VolcanoRealtime when a message arrives on the internal channel
|
|
692
|
+
*/
|
|
693
|
+
_handlePublication(ctx) {
|
|
694
|
+
const data = ctx.data;
|
|
695
|
+
|
|
696
|
+
// Check if this is a lightweight notification (Phase 3)
|
|
697
|
+
if (data?.mode === 'lightweight') {
|
|
698
|
+
this._handleLightweightNotification(data, ctx);
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// Full payload - deliver immediately
|
|
703
|
+
this._deliverPayload(data, ctx);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* Handle a lightweight notification by auto-fetching the record data
|
|
708
|
+
* @param {Object} data - Lightweight notification data
|
|
709
|
+
* @param {Object} ctx - Publication context
|
|
710
|
+
*/
|
|
711
|
+
async _handleLightweightNotification(data, ctx) {
|
|
712
|
+
const volcanoClient = this._realtime.getVolcanoClient();
|
|
713
|
+
|
|
714
|
+
// DELETE notifications may include old_record, deliver immediately
|
|
715
|
+
if (data.type === 'DELETE') {
|
|
716
|
+
// Convert lightweight DELETE to full format for backward compatibility
|
|
717
|
+
const oldRecord = data.old_record !== undefined
|
|
718
|
+
? data.old_record
|
|
719
|
+
: (data.id !== undefined ? { id: data.id } : undefined);
|
|
720
|
+
const fullPayload = {
|
|
721
|
+
type: data.type,
|
|
722
|
+
schema: data.schema,
|
|
723
|
+
table: data.table,
|
|
724
|
+
old_record: oldRecord,
|
|
725
|
+
id: data.id,
|
|
726
|
+
timestamp: data.timestamp,
|
|
727
|
+
};
|
|
728
|
+
this._deliverPayload(fullPayload, ctx);
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// If no volcanoClient or auto-fetch disabled, deliver lightweight as-is
|
|
733
|
+
if (!volcanoClient || !this._fetchConfig.enabled) {
|
|
734
|
+
this._deliverPayload(data, ctx);
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// Auto-fetch the record for INSERT/UPDATE
|
|
739
|
+
try {
|
|
740
|
+
const record = await this._fetchRow(data.schema, data.table, data.id);
|
|
741
|
+
|
|
742
|
+
// Convert to full payload format for backward compatibility
|
|
743
|
+
const fullPayload = {
|
|
744
|
+
type: data.type,
|
|
745
|
+
schema: data.schema,
|
|
746
|
+
table: data.table,
|
|
747
|
+
record: record,
|
|
748
|
+
timestamp: data.timestamp,
|
|
749
|
+
};
|
|
750
|
+
|
|
751
|
+
this._deliverPayload(fullPayload, ctx);
|
|
752
|
+
} catch (err) {
|
|
753
|
+
// On fetch error, still deliver the lightweight notification
|
|
754
|
+
// so the client knows something changed, even if we couldn't get the data
|
|
755
|
+
console.warn(`[Realtime] Failed to fetch record for ${data.schema}.${data.table}:${data.id}:`, err.message);
|
|
756
|
+
this._deliverPayload(data, ctx);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* Fetch a row from the database, batching requests for efficiency
|
|
762
|
+
* @param {string} schema - Schema name
|
|
763
|
+
* @param {string} table - Table name
|
|
764
|
+
* @param {*} id - Primary key value
|
|
765
|
+
* @returns {Promise<Object>} The fetched record
|
|
766
|
+
*/
|
|
767
|
+
_fetchRow(schema, table, id) {
|
|
768
|
+
const tableKey = `${schema}.${table}`;
|
|
769
|
+
|
|
770
|
+
return new Promise((resolve, reject) => {
|
|
771
|
+
// Get or create pending batch for this table
|
|
772
|
+
if (!this._pendingFetches.has(tableKey)) {
|
|
773
|
+
this._pendingFetches.set(tableKey, {
|
|
774
|
+
ids: new Map(),
|
|
775
|
+
timer: null,
|
|
776
|
+
schema: schema,
|
|
777
|
+
table: table,
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
const batch = this._pendingFetches.get(tableKey);
|
|
782
|
+
|
|
783
|
+
// Add this ID to the batch
|
|
784
|
+
batch.ids.set(String(id), { resolve, reject });
|
|
785
|
+
|
|
786
|
+
// Check if we should flush due to size
|
|
787
|
+
if (batch.ids.size >= this._fetchConfig.maxBatchSize) {
|
|
788
|
+
this._flushFetch(schema, table);
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// Set timer for batch window if not already set
|
|
793
|
+
if (!batch.timer) {
|
|
794
|
+
batch.timer = setTimeout(() => {
|
|
795
|
+
this._flushFetch(schema, table);
|
|
796
|
+
}, this._fetchConfig.batchWindowMs);
|
|
797
|
+
}
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* Flush pending fetch requests for a table
|
|
803
|
+
* @param {string} schema - Schema name
|
|
804
|
+
* @param {string} table - Table name
|
|
805
|
+
*/
|
|
806
|
+
async _flushFetch(schema, table) {
|
|
807
|
+
const tableKey = `${schema}.${table}`;
|
|
808
|
+
const batch = this._pendingFetches.get(tableKey);
|
|
809
|
+
|
|
810
|
+
if (!batch || batch.ids.size === 0) {
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// Clear timer and remove from pending
|
|
815
|
+
if (batch.timer) {
|
|
816
|
+
clearTimeout(batch.timer);
|
|
817
|
+
}
|
|
818
|
+
this._pendingFetches.delete(tableKey);
|
|
819
|
+
|
|
820
|
+
// Get all IDs to fetch
|
|
821
|
+
const idsToFetch = Array.from(batch.ids.keys());
|
|
822
|
+
const callbacks = new Map(batch.ids);
|
|
823
|
+
|
|
824
|
+
try {
|
|
825
|
+
const volcanoClient = this._realtime.getVolcanoClient();
|
|
826
|
+
|
|
827
|
+
if (!volcanoClient?.from || typeof volcanoClient.from !== 'function') {
|
|
828
|
+
throw new Error('volcanoClient.from not available');
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
const databaseName = this._realtime.getDatabaseName?.() || volcanoClient._currentDatabaseName || null;
|
|
832
|
+
let dbClient = volcanoClient;
|
|
833
|
+
if (databaseName) {
|
|
834
|
+
if (typeof volcanoClient.database !== 'function') {
|
|
835
|
+
throw new Error('volcanoClient.database not available');
|
|
836
|
+
}
|
|
837
|
+
dbClient = volcanoClient.database(databaseName);
|
|
838
|
+
} else if (typeof volcanoClient.database === 'function') {
|
|
839
|
+
throw new Error('Database name not set. Call volcanoClient.database(name) or pass databaseName to VolcanoRealtime.');
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
const tableName = schema && schema !== 'public' ? `${schema}.${table}` : table;
|
|
843
|
+
|
|
844
|
+
// Fetch all records in a single query using IN clause
|
|
845
|
+
// Assumes primary key column is 'id' - this is a common convention
|
|
846
|
+
const { data, error } = await dbClient
|
|
847
|
+
.from(tableName)
|
|
848
|
+
.select('*')
|
|
849
|
+
.in('id', idsToFetch);
|
|
850
|
+
|
|
851
|
+
if (error) {
|
|
852
|
+
// Reject all pending callbacks
|
|
853
|
+
for (const cb of callbacks.values()) {
|
|
854
|
+
cb.reject(new Error(error.message || 'Database fetch failed'));
|
|
855
|
+
}
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// Build a map of id -> record
|
|
860
|
+
const recordMap = new Map();
|
|
861
|
+
for (const record of (data || [])) {
|
|
862
|
+
recordMap.set(String(record.id), record);
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// Resolve callbacks
|
|
866
|
+
for (const [id, cb] of callbacks) {
|
|
867
|
+
const record = recordMap.get(id);
|
|
868
|
+
if (record) {
|
|
869
|
+
cb.resolve(record);
|
|
870
|
+
} else {
|
|
871
|
+
// Record not found - could be RLS denial or row deleted
|
|
872
|
+
cb.reject(new Error(`Record not found or access denied: ${table}:${id}`));
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
} catch (err) {
|
|
876
|
+
// Reject all pending callbacks on error
|
|
877
|
+
for (const cb of callbacks.values()) {
|
|
878
|
+
cb.reject(err);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* Deliver a payload to registered callbacks
|
|
885
|
+
* @param {Object} data - Payload data
|
|
886
|
+
* @param {Object} ctx - Publication context
|
|
887
|
+
*/
|
|
888
|
+
_deliverPayload(data, ctx) {
|
|
889
|
+
const event = data?.event || data?.type || 'message';
|
|
890
|
+
const callbacks = this._callbacks.get(event) || [];
|
|
891
|
+
callbacks.forEach(cb => cb(data, ctx));
|
|
892
|
+
|
|
893
|
+
// Also trigger wildcard listeners
|
|
894
|
+
const wildcardCallbacks = this._callbacks.get('*') || [];
|
|
895
|
+
wildcardCallbacks.forEach(cb => cb(data, ctx));
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* Listen for events on the channel
|
|
900
|
+
* @param {string} event - Event name or '*' for all events
|
|
901
|
+
* @param {Function} callback - Callback function
|
|
902
|
+
*/
|
|
903
|
+
on(event, callback) {
|
|
904
|
+
if (!this._callbacks.has(event)) {
|
|
905
|
+
this._callbacks.set(event, []);
|
|
906
|
+
}
|
|
907
|
+
this._callbacks.get(event).push(callback);
|
|
908
|
+
|
|
909
|
+
// Return unsubscribe function
|
|
910
|
+
return () => {
|
|
911
|
+
const callbacks = this._callbacks.get(event) || [];
|
|
912
|
+
this._callbacks.set(event, callbacks.filter(cb => cb !== callback));
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* Send a message to the channel (broadcast only)
|
|
918
|
+
* @param {Object} data - Message data
|
|
919
|
+
*/
|
|
920
|
+
async send(data) {
|
|
921
|
+
if (this._type !== 'broadcast') {
|
|
922
|
+
throw new Error('send() is only available for broadcast channels');
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
if (!this._subscription) {
|
|
926
|
+
throw new Error('Channel not subscribed');
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
await this._subscription.publish(data);
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* Listen for database changes (postgres channels only)
|
|
934
|
+
* @param {string} event - Event type: 'INSERT', 'UPDATE', 'DELETE', or '*'
|
|
935
|
+
* @param {string} schema - Schema name
|
|
936
|
+
* @param {string} table - Table name
|
|
937
|
+
* @param {Function} callback - Callback function
|
|
938
|
+
*/
|
|
939
|
+
onPostgresChanges(event, schema, table, callback) {
|
|
940
|
+
if (this._type !== 'postgres') {
|
|
941
|
+
throw new Error('onPostgresChanges() is only available for postgres channels');
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// Filter callback to only match the requested event type
|
|
945
|
+
return this.on('*', (data, ctx) => {
|
|
946
|
+
if (data.schema !== schema || data.table !== table) return;
|
|
947
|
+
if (event !== '*' && data.type !== event) return;
|
|
948
|
+
callback(data, ctx);
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
/**
|
|
953
|
+
* Listen for presence state sync
|
|
954
|
+
* @param {Function} callback - Callback with presence state
|
|
955
|
+
*/
|
|
956
|
+
onPresenceSync(callback) {
|
|
957
|
+
if (this._type !== 'presence') {
|
|
958
|
+
throw new Error('onPresenceSync() is only available for presence channels');
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
return this.on('presence_sync', callback);
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/**
|
|
965
|
+
* Track this client's presence
|
|
966
|
+
* @param {Object} state - Presence state data (optional, for client-side state tracking)
|
|
967
|
+
*
|
|
968
|
+
* Note: Presence data is automatically sent from the server based on your
|
|
969
|
+
* user metadata (from sign-up). Custom presence data should be included
|
|
970
|
+
* when creating the anonymous user.
|
|
971
|
+
*/
|
|
972
|
+
async track(state = {}) {
|
|
973
|
+
if (this._type !== 'presence') {
|
|
974
|
+
throw new Error('track() is only available for presence channels');
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// Store local presence state for client-side access
|
|
978
|
+
this._myPresenceState = state;
|
|
979
|
+
|
|
980
|
+
// Presence is automatically managed by Centrifuge based on subscription
|
|
981
|
+
// The connection data (from user metadata) is what other clients see
|
|
982
|
+
// Note: Custom state is stored locally for client-side access
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/**
|
|
986
|
+
* Get current presence state
|
|
987
|
+
*/
|
|
988
|
+
getPresenceState() {
|
|
989
|
+
return { ...this._presenceState };
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
_updatePresenceState(ctx) {
|
|
993
|
+
this._presenceState = {};
|
|
994
|
+
if (ctx.clients) {
|
|
995
|
+
for (const [clientId, info] of Object.entries(ctx.clients)) {
|
|
996
|
+
this._presenceState[clientId] = info.data;
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
_triggerPresenceSync() {
|
|
1002
|
+
this._triggerEvent('presence_sync', this._presenceState);
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
_triggerEvent(event, data) {
|
|
1006
|
+
const callbacks = this._callbacks.get(event) || [];
|
|
1007
|
+
callbacks.forEach(cb => cb(data));
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// Export for CommonJS
|
|
1012
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
1013
|
+
module.exports = { VolcanoRealtime, RealtimeChannel };
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
export { RealtimeChannel, VolcanoRealtime };
|