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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. 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.2",
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 and check if
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 1 if this is the first live instance for the user (broadcast join),
34
- * 0 if another live instance already has this user.
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
- return 0
57
+ isFirst = 0
58
+ break
53
59
  end
54
60
  end
55
61
  end
56
- return 1
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 -- check if globally new via atomic Lua
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 isFirstGlobally = await redis.eval(
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
- await redis.expire(hashKey(topic), presenceTtl);
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
- await unsubscribeFromTopic(topic);
533
+ needsUnsub = true;
472
534
  }
473
535
  }
474
536
 
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
- }
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
- syncObservers.delete(ws);
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 redis.hgetall(hashKey(topic));
587
+ const all = await coalesceHgetall(topic);
518
588
  const presenceTopic = '__presence:' + topic;
519
589
  const entries = parseEntries(all);
520
590
  const list = [];