svelte-realtime 0.5.2 → 0.5.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 (3) hide show
  1. package/package.json +3 -3
  2. package/server.d.ts +17 -0
  3. package/server.js +214 -48
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-realtime",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
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.d.ts CHANGED
@@ -619,6 +619,23 @@ export const combineMerge: <T extends object>(...buckets: Array<T | undefined>)
619
619
  */
620
620
  export const MAX_AGGREGATE_BUCKETS: number;
621
621
 
622
+ /**
623
+ * Maximum entries in the per-userId connection registry that backs
624
+ * `live.push({ userId })` / `live.notify`. Saturation behaviour is
625
+ * WARN-once and skip new registrations; existing entries continue to
626
+ * route. Default 10,000,000.
627
+ */
628
+ export const MAX_PUSH_REGISTRY: number;
629
+
630
+ /**
631
+ * Maximum entries in the in-memory presence-ref map that backs
632
+ * `live.room({ presence })` on the single-instance / dev path. When
633
+ * `platform.presence.list` is wired (e.g. via the Redis presence
634
+ * extension), this cap is bypassed. Saturation behaviour is WARN-once
635
+ * and skip new entries. Default 1,000,000.
636
+ */
637
+ export const MAX_PRESENCE_REF: number;
638
+
622
639
  export type TopicEntry = string | ((...args: any[]) => string);
623
640
 
624
641
  /**
package/server.js CHANGED
@@ -982,8 +982,16 @@ const _publishRateConfig = {
982
982
  const _publishRateWarned = new Set();
983
983
  /** @type {WeakMap<any, ReturnType<typeof setInterval>>} */
984
984
  const _publishRateSamplers = new WeakMap();
985
- /** @type {Set<any>} Tracks platforms with active samplers so reset can clear them. */
986
- const _publishRateActivePlatforms = new Set();
985
+ /**
986
+ * Bumped by `_resetPublishRateWarning` and by `live.publishRateWarning(false)`.
987
+ * Each sampler captures its activation-time epoch and self-clears on the next
988
+ * fire when the epoch no longer matches. Pattern used in place of the prior
989
+ * strong-reference `Set<platform>` because that Set held every dev-mode
990
+ * platform alive across the process lifetime, defeating the WeakMap above and
991
+ * leaking the platform + all captured helpers/closures on every per-call
992
+ * wrap pattern (e.g. cron tick wrapping a fresh `bus.wrap(platform)` per fire).
993
+ */
994
+ let _publishRateEpoch = 0;
987
995
 
988
996
  /**
989
997
  * Activate the dev-mode publish-rate warning sampler for one platform.
@@ -993,6 +1001,15 @@ const _publishRateActivePlatforms = new Set();
993
1001
  * underscore prefix so tests can drive activation deterministically
994
1002
  * without going through the async RPC path.
995
1003
  *
1004
+ * The sampler closure must NOT strongly capture `platform`. Node's timer
1005
+ * queue holds the `setInterval` Timer alive until clearInterval fires; if
1006
+ * the closure captured `platform` directly, every platform ever passed in
1007
+ * would stay reachable forever, leaking the entire helpers+closures graph.
1008
+ * The `WeakRef` wrapper here breaks that retention: on each tick the
1009
+ * sampler derefs, and a null deref (platform GC'd elsewhere) self-clears
1010
+ * the timer. Net effect: at most one stale tick after platform GC, then
1011
+ * the entry vanishes.
1012
+ *
996
1013
  * @param {any} platform
997
1014
  */
998
1015
  export function _activatePublishRateWarning(platform) {
@@ -1000,8 +1017,22 @@ export function _activatePublishRateWarning(platform) {
1000
1017
  if (!_publishRateConfig.enabled) return;
1001
1018
  if (_publishRateSamplers.has(platform)) return;
1002
1019
  if (typeof platform?.pressure !== 'object' || platform.pressure === null) return;
1020
+ const platformRef = new WeakRef(platform);
1021
+ const epoch = _publishRateEpoch;
1003
1022
  const sampler = setInterval(() => {
1004
- const top = platform.pressure?.topPublishers;
1023
+ // Self-clear on disable / reset / platform-GC. Any of the three
1024
+ // makes the sampler stale; clearInterval here lets Node drop the
1025
+ // Timer from the queue on the next event-loop turn.
1026
+ if (!_publishRateConfig.enabled || epoch !== _publishRateEpoch) {
1027
+ clearInterval(sampler);
1028
+ return;
1029
+ }
1030
+ const p = platformRef.deref();
1031
+ if (!p) {
1032
+ clearInterval(sampler);
1033
+ return;
1034
+ }
1035
+ const top = p.pressure?.topPublishers;
1005
1036
  if (!Array.isArray(top)) return;
1006
1037
  for (const entry of top) {
1007
1038
  if (!entry || typeof entry.topic !== 'string') continue;
@@ -1029,24 +1060,24 @@ export function _activatePublishRateWarning(platform) {
1029
1060
  }, _publishRateConfig.intervalMs);
1030
1061
  if (typeof sampler.unref === 'function') sampler.unref();
1031
1062
  _publishRateSamplers.set(platform, sampler);
1032
- _publishRateActivePlatforms.add(platform);
1033
1063
  }
1034
1064
 
1035
1065
  /**
1036
1066
  * Reset the dev-mode publish-rate warning state. Tests only. Clears the
1037
- * one-shot warned set, stops every active sampler, and removes the
1038
- * activation marker so the next ctx-helpers cache miss for a previously
1039
- * seen platform re-attaches a sampler. Does NOT reset config back to
1040
- * defaults - tests that mutate config should restore it themselves.
1067
+ * one-shot warned set and bumps the per-process epoch so every existing
1068
+ * sampler self-clears on its next fire. Stale samplers stop within one
1069
+ * `intervalMs` of the reset (default 5s); a same-platform re-activation
1070
+ * after reset gets a fresh sampler because the old WeakMap entry's
1071
+ * sampler will self-clear on its next tick and never write state again.
1072
+ *
1073
+ * If a test needs synchronous teardown (e.g. to assert no extra warns
1074
+ * fire after reset within the same tick), call this AND assert that
1075
+ * `_publishRateConfig.enabled` is false; samplers short-circuit on the
1076
+ * disabled flag without doing any work.
1041
1077
  */
1042
1078
  export function _resetPublishRateWarning() {
1043
1079
  _publishRateWarned.clear();
1044
- for (const platform of _publishRateActivePlatforms) {
1045
- const sampler = _publishRateSamplers.get(platform);
1046
- if (sampler) clearInterval(sampler);
1047
- _publishRateSamplers.delete(platform);
1048
- }
1049
- _publishRateActivePlatforms.clear();
1080
+ _publishRateEpoch++;
1050
1081
  }
1051
1082
 
1052
1083
  /**
@@ -2183,13 +2214,13 @@ export function _resetRateLimits() {
2183
2214
  live.publishRateWarning = function publishRateWarning(config) {
2184
2215
  if (config === false) {
2185
2216
  _publishRateConfig.enabled = false;
2186
- // Stop active samplers so disable takes effect immediately.
2187
- for (const platform of _publishRateActivePlatforms) {
2188
- const sampler = _publishRateSamplers.get(platform);
2189
- if (sampler) clearInterval(sampler);
2190
- _publishRateSamplers.delete(platform);
2191
- }
2192
- _publishRateActivePlatforms.clear();
2217
+ // Existing samplers self-clear on their next fire via the
2218
+ // `_publishRateConfig.enabled` check at the top of the callback.
2219
+ // Bumping the epoch is belt-and-suspenders: a sampler whose
2220
+ // callback is in flight when the flag flips still sees the
2221
+ // epoch mismatch on its NEXT scheduled fire. Worst case is one
2222
+ // stale interval (default 5s) before the timer goes idle.
2223
+ _publishRateEpoch++;
2193
2224
  return;
2194
2225
  }
2195
2226
  if (config === undefined || config === true) {
@@ -4518,6 +4549,131 @@ export function _getIdentityKey(ctx) {
4518
4549
  return guestId;
4519
4550
  }
4520
4551
 
4552
+ /**
4553
+ * Cluster-shared presence-ref store. Used by `live.room({ presence })` when
4554
+ * `platform.redis` is wired by the host app (raw ioredis-shaped client with
4555
+ * hincrby / hset / hdel / hgetall / expire). Falls back to the in-process
4556
+ * `_presenceRef` Map when absent, preserving zero-config dev behavior.
4557
+ *
4558
+ * Storage layout (one Redis HASH per topic):
4559
+ * key: `__live-presence:{topic}`
4560
+ * fields: 'c:{userKey}' -> integer (cluster-wide subscriber count)
4561
+ * 'd:{userKey}' -> JSON-stringified presence data
4562
+ *
4563
+ * Cluster semantics:
4564
+ * - First replica to take a user's count from 0->1 publishes 'join' (cluster-
4565
+ * wide isFirst). Subsequent replicas just increment.
4566
+ * - Last replica to take a user's count from 1->0 publishes 'leave'.
4567
+ * - Loader returns the full HGETALL view so any replica's new subscriber sees
4568
+ * all users from all replicas, not just the locally-attached ones.
4569
+ *
4570
+ * Per-replica refcount (multiple tabs of the same user on the same replica)
4571
+ * and grace-timer behavior stay in the existing _presenceRef Map. The cluster
4572
+ * helpers only fire at the local 0<->1 transitions, so a quick reconnect
4573
+ * burst on a single replica doesn't churn the cluster counter.
4574
+ */
4575
+ const _PRESENCE_KEY_PREFIX = '__live-presence:';
4576
+ const _PRESENCE_TTL_SEC = 3600;
4577
+
4578
+ /**
4579
+ * Bump the cluster-wide count for (topic, key). Returns isFirst=true when
4580
+ * this acquire took the count from 0 to 1 cluster-wide, signaling that the
4581
+ * caller should publish a 'join' event. Falls through to a no-op stub when
4582
+ * platform.redis is missing (single-replica dev path).
4583
+ */
4584
+ async function _clusterPresenceAcquire(platform, topic, key, data) {
4585
+ const redis = platform && platform.redis;
4586
+ if (!redis || typeof redis.hincrby !== 'function') return { isFirst: true };
4587
+ const hKey = _PRESENCE_KEY_PREFIX + topic;
4588
+ const countField = 'c:' + key;
4589
+ const dataField = 'd:' + key;
4590
+ let serialized;
4591
+ try { serialized = JSON.stringify(data); } catch { serialized = 'null'; }
4592
+ try {
4593
+ // Write the data field BEFORE bumping the count. A concurrent
4594
+ // `_clusterPresenceList` (e.g. the same user's own :presence stream
4595
+ // loader racing the data stream's acquire) reads data fields only;
4596
+ // if HINCRBY ran first the loader could observe a count without a
4597
+ // data field and return an empty roster, missing the user's own
4598
+ // entry. Writing data first guarantees the loader sees the entry
4599
+ // as soon as the count is visible.
4600
+ await redis.hset(hKey, dataField, serialized);
4601
+ const count = await redis.hincrby(hKey, countField, 1);
4602
+ if (count === 1) {
4603
+ await redis.expire(hKey, _PRESENCE_TTL_SEC);
4604
+ return { isFirst: true };
4605
+ }
4606
+ // Refresh TTL on activity so the hash doesn't expire under a busy room.
4607
+ try { await redis.expire(hKey, _PRESENCE_TTL_SEC); } catch { /* best-effort */ }
4608
+ return { isFirst: false };
4609
+ } catch {
4610
+ // Redis blip: treat as first so we publish a join. Worst case a duplicate
4611
+ // 'join' merges idempotently by key on the client.
4612
+ return { isFirst: true };
4613
+ }
4614
+ }
4615
+
4616
+ /**
4617
+ * Decrement the cluster-wide count for (topic, key). Returns isLast=true when
4618
+ * this release took the count from 1 to 0 cluster-wide, signaling that the
4619
+ * caller should publish a 'leave' event. Falls through to isLast=true when
4620
+ * platform.redis is missing (single-replica dev path treats every grace-timer
4621
+ * expiry as the final leave).
4622
+ */
4623
+ async function _clusterPresenceRelease(platform, topic, key) {
4624
+ const redis = platform && platform.redis;
4625
+ if (!redis || typeof redis.hincrby !== 'function') return { isLast: true };
4626
+ const hKey = _PRESENCE_KEY_PREFIX + topic;
4627
+ const countField = 'c:' + key;
4628
+ const dataField = 'd:' + key;
4629
+ try {
4630
+ const count = await redis.hincrby(hKey, countField, -1);
4631
+ if (count <= 0) {
4632
+ await redis.hdel(hKey, countField, dataField);
4633
+ return { isLast: true };
4634
+ }
4635
+ return { isLast: false };
4636
+ } catch {
4637
+ // Redis blip: assume last so we publish a leave. A late observer reading
4638
+ // HGETALL might still see the stale field until the next acquire repairs
4639
+ // the count (or the hash TTL expires).
4640
+ return { isLast: true };
4641
+ }
4642
+ }
4643
+
4644
+ /**
4645
+ * Return the cluster-wide presence roster for a topic as `[{key, data}, ...]`.
4646
+ * Falls through to the local _presenceRef iteration when platform.redis is
4647
+ * missing.
4648
+ */
4649
+ async function _clusterPresenceList(platform, topic) {
4650
+ const redis = platform && platform.redis;
4651
+ if (!redis || typeof redis.hgetall !== 'function') {
4652
+ const prefix = topic + '\0';
4653
+ const out = [];
4654
+ for (const [refKey, ref] of _presenceRef) {
4655
+ if (!refKey.startsWith(prefix)) continue;
4656
+ if (ref.data == null) continue;
4657
+ out.push({ key: refKey.slice(prefix.length), data: ref.data });
4658
+ }
4659
+ return out;
4660
+ }
4661
+ const hKey = _PRESENCE_KEY_PREFIX + topic;
4662
+ try {
4663
+ const all = await redis.hgetall(hKey);
4664
+ const out = [];
4665
+ for (const field of Object.keys(all)) {
4666
+ if (field.length < 3 || field[0] !== 'd' || field[1] !== ':') continue;
4667
+ const key = field.slice(2);
4668
+ try { out.push({ key, data: JSON.parse(all[field]) }); }
4669
+ catch { /* skip corrupt entry */ }
4670
+ }
4671
+ return out;
4672
+ } catch {
4673
+ return [];
4674
+ }
4675
+ }
4676
+
4521
4677
  live.room = function room(config) {
4522
4678
  const {
4523
4679
  topic: topicFn,
@@ -4562,7 +4718,7 @@ live.room = function room(config) {
4562
4718
  }, {
4563
4719
  merge: mergeMode,
4564
4720
  key: keyField,
4565
- onSubscribe: presenceFn ? (ctx, topic) => {
4721
+ onSubscribe: presenceFn ? async (ctx, topic) => {
4566
4722
  const userId = _getIdentityKey(ctx);
4567
4723
  const refKey = topic + '\0' + userId;
4568
4724
 
@@ -4582,7 +4738,13 @@ live.room = function room(config) {
4582
4738
  if (r.timer) {
4583
4739
  clearTimeout(r.timer);
4584
4740
  const [t, u] = k.split('\0');
4585
- ctx.publish(t + ':presence', 'leave', { key: u });
4741
+ // Cluster release runs eagerly here too: an evicted entry
4742
+ // would otherwise leak a phantom counter on Redis.
4743
+ _clusterPresenceRelease(ctx.platform, t, u).then((res) => {
4744
+ if (res.isLast) {
4745
+ ctx.publish(t + ':presence', 'leave', { key: u });
4746
+ }
4747
+ }).catch(() => {});
4586
4748
  if (onLeave) {
4587
4749
  Promise.resolve().then(() => onLeave(ctx, t)).catch(() => {});
4588
4750
  }
@@ -4595,7 +4757,7 @@ live.room = function room(config) {
4595
4757
  console.warn(
4596
4758
  "[svelte-realtime] presence-ref map reached MAX_PRESENCE_REF=" + _maxPresenceRef +
4597
4759
  "; 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" +
4760
+ " For multi-instance deploys, wire `platform.redis` (raw ioredis client) so the cluster-shared Redis presence is bypasses the in-memory cap.\n" +
4599
4761
  " See: https://svti.me/presence"
4600
4762
  );
4601
4763
  }
@@ -4609,8 +4771,14 @@ live.room = function room(config) {
4609
4771
  // to the :presence topic.
4610
4772
  const presenceData = presenceFn(ctx);
4611
4773
  _presenceRef.set(refKey, { count: 1, timer: null, data: presenceData });
4774
+ // Cluster transition: bump shared count; only the first replica to
4775
+ // reach 1 publishes 'join'. With no platform.redis the helper
4776
+ // returns isFirst=true unconditionally, matching the in-memory path.
4612
4777
  if (presenceData) {
4613
- ctx.publish(topic + ':presence', 'join', { key: userId, data: presenceData });
4778
+ const { isFirst } = await _clusterPresenceAcquire(ctx.platform, topic, userId, presenceData);
4779
+ if (isFirst) {
4780
+ ctx.publish(topic + ':presence', 'join', { key: userId, data: presenceData });
4781
+ }
4614
4782
  }
4615
4783
  } : undefined,
4616
4784
  onUnsubscribe: presenceFn ? (ctx, topic) => {
@@ -4623,11 +4791,17 @@ live.room = function room(config) {
4623
4791
  ref.count--;
4624
4792
  if (ref.count > 0) return;
4625
4793
 
4626
- // On rollback (failed stream init), skip grace and leave immediately
4794
+ // On rollback (failed stream init), skip grace and release
4795
+ // immediately. Cluster release decides whether this was the LAST
4796
+ // subscriber across the cluster - only then do we publish 'leave'.
4627
4797
  if (ctx.ws && _rollingBack.has(ctx.ws)) {
4628
4798
  if (ref.timer) clearTimeout(ref.timer);
4629
4799
  _presenceRef.delete(refKey);
4630
- ctx.publish(topic + ':presence', 'leave', { key: userId });
4800
+ _clusterPresenceRelease(ctx.platform, topic, userId).then((res) => {
4801
+ if (res.isLast) {
4802
+ ctx.publish(topic + ':presence', 'leave', { key: userId });
4803
+ }
4804
+ }).catch(() => {});
4631
4805
  if (onLeave) {
4632
4806
  Promise.resolve().then(() => onLeave(ctx, topic)).catch(() => {});
4633
4807
  }
@@ -4636,7 +4810,11 @@ live.room = function room(config) {
4636
4810
 
4637
4811
  ref.timer = setTimeout(() => {
4638
4812
  _presenceRef.delete(refKey);
4639
- ctx.publish(topic + ':presence', 'leave', { key: userId });
4813
+ _clusterPresenceRelease(ctx.platform, topic, userId).then((res) => {
4814
+ if (res.isLast) {
4815
+ ctx.publish(topic + ':presence', 'leave', { key: userId });
4816
+ }
4817
+ }).catch(() => {});
4640
4818
  if (onLeave) {
4641
4819
  Promise.resolve().then(() => onLeave(ctx, topic)).catch(() => {});
4642
4820
  }
@@ -4658,24 +4836,12 @@ live.room = function room(config) {
4658
4836
  async (ctx, ...args) => {
4659
4837
  if (guardFn) await guardFn(ctx, ...args);
4660
4838
  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;
4839
+ // Cluster-shared roster when `platform.redis` is wired; falls
4840
+ // back to the local _presenceRef iteration otherwise. The
4841
+ // loader reconstructs the roster even when this user's join
4842
+ // was published before they subscribed to :presence (the live
4843
+ // merge takes over from here).
4844
+ return _clusterPresenceList(ctx.platform, dataTopic);
4679
4845
  },
4680
4846
  { merge: 'presence' }
4681
4847
  );
@@ -7561,10 +7727,10 @@ function _respond(ws, platform, correlationId, payload) {
7561
7727
  if (_IS_DEV) {
7562
7728
  // Estimate size without double-serialization.
7563
7729
  const data = payload.data;
7564
- if ((Array.isArray(data) && data.length > 100) || (typeof data === 'string' && data.length > 12000)) {
7730
+ if ((Array.isArray(data) && data.length > 5000) || (typeof data === 'string' && data.length > 800_000)) {
7565
7731
  console.warn(
7566
7732
  `[svelte-realtime] RPC response for '${correlationId}' contains ${data.length} items - ` +
7567
- 'large responses may exceed maxPayloadLength (16KB). Increase maxPayloadLength in adapter config if needed.\n See: https://svti.me/adapter-config'
7733
+ "large responses may exceed maxPayloadLength (default 1 MB; raise `websocket.maxPayloadLength` in svelte.config.js if needed).\n See: https://svti.me/adapter-config"
7568
7734
  );
7569
7735
  }
7570
7736
  }