svelte-realtime 0.5.2 → 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.
- package/package.json +3 -3
- package/server.js +160 -25
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "svelte-realtime",
|
|
3
|
-
"version": "0.5.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
4662
|
-
|
|
4663
|
-
|
|
4664
|
-
//
|
|
4665
|
-
//
|
|
4666
|
-
|
|
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
|
);
|