svelte-adapter-uws-extensions 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-adapter-uws-extensions",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
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.2"
157
+ "svelte-adapter-uws": "^0.5.4"
158
158
  },
159
159
  "dependencies": {
160
160
  "ioredis": "^5.0.0"
package/redis/cursor.d.ts CHANGED
@@ -127,6 +127,32 @@ export interface RedisCursorTracker {
127
127
  /** Stop the Redis subscriber and clear local timers. */
128
128
  destroy(): void;
129
129
 
130
+ /**
131
+ * Snapshot of scheduler health. Always available, near-zero cost.
132
+ *
133
+ * - `flushes`: total tick-driven flushes since tracker creation.
134
+ * - `driftMeanMs`: mean (target_deadline - actual_fire_time) across
135
+ * all tick-driven flushes. 0 means perfect cadence; values >
136
+ * `topicThrottle` indicate sustained event-loop saturation or CPU
137
+ * contention (consider a dedicated-CPU instance, or raise
138
+ * `topicThrottle`).
139
+ * - `driftMaxMs`: largest single observed late fire. Useful for
140
+ * spotting one-off GC pauses vs. sustained drift.
141
+ * - `dirtyTopicsCurrent`: topics with pending coalesced entries right
142
+ * now. Should hover near zero in healthy operation.
143
+ * - `activeTopicsTotal`: topics with at least one local cursor.
144
+ *
145
+ * Leading-edge synchronous flushes are not counted in drift stats -
146
+ * they fire on the call thread, not via the scheduler.
147
+ */
148
+ stats(): {
149
+ flushes: number;
150
+ driftMeanMs: number;
151
+ driftMaxMs: number;
152
+ dirtyTopicsCurrent: number;
153
+ activeTopicsTotal: number;
154
+ };
155
+
130
156
  /**
131
157
  * Ready-made WebSocket hooks for cursor tracking.
132
158
  *
package/redis/cursor.js CHANGED
@@ -91,6 +91,7 @@ const EVENTS = Object.freeze({
91
91
  * @property {(topic: string) => Promise<CursorEntry[]>} list
92
92
  * @property {() => Promise<void>} clear
93
93
  * @property {() => void} destroy - Stop the Redis subscriber
94
+ * @property {() => { flushes: number, driftMeanMs: number, driftMaxMs: number, dirtyTopicsCurrent: number, activeTopicsTotal: number }} stats - Scheduler health snapshot
94
95
  */
95
96
 
96
97
  /**
@@ -198,7 +199,29 @@ export function createCursor(client, options = {}) {
198
199
  const parsed = JSON.parse(message);
199
200
  if (parsed.instanceId === instanceId) return;
200
201
  if (!validator.acceptEnvelope(parsed.topic, parsed.event)) return;
201
- if (activePlatform) {
202
+ if (!activePlatform) return;
203
+
204
+ // Receiver-side coalescing for high-frequency cursor-position
205
+ // events. UPDATE / BULK enqueue into the local topic's
206
+ // inboundDirty map so the NEXT local flush emits one combined
207
+ // frame covering local + peer cursors. Pre-change, peer-
208
+ // relayed frames published immediately on receive, producing
209
+ // tight doublets at subscribers (one frame per worker per
210
+ // cycle, ms apart). Now one frame per subscriber per cycle
211
+ // regardless of worker count.
212
+ //
213
+ // CATALOG / JOIN / REMOVE stay immediate: low-frequency
214
+ // roster events where coalescing would add latency without
215
+ // smoothness benefit.
216
+ if (parsed.event === EVENTS.UPDATE && parsed.payload && typeof parsed.payload.key === 'string') {
217
+ enqueueInbound(parsed.topic, parsed.payload.key, parsed.payload.data, activePlatform);
218
+ } else if (parsed.event === EVENTS.BULK && Array.isArray(parsed.payload)) {
219
+ for (const entry of parsed.payload) {
220
+ if (entry && typeof entry.key === 'string') {
221
+ enqueueInbound(parsed.topic, entry.key, entry.data, activePlatform);
222
+ }
223
+ }
224
+ } else {
202
225
  activePlatform.publish(
203
226
  '__cursor:' + parsed.topic,
204
227
  parsed.event,
@@ -358,11 +381,53 @@ export function createCursor(client, options = {}) {
358
381
  }
359
382
 
360
383
  /**
361
- * Per-topic aggregate throttle state.
362
- * @type {Map<string, { lastFlush: number, timer: any, dirty: Map<string, { user: any, data: any, platform: any }> }>}
384
+ * Per-topic aggregate flush state.
385
+ *
386
+ * - `dirty`: locally-originated cursors. Flushed locally AND relayed.
387
+ * - `inboundDirty`: cursors received from peer instances via Redis pub/sub.
388
+ * Flushed locally ONLY (re-relaying would loop). Kept separate from
389
+ * `dirty` so the relay payload is structurally a subset of the local
390
+ * flush, not a per-entry origin check.
391
+ * - `lastFlush`: target-anchored timestamp of the most recent flush.
392
+ * Advanced by `topicThrottleMs` per cycle (not to actual fire time) so
393
+ * a single late tick does not compound drift on subsequent cycles.
394
+ *
395
+ * @type {Map<string, { dirty: Map<string, { user: any, data: any, platform: any }>, inboundDirty: Map<string, { data: any, platform: any }>, lastFlush: number }>}
363
396
  */
364
397
  const topicFlush = new Map();
365
398
 
399
+ /**
400
+ * Single scheduler-driven set: topics with at least one dirty entry
401
+ * awaiting flush. Bounded by mover count, not topic count, so the
402
+ * per-tick walk does not scan idle topics. Updated synchronously on
403
+ * `broadcast()` / `enqueueInbound()` and on every tick.
404
+ *
405
+ * @type {Set<string>}
406
+ */
407
+ const dirtyTopics = new Set();
408
+
409
+ /**
410
+ * Single timer for the whole tracker. Always points at the next earliest
411
+ * topic deadline (or null when idle). Replaces the previous per-topic
412
+ * setTimeout pattern: N pending timers -> 1 pending timer regardless of
413
+ * topic count. Scheduling overhead is O(dirty topics), not O(active
414
+ * topics), and a single late fire affects exactly one cycle (target-
415
+ * anchored, no drift compounding).
416
+ *
417
+ * @type {ReturnType<typeof setTimeout> | null}
418
+ */
419
+ let tickTimer = null;
420
+
421
+ /**
422
+ * Drift accounting for observability. Updated on every flush in `tick()`.
423
+ * Exposed via the `stats()` accessor; optional `metrics` integration
424
+ * (Prometheus histogram) is wired separately.
425
+ */
426
+ let driftSum = 0;
427
+ let driftCount = 0;
428
+ let driftMax = 0;
429
+ let flushCount = 0;
430
+
366
431
  function relay(topic, event, payload) {
367
432
  if (b) { try { b.guard(); } catch { return; } }
368
433
  const msg = JSON.stringify({ instanceId, topic, event, payload });
@@ -388,24 +453,129 @@ export function createCursor(client, options = {}) {
388
453
  }
389
454
 
390
455
  /**
391
- * Flush all coalesced entries for a topic as a single `bulk` event.
392
- * Entries carry `{key, data}` only; `user` lives on the catalog channel.
393
- * Per-entry Redis snapshot writes are coalesced through `queueSnapshot`
394
- * onto the snapshot timer.
456
+ * Flush a topic's `dirty` + `inboundDirty` maps as a single wire frame to
457
+ * local subscribers, then relay the local-origin slice to peers.
458
+ *
459
+ * - Local subscribers see one combined frame per cycle covering this
460
+ * worker's own cursors PLUS any cursors received from peers since the
461
+ * last flush. Pre-change, peer-relayed cursors emitted as a separate
462
+ * frame immediately on receive, producing tight doublets at subscribers.
463
+ * - Peers receive only the local-origin slice (relay payload is built
464
+ * from `dirty`, not from `inboundDirty`). Re-relaying inbound cursors
465
+ * would loop: filtered at the receiver via `instanceId`, but still
466
+ * wastes Redis pub/sub bandwidth.
467
+ * - `queueSnapshot` runs for local-origin only. The originating worker
468
+ * owns the Redis HSET for its cursors; receivers must not re-write
469
+ * what the origin already wrote (would double the HSET storm).
470
+ *
471
+ * Single-entry vs. multi-entry choice mirrors the existing wire shape:
472
+ * one cursor -> `update {key, data}`, many -> `bulk [{key, data}, ...]`.
473
+ * Subscribers handle both as cursor-position frames.
395
474
  */
396
- function flushBulk(topic, dirty) {
475
+ function flushBoth(topic, state) {
397
476
  const entries = [];
398
477
  let flushPlatform = null;
399
- for (const [k, v] of dirty) {
478
+ let localCount = 0;
479
+
480
+ // Local-origin slice first so we can take a prefix for the relay.
481
+ for (const [k, v] of state.dirty) {
400
482
  entries.push({ key: k, data: v.data });
401
483
  flushPlatform = v.platform;
402
484
  queueSnapshot(topic, k, v.user, v.data);
485
+ localCount++;
486
+ }
487
+ for (const [k, v] of state.inboundDirty) {
488
+ entries.push({ key: k, data: v.data });
489
+ flushPlatform ||= v.platform;
403
490
  }
491
+
492
+ state.dirty.clear();
493
+ state.inboundDirty.clear();
494
+
404
495
  if (!flushPlatform || entries.length === 0) return;
405
- flushPlatform.publish('__cursor:' + topic, EVENTS.BULK, entries);
406
- relay(topic, EVENTS.BULK, entries);
496
+
497
+ mBroadcasts?.inc({ topic: mt(topic) });
498
+ flushCount++;
499
+
500
+ // Single local publish covering all entries (local + inbound).
501
+ if (entries.length === 1) {
502
+ flushPlatform.publish('__cursor:' + topic, EVENTS.UPDATE, entries[0]);
503
+ } else {
504
+ flushPlatform.publish('__cursor:' + topic, EVENTS.BULK, entries);
505
+ }
506
+
507
+ // Relay LOCAL-ORIGIN slice only; never re-relay what came from peers.
508
+ if (localCount > 0) {
509
+ if (localCount === 1) {
510
+ relay(topic, EVENTS.UPDATE, entries[0]);
511
+ } else {
512
+ relay(topic, EVENTS.BULK, entries.slice(0, localCount));
513
+ }
514
+ }
407
515
  }
408
516
 
517
+ /**
518
+ * Scheduler tick. Walks `dirtyTopics`, flushes any topic whose deadline
519
+ * (`lastFlush + topicThrottleMs`) has passed, and re-arms `tickTimer`
520
+ * for the next earliest pending deadline. Topics whose deadline has not
521
+ * yet passed stay in `dirtyTopics` for the next tick.
522
+ *
523
+ * Target-anchored advance: on flush, `lastFlush` is set to the deadline
524
+ * (not the actual fire time) so a single late tick does not compound
525
+ * drift on subsequent cycles. If we fell behind by more than one cycle
526
+ * (event loop saturation > `topicThrottleMs`), `lastFlush` resets to
527
+ * `now` to avoid queueing phantom catch-up fires that would all hit the
528
+ * next event loop turn.
529
+ */
530
+ function tick() {
531
+ tickTimer = null;
532
+ const now = Date.now();
533
+ let nextDeadline = Infinity;
534
+
535
+ for (const topic of dirtyTopics) {
536
+ const state = topicFlush.get(topic);
537
+ if (!state) { dirtyTopics.delete(topic); continue; }
538
+ if (state.dirty.size === 0 && state.inboundDirty.size === 0) {
539
+ dirtyTopics.delete(topic);
540
+ continue;
541
+ }
542
+ const deadline = state.lastFlush + topicThrottleMs;
543
+ if (deadline <= now) {
544
+ const drift = now - deadline;
545
+ driftSum += drift;
546
+ driftCount++;
547
+ if (drift > driftMax) driftMax = drift;
548
+
549
+ flushBoth(topic, state);
550
+ dirtyTopics.delete(topic);
551
+
552
+ // Target-anchored: advance lastFlush by the cadence amount.
553
+ // Multi-cycle backlog collapse to `now` so the next leading-
554
+ // edge check `(now - lastFlush) >= topicThrottleMs` works as
555
+ // expected without firing every queued cycle on this turn.
556
+ state.lastFlush = drift < topicThrottleMs ? deadline : now;
557
+ } else if (deadline < nextDeadline) {
558
+ nextDeadline = deadline;
559
+ }
560
+ }
561
+
562
+ if (nextDeadline !== Infinity) {
563
+ tickTimer = setTimeout(tick, Math.max(0, nextDeadline - Date.now()));
564
+ }
565
+ // else: scheduler goes idle until next broadcast() / enqueueInbound().
566
+ }
567
+
568
+ function armTick(delay) {
569
+ if (tickTimer !== null) return;
570
+ tickTimer = setTimeout(tick, delay);
571
+ }
572
+
573
+ /**
574
+ * Schedule a local cursor for the next coalesced flush. The leading-
575
+ * edge check fires synchronously when `topicThrottleMs` has elapsed
576
+ * since the last flush (preserves the contract that the first call on
577
+ * an idle topic publishes immediately, without a setTimeout(0) detour).
578
+ */
409
579
  function broadcast(topic, key, user, data, platform) {
410
580
  if (topicThrottleMs <= 0) {
411
581
  doBroadcast(topic, key, user, data, platform);
@@ -414,42 +584,64 @@ export function createCursor(client, options = {}) {
414
584
 
415
585
  let state = topicFlush.get(topic);
416
586
  if (!state) {
417
- state = { lastFlush: 0, timer: null, dirty: new Map() };
587
+ state = { dirty: new Map(), inboundDirty: new Map(), lastFlush: 0 };
418
588
  topicFlush.set(topic, state);
419
589
  }
420
-
421
590
  state.dirty.set(key, { user, data, platform });
422
591
 
423
592
  const now = Date.now();
424
-
425
593
  if (now - state.lastFlush >= topicThrottleMs) {
426
- if (state.timer) { clearTimeout(state.timer); state.timer = null; }
594
+ // Leading-edge synchronous flush.
427
595
  state.lastFlush = now;
428
- if (state.dirty.size === 1) {
429
- const [k, v] = state.dirty.entries().next().value;
430
- doBroadcast(topic, k, v.user, v.data, v.platform);
431
- } else {
432
- flushBulk(topic, state.dirty);
433
- }
434
- state.dirty.clear();
596
+ flushBoth(topic, state);
597
+ dirtyTopics.delete(topic);
435
598
  return;
436
599
  }
437
600
 
438
- if (!state.timer) {
439
- state.timer = setTimeout(() => {
440
- const s = topicFlush.get(topic);
441
- if (!s) return;
442
- s.timer = null;
443
- s.lastFlush = Date.now();
444
- if (s.dirty.size === 1) {
445
- const [k, v] = s.dirty.entries().next().value;
446
- doBroadcast(topic, k, v.user, v.data, v.platform);
447
- } else {
448
- flushBulk(topic, s.dirty);
449
- }
450
- s.dirty.clear();
451
- }, topicThrottleMs - (now - state.lastFlush));
601
+ // Within window: trailing-edge flush via the scheduler tick.
602
+ dirtyTopics.add(topic);
603
+ armTick(Math.max(0, topicThrottleMs - (now - state.lastFlush)));
604
+ }
605
+
606
+ /**
607
+ * Schedule a peer-relayed cursor for the next coalesced flush. Symmetric
608
+ * to `broadcast()`: same leading/trailing edge semantics, but inbound
609
+ * entries route through `state.inboundDirty` so they are visible to
610
+ * local subscribers on the next flush WITHOUT being re-relayed to peers
611
+ * (which would loop) and WITHOUT being written to Redis (origin owns
612
+ * the HSET).
613
+ *
614
+ * The peer's cross-replica end-to-end latency gains up to one
615
+ * `topicThrottleMs` of coalescing delay on the receiver side. Cursors
616
+ * are already throttled in the 8-16ms range; adding 8-16ms is well
617
+ * below the ~50-100ms human perception threshold for cursor lag. The
618
+ * smoothness win (one frame per subscriber per cycle instead of two)
619
+ * is the structural benefit.
620
+ */
621
+ function enqueueInbound(topic, key, data, platform) {
622
+ if (topicThrottleMs <= 0) {
623
+ // Legacy immediate mode (matches old receiver behavior).
624
+ platform.publish('__cursor:' + topic, EVENTS.UPDATE, { key, data }, { relay: false });
625
+ return;
626
+ }
627
+
628
+ let state = topicFlush.get(topic);
629
+ if (!state) {
630
+ state = { dirty: new Map(), inboundDirty: new Map(), lastFlush: 0 };
631
+ topicFlush.set(topic, state);
632
+ }
633
+ state.inboundDirty.set(key, { data, platform });
634
+
635
+ const now = Date.now();
636
+ if (now - state.lastFlush >= topicThrottleMs) {
637
+ state.lastFlush = now;
638
+ flushBoth(topic, state);
639
+ dirtyTopics.delete(topic);
640
+ return;
452
641
  }
642
+
643
+ dirtyTopics.add(topic);
644
+ armTick(Math.max(0, topicThrottleMs - (now - state.lastFlush)));
453
645
  }
454
646
 
455
647
  async function broadcastRemove(topic, key, platform) {
@@ -580,6 +772,7 @@ export function createCursor(client, options = {}) {
580
772
  topics.delete(topic);
581
773
  activeTopics.delete(topic);
582
774
  topicFlush.delete(topic);
775
+ dirtyTopics.delete(topic);
583
776
  redisPending.delete(topic);
584
777
  stopCleanupTimer();
585
778
  }
@@ -637,6 +830,7 @@ export function createCursor(client, options = {}) {
637
830
  topics.delete(t);
638
831
  activeTopics.delete(t);
639
832
  topicFlush.delete(t);
833
+ dirtyTopics.delete(t);
640
834
  redisPending.delete(t);
641
835
  }
642
836
  }
@@ -717,9 +911,9 @@ export function createCursor(client, options = {}) {
717
911
  if (entry.timer) clearTimeout(entry.timer);
718
912
  }
719
913
  }
720
- for (const [, state] of topicFlush) {
721
- if (state.timer) clearTimeout(state.timer);
722
- }
914
+ // Tracker-level scheduler timer + dirty-topic set.
915
+ if (tickTimer !== null) { clearTimeout(tickTimer); tickTimer = null; }
916
+ dirtyTopics.clear();
723
917
  topics.clear();
724
918
  topicFlush.clear();
725
919
  wsState.clear();
@@ -739,9 +933,8 @@ export function createCursor(client, options = {}) {
739
933
  if (entry.timer) clearTimeout(entry.timer);
740
934
  }
741
935
  }
742
- for (const [, state] of topicFlush) {
743
- if (state.timer) clearTimeout(state.timer);
744
- }
936
+ if (tickTimer !== null) { clearTimeout(tickTimer); tickTimer = null; }
937
+ dirtyTopics.clear();
745
938
  topicFlush.clear();
746
939
  if (subscriber) {
747
940
  subscriber.quit().catch(() => subscriber.disconnect());
@@ -750,6 +943,37 @@ export function createCursor(client, options = {}) {
750
943
  activePlatform = null;
751
944
  },
752
945
 
946
+ /**
947
+ * Snapshot of scheduler health. Always available, near-zero cost.
948
+ *
949
+ * - `flushes`: total tick-driven flushes since tracker creation.
950
+ * - `driftMeanMs`: mean (target_deadline - actual_fire_time) across
951
+ * all tick-driven flushes. 0 means perfect cadence; values >
952
+ * `topicThrottle` indicate sustained event-loop saturation or
953
+ * CPU contention.
954
+ * - `driftMaxMs`: largest single observed late fire. Useful for
955
+ * spotting one-off GC pauses vs. sustained drift.
956
+ * - `dirtyTopicsCurrent`: topics with pending coalesced entries
957
+ * right now. Should hover near zero in healthy operation; growth
958
+ * means tick is falling behind.
959
+ * - `activeTopicsTotal`: topics with at least one local cursor.
960
+ *
961
+ * Leading-edge synchronous flushes (first call on an idle topic)
962
+ * are not counted in drift stats - they fire on the call thread,
963
+ * not via the scheduler.
964
+ *
965
+ * @returns {{ flushes: number, driftMeanMs: number, driftMaxMs: number, dirtyTopicsCurrent: number, activeTopicsTotal: number }}
966
+ */
967
+ stats() {
968
+ return {
969
+ flushes: flushCount,
970
+ driftMeanMs: driftCount > 0 ? driftSum / driftCount : 0,
971
+ driftMaxMs: driftMax,
972
+ dirtyTopicsCurrent: dirtyTopics.size,
973
+ activeTopicsTotal: topics.size
974
+ };
975
+ },
976
+
753
977
  hooks: {
754
978
  subscribe(ws, topic, { platform }) {
755
979
  if (topic.startsWith('__cursor:')) {
@@ -760,7 +984,23 @@ export function createCursor(client, options = {}) {
760
984
  message(ws, { data, platform }) {
761
985
  if (data && data.type === 'cursor' && data.topic && data.data !== undefined) {
762
986
  tracker.update(ws, data.topic, data.data, platform);
987
+ return;
988
+ }
989
+ // Client-initiated reconnect-snapshot. The cursor plugin client
990
+ // sends `{type:'cursor-snapshot', topic}` on every status==='open'
991
+ // (initial connect + reconnect). Pre-fix, this text frame had no
992
+ // server handler and was a dead wire frame; the snapshot path
993
+ // only fired through `hooks.subscribe` -> `tracker.snapshot` when
994
+ // the ws subscribed to the `__cursor:{topic}` channel. With this
995
+ // branch, the snapshot also re-emits on the explicit frame so a
996
+ // reconnecting tab that resubscribes via `subscribe-batch` (which
997
+ // the adapter dedups when the topic is already in the user data
998
+ // set) still gets a fresh catalog + bulk.
999
+ if (data && data.type === 'cursor-snapshot' && typeof data.topic === 'string') {
1000
+ tracker.snapshot(ws, data.topic, platform);
1001
+ return;
763
1002
  }
1003
+ _warnCursorHooksMessageShape(data);
764
1004
  },
765
1005
  close(ws, { platform }) {
766
1006
  return tracker.remove(ws, platform);
@@ -770,3 +1010,40 @@ export function createCursor(client, options = {}) {
770
1010
 
771
1011
  return tracker;
772
1012
  }
1013
+
1014
+ /**
1015
+ * One-time dev-warn dedup for `cursor.hooks.message` shape misuse. The most
1016
+ * common cause is wiring the hook against `createMessage({ onUnhandled })`
1017
+ * which passes raw bytes, not a parsed envelope. The fix is to switch to
1018
+ * `createMessage({ onJsonMessage(ws, msg, platform) { ... } })` (svelte-
1019
+ * realtime >= 0.5.9 + svelte-adapter-uws >= 0.5.3), which forwards the
1020
+ * parsed object directly.
1021
+ */
1022
+ let _cursorHooksMessageBadShapeWarned = false;
1023
+
1024
+ /**
1025
+ * @param {any} data
1026
+ */
1027
+ function _warnCursorHooksMessageShape(data) {
1028
+ if (_cursorHooksMessageBadShapeWarned) return;
1029
+ _cursorHooksMessageBadShapeWarned = true;
1030
+ const got = data instanceof ArrayBuffer
1031
+ ? 'ArrayBuffer (raw bytes -- did you wire this from createMessage({onUnhandled}) ?)'
1032
+ : Array.isArray(data)
1033
+ ? 'Array'
1034
+ : data === null
1035
+ ? 'null'
1036
+ : typeof data === 'object'
1037
+ ? 'object with data.type=' + String(data.type)
1038
+ : typeof data;
1039
+ console.warn(
1040
+ '[redis/cursor] hooks.message called with unexpected shape (' + got + '). ' +
1041
+ 'Expected a parsed object {type:"cursor", topic, data} or ' +
1042
+ '{type:"cursor-snapshot", topic}. ' +
1043
+ 'If you wired this from `createMessage({ onUnhandled })` and got raw bytes, ' +
1044
+ 'switch to `createMessage({ onJsonMessage(ws, msg, platform) { ... } })` ' +
1045
+ 'which forwards the parsed JSON envelope. ' +
1046
+ 'This warning fires once per process.\n' +
1047
+ ' See: https://svti.me/cursor-hooks-message'
1048
+ );
1049
+ }
package/redis/presence.js CHANGED
@@ -522,8 +522,22 @@ export function createPresence(client, options = {}) {
522
522
  pipe.hpexpire(topicHash, presenceTtlMs, 'FIELDS', 1, userKey);
523
523
  }
524
524
  if (activePlatform) {
525
- const keys = [...data.keys()];
526
- activePlatform.publish('__presence:' + topic, 'heartbeat', keys);
525
+ // Publish a `{userKey: data}` map (instead of a key-only
526
+ // array) so a client whose entry aged out between
527
+ // heartbeats can re-add it from the heartbeat alone.
528
+ // Pre-fix, the wire carried only `keys` and the client
529
+ // handler could only refresh `existing` entries; an
530
+ // entry the client swept (cross-replica relay latency,
531
+ // brief backpressure, JS thread saturation) could never
532
+ // be recovered without a presence_diff for that user.
533
+ // Older clients fall back gracefully: they see an
534
+ // object instead of an array and skip the legacy
535
+ // "refresh-existing" branch, but the next presence_diff
536
+ // or presence_state still reconciles them.
537
+ /** @type {Record<string, any>} */
538
+ const dataMap = {};
539
+ for (const [userKey, entry] of data) dataMap[userKey] = entry.data;
540
+ activePlatform.publish('__presence:' + topic, 'heartbeat', dataMap);
527
541
  }
528
542
  }
529
543
  }
@@ -1360,6 +1374,26 @@ export function createPresence(client, options = {}) {
1360
1374
  }
1361
1375
  await tracker.join(ws, topic, platform);
1362
1376
  },
1377
+ message(ws, { data, platform }) {
1378
+ // Client-initiated reconnect-snapshot. The presence plugin
1379
+ // client sends `{type:'presence-snapshot', topic}` on every
1380
+ // status==='open' (initial connect + reconnect). Re-emits
1381
+ // `presence_state` to the requesting ws via `tracker.sync`,
1382
+ // which is the same path that fires on a fresh subscribe.
1383
+ // Symmetric to cursor's `cursor-snapshot` text frame.
1384
+ //
1385
+ // Without this, board-scoped presence stayed stale across
1386
+ // reconnects: a tab that had joined via an RPC saw no
1387
+ // presence_diff during the disconnect window, and on
1388
+ // reconnect its in-memory map was whatever it last knew.
1389
+ // Global presence accidentally self-healed because most
1390
+ // apps call `presence.join('global')` from the `open` hook
1391
+ // which fires on every reconnect; per-board presence does
1392
+ // not have an equivalent auto-rejoin.
1393
+ if (data && data.type === 'presence-snapshot' && typeof data.topic === 'string') {
1394
+ tracker.sync(ws, data.topic, platform).catch(() => { /* surfaced via breaker */ });
1395
+ }
1396
+ },
1363
1397
  async unsubscribe(ws, topic, { platform }) {
1364
1398
  if (topic.startsWith('__presence:')) {
1365
1399
  const realTopic = topic.slice('__presence:'.length);