svelte-adapter-uws-extensions 0.5.4 → 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.4",
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
@@ -74,6 +74,18 @@ export interface CursorEntry {
74
74
  data: any;
75
75
  }
76
76
 
77
+ /**
78
+ * Thrown by `attach()` when the websocket closes before `platform.subscribe`
79
+ * can land. Same shape as `presence.WsClosedError`; catch on `err.code ===
80
+ * 'WS_CLOSED'` for cross-feature handling.
81
+ */
82
+ export class WsClosedError extends Error {
83
+ name: 'WsClosedError';
84
+ code: 'WS_CLOSED';
85
+ operation: string;
86
+ topic: string;
87
+ }
88
+
77
89
  export interface RedisCursorTracker {
78
90
  /**
79
91
  * Opt this connection into receiving cursor updates for `topic`.
@@ -84,6 +96,19 @@ export interface RedisCursorTracker {
84
96
  *
85
97
  * Without `attach`, the publishes in `update` fan out to an empty
86
98
  * subscriber set and no client ever sees a cursor frame.
99
+ *
100
+ * @throws {WsClosedError} (`err.code === 'WS_CLOSED'`) if the websocket
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
+ * when this throws. Snapshot-send failures on an already-subscribed
105
+ * connection are NOT thrown - cursor frames are self-recovering via
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.
87
112
  */
88
113
  attach(ws: any, topic: string, platform: Platform): Promise<void>;
89
114
 
package/redis/cursor.js CHANGED
@@ -40,6 +40,9 @@ import { stripInternal, createSensitiveWarner } from '../shared/sensitive.js';
40
40
  import { scanAndUnlink } from '../shared/redis-scan.js';
41
41
  import { MAX_CURSOR_WS, MAX_CURSOR_TOPICS } from '../shared/caps.js';
42
42
  import { createBusValidator } from '../shared/bus-validate.js';
43
+ import { WsClosedError } from '../shared/errors.js';
44
+
45
+ export { WsClosedError };
43
46
 
44
47
  /** Wire-protocol event names this module emits. */
45
48
  const EVENTS = Object.freeze({
@@ -140,13 +143,22 @@ export function createCursor(client, options = {}) {
140
143
  const mUpdates = m?.counter('cursor_updates_total', 'Cursor update calls', ['topic']);
141
144
  const mBroadcasts = m?.counter('cursor_broadcasts_total', 'Cursor broadcasts sent', ['topic']);
142
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 `ws.subscribe` could complete. Symmetric with `presence_joins_aborted_total`; same `WS_CLOSED` cause.', ['topic', 'reason']);
143
147
 
144
148
  const warnSensitive = createSensitiveWarner('redis/cursor');
145
149
 
146
150
  let connCounter = 0;
147
151
 
148
152
  function safeUserData(ws) {
149
- 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
+ }
150
162
  if (!raw || typeof raw !== 'object') return {};
151
163
  const { __subscriptions, remoteAddress, ...safeData } = raw;
152
164
  return safeData;
@@ -391,8 +403,16 @@ export function createCursor(client, options = {}) {
391
403
  * - `lastFlush`: target-anchored timestamp of the most recent flush.
392
404
  * Advanced by `topicThrottleMs` per cycle (not to actual fire time) so
393
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`.
394
414
  *
395
- * @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 }>}
396
416
  */
397
417
  const topicFlush = new Map();
398
418
 
@@ -571,10 +591,21 @@ export function createCursor(client, options = {}) {
571
591
  }
572
592
 
573
593
  /**
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).
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.
578
609
  */
579
610
  function broadcast(topic, key, user, data, platform) {
580
611
  if (topicThrottleMs <= 0) {
@@ -584,17 +615,28 @@ export function createCursor(client, options = {}) {
584
615
 
585
616
  let state = topicFlush.get(topic);
586
617
  if (!state) {
587
- state = { dirty: new Map(), inboundDirty: new Map(), lastFlush: 0 };
618
+ state = { dirty: new Map(), inboundDirty: new Map(), lastFlush: 0, pendingMicroflush: false };
588
619
  topicFlush.set(topic, state);
589
620
  }
590
621
  state.dirty.set(key, { user, data, platform });
591
622
 
592
623
  const now = Date.now();
593
624
  if (now - state.lastFlush >= topicThrottleMs) {
594
- // 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.
595
627
  state.lastFlush = now;
596
- flushBoth(topic, state);
597
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
+ }
598
640
  return;
599
641
  }
600
642
 
@@ -627,16 +669,26 @@ export function createCursor(client, options = {}) {
627
669
 
628
670
  let state = topicFlush.get(topic);
629
671
  if (!state) {
630
- state = { dirty: new Map(), inboundDirty: new Map(), lastFlush: 0 };
672
+ state = { dirty: new Map(), inboundDirty: new Map(), lastFlush: 0, pendingMicroflush: false };
631
673
  topicFlush.set(topic, state);
632
674
  }
633
675
  state.inboundDirty.set(key, { data, platform });
634
676
 
635
677
  const now = Date.now();
636
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.
637
682
  state.lastFlush = now;
638
- flushBoth(topic, state);
639
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
+ }
640
692
  return;
641
693
  }
642
694
 
@@ -671,11 +723,28 @@ export function createCursor(client, options = {}) {
671
723
  /** @type {RedisCursorTracker} */
672
724
  const tracker = {
673
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:...')`.
674
734
  try {
675
- platform.subscribe(ws, '__cursor:' + topic);
735
+ ws.subscribe('__cursor:' + topic);
676
736
  } catch {
677
- return;
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.
742
+ mAttachesAborted?.inc({ topic: mt(topic), reason: 'ws_closed' });
743
+ throw new WsClosedError('cursor.attach', topic);
678
744
  }
745
+ // snapshot() itself swallows ws-closed during platform.send (the
746
+ // state is already committed; clients recover via the next bulk
747
+ // frame). Intentional asymmetry with subscribe failure above.
679
748
  await tracker.snapshot(ws, topic, platform);
680
749
  },
681
750
 
@@ -51,10 +51,29 @@ export interface PresenceMetricsSnapshot {
51
51
  staleCleanedTotal: number;
52
52
  }
53
53
 
54
+ /**
55
+ * Thrown by `join()` when the websocket closes during an async gap before
56
+ * the join can commit. Server-side state is fully rolled back before the
57
+ * throw. Catch on `err.code === 'WS_CLOSED'` rather than the class - the
58
+ * same code is shared with `cursor.attach` and any future RPC-shaped
59
+ * operation in this package.
60
+ */
61
+ export class WsClosedError extends Error {
62
+ name: 'WsClosedError';
63
+ code: 'WS_CLOSED';
64
+ operation: string;
65
+ topic: string;
66
+ }
67
+
54
68
  export interface RedisPresenceTracker {
55
69
  /**
56
70
  * Add a connection to a topic's presence.
57
71
  * Ignores `__`-prefixed topics. Idempotent.
72
+ *
73
+ * @throws {WsClosedError} (`err.code === 'WS_CLOSED'`) if the websocket
74
+ * closes during one of the internal async gaps (subscribe, Redis eval,
75
+ * snapshot fetch, ws.subscribe). Server state is rolled back before
76
+ * the throw; callers do not need to compensate.
58
77
  */
59
78
  join(ws: any, topic: string, platform: Platform): Promise<void>;
60
79
 
package/redis/presence.js CHANGED
@@ -47,6 +47,9 @@ import { stripInternal, createSensitiveWarner } from '../shared/sensitive.js';
47
47
  import { scanAndUnlink } from '../shared/redis-scan.js';
48
48
  import { withBreaker } from '../shared/breaker.js';
49
49
  import { MAX_PRESENCE_WS, MAX_PRESENCE_TOPICS } from '../shared/caps.js';
50
+ import { WsClosedError } from '../shared/errors.js';
51
+
52
+ export { WsClosedError };
50
53
 
51
54
  /**
52
55
  * Lua script for atomic JOIN. Sets this instance's field on the per-user
@@ -247,6 +250,7 @@ export function createPresence(client, options = {}) {
247
250
  const m = options.metrics;
248
251
  const mt = m?.mapTopic;
249
252
  const mJoins = m?.counter('presence_joins_total', 'Presence join events', ['topic']);
253
+ const mJoinsAborted = m?.counter('presence_joins_aborted_total', 'Presence join calls that aborted before commit because the websocket closed during an async gap. Server state was rolled back before the throw. Distinct from `presence_joins_total` (commits) and from generic RPC error metrics (which bucket all throws together regardless of cause).', ['topic', 'reason']);
250
254
  const mLeaves = m?.counter('presence_leaves_total', 'Presence leave events', ['topic']);
251
255
  const mHeartbeats = m?.counter('presence_heartbeats_total', 'Heartbeat refresh cycles');
252
256
  const mTotalOnline = m?.gauge('presence_total_online', 'Unique users present per topic on this instance', ['topic']);
@@ -985,6 +989,15 @@ export function createPresence(client, options = {}) {
985
989
  }
986
990
  }
987
991
 
992
+ // Throw helper for "ws closed during async gap" paths inside join(). All
993
+ // five callsites need the same metric label and the same typed error;
994
+ // inlining a helper avoids drift between them and keeps each callsite
995
+ // single-line.
996
+ function throwWsClosed(topic) {
997
+ mJoinsAborted?.inc({ topic: mt(topic), reason: 'ws_closed' });
998
+ throw new WsClosedError('presence.join', topic);
999
+ }
1000
+
988
1001
  /** @type {RedisPresenceTracker} */
989
1002
  const tracker = {
990
1003
  async join(ws, topic, platform) {
@@ -1071,11 +1084,15 @@ export function createPresence(client, options = {}) {
1071
1084
  throw err;
1072
1085
  }
1073
1086
 
1074
- if (!wsTopics.has(ws)) return;
1087
+ // ws closed during `await subscribeToTopic`. The close hook already
1088
+ // ran leaveAll, which swept localCounts / wsTopics for this ws;
1089
+ // no compensating undoJoin needed. Throw so the caller sees the
1090
+ // abort instead of a silent success.
1091
+ if (!wsTopics.has(ws)) throwWsClosed(topic);
1075
1092
 
1076
1093
  try { ws.getBufferedAmount(); } catch {
1077
1094
  await undoJoin(ws, topic, key, data, prevCount, prevData, false, false, platform);
1078
- return;
1095
+ throwWsClosed(topic);
1079
1096
  }
1080
1097
 
1081
1098
  let didRedisWrite = false;
@@ -1108,13 +1125,14 @@ export function createPresence(client, options = {}) {
1108
1125
 
1109
1126
  if (!wsTopics.has(ws)) {
1110
1127
  // ws closed during the eval. Roll back our Redis write so
1111
- // the per-user hash entry does not linger past TTL.
1128
+ // the per-user hash entry does not linger past TTL, then
1129
+ // surface the abort to the caller.
1112
1130
  await redis.eval(
1113
1131
  LEAVE_SCRIPT, 2,
1114
1132
  userHashKey(topic, key), topicHashKey(topic),
1115
1133
  instanceId, key
1116
1134
  ).catch(() => {});
1117
- return;
1135
+ throwWsClosed(topic);
1118
1136
  }
1119
1137
  } else if (prevData !== undefined && !deepEqual(prevData, data)) {
1120
1138
  // Same instance, same user, different `select()` output.
@@ -1157,7 +1175,7 @@ export function createPresence(client, options = {}) {
1157
1175
  ws.subscribe('__presence:' + topic);
1158
1176
  } catch {
1159
1177
  await undoJoin(ws, topic, key, data, prevCount, prevData, didRedisWrite, false, platform);
1160
- return;
1178
+ throwWsClosed(topic);
1161
1179
  }
1162
1180
 
1163
1181
  // If ws closed after subscribe, leave() already handled
@@ -1170,7 +1188,7 @@ export function createPresence(client, options = {}) {
1170
1188
  instanceId, key
1171
1189
  ).catch(() => {});
1172
1190
  }
1173
- return;
1191
+ throwWsClosed(topic);
1174
1192
  }
1175
1193
 
1176
1194
  // Commit localData and activeTopics now that the join is
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
package/shared/errors.js CHANGED
@@ -57,3 +57,41 @@ export class IdempotencyResultTooLargeError extends Error {
57
57
  this.maxBytes = maxBytes;
58
58
  }
59
59
  }
60
+
61
+ /**
62
+ * Thrown by RPC-shaped operations (`presence.join`, `cursor.attach`) when the
63
+ * caller's websocket closes during an async gap before the operation could
64
+ * commit, OR the websocket was already gone by the time the operation
65
+ * resumed from one of its awaits. Server-side state is fully rolled back
66
+ * before the throw so the caller does not need to compensate.
67
+ *
68
+ * Stable contract: `err.code === 'WS_CLOSED'`. Catch on the code, not the
69
+ * class - future RPC-shaped operations that hit the same pattern throw the
70
+ * same code. The `operation` field carries the dotted path (e.g.
71
+ * `'presence.join'`) for operators that want to bucket by feature without
72
+ * parsing the message.
73
+ *
74
+ * Pattern in callers:
75
+ *
76
+ * ```js
77
+ * try {
78
+ * await presence.join(ws, topic, platform);
79
+ * } catch (err) {
80
+ * if (err.code === 'WS_CLOSED') return; // ws already gone, no compensation needed
81
+ * throw err;
82
+ * }
83
+ * ```
84
+ */
85
+ export class WsClosedError extends Error {
86
+ /**
87
+ * @param {string} operation - Dotted operation path, e.g. `'presence.join'`.
88
+ * @param {string} topic
89
+ */
90
+ constructor(operation, topic) {
91
+ super(`${operation}: websocket closed during async gap (topic="${topic}"); rolled back`);
92
+ this.name = 'WsClosedError';
93
+ this.code = 'WS_CLOSED';
94
+ this.operation = operation;
95
+ this.topic = topic;
96
+ }
97
+ }
@@ -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;