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.
- package/LICENSE +21 -0
- package/README.md +697 -0
- package/package.json +99 -0
- package/postgres/index.d.ts +26 -0
- package/postgres/index.js +88 -0
- package/postgres/notify.d.ts +33 -0
- package/postgres/notify.js +214 -0
- package/postgres/replay.d.ts +56 -0
- package/postgres/replay.js +279 -0
- package/redis/cursor.d.ts +65 -0
- package/redis/cursor.js +365 -0
- package/redis/groups.d.ts +64 -0
- package/redis/groups.js +394 -0
- package/redis/index.d.ts +30 -0
- package/redis/index.js +100 -0
- package/redis/presence.d.ts +62 -0
- package/redis/presence.js +631 -0
- package/redis/pubsub.d.ts +30 -0
- package/redis/pubsub.js +153 -0
- package/redis/ratelimit.d.ts +39 -0
- package/redis/ratelimit.js +223 -0
- package/redis/replay.d.ts +47 -0
- package/redis/replay.js +156 -0
- package/shared/errors.js +33 -0
|
@@ -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;
|