svelte-adapter-uws-extensions 0.5.6 → 0.5.8
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/MIGRATION.md +8 -8
- package/README.md +9 -9
- package/package.json +2 -2
- package/redis/cursor.js +50 -71
- package/redis/presence.d.ts +3 -3
- package/redis/presence.js +14 -14
package/MIGRATION.md
CHANGED
|
@@ -36,7 +36,7 @@ Both hashes have per-field TTLs via `HPEXPIRE`. The pre-fix whole-key EXPIRE is
|
|
|
36
36
|
|
|
37
37
|
3. **`metrics().staleCleanedTotal` is now always 0.** Per-field staleness is enforced atomically by Redis HPEXPIRE rather than by an application-side cleanup script, so there is nothing to count. The field stays in the return shape for backward-compatibility. The corresponding Prometheus counter `presence_stale_cleaned_total` is no longer registered.
|
|
38
38
|
|
|
39
|
-
4. **`keyspaceNotifications: true` scope is narrower.** Pre-fix, the option subscribed to whole-key `__keyevent@*__:expired` for `presence:{topic}` keys and emitted an empty `
|
|
39
|
+
4. **`keyspaceNotifications: true` scope is narrower.** Pre-fix, the option subscribed to whole-key `__keyevent@*__:expired` for `presence:{topic}` keys and emitted an empty `state` when one fired. Same shape in 0.5 (the per-topic hash whole-key expiry only fires when every field has expired, i.e. no live instances). Per-field expiry (a single crashed instance) does NOT trigger this notification; users disappear from `list()` / `count()` results lazily. If you need explicit per-field-expiry events, subscribe to `__keyevent@*__:hexpired` separately (separate Redis CONFIG flag).
|
|
40
40
|
|
|
41
41
|
5. **Falling back to single-instance.** If you can't upgrade Redis, switch to the in-memory `createPresence` plugin from `svelte-adapter-uws/plugins/presence`. Public API is the same; you lose cross-instance presence which is the whole point of the Redis variant, but for single-instance deployments it's a clean drop-in.
|
|
42
42
|
|
|
@@ -219,19 +219,19 @@ export function leaveBoard(ws, { topic, platform }) {
|
|
|
219
219
|
|
|
220
220
|
No `close`-hook change required: uWS releases all per-`ws` subscriptions on disconnect, so `cursors.remove(ws, platform)` in your `close` hook still handles cleanup. The legacy `cursors.hooks.subscribe` slot is now a no-op (the adapter's wire gate prevents it from ever firing); it stays exported for source-compat but new code should not rely on it.
|
|
221
221
|
|
|
222
|
-
### Presence wire shape on `__presence:{topic}` migrated to `
|
|
222
|
+
### Presence wire shape on `__presence:{topic}` migrated to `state` / `diff`
|
|
223
223
|
|
|
224
224
|
**Only impacts apps with hand-rolled WebSocket clients consuming the wire directly.**
|
|
225
225
|
|
|
226
226
|
**What changed.** The `redis/presence` channel previously emitted `list` / `join` / `leave` / `updated` / `heartbeat` events. It now emits:
|
|
227
227
|
|
|
228
|
-
- `
|
|
229
|
-
- `
|
|
228
|
+
- `state` (sent once on subscribe to a single connection): payload is `{[userKey]: data}` - a flat snapshot.
|
|
229
|
+
- `diff` (broadcast to topic subscribers, microtask-batched): payload is `{joins: {[key]: data}, leaves: {[key]: data}}`. Same-tick joins+leaves on the same key collapse to the latest op; updates appear as a `joins` entry with the new data.
|
|
230
230
|
- `heartbeat` is unchanged.
|
|
231
231
|
|
|
232
|
-
The public JS API on `createPresence` is unchanged (`join`, `leave`, `sync`, `list`, `count`, `metrics`, `clear`, `destroy`, `hooks` keep their signatures). The cross-instance Redis pub/sub envelope on `presence:events:{topic}` is also unchanged. The `keyspaceNotifications: true` mode now emits an empty `
|
|
232
|
+
The public JS API on `createPresence` is unchanged (`join`, `leave`, `sync`, `list`, `count`, `metrics`, `clear`, `destroy`, `hooks` keep their signatures). The cross-instance Redis pub/sub envelope on `presence:events:{topic}` is also unchanged. The `keyspaceNotifications: true` mode now emits an empty `state` event (was an empty `list` event) on hash expiry.
|
|
233
233
|
|
|
234
|
-
**How to migrate.** Apps with a custom WebSocket client decoding presence frames must swap their decoder from the four legacy event names to `
|
|
234
|
+
**How to migrate.** Apps with a custom WebSocket client decoding presence frames must swap their decoder from the four legacy event names to `state` / `diff`:
|
|
235
235
|
|
|
236
236
|
```js
|
|
237
237
|
// before
|
|
@@ -241,11 +241,11 @@ case 'leave': remove(payload.key); break;
|
|
|
241
241
|
case 'updated': set(payload.key, payload.data); break;
|
|
242
242
|
|
|
243
243
|
// after
|
|
244
|
-
case '
|
|
244
|
+
case 'state':
|
|
245
245
|
state.clear();
|
|
246
246
|
for (const k of Object.keys(payload)) state.set(k, payload[k]);
|
|
247
247
|
break;
|
|
248
|
-
case '
|
|
248
|
+
case 'diff':
|
|
249
249
|
for (const k of Object.keys(payload.joins)) state.set(k, payload.joins[k]);
|
|
250
250
|
for (const k of Object.keys(payload.leaves)) state.delete(k);
|
|
251
251
|
break;
|
package/README.md
CHANGED
|
@@ -608,13 +608,13 @@ Clients see three event types on `__presence:{topic}`. Mirrors the adapter's bun
|
|
|
608
608
|
|
|
609
609
|
| Event | When | Payload | Direction |
|
|
610
610
|
|---|---|---|---|
|
|
611
|
-
| `
|
|
612
|
-
| `
|
|
611
|
+
| `state` | Once on subscribe | `{[userKey]: data}` - flat snapshot of current presence | Server -> single connection |
|
|
612
|
+
| `diff` | Microtask-batched after joins / leaves / updates | `{joins: {[key]: data}, leaves: {[key]: data}}` | Server -> topic subscribers |
|
|
613
613
|
| `heartbeat` | Per heartbeat interval | `string[]` - array of currently-known user keys | Server -> topic subscribers |
|
|
614
614
|
|
|
615
|
-
`
|
|
615
|
+
`diff` collapses by key per-tick: if the same user joins and leaves in the same microtask, only the latest op survives on the wire. An update (same user re-joins with different data) appears as a `joins` entry carrying the new data, since clients overwrite their `Map.set` on the same key.
|
|
616
616
|
|
|
617
|
-
Cross-instance traffic on the dedicated `presence:events:{topic}` Redis pub/sub channel is `{instanceId, topic, event, payload}` with `event` in `'join' | 'leave' | 'updated'`. Receivers route inbound events into their local diff buffer for client fan-out, so clients only ever see the unified `
|
|
617
|
+
Cross-instance traffic on the dedicated `presence:events:{topic}` Redis pub/sub channel is `{instanceId, topic, event, payload}` with `event` in `'join' | 'leave' | 'updated'`. Receivers route inbound events into their local diff buffer for client fan-out, so clients only ever see the unified `state` / `diff` shape regardless of which instance the change originated on.
|
|
618
618
|
|
|
619
619
|
Joins are staged with full rollback on failure: local state is set up first, then the Redis hashes are written, then the WebSocket is subscribed. If any step fails (circuit breaker trips, Redis is down, WebSocket closed during an async gap), all prior steps are undone - local maps, the Redis state, and any buffered diff entry are reversed. Compensating join+leave ops on the same key in the same tick collapse to nothing on the wire.
|
|
620
620
|
|
|
@@ -662,7 +662,7 @@ export async function close(ws, { platform }) {
|
|
|
662
662
|
| `select` | strips `__`-prefixed keys | Extract public fields from userData |
|
|
663
663
|
| `heartbeat` | `30000` | TTL refresh interval in ms |
|
|
664
664
|
| `ttl` | `90` | Per-entry expiry in seconds. Entries from crashed instances expire individually after this period, even if other instances are still active on the same topic. |
|
|
665
|
-
| `keyspaceNotifications` | `false` | Subscribe to Redis `__keyevent@*__:expired`. When a presence hash key expires (instance-died scenario), this instance's local subscribers receive an empty `
|
|
665
|
+
| `keyspaceNotifications` | `false` | Subscribe to Redis `__keyevent@*__:expired`. When a presence hash key expires (instance-died scenario), this instance's local subscribers receive an empty `state` event. See [Keyspace cleanup mode](#keyspace-cleanup-mode). |
|
|
666
666
|
|
|
667
667
|
#### API
|
|
668
668
|
|
|
@@ -674,7 +674,7 @@ export async function close(ws, { platform }) {
|
|
|
674
674
|
| `list(topic)` | Get current users |
|
|
675
675
|
| `count(topic)` | Count unique users |
|
|
676
676
|
| `metrics()` | Synchronous snapshot: `{ totalOnline, heartbeatLatencyMs, staleCleanedTotal }`. See [Metrics snapshot](#metrics-snapshot). |
|
|
677
|
-
| `flushDiffs()` | Drain the pending `
|
|
677
|
+
| `flushDiffs()` | Drain the pending `diff` buffer synchronously. Use in graceful-shutdown paths or tests that need the diff to land before the await chain continues. |
|
|
678
678
|
| `clear()` | Reset all presence state |
|
|
679
679
|
| `destroy()` | Stop heartbeat and subscriber |
|
|
680
680
|
| `hooks` | `{ subscribe, close }` - ready-made WebSocket hooks. Destructure for one-line `hooks.ws.js` setup. |
|
|
@@ -695,14 +695,14 @@ Two additional counters track the diff-protocol behavior:
|
|
|
695
695
|
|
|
696
696
|
| Metric | Description |
|
|
697
697
|
|---|---|
|
|
698
|
-
| `presence_diff_frames_total{topic="..."}` | `
|
|
698
|
+
| `presence_diff_frames_total{topic="..."}` | `diff` frames published to topic subscribers. Compared against `presence_joins_total` + `presence_leaves_total` it tells you how much per-tick coalescing the buffer is doing - the bigger the gap, the more bandwidth saved versus per-event broadcast. |
|
|
699
699
|
| `presence_diff_coalesced_total{topic="..."}` | Buffered diff entries overwritten by a later op in the same tick. A non-zero rate confirms the same-key collapse is working (e.g. a user reconnecting fast enough to leave-then-join in one tick). Zero is also a valid state under steady traffic. |
|
|
700
700
|
|
|
701
701
|
#### Keyspace cleanup mode
|
|
702
702
|
|
|
703
|
-
By default a sync-only observer (a connection that called `presence.sync()` to watch a room without joining it) only learns about leaves when the tracking instance broadcasts a `
|
|
703
|
+
By default a sync-only observer (a connection that called `presence.sync()` to watch a room without joining it) only learns about leaves when the tracking instance broadcasts a `diff` with the user in `leaves`. If the tracking instance crashes, the broadcast never fires and the observer's UI shows stale data until the page is reloaded.
|
|
704
704
|
|
|
705
|
-
`keyspaceNotifications: true` closes that gap by `psubscribe`-ing to `__keyevent@*__:expired`. When the presence hash key for a topic expires (which happens once no instance is heartbeating the topic anymore - typically because the only tracker crashed), this instance emits an empty `
|
|
705
|
+
`keyspaceNotifications: true` closes that gap by `psubscribe`-ing to `__keyevent@*__:expired`. When the presence hash key for a topic expires (which happens once no instance is heartbeating the topic anymore - typically because the only tracker crashed), this instance emits an empty `state` event on `__presence:<topic>` so local subscribers can replace their entire local map with "no one here."
|
|
706
706
|
|
|
707
707
|
```js
|
|
708
708
|
const presence = createPresence(redis, {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "svelte-adapter-uws-extensions",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.8",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"tag": "latest"
|
|
6
6
|
},
|
|
@@ -154,7 +154,7 @@
|
|
|
154
154
|
"node": ">=22.0.0"
|
|
155
155
|
},
|
|
156
156
|
"peerDependencies": {
|
|
157
|
-
"svelte-adapter-uws": "^0.5.
|
|
157
|
+
"svelte-adapter-uws": "^0.5.7"
|
|
158
158
|
},
|
|
159
159
|
"dependencies": {
|
|
160
160
|
"ioredis": "^5.0.0"
|
package/redis/cursor.js
CHANGED
|
@@ -403,16 +403,11 @@ export function createCursor(client, options = {}) {
|
|
|
403
403
|
* - `lastFlush`: target-anchored timestamp of the most recent flush.
|
|
404
404
|
* Advanced by `topicThrottleMs` per cycle (not to actual fire time) so
|
|
405
405
|
* a single late tick does not compound drift on subsequent cycles.
|
|
406
|
-
*
|
|
407
|
-
*
|
|
408
|
-
*
|
|
409
|
-
* `inboundDirty` and ship as one combined frame when the microtask
|
|
410
|
-
* runs. Without this, a single co-arriving cursor fired alone as a
|
|
411
|
-
* single-cursor UPDATE while every other cursor in the same pass
|
|
412
|
-
* queued for the trailing tick - 86 percent fragmentation observed at
|
|
413
|
-
* 1000-mover load on Hetzner CCX13 with `topicThrottle: 8`.
|
|
406
|
+
* Initialized to `Date.now() - topicThrottleMs` so the first broadcast
|
|
407
|
+
* on a new topic is "cycle ready" without polluting drift stats with
|
|
408
|
+
* the full `Date.now()` lateness an init of 0 would imply.
|
|
414
409
|
*
|
|
415
|
-
* @type {Map<string, { dirty: Map<string, { user: any, data: any, platform: any }>, inboundDirty: Map<string, { data: any, platform: any }>, lastFlush: number
|
|
410
|
+
* @type {Map<string, { dirty: Map<string, { user: any, data: any, platform: any }>, inboundDirty: Map<string, { data: any, platform: any }>, lastFlush: number }>}
|
|
416
411
|
*/
|
|
417
412
|
const topicFlush = new Map();
|
|
418
413
|
|
|
@@ -570,9 +565,10 @@ export function createCursor(client, options = {}) {
|
|
|
570
565
|
dirtyTopics.delete(topic);
|
|
571
566
|
|
|
572
567
|
// Target-anchored: advance lastFlush by the cadence amount.
|
|
573
|
-
// Multi-cycle backlog collapse to `now` so the next
|
|
574
|
-
//
|
|
575
|
-
//
|
|
568
|
+
// Multi-cycle backlog collapse to `now` so the next
|
|
569
|
+
// broadcast's `Date.now() - lastFlush >= topicThrottleMs`
|
|
570
|
+
// delay computation does not fire every queued cycle on
|
|
571
|
+
// this turn.
|
|
576
572
|
state.lastFlush = drift < topicThrottleMs ? deadline : now;
|
|
577
573
|
} else if (deadline < nextDeadline) {
|
|
578
574
|
nextDeadline = deadline;
|
|
@@ -591,21 +587,31 @@ export function createCursor(client, options = {}) {
|
|
|
591
587
|
}
|
|
592
588
|
|
|
593
589
|
/**
|
|
594
|
-
* Schedule a local cursor for the next coalesced flush.
|
|
595
|
-
*
|
|
596
|
-
*
|
|
597
|
-
* microtask.
|
|
598
|
-
* function OR `enqueueInbound` - they share `state.pendingMicroflush`)
|
|
599
|
-
* see `now - state.lastFlush < topicThrottleMs` and take the trailing
|
|
600
|
-
* path, accumulating into `state.dirty` / `state.inboundDirty`. The
|
|
601
|
-
* microtask then flushes everything as one combined frame.
|
|
590
|
+
* Schedule a local cursor for the next coalesced flush. Always-tick: every
|
|
591
|
+
* call appends to `state.dirty`, adds the topic to `dirtyTopics`, and arms
|
|
592
|
+
* the tracker-wide tick timer. NO leading-edge synchronous fire and NO
|
|
593
|
+
* microtask defer.
|
|
602
594
|
*
|
|
603
|
-
*
|
|
604
|
-
*
|
|
605
|
-
*
|
|
606
|
-
*
|
|
607
|
-
*
|
|
608
|
-
*
|
|
595
|
+
* Why: uWS dispatches each WS message as its own JS task, and N-API
|
|
596
|
+
* drains microtasks at the C++/JS boundary between dispatches. A
|
|
597
|
+
* `queueMicrotask`-deferred flush fires BEFORE the next socket's message
|
|
598
|
+
* handler runs, so cross-socket coalescing is impossible at the microtask
|
|
599
|
+
* level. `setTimeout(0)` is in libuv's timers phase and fires only after
|
|
600
|
+
* the poll phase processes every ready message on every socket - so all
|
|
601
|
+
* broadcasts dispatched in the same loop iteration end up in one flush
|
|
602
|
+
* regardless of how many task boundaries separate them.
|
|
603
|
+
*
|
|
604
|
+
* 0.5.5/0.5.6 shipped a `queueMicrotask` + `pendingMicroflush` variant
|
|
605
|
+
* built on the wrong dispatch-model assumption (that co-arriving
|
|
606
|
+
* broadcasts share a JS task). Demo measured ~99% single-cursor UPDATE /
|
|
607
|
+
* ~1% BULK at 1000-mover load on the deployed 0.5.6 - essentially the
|
|
608
|
+
* pre-fix shape. The bench validated the assumed input shape (all
|
|
609
|
+
* broadcasts in one synchronous task) instead of the input shape uWS
|
|
610
|
+
* actually produces (per-message tasks separated by microtask drains).
|
|
611
|
+
*
|
|
612
|
+
* First-cursor latency cost of always-tick: up to `topicThrottleMs` (16ms
|
|
613
|
+
* default, one frame budget) before fanout. Below the perceptual floor
|
|
614
|
+
* for cursors. Same trade-off the adapter's bundled cursor plugin makes.
|
|
609
615
|
*/
|
|
610
616
|
function broadcast(topic, key, user, data, platform) {
|
|
611
617
|
if (topicThrottleMs <= 0) {
|
|
@@ -615,34 +621,19 @@ export function createCursor(client, options = {}) {
|
|
|
615
621
|
|
|
616
622
|
let state = topicFlush.get(topic);
|
|
617
623
|
if (!state) {
|
|
618
|
-
|
|
624
|
+
// Anchor `lastFlush` one cycle in the past so the first broadcast
|
|
625
|
+
// is treated as "cycle ready" with zero drift on the very first
|
|
626
|
+
// tick. Without this, `Date.now() - 0` would be a huge "lateness"
|
|
627
|
+
// that pollutes the drift stats forever.
|
|
628
|
+
state = { dirty: new Map(), inboundDirty: new Map(), lastFlush: Date.now() - topicThrottleMs };
|
|
619
629
|
topicFlush.set(topic, state);
|
|
620
630
|
}
|
|
621
631
|
state.dirty.set(key, { user, data, platform });
|
|
622
|
-
|
|
623
|
-
const now = Date.now();
|
|
624
|
-
if (now - state.lastFlush >= topicThrottleMs) {
|
|
625
|
-
// Claim the cadence slot synchronously so subsequent broadcasts
|
|
626
|
-
// in the same JS pass take the trailing path and accumulate.
|
|
627
|
-
state.lastFlush = now;
|
|
628
|
-
dirtyTopics.delete(topic);
|
|
629
|
-
if (!state.pendingMicroflush) {
|
|
630
|
-
state.pendingMicroflush = true;
|
|
631
|
-
queueMicrotask(() => {
|
|
632
|
-
state.pendingMicroflush = false;
|
|
633
|
-
// flushBoth clears `dirty` and `inboundDirty` internally
|
|
634
|
-
// and early-returns on empty; this guard saves the
|
|
635
|
-
// for-of entry cost on the empty case.
|
|
636
|
-
if (state.dirty.size === 0 && state.inboundDirty.size === 0) return;
|
|
637
|
-
flushBoth(topic, state);
|
|
638
|
-
});
|
|
639
|
-
}
|
|
640
|
-
return;
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
// Within window: trailing-edge flush via the scheduler tick.
|
|
644
632
|
dirtyTopics.add(topic);
|
|
645
|
-
|
|
633
|
+
|
|
634
|
+
const elapsed = Date.now() - state.lastFlush;
|
|
635
|
+
const delay = elapsed >= topicThrottleMs ? 0 : topicThrottleMs - elapsed;
|
|
636
|
+
armTick(delay);
|
|
646
637
|
}
|
|
647
638
|
|
|
648
639
|
/**
|
|
@@ -669,31 +660,19 @@ export function createCursor(client, options = {}) {
|
|
|
669
660
|
|
|
670
661
|
let state = topicFlush.get(topic);
|
|
671
662
|
if (!state) {
|
|
672
|
-
state = { dirty: new Map(), inboundDirty: new Map(), lastFlush:
|
|
663
|
+
state = { dirty: new Map(), inboundDirty: new Map(), lastFlush: Date.now() - topicThrottleMs };
|
|
673
664
|
topicFlush.set(topic, state);
|
|
674
665
|
}
|
|
675
666
|
state.inboundDirty.set(key, { data, platform });
|
|
676
|
-
|
|
677
|
-
const now = Date.now();
|
|
678
|
-
if (now - state.lastFlush >= topicThrottleMs) {
|
|
679
|
-
// Symmetric with `broadcast()`: same shared `pendingMicroflush`
|
|
680
|
-
// per topic state, so if a local broadcast already claimed the
|
|
681
|
-
// slot the peer-relay entry just appends and ships with it.
|
|
682
|
-
state.lastFlush = now;
|
|
683
|
-
dirtyTopics.delete(topic);
|
|
684
|
-
if (!state.pendingMicroflush) {
|
|
685
|
-
state.pendingMicroflush = true;
|
|
686
|
-
queueMicrotask(() => {
|
|
687
|
-
state.pendingMicroflush = false;
|
|
688
|
-
if (state.dirty.size === 0 && state.inboundDirty.size === 0) return;
|
|
689
|
-
flushBoth(topic, state);
|
|
690
|
-
});
|
|
691
|
-
}
|
|
692
|
-
return;
|
|
693
|
-
}
|
|
694
|
-
|
|
695
667
|
dirtyTopics.add(topic);
|
|
696
|
-
|
|
668
|
+
|
|
669
|
+
// Symmetric with `broadcast()` always-tick: both source paths share
|
|
670
|
+
// the same `state` and the same tracker-wide tick timer, so a local
|
|
671
|
+
// broadcast and a peer-relayed inbound landing in the same loop
|
|
672
|
+
// iteration ship together as one combined frame at the next tick.
|
|
673
|
+
const elapsed = Date.now() - state.lastFlush;
|
|
674
|
+
const delay = elapsed >= topicThrottleMs ? 0 : topicThrottleMs - elapsed;
|
|
675
|
+
armTick(delay);
|
|
697
676
|
}
|
|
698
677
|
|
|
699
678
|
async function broadcastRemove(topic, key, platform) {
|
package/redis/presence.d.ts
CHANGED
|
@@ -38,8 +38,8 @@ export interface RedisPresenceOptions {
|
|
|
38
38
|
* both single-instance and cluster deployments.
|
|
39
39
|
*/
|
|
40
40
|
export type PresenceWireEvent =
|
|
41
|
-
| { event: '
|
|
42
|
-
| { event: '
|
|
41
|
+
| { event: 'state'; data: Record<string, Record<string, any>> }
|
|
42
|
+
| { event: 'diff'; data: { joins: Record<string, Record<string, any>>; leaves: Record<string, Record<string, any>> } }
|
|
43
43
|
| { event: 'heartbeat'; data: string[] };
|
|
44
44
|
|
|
45
45
|
export interface PresenceMetricsSnapshot {
|
|
@@ -101,7 +101,7 @@ export interface RedisPresenceTracker {
|
|
|
101
101
|
* Drain the pending diff buffer synchronously. The diff buffer
|
|
102
102
|
* normally flushes on the next microtask after a join / leave /
|
|
103
103
|
* update; call this when a test or graceful-shutdown path needs
|
|
104
|
-
* the `
|
|
104
|
+
* the `diff` to land before the await chain continues.
|
|
105
105
|
*/
|
|
106
106
|
flushDiffs(): void;
|
|
107
107
|
|
package/redis/presence.js
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
* for cross-instance join/leave notifications.
|
|
7
7
|
*
|
|
8
8
|
* Wire shape clients see on `__presence:{topic}`:
|
|
9
|
-
* - `
|
|
9
|
+
* - `state` (sent once on subscribe to a single connection)
|
|
10
10
|
* payload: `{[userKey]: data}` flat snapshot of current presence
|
|
11
|
-
* - `
|
|
11
|
+
* - `diff` (broadcast to topic subscribers, microtask-batched)
|
|
12
12
|
* payload: `{joins: {[key]: data}, leaves: {[key]: data}}`
|
|
13
13
|
* Same-tick joins+leaves on the same key collapse: latest op wins.
|
|
14
14
|
* - `heartbeat` (broadcast to topic subscribers, per heartbeat interval)
|
|
@@ -148,7 +148,7 @@ return 0
|
|
|
148
148
|
|
|
149
149
|
/**
|
|
150
150
|
* Internal cross-instance Redis pub/sub envelope event names. NOT the
|
|
151
|
-
* client wire shape - clients see `
|
|
151
|
+
* client wire shape - clients see `state` / `diff` /
|
|
152
152
|
* `heartbeat`. These names live on the `presence:events:{topic}` channel
|
|
153
153
|
* between instances and are routed into the local diff buffer on receive.
|
|
154
154
|
*/
|
|
@@ -256,7 +256,7 @@ export function createPresence(client, options = {}) {
|
|
|
256
256
|
const mTotalOnline = m?.gauge('presence_total_online', 'Unique users present per topic on this instance', ['topic']);
|
|
257
257
|
const mHeartbeatLatency = m?.gauge('presence_heartbeat_latency_ms', 'Duration of the most recent heartbeat tick in milliseconds');
|
|
258
258
|
const mKeyspaceCleanups = m?.counter('presence_keyspace_cleanups_total', 'Topics whose hash expiry triggered a local empty-list emit');
|
|
259
|
-
const mDiffFrames = m?.counter('presence_diff_frames_total', '
|
|
259
|
+
const mDiffFrames = m?.counter('presence_diff_frames_total', 'diff frames published to topic subscribers', ['topic']);
|
|
260
260
|
const mDiffCoalesced = m?.counter('presence_diff_coalesced_total', 'Buffered diff entries overwritten by a later op in the same tick', ['topic']);
|
|
261
261
|
|
|
262
262
|
let lastHeartbeatLatency = 0;
|
|
@@ -363,7 +363,7 @@ export function createPresence(client, options = {}) {
|
|
|
363
363
|
else leaves[key] = data;
|
|
364
364
|
}
|
|
365
365
|
try {
|
|
366
|
-
platform.publish('__presence:' + topic, '
|
|
366
|
+
platform.publish('__presence:' + topic, 'diff', { joins, leaves }, { relay: false });
|
|
367
367
|
mDiffFrames?.inc({ topic: mt(topic) });
|
|
368
368
|
} catch { /* platform unavailable mid-flight */ }
|
|
369
369
|
}
|
|
@@ -533,11 +533,11 @@ export function createPresence(client, options = {}) {
|
|
|
533
533
|
// handler could only refresh `existing` entries; an
|
|
534
534
|
// entry the client swept (cross-replica relay latency,
|
|
535
535
|
// brief backpressure, JS thread saturation) could never
|
|
536
|
-
// be recovered without a
|
|
536
|
+
// be recovered without a diff for that user.
|
|
537
537
|
// Older clients fall back gracefully: they see an
|
|
538
538
|
// object instead of an array and skip the legacy
|
|
539
|
-
// "refresh-existing" branch, but the next
|
|
540
|
-
// or
|
|
539
|
+
// "refresh-existing" branch, but the next diff
|
|
540
|
+
// or state still reconciles them.
|
|
541
541
|
/** @type {Record<string, any>} */
|
|
542
542
|
const dataMap = {};
|
|
543
543
|
for (const [userKey, entry] of data) dataMap[userKey] = entry.data;
|
|
@@ -590,7 +590,7 @@ export function createPresence(client, options = {}) {
|
|
|
590
590
|
// The per-topic hash key expires only when every field has
|
|
591
591
|
// expired (no live instances presenting any user on this
|
|
592
592
|
// topic). That is the "whole topic empty" signal we forward
|
|
593
|
-
// as an empty
|
|
593
|
+
// as an empty state to local subscribers. Per-user
|
|
594
594
|
// hash keys (presence:user:{topic}:{userKey}) and the events
|
|
595
595
|
// channel are filtered out.
|
|
596
596
|
const topicPrefix = client.key('presence:topic:');
|
|
@@ -599,7 +599,7 @@ export function createPresence(client, options = {}) {
|
|
|
599
599
|
if (!expiredKey.startsWith(topicPrefix)) return;
|
|
600
600
|
const topic = expiredKey.slice(topicPrefix.length);
|
|
601
601
|
if (activePlatform) {
|
|
602
|
-
activePlatform.publish('__presence:' + topic, '
|
|
602
|
+
activePlatform.publish('__presence:' + topic, 'state', {}, { relay: false });
|
|
603
603
|
mKeyspaceCleanups?.inc();
|
|
604
604
|
}
|
|
605
605
|
});
|
|
@@ -1223,7 +1223,7 @@ export function createPresence(client, options = {}) {
|
|
|
1223
1223
|
state[userKey] = entry.data;
|
|
1224
1224
|
}
|
|
1225
1225
|
try {
|
|
1226
|
-
platform.send(ws, '__presence:' + topic, '
|
|
1226
|
+
platform.send(ws, '__presence:' + topic, 'state', state);
|
|
1227
1227
|
} catch {
|
|
1228
1228
|
// WebSocket closed before send
|
|
1229
1229
|
}
|
|
@@ -1270,7 +1270,7 @@ export function createPresence(client, options = {}) {
|
|
|
1270
1270
|
|
|
1271
1271
|
try {
|
|
1272
1272
|
ws.subscribe(presenceTopic);
|
|
1273
|
-
platform.send(ws, presenceTopic, '
|
|
1273
|
+
platform.send(ws, presenceTopic, 'state', state);
|
|
1274
1274
|
} catch {
|
|
1275
1275
|
const topics = syncObservers.get(ws);
|
|
1276
1276
|
if (topics && topics.has(topic)) {
|
|
@@ -1396,13 +1396,13 @@ export function createPresence(client, options = {}) {
|
|
|
1396
1396
|
// Client-initiated reconnect-snapshot. The presence plugin
|
|
1397
1397
|
// client sends `{type:'presence-snapshot', topic}` on every
|
|
1398
1398
|
// status==='open' (initial connect + reconnect). Re-emits
|
|
1399
|
-
// `
|
|
1399
|
+
// `state` to the requesting ws via `tracker.sync`,
|
|
1400
1400
|
// which is the same path that fires on a fresh subscribe.
|
|
1401
1401
|
// Symmetric to cursor's `cursor-snapshot` text frame.
|
|
1402
1402
|
//
|
|
1403
1403
|
// Without this, board-scoped presence stayed stale across
|
|
1404
1404
|
// reconnects: a tab that had joined via an RPC saw no
|
|
1405
|
-
//
|
|
1405
|
+
// diff during the disconnect window, and on
|
|
1406
1406
|
// reconnect its in-memory map was whatever it last knew.
|
|
1407
1407
|
// Global presence accidentally self-healed because most
|
|
1408
1408
|
// apps call `presence.join('global')` from the `open` hook
|