svelte-adapter-uws 0.5.3 → 0.5.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/README.md CHANGED
@@ -2377,8 +2377,8 @@ import { createPresence } from 'svelte-adapter-uws/plugins/presence';
2377
2377
 
2378
2378
  export const presence = createPresence({
2379
2379
  key: 'id',
2380
- select: (userData) => ({ id: userData.id, name: userData.name }),
2381
- heartbeat: 60_000 // optional: needed if clients use maxAge
2380
+ select: (userData) => ({ id: userData.id, name: userData.name })
2381
+ // heartbeat: 30_000 (default) - broadcast every 30s; clients refresh maxAge / re-add aged-out entries
2382
2382
  // maxConnections: 1_000_000 (default) - hard cap on tracked connections
2383
2383
  // maxTopics: 1_000_000 (default) - hard cap on active topic registry
2384
2384
  });
@@ -2450,8 +2450,8 @@ import { createPresence } from 'svelte-adapter-uws/plugins/presence';
2450
2450
 
2451
2451
  const presence = createPresence({
2452
2452
  key: 'id', // field for multi-tab dedup (default: 'id')
2453
- select: (userData) => userData, // extract public fields (default: full userData)
2454
- heartbeat: 60_000 // broadcast active keys every 60s (default: disabled)
2453
+ select: (userData) => userData, // extract public fields (default: recursive denylist)
2454
+ heartbeat: 30_000 // broadcast every 30s (default: 30000; pass 0 to disable)
2455
2455
  });
2456
2456
 
2457
2457
  presence.hooks // ready-made { subscribe, unsubscribe, close } hooks
@@ -2466,14 +2466,15 @@ presence.clear() // reset everything (stops heartbeat timer)
2466
2466
 
2467
2467
  #### Wire format
2468
2468
 
2469
- The plugin emits two frame types on the `__presence:{topic}` channel:
2469
+ The plugin emits three frame types on the `__presence:{topic}` channel:
2470
2470
 
2471
2471
  - `{event: 'presence_state', data: {[key]: meta}}` - full snapshot, sent to a single connection on join or sync.
2472
2472
  - `{event: 'presence_diff', data: {joins: {[key]: meta}, leaves: {[key]: meta}}}` - changes, broadcast to all subscribers of the topic.
2473
+ - `{event: 'heartbeat', data: {[key]: meta}}` - periodic full-roster refresh, broadcast every `heartbeat` ms (30 s default). Carries a `{userKey: data}` map so a client whose entry aged out of its local `maxAge` sweep can re-add it from the heartbeat alone, without waiting for the next `presence_diff`.
2473
2474
 
2474
2475
  Diffs are buffered in a microtask queue: multiple joins / leaves in the same tick collapse into one diff frame. Within a diff, `leaves` are applied first then `joins`, so an update (same key in both) ends with the user present using the new data. If a key cycles join then leave in the same tick, the diff carries only the latest op (`leave` wins).
2475
2476
 
2476
- `heartbeat` events (when configured) are unchanged: they carry an array of currently-active keys.
2477
+ The Redis-backed variant in the [extensions](https://github.com/lanteanio/svelte-adapter-uws-extensions) package emits the same three frame shapes, so the same client bundle works against either backend.
2477
2478
 
2478
2479
  #### Client API
2479
2480
 
@@ -2484,20 +2485,24 @@ const users = presence('room');
2484
2485
  // $users = [{ id: '1', name: 'Alice' }, { id: '2', name: 'Bob' }]
2485
2486
  ```
2486
2487
 
2487
- The `presence()` function accepts an optional second argument with a `maxAge` option (in milliseconds). When set, entries that haven't been refreshed within that window are automatically removed from the store. This makes clients self-healing when the server fails to broadcast a leaving entry in a `presence_diff` frame under load.
2488
+ The client store defaults to a 90 s `maxAge` sweep: entries that haven't been refreshed by a heartbeat or `presence_diff` / `presence_state` inside the window are removed from the local map. With the server's 30 s default heartbeat, still-present users are refreshed three times per window and never flicker; ghost entries left over by silent server-side cleanup (cluster mass-disconnect, ungraceful client close) clear within one sweep window.
2488
2489
 
2489
- **Important:** `maxAge` requires the server-side `heartbeat` option. Without heartbeat, no events arrive between the initial `presence_state` snapshot and eventual leave, so maxAge would expire every user - including ones who are still connected. The heartbeat periodically tells clients which keys are still active, resetting their maxAge timers.
2490
+ For admin / audit views that want unbounded retention ("show every user who ever touched this topic"), opt out with `maxAge: 0`:
2491
+
2492
+ ```js
2493
+ const everyoneEver = presence('room', { maxAge: 0 });
2494
+ ```
2495
+
2496
+ To customize the window, set `maxAge` and the matching server `heartbeat` together (rule of thumb: heartbeat is one-third of `maxAge` or less, so a still-present user gets at least two refreshes per sweep window):
2490
2497
 
2491
2498
  ```js
2492
2499
  // Server: heartbeat every 60s
2493
2500
  const presence = createPresence({ key: 'id', heartbeat: 60_000 });
2494
2501
 
2495
- // Client: entries expire after 120s without a heartbeat refresh
2496
- const users = presence('room', { maxAge: 120_000 });
2502
+ // Client: entries expire after 180s without a heartbeat refresh
2503
+ const users = presence('room', { maxAge: 180_000 });
2497
2504
  ```
2498
2505
 
2499
- Rule of thumb: set `heartbeat` to half (or less) of the client's `maxAge`.
2500
-
2501
2506
  #### How multi-tab dedup works
2502
2507
 
2503
2508
  If user "Alice" (key `id: '1'`) has three browser tabs open, `presence.join()` is called three times with the same key. The plugin ref-counts connections per key: Alice appears once in the list. When she closes two tabs, she stays present. Only when the last tab closes does the plugin broadcast a `leave` event.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-adapter-uws",
3
- "version": "0.5.3",
3
+ "version": "0.5.4",
4
4
  "publishConfig": {
5
5
  "tag": "latest"
6
6
  },
@@ -99,6 +99,13 @@ const EVENTS = Object.freeze({
99
99
  * subscribe so late joiners see existing cursors immediately.
100
100
  * @property {() => void} clear -
101
101
  * Clear all cursor tracking state and pending timers.
102
+ * @property {() => { flushes: number, driftMeanMs: number, driftMaxMs: number, dirtyTopicsCurrent: number, activeTopicsTotal: number }} stats -
103
+ * Snapshot of scheduler health. `flushes` is the total tick-driven
104
+ * flushes; `driftMeanMs` / `driftMaxMs` measure the gap between the
105
+ * target deadline and the actual fire time (`> topicThrottle` indicates
106
+ * sustained event-loop saturation); `dirtyTopicsCurrent` is topics with
107
+ * pending coalesced entries (should hover near zero); `activeTopicsTotal`
108
+ * is topics with at least one local cursor.
102
109
  */
103
110
 
104
111
  /**
@@ -193,14 +200,48 @@ export function createCursor(options = {}) {
193
200
  const topics = new Map();
194
201
 
195
202
  /**
196
- * Per-topic aggregate throttle state for `topicThrottle` coalescing.
197
- * Dirty entries are keyed by connection key; latest-wins. When the
198
- * coalesce window elapses, `dirty.size === 1` sends a single `update`
199
- * and any other count sends one `bulk` array.
200
- * @type {Map<string, { lastFlush: number, timer: any, dirty: Map<string, { data: any, platform: any }> }>}
203
+ * Per-topic aggregate flush state.
204
+ *
205
+ * - `dirty`: cursors awaiting coalesced flush. Keyed by connection key;
206
+ * latest-wins. When the coalesce window elapses, `dirty.size === 1`
207
+ * sends a single `update`; any other count sends one `bulk` array.
208
+ * - `lastFlush`: target-anchored timestamp of the most recent flush.
209
+ * Advanced by `topicThrottleMs` per cycle (not to actual fire time)
210
+ * so a single late tick does not compound drift on subsequent cycles.
211
+ *
212
+ * @type {Map<string, { dirty: Map<string, { data: any, platform: any }>, lastFlush: number }>}
201
213
  */
202
214
  const topicFlush = new Map();
203
215
 
216
+ /**
217
+ * Topics with at least one pending dirty entry. Bounded by mover count,
218
+ * not active-topic count, so the scheduler walks only dirty topics on
219
+ * each tick instead of every active one.
220
+ * @type {Set<string>}
221
+ */
222
+ const dirtyTopics = new Set();
223
+
224
+ /**
225
+ * Single tracker-wide timer. Always points at the next earliest topic
226
+ * deadline (or null when idle). Replaces the previous per-topic
227
+ * setTimeout pattern: N pending timers -> 1 pending timer regardless
228
+ * of topic count. Scheduling cost is O(dirty topics), not O(active
229
+ * topics).
230
+ * @type {ReturnType<typeof setTimeout> | null}
231
+ */
232
+ let tickTimer = null;
233
+
234
+ /**
235
+ * Drift accounting for `stats()` observability. Mean (target - actual)
236
+ * and max over tick-driven flushes. Leading-edge synchronous flushes
237
+ * are NOT counted (they fire on the caller's thread, not via the
238
+ * scheduler; their drift is structurally zero).
239
+ */
240
+ let driftSum = 0;
241
+ let driftCount = 0;
242
+ let driftMax = 0;
243
+ let flushCount = 0;
244
+
204
245
  /**
205
246
  * Get or create ws state and return the connection key + user data.
206
247
  * @param {any} ws
@@ -224,14 +265,15 @@ export function createCursor(options = {}) {
224
265
  }
225
266
 
226
267
  /**
227
- * Drop the topic's coalesce state (clears any pending timer first).
268
+ * Drop the topic's coalesce state. The single tracker-wide tickTimer is
269
+ * left alone (it self-cancels on the next tick when `dirtyTopics` is
270
+ * empty); we just remove this topic from both the flush map and the
271
+ * dirty set so the next tick skips it.
228
272
  * @param {string} topic
229
273
  */
230
274
  function clearTopicFlush(topic) {
231
- const flushState = topicFlush.get(topic);
232
- if (!flushState) return;
233
- if (flushState.timer) clearTimeout(flushState.timer);
234
275
  topicFlush.delete(topic);
276
+ dirtyTopics.delete(topic);
235
277
  }
236
278
 
237
279
  /**
@@ -262,6 +304,7 @@ export function createCursor(options = {}) {
262
304
  */
263
305
  function flushDirty(topic, dirty) {
264
306
  if (dirty.size === 0) return;
307
+ flushCount++;
265
308
  if (dirty.size === 1) {
266
309
  const [k, v] = dirty.entries().next().value;
267
310
  doBroadcast(topic, k, v.data, v.platform);
@@ -278,9 +321,65 @@ export function createCursor(options = {}) {
278
321
  }
279
322
  }
280
323
 
324
+ /**
325
+ * Scheduler tick. Walks `dirtyTopics`, flushes any topic whose deadline
326
+ * (`lastFlush + topicThrottleMs`) has passed, and re-arms `tickTimer`
327
+ * for the next earliest pending deadline. Topics whose deadline has
328
+ * not yet passed stay in `dirtyTopics` for the next tick.
329
+ *
330
+ * Target-anchored advance: on flush, `lastFlush` is set to the deadline
331
+ * (not the actual fire time) so a single late tick does not compound
332
+ * drift on subsequent cycles. If we fell behind by more than one cycle
333
+ * (event loop saturation > `topicThrottleMs`), `lastFlush` resets to
334
+ * `now` to avoid queueing phantom catch-up fires.
335
+ */
336
+ function tick() {
337
+ tickTimer = null;
338
+ const now = Date.now();
339
+ let nextDeadline = Infinity;
340
+
341
+ for (const topic of dirtyTopics) {
342
+ const state = topicFlush.get(topic);
343
+ if (!state) { dirtyTopics.delete(topic); continue; }
344
+ if (state.dirty.size === 0) {
345
+ dirtyTopics.delete(topic);
346
+ continue;
347
+ }
348
+ const deadline = state.lastFlush + topicThrottleMs;
349
+ if (deadline <= now) {
350
+ const drift = now - deadline;
351
+ driftSum += drift;
352
+ driftCount++;
353
+ if (drift > driftMax) driftMax = drift;
354
+
355
+ flushDirty(topic, state.dirty); // increments flushCount internally
356
+ state.dirty.clear();
357
+ dirtyTopics.delete(topic);
358
+
359
+ state.lastFlush = drift < topicThrottleMs ? deadline : now;
360
+ } else if (deadline < nextDeadline) {
361
+ nextDeadline = deadline;
362
+ }
363
+ }
364
+
365
+ if (nextDeadline !== Infinity) {
366
+ tickTimer = setTimeout(tick, Math.max(0, nextDeadline - Date.now()));
367
+ }
368
+ // else: scheduler idle until next `broadcast()` call.
369
+ }
370
+
371
+ function armTick(delay) {
372
+ if (tickTimer !== null) return;
373
+ tickTimer = setTimeout(tick, delay);
374
+ }
375
+
281
376
  /**
282
377
  * Route a broadcast through the per-topic coalesce window when
283
378
  * `topicThrottle` is enabled, or directly publish when disabled.
379
+ *
380
+ * Leading-edge synchronous flush preserves the contract that the first
381
+ * call on an idle topic publishes immediately (no setTimeout(0) detour).
382
+ * Trailing-edge fires via the single tracker-wide `tickTimer`.
284
383
  */
285
384
  function broadcast(topic, key, data, platform) {
286
385
  if (topicThrottleMs <= 0) {
@@ -290,34 +389,22 @@ export function createCursor(options = {}) {
290
389
 
291
390
  let state = topicFlush.get(topic);
292
391
  if (!state) {
293
- state = { lastFlush: 0, timer: null, dirty: new Map() };
392
+ state = { dirty: new Map(), lastFlush: 0 };
294
393
  topicFlush.set(topic, state);
295
394
  }
296
-
297
395
  state.dirty.set(key, { data, platform });
298
396
 
299
397
  const now = Date.now();
300
398
  if (now - state.lastFlush >= topicThrottleMs) {
301
- if (state.timer) {
302
- clearTimeout(state.timer);
303
- state.timer = null;
304
- }
305
399
  state.lastFlush = now;
306
400
  flushDirty(topic, state.dirty);
307
401
  state.dirty.clear();
402
+ dirtyTopics.delete(topic);
308
403
  return;
309
404
  }
310
405
 
311
- if (!state.timer) {
312
- state.timer = setTimeout(() => {
313
- const s = topicFlush.get(topic);
314
- if (!s) return;
315
- s.timer = null;
316
- s.lastFlush = Date.now();
317
- flushDirty(topic, s.dirty);
318
- s.dirty.clear();
319
- }, topicThrottleMs - (now - state.lastFlush));
320
- }
406
+ dirtyTopics.add(topic);
407
+ armTick(Math.max(0, topicThrottleMs - (now - state.lastFlush)));
321
408
  }
322
409
 
323
410
  /** @type {CursorTracker} */
@@ -460,15 +547,42 @@ export function createCursor(options = {}) {
460
547
  if (entry.timer) clearTimeout(entry.timer);
461
548
  }
462
549
  }
463
- for (const [, state] of topicFlush) {
464
- if (state.timer) clearTimeout(state.timer);
465
- }
550
+ if (tickTimer !== null) { clearTimeout(tickTimer); tickTimer = null; }
551
+ dirtyTopics.clear();
466
552
  topics.clear();
467
553
  topicFlush.clear();
468
554
  wsState.clear();
469
555
  connCounter = 0;
470
556
  },
471
557
 
558
+ /**
559
+ * Snapshot of scheduler health. Always available, near-zero cost.
560
+ *
561
+ * - `flushes`: total tick-driven flushes since tracker creation.
562
+ * - `driftMeanMs`: mean (target_deadline - actual_fire_time) across
563
+ * all tick-driven flushes. 0 means perfect cadence; values >
564
+ * `topicThrottle` indicate sustained event-loop saturation or
565
+ * CPU contention.
566
+ * - `driftMaxMs`: largest single observed late fire. Useful for
567
+ * spotting one-off GC pauses vs. sustained drift.
568
+ * - `dirtyTopicsCurrent`: topics with pending coalesced entries
569
+ * right now. Should hover near zero in healthy operation.
570
+ * - `activeTopicsTotal`: topics with at least one local cursor.
571
+ *
572
+ * Leading-edge synchronous flushes (first call on an idle topic)
573
+ * are not counted in drift stats - they fire on the call thread,
574
+ * not via the scheduler.
575
+ */
576
+ stats() {
577
+ return {
578
+ flushes: flushCount,
579
+ driftMeanMs: driftCount > 0 ? driftSum / driftCount : 0,
580
+ driftMaxMs: driftMax,
581
+ dirtyTopicsCurrent: dirtyTopics.size,
582
+ activeTopicsTotal: topics.size
583
+ };
584
+ },
585
+
472
586
  hooks: {
473
587
  message(ws, { data, platform }) {
474
588
  let parsed;
@@ -6,10 +6,19 @@ import type { Readable } from 'svelte/store';
6
6
  * Returns a readable Svelte store containing an array of user data objects.
7
7
  * The array updates automatically when users join or leave.
8
8
  *
9
+ * Defaults to a 90 s `maxAge` sweep: entries that haven't been refreshed
10
+ * by a heartbeat or presence_diff/state inside the window are removed
11
+ * from the local map. The server emits `{userKey: data}` heartbeats
12
+ * every 30 s by default, so still-present users re-appear on the next
13
+ * heartbeat (no flicker). Pass `maxAge: 0` to opt out of the sweep for
14
+ * admin / audit views that want unbounded retention.
15
+ *
9
16
  * You must also subscribe to the topic itself (via `on()`, `crud()`, etc.)
10
17
  * for the server's `subscribe` hook to fire and register your presence.
11
18
  *
12
19
  * @param topic - Topic to track presence on
20
+ * @param options - `maxAge` defaults to 90000 (ms). Pass `0` to disable
21
+ * the sweep.
13
22
  *
14
23
  * @example
15
24
  * ```svelte
@@ -5,10 +5,17 @@
5
5
  * a live list of who's connected. The server handles join/leave tracking;
6
6
  * this module just keeps the client-side state in sync.
7
7
  *
8
- * When `maxAge` is set, entries that haven't been refreshed (via `list`
9
- * or `join` events) within that window are automatically removed. This
10
- * makes clients self-healing when the server fails to broadcast a `leave`
11
- * event (e.g. mass disconnects overwhelming Redis cleanup).
8
+ * Defaults to a 90 s `maxAge` sweep: entries that haven't been refreshed
9
+ * by a heartbeat or presence_diff/state inside the window are removed
10
+ * from the local map. The in-memory server (and the Redis-backed variant
11
+ * in svelte-adapter-uws-extensions) emits `{userKey: data}` heartbeats
12
+ * every 30 s by default, so a still-present user re-appears on the very
13
+ * next heartbeat - no flicker for live users, and ghost entries from
14
+ * silent server-side TTL expiry (cluster mass-disconnect, ungraceful
15
+ * client close) clear within one sweep window.
16
+ *
17
+ * Apps that want unbounded retention ("show every user who ever touched
18
+ * this topic" - admin / audit views) opt out with `maxAge: 0`.
12
19
  *
13
20
  * @module svelte-adapter-uws/plugins/presence/client
14
21
  */
@@ -62,14 +69,19 @@ const presenceStores = new Map();
62
69
  * @example
63
70
  * ```svelte
64
71
  * <script>
65
- * // Self-healing: entries expire after 90s without a refresh
66
- * const users = presence('room', { maxAge: 90_000 });
72
+ * // Opt out of the default 90 s sweep for an admin / audit view.
73
+ * const users = presence('room', { maxAge: 0 });
67
74
  * </script>
68
75
  * ```
69
76
  */
70
77
  export function presence(topic, options) {
71
- const maxAge = options?.maxAge;
72
- const cacheKey = maxAge > 0 ? topic + '\0' + maxAge : topic;
78
+ // Default 90 s sweep matches the extensions Redis presence's default
79
+ // `ttl: 90` (server-side per-field TTL) and gives the in-memory
80
+ // server's 30 s default heartbeat a 3x safety margin. Apps that want
81
+ // "show every user who ever touched this topic" (admin/audit views)
82
+ // opt out with `maxAge: 0`.
83
+ const maxAge = options?.maxAge ?? 90000;
84
+ const cacheKey = topic + '\0' + maxAge;
73
85
 
74
86
  const cached = presenceStores.get(cacheKey);
75
87
  if (cached) return cached;
@@ -47,19 +47,31 @@ export interface PresenceOptions<UserData = unknown, Selected extends Record<str
47
47
  /**
48
48
  * Interval in milliseconds between heartbeat broadcasts.
49
49
  *
50
- * When set, the server periodically publishes a `heartbeat` event to all
51
- * presence topics containing the list of active user keys. This resets
52
- * the `maxAge` timer on clients, preventing live users from being expired.
50
+ * The server periodically publishes a `heartbeat` event to all presence
51
+ * topics carrying a `{userKey: data}` map of every active user. This
52
+ * refreshes each entry's `maxAge` timer on the client AND re-adds any
53
+ * entry the client swept while the user was still present, so live
54
+ * users do not flicker out when a `presence_diff` is missed (transient
55
+ * network blip, JS thread saturation).
53
56
  *
54
- * Set this to a value shorter than the client's `maxAge`.
57
+ * Set this to a value shorter than the client's `maxAge`. The 30 s
58
+ * default fits the 90 s default client `maxAge` with a 3x safety
59
+ * margin. Pass `0` to disable heartbeats entirely (apps that do not
60
+ * use the `maxAge` self-healing path).
55
61
  *
56
- * @default 0 (disabled)
62
+ * @default 30000
57
63
  *
58
64
  * @example
59
65
  * ```js
60
- * // Server heartbeat every 60s, client maxAge 120s
66
+ * // Slower heartbeat (less wire traffic, larger window for ghost entries)
61
67
  * const presence = createPresence({ heartbeat: 60_000 });
62
68
  * ```
69
+ *
70
+ * @example
71
+ * ```js
72
+ * // Disable heartbeats; client must rely on presence_diff alone
73
+ * const presence = createPresence({ heartbeat: 0 });
74
+ * ```
63
75
  */
64
76
  heartbeat?: number;
65
77
 
@@ -52,11 +52,14 @@ const TOPIC_PREFIX = '__presence:';
52
52
  *
53
53
  * Should return JSON-serializable data (plain objects, arrays, strings, numbers,
54
54
  * booleans, null) since the result is sent over WebSocket.
55
- * @property {number} [heartbeat=0] - Interval in milliseconds between heartbeat broadcasts.
56
- * When set, the server periodically publishes a `heartbeat` event to all presence topics
57
- * containing the list of active keys. This resets the `maxAge` timer on clients, preventing
58
- * live users from being expired. Set this to a value shorter than the client's `maxAge`.
59
- * Disabled by default (0 or omitted).
55
+ * @property {number} [heartbeat=30000] - Interval in milliseconds between heartbeat broadcasts.
56
+ * The server periodically publishes a `heartbeat` event to all presence topics carrying a
57
+ * `{userKey: data}` map of every active user. This refreshes each entry's `maxAge` timer on
58
+ * the client AND re-adds any entry the client swept while the user was still present, so
59
+ * live users do not flicker out when a `presence_diff` is missed (e.g. transient network
60
+ * blip, JS thread saturation). Set this to a value shorter than the client's `maxAge`
61
+ * (default client `maxAge` is 90 s, so 30 s gives a 3x safety margin). Pass `0` to disable
62
+ * heartbeats entirely (apps that do not use the `maxAge` self-healing path).
60
63
  */
61
64
 
62
65
  /**
@@ -252,7 +255,15 @@ function defaultPresenceSelect(obj, ancestors) {
252
255
  export function createPresence(options = {}) {
253
256
  const keyField = options.key || 'id';
254
257
  const select = options.select || defaultPresenceSelect;
255
- const heartbeatMs = options.heartbeat || 0;
258
+ // Default 30 s heartbeat keeps the client's `maxAge` sweep self-healing:
259
+ // a still-present user re-appears on the next heartbeat after their
260
+ // entry ages out of the local map. Apps that want zero heartbeat
261
+ // traffic (no `maxAge` consumers, or out-of-band liveness) pass
262
+ // `heartbeat: 0` explicitly to opt out.
263
+ const heartbeatMs = options.heartbeat ?? 30000;
264
+ if (typeof heartbeatMs !== 'number' || !Number.isFinite(heartbeatMs) || heartbeatMs < 0) {
265
+ throw new Error('presence: heartbeat must be a non-negative number');
266
+ }
256
267
  const maxConnections = options.maxConnections ?? 1_000_000;
257
268
  const maxTopics = options.maxTopics ?? 1_000_000;
258
269
 
@@ -373,11 +384,16 @@ export function createPresence(options = {}) {
373
384
  if (heartbeatMs > 0) {
374
385
  heartbeatTimer = setInterval(() => {
375
386
  for (const [topic, users] of topicPresence) {
376
- _platform.publish(
377
- TOPIC_PREFIX + topic,
378
- 'heartbeat',
379
- [...users.keys()]
380
- );
387
+ // Publish a `{userKey: data}` map (rather than a keys-only
388
+ // array) so a client whose entry aged out of its local
389
+ // `maxAge` sweep between heartbeats can re-add it from the
390
+ // heartbeat alone, without waiting for a presence_diff /
391
+ // presence_state to reconcile. Matches the Redis-backed
392
+ // variant in svelte-adapter-uws-extensions.
393
+ /** @type {Record<string, any>} */
394
+ const dataMap = {};
395
+ for (const [userKey, entry] of users) dataMap[userKey] = entry.data;
396
+ _platform.publish(TOPIC_PREFIX + topic, 'heartbeat', dataMap);
381
397
  }
382
398
  }, heartbeatMs);
383
399
  }