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 +1 -1
- package/redis/cursor.d.ts +20 -0
- package/redis/cursor.js +14 -1
- package/redis/presence.d.ts +19 -0
- package/redis/presence.js +24 -6
- package/shared/errors.js +38 -0
package/package.json
CHANGED
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
|
-
|
|
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
|
|
package/redis/presence.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|