svelte-adapter-uws-extensions 0.5.5 → 0.5.7

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.5",
3
+ "version": "0.5.7",
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.4"
157
+ "svelte-adapter-uws": "^0.5.6"
158
158
  },
159
159
  "dependencies": {
160
160
  "ioredis": "^5.0.0"
package/redis/cursor.d.ts CHANGED
@@ -98,12 +98,17 @@ export interface RedisCursorTracker {
98
98
  * subscriber set and no client ever sees a cursor frame.
99
99
  *
100
100
  * @throws {WsClosedError} (`err.code === 'WS_CLOSED'`) if the websocket
101
- * has already closed by the time `platform.subscribe` runs. No state
102
- * to roll back (`wsState` is only created on `update`); callers do
103
- * not need to compensate. The follow-up `snapshot()` call is skipped
101
+ * has already closed by the time the underlying `ws.subscribe` runs.
102
+ * No state to roll back (`wsState` is only created on `update`); callers
103
+ * do not need to compensate. The follow-up `snapshot()` call is skipped
104
104
  * when this throws. Snapshot-send failures on an already-subscribed
105
105
  * connection are NOT thrown - cursor frames are self-recovering via
106
- * the next bulk tick.
106
+ * the next bulk tick. Note: this module uses the uWS-native
107
+ * `ws.subscribe` for `__cursor:*` topics (mirroring presence's
108
+ * `__presence:*` path), not `platform.subscribe` - as of adapter 0.5.5
109
+ * `platform.subscribe` swallows closed-ws throws and returns the same
110
+ * sentinel on success and on close, which would silently break this
111
+ * throw contract.
107
112
  */
108
113
  attach(ws: any, topic: string, platform: Platform): Promise<void>;
109
114
 
package/redis/cursor.js CHANGED
@@ -143,14 +143,22 @@ export function createCursor(client, options = {}) {
143
143
  const mUpdates = m?.counter('cursor_updates_total', 'Cursor update calls', ['topic']);
144
144
  const mBroadcasts = m?.counter('cursor_broadcasts_total', 'Cursor broadcasts sent', ['topic']);
145
145
  const mThrottled = m?.counter('cursor_throttled_total', 'Cursor updates deferred by throttle', ['topic']);
146
- const mAttachesAborted = m?.counter('cursor_attaches_aborted_total', 'Cursor attach calls that aborted because the websocket closed before `platform.subscribe` could complete. Symmetric with `presence_joins_aborted_total`; same `WS_CLOSED` cause.', ['topic', 'reason']);
146
+ const mAttachesAborted = m?.counter('cursor_attaches_aborted_total', 'Cursor attach calls that aborted because the websocket closed before `ws.subscribe` could complete. Symmetric with `presence_joins_aborted_total`; same `WS_CLOSED` cause.', ['topic', 'reason']);
147
147
 
148
148
  const warnSensitive = createSensitiveWarner('redis/cursor');
149
149
 
150
150
  let connCounter = 0;
151
151
 
152
152
  function safeUserData(ws) {
153
- const raw = typeof ws.getUserData === 'function' ? ws.getUserData() : {};
153
+ // Closed-WS race: getWsState (called from `update`) may reach here
154
+ // after an `await` that outlasted the socket; `ws.getUserData()`
155
+ // throws on a freed native handle. Fall back to an empty userData
156
+ // rather than crashing the worker. Matches adapter 0.5.5's
157
+ // `plugins/cursor/server.js getWsState` guard.
158
+ let raw = {};
159
+ if (typeof ws.getUserData === 'function') {
160
+ try { raw = ws.getUserData(); } catch { raw = {}; }
161
+ }
154
162
  if (!raw || typeof raw !== 'object') return {};
155
163
  const { __subscriptions, remoteAddress, ...safeData } = raw;
156
164
  return safeData;
@@ -395,6 +403,9 @@ export function createCursor(client, options = {}) {
395
403
  * - `lastFlush`: target-anchored timestamp of the most recent flush.
396
404
  * Advanced by `topicThrottleMs` per cycle (not to actual fire time) so
397
405
  * a single late tick does not compound drift on subsequent cycles.
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.
398
409
  *
399
410
  * @type {Map<string, { dirty: Map<string, { user: any, data: any, platform: any }>, inboundDirty: Map<string, { data: any, platform: any }>, lastFlush: number }>}
400
411
  */
@@ -554,9 +565,10 @@ export function createCursor(client, options = {}) {
554
565
  dirtyTopics.delete(topic);
555
566
 
556
567
  // Target-anchored: advance lastFlush by the cadence amount.
557
- // Multi-cycle backlog collapse to `now` so the next leading-
558
- // edge check `(now - lastFlush) >= topicThrottleMs` works as
559
- // expected without firing every queued cycle on this turn.
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.
560
572
  state.lastFlush = drift < topicThrottleMs ? deadline : now;
561
573
  } else if (deadline < nextDeadline) {
562
574
  nextDeadline = deadline;
@@ -575,10 +587,31 @@ export function createCursor(client, options = {}) {
575
587
  }
576
588
 
577
589
  /**
578
- * Schedule a local cursor for the next coalesced flush. The leading-
579
- * edge check fires synchronously when `topicThrottleMs` has elapsed
580
- * since the last flush (preserves the contract that the first call on
581
- * an idle topic publishes immediately, without a setTimeout(0) detour).
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.
594
+ *
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.
582
615
  */
583
616
  function broadcast(topic, key, user, data, platform) {
584
617
  if (topicThrottleMs <= 0) {
@@ -588,23 +621,19 @@ export function createCursor(client, options = {}) {
588
621
 
589
622
  let state = topicFlush.get(topic);
590
623
  if (!state) {
591
- state = { dirty: new Map(), inboundDirty: new Map(), lastFlush: 0 };
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 };
592
629
  topicFlush.set(topic, state);
593
630
  }
594
631
  state.dirty.set(key, { user, data, platform });
595
-
596
- const now = Date.now();
597
- if (now - state.lastFlush >= topicThrottleMs) {
598
- // Leading-edge synchronous flush.
599
- state.lastFlush = now;
600
- flushBoth(topic, state);
601
- dirtyTopics.delete(topic);
602
- return;
603
- }
604
-
605
- // Within window: trailing-edge flush via the scheduler tick.
606
632
  dirtyTopics.add(topic);
607
- armTick(Math.max(0, topicThrottleMs - (now - state.lastFlush)));
633
+
634
+ const elapsed = Date.now() - state.lastFlush;
635
+ const delay = elapsed >= topicThrottleMs ? 0 : topicThrottleMs - elapsed;
636
+ armTick(delay);
608
637
  }
609
638
 
610
639
  /**
@@ -631,21 +660,19 @@ export function createCursor(client, options = {}) {
631
660
 
632
661
  let state = topicFlush.get(topic);
633
662
  if (!state) {
634
- state = { dirty: new Map(), inboundDirty: new Map(), lastFlush: 0 };
663
+ state = { dirty: new Map(), inboundDirty: new Map(), lastFlush: Date.now() - topicThrottleMs };
635
664
  topicFlush.set(topic, state);
636
665
  }
637
666
  state.inboundDirty.set(key, { data, platform });
638
-
639
- const now = Date.now();
640
- if (now - state.lastFlush >= topicThrottleMs) {
641
- state.lastFlush = now;
642
- flushBoth(topic, state);
643
- dirtyTopics.delete(topic);
644
- return;
645
- }
646
-
647
667
  dirtyTopics.add(topic);
648
- armTick(Math.max(0, topicThrottleMs - (now - state.lastFlush)));
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);
649
676
  }
650
677
 
651
678
  async function broadcastRemove(topic, key, platform) {
@@ -675,14 +702,22 @@ export function createCursor(client, options = {}) {
675
702
  /** @type {RedisCursorTracker} */
676
703
  const tracker = {
677
704
  async attach(ws, topic, platform) {
705
+ // Raw `ws.subscribe` (uWS-native) NOT `platform.subscribe`. As of
706
+ // adapter 0.5.5 `platform.subscribe` swallows uWS's "closed
707
+ // websocket" throw and returns the same `null` sentinel it returns
708
+ // on success, so a try/catch around `platform.subscribe` cannot
709
+ // distinguish closed-ws from success without racing on
710
+ // `platform.closedWsAborts`. uWS-native `ws.subscribe` still throws
711
+ // on closed-ws, so the throw-WsClosedError contract holds. Mirrors
712
+ // what `presence.join` already does with `ws.subscribe('__presence:...')`.
678
713
  try {
679
- platform.subscribe(ws, '__cursor:' + topic);
714
+ ws.subscribe('__cursor:' + topic);
680
715
  } catch {
681
- // ws closed before subscribe could land. No state to roll back
682
- // (no wsState entry exists yet; that is only created on update).
683
- // Throw so the caller can distinguish a no-op-and-rollback from
684
- // a successful attach; without this the RPC metric reports
685
- // status=ok for connections that never received cursor frames.
716
+ // No state to roll back (no `wsState` entry exists yet; that
717
+ // is only created on `update`). Throw so the caller can
718
+ // distinguish a no-op-and-rollback from a successful attach;
719
+ // without this the RPC metric reports `status=ok` for
720
+ // connections that never received cursor frames.
686
721
  mAttachesAborted?.inc({ topic: mt(topic), reason: 'ws_closed' });
687
722
  throw new WsClosedError('cursor.attach', topic);
688
723
  }
package/redis/pubsub.js CHANGED
@@ -242,6 +242,7 @@ export function createPubSubBus(client, options = {}) {
242
242
  checkSubscribe: platform.checkSubscribe.bind(platform),
243
243
  get maxPayloadLength() { return platform.maxPayloadLength; },
244
244
  bufferedAmount: platform.bufferedAmount.bind(platform),
245
+ get closedWsAborts() { return platform.closedWsAborts ?? 0; },
245
246
  // Framework conventions stashed on the source platform by
246
247
  // app init code (e.g. `platform.replay = createReplay(...)`,
247
248
  // `platform.redis = ioredisClient`) must survive the wrap so
package/redis/registry.js CHANGED
@@ -1008,9 +1008,17 @@ export function createConnectionRegistry(client, options) {
1008
1008
  hooks: {
1009
1009
  async open(ws, ctx) {
1010
1010
  await ensureSubscriber(ctx?.platform);
1011
- const userId = identify(ws);
1012
- if (!userId) return;
1013
- const ud = ws.getUserData ? ws.getUserData() : {};
1011
+ // Closed-WS race: the ws may have closed during the
1012
+ // `await ensureSubscriber` above. `identify(ws)` and the
1013
+ // subsequent `ws.getUserData()` both throw on a freed
1014
+ // native handle; bail silently rather than crashing the
1015
+ // worker. No state to roll back yet.
1016
+ let userId, ud;
1017
+ try {
1018
+ userId = identify(ws);
1019
+ if (!userId) return;
1020
+ ud = ws.getUserData ? ws.getUserData() : {};
1021
+ } catch { return; }
1014
1022
  // Read the session id via the adapter's slot symbol.
1015
1023
  const sessionId = sessionIdFromUserData(ud);
1016
1024
  if (!sessionId) return;
@@ -531,6 +531,7 @@ export function createShardedBus(client, options = {}) {
531
531
  checkSubscribe: platform.checkSubscribe.bind(platform),
532
532
  get maxPayloadLength() { return platform.maxPayloadLength; },
533
533
  bufferedAmount: platform.bufferedAmount.bind(platform),
534
+ get closedWsAborts() { return platform.closedWsAborts ?? 0; },
534
535
  // Framework conventions stashed on the source platform by
535
536
  // app init code (e.g. `platform.replay = createReplay(...)`)
536
537
  // must survive the wrap so downstream framework auto-routing
@@ -16,7 +16,8 @@ export const PLATFORM_KEYS = Object.freeze([
16
16
  'pressure', 'onPressure', 'onPublishRate',
17
17
  'subscribers', 'subscribe', 'unsubscribe', 'checkSubscribe',
18
18
  'topic',
19
- 'maxPayloadLength', 'bufferedAmount'
19
+ 'maxPayloadLength', 'bufferedAmount',
20
+ 'closedWsAborts'
20
21
  ]);
21
22
 
22
23
  /**
@@ -76,6 +77,12 @@ export function mockPlatform() {
76
77
  bufferedAmount(_ws) {
77
78
  return 0;
78
79
  },
80
+ // Mirrors adapter 0.5.5's `platform.closedWsAborts` counter.
81
+ // Tests that want to simulate a non-zero value can reassign
82
+ // `p.closedWsAborts` directly; the default zero matches a healthy
83
+ // worker and keeps the parity-test surface symmetric with the
84
+ // adapter's real platform shape.
85
+ closedWsAborts: 0,
79
86
  publish(topic, event, data, options) {
80
87
  p.published.push({ topic, event, data, options });
81
88
  return true;