svelte-realtime 0.5.1 → 0.5.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 (3) hide show
  1. package/client.js +12 -1
  2. package/package.json +3 -3
  3. package/server.js +162 -27
package/client.js CHANGED
@@ -2801,7 +2801,18 @@ function _createStream(path, options, dynamicArgs, initialSchemaVersion) {
2801
2801
  }
2802
2802
 
2803
2803
  if (_optimisticQueue.length === 0) {
2804
- _serverValue = Array.isArray(currentValue) ? currentValue.slice() : currentValue;
2804
+ // Default to [] for array-merge types when the loader has not
2805
+ // resolved yet (currentValue still undefined). Without this,
2806
+ // an early-click optimistic change that does e.g.
2807
+ // `(current) => [...current, item]` throws synchronously on
2808
+ // `[...undefined]`, the mutate rejects, and the user sees
2809
+ // nothing land. The eventual loader response replaces
2810
+ // currentValue cleanly via the response path, and the still-
2811
+ // in-flight optimistic entry replays against the new
2812
+ // _serverValue when the server's confirming event arrives.
2813
+ const isArrayMerge = merge === 'crud' || merge === 'presence' || merge === 'cursor' || merge === 'latest';
2814
+ const baseline = currentValue === undefined && isArrayMerge ? [] : currentValue;
2815
+ _serverValue = Array.isArray(baseline) ? baseline.slice() : baseline;
2805
2816
  _serverIndex = new Map(_index);
2806
2817
  }
2807
2818
  const entry = { change: optimisticChange, optimisticKey, serverConfirmed: false };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-realtime",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "publishConfig": {
5
5
  "tag": "latest"
6
6
  },
@@ -89,12 +89,12 @@
89
89
  "peerDependencies": {
90
90
  "@sveltejs/kit": "^2.0.0",
91
91
  "svelte": "^4.0.0 || ^5.0.0",
92
- "svelte-adapter-uws": "^0.5.0"
92
+ "svelte-adapter-uws": "^0.5.1"
93
93
  },
94
94
  "devDependencies": {
95
95
  "@playwright/test": "^1.59.1",
96
96
  "fast-check": "^4.7.0",
97
- "svelte-adapter-uws-extensions": "^0.5.0",
97
+ "svelte-adapter-uws-extensions": "^0.5.1",
98
98
  "vitest": "^4.0.18"
99
99
  },
100
100
  "keywords": [
package/server.js CHANGED
@@ -4518,6 +4518,131 @@ export function _getIdentityKey(ctx) {
4518
4518
  return guestId;
4519
4519
  }
4520
4520
 
4521
+ /**
4522
+ * Cluster-shared presence-ref store. Used by `live.room({ presence })` when
4523
+ * `platform.redis` is wired by the host app (raw ioredis-shaped client with
4524
+ * hincrby / hset / hdel / hgetall / expire). Falls back to the in-process
4525
+ * `_presenceRef` Map when absent, preserving zero-config dev behavior.
4526
+ *
4527
+ * Storage layout (one Redis HASH per topic):
4528
+ * key: `__live-presence:{topic}`
4529
+ * fields: 'c:{userKey}' -> integer (cluster-wide subscriber count)
4530
+ * 'd:{userKey}' -> JSON-stringified presence data
4531
+ *
4532
+ * Cluster semantics:
4533
+ * - First replica to take a user's count from 0->1 publishes 'join' (cluster-
4534
+ * wide isFirst). Subsequent replicas just increment.
4535
+ * - Last replica to take a user's count from 1->0 publishes 'leave'.
4536
+ * - Loader returns the full HGETALL view so any replica's new subscriber sees
4537
+ * all users from all replicas, not just the locally-attached ones.
4538
+ *
4539
+ * Per-replica refcount (multiple tabs of the same user on the same replica)
4540
+ * and grace-timer behavior stay in the existing _presenceRef Map. The cluster
4541
+ * helpers only fire at the local 0<->1 transitions, so a quick reconnect
4542
+ * burst on a single replica doesn't churn the cluster counter.
4543
+ */
4544
+ const _PRESENCE_KEY_PREFIX = '__live-presence:';
4545
+ const _PRESENCE_TTL_SEC = 3600;
4546
+
4547
+ /**
4548
+ * Bump the cluster-wide count for (topic, key). Returns isFirst=true when
4549
+ * this acquire took the count from 0 to 1 cluster-wide, signaling that the
4550
+ * caller should publish a 'join' event. Falls through to a no-op stub when
4551
+ * platform.redis is missing (single-replica dev path).
4552
+ */
4553
+ async function _clusterPresenceAcquire(platform, topic, key, data) {
4554
+ const redis = platform && platform.redis;
4555
+ if (!redis || typeof redis.hincrby !== 'function') return { isFirst: true };
4556
+ const hKey = _PRESENCE_KEY_PREFIX + topic;
4557
+ const countField = 'c:' + key;
4558
+ const dataField = 'd:' + key;
4559
+ let serialized;
4560
+ try { serialized = JSON.stringify(data); } catch { serialized = 'null'; }
4561
+ try {
4562
+ // Write the data field BEFORE bumping the count. A concurrent
4563
+ // `_clusterPresenceList` (e.g. the same user's own :presence stream
4564
+ // loader racing the data stream's acquire) reads data fields only;
4565
+ // if HINCRBY ran first the loader could observe a count without a
4566
+ // data field and return an empty roster, missing the user's own
4567
+ // entry. Writing data first guarantees the loader sees the entry
4568
+ // as soon as the count is visible.
4569
+ await redis.hset(hKey, dataField, serialized);
4570
+ const count = await redis.hincrby(hKey, countField, 1);
4571
+ if (count === 1) {
4572
+ await redis.expire(hKey, _PRESENCE_TTL_SEC);
4573
+ return { isFirst: true };
4574
+ }
4575
+ // Refresh TTL on activity so the hash doesn't expire under a busy room.
4576
+ try { await redis.expire(hKey, _PRESENCE_TTL_SEC); } catch { /* best-effort */ }
4577
+ return { isFirst: false };
4578
+ } catch {
4579
+ // Redis blip: treat as first so we publish a join. Worst case a duplicate
4580
+ // 'join' merges idempotently by key on the client.
4581
+ return { isFirst: true };
4582
+ }
4583
+ }
4584
+
4585
+ /**
4586
+ * Decrement the cluster-wide count for (topic, key). Returns isLast=true when
4587
+ * this release took the count from 1 to 0 cluster-wide, signaling that the
4588
+ * caller should publish a 'leave' event. Falls through to isLast=true when
4589
+ * platform.redis is missing (single-replica dev path treats every grace-timer
4590
+ * expiry as the final leave).
4591
+ */
4592
+ async function _clusterPresenceRelease(platform, topic, key) {
4593
+ const redis = platform && platform.redis;
4594
+ if (!redis || typeof redis.hincrby !== 'function') return { isLast: true };
4595
+ const hKey = _PRESENCE_KEY_PREFIX + topic;
4596
+ const countField = 'c:' + key;
4597
+ const dataField = 'd:' + key;
4598
+ try {
4599
+ const count = await redis.hincrby(hKey, countField, -1);
4600
+ if (count <= 0) {
4601
+ await redis.hdel(hKey, countField, dataField);
4602
+ return { isLast: true };
4603
+ }
4604
+ return { isLast: false };
4605
+ } catch {
4606
+ // Redis blip: assume last so we publish a leave. A late observer reading
4607
+ // HGETALL might still see the stale field until the next acquire repairs
4608
+ // the count (or the hash TTL expires).
4609
+ return { isLast: true };
4610
+ }
4611
+ }
4612
+
4613
+ /**
4614
+ * Return the cluster-wide presence roster for a topic as `[{key, data}, ...]`.
4615
+ * Falls through to the local _presenceRef iteration when platform.redis is
4616
+ * missing.
4617
+ */
4618
+ async function _clusterPresenceList(platform, topic) {
4619
+ const redis = platform && platform.redis;
4620
+ if (!redis || typeof redis.hgetall !== 'function') {
4621
+ const prefix = topic + '\0';
4622
+ const out = [];
4623
+ for (const [refKey, ref] of _presenceRef) {
4624
+ if (!refKey.startsWith(prefix)) continue;
4625
+ if (ref.data == null) continue;
4626
+ out.push({ key: refKey.slice(prefix.length), data: ref.data });
4627
+ }
4628
+ return out;
4629
+ }
4630
+ const hKey = _PRESENCE_KEY_PREFIX + topic;
4631
+ try {
4632
+ const all = await redis.hgetall(hKey);
4633
+ const out = [];
4634
+ for (const field of Object.keys(all)) {
4635
+ if (field.length < 3 || field[0] !== 'd' || field[1] !== ':') continue;
4636
+ const key = field.slice(2);
4637
+ try { out.push({ key, data: JSON.parse(all[field]) }); }
4638
+ catch { /* skip corrupt entry */ }
4639
+ }
4640
+ return out;
4641
+ } catch {
4642
+ return [];
4643
+ }
4644
+ }
4645
+
4521
4646
  live.room = function room(config) {
4522
4647
  const {
4523
4648
  topic: topicFn,
@@ -4562,7 +4687,7 @@ live.room = function room(config) {
4562
4687
  }, {
4563
4688
  merge: mergeMode,
4564
4689
  key: keyField,
4565
- onSubscribe: presenceFn ? (ctx, topic) => {
4690
+ onSubscribe: presenceFn ? async (ctx, topic) => {
4566
4691
  const userId = _getIdentityKey(ctx);
4567
4692
  const refKey = topic + '\0' + userId;
4568
4693
 
@@ -4582,7 +4707,13 @@ live.room = function room(config) {
4582
4707
  if (r.timer) {
4583
4708
  clearTimeout(r.timer);
4584
4709
  const [t, u] = k.split('\0');
4585
- ctx.publish(t + ':presence', 'leave', { key: u });
4710
+ // Cluster release runs eagerly here too: an evicted entry
4711
+ // would otherwise leak a phantom counter on Redis.
4712
+ _clusterPresenceRelease(ctx.platform, t, u).then((res) => {
4713
+ if (res.isLast) {
4714
+ ctx.publish(t + ':presence', 'leave', { key: u });
4715
+ }
4716
+ }).catch(() => {});
4586
4717
  if (onLeave) {
4587
4718
  Promise.resolve().then(() => onLeave(ctx, t)).catch(() => {});
4588
4719
  }
@@ -4595,7 +4726,7 @@ live.room = function room(config) {
4595
4726
  console.warn(
4596
4727
  "[svelte-realtime] presence-ref map reached MAX_PRESENCE_REF=" + _maxPresenceRef +
4597
4728
  "; new joiners will not appear in any subscriber's roster until existing entries clear.\n" +
4598
- " For multi-instance deploys, wire `platform.presence` (e.g. svelte-adapter-uws-extensions/presence) so this in-memory fallback is bypassed.\n" +
4729
+ " For multi-instance deploys, wire `platform.redis` (raw ioredis client) so the cluster-shared Redis presence is bypasses the in-memory cap.\n" +
4599
4730
  " See: https://svti.me/presence"
4600
4731
  );
4601
4732
  }
@@ -4609,8 +4740,14 @@ live.room = function room(config) {
4609
4740
  // to the :presence topic.
4610
4741
  const presenceData = presenceFn(ctx);
4611
4742
  _presenceRef.set(refKey, { count: 1, timer: null, data: presenceData });
4743
+ // Cluster transition: bump shared count; only the first replica to
4744
+ // reach 1 publishes 'join'. With no platform.redis the helper
4745
+ // returns isFirst=true unconditionally, matching the in-memory path.
4612
4746
  if (presenceData) {
4613
- ctx.publish(topic + ':presence', 'join', { key: userId, data: presenceData });
4747
+ const { isFirst } = await _clusterPresenceAcquire(ctx.platform, topic, userId, presenceData);
4748
+ if (isFirst) {
4749
+ ctx.publish(topic + ':presence', 'join', { key: userId, data: presenceData });
4750
+ }
4614
4751
  }
4615
4752
  } : undefined,
4616
4753
  onUnsubscribe: presenceFn ? (ctx, topic) => {
@@ -4623,11 +4760,17 @@ live.room = function room(config) {
4623
4760
  ref.count--;
4624
4761
  if (ref.count > 0) return;
4625
4762
 
4626
- // On rollback (failed stream init), skip grace and leave immediately
4763
+ // On rollback (failed stream init), skip grace and release
4764
+ // immediately. Cluster release decides whether this was the LAST
4765
+ // subscriber across the cluster - only then do we publish 'leave'.
4627
4766
  if (ctx.ws && _rollingBack.has(ctx.ws)) {
4628
4767
  if (ref.timer) clearTimeout(ref.timer);
4629
4768
  _presenceRef.delete(refKey);
4630
- ctx.publish(topic + ':presence', 'leave', { key: userId });
4769
+ _clusterPresenceRelease(ctx.platform, topic, userId).then((res) => {
4770
+ if (res.isLast) {
4771
+ ctx.publish(topic + ':presence', 'leave', { key: userId });
4772
+ }
4773
+ }).catch(() => {});
4631
4774
  if (onLeave) {
4632
4775
  Promise.resolve().then(() => onLeave(ctx, topic)).catch(() => {});
4633
4776
  }
@@ -4636,7 +4779,11 @@ live.room = function room(config) {
4636
4779
 
4637
4780
  ref.timer = setTimeout(() => {
4638
4781
  _presenceRef.delete(refKey);
4639
- ctx.publish(topic + ':presence', 'leave', { key: userId });
4782
+ _clusterPresenceRelease(ctx.platform, topic, userId).then((res) => {
4783
+ if (res.isLast) {
4784
+ ctx.publish(topic + ':presence', 'leave', { key: userId });
4785
+ }
4786
+ }).catch(() => {});
4640
4787
  if (onLeave) {
4641
4788
  Promise.resolve().then(() => onLeave(ctx, topic)).catch(() => {});
4642
4789
  }
@@ -4658,24 +4805,12 @@ live.room = function room(config) {
4658
4805
  async (ctx, ...args) => {
4659
4806
  if (guardFn) await guardFn(ctx, ...args);
4660
4807
  const dataTopic = topicFn(ctx, ...args);
4661
- if (ctx.platform.presence && typeof ctx.platform.presence.list === 'function') {
4662
- return ctx.platform.presence.list(dataTopic + ':presence');
4663
- }
4664
- // In-memory fallback (zero-config dev path). Without this,
4665
- // the data stream's onSubscribe publishes the join BEFORE the
4666
- // user subscribes to :presence, so a user alone in a room
4667
- // would never see its own join. Reconstructs the roster from
4668
- // `_presenceRef`. Production wires `platform.presence.list`
4669
- // (Redis-backed) for cluster-wide consistency and bypasses
4670
- // this branch.
4671
- const prefix = dataTopic + '\0';
4672
- const result = [];
4673
- for (const [refKey, ref] of _presenceRef) {
4674
- if (!refKey.startsWith(prefix)) continue;
4675
- if (ref.data == null) continue;
4676
- result.push({ key: refKey.slice(prefix.length), data: ref.data });
4677
- }
4678
- return result;
4808
+ // Cluster-shared roster when `platform.redis` is wired; falls
4809
+ // back to the local _presenceRef iteration otherwise. The
4810
+ // loader reconstructs the roster even when this user's join
4811
+ // was published before they subscribed to :presence (the live
4812
+ // merge takes over from here).
4813
+ return _clusterPresenceList(ctx.platform, dataTopic);
4679
4814
  },
4680
4815
  { merge: 'presence' }
4681
4816
  );
@@ -5986,12 +6121,12 @@ live.webhook = function webhook(topic, config) {
5986
6121
  async handle(req) {
5987
6122
  let event;
5988
6123
  try {
5989
- event = config.verify({ body: req.body, headers: req.headers });
6124
+ event = await config.verify({ body: req.body, headers: req.headers });
5990
6125
  } catch {
5991
6126
  return { status: 400, body: 'Verification failed' };
5992
6127
  }
5993
6128
 
5994
- const mapped = config.transform(event);
6129
+ const mapped = await config.transform(event);
5995
6130
  if (!mapped) return { status: 200, body: 'Ignored' };
5996
6131
 
5997
6132
  if (req.platform) {