svelte-adapter-uws-extensions 0.1.3 → 0.1.4
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/presence.js +54 -13
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.4",
|
|
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/presence.js
CHANGED
|
@@ -20,8 +20,9 @@
|
|
|
20
20
|
import { randomBytes } from 'node:crypto';
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
|
-
* Lua script for atomic join: set this instance's field
|
|
24
|
-
* the user already exists on another instance (non-stale)
|
|
23
|
+
* Lua script for atomic join: set this instance's field, expire the key,
|
|
24
|
+
* check if the user already exists on another instance (non-stale),
|
|
25
|
+
* and return all hash entries so the caller can skip a separate HGETALL.
|
|
25
26
|
*
|
|
26
27
|
* KEYS[1] = hash key
|
|
27
28
|
* ARGV[1] = field to set (instanceId|userKey)
|
|
@@ -29,9 +30,10 @@ import { randomBytes } from 'node:crypto';
|
|
|
29
30
|
* ARGV[3] = "|userKey" suffix to match
|
|
30
31
|
* ARGV[4] = now (ms)
|
|
31
32
|
* ARGV[5] = presenceTtlMs
|
|
33
|
+
* ARGV[6] = presenceTtl (seconds, for EXPIRE)
|
|
32
34
|
*
|
|
33
|
-
* Returns
|
|
34
|
-
*
|
|
35
|
+
* Returns {isFirst, field1, val1, field2, val2, ...} so a single round
|
|
36
|
+
* trip handles HSET + EXPIRE + dedup check + HGETALL.
|
|
35
37
|
*/
|
|
36
38
|
const JOIN_SCRIPT = `
|
|
37
39
|
local key = KEYS[1]
|
|
@@ -40,20 +42,29 @@ local value = ARGV[2]
|
|
|
40
42
|
local suffix = ARGV[3]
|
|
41
43
|
local now = tonumber(ARGV[4])
|
|
42
44
|
local ttlMs = tonumber(ARGV[5])
|
|
45
|
+
local ttlSec = tonumber(ARGV[6])
|
|
43
46
|
|
|
44
47
|
redis.call('hset', key, field, value)
|
|
48
|
+
redis.call('expire', key, ttlSec)
|
|
45
49
|
|
|
46
50
|
local all = redis.call('hgetall', key)
|
|
51
|
+
local isFirst = 1
|
|
47
52
|
for i = 1, #all, 2 do
|
|
48
53
|
local f = all[i]
|
|
49
54
|
if f ~= field and #f >= #suffix and string.sub(f, -#suffix) == suffix then
|
|
50
55
|
local ok, parsed = pcall(cjson.decode, all[i+1])
|
|
51
56
|
if ok and parsed.ts and (now - parsed.ts) <= ttlMs then
|
|
52
|
-
|
|
57
|
+
isFirst = 0
|
|
58
|
+
break
|
|
53
59
|
end
|
|
54
60
|
end
|
|
55
61
|
end
|
|
56
|
-
|
|
62
|
+
|
|
63
|
+
local result = {isFirst}
|
|
64
|
+
for i = 1, #all do
|
|
65
|
+
result[#result + 1] = all[i]
|
|
66
|
+
end
|
|
67
|
+
return result
|
|
57
68
|
`;
|
|
58
69
|
|
|
59
70
|
/**
|
|
@@ -162,10 +173,27 @@ export function createPresence(client, options = {}) {
|
|
|
162
173
|
*/
|
|
163
174
|
const syncCounts = new Map();
|
|
164
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Dedup in-flight HGETALL requests for the same topic. Multiple callers
|
|
178
|
+
* awaiting the same key share one Redis round trip.
|
|
179
|
+
* @type {Map<string, Promise<Record<string, string>>>}
|
|
180
|
+
*/
|
|
181
|
+
const hgetallInflight = new Map();
|
|
182
|
+
|
|
165
183
|
function hashKey(topic) {
|
|
166
184
|
return client.key('presence:' + topic);
|
|
167
185
|
}
|
|
168
186
|
|
|
187
|
+
function coalesceHgetall(topic) {
|
|
188
|
+
const key = hashKey(topic);
|
|
189
|
+
let pending = hgetallInflight.get(key);
|
|
190
|
+
if (!pending) {
|
|
191
|
+
pending = redis.hgetall(key).finally(() => hgetallInflight.delete(key));
|
|
192
|
+
hgetallInflight.set(key, pending);
|
|
193
|
+
}
|
|
194
|
+
return pending;
|
|
195
|
+
}
|
|
196
|
+
|
|
169
197
|
function eventChannel(topic) {
|
|
170
198
|
return client.key('presence:events:' + topic);
|
|
171
199
|
}
|
|
@@ -337,17 +365,23 @@ export function createPresence(client, options = {}) {
|
|
|
337
365
|
// cleaned local state -- undo any Redis write and bail out.
|
|
338
366
|
if (!wsTopics.has(ws)) return;
|
|
339
367
|
|
|
368
|
+
// all will hold the raw hash entries for the initial list.
|
|
369
|
+
// When prevCount === 0 the Lua script returns them inline
|
|
370
|
+
// (no separate HGETALL needed). Otherwise we coalesce so
|
|
371
|
+
// N connections joining the same topic share one round trip.
|
|
372
|
+
let all;
|
|
373
|
+
|
|
340
374
|
if (prevCount === 0) {
|
|
341
|
-
// New user on this instance --
|
|
375
|
+
// New user on this instance -- single Lua call does
|
|
376
|
+
// HSET + EXPIRE + dedup check + returns all entries.
|
|
342
377
|
const now = Date.now();
|
|
343
378
|
const field = compoundField(key);
|
|
344
379
|
const value = JSON.stringify({ data, ts: now });
|
|
345
380
|
const suffix = '|' + key;
|
|
346
|
-
const
|
|
381
|
+
const result = await redis.eval(
|
|
347
382
|
JOIN_SCRIPT, 1, hashKey(topic),
|
|
348
|
-
field, value, suffix, now, presenceTtlMs
|
|
383
|
+
field, value, suffix, now, presenceTtlMs, presenceTtl
|
|
349
384
|
);
|
|
350
|
-
await redis.expire(hashKey(topic), presenceTtl);
|
|
351
385
|
|
|
352
386
|
// Guard: if ws closed while awaiting Redis, remove the field
|
|
353
387
|
// we just set and bail -- leave() already cleaned local state.
|
|
@@ -356,12 +390,20 @@ export function createPresence(client, options = {}) {
|
|
|
356
390
|
return;
|
|
357
391
|
}
|
|
358
392
|
|
|
393
|
+
// Parse result: [isFirst, field1, val1, field2, val2, ...]
|
|
394
|
+
const isFirstGlobally = result[0];
|
|
395
|
+
all = {};
|
|
396
|
+
for (let i = 1; i < result.length; i += 2) {
|
|
397
|
+
all[result[i]] = result[i + 1];
|
|
398
|
+
}
|
|
399
|
+
|
|
359
400
|
if (isFirstGlobally === 1) {
|
|
360
|
-
// No other live instance has this user -- broadcast join
|
|
361
401
|
const payload = { key, data };
|
|
362
402
|
platform.publish('__presence:' + topic, 'join', payload);
|
|
363
403
|
await publishEvent(topic, 'join', payload);
|
|
364
404
|
}
|
|
405
|
+
} else {
|
|
406
|
+
all = await coalesceHgetall(topic);
|
|
365
407
|
}
|
|
366
408
|
|
|
367
409
|
// Subscribe ws to presence channel (may have closed during async gap)
|
|
@@ -372,7 +414,6 @@ export function createPresence(client, options = {}) {
|
|
|
372
414
|
}
|
|
373
415
|
|
|
374
416
|
// Send current list to this connection
|
|
375
|
-
const all = await redis.hgetall(hashKey(topic));
|
|
376
417
|
const entries = parseEntries(all);
|
|
377
418
|
const list = [];
|
|
378
419
|
for (const [userKey, entry] of entries) {
|
|
@@ -543,7 +584,7 @@ export function createPresence(client, options = {}) {
|
|
|
543
584
|
},
|
|
544
585
|
|
|
545
586
|
async sync(ws, topic, platform) {
|
|
546
|
-
const all = await
|
|
587
|
+
const all = await coalesceHgetall(topic);
|
|
547
588
|
const presenceTopic = '__presence:' + topic;
|
|
548
589
|
const entries = parseEntries(all);
|
|
549
590
|
const list = [];
|