svelte-adapter-uws-extensions 0.5.5 → 0.5.7
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 -39
- 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.7",
|
|
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.6"
|
|
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,6 +403,9 @@ 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
|
+
* Initialized to `Date.now() - topicThrottleMs` so the first broadcast
|
|
407
|
+
* on a new topic is "cycle ready" without polluting drift stats with
|
|
408
|
+
* the full `Date.now()` lateness an init of 0 would imply.
|
|
398
409
|
*
|
|
399
410
|
* @type {Map<string, { dirty: Map<string, { user: any, data: any, platform: any }>, inboundDirty: Map<string, { data: any, platform: any }>, lastFlush: number }>}
|
|
400
411
|
*/
|
|
@@ -554,9 +565,10 @@ export function createCursor(client, options = {}) {
|
|
|
554
565
|
dirtyTopics.delete(topic);
|
|
555
566
|
|
|
556
567
|
// Target-anchored: advance lastFlush by the cadence amount.
|
|
557
|
-
// Multi-cycle backlog collapse to `now` so the next
|
|
558
|
-
//
|
|
559
|
-
//
|
|
568
|
+
// Multi-cycle backlog collapse to `now` so the next
|
|
569
|
+
// broadcast's `Date.now() - lastFlush >= topicThrottleMs`
|
|
570
|
+
// delay computation does not fire every queued cycle on
|
|
571
|
+
// this turn.
|
|
560
572
|
state.lastFlush = drift < topicThrottleMs ? deadline : now;
|
|
561
573
|
} else if (deadline < nextDeadline) {
|
|
562
574
|
nextDeadline = deadline;
|
|
@@ -575,10 +587,31 @@ export function createCursor(client, options = {}) {
|
|
|
575
587
|
}
|
|
576
588
|
|
|
577
589
|
/**
|
|
578
|
-
* Schedule a local cursor for the next coalesced flush.
|
|
579
|
-
*
|
|
580
|
-
*
|
|
581
|
-
*
|
|
590
|
+
* Schedule a local cursor for the next coalesced flush. Always-tick: every
|
|
591
|
+
* call appends to `state.dirty`, adds the topic to `dirtyTopics`, and arms
|
|
592
|
+
* the tracker-wide tick timer. NO leading-edge synchronous fire and NO
|
|
593
|
+
* microtask defer.
|
|
594
|
+
*
|
|
595
|
+
* Why: uWS dispatches each WS message as its own JS task, and N-API
|
|
596
|
+
* drains microtasks at the C++/JS boundary between dispatches. A
|
|
597
|
+
* `queueMicrotask`-deferred flush fires BEFORE the next socket's message
|
|
598
|
+
* handler runs, so cross-socket coalescing is impossible at the microtask
|
|
599
|
+
* level. `setTimeout(0)` is in libuv's timers phase and fires only after
|
|
600
|
+
* the poll phase processes every ready message on every socket - so all
|
|
601
|
+
* broadcasts dispatched in the same loop iteration end up in one flush
|
|
602
|
+
* regardless of how many task boundaries separate them.
|
|
603
|
+
*
|
|
604
|
+
* 0.5.5/0.5.6 shipped a `queueMicrotask` + `pendingMicroflush` variant
|
|
605
|
+
* built on the wrong dispatch-model assumption (that co-arriving
|
|
606
|
+
* broadcasts share a JS task). Demo measured ~99% single-cursor UPDATE /
|
|
607
|
+
* ~1% BULK at 1000-mover load on the deployed 0.5.6 - essentially the
|
|
608
|
+
* pre-fix shape. The bench validated the assumed input shape (all
|
|
609
|
+
* broadcasts in one synchronous task) instead of the input shape uWS
|
|
610
|
+
* actually produces (per-message tasks separated by microtask drains).
|
|
611
|
+
*
|
|
612
|
+
* First-cursor latency cost of always-tick: up to `topicThrottleMs` (16ms
|
|
613
|
+
* default, one frame budget) before fanout. Below the perceptual floor
|
|
614
|
+
* for cursors. Same trade-off the adapter's bundled cursor plugin makes.
|
|
582
615
|
*/
|
|
583
616
|
function broadcast(topic, key, user, data, platform) {
|
|
584
617
|
if (topicThrottleMs <= 0) {
|
|
@@ -588,23 +621,19 @@ export function createCursor(client, options = {}) {
|
|
|
588
621
|
|
|
589
622
|
let state = topicFlush.get(topic);
|
|
590
623
|
if (!state) {
|
|
591
|
-
|
|
624
|
+
// Anchor `lastFlush` one cycle in the past so the first broadcast
|
|
625
|
+
// is treated as "cycle ready" with zero drift on the very first
|
|
626
|
+
// tick. Without this, `Date.now() - 0` would be a huge "lateness"
|
|
627
|
+
// that pollutes the drift stats forever.
|
|
628
|
+
state = { dirty: new Map(), inboundDirty: new Map(), lastFlush: Date.now() - topicThrottleMs };
|
|
592
629
|
topicFlush.set(topic, state);
|
|
593
630
|
}
|
|
594
631
|
state.dirty.set(key, { user, data, platform });
|
|
595
|
-
|
|
596
|
-
const now = Date.now();
|
|
597
|
-
if (now - state.lastFlush >= topicThrottleMs) {
|
|
598
|
-
// Leading-edge synchronous flush.
|
|
599
|
-
state.lastFlush = now;
|
|
600
|
-
flushBoth(topic, state);
|
|
601
|
-
dirtyTopics.delete(topic);
|
|
602
|
-
return;
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
// Within window: trailing-edge flush via the scheduler tick.
|
|
606
632
|
dirtyTopics.add(topic);
|
|
607
|
-
|
|
633
|
+
|
|
634
|
+
const elapsed = Date.now() - state.lastFlush;
|
|
635
|
+
const delay = elapsed >= topicThrottleMs ? 0 : topicThrottleMs - elapsed;
|
|
636
|
+
armTick(delay);
|
|
608
637
|
}
|
|
609
638
|
|
|
610
639
|
/**
|
|
@@ -631,21 +660,19 @@ export function createCursor(client, options = {}) {
|
|
|
631
660
|
|
|
632
661
|
let state = topicFlush.get(topic);
|
|
633
662
|
if (!state) {
|
|
634
|
-
state = { dirty: new Map(), inboundDirty: new Map(), lastFlush:
|
|
663
|
+
state = { dirty: new Map(), inboundDirty: new Map(), lastFlush: Date.now() - topicThrottleMs };
|
|
635
664
|
topicFlush.set(topic, state);
|
|
636
665
|
}
|
|
637
666
|
state.inboundDirty.set(key, { data, platform });
|
|
638
|
-
|
|
639
|
-
const now = Date.now();
|
|
640
|
-
if (now - state.lastFlush >= topicThrottleMs) {
|
|
641
|
-
state.lastFlush = now;
|
|
642
|
-
flushBoth(topic, state);
|
|
643
|
-
dirtyTopics.delete(topic);
|
|
644
|
-
return;
|
|
645
|
-
}
|
|
646
|
-
|
|
647
667
|
dirtyTopics.add(topic);
|
|
648
|
-
|
|
668
|
+
|
|
669
|
+
// Symmetric with `broadcast()` always-tick: both source paths share
|
|
670
|
+
// the same `state` and the same tracker-wide tick timer, so a local
|
|
671
|
+
// broadcast and a peer-relayed inbound landing in the same loop
|
|
672
|
+
// iteration ship together as one combined frame at the next tick.
|
|
673
|
+
const elapsed = Date.now() - state.lastFlush;
|
|
674
|
+
const delay = elapsed >= topicThrottleMs ? 0 : topicThrottleMs - elapsed;
|
|
675
|
+
armTick(delay);
|
|
649
676
|
}
|
|
650
677
|
|
|
651
678
|
async function broadcastRemove(topic, key, platform) {
|
|
@@ -675,14 +702,22 @@ export function createCursor(client, options = {}) {
|
|
|
675
702
|
/** @type {RedisCursorTracker} */
|
|
676
703
|
const tracker = {
|
|
677
704
|
async attach(ws, topic, platform) {
|
|
705
|
+
// Raw `ws.subscribe` (uWS-native) NOT `platform.subscribe`. As of
|
|
706
|
+
// adapter 0.5.5 `platform.subscribe` swallows uWS's "closed
|
|
707
|
+
// websocket" throw and returns the same `null` sentinel it returns
|
|
708
|
+
// on success, so a try/catch around `platform.subscribe` cannot
|
|
709
|
+
// distinguish closed-ws from success without racing on
|
|
710
|
+
// `platform.closedWsAborts`. uWS-native `ws.subscribe` still throws
|
|
711
|
+
// on closed-ws, so the throw-WsClosedError contract holds. Mirrors
|
|
712
|
+
// what `presence.join` already does with `ws.subscribe('__presence:...')`.
|
|
678
713
|
try {
|
|
679
|
-
|
|
714
|
+
ws.subscribe('__cursor:' + topic);
|
|
680
715
|
} catch {
|
|
681
|
-
//
|
|
682
|
-
//
|
|
683
|
-
//
|
|
684
|
-
//
|
|
685
|
-
//
|
|
716
|
+
// No state to roll back (no `wsState` entry exists yet; that
|
|
717
|
+
// is only created on `update`). Throw so the caller can
|
|
718
|
+
// distinguish a no-op-and-rollback from a successful attach;
|
|
719
|
+
// without this the RPC metric reports `status=ok` for
|
|
720
|
+
// connections that never received cursor frames.
|
|
686
721
|
mAttachesAborted?.inc({ topic: mt(topic), reason: 'ws_closed' });
|
|
687
722
|
throw new WsClosedError('cursor.attach', topic);
|
|
688
723
|
}
|
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;
|