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 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 leading-edge + trailing-edge throttle:
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, 'update' {x:0} (leading edge of both)
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 [trailing timer fires] --> 'bulk' [{key:A, data:{x:5}}, {key:B, data:{x:0}}]
2914
+ t=16 [tick timer fires] --> 'bulk' [{key:A, data:{x:5}}, {key:B, data:{x:0}}]
2914
2915
  ```
2915
2916
 
2916
- The trailing edges ensure you always see where each cursor stopped, even when the user stops moving mid-window.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-adapter-uws",
3
- "version": "0.5.5",
3
+ "version": "0.5.6",
4
4
  "publishConfig": {
5
5
  "tag": "latest"
6
6
  },
@@ -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 directly publish when disabled.
386
+ * `topicThrottle` is enabled, or publish immediately when disabled.
387
387
  *
388
- * Leading-edge claims the cadence slot synchronously (lastFlush =
389
- * now) but defers the actual flush by one microtask so co-arriving
390
- * broadcasts in the same JS pass batch into a single bulk frame.
391
- * Without the microtask defer, an event-loop pause > topicThrottleMs
392
- * caused the post-pause first cursor to fire alone (single-cursor
393
- * UPDATE) while every other cursor in the same burst queued to the
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
- * Trailing-edge fires via the single tracker-wide `tickTimer` for
402
- * broadcasts that land mid-window.
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
- state = { dirty: new Map(), lastFlush: 0, pendingMicroflush: false };
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
- armTick(Math.max(0, topicThrottleMs - (now - state.lastFlush)));
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} */