svelte-adapter-uws-extensions 0.1.2 → 0.1.4
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/package.json +1 -1
- package/redis/presence.js +109 -39
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "svelte-adapter-uws-extensions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Redis and Postgres extensions for svelte-adapter-uws - distributed pub/sub, replay buffers, presence tracking, rate limiting, groups, and DB change notifications",
|
|
5
5
|
"author": "Kevin Radziszewski",
|
|
6
6
|
"license": "MIT",
|
package/redis/presence.js
CHANGED
|
@@ -20,8 +20,9 @@
|
|
|
20
20
|
import { randomBytes } from 'node:crypto';
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
|
-
* Lua script for atomic join: set this instance's field
|
|
24
|
-
* the user already exists on another instance (non-stale)
|
|
23
|
+
* Lua script for atomic join: set this instance's field, expire the key,
|
|
24
|
+
* check if the user already exists on another instance (non-stale),
|
|
25
|
+
* and return all hash entries so the caller can skip a separate HGETALL.
|
|
25
26
|
*
|
|
26
27
|
* KEYS[1] = hash key
|
|
27
28
|
* ARGV[1] = field to set (instanceId|userKey)
|
|
@@ -29,9 +30,10 @@ import { randomBytes } from 'node:crypto';
|
|
|
29
30
|
* ARGV[3] = "|userKey" suffix to match
|
|
30
31
|
* ARGV[4] = now (ms)
|
|
31
32
|
* ARGV[5] = presenceTtlMs
|
|
33
|
+
* ARGV[6] = presenceTtl (seconds, for EXPIRE)
|
|
32
34
|
*
|
|
33
|
-
* Returns
|
|
34
|
-
*
|
|
35
|
+
* Returns {isFirst, field1, val1, field2, val2, ...} so a single round
|
|
36
|
+
* trip handles HSET + EXPIRE + dedup check + HGETALL.
|
|
35
37
|
*/
|
|
36
38
|
const JOIN_SCRIPT = `
|
|
37
39
|
local key = KEYS[1]
|
|
@@ -40,20 +42,29 @@ local value = ARGV[2]
|
|
|
40
42
|
local suffix = ARGV[3]
|
|
41
43
|
local now = tonumber(ARGV[4])
|
|
42
44
|
local ttlMs = tonumber(ARGV[5])
|
|
45
|
+
local ttlSec = tonumber(ARGV[6])
|
|
43
46
|
|
|
44
47
|
redis.call('hset', key, field, value)
|
|
48
|
+
redis.call('expire', key, ttlSec)
|
|
45
49
|
|
|
46
50
|
local all = redis.call('hgetall', key)
|
|
51
|
+
local isFirst = 1
|
|
47
52
|
for i = 1, #all, 2 do
|
|
48
53
|
local f = all[i]
|
|
49
54
|
if f ~= field and #f >= #suffix and string.sub(f, -#suffix) == suffix then
|
|
50
55
|
local ok, parsed = pcall(cjson.decode, all[i+1])
|
|
51
56
|
if ok and parsed.ts and (now - parsed.ts) <= ttlMs then
|
|
52
|
-
|
|
57
|
+
isFirst = 0
|
|
58
|
+
break
|
|
53
59
|
end
|
|
54
60
|
end
|
|
55
61
|
end
|
|
56
|
-
|
|
62
|
+
|
|
63
|
+
local result = {isFirst}
|
|
64
|
+
for i = 1, #all do
|
|
65
|
+
result[#result + 1] = all[i]
|
|
66
|
+
end
|
|
67
|
+
return result
|
|
57
68
|
`;
|
|
58
69
|
|
|
59
70
|
/**
|
|
@@ -162,10 +173,27 @@ export function createPresence(client, options = {}) {
|
|
|
162
173
|
*/
|
|
163
174
|
const syncCounts = new Map();
|
|
164
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Dedup in-flight HGETALL requests for the same topic. Multiple callers
|
|
178
|
+
* awaiting the same key share one Redis round trip.
|
|
179
|
+
* @type {Map<string, Promise<Record<string, string>>>}
|
|
180
|
+
*/
|
|
181
|
+
const hgetallInflight = new Map();
|
|
182
|
+
|
|
165
183
|
function hashKey(topic) {
|
|
166
184
|
return client.key('presence:' + topic);
|
|
167
185
|
}
|
|
168
186
|
|
|
187
|
+
function coalesceHgetall(topic) {
|
|
188
|
+
const key = hashKey(topic);
|
|
189
|
+
let pending = hgetallInflight.get(key);
|
|
190
|
+
if (!pending) {
|
|
191
|
+
pending = redis.hgetall(key).finally(() => hgetallInflight.delete(key));
|
|
192
|
+
hgetallInflight.set(key, pending);
|
|
193
|
+
}
|
|
194
|
+
return pending;
|
|
195
|
+
}
|
|
196
|
+
|
|
169
197
|
function eventChannel(topic) {
|
|
170
198
|
return client.key('presence:events:' + topic);
|
|
171
199
|
}
|
|
@@ -333,24 +361,49 @@ export function createPresence(client, options = {}) {
|
|
|
333
361
|
// Subscribe to cross-instance events for this topic
|
|
334
362
|
await subscribeToTopic(topic, platform);
|
|
335
363
|
|
|
364
|
+
// Guard: if ws was closed during the async gap, leave() already
|
|
365
|
+
// cleaned local state -- undo any Redis write and bail out.
|
|
366
|
+
if (!wsTopics.has(ws)) return;
|
|
367
|
+
|
|
368
|
+
// all will hold the raw hash entries for the initial list.
|
|
369
|
+
// When prevCount === 0 the Lua script returns them inline
|
|
370
|
+
// (no separate HGETALL needed). Otherwise we coalesce so
|
|
371
|
+
// N connections joining the same topic share one round trip.
|
|
372
|
+
let all;
|
|
373
|
+
|
|
336
374
|
if (prevCount === 0) {
|
|
337
|
-
// New user on this instance --
|
|
375
|
+
// New user on this instance -- single Lua call does
|
|
376
|
+
// HSET + EXPIRE + dedup check + returns all entries.
|
|
338
377
|
const now = Date.now();
|
|
339
378
|
const field = compoundField(key);
|
|
340
379
|
const value = JSON.stringify({ data, ts: now });
|
|
341
380
|
const suffix = '|' + key;
|
|
342
|
-
const
|
|
381
|
+
const result = await redis.eval(
|
|
343
382
|
JOIN_SCRIPT, 1, hashKey(topic),
|
|
344
|
-
field, value, suffix, now, presenceTtlMs
|
|
383
|
+
field, value, suffix, now, presenceTtlMs, presenceTtl
|
|
345
384
|
);
|
|
346
|
-
|
|
385
|
+
|
|
386
|
+
// Guard: if ws closed while awaiting Redis, remove the field
|
|
387
|
+
// we just set and bail -- leave() already cleaned local state.
|
|
388
|
+
if (!wsTopics.has(ws)) {
|
|
389
|
+
await redis.hdel(hashKey(topic), field).catch(() => {});
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Parse result: [isFirst, field1, val1, field2, val2, ...]
|
|
394
|
+
const isFirstGlobally = result[0];
|
|
395
|
+
all = {};
|
|
396
|
+
for (let i = 1; i < result.length; i += 2) {
|
|
397
|
+
all[result[i]] = result[i + 1];
|
|
398
|
+
}
|
|
347
399
|
|
|
348
400
|
if (isFirstGlobally === 1) {
|
|
349
|
-
// No other live instance has this user -- broadcast join
|
|
350
401
|
const payload = { key, data };
|
|
351
402
|
platform.publish('__presence:' + topic, 'join', payload);
|
|
352
403
|
await publishEvent(topic, 'join', payload);
|
|
353
404
|
}
|
|
405
|
+
} else {
|
|
406
|
+
all = await coalesceHgetall(topic);
|
|
354
407
|
}
|
|
355
408
|
|
|
356
409
|
// Subscribe ws to presence channel (may have closed during async gap)
|
|
@@ -361,7 +414,6 @@ export function createPresence(client, options = {}) {
|
|
|
361
414
|
}
|
|
362
415
|
|
|
363
416
|
// Send current list to this connection
|
|
364
|
-
const all = await redis.hgetall(hashKey(topic));
|
|
365
417
|
const entries = parseEntries(all);
|
|
366
418
|
const list = [];
|
|
367
419
|
for (const [userKey, entry] of entries) {
|
|
@@ -446,7 +498,18 @@ export function createPresence(client, options = {}) {
|
|
|
446
498
|
}
|
|
447
499
|
|
|
448
500
|
// --- Leave all topics ---
|
|
501
|
+
// Phase 1: Synchronous cleanup of ALL local state before any async
|
|
502
|
+
// work. This prevents the heartbeat from refreshing dead entries and
|
|
503
|
+
// lets concurrent join() calls detect the closed ws via wsTopics.
|
|
449
504
|
const connTopics = wsTopics.get(ws);
|
|
505
|
+
wsTopics.delete(ws);
|
|
506
|
+
|
|
507
|
+
const syncTopics = syncObservers.get(ws);
|
|
508
|
+
syncObservers.delete(ws);
|
|
509
|
+
|
|
510
|
+
/** @type {Array<{ topic: string, key: string, data: Record<string, any>, needsUnsub: boolean }>} */
|
|
511
|
+
const pendingLeaves = [];
|
|
512
|
+
|
|
450
513
|
if (connTopics) {
|
|
451
514
|
for (const [topic, { key, data }] of connTopics) {
|
|
452
515
|
const counts = localCounts.get(topic);
|
|
@@ -456,65 +519,72 @@ export function createPresence(client, options = {}) {
|
|
|
456
519
|
if (current <= 1) {
|
|
457
520
|
counts.delete(key);
|
|
458
521
|
|
|
459
|
-
// Clean up local data for this user on this topic
|
|
460
522
|
const topicData = localData.get(topic);
|
|
461
523
|
if (topicData) {
|
|
462
524
|
topicData.delete(key);
|
|
463
525
|
if (topicData.size === 0) localData.delete(topic);
|
|
464
526
|
}
|
|
465
527
|
|
|
528
|
+
let needsUnsub = false;
|
|
466
529
|
if (counts.size === 0) {
|
|
467
530
|
localCounts.delete(topic);
|
|
468
531
|
activeTopics.delete(topic);
|
|
469
|
-
// Unsubscribe from Redis channel if no sync observers remain
|
|
470
532
|
if (!syncCounts.has(topic)) {
|
|
471
|
-
|
|
533
|
+
needsUnsub = true;
|
|
472
534
|
}
|
|
473
535
|
}
|
|
474
536
|
|
|
475
|
-
|
|
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
|
-
}
|
|
537
|
+
pendingLeaves.push({ topic, key, data, needsUnsub });
|
|
490
538
|
} else {
|
|
491
539
|
counts.set(key, current - 1);
|
|
492
540
|
}
|
|
493
541
|
}
|
|
494
|
-
wsTopics.delete(ws);
|
|
495
542
|
}
|
|
496
543
|
|
|
497
|
-
// Handle sync-only observers
|
|
498
|
-
const syncTopics = syncObservers.get(ws);
|
|
499
544
|
if (syncTopics) {
|
|
500
545
|
for (const topic of syncTopics) {
|
|
501
546
|
const count = (syncCounts.get(topic) || 1) - 1;
|
|
502
547
|
if (count <= 0) {
|
|
503
548
|
syncCounts.delete(topic);
|
|
504
|
-
// Unsubscribe from Redis channel if no joined users remain
|
|
505
|
-
if (!localCounts.has(topic)) {
|
|
506
|
-
await unsubscribeFromTopic(topic);
|
|
507
|
-
}
|
|
508
549
|
} else {
|
|
509
550
|
syncCounts.set(topic, count);
|
|
510
551
|
}
|
|
511
552
|
}
|
|
512
|
-
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Phase 2: Async Redis cleanup. Local state is already clean so
|
|
556
|
+
// the heartbeat will not refresh any of these entries.
|
|
557
|
+
for (const { topic, key, data, needsUnsub } of pendingLeaves) {
|
|
558
|
+
if (needsUnsub) {
|
|
559
|
+
await unsubscribeFromTopic(topic);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const field = compoundField(key);
|
|
563
|
+
const suffix = '|' + key;
|
|
564
|
+
const now = Date.now();
|
|
565
|
+
const userGone = await redis.eval(
|
|
566
|
+
LEAVE_SCRIPT, 1, hashKey(topic), field, suffix, now, presenceTtlMs
|
|
567
|
+
);
|
|
568
|
+
|
|
569
|
+
if (userGone === 1) {
|
|
570
|
+
const payload = { key, data };
|
|
571
|
+
platform.publish('__presence:' + topic, 'leave', payload);
|
|
572
|
+
await publishEvent(topic, 'leave', payload);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// Async unsubscribe for sync-only observer topics
|
|
577
|
+
if (syncTopics) {
|
|
578
|
+
for (const topic of syncTopics) {
|
|
579
|
+
if (!syncCounts.has(topic) && !localCounts.has(topic)) {
|
|
580
|
+
await unsubscribeFromTopic(topic);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
513
583
|
}
|
|
514
584
|
},
|
|
515
585
|
|
|
516
586
|
async sync(ws, topic, platform) {
|
|
517
|
-
const all = await
|
|
587
|
+
const all = await coalesceHgetall(topic);
|
|
518
588
|
const presenceTopic = '__presence:' + topic;
|
|
519
589
|
const entries = parseEntries(all);
|
|
520
590
|
const list = [];
|