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 +2 -2
- package/redis/cursor.d.ts +25 -0
- package/redis/cursor.js +82 -13
- package/redis/presence.d.ts +19 -0
- package/redis/presence.js +24 -6
- package/redis/pubsub.js +1 -0
- package/redis/registry.js +11 -3
- package/redis/sharded-pubsub.js +1 -0
- package/shared/errors.js +38 -0
- package/testing/mock-platform.js +8 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "svelte-adapter-uws-extensions",
|
|
3
|
-
"version": "0.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.
|
|
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
|
-
|
|
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
|
-
*
|
|
576
|
-
* since the last flush
|
|
577
|
-
*
|
|
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
|
-
//
|
|
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
|
-
|
|
735
|
+
ws.subscribe('__cursor:' + topic);
|
|
676
736
|
} catch {
|
|
677
|
-
|
|
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
|
|
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/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
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
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;
|
package/redis/sharded-pubsub.js
CHANGED
|
@@ -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
|
+
}
|
package/testing/mock-platform.js
CHANGED
|
@@ -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;
|