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 +2 -2
- package/redis/cursor.d.ts +9 -4
- package/redis/cursor.js +74 -18
- package/redis/pubsub.js +1 -0
- package/redis/registry.js +11 -3
- package/redis/sharded-pubsub.js +1 -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
|
@@ -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 `
|
|
102
|
-
* to roll back (`wsState` is only created on `update`); callers
|
|
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 `
|
|
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
|
-
|
|
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
|
-
*
|
|
580
|
-
* since the last flush
|
|
581
|
-
*
|
|
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
|
-
//
|
|
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
|
-
|
|
735
|
+
ws.subscribe('__cursor:' + topic);
|
|
680
736
|
} catch {
|
|
681
|
-
//
|
|
682
|
-
//
|
|
683
|
-
//
|
|
684
|
-
//
|
|
685
|
-
//
|
|
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
|
-
|
|
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/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;
|