svelte-adapter-uws 0.5.5 → 0.5.6
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/README.md +7 -6
- package/package.json +1 -1
- package/plugins/cursor/server.js +31 -36
package/README.md
CHANGED
|
@@ -2898,22 +2898,23 @@ const positions = cursor('canvas', { maxAge: 30_000 });
|
|
|
2898
2898
|
|
|
2899
2899
|
#### How throttle works
|
|
2900
2900
|
|
|
2901
|
-
The cursor plugin uses two layers of
|
|
2901
|
+
The cursor plugin uses two layers of throttle:
|
|
2902
2902
|
|
|
2903
|
-
1. **`throttle`** caps how often a single user broadcasts on a single topic.
|
|
2904
|
-
2. **`topicThrottle`** caps how often a topic emits a frame at all. Multiple movers in the same window coalesce into one `bulk` array; a single mover in the window emits one `update`.
|
|
2903
|
+
1. **`throttle`** caps how often a single user broadcasts on a single topic. Leading edge fires the first move immediately; subsequent moves within the window are stored and a trailing timer flushes the latest position at the window boundary.
|
|
2904
|
+
2. **`topicThrottle`** caps how often a topic emits a frame at all. Every move appends to the topic's dirty set and shares a single tracker-wide timer that fires once per cadence cycle. Multiple movers in the same window coalesce into one `bulk` array; a single mover in the window emits one `update`. There is no synchronous leading-edge fire: every flush goes through the tick, so movers arriving from different sockets (each a separate JS task in production) batch into the same frame regardless of how many task boundaries separate them.
|
|
2905
2905
|
|
|
2906
2906
|
```
|
|
2907
2907
|
throttle: 16, topicThrottle: 16
|
|
2908
2908
|
|
|
2909
|
-
t=0 A.update({x:0}) --> 'join' A
|
|
2909
|
+
t=0 A.update({x:0}) --> 'join' A (catalog channel)
|
|
2910
|
+
position queued in topic dirty set
|
|
2910
2911
|
t=4 B.update({x:0}) --> 'join' B (catalog channel)
|
|
2911
2912
|
position queued in topic dirty set
|
|
2912
2913
|
t=8 A.update({x:5}) --> queued (entry-level throttle says wait until t=16)
|
|
2913
|
-
t=16 [
|
|
2914
|
+
t=16 [tick timer fires] --> 'bulk' [{key:A, data:{x:5}}, {key:B, data:{x:0}}]
|
|
2914
2915
|
```
|
|
2915
2916
|
|
|
2916
|
-
The
|
|
2917
|
+
Latency cost vs. the alternate "fire-the-first-mover-synchronously" design: the first mover on an idle topic waits up to `topicThrottleMs` before its frame leaves. At the default 16 ms (~60 Hz) that's one frame-budget; well below the perceptual floor for cursor. The cost buys cross-socket coalescing - without it, the first mover from each socket fragments out as its own single-cursor `update` because uWS dispatches each WS message as its own JS task and microtasks drain between dispatches.
|
|
2917
2918
|
|
|
2918
2919
|
#### Limitations
|
|
2919
2920
|
|
package/package.json
CHANGED
package/plugins/cursor/server.js
CHANGED
|
@@ -383,23 +383,30 @@ export function createCursor(options = {}) {
|
|
|
383
383
|
|
|
384
384
|
/**
|
|
385
385
|
* Route a broadcast through the per-topic coalesce window when
|
|
386
|
-
* `topicThrottle` is enabled, or
|
|
386
|
+
* `topicThrottle` is enabled, or publish immediately when disabled.
|
|
387
387
|
*
|
|
388
|
-
*
|
|
389
|
-
*
|
|
390
|
-
*
|
|
391
|
-
*
|
|
392
|
-
*
|
|
393
|
-
*
|
|
394
|
-
* trailing tick: under sustained pressure (30K RPCs/sec/worker) this
|
|
395
|
-
* fragmented 86% of cursor frames into single-cursor UPDATEs.
|
|
396
|
-
* Microtasks run after the current synchronous code completes but
|
|
397
|
-
* before the next I/O / setTimeout / event-loop tick, so any
|
|
398
|
-
* subsequent broadcast() in the same handler batch adds itself to
|
|
399
|
-
* `dirty` before the flush runs.
|
|
388
|
+
* Every broadcast appends to `dirty` and arms (or shares) the
|
|
389
|
+
* tracker-wide tick timer. When the cadence window has already
|
|
390
|
+
* elapsed since the last flush, the tick is armed at delay 0 so it
|
|
391
|
+
* fires on the next event-loop iteration; otherwise it is armed at
|
|
392
|
+
* the remaining window time. Either way, the actual fanout happens
|
|
393
|
+
* inside `tick()`, never synchronously.
|
|
400
394
|
*
|
|
401
|
-
*
|
|
402
|
-
*
|
|
395
|
+
* Why no synchronous leading-edge fire: uWS dispatches each WS
|
|
396
|
+
* message as its own JS task. Microtasks drain at the C++ <-> JS
|
|
397
|
+
* boundary between tasks, so a `queueMicrotask`-deferred flush
|
|
398
|
+
* (previous design) runs BEFORE the next socket's message handler -
|
|
399
|
+
* cross-socket coalescing window is zero, and every "first message
|
|
400
|
+
* of a new cadence slot" from any socket fires alone as a single-
|
|
401
|
+
* cursor UPDATE. Going through the tick timer instead schedules a
|
|
402
|
+
* macrotask, which is dequeued only after the poll phase processes
|
|
403
|
+
* every ready message on every socket. All messages dispatched in
|
|
404
|
+
* the same loop iteration end up in one flush.
|
|
405
|
+
*
|
|
406
|
+
* Latency cost: the first cursor on an idle topic waits up to
|
|
407
|
+
* `topicThrottleMs` (one cycle) before its frame leaves. At the
|
|
408
|
+
* default 16 ms / 60 Hz this is one frame-budget; at 8 ms / 125 Hz
|
|
409
|
+
* it is half a frame. Below the perceptual floor for cursor.
|
|
403
410
|
*/
|
|
404
411
|
function broadcast(topic, key, data, platform) {
|
|
405
412
|
if (topicThrottleMs <= 0) {
|
|
@@ -409,31 +416,19 @@ export function createCursor(options = {}) {
|
|
|
409
416
|
|
|
410
417
|
let state = topicFlush.get(topic);
|
|
411
418
|
if (!state) {
|
|
412
|
-
|
|
419
|
+
// Anchor lastFlush one full cycle in the past so the first
|
|
420
|
+
// broadcast on a fresh topic is treated as "cycle ready" and
|
|
421
|
+
// schedules the tick at delay 0 with zero drift, rather than
|
|
422
|
+
// inflating drift stats by Date.now() worth of "lateness".
|
|
423
|
+
state = { dirty: new Map(), lastFlush: Date.now() - topicThrottleMs };
|
|
413
424
|
topicFlush.set(topic, state);
|
|
414
425
|
}
|
|
415
426
|
state.dirty.set(key, { data, platform });
|
|
416
|
-
|
|
417
|
-
const now = Date.now();
|
|
418
|
-
if (now - state.lastFlush >= topicThrottleMs) {
|
|
419
|
-
state.lastFlush = now;
|
|
420
|
-
dirtyTopics.delete(topic);
|
|
421
|
-
// Schedule once per cycle slot; subsequent broadcasts inside
|
|
422
|
-
// the same microtask boundary just append to `state.dirty`.
|
|
423
|
-
if (!state.pendingMicroflush) {
|
|
424
|
-
state.pendingMicroflush = true;
|
|
425
|
-
queueMicrotask(() => {
|
|
426
|
-
state.pendingMicroflush = false;
|
|
427
|
-
if (state.dirty.size === 0) return;
|
|
428
|
-
flushDirty(topic, state.dirty);
|
|
429
|
-
state.dirty.clear();
|
|
430
|
-
});
|
|
431
|
-
}
|
|
432
|
-
return;
|
|
433
|
-
}
|
|
434
|
-
|
|
435
427
|
dirtyTopics.add(topic);
|
|
436
|
-
|
|
428
|
+
|
|
429
|
+
const elapsed = Date.now() - state.lastFlush;
|
|
430
|
+
const delay = elapsed >= topicThrottleMs ? 0 : topicThrottleMs - elapsed;
|
|
431
|
+
armTick(delay);
|
|
437
432
|
}
|
|
438
433
|
|
|
439
434
|
/** @type {CursorTracker} */
|