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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. 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",
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 and check if
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 1 if this is the first live instance for the user (broadcast join),
34
- * 0 if another live instance already has this user.
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
- return 0
57
+ isFirst = 0
58
+ break
53
59
  end
54
60
  end
55
61
  end
56
- return 1
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 -- check if globally new via atomic Lua
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 isFirstGlobally = await redis.eval(
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 redis.hgetall(hashKey(topic));
587
+ const all = await coalesceHgetall(topic);
547
588
  const presenceTopic = '__presence:' + topic;
548
589
  const entries = parseEntries(all);
549
590
  const list = [];