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 +2 -2
- package/redis/cursor.d.ts +26 -0
- package/redis/cursor.js +319 -42
- package/redis/presence.js +36 -2
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.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.
|
|
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
|
|
362
|
-
*
|
|
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
|
|
392
|
-
*
|
|
393
|
-
*
|
|
394
|
-
*
|
|
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
|
|
475
|
+
function flushBoth(topic, state) {
|
|
397
476
|
const entries = [];
|
|
398
477
|
let flushPlatform = null;
|
|
399
|
-
|
|
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
|
-
|
|
406
|
-
|
|
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 = {
|
|
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
|
-
|
|
594
|
+
// Leading-edge synchronous flush.
|
|
427
595
|
state.lastFlush = now;
|
|
428
|
-
|
|
429
|
-
|
|
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
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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
|
-
|
|
721
|
-
|
|
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
|
-
|
|
743
|
-
|
|
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
|
-
|
|
526
|
-
|
|
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);
|