svelte-adapter-uws-extensions 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/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.6",
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.5"
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,8 +403,16 @@ 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
+ * - `pendingMicroflush`: a leading-edge flush is queued for the current
407
+ * cadence slot; co-arriving broadcasts (from either local OR peer-
408
+ * relay sources) in the same JS pass append to `dirty` /
409
+ * `inboundDirty` and ship as one combined frame when the microtask
410
+ * runs. Without this, a single co-arriving cursor fired alone as a
411
+ * single-cursor UPDATE while every other cursor in the same pass
412
+ * queued for the trailing tick - 86 percent fragmentation observed at
413
+ * 1000-mover load on Hetzner CCX13 with `topicThrottle: 8`.
398
414
  *
399
- * @type {Map<string, { dirty: Map<string, { user: any, data: any, platform: any }>, inboundDirty: Map<string, { data: any, platform: any }>, lastFlush: number }>}
415
+ * @type {Map<string, { dirty: Map<string, { user: any, data: any, platform: any }>, inboundDirty: Map<string, { data: any, platform: any }>, lastFlush: number, pendingMicroflush: boolean }>}
400
416
  */
401
417
  const topicFlush = new Map();
402
418
 
@@ -575,10 +591,21 @@ export function createCursor(client, options = {}) {
575
591
  }
576
592
 
577
593
  /**
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).
594
+ * Schedule a local cursor for the next coalesced flush. The leading-edge
595
+ * check claims the cadence slot synchronously when `topicThrottleMs` has
596
+ * elapsed since the last flush, then defers the actual `flushBoth` by one
597
+ * microtask. Co-arriving broadcasts in the same JS pass (from this
598
+ * function OR `enqueueInbound` - they share `state.pendingMicroflush`)
599
+ * see `now - state.lastFlush < topicThrottleMs` and take the trailing
600
+ * path, accumulating into `state.dirty` / `state.inboundDirty`. The
601
+ * microtask then flushes everything as one combined frame.
602
+ *
603
+ * Without this defer, the first cursor in a post-pause burst fires alone
604
+ * as a single-cursor UPDATE while every co-arriving cursor in the same
605
+ * pass queues for the trailing tick. Demo measured 86 percent
606
+ * fragmentation (1794 single UPDATEs vs 38 BULKs in 30s) at 1000-mover
607
+ * load on Hetzner CCX13 with `topicThrottle: 8`. After the defer:
608
+ * single UPDATE rate collapses, BULK rate matches cycle rate.
582
609
  */
583
610
  function broadcast(topic, key, user, data, platform) {
584
611
  if (topicThrottleMs <= 0) {
@@ -588,17 +615,28 @@ export function createCursor(client, options = {}) {
588
615
 
589
616
  let state = topicFlush.get(topic);
590
617
  if (!state) {
591
- state = { dirty: new Map(), inboundDirty: new Map(), lastFlush: 0 };
618
+ state = { dirty: new Map(), inboundDirty: new Map(), lastFlush: 0, pendingMicroflush: false };
592
619
  topicFlush.set(topic, state);
593
620
  }
594
621
  state.dirty.set(key, { user, data, platform });
595
622
 
596
623
  const now = Date.now();
597
624
  if (now - state.lastFlush >= topicThrottleMs) {
598
- // Leading-edge synchronous flush.
625
+ // Claim the cadence slot synchronously so subsequent broadcasts
626
+ // in the same JS pass take the trailing path and accumulate.
599
627
  state.lastFlush = now;
600
- flushBoth(topic, state);
601
628
  dirtyTopics.delete(topic);
629
+ if (!state.pendingMicroflush) {
630
+ state.pendingMicroflush = true;
631
+ queueMicrotask(() => {
632
+ state.pendingMicroflush = false;
633
+ // flushBoth clears `dirty` and `inboundDirty` internally
634
+ // and early-returns on empty; this guard saves the
635
+ // for-of entry cost on the empty case.
636
+ if (state.dirty.size === 0 && state.inboundDirty.size === 0) return;
637
+ flushBoth(topic, state);
638
+ });
639
+ }
602
640
  return;
603
641
  }
604
642
 
@@ -631,16 +669,26 @@ export function createCursor(client, options = {}) {
631
669
 
632
670
  let state = topicFlush.get(topic);
633
671
  if (!state) {
634
- state = { dirty: new Map(), inboundDirty: new Map(), lastFlush: 0 };
672
+ state = { dirty: new Map(), inboundDirty: new Map(), lastFlush: 0, pendingMicroflush: false };
635
673
  topicFlush.set(topic, state);
636
674
  }
637
675
  state.inboundDirty.set(key, { data, platform });
638
676
 
639
677
  const now = Date.now();
640
678
  if (now - state.lastFlush >= topicThrottleMs) {
679
+ // Symmetric with `broadcast()`: same shared `pendingMicroflush`
680
+ // per topic state, so if a local broadcast already claimed the
681
+ // slot the peer-relay entry just appends and ships with it.
641
682
  state.lastFlush = now;
642
- flushBoth(topic, state);
643
683
  dirtyTopics.delete(topic);
684
+ if (!state.pendingMicroflush) {
685
+ state.pendingMicroflush = true;
686
+ queueMicrotask(() => {
687
+ state.pendingMicroflush = false;
688
+ if (state.dirty.size === 0 && state.inboundDirty.size === 0) return;
689
+ flushBoth(topic, state);
690
+ });
691
+ }
644
692
  return;
645
693
  }
646
694
 
@@ -675,14 +723,22 @@ export function createCursor(client, options = {}) {
675
723
  /** @type {RedisCursorTracker} */
676
724
  const tracker = {
677
725
  async attach(ws, topic, platform) {
726
+ // Raw `ws.subscribe` (uWS-native) NOT `platform.subscribe`. As of
727
+ // adapter 0.5.5 `platform.subscribe` swallows uWS's "closed
728
+ // websocket" throw and returns the same `null` sentinel it returns
729
+ // on success, so a try/catch around `platform.subscribe` cannot
730
+ // distinguish closed-ws from success without racing on
731
+ // `platform.closedWsAborts`. uWS-native `ws.subscribe` still throws
732
+ // on closed-ws, so the throw-WsClosedError contract holds. Mirrors
733
+ // what `presence.join` already does with `ws.subscribe('__presence:...')`.
678
734
  try {
679
- platform.subscribe(ws, '__cursor:' + topic);
735
+ ws.subscribe('__cursor:' + topic);
680
736
  } 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.
737
+ // No state to roll back (no `wsState` entry exists yet; that
738
+ // is only created on `update`). Throw so the caller can
739
+ // distinguish a no-op-and-rollback from a successful attach;
740
+ // without this the RPC metric reports `status=ok` for
741
+ // connections that never received cursor frames.
686
742
  mAttachesAborted?.inc({ topic: mt(topic), reason: 'ws_closed' });
687
743
  throw new WsClosedError('cursor.attach', topic);
688
744
  }
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;