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,279 @@
1
+ /**
2
+ * Postgres-backed replay buffer for svelte-adapter-uws.
3
+ *
4
+ * Same API as the core createReplay plugin, but stores messages in a
5
+ * Postgres table for durable history that survives restarts.
6
+ *
7
+ * Table schema (auto-created if autoMigrate is true):
8
+ * ws_replay (
9
+ * ws_replay_id BIGSERIAL PRIMARY KEY,
10
+ * topic TEXT NOT NULL,
11
+ * seq BIGINT NOT NULL,
12
+ * event TEXT NOT NULL,
13
+ * data JSONB,
14
+ * created_date TIMESTAMPTZ DEFAULT now()
15
+ * )
16
+ * + index on (topic, seq)
17
+ *
18
+ * ws_replay_seq (
19
+ * topic TEXT PRIMARY KEY,
20
+ * seq BIGINT NOT NULL DEFAULT 0
21
+ * )
22
+ *
23
+ * Sequences are generated atomically via the _seq table using
24
+ * INSERT ... ON CONFLICT DO UPDATE, so they are safe across multiple
25
+ * server instances without races.
26
+ *
27
+ * @module svelte-adapter-uws-extensions/postgres/replay
28
+ */
29
+
30
+ /**
31
+ * @typedef {Object} PgReplayOptions
32
+ * @property {string} [table='ws_replay'] - Table name
33
+ * @property {number} [size=1000] - Max messages per topic
34
+ * @property {number} [ttl=0] - TTL in seconds (0 = no expiry). Rows older than this are cleaned up periodically.
35
+ * @property {boolean} [autoMigrate=true] - Auto-create table on first use
36
+ * @property {number} [cleanupInterval=60000] - How often to run cleanup (ms). 0 to disable.
37
+ */
38
+
39
+ /**
40
+ * @typedef {Object} PgReplayBuffer
41
+ * @property {(platform: import('svelte-adapter-uws').Platform, topic: string, event: string, data?: unknown) => Promise<boolean>} publish
42
+ * @property {(topic: string) => Promise<number>} seq
43
+ * @property {(topic: string, since: number) => Promise<Array<{seq: number, topic: string, event: string, data: unknown}>>} since
44
+ * @property {(ws: any, topic: string, sinceSeq: number, platform: import('svelte-adapter-uws').Platform) => Promise<void>} replay
45
+ * @property {() => Promise<void>} clear
46
+ * @property {(topic: string) => Promise<void>} clearTopic
47
+ * @property {() => void} destroy - Stop cleanup timer
48
+ */
49
+
50
+ /**
51
+ * Create a Postgres-backed replay buffer.
52
+ *
53
+ * @param {import('./index.js').PgClient} client
54
+ * @param {PgReplayOptions} [options]
55
+ * @returns {PgReplayBuffer}
56
+ */
57
+ export function createReplay(client, options = {}) {
58
+ if (options.size !== undefined) {
59
+ if (typeof options.size !== 'number' || options.size < 1 || !Number.isInteger(options.size)) {
60
+ throw new Error(`postgres replay: size must be a positive integer, got ${options.size}`);
61
+ }
62
+ }
63
+ if (options.ttl !== undefined) {
64
+ if (typeof options.ttl !== 'number' || options.ttl < 0 || !Number.isInteger(options.ttl)) {
65
+ throw new Error(`postgres replay: ttl must be a non-negative integer, got ${options.ttl}`);
66
+ }
67
+ }
68
+
69
+ const table = options.table || 'ws_replay';
70
+ const seqTable = table + '_seq';
71
+ const pkCol = table + '_id';
72
+ const maxSize = options.size || 1000;
73
+ const ttl = options.ttl || 0;
74
+ const autoMigrate = options.autoMigrate !== false;
75
+ const cleanupInterval = options.cleanupInterval !== undefined ? options.cleanupInterval : 60000;
76
+
77
+ // Validate table name to prevent SQL injection (alphanumeric + underscore only)
78
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(table)) {
79
+ throw new Error(`postgres replay: invalid table name "${table}"`);
80
+ }
81
+
82
+ let migrated = false;
83
+
84
+ async function ensureTable() {
85
+ if (migrated || !autoMigrate) return;
86
+ await client.query(`
87
+ CREATE TABLE IF NOT EXISTS ${table} (
88
+ ${pkCol} BIGSERIAL PRIMARY KEY,
89
+ topic TEXT NOT NULL,
90
+ seq BIGINT NOT NULL,
91
+ event TEXT NOT NULL,
92
+ data JSONB,
93
+ created_date TIMESTAMPTZ DEFAULT now()
94
+ )
95
+ `);
96
+ await client.query(`
97
+ CREATE INDEX IF NOT EXISTS idx_${table}_topic_seq ON ${table} (topic, seq)
98
+ `);
99
+ // Atomic per-topic sequence counter table
100
+ await client.query(`
101
+ CREATE TABLE IF NOT EXISTS ${seqTable} (
102
+ topic TEXT PRIMARY KEY,
103
+ seq BIGINT NOT NULL DEFAULT 0
104
+ )
105
+ `);
106
+ migrated = true;
107
+ }
108
+
109
+ /**
110
+ * Atomically get the next sequence number for a topic.
111
+ * Uses INSERT ... ON CONFLICT DO UPDATE for cross-instance safety.
112
+ */
113
+ async function nextSeq(topic) {
114
+ const res = await client.query(
115
+ `INSERT INTO ${seqTable} (topic, seq)
116
+ VALUES ($1, 1)
117
+ ON CONFLICT (topic)
118
+ DO UPDATE
119
+ SET seq = ${seqTable}.seq + 1
120
+ RETURNING seq`,
121
+ [topic]
122
+ );
123
+ return parseInt(res.rows[0].seq, 10);
124
+ }
125
+
126
+ // Local publish counter per topic -- avoids COUNT(*) on every publish
127
+ /** @type {Map<string, number>} */
128
+ const publishCounts = new Map();
129
+
130
+ // Periodic cleanup
131
+ let cleanupTimer = null;
132
+ if (cleanupInterval > 0) {
133
+ cleanupTimer = setInterval(async () => {
134
+ try {
135
+ await ensureTable();
136
+
137
+ // Trim by size: for each topic, keep only the newest `maxSize` rows
138
+ await client.query(`
139
+ DELETE FROM ${table}
140
+ WHERE ${pkCol} IN (
141
+ SELECT ${pkCol}
142
+ FROM (SELECT ${pkCol},
143
+ ROW_NUMBER() OVER (PARTITION BY topic ORDER BY seq DESC) AS row_num
144
+ FROM ${table}) ranked
145
+ WHERE row_num > $1)
146
+ `, [maxSize]);
147
+
148
+ // Trim by TTL
149
+ if (ttl > 0) {
150
+ await client.query(
151
+ `DELETE FROM ${table}
152
+ WHERE created_date < now() - interval '1 second' * $1`,
153
+ [ttl]
154
+ );
155
+ }
156
+
157
+ // Reset local counters after cleanup
158
+ publishCounts.clear();
159
+ } catch {
160
+ // Cleanup failures are non-fatal
161
+ }
162
+ }, cleanupInterval);
163
+ if (cleanupTimer.unref) cleanupTimer.unref();
164
+ }
165
+
166
+ return {
167
+ async publish(platform, topic, event, data) {
168
+ await ensureTable();
169
+ const seq = await nextSeq(topic);
170
+
171
+ await client.query(
172
+ `INSERT INTO ${table} (topic, seq, event, data)
173
+ VALUES ($1, $2, $3, $4)`,
174
+ [topic, seq, event, JSON.stringify(data)]
175
+ );
176
+
177
+ // Inline trim: use local counter to avoid COUNT(*) on every publish.
178
+ // On first publish per topic in this process, seed from the database
179
+ // so a fresh/restarted instance trims correctly.
180
+ let count;
181
+ if (publishCounts.has(topic)) {
182
+ count = publishCounts.get(topic) + 1;
183
+ } else {
184
+ const res = await client.query(
185
+ `SELECT COUNT(*)::int AS message_count
186
+ FROM ${table}
187
+ WHERE topic = $1`,
188
+ [topic]
189
+ );
190
+ count = parseInt(res.rows[0].message_count, 10);
191
+ }
192
+ publishCounts.set(topic, count);
193
+ if (count > maxSize) {
194
+ await client.query(`
195
+ DELETE FROM ${table}
196
+ WHERE topic = $1
197
+ AND ${pkCol} NOT IN (
198
+ SELECT ${pkCol}
199
+ FROM ${table}
200
+ WHERE topic = $1
201
+ ORDER BY seq DESC
202
+ LIMIT $2)
203
+ `, [topic, maxSize]);
204
+ publishCounts.set(topic, maxSize);
205
+ }
206
+
207
+ return platform.publish(topic, event, data);
208
+ },
209
+
210
+ async seq(topic) {
211
+ await ensureTable();
212
+ const res = await client.query(
213
+ `SELECT COALESCE(MAX(seq), 0)::int AS max_seq
214
+ FROM ${table}
215
+ WHERE topic = $1`,
216
+ [topic]
217
+ );
218
+ return parseInt(res.rows[0].max_seq, 10);
219
+ },
220
+
221
+ async since(topic, since) {
222
+ await ensureTable();
223
+ const res = await client.query(
224
+ `SELECT seq, topic, event, data
225
+ FROM ${table}
226
+ WHERE topic = $1
227
+ AND seq > $2
228
+ ORDER BY seq ASC`,
229
+ [topic, since]
230
+ );
231
+ return res.rows.map((row) => ({
232
+ seq: parseInt(row.seq, 10),
233
+ topic: row.topic,
234
+ event: row.event,
235
+ data: row.data
236
+ }));
237
+ },
238
+
239
+ async replay(ws, topic, sinceSeq, platform) {
240
+ const missed = await this.since(topic, sinceSeq);
241
+ const replayTopic = '__replay:' + topic;
242
+
243
+ for (let i = 0; i < missed.length; i++) {
244
+ const msg = missed[i];
245
+ platform.send(ws, replayTopic, 'msg', {
246
+ seq: msg.seq,
247
+ event: msg.event,
248
+ data: msg.data
249
+ });
250
+ }
251
+ platform.send(ws, replayTopic, 'end', null);
252
+ },
253
+
254
+ async clear() {
255
+ await ensureTable();
256
+ await client.query(`DELETE FROM ${table}`);
257
+ await client.query(`DELETE FROM ${seqTable}`);
258
+ publishCounts.clear();
259
+ },
260
+
261
+ async clearTopic(topic) {
262
+ await ensureTable();
263
+ await client.query(
264
+ `DELETE FROM ${table}
265
+ WHERE topic = $1`, [topic]);
266
+ await client.query(
267
+ `DELETE FROM ${seqTable}
268
+ WHERE topic = $1`, [topic]);
269
+ publishCounts.delete(topic);
270
+ },
271
+
272
+ destroy() {
273
+ if (cleanupTimer) {
274
+ clearInterval(cleanupTimer);
275
+ cleanupTimer = null;
276
+ }
277
+ }
278
+ };
279
+ }
@@ -0,0 +1,65 @@
1
+ import type { Platform } from 'svelte-adapter-uws';
2
+ import type { RedisClient } from './index.js';
3
+
4
+ export interface RedisCursorOptions {
5
+ /**
6
+ * Minimum ms between broadcasts per user per topic.
7
+ * Trailing-edge timer ensures the final position is always sent.
8
+ * @default 50
9
+ */
10
+ throttle?: number;
11
+
12
+ /**
13
+ * Extract user-identifying data from userData.
14
+ * Broadcast alongside cursor data so other clients know who the cursor belongs to.
15
+ * @default identity
16
+ */
17
+ select?: (userData: any) => any;
18
+
19
+ /**
20
+ * TTL in seconds for Redis hash entries.
21
+ * Entries are refreshed on every broadcast. Stale cursors from crashed
22
+ * instances are cleaned up automatically after this period.
23
+ * @default 30
24
+ */
25
+ ttl?: number;
26
+ }
27
+
28
+ export interface CursorEntry {
29
+ /** Unique connection key. */
30
+ key: string;
31
+ /** Selected user data. */
32
+ user: any;
33
+ /** Latest cursor/position data. */
34
+ data: any;
35
+ }
36
+
37
+ export interface RedisCursorTracker {
38
+ /**
39
+ * Broadcast a cursor position update. Throttled per user per topic.
40
+ * Call this from your `message` hook when you receive cursor data.
41
+ */
42
+ update(ws: any, topic: string, data: any, platform: Platform): void;
43
+
44
+ /**
45
+ * Remove a connection's cursor state from a specific topic, or all topics if omitted.
46
+ * Call this from your `close` hook.
47
+ */
48
+ remove(ws: any, platform: Platform, topic?: string): Promise<void>;
49
+
50
+ /**
51
+ * Get current cursor positions for a topic across all instances.
52
+ */
53
+ list(topic: string): Promise<CursorEntry[]>;
54
+
55
+ /** Clear all cursor state (local and Redis). */
56
+ clear(): Promise<void>;
57
+
58
+ /** Stop the Redis subscriber and clear local timers. */
59
+ destroy(): void;
60
+ }
61
+
62
+ /**
63
+ * Create a Redis-backed cursor tracker.
64
+ */
65
+ export function createCursor(client: RedisClient, options?: RedisCursorOptions): RedisCursorTracker;
@@ -0,0 +1,365 @@
1
+ /**
2
+ * Redis-backed cursor / ephemeral state plugin for svelte-adapter-uws.
3
+ *
4
+ * Same API as the core createCursor plugin, but cursor positions are shared
5
+ * across instances via Redis. Each instance throttles locally (same
6
+ * leading/trailing edge logic as the core), then relays broadcasts through
7
+ * Redis pub/sub so subscribers on other instances see cursor updates.
8
+ *
9
+ * Storage layout:
10
+ * - Hash `{prefix}cursor:{topic}` - field = connectionKey, value = JSON { user, data }
11
+ * - Channel `{prefix}cursor:events` - pub/sub for update/remove relay
12
+ *
13
+ * Hash entries expire via TTL so stale cursors from crashed instances
14
+ * get cleaned up automatically.
15
+ *
16
+ * @module svelte-adapter-uws-extensions/redis/cursor
17
+ */
18
+
19
+ import { randomBytes } from 'node:crypto';
20
+
21
+ /**
22
+ * @typedef {Object} RedisCursorOptions
23
+ * @property {number} [throttle=50] - Minimum ms between broadcasts per user per topic.
24
+ * Trailing-edge timer fires to ensure the final position is always sent.
25
+ * @property {(userData: any) => any} [select] - Extract user-identifying data from userData.
26
+ * Defaults to the full userData.
27
+ * @property {number} [ttl=30] - TTL in seconds for hash entries. Should be longer than
28
+ * the expected gap between updates. Entries are refreshed on every broadcast.
29
+ */
30
+
31
+ /**
32
+ * @typedef {Object} CursorEntry
33
+ * @property {string} key - Unique connection key.
34
+ * @property {any} user - Selected user data.
35
+ * @property {any} data - Latest cursor/position data.
36
+ */
37
+
38
+ /**
39
+ * @typedef {Object} RedisCursorTracker
40
+ * @property {(ws: any, topic: string, data: any, platform: import('svelte-adapter-uws').Platform) => void} update
41
+ * @property {(ws: any, platform: import('svelte-adapter-uws').Platform, topic?: string) => Promise<void>} remove
42
+ * @property {(topic: string) => Promise<CursorEntry[]>} list
43
+ * @property {() => Promise<void>} clear
44
+ * @property {() => void} destroy - Stop the Redis subscriber
45
+ */
46
+
47
+ /**
48
+ * Create a Redis-backed cursor tracker.
49
+ *
50
+ * @param {import('./index.js').RedisClient} client
51
+ * @param {RedisCursorOptions} [options]
52
+ * @returns {RedisCursorTracker}
53
+ */
54
+ export function createCursor(client, options = {}) {
55
+ const throttleMs = options.throttle ?? 50;
56
+ const select = options.select || ((userData) => userData);
57
+ const cursorTtl = options.ttl || 30;
58
+
59
+ if (typeof throttleMs !== 'number' || !Number.isFinite(throttleMs) || throttleMs < 0) {
60
+ throw new Error('redis cursor: throttle must be a non-negative number');
61
+ }
62
+ if (typeof select !== 'function') {
63
+ throw new Error('redis cursor: select must be a function');
64
+ }
65
+
66
+ const instanceId = randomBytes(8).toString('hex');
67
+ const redis = client.redis;
68
+ const channel = client.key('cursor:events');
69
+
70
+ let connCounter = 0;
71
+
72
+ /**
73
+ * Per-ws state: their key and which topics they have cursor state on.
74
+ * @type {Map<any, { key: string, user: any, topics: Set<string> }>}
75
+ */
76
+ const wsState = new Map();
77
+
78
+ /**
79
+ * Per-topic cursor positions (local throttle state).
80
+ * @type {Map<string, Map<string, { user: any, data: any, lastBroadcast: number, timer: any }>>}
81
+ */
82
+ const topics = new Map();
83
+
84
+ // Redis subscriber for cross-instance relay
85
+ /** @type {import('ioredis').Redis | null} */
86
+ let subscriber = null;
87
+ /** @type {import('svelte-adapter-uws').Platform | null} */
88
+ let activePlatform = null;
89
+ let subscriberReady = false;
90
+
91
+ function ensureSubscriber(platform) {
92
+ activePlatform = platform;
93
+ if (subscriber) return;
94
+ const sub = client.duplicate({ enableReadyCheck: false });
95
+ subscriber = sub;
96
+ sub.on('message', (ch, message) => {
97
+ if (ch !== channel) return;
98
+ try {
99
+ const parsed = JSON.parse(message);
100
+ if (parsed.instanceId === instanceId) return;
101
+ // relay: false -- each worker has its own subscriber,
102
+ // so no need to IPC-relay to sibling workers.
103
+ if (activePlatform) {
104
+ activePlatform.publish(
105
+ '__cursor:' + parsed.topic,
106
+ parsed.event,
107
+ parsed.payload,
108
+ { relay: false }
109
+ );
110
+ }
111
+ } catch {
112
+ // Malformed, skip
113
+ }
114
+ });
115
+ sub.subscribe(channel).then(() => {
116
+ subscriberReady = true;
117
+ }).catch(() => {
118
+ // Subscribe failed -- clean up so the next call can retry
119
+ sub.quit().catch(() => sub.disconnect());
120
+ if (subscriber === sub) {
121
+ subscriber = null;
122
+ subscriberReady = false;
123
+ }
124
+ });
125
+ }
126
+
127
+ const cursorTtlMs = cursorTtl * 1000;
128
+
129
+ // Track topics with local activity for periodic stale field cleanup
130
+ /** @type {Set<string>} */
131
+ const activeTopics = new Set();
132
+
133
+ // Periodic cleanup: remove stale fields from dead instances
134
+ const cleanupInterval = Math.max(cursorTtlMs, 10000);
135
+ const cleanupTimer = setInterval(() => {
136
+ const now = Date.now();
137
+ for (const topic of activeTopics) {
138
+ redis.hgetall(client.key('cursor:' + topic)).then((all) => {
139
+ if (!all) return;
140
+ for (const [field, v] of Object.entries(all)) {
141
+ try {
142
+ const parsed = JSON.parse(v);
143
+ if (parsed.ts && (now - parsed.ts) > cursorTtlMs) {
144
+ redis.hdel(client.key('cursor:' + topic), field).catch(() => {});
145
+ }
146
+ } catch {
147
+ redis.hdel(client.key('cursor:' + topic), field).catch(() => {});
148
+ }
149
+ }
150
+ }).catch(() => {});
151
+ }
152
+ }, cleanupInterval);
153
+ if (cleanupTimer.unref) cleanupTimer.unref();
154
+
155
+ function hashKey(topic) {
156
+ return client.key('cursor:' + topic);
157
+ }
158
+
159
+ function getWsState(ws) {
160
+ let state = wsState.get(ws);
161
+ if (!state) {
162
+ state = {
163
+ key: instanceId + ':' + (++connCounter),
164
+ user: select(typeof ws.getUserData === 'function' ? ws.getUserData() : {}),
165
+ topics: new Set()
166
+ };
167
+ wsState.set(ws, state);
168
+ }
169
+ return state;
170
+ }
171
+
172
+ /**
173
+ * Broadcast locally + relay to other instances via Redis.
174
+ */
175
+ function broadcast(topic, key, user, data, platform) {
176
+ // Local broadcast
177
+ platform.publish('__cursor:' + topic, 'update', { key, user, data });
178
+
179
+ // Persist to Redis hash with timestamp for per-entry staleness detection
180
+ const now = Date.now();
181
+ redis.hset(hashKey(topic), key, JSON.stringify({ user, data, ts: now })).catch(() => {});
182
+ redis.expire(hashKey(topic), cursorTtl).catch(() => {});
183
+
184
+ // Relay to other instances
185
+ const msg = JSON.stringify({
186
+ instanceId,
187
+ topic,
188
+ event: 'update',
189
+ payload: { key, user, data }
190
+ });
191
+ redis.publish(channel, msg).catch(() => {});
192
+ }
193
+
194
+ function broadcastRemove(topic, key, platform) {
195
+ platform.publish('__cursor:' + topic, 'remove', { key });
196
+
197
+ redis.hdel(hashKey(topic), key).catch(() => {});
198
+
199
+ const msg = JSON.stringify({
200
+ instanceId,
201
+ topic,
202
+ event: 'remove',
203
+ payload: { key }
204
+ });
205
+ redis.publish(channel, msg).catch(() => {});
206
+ }
207
+
208
+ return {
209
+ update(ws, topic, data, platform) {
210
+ ensureSubscriber(platform);
211
+
212
+ const state = getWsState(ws);
213
+ state.topics.add(topic);
214
+ activeTopics.add(topic);
215
+
216
+ let topicMap = topics.get(topic);
217
+ if (!topicMap) {
218
+ topicMap = new Map();
219
+ topics.set(topic, topicMap);
220
+ }
221
+
222
+ let entry = topicMap.get(state.key);
223
+ const now = Date.now();
224
+
225
+ if (!entry) {
226
+ entry = { user: state.user, data, lastBroadcast: 0, timer: null };
227
+ topicMap.set(state.key, entry);
228
+ }
229
+
230
+ // Always store latest data
231
+ entry.data = data;
232
+ entry.user = state.user;
233
+
234
+ // Leading edge: broadcast immediately if throttle window passed
235
+ if (now - entry.lastBroadcast >= throttleMs) {
236
+ if (entry.timer) {
237
+ clearTimeout(entry.timer);
238
+ entry.timer = null;
239
+ }
240
+ entry.lastBroadcast = now;
241
+ broadcast(topic, state.key, state.user, data, platform);
242
+ return;
243
+ }
244
+
245
+ // Trailing edge: schedule a broadcast for the end of the window
246
+ if (!entry.timer) {
247
+ const key = state.key;
248
+ const user = state.user;
249
+ entry.timer = setTimeout(() => {
250
+ const e = topicMap.get(key);
251
+ if (e) {
252
+ e.lastBroadcast = Date.now();
253
+ e.timer = null;
254
+ broadcast(topic, key, user, e.data, platform);
255
+ }
256
+ }, throttleMs - (now - entry.lastBroadcast));
257
+ }
258
+ },
259
+
260
+ async remove(ws, platform, topic) {
261
+ const state = wsState.get(ws);
262
+ if (!state) return;
263
+
264
+ if (topic !== undefined) {
265
+ // --- Per-topic remove ---
266
+ if (!state.topics.has(topic)) return;
267
+ state.topics.delete(topic);
268
+
269
+ const topicMap = topics.get(topic);
270
+ if (topicMap) {
271
+ const entry = topicMap.get(state.key);
272
+ if (entry) {
273
+ if (entry.timer) clearTimeout(entry.timer);
274
+ topicMap.delete(state.key);
275
+ if (topicMap.size === 0) {
276
+ topics.delete(topic);
277
+ activeTopics.delete(topic);
278
+ }
279
+ broadcastRemove(topic, state.key, platform);
280
+ }
281
+ }
282
+
283
+ if (state.topics.size === 0) wsState.delete(ws);
284
+ return;
285
+ }
286
+
287
+ // --- Remove from all topics ---
288
+ for (const topic of state.topics) {
289
+ const topicMap = topics.get(topic);
290
+ if (!topicMap) continue;
291
+
292
+ const entry = topicMap.get(state.key);
293
+ if (entry) {
294
+ if (entry.timer) clearTimeout(entry.timer);
295
+ topicMap.delete(state.key);
296
+ if (topicMap.size === 0) {
297
+ topics.delete(topic);
298
+ activeTopics.delete(topic);
299
+ }
300
+ broadcastRemove(topic, state.key, platform);
301
+ }
302
+ }
303
+
304
+ wsState.delete(ws);
305
+ },
306
+
307
+ async list(topic) {
308
+ const all = await redis.hgetall(hashKey(topic));
309
+ const result = [];
310
+ const now = Date.now();
311
+ const ttlMs = cursorTtl * 1000;
312
+ for (const [key, v] of Object.entries(all)) {
313
+ try {
314
+ const parsed = JSON.parse(v);
315
+ // Filter out stale entries from crashed instances
316
+ if (parsed.ts && (now - parsed.ts) > ttlMs) continue;
317
+ result.push({ key, user: parsed.user, data: parsed.data });
318
+ } catch {
319
+ // Corrupted entry, skip
320
+ }
321
+ }
322
+ return result;
323
+ },
324
+
325
+ async clear() {
326
+ // Clear all timers
327
+ for (const [, topicMap] of topics) {
328
+ for (const [, entry] of topicMap) {
329
+ if (entry.timer) clearTimeout(entry.timer);
330
+ }
331
+ }
332
+ topics.clear();
333
+ wsState.clear();
334
+ activeTopics.clear();
335
+ connCounter = 0;
336
+
337
+ // Clear Redis keys
338
+ const pattern = client.key('cursor:*');
339
+ let cursor = '0';
340
+ do {
341
+ const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
342
+ cursor = nextCursor;
343
+ if (keys.length > 0) {
344
+ await redis.del(...keys);
345
+ }
346
+ } while (cursor !== '0');
347
+ },
348
+
349
+ destroy() {
350
+ // Clear all timers
351
+ clearInterval(cleanupTimer);
352
+ for (const [, topicMap] of topics) {
353
+ for (const [, entry] of topicMap) {
354
+ if (entry.timer) clearTimeout(entry.timer);
355
+ }
356
+ }
357
+ if (subscriber) {
358
+ subscriber.quit().catch(() => subscriber.disconnect());
359
+ subscriber = null;
360
+ }
361
+ subscriberReady = false;
362
+ activePlatform = null;
363
+ }
364
+ };
365
+ }