svelte-adapter-uws-extensions 0.5.4 → 0.5.5

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.5",
4
4
  "publishConfig": {
5
5
  "tag": "latest"
6
6
  },
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,14 @@ 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 `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
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.
87
107
  */
88
108
  attach(ws: any, topic: string, platform: Platform): Promise<void>;
89
109
 
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,6 +143,7 @@ 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 `platform.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
 
@@ -674,8 +678,17 @@ export function createCursor(client, options = {}) {
674
678
  try {
675
679
  platform.subscribe(ws, '__cursor:' + topic);
676
680
  } catch {
677
- return;
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.
686
+ mAttachesAborted?.inc({ topic: mt(topic), reason: 'ws_closed' });
687
+ throw new WsClosedError('cursor.attach', topic);
678
688
  }
689
+ // snapshot() itself swallows ws-closed during platform.send (the
690
+ // state is already committed; clients recover via the next bulk
691
+ // frame). Intentional asymmetry with subscribe failure above.
679
692
  await tracker.snapshot(ws, topic, platform);
680
693
  },
681
694
 
@@ -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/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
+ }