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,64 @@
1
+ import type { Platform } from 'svelte-adapter-uws';
2
+ import type { RedisClient } from './index.js';
3
+
4
+ export type GroupRole = 'member' | 'admin' | 'viewer';
5
+
6
+ export interface RedisGroupOptions {
7
+ /** Maximum members allowed. @default Infinity */
8
+ maxMembers?: number;
9
+ /** Initial group metadata. */
10
+ meta?: Record<string, any>;
11
+ /** Member entry TTL in seconds. Entries from crashed instances expire after this. @default 120 */
12
+ memberTtl?: number;
13
+ /** Called after a member joins. */
14
+ onJoin?: (ws: any, role: GroupRole) => void;
15
+ /** Called after a member leaves. */
16
+ onLeave?: (ws: any, role: GroupRole) => void;
17
+ /** Called when a join is rejected because the group is full. */
18
+ onFull?: (ws: any, role: GroupRole) => void;
19
+ /** Called when the group is closed. */
20
+ onClose?: () => void;
21
+ }
22
+
23
+ export interface RedisGroup {
24
+ /** The group name. */
25
+ readonly name: string;
26
+
27
+ /** Get group metadata from Redis. */
28
+ getMeta(): Promise<Record<string, any>>;
29
+
30
+ /** Set group metadata in Redis. */
31
+ setMeta(meta: Record<string, any>): Promise<void>;
32
+
33
+ /** Add a member. Returns true on success, false if full or closed. */
34
+ join(ws: any, platform: Platform, role?: GroupRole): Promise<boolean>;
35
+
36
+ /** Remove a member. */
37
+ leave(ws: any, platform: Platform): Promise<void>;
38
+
39
+ /** Broadcast to all members, or filter by role. */
40
+ publish(platform: Platform, event: string, data?: any, role?: GroupRole): Promise<void>;
41
+
42
+ /** Send to a single member (validates membership). */
43
+ send(platform: Platform, ws: any, event: string, data?: any): void;
44
+
45
+ /** List members on this instance. */
46
+ localMembers(): Array<{ ws: any; role: GroupRole }>;
47
+
48
+ /** Total member count across all instances. */
49
+ count(): Promise<number>;
50
+
51
+ /** Check if a ws is a member on this instance. */
52
+ has(ws: any): boolean;
53
+
54
+ /** Dissolve the group, notify all members, clean up. */
55
+ close(platform: Platform): Promise<void>;
56
+
57
+ /** Stop the Redis subscriber. */
58
+ destroy(): void;
59
+ }
60
+
61
+ /**
62
+ * Create a Redis-backed broadcast group.
63
+ */
64
+ export function createGroup(client: RedisClient, name: string, options?: RedisGroupOptions): RedisGroup;
@@ -0,0 +1,394 @@
1
+ /**
2
+ * Redis-backed broadcast groups for svelte-adapter-uws.
3
+ *
4
+ * Same API as the core createGroup plugin, but membership and metadata
5
+ * are stored in Redis so groups work across multiple server instances.
6
+ *
7
+ * Local members (ws connections on this instance) still get tracked locally
8
+ * because WebSocket objects cannot be serialized. Cross-instance publish()
9
+ * uses Redis pub/sub to reach members on other instances.
10
+ *
11
+ * Storage layout:
12
+ * - Key `{prefix}group:{name}:meta` - hash (group metadata)
13
+ * - Key `{prefix}group:{name}:members` - hash (field=memberId, value=JSON {role, instanceId, ts})
14
+ * - Key `{prefix}group:{name}:closed` - string flag ("1" if closed)
15
+ * - Channel `{prefix}group:{name}:events` - pub/sub for cross-instance events
16
+ *
17
+ * @module svelte-adapter-uws-extensions/redis/groups
18
+ */
19
+
20
+ import { randomBytes } from 'node:crypto';
21
+
22
+ const VALID_ROLES = new Set(['member', 'admin', 'viewer']);
23
+
24
+ /**
25
+ * Lua script for atomic join: check capacity (excluding stale entries),
26
+ * clean up stale entries, and insert the new member in one roundtrip.
27
+ *
28
+ * KEYS[1] = members hash key
29
+ * ARGV[1] = maxMembers
30
+ * ARGV[2] = memberId
31
+ * ARGV[3] = member data JSON
32
+ * ARGV[4] = now (ms)
33
+ * ARGV[5] = memberTtl (ms)
34
+ *
35
+ * Returns 1 on success, 0 if full.
36
+ */
37
+ const JOIN_SCRIPT = `
38
+ local key = KEYS[1]
39
+ local maxMembers = tonumber(ARGV[1])
40
+ local memberId = ARGV[2]
41
+ local memberData = ARGV[3]
42
+ local now = tonumber(ARGV[4])
43
+ local memberTtl = tonumber(ARGV[5])
44
+
45
+ local all = redis.call('hgetall', key)
46
+ local liveCount = 0
47
+ for i = 1, #all, 2 do
48
+ local ok, val = pcall(cjson.decode, all[i+1])
49
+ if ok and val.ts and (now - val.ts) <= memberTtl then
50
+ liveCount = liveCount + 1
51
+ else
52
+ redis.call('hdel', key, all[i])
53
+ end
54
+ end
55
+
56
+ if liveCount >= maxMembers then
57
+ return 0
58
+ end
59
+ redis.call('hset', key, memberId, memberData)
60
+ return 1
61
+ `;
62
+
63
+ /**
64
+ * @typedef {'member' | 'admin' | 'viewer'} GroupRole
65
+ */
66
+
67
+ /**
68
+ * @typedef {Object} RedisGroupOptions
69
+ * @property {number} [maxMembers=Infinity] - Maximum members allowed
70
+ * @property {Record<string, any>} [meta] - Initial group metadata
71
+ * @property {number} [memberTtl=120] - Member entry TTL in seconds. Entries from crashed instances expire after this.
72
+ * @property {(ws: any, role: GroupRole) => void} [onJoin]
73
+ * @property {(ws: any, role: GroupRole) => void} [onLeave]
74
+ * @property {(ws: any, role: GroupRole) => void} [onFull]
75
+ * @property {() => void} [onClose]
76
+ */
77
+
78
+ /**
79
+ * @typedef {Object} RedisGroup
80
+ * @property {string} name
81
+ * @property {() => Promise<Record<string, any>>} getMeta
82
+ * @property {(meta: Record<string, any>) => Promise<void>} setMeta
83
+ * @property {(ws: any, platform: import('svelte-adapter-uws').Platform, role?: GroupRole) => Promise<boolean>} join
84
+ * @property {(ws: any, platform: import('svelte-adapter-uws').Platform) => Promise<void>} leave
85
+ * @property {(platform: import('svelte-adapter-uws').Platform, event: string, data?: any, role?: GroupRole) => Promise<void>} publish
86
+ * @property {(platform: import('svelte-adapter-uws').Platform, ws: any, event: string, data?: any) => void} send
87
+ * @property {() => Array<{ws: any, role: GroupRole}>} localMembers - Members on this instance
88
+ * @property {() => Promise<number>} count - Total members across all instances
89
+ * @property {(ws: any) => boolean} has
90
+ * @property {(platform: import('svelte-adapter-uws').Platform) => Promise<void>} close
91
+ * @property {() => void} destroy - Stop subscriber
92
+ */
93
+
94
+ /**
95
+ * Create a Redis-backed broadcast group.
96
+ *
97
+ * @param {import('./index.js').RedisClient} client
98
+ * @param {string} name
99
+ * @param {RedisGroupOptions} [options]
100
+ * @returns {RedisGroup}
101
+ */
102
+ export function createGroup(client, name, options = {}) {
103
+ if (!name || typeof name !== 'string') {
104
+ throw new Error('redis group: name must be a non-empty string');
105
+ }
106
+
107
+ const maxMembers = options.maxMembers ?? Infinity;
108
+ const memberTtl = options.memberTtl ?? 120;
109
+ const memberTtlMs = memberTtl * 1000;
110
+ const onJoin = options.onJoin ?? null;
111
+ const onLeave = options.onLeave ?? null;
112
+ const onFull = options.onFull ?? null;
113
+ const onClose = options.onClose ?? null;
114
+
115
+ if (typeof maxMembers !== 'number' || (!Number.isFinite(maxMembers) && maxMembers !== Infinity) || maxMembers < 1) {
116
+ throw new Error('redis group: maxMembers must be a positive number or Infinity');
117
+ }
118
+ if (onJoin != null && typeof onJoin !== 'function') throw new Error('redis group: onJoin must be a function');
119
+ if (onLeave != null && typeof onLeave !== 'function') throw new Error('redis group: onLeave must be a function');
120
+ if (onFull != null && typeof onFull !== 'function') throw new Error('redis group: onFull must be a function');
121
+ if (onClose != null && typeof onClose !== 'function') throw new Error('redis group: onClose must be a function');
122
+
123
+ const instanceId = randomBytes(8).toString('hex');
124
+ const redis = client.redis;
125
+
126
+ const metaKey = client.key('group:' + name + ':meta');
127
+ const membersKey = client.key('group:' + name + ':members');
128
+ const closedKey = client.key('group:' + name + ':closed');
129
+ const eventChannel = client.key('group:' + name + ':events');
130
+ const internalTopic = '__group:' + name;
131
+
132
+ // Local member tracking (ws objects on this instance)
133
+ /** @type {Map<any, { role: GroupRole, memberId: string }>} */
134
+ const localMembers = new Map();
135
+ let memberCounter = 0;
136
+
137
+ // Set initial metadata
138
+ if (options.meta) {
139
+ redis.hmset(metaKey, options.meta).catch(() => {});
140
+ }
141
+
142
+ // Heartbeat: refresh timestamps on local member entries and
143
+ // remove stale entries from crashed instances.
144
+ const heartbeatTimer = setInterval(() => {
145
+ const now = Date.now();
146
+ for (const [, entry] of localMembers) {
147
+ const memberData = JSON.stringify({ role: entry.role, instanceId, ts: now });
148
+ redis.hset(membersKey, entry.memberId, memberData).catch(() => {});
149
+ }
150
+ // Clean up stale entries from dead instances so the hash
151
+ // does not grow forever after crashes.
152
+ redis.hgetall(membersKey).then((all) => {
153
+ if (!all) return;
154
+ for (const [field, v] of Object.entries(all)) {
155
+ try {
156
+ const parsed = JSON.parse(v);
157
+ if (parsed.ts && (now - parsed.ts) > memberTtlMs) {
158
+ redis.hdel(membersKey, field).catch(() => {});
159
+ }
160
+ } catch {
161
+ redis.hdel(membersKey, field).catch(() => {});
162
+ }
163
+ }
164
+ }).catch(() => {});
165
+ }, Math.max(memberTtlMs / 3, 5000));
166
+ if (heartbeatTimer.unref) heartbeatTimer.unref();
167
+
168
+ // Subscriber for cross-instance events
169
+ /** @type {import('ioredis').Redis | null} */
170
+ let subscriber = null;
171
+ /** @type {import('svelte-adapter-uws').Platform | null} */
172
+ let subscribedPlatform = null;
173
+
174
+ async function ensureSubscriber(platform) {
175
+ subscribedPlatform = platform;
176
+ if (subscriber) return;
177
+ subscriber = client.duplicate({ enableReadyCheck: false });
178
+ subscriber.on('message', (ch, message) => {
179
+ if (ch !== eventChannel) return;
180
+ try {
181
+ const parsed = JSON.parse(message);
182
+ if (parsed.instanceId === instanceId) return;
183
+ if (subscribedPlatform) {
184
+ // Handle role-filtered events: deliver only to matching local members
185
+ if (parsed.event === '__role_filtered') {
186
+ const { event, data, role } = parsed.data;
187
+ for (const [ws, entry] of localMembers) {
188
+ if (entry.role === role) {
189
+ subscribedPlatform.send(ws, internalTopic, event, data);
190
+ }
191
+ }
192
+ } else if (parsed.event === 'close') {
193
+ // Remote close: clean up local members just like a local close().
194
+ // relay: false -- each worker has its own subscriber.
195
+ subscribedPlatform.publish(internalTopic, 'close', parsed.data, { relay: false });
196
+ for (const [ws] of localMembers) {
197
+ ws.unsubscribe(internalTopic);
198
+ }
199
+ localMembers.clear();
200
+ if (onClose) onClose();
201
+ } else {
202
+ // relay: false -- each worker has its own subscriber.
203
+ subscribedPlatform.publish(internalTopic, parsed.event, parsed.data, { relay: false });
204
+ }
205
+ }
206
+ } catch {
207
+ // Malformed, skip
208
+ }
209
+ });
210
+ await subscriber.subscribe(eventChannel);
211
+ }
212
+
213
+ async function publishEvent(event, data) {
214
+ const msg = JSON.stringify({ instanceId, event, data });
215
+ await redis.publish(eventChannel, msg).catch(() => {});
216
+ }
217
+
218
+ return {
219
+ get name() { return name; },
220
+
221
+ async getMeta() {
222
+ const raw = await redis.hgetall(metaKey);
223
+ return raw || {};
224
+ },
225
+
226
+ async setMeta(meta) {
227
+ if (Object.keys(meta).length === 0) {
228
+ await redis.del(metaKey);
229
+ } else {
230
+ await redis.hmset(metaKey, meta);
231
+ }
232
+ },
233
+
234
+ async join(ws, platform, role = 'member') {
235
+ // Check closed
236
+ const closed = await redis.get(closedKey);
237
+ if (closed === '1') return false;
238
+
239
+ // Idempotent
240
+ if (localMembers.has(ws)) return true;
241
+
242
+ if (!VALID_ROLES.has(role)) {
243
+ throw new Error(`redis group "${name}": invalid role "${role}"`);
244
+ }
245
+
246
+ const memberId = instanceId + ':' + (++memberCounter);
247
+ const now = Date.now();
248
+ const memberData = JSON.stringify({ role, instanceId, ts: now });
249
+
250
+ // Atomic capacity check + insert (skips stale entries from crashed instances)
251
+ if (Number.isFinite(maxMembers)) {
252
+ const result = await redis.eval(
253
+ JOIN_SCRIPT, 1, membersKey,
254
+ maxMembers, memberId, memberData, now, memberTtlMs
255
+ );
256
+ if (result === 0) {
257
+ if (onFull) onFull(ws, role);
258
+ return false;
259
+ }
260
+ } else {
261
+ // No capacity limit, just insert
262
+ await redis.hset(membersKey, memberId, memberData);
263
+ }
264
+
265
+ localMembers.set(ws, { role, memberId });
266
+
267
+ // Ensure cross-instance subscriber is running
268
+ await ensureSubscriber(platform);
269
+
270
+ // Publish join before subscribing (joiner doesn't see own join)
271
+ platform.publish(internalTopic, 'join', { role });
272
+ await publishEvent('join', { role });
273
+
274
+ ws.subscribe(internalTopic);
275
+
276
+ // Send current member list to the joiner
277
+ const allRaw = await redis.hgetall(membersKey);
278
+ const membersList = [];
279
+ for (const v of Object.values(allRaw)) {
280
+ try {
281
+ const parsed = JSON.parse(v);
282
+ // Filter out stale entries
283
+ if (parsed.ts && (now - parsed.ts) > memberTtlMs) continue;
284
+ membersList.push({ role: parsed.role });
285
+ } catch { /* skip corrupted */ }
286
+ }
287
+ platform.send(ws, internalTopic, 'members', membersList);
288
+
289
+ if (onJoin) onJoin(ws, role);
290
+ return true;
291
+ },
292
+
293
+ async leave(ws, platform) {
294
+ const entry = localMembers.get(ws);
295
+ if (!entry) return;
296
+
297
+ localMembers.delete(ws);
298
+ ws.unsubscribe(internalTopic);
299
+
300
+ // Remove from Redis
301
+ await redis.hdel(membersKey, entry.memberId);
302
+
303
+ platform.publish(internalTopic, 'leave', { role: entry.role });
304
+ await publishEvent('leave', { role: entry.role });
305
+
306
+ if (onLeave) onLeave(ws, entry.role);
307
+ },
308
+
309
+ async publish(platform, event, data, role) {
310
+ const closed = await redis.get(closedKey);
311
+ if (closed === '1') return;
312
+
313
+ if (role == null) {
314
+ // Broadcast to all via topic
315
+ platform.publish(internalTopic, event, data);
316
+ await publishEvent(event, data);
317
+ return;
318
+ }
319
+
320
+ // Role-filtered: send individually to local members with that role
321
+ for (const [ws, entry] of localMembers) {
322
+ if (entry.role === role) {
323
+ platform.send(ws, internalTopic, event, data);
324
+ }
325
+ }
326
+ // For remote instances, publish with role filter info
327
+ // Remote subscriber handler will filter by role locally
328
+ await publishEvent('__role_filtered', { event, data, role });
329
+ },
330
+
331
+ send(platform, ws, event, data) {
332
+ if (!localMembers.has(ws)) {
333
+ throw new Error(`redis group "${name}": ws is not a member`);
334
+ }
335
+ platform.send(ws, internalTopic, event, data);
336
+ },
337
+
338
+ localMembers() {
339
+ const result = [];
340
+ for (const [ws, entry] of localMembers) {
341
+ result.push({ ws, role: entry.role });
342
+ }
343
+ return result;
344
+ },
345
+
346
+ async count() {
347
+ const all = await redis.hgetall(membersKey);
348
+ if (!all) return 0;
349
+ const now = Date.now();
350
+ let liveCount = 0;
351
+ for (const v of Object.values(all)) {
352
+ try {
353
+ const parsed = JSON.parse(v);
354
+ if (parsed.ts && (now - parsed.ts) > memberTtlMs) continue;
355
+ liveCount++;
356
+ } catch { /* skip corrupted */ }
357
+ }
358
+ return liveCount;
359
+ },
360
+
361
+ has(ws) {
362
+ return localMembers.has(ws);
363
+ },
364
+
365
+ async close(platform) {
366
+ const alreadyClosed = await redis.get(closedKey);
367
+ if (alreadyClosed === '1') return;
368
+
369
+ await redis.set(closedKey, '1');
370
+
371
+ platform.publish(internalTopic, 'close', null);
372
+ await publishEvent('close', null);
373
+
374
+ for (const [ws] of localMembers) {
375
+ ws.unsubscribe(internalTopic);
376
+ }
377
+ localMembers.clear();
378
+
379
+ // Clean up Redis keys
380
+ await redis.del(membersKey);
381
+
382
+ if (onClose) onClose();
383
+ },
384
+
385
+ destroy() {
386
+ clearInterval(heartbeatTimer);
387
+ if (subscriber) {
388
+ subscriber.quit().catch(() => subscriber.disconnect());
389
+ subscriber = null;
390
+ }
391
+ subscribedPlatform = null;
392
+ }
393
+ };
394
+ }
@@ -0,0 +1,30 @@
1
+ import type { Redis, RedisOptions } from 'ioredis';
2
+
3
+ export interface RedisClientOptions {
4
+ /** Redis connection URL. @default 'redis://localhost:6379' */
5
+ url?: string;
6
+ /** Prefix for all keys written by extensions. @default '' */
7
+ keyPrefix?: string;
8
+ /** Listen for `sveltekit:shutdown` and disconnect. @default true */
9
+ autoShutdown?: boolean;
10
+ /** Extra ioredis options (merged on top of URL parsing). */
11
+ options?: RedisOptions;
12
+ }
13
+
14
+ export interface RedisClient {
15
+ /** The underlying ioredis instance. */
16
+ readonly redis: Redis;
17
+ /** The key prefix. */
18
+ readonly keyPrefix: string;
19
+ /** Prefix a key: `keyPrefix + key`. */
20
+ key(key: string): string;
21
+ /** Create a new connection with the same config. Pass overrides to change options (e.g. `{ enableReadyCheck: false }` for subscriber clients). */
22
+ duplicate(overrides?: RedisOptions): Redis;
23
+ /** Gracefully disconnect all connections. */
24
+ quit(): Promise<void>;
25
+ }
26
+
27
+ /**
28
+ * Create a Redis client with lifecycle management.
29
+ */
30
+ export function createRedisClient(options?: RedisClientOptions): RedisClient;
package/redis/index.js ADDED
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Redis client factory for svelte-adapter-uws-extensions.
3
+ *
4
+ * Wraps ioredis with lifecycle management and graceful shutdown
5
+ * via the SvelteKit `sveltekit:shutdown` event.
6
+ *
7
+ * @module svelte-adapter-uws-extensions/redis
8
+ */
9
+
10
+ import Redis from 'ioredis';
11
+ import { ConnectionError } from '../shared/errors.js';
12
+
13
+ /**
14
+ * @typedef {Object} RedisClientOptions
15
+ * @property {string} [url='redis://localhost:6379'] - Redis connection URL
16
+ * @property {string} [keyPrefix=''] - Prefix for all keys written by extensions
17
+ * @property {boolean} [autoShutdown=true] - Listen for `sveltekit:shutdown` and disconnect
18
+ * @property {import('ioredis').RedisOptions} [options] - Extra ioredis options (merged on top of URL parsing)
19
+ */
20
+
21
+ /**
22
+ * @typedef {Object} RedisClient
23
+ * @property {import('ioredis').Redis} redis - The underlying ioredis instance
24
+ * @property {string} keyPrefix - The key prefix
25
+ * @property {(key: string) => string} key - Prefix a key: `keyPrefix + key`
26
+ * @property {(overrides?: import('ioredis').RedisOptions) => import('ioredis').Redis} duplicate - Create a new connection with the same config (for subscribers)
27
+ * @property {() => Promise<void>} quit - Gracefully disconnect
28
+ */
29
+
30
+ /**
31
+ * Create a Redis client.
32
+ *
33
+ * @param {RedisClientOptions} [options]
34
+ * @returns {RedisClient}
35
+ *
36
+ * @example
37
+ * ```js
38
+ * import { createRedisClient } from 'svelte-adapter-uws-extensions/redis';
39
+ *
40
+ * export const redis = createRedisClient({
41
+ * url: 'redis://localhost:6379',
42
+ * keyPrefix: 'myapp:'
43
+ * });
44
+ * ```
45
+ */
46
+ export function createRedisClient(options = {}) {
47
+ const url = options.url || 'redis://localhost:6379';
48
+ const keyPrefix = options.keyPrefix || '';
49
+ const autoShutdown = options.autoShutdown !== false;
50
+
51
+ /** @type {import('ioredis').RedisOptions} */
52
+ const redisOpts = {
53
+ maxRetriesPerRequest: null,
54
+ enableReadyCheck: true,
55
+ lazyConnect: false,
56
+ ...(options.options || {})
57
+ };
58
+
59
+ let redis;
60
+ try {
61
+ redis = new Redis(url, redisOpts);
62
+ } catch (err) {
63
+ throw new ConnectionError('redis', `failed to create client for ${url}`, err);
64
+ }
65
+
66
+ // Track duplicate connections for cleanup
67
+ /** @type {import('ioredis').Redis[]} */
68
+ const duplicates = [];
69
+
70
+ /** @type {boolean} */
71
+ let shuttingDown = false;
72
+
73
+ async function quit() {
74
+ if (shuttingDown) return;
75
+ shuttingDown = true;
76
+ const all = [redis, ...duplicates];
77
+ await Promise.allSettled(all.map((c) => c.quit().catch(() => c.disconnect())));
78
+ }
79
+
80
+ if (autoShutdown && typeof process !== 'undefined') {
81
+ process.once('sveltekit:shutdown', quit);
82
+ }
83
+
84
+ return {
85
+ redis,
86
+ keyPrefix,
87
+
88
+ key(k) {
89
+ return keyPrefix + k;
90
+ },
91
+
92
+ duplicate(overrides) {
93
+ const dup = overrides ? redis.duplicate(overrides) : redis.duplicate();
94
+ duplicates.push(dup);
95
+ return dup;
96
+ },
97
+
98
+ quit
99
+ };
100
+ }
@@ -0,0 +1,62 @@
1
+ import type { Platform } from 'svelte-adapter-uws';
2
+ import type { RedisClient } from './index.js';
3
+
4
+ export interface RedisPresenceOptions {
5
+ /** Field in selected data for user dedup. @default 'id' */
6
+ key?: string;
7
+ /** Extract public fields from userData. @default identity */
8
+ select?: (userData: any) => Record<string, any>;
9
+ /** Heartbeat interval in ms (refresh TTL). @default 30000 */
10
+ heartbeat?: number;
11
+ /** TTL in seconds for presence hash entries. @default 90 */
12
+ ttl?: number;
13
+ }
14
+
15
+ export interface RedisPresenceTracker {
16
+ /**
17
+ * Add a connection to a topic's presence.
18
+ * Ignores `__`-prefixed topics. Idempotent.
19
+ */
20
+ join(ws: any, topic: string, platform: Platform): Promise<void>;
21
+
22
+ /** Remove a connection from a specific topic, or all topics if omitted. */
23
+ leave(ws: any, platform: Platform, topic?: string): Promise<void>;
24
+
25
+ /** Send current presence list without joining. */
26
+ sync(ws: any, topic: string, platform: Platform): Promise<void>;
27
+
28
+ /** Get the current presence list for a topic. */
29
+ list(topic: string): Promise<Record<string, any>[]>;
30
+
31
+ /** Get the number of unique users present on a topic. */
32
+ count(topic: string): Promise<number>;
33
+
34
+ /** Clear all presence state. */
35
+ clear(): Promise<void>;
36
+
37
+ /** Stop heartbeat timer and Redis subscriber. */
38
+ destroy(): void;
39
+
40
+ /**
41
+ * Ready-made WebSocket hooks for zero-config presence.
42
+ *
43
+ * `subscribe` handles both regular topics (calls `join`) and `__presence:*`
44
+ * topics (calls `sync` so the client gets the current list immediately).
45
+ * `close` calls `leave`.
46
+ *
47
+ * @example
48
+ * ```js
49
+ * import { presence } from '$lib/server/presence';
50
+ * export const { subscribe, close } = presence.hooks;
51
+ * ```
52
+ */
53
+ hooks: {
54
+ subscribe(ws: any, topic: string, ctx: { platform: Platform }): Promise<void>;
55
+ close(ws: any, ctx: { platform: Platform }): Promise<void>;
56
+ };
57
+ }
58
+
59
+ /**
60
+ * Create a Redis-backed presence tracker.
61
+ */
62
+ export function createPresence(client: RedisClient, options?: RedisPresenceOptions): RedisPresenceTracker;