svelte-adapter-uws-extensions 0.1.5 → 0.1.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 +1 -1
- package/redis/cursor.d.ts +9 -0
- package/redis/cursor.js +66 -1
- package/redis/presence.js +69 -24
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "svelte-adapter-uws-extensions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "Redis and Postgres extensions for svelte-adapter-uws - distributed pub/sub, replay buffers, presence tracking, rate limiting, groups, and DB change notifications",
|
|
5
5
|
"author": "Kevin Radziszewski",
|
|
6
6
|
"license": "MIT",
|
package/redis/cursor.d.ts
CHANGED
|
@@ -9,6 +9,15 @@ export interface RedisCursorOptions {
|
|
|
9
9
|
*/
|
|
10
10
|
throttle?: number;
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Minimum ms between aggregate broadcasts per topic, across all
|
|
14
|
+
* connections. Caps total Redis writes regardless of connection count.
|
|
15
|
+
* Set to ~16 (60 broadcasts/sec) to prevent Redis saturation under
|
|
16
|
+
* high concurrency. 0 disables the aggregate throttle.
|
|
17
|
+
* @default 0
|
|
18
|
+
*/
|
|
19
|
+
topicThrottle?: number;
|
|
20
|
+
|
|
12
21
|
/**
|
|
13
22
|
* Extract user-identifying data from userData.
|
|
14
23
|
* Broadcast alongside cursor data so other clients know who the cursor belongs to.
|
package/redis/cursor.js
CHANGED
|
@@ -49,6 +49,9 @@ return removed
|
|
|
49
49
|
* @typedef {Object} RedisCursorOptions
|
|
50
50
|
* @property {number} [throttle=50] - Minimum ms between broadcasts per user per topic.
|
|
51
51
|
* Trailing-edge timer fires to ensure the final position is always sent.
|
|
52
|
+
* @property {number} [topicThrottle=0] - Minimum ms between aggregate broadcasts per
|
|
53
|
+
* topic. Caps total Redis writes regardless of connection count. 0 = no limit.
|
|
54
|
+
* Set to ~16 (60/sec) to prevent Redis saturation under high concurrency.
|
|
52
55
|
* @property {(userData: any) => any} [select] - Extract user-identifying data from userData.
|
|
53
56
|
* Defaults to the full userData.
|
|
54
57
|
* @property {number} [ttl=30] - TTL in seconds for hash entries. Should be longer than
|
|
@@ -80,6 +83,7 @@ return removed
|
|
|
80
83
|
*/
|
|
81
84
|
export function createCursor(client, options = {}) {
|
|
82
85
|
const throttleMs = options.throttle ?? 50;
|
|
86
|
+
const topicThrottleMs = options.topicThrottle ?? 0;
|
|
83
87
|
const select = options.select || ((userData) => userData);
|
|
84
88
|
const cursorTtl = options.ttl || 30;
|
|
85
89
|
|
|
@@ -189,7 +193,16 @@ export function createCursor(client, options = {}) {
|
|
|
189
193
|
/**
|
|
190
194
|
* Broadcast locally + relay to other instances via Redis.
|
|
191
195
|
*/
|
|
192
|
-
|
|
196
|
+
/**
|
|
197
|
+
* Per-topic aggregate throttle state.
|
|
198
|
+
* When topicThrottleMs > 0, excess broadcasts are coalesced and
|
|
199
|
+
* flushed on a trailing-edge timer so total Redis load per topic
|
|
200
|
+
* is capped regardless of connection count.
|
|
201
|
+
* @type {Map<string, { lastFlush: number, timer: any, dirty: Map<string, { user: any, data: any, platform: any }> }>}
|
|
202
|
+
*/
|
|
203
|
+
const topicFlush = new Map();
|
|
204
|
+
|
|
205
|
+
function doBroadcast(topic, key, user, data, platform) {
|
|
193
206
|
// Local broadcast
|
|
194
207
|
platform.publish('__cursor:' + topic, 'update', { key, user, data });
|
|
195
208
|
|
|
@@ -208,6 +221,50 @@ export function createCursor(client, options = {}) {
|
|
|
208
221
|
redis.publish(channel, msg).catch(() => {});
|
|
209
222
|
}
|
|
210
223
|
|
|
224
|
+
function broadcast(topic, key, user, data, platform) {
|
|
225
|
+
if (topicThrottleMs <= 0) {
|
|
226
|
+
doBroadcast(topic, key, user, data, platform);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Per-topic aggregate throttle
|
|
231
|
+
let state = topicFlush.get(topic);
|
|
232
|
+
if (!state) {
|
|
233
|
+
state = { lastFlush: 0, timer: null, dirty: new Map() };
|
|
234
|
+
topicFlush.set(topic, state);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Always store the latest data per key
|
|
238
|
+
state.dirty.set(key, { user, data, platform });
|
|
239
|
+
|
|
240
|
+
const now = Date.now();
|
|
241
|
+
|
|
242
|
+
// Leading edge: flush immediately if window has passed
|
|
243
|
+
if (now - state.lastFlush >= topicThrottleMs) {
|
|
244
|
+
if (state.timer) { clearTimeout(state.timer); state.timer = null; }
|
|
245
|
+
state.lastFlush = now;
|
|
246
|
+
for (const [k, v] of state.dirty) {
|
|
247
|
+
doBroadcast(topic, k, v.user, v.data, v.platform);
|
|
248
|
+
}
|
|
249
|
+
state.dirty.clear();
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Trailing edge: schedule flush at end of window
|
|
254
|
+
if (!state.timer) {
|
|
255
|
+
state.timer = setTimeout(() => {
|
|
256
|
+
const s = topicFlush.get(topic);
|
|
257
|
+
if (!s) return;
|
|
258
|
+
s.timer = null;
|
|
259
|
+
s.lastFlush = Date.now();
|
|
260
|
+
for (const [k, v] of s.dirty) {
|
|
261
|
+
doBroadcast(topic, k, v.user, v.data, v.platform);
|
|
262
|
+
}
|
|
263
|
+
s.dirty.clear();
|
|
264
|
+
}, topicThrottleMs - (now - state.lastFlush));
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
211
268
|
function broadcastRemove(topic, key, platform) {
|
|
212
269
|
platform.publish('__cursor:' + topic, 'remove', { key });
|
|
213
270
|
|
|
@@ -346,7 +403,11 @@ export function createCursor(client, options = {}) {
|
|
|
346
403
|
if (entry.timer) clearTimeout(entry.timer);
|
|
347
404
|
}
|
|
348
405
|
}
|
|
406
|
+
for (const [, state] of topicFlush) {
|
|
407
|
+
if (state.timer) clearTimeout(state.timer);
|
|
408
|
+
}
|
|
349
409
|
topics.clear();
|
|
410
|
+
topicFlush.clear();
|
|
350
411
|
wsState.clear();
|
|
351
412
|
activeTopics.clear();
|
|
352
413
|
connCounter = 0;
|
|
@@ -371,6 +432,10 @@ export function createCursor(client, options = {}) {
|
|
|
371
432
|
if (entry.timer) clearTimeout(entry.timer);
|
|
372
433
|
}
|
|
373
434
|
}
|
|
435
|
+
for (const [, state] of topicFlush) {
|
|
436
|
+
if (state.timer) clearTimeout(state.timer);
|
|
437
|
+
}
|
|
438
|
+
topicFlush.clear();
|
|
374
439
|
if (subscriber) {
|
|
375
440
|
subscriber.quit().catch(() => subscriber.disconnect());
|
|
376
441
|
subscriber = null;
|
package/redis/presence.js
CHANGED
|
@@ -60,11 +60,17 @@ for i = 1, #all, 2 do
|
|
|
60
60
|
end
|
|
61
61
|
end
|
|
62
62
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
-- For small hashes return entries inline (saves a round trip).
|
|
64
|
+
-- For large hashes return only the flag; the caller fetches the
|
|
65
|
+
-- list via a coalesced HGETALL shared across concurrent joiners.
|
|
66
|
+
if #all <= 200 then
|
|
67
|
+
local result = {isFirst}
|
|
68
|
+
for i = 1, #all do
|
|
69
|
+
result[#result + 1] = all[i]
|
|
70
|
+
end
|
|
71
|
+
return result
|
|
66
72
|
end
|
|
67
|
-
return
|
|
73
|
+
return {isFirst}
|
|
68
74
|
`;
|
|
69
75
|
|
|
70
76
|
/**
|
|
@@ -268,6 +274,22 @@ export function createPresence(client, options = {}) {
|
|
|
268
274
|
/** @type {Set<string>} */
|
|
269
275
|
const activeTopics = new Set();
|
|
270
276
|
const heartbeatTimer = setInterval(() => {
|
|
277
|
+
// Detect dead connections whose close handler never fired.
|
|
278
|
+
// Under mass disconnect, the runtime may drop close events.
|
|
279
|
+
// Probe each tracked ws; if the probe throws the socket is
|
|
280
|
+
// dead and we synchronously purge it from local state so the
|
|
281
|
+
// refresh loop below never touches it.
|
|
282
|
+
if (activePlatform) {
|
|
283
|
+
const dead = [];
|
|
284
|
+
for (const [ws] of wsTopics) {
|
|
285
|
+
try { ws.getBufferedAmount(); } catch { dead.push(ws); }
|
|
286
|
+
}
|
|
287
|
+
for (const ws of dead) {
|
|
288
|
+
// Full leave (sync Phase 1 + async Phase 2 fire-and-forget)
|
|
289
|
+
tracker.leave(ws, activePlatform).catch(() => {});
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
271
293
|
const now = Date.now();
|
|
272
294
|
for (const topic of activeTopics) {
|
|
273
295
|
const data = localData.get(topic);
|
|
@@ -385,15 +407,14 @@ export function createPresence(client, options = {}) {
|
|
|
385
407
|
// cleaned local state -- undo any Redis write and bail out.
|
|
386
408
|
if (!wsTopics.has(ws)) return;
|
|
387
409
|
|
|
388
|
-
// all will hold the raw hash entries for the initial list.
|
|
389
|
-
// When prevCount === 0 the Lua script returns them inline
|
|
390
|
-
// (no separate HGETALL needed). Otherwise we coalesce so
|
|
391
|
-
// N connections joining the same topic share one round trip.
|
|
392
410
|
let all;
|
|
393
411
|
|
|
394
412
|
if (prevCount === 0) {
|
|
395
413
|
// New user on this instance -- single Lua call does
|
|
396
|
-
// HSET + EXPIRE + dedup check
|
|
414
|
+
// HSET + EXPIRE + dedup check. For small hashes the
|
|
415
|
+
// script returns all entries inline (1 round trip).
|
|
416
|
+
// For large hashes it returns only the dedup flag and
|
|
417
|
+
// we fetch the list via coalesceHgetall below.
|
|
397
418
|
const now = Date.now();
|
|
398
419
|
const field = compoundField(key);
|
|
399
420
|
const value = JSON.stringify({ data, ts: now });
|
|
@@ -410,11 +431,14 @@ export function createPresence(client, options = {}) {
|
|
|
410
431
|
return;
|
|
411
432
|
}
|
|
412
433
|
|
|
413
|
-
// Parse result: [isFirst, field1, val1, field2, val2, ...]
|
|
414
434
|
const isFirstGlobally = result[0];
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
435
|
+
|
|
436
|
+
if (result.length > 1) {
|
|
437
|
+
// Small hash: entries returned inline
|
|
438
|
+
all = {};
|
|
439
|
+
for (let i = 1; i < result.length; i += 2) {
|
|
440
|
+
all[result[i]] = result[i + 1];
|
|
441
|
+
}
|
|
418
442
|
}
|
|
419
443
|
|
|
420
444
|
if (isFirstGlobally === 1) {
|
|
@@ -422,7 +446,12 @@ export function createPresence(client, options = {}) {
|
|
|
422
446
|
platform.publish('__presence:' + topic, 'join', payload);
|
|
423
447
|
await publishEvent(topic, 'join', payload);
|
|
424
448
|
}
|
|
425
|
-
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Fetch initial list via coalesced HGETALL when the script
|
|
452
|
+
// did not return entries inline (large hash or dedup join).
|
|
453
|
+
// Concurrent joiners share a single Redis round trip.
|
|
454
|
+
if (!all) {
|
|
426
455
|
all = await coalesceHgetall(topic);
|
|
427
456
|
}
|
|
428
457
|
|
|
@@ -574,23 +603,39 @@ export function createPresence(client, options = {}) {
|
|
|
574
603
|
|
|
575
604
|
// Phase 2: Async Redis cleanup. Local state is already clean so
|
|
576
605
|
// the heartbeat will not refresh any of these entries.
|
|
577
|
-
|
|
606
|
+
// Use a pipeline to batch all LEAVE_SCRIPT EVALs into a single
|
|
607
|
+
// Redis round trip. Under mass disconnect (1000+ connections)
|
|
608
|
+
// this avoids overwhelming the Redis command queue.
|
|
609
|
+
for (const { needsUnsub, topic } of pendingLeaves) {
|
|
578
610
|
if (needsUnsub) {
|
|
579
611
|
await unsubscribeFromTopic(topic);
|
|
580
612
|
}
|
|
613
|
+
}
|
|
581
614
|
|
|
615
|
+
const now = Date.now();
|
|
616
|
+
const pipe = redis.pipeline();
|
|
617
|
+
for (const { topic, key } of pendingLeaves) {
|
|
582
618
|
const field = compoundField(key);
|
|
583
619
|
const suffix = '|' + key;
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
LEAVE_SCRIPT, 1, hashKey(topic), field, suffix, now, presenceTtlMs
|
|
587
|
-
);
|
|
620
|
+
pipe.eval(LEAVE_SCRIPT, 1, hashKey(topic), field, suffix, now, presenceTtlMs);
|
|
621
|
+
}
|
|
588
622
|
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
623
|
+
let results;
|
|
624
|
+
try {
|
|
625
|
+
results = await pipe.exec();
|
|
626
|
+
} catch {
|
|
627
|
+
// Pipeline failed -- CLEANUP_SCRIPT will handle stale entries via TTL
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Broadcast leave events for users that are completely gone
|
|
632
|
+
for (let i = 0; i < pendingLeaves.length; i++) {
|
|
633
|
+
const [err, userGone] = results[i];
|
|
634
|
+
if (err || userGone !== 1) continue;
|
|
635
|
+
const { topic, key, data } = pendingLeaves[i];
|
|
636
|
+
const payload = { key, data };
|
|
637
|
+
platform.publish('__presence:' + topic, 'leave', payload);
|
|
638
|
+
await publishEvent(topic, 'leave', payload);
|
|
594
639
|
}
|
|
595
640
|
|
|
596
641
|
// Async unsubscribe for sync-only observer topics
|