svelte-adapter-uws-extensions 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,631 @@
1
+ /**
2
+ * Redis-backed presence tracker for svelte-adapter-uws.
3
+ *
4
+ * Same API as the core createPresence plugin, but stores presence state
5
+ * in Redis hashes so it is shared across instances. Uses Redis pub/sub
6
+ * for cross-instance join/leave notifications.
7
+ *
8
+ * Storage layout per topic:
9
+ * - Key `{prefix}presence:{topic}` - hash
10
+ * field = `{instanceId}|{userKey}`, value = JSON `{ data, ts }`
11
+ * Each instance owns its own fields so cross-instance leave is safe.
12
+ * - Channel `{prefix}presence:events:{topic}` - pub/sub for join/leave events
13
+ *
14
+ * Each instance also maintains a local connection map so it knows when to
15
+ * publish leave events (last connection for a user on this instance).
16
+ *
17
+ * @module svelte-adapter-uws-extensions/redis/presence
18
+ */
19
+
20
+ import { randomBytes } from 'node:crypto';
21
+
22
+ /**
23
+ * Lua script for atomic join: set this instance's field and check if
24
+ * the user already exists on another instance (non-stale).
25
+ *
26
+ * KEYS[1] = hash key
27
+ * ARGV[1] = field to set (instanceId|userKey)
28
+ * ARGV[2] = field value (JSON with data and ts)
29
+ * ARGV[3] = "|userKey" suffix to match
30
+ * ARGV[4] = now (ms)
31
+ * ARGV[5] = presenceTtlMs
32
+ *
33
+ * Returns 1 if this is the first live instance for the user (broadcast join),
34
+ * 0 if another live instance already has this user.
35
+ */
36
+ const JOIN_SCRIPT = `
37
+ local key = KEYS[1]
38
+ local field = ARGV[1]
39
+ local value = ARGV[2]
40
+ local suffix = ARGV[3]
41
+ local now = tonumber(ARGV[4])
42
+ local ttlMs = tonumber(ARGV[5])
43
+
44
+ redis.call('hset', key, field, value)
45
+
46
+ local all = redis.call('hgetall', key)
47
+ for i = 1, #all, 2 do
48
+ local f = all[i]
49
+ if f ~= field and #f >= #suffix and string.sub(f, -#suffix) == suffix then
50
+ local ok, parsed = pcall(cjson.decode, all[i+1])
51
+ if ok and parsed.ts and (now - parsed.ts) <= ttlMs then
52
+ return 0
53
+ end
54
+ end
55
+ end
56
+ return 1
57
+ `;
58
+
59
+ /**
60
+ * Lua script for atomic leave + check if user is still present on
61
+ * another instance (non-stale). Removes this instance's field and scans
62
+ * remaining fields for the same userKey, ignoring stale entries.
63
+ *
64
+ * KEYS[1] = hash key
65
+ * ARGV[1] = field to remove (instanceId|userKey)
66
+ * ARGV[2] = "|userKey" suffix to match
67
+ * ARGV[3] = now (ms)
68
+ * ARGV[4] = presenceTtlMs
69
+ *
70
+ * Returns 1 if user is completely gone (broadcast leave), 0 if still present.
71
+ */
72
+ const LEAVE_SCRIPT = `
73
+ local key = KEYS[1]
74
+ local field = ARGV[1]
75
+ local suffix = ARGV[2]
76
+ local now = tonumber(ARGV[3])
77
+ local ttlMs = tonumber(ARGV[4])
78
+
79
+ redis.call('hdel', key, field)
80
+
81
+ local all = redis.call('hgetall', key)
82
+ for i = 1, #all, 2 do
83
+ local f = all[i]
84
+ if #f >= #suffix and string.sub(f, -#suffix) == suffix then
85
+ local ok, parsed = pcall(cjson.decode, all[i+1])
86
+ if ok and parsed.ts and (now - parsed.ts) <= ttlMs then
87
+ return 0
88
+ end
89
+ end
90
+ end
91
+ return 1
92
+ `;
93
+
94
+ /**
95
+ * @typedef {Object} RedisPresenceOptions
96
+ * @property {string} [key='id'] - Field in selected data for user dedup
97
+ * @property {(userData: any) => Record<string, any>} [select] - Extract public fields from userData
98
+ * @property {number} [heartbeat=30000] - Heartbeat interval in ms (how often to refresh expiry)
99
+ * @property {number} [ttl=90] - TTL in seconds for presence entries (should be > heartbeat * 3)
100
+ */
101
+
102
+ /**
103
+ * @typedef {Object} RedisPresenceTracker
104
+ * @property {(ws: any, topic: string, platform: import('svelte-adapter-uws').Platform) => Promise<void>} join
105
+ * @property {(ws: any, platform: import('svelte-adapter-uws').Platform, topic?: string) => Promise<void>} leave
106
+ * @property {(ws: any, topic: string, platform: import('svelte-adapter-uws').Platform) => Promise<void>} sync
107
+ * @property {(topic: string) => Promise<Array<Record<string, any>>>} list
108
+ * @property {(topic: string) => Promise<number>} count
109
+ * @property {() => Promise<void>} clear
110
+ * @property {() => void} destroy - Stop heartbeat and subscriber
111
+ * @property {{ subscribe: (ws: any, topic: string, ctx: { platform: import('svelte-adapter-uws').Platform }) => Promise<void>, close: (ws: any, ctx: { platform: import('svelte-adapter-uws').Platform }) => Promise<void> }} hooks
112
+ */
113
+
114
+ /**
115
+ * Create a Redis-backed presence tracker.
116
+ *
117
+ * @param {import('./index.js').RedisClient} client
118
+ * @param {RedisPresenceOptions} [options]
119
+ * @returns {RedisPresenceTracker}
120
+ */
121
+ export function createPresence(client, options = {}) {
122
+ const keyField = options.key || 'id';
123
+ const select = options.select || ((userData) => userData);
124
+ const heartbeatInterval = options.heartbeat || 30000;
125
+ const presenceTtl = options.ttl || 90;
126
+ const presenceTtlMs = presenceTtl * 1000;
127
+
128
+ const instanceId = randomBytes(8).toString('hex');
129
+ const redis = client.redis;
130
+
131
+ let connCounter = 0;
132
+
133
+ /**
134
+ * Per-connection state: which topics they've joined and their key on each.
135
+ * @type {Map<any, Map<string, { key: string, data: Record<string, any> }>>}
136
+ */
137
+ const wsTopics = new Map();
138
+
139
+ /**
140
+ * Local per-topic reference count per user key.
141
+ * Used to know when the last local connection for a user leaves.
142
+ * @type {Map<string, Map<string, number>>}
143
+ */
144
+ const localCounts = new Map();
145
+
146
+ /**
147
+ * Local per-topic data cache for heartbeat updates.
148
+ * @type {Map<string, Map<string, Record<string, any>>>}
149
+ */
150
+ const localData = new Map();
151
+
152
+ /**
153
+ * Track sync-only ws so leave() can clean up their Redis channel subscriptions.
154
+ * @type {Map<any, Set<string>>}
155
+ */
156
+ const syncObservers = new Map();
157
+
158
+ /**
159
+ * Per-topic refcount for sync-only observers.
160
+ * Used alongside localCounts to decide when to unsubscribe from Redis.
161
+ * @type {Map<string, number>}
162
+ */
163
+ const syncCounts = new Map();
164
+
165
+ function hashKey(topic) {
166
+ return client.key('presence:' + topic);
167
+ }
168
+
169
+ function eventChannel(topic) {
170
+ return client.key('presence:events:' + topic);
171
+ }
172
+
173
+ function compoundField(userKey) {
174
+ return instanceId + '|' + userKey;
175
+ }
176
+
177
+ function resolveKey(data) {
178
+ if (data && keyField in data && data[keyField] != null) {
179
+ return String(data[keyField]);
180
+ }
181
+ return '__conn:' + (++connCounter);
182
+ }
183
+
184
+ /**
185
+ * Parse hash entries, deduplicate by userKey, filter stale entries.
186
+ * Returns an array of { key, data } objects.
187
+ */
188
+ function parseEntries(all) {
189
+ const now = Date.now();
190
+ const seen = new Map(); // userKey -> { data, ts }
191
+ for (const [field, v] of Object.entries(all)) {
192
+ try {
193
+ const parsed = JSON.parse(v);
194
+ // Filter stale entries
195
+ if (parsed.ts && (now - parsed.ts) > presenceTtlMs) continue;
196
+ // Extract userKey from compound field
197
+ const sep = field.indexOf('|');
198
+ const userKey = sep !== -1 ? field.slice(sep + 1) : field;
199
+ // Keep the most recent entry per userKey
200
+ const existing = seen.get(userKey);
201
+ if (!existing || (parsed.ts || 0) > (existing.ts || 0)) {
202
+ seen.set(userKey, parsed);
203
+ }
204
+ } catch {
205
+ // Corrupted entry, skip
206
+ }
207
+ }
208
+ return seen;
209
+ }
210
+
211
+ // Heartbeat: refresh timestamps on local entries, TTL on hash keys,
212
+ // and clean up stale fields from crashed instances
213
+ /** @type {Set<string>} */
214
+ const activeTopics = new Set();
215
+ const heartbeatTimer = setInterval(() => {
216
+ const now = Date.now();
217
+ for (const topic of activeTopics) {
218
+ const data = localData.get(topic);
219
+ if (data) {
220
+ for (const [userKey, userData] of data) {
221
+ const field = compoundField(userKey);
222
+ redis.hset(hashKey(topic), field, JSON.stringify({ data: userData, ts: now })).catch(() => {});
223
+ }
224
+ }
225
+ redis.expire(hashKey(topic), presenceTtl).catch(() => {});
226
+ // Clean up stale fields from dead instances
227
+ redis.hgetall(hashKey(topic)).then((all) => {
228
+ if (!all) return;
229
+ for (const [field, v] of Object.entries(all)) {
230
+ try {
231
+ const parsed = JSON.parse(v);
232
+ if (parsed.ts && (now - parsed.ts) > presenceTtlMs) {
233
+ redis.hdel(hashKey(topic), field).catch(() => {});
234
+ }
235
+ } catch { /* corrupted, remove */
236
+ redis.hdel(hashKey(topic), field).catch(() => {});
237
+ }
238
+ }
239
+ }).catch(() => {});
240
+ }
241
+ }, heartbeatInterval);
242
+ if (heartbeatTimer.unref) heartbeatTimer.unref();
243
+
244
+ // Redis subscriber for cross-instance join/leave events
245
+ /** @type {import('ioredis').Redis | null} */
246
+ let subscriber = null;
247
+ /** @type {import('svelte-adapter-uws').Platform | null} */
248
+ let activePlatform = null;
249
+ /** @type {Set<string>} - channels we have subscribed to */
250
+ const subscribedChannels = new Set();
251
+
252
+ async function ensureSubscriber(platform) {
253
+ activePlatform = platform;
254
+ if (!subscriber) {
255
+ subscriber = client.duplicate({ enableReadyCheck: false });
256
+ subscriber.on('message', (ch, message) => {
257
+ try {
258
+ const parsed = JSON.parse(message);
259
+ if (parsed.instanceId === instanceId) return;
260
+ // Forward to local platform only -- relay: false prevents
261
+ // duplicate delivery since each worker has its own subscriber.
262
+ if (activePlatform) {
263
+ activePlatform.publish('__presence:' + parsed.topic, parsed.event, parsed.payload, { relay: false });
264
+ }
265
+ } catch {
266
+ // Malformed, skip
267
+ }
268
+ });
269
+ }
270
+ }
271
+
272
+ async function subscribeToTopic(topic, platform) {
273
+ await ensureSubscriber(platform);
274
+ const ch = eventChannel(topic);
275
+ if (!subscribedChannels.has(ch)) {
276
+ subscribedChannels.add(ch);
277
+ await subscriber.subscribe(ch);
278
+ }
279
+ }
280
+
281
+ async function unsubscribeFromTopic(topic) {
282
+ if (!subscriber) return;
283
+ const ch = eventChannel(topic);
284
+ if (subscribedChannels.has(ch)) {
285
+ subscribedChannels.delete(ch);
286
+ await subscriber.unsubscribe(ch).catch(() => {});
287
+ }
288
+ }
289
+
290
+ async function publishEvent(topic, event, payload) {
291
+ const ch = eventChannel(topic);
292
+ const msg = JSON.stringify({ instanceId, topic, event, payload });
293
+ await redis.publish(ch, msg).catch(() => {});
294
+ }
295
+
296
+ /** @type {RedisPresenceTracker} */
297
+ const tracker = {
298
+ async join(ws, topic, platform) {
299
+ if (topic.startsWith('__')) return;
300
+
301
+ let connTopics = wsTopics.get(ws);
302
+ if (connTopics && connTopics.has(topic)) return;
303
+
304
+ const data = select(ws.getUserData());
305
+ const key = resolveKey(data);
306
+
307
+ // Track per-connection
308
+ if (!connTopics) {
309
+ connTopics = new Map();
310
+ wsTopics.set(ws, connTopics);
311
+ }
312
+ connTopics.set(topic, { key, data });
313
+
314
+ // Track local reference count
315
+ let counts = localCounts.get(topic);
316
+ if (!counts) {
317
+ counts = new Map();
318
+ localCounts.set(topic, counts);
319
+ }
320
+ const prevCount = counts.get(key) || 0;
321
+ counts.set(key, prevCount + 1);
322
+
323
+ // Track local data for heartbeat
324
+ let topicData = localData.get(topic);
325
+ if (!topicData) {
326
+ topicData = new Map();
327
+ localData.set(topic, topicData);
328
+ }
329
+ topicData.set(key, data);
330
+
331
+ activeTopics.add(topic);
332
+
333
+ // Subscribe to cross-instance events for this topic
334
+ await subscribeToTopic(topic, platform);
335
+
336
+ if (prevCount === 0) {
337
+ // New user on this instance -- check if globally new via atomic Lua
338
+ const now = Date.now();
339
+ const field = compoundField(key);
340
+ const value = JSON.stringify({ data, ts: now });
341
+ const suffix = '|' + key;
342
+ const isFirstGlobally = await redis.eval(
343
+ JOIN_SCRIPT, 1, hashKey(topic),
344
+ field, value, suffix, now, presenceTtlMs
345
+ );
346
+ await redis.expire(hashKey(topic), presenceTtl);
347
+
348
+ if (isFirstGlobally === 1) {
349
+ // No other live instance has this user -- broadcast join
350
+ const payload = { key, data };
351
+ platform.publish('__presence:' + topic, 'join', payload);
352
+ await publishEvent(topic, 'join', payload);
353
+ }
354
+ }
355
+
356
+ // Subscribe ws to presence channel (may have closed during async gap)
357
+ try {
358
+ ws.subscribe('__presence:' + topic);
359
+ } catch {
360
+ return;
361
+ }
362
+
363
+ // Send current list to this connection
364
+ const all = await redis.hgetall(hashKey(topic));
365
+ const entries = parseEntries(all);
366
+ const list = [];
367
+ for (const [userKey, entry] of entries) {
368
+ list.push({ key: userKey, data: entry.data });
369
+ }
370
+ try {
371
+ platform.send(ws, '__presence:' + topic, 'list', list);
372
+ } catch {
373
+ // WebSocket closed before send
374
+ }
375
+ },
376
+
377
+ async leave(ws, platform, topic) {
378
+ if (topic !== undefined) {
379
+ // --- Per-topic leave ---
380
+ const connTopics = wsTopics.get(ws);
381
+ if (connTopics && connTopics.has(topic)) {
382
+ const { key, data } = connTopics.get(topic);
383
+ connTopics.delete(topic);
384
+ if (connTopics.size === 0) wsTopics.delete(ws);
385
+
386
+ try { ws.unsubscribe('__presence:' + topic); } catch { /* closed */ }
387
+
388
+ const counts = localCounts.get(topic);
389
+ if (counts) {
390
+ const current = counts.get(key) || 0;
391
+ if (current <= 1) {
392
+ counts.delete(key);
393
+
394
+ const topicData = localData.get(topic);
395
+ if (topicData) {
396
+ topicData.delete(key);
397
+ if (topicData.size === 0) localData.delete(topic);
398
+ }
399
+
400
+ if (counts.size === 0) {
401
+ localCounts.delete(topic);
402
+ activeTopics.delete(topic);
403
+ if (!syncCounts.has(topic)) {
404
+ await unsubscribeFromTopic(topic);
405
+ }
406
+ }
407
+
408
+ const field = compoundField(key);
409
+ const suffix = '|' + key;
410
+ const now = Date.now();
411
+ const userGone = await redis.eval(
412
+ LEAVE_SCRIPT, 1, hashKey(topic), field, suffix, now, presenceTtlMs
413
+ );
414
+
415
+ if (userGone === 1) {
416
+ const payload = { key, data };
417
+ platform.publish('__presence:' + topic, 'leave', payload);
418
+ await publishEvent(topic, 'leave', payload);
419
+ }
420
+ } else {
421
+ counts.set(key, current - 1);
422
+ }
423
+ }
424
+ }
425
+
426
+ // Handle sync-only observer for this topic
427
+ const syncTopics = syncObservers.get(ws);
428
+ if (syncTopics && syncTopics.has(topic)) {
429
+ syncTopics.delete(topic);
430
+ if (syncTopics.size === 0) syncObservers.delete(ws);
431
+
432
+ try { ws.unsubscribe('__presence:' + topic); } catch { /* closed */ }
433
+
434
+ const count = (syncCounts.get(topic) || 1) - 1;
435
+ if (count <= 0) {
436
+ syncCounts.delete(topic);
437
+ if (!localCounts.has(topic)) {
438
+ await unsubscribeFromTopic(topic);
439
+ }
440
+ } else {
441
+ syncCounts.set(topic, count);
442
+ }
443
+ }
444
+
445
+ return;
446
+ }
447
+
448
+ // --- Leave all topics ---
449
+ const connTopics = wsTopics.get(ws);
450
+ if (connTopics) {
451
+ for (const [topic, { key, data }] of connTopics) {
452
+ const counts = localCounts.get(topic);
453
+ if (!counts) continue;
454
+
455
+ const current = counts.get(key) || 0;
456
+ if (current <= 1) {
457
+ counts.delete(key);
458
+
459
+ // Clean up local data for this user on this topic
460
+ const topicData = localData.get(topic);
461
+ if (topicData) {
462
+ topicData.delete(key);
463
+ if (topicData.size === 0) localData.delete(topic);
464
+ }
465
+
466
+ if (counts.size === 0) {
467
+ localCounts.delete(topic);
468
+ activeTopics.delete(topic);
469
+ // Unsubscribe from Redis channel if no sync observers remain
470
+ if (!syncCounts.has(topic)) {
471
+ await unsubscribeFromTopic(topic);
472
+ }
473
+ }
474
+
475
+ // Atomically remove this instance's field and check if user
476
+ // is still present on another instance (ignoring stale entries)
477
+ const field = compoundField(key);
478
+ const suffix = '|' + key;
479
+ const now = Date.now();
480
+ const userGone = await redis.eval(
481
+ LEAVE_SCRIPT, 1, hashKey(topic), field, suffix, now, presenceTtlMs
482
+ );
483
+
484
+ if (userGone === 1) {
485
+ // No other instance has this user -- broadcast leave
486
+ const payload = { key, data };
487
+ platform.publish('__presence:' + topic, 'leave', payload);
488
+ await publishEvent(topic, 'leave', payload);
489
+ }
490
+ } else {
491
+ counts.set(key, current - 1);
492
+ }
493
+ }
494
+ wsTopics.delete(ws);
495
+ }
496
+
497
+ // Handle sync-only observers
498
+ const syncTopics = syncObservers.get(ws);
499
+ if (syncTopics) {
500
+ for (const topic of syncTopics) {
501
+ const count = (syncCounts.get(topic) || 1) - 1;
502
+ if (count <= 0) {
503
+ syncCounts.delete(topic);
504
+ // Unsubscribe from Redis channel if no joined users remain
505
+ if (!localCounts.has(topic)) {
506
+ await unsubscribeFromTopic(topic);
507
+ }
508
+ } else {
509
+ syncCounts.set(topic, count);
510
+ }
511
+ }
512
+ syncObservers.delete(ws);
513
+ }
514
+ },
515
+
516
+ async sync(ws, topic, platform) {
517
+ const all = await redis.hgetall(hashKey(topic));
518
+ const presenceTopic = '__presence:' + topic;
519
+ const entries = parseEntries(all);
520
+ const list = [];
521
+ for (const [userKey, entry] of entries) {
522
+ list.push({ key: userKey, data: entry.data });
523
+ }
524
+ // Subscribe to Redis channel so remote join/leave events are received
525
+ await subscribeToTopic(topic, platform);
526
+
527
+ // Track this sync-only observer so leave() can clean up
528
+ if (!wsTopics.has(ws)) {
529
+ let topics = syncObservers.get(ws);
530
+ if (!topics) {
531
+ topics = new Set();
532
+ syncObservers.set(ws, topics);
533
+ }
534
+ if (!topics.has(topic)) {
535
+ topics.add(topic);
536
+ syncCounts.set(topic, (syncCounts.get(topic) || 0) + 1);
537
+ }
538
+ }
539
+
540
+ try {
541
+ ws.subscribe(presenceTopic);
542
+ platform.send(ws, presenceTopic, 'list', list);
543
+ } catch {
544
+ // WebSocket closed during async gap
545
+ }
546
+ },
547
+
548
+ async list(topic) {
549
+ const all = await redis.hgetall(hashKey(topic));
550
+ const entries = parseEntries(all);
551
+ const result = [];
552
+ for (const entry of entries.values()) {
553
+ result.push(entry.data);
554
+ }
555
+ return result;
556
+ },
557
+
558
+ async count(topic) {
559
+ const all = await redis.hgetall(hashKey(topic));
560
+ const entries = parseEntries(all);
561
+ return entries.size;
562
+ },
563
+
564
+ async clear() {
565
+ // Unsubscribe all local ws from their presence topics
566
+ for (const [ws, connTopics] of wsTopics) {
567
+ for (const topic of connTopics.keys()) {
568
+ try { ws.unsubscribe('__presence:' + topic); } catch { /* closed */ }
569
+ }
570
+ }
571
+ for (const [ws, topics] of syncObservers) {
572
+ for (const topic of topics) {
573
+ try { ws.unsubscribe('__presence:' + topic); } catch { /* closed */ }
574
+ }
575
+ }
576
+
577
+ // Unsubscribe the Redis subscriber from all event channels
578
+ if (subscriber) {
579
+ for (const ch of subscribedChannels) {
580
+ await subscriber.unsubscribe(ch).catch(() => {});
581
+ }
582
+ subscribedChannels.clear();
583
+ }
584
+
585
+ // Clear all presence keys in Redis
586
+ const pattern = client.key('presence:*');
587
+ let cursor = '0';
588
+ do {
589
+ const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
590
+ cursor = nextCursor;
591
+ if (keys.length > 0) {
592
+ await redis.del(...keys);
593
+ }
594
+ } while (cursor !== '0');
595
+
596
+ wsTopics.clear();
597
+ localCounts.clear();
598
+ localData.clear();
599
+ activeTopics.clear();
600
+ syncObservers.clear();
601
+ syncCounts.clear();
602
+ connCounter = 0;
603
+ },
604
+
605
+ destroy() {
606
+ clearInterval(heartbeatTimer);
607
+ if (subscriber) {
608
+ subscriber.quit().catch(() => subscriber.disconnect());
609
+ subscriber = null;
610
+ }
611
+ subscribedChannels.clear();
612
+ activePlatform = null;
613
+ },
614
+
615
+ hooks: {
616
+ async subscribe(ws, topic, { platform }) {
617
+ if (topic.startsWith('__presence:')) {
618
+ const realTopic = topic.slice('__presence:'.length);
619
+ await tracker.sync(ws, realTopic, platform);
620
+ return;
621
+ }
622
+ await tracker.join(ws, topic, platform);
623
+ },
624
+ async close(ws, { platform }) {
625
+ await tracker.leave(ws, platform);
626
+ }
627
+ }
628
+ };
629
+
630
+ return tracker;
631
+ }
@@ -0,0 +1,30 @@
1
+ import type { Platform } from 'svelte-adapter-uws';
2
+ import type { RedisClient } from './index.js';
3
+
4
+ export interface PubSubBusOptions {
5
+ /** Redis channel name for pub/sub messages. @default 'uws:pubsub' */
6
+ channel?: string;
7
+ }
8
+
9
+ export interface PubSubBus {
10
+ /**
11
+ * Returns a new Platform whose publish() sends to Redis + local.
12
+ * Use this wrapped platform everywhere you call publish().
13
+ */
14
+ wrap(platform: Platform): Platform;
15
+
16
+ /**
17
+ * Start the Redis subscriber. Incoming messages from other instances
18
+ * are forwarded to the local platform.publish(). Call once at startup.
19
+ * Idempotent.
20
+ */
21
+ activate(platform: Platform): Promise<void>;
22
+
23
+ /** Stop the Redis subscriber and clean up. */
24
+ deactivate(): Promise<void>;
25
+ }
26
+
27
+ /**
28
+ * Create a Redis-backed pub/sub bus for cross-instance message distribution.
29
+ */
30
+ export function createPubSubBus(client: RedisClient, options?: PubSubBusOptions): PubSubBus;