svelte-adapter-uws-extensions 0.1.2 → 0.1.3

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 +55 -26
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.3",
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
@@ -333,6 +333,10 @@ export function createPresence(client, options = {}) {
333
333
  // Subscribe to cross-instance events for this topic
334
334
  await subscribeToTopic(topic, platform);
335
335
 
336
+ // Guard: if ws was closed during the async gap, leave() already
337
+ // cleaned local state -- undo any Redis write and bail out.
338
+ if (!wsTopics.has(ws)) return;
339
+
336
340
  if (prevCount === 0) {
337
341
  // New user on this instance -- check if globally new via atomic Lua
338
342
  const now = Date.now();
@@ -345,6 +349,13 @@ export function createPresence(client, options = {}) {
345
349
  );
346
350
  await redis.expire(hashKey(topic), presenceTtl);
347
351
 
352
+ // Guard: if ws closed while awaiting Redis, remove the field
353
+ // we just set and bail -- leave() already cleaned local state.
354
+ if (!wsTopics.has(ws)) {
355
+ await redis.hdel(hashKey(topic), field).catch(() => {});
356
+ return;
357
+ }
358
+
348
359
  if (isFirstGlobally === 1) {
349
360
  // No other live instance has this user -- broadcast join
350
361
  const payload = { key, data };
@@ -446,7 +457,18 @@ export function createPresence(client, options = {}) {
446
457
  }
447
458
 
448
459
  // --- Leave all topics ---
460
+ // Phase 1: Synchronous cleanup of ALL local state before any async
461
+ // work. This prevents the heartbeat from refreshing dead entries and
462
+ // lets concurrent join() calls detect the closed ws via wsTopics.
449
463
  const connTopics = wsTopics.get(ws);
464
+ wsTopics.delete(ws);
465
+
466
+ const syncTopics = syncObservers.get(ws);
467
+ syncObservers.delete(ws);
468
+
469
+ /** @type {Array<{ topic: string, key: string, data: Record<string, any>, needsUnsub: boolean }>} */
470
+ const pendingLeaves = [];
471
+
450
472
  if (connTopics) {
451
473
  for (const [topic, { key, data }] of connTopics) {
452
474
  const counts = localCounts.get(topic);
@@ -456,60 +478,67 @@ export function createPresence(client, options = {}) {
456
478
  if (current <= 1) {
457
479
  counts.delete(key);
458
480
 
459
- // Clean up local data for this user on this topic
460
481
  const topicData = localData.get(topic);
461
482
  if (topicData) {
462
483
  topicData.delete(key);
463
484
  if (topicData.size === 0) localData.delete(topic);
464
485
  }
465
486
 
487
+ let needsUnsub = false;
466
488
  if (counts.size === 0) {
467
489
  localCounts.delete(topic);
468
490
  activeTopics.delete(topic);
469
- // Unsubscribe from Redis channel if no sync observers remain
470
491
  if (!syncCounts.has(topic)) {
471
- await unsubscribeFromTopic(topic);
492
+ needsUnsub = true;
472
493
  }
473
494
  }
474
495
 
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
- }
496
+ pendingLeaves.push({ topic, key, data, needsUnsub });
490
497
  } else {
491
498
  counts.set(key, current - 1);
492
499
  }
493
500
  }
494
- wsTopics.delete(ws);
495
501
  }
496
502
 
497
- // Handle sync-only observers
498
- const syncTopics = syncObservers.get(ws);
499
503
  if (syncTopics) {
500
504
  for (const topic of syncTopics) {
501
505
  const count = (syncCounts.get(topic) || 1) - 1;
502
506
  if (count <= 0) {
503
507
  syncCounts.delete(topic);
504
- // Unsubscribe from Redis channel if no joined users remain
505
- if (!localCounts.has(topic)) {
506
- await unsubscribeFromTopic(topic);
507
- }
508
508
  } else {
509
509
  syncCounts.set(topic, count);
510
510
  }
511
511
  }
512
- syncObservers.delete(ws);
512
+ }
513
+
514
+ // Phase 2: Async Redis cleanup. Local state is already clean so
515
+ // the heartbeat will not refresh any of these entries.
516
+ for (const { topic, key, data, needsUnsub } of pendingLeaves) {
517
+ if (needsUnsub) {
518
+ await unsubscribeFromTopic(topic);
519
+ }
520
+
521
+ const field = compoundField(key);
522
+ const suffix = '|' + key;
523
+ const now = Date.now();
524
+ const userGone = await redis.eval(
525
+ LEAVE_SCRIPT, 1, hashKey(topic), field, suffix, now, presenceTtlMs
526
+ );
527
+
528
+ if (userGone === 1) {
529
+ const payload = { key, data };
530
+ platform.publish('__presence:' + topic, 'leave', payload);
531
+ await publishEvent(topic, 'leave', payload);
532
+ }
533
+ }
534
+
535
+ // Async unsubscribe for sync-only observer topics
536
+ if (syncTopics) {
537
+ for (const topic of syncTopics) {
538
+ if (!syncCounts.has(topic) && !localCounts.has(topic)) {
539
+ await unsubscribeFromTopic(topic);
540
+ }
541
+ }
513
542
  }
514
543
  },
515
544