svelte-adapter-uws 0.5.2 → 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
@@ -699,12 +699,25 @@ export function open(ws, { platform }) {
699
699
  ws.subscribe(`user:${userId}`);
700
700
  }
701
701
 
702
- // Called when a message is received
702
+ // Called when a message is received.
703
703
  // Note: subscribe/unsubscribe messages from the client store are
704
- // handled automatically BEFORE this function is called
705
- export function message(ws, { data, isBinary }) {
706
- const msg = JSON.parse(Buffer.from(data).toString());
707
- console.log('Got message:', msg);
704
+ // handled automatically BEFORE this function is called.
705
+ //
706
+ // `msg` is the JSON-parsed envelope when the adapter parsed the frame
707
+ // for control-message routing but no control type matched (i.e. it
708
+ // looks like `{"type":"<custom>",...}` from a plugin). The adapter
709
+ // already did `TextDecoder + JSON.parse` once during routing, so this
710
+ // avoids a second parse on the dispatch path. `msg` is `undefined`
711
+ // for binary frames, prefix-miss frames, parse failures, or frames
712
+ // that parse to a non-object.
713
+ export function message(ws, { data, isBinary, msg }) {
714
+ if (msg) {
715
+ // Already-parsed JSON object envelope - dispatch by msg.type
716
+ console.log('Got envelope:', msg);
717
+ return;
718
+ }
719
+ // Binary or non-envelope text frame - decode manually
720
+ console.log('Got raw frame, byteLength:', data.byteLength);
708
721
  }
709
722
 
710
723
  // Called when a client tries to subscribe to a topic (optional)
@@ -2364,8 +2377,8 @@ import { createPresence } from 'svelte-adapter-uws/plugins/presence';
2364
2377
 
2365
2378
  export const presence = createPresence({
2366
2379
  key: 'id',
2367
- select: (userData) => ({ id: userData.id, name: userData.name }),
2368
- 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
2369
2382
  // maxConnections: 1_000_000 (default) - hard cap on tracked connections
2370
2383
  // maxTopics: 1_000_000 (default) - hard cap on active topic registry
2371
2384
  });
@@ -2437,8 +2450,8 @@ import { createPresence } from 'svelte-adapter-uws/plugins/presence';
2437
2450
 
2438
2451
  const presence = createPresence({
2439
2452
  key: 'id', // field for multi-tab dedup (default: 'id')
2440
- select: (userData) => userData, // extract public fields (default: full userData)
2441
- 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)
2442
2455
  });
2443
2456
 
2444
2457
  presence.hooks // ready-made { subscribe, unsubscribe, close } hooks
@@ -2453,14 +2466,15 @@ presence.clear() // reset everything (stops heartbeat timer)
2453
2466
 
2454
2467
  #### Wire format
2455
2468
 
2456
- The plugin emits two frame types on the `__presence:{topic}` channel:
2469
+ The plugin emits three frame types on the `__presence:{topic}` channel:
2457
2470
 
2458
2471
  - `{event: 'presence_state', data: {[key]: meta}}` - full snapshot, sent to a single connection on join or sync.
2459
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`.
2460
2474
 
2461
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).
2462
2476
 
2463
- `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.
2464
2478
 
2465
2479
  #### Client API
2466
2480
 
@@ -2471,20 +2485,24 @@ const users = presence('room');
2471
2485
  // $users = [{ id: '1', name: 'Alice' }, { id: '2', name: 'Bob' }]
2472
2486
  ```
2473
2487
 
2474
- 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.
2489
+
2490
+ For admin / audit views that want unbounded retention ("show every user who ever touched this topic"), opt out with `maxAge: 0`:
2475
2491
 
2476
- **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.
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):
2477
2497
 
2478
2498
  ```js
2479
2499
  // Server: heartbeat every 60s
2480
2500
  const presence = createPresence({ key: 'id', heartbeat: 60_000 });
2481
2501
 
2482
- // Client: entries expire after 120s without a heartbeat refresh
2483
- 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 });
2484
2504
  ```
2485
2505
 
2486
- Rule of thumb: set `heartbeat` to half (or less) of the client's `maxAge`.
2487
-
2488
2506
  #### How multi-tab dedup works
2489
2507
 
2490
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/files/handler.js CHANGED
@@ -3035,17 +3035,30 @@ if (WS_ENABLED) {
3035
3035
  // The 8192-byte ceiling is generous enough for subscribe-batch with
3036
3036
  // many topics (N * 256-char names) while keeping the JSON.parse
3037
3037
  // guard against truly large user messages.
3038
+ // `msg` is hoisted to outer scope so it can be forwarded to the user
3039
+ // handler in the fall-through delegation below. When the prefix
3040
+ // matched and JSON.parse produced an object that did NOT match any
3041
+ // known control type, the parsed value reaches plugin-layer
3042
+ // dispatchers (e.g. svelte-realtime's `onJsonMessage`) directly, so
3043
+ // they don't re-run TextDecoder + JSON.parse on every frame.
3044
+ /** @type {any} */
3045
+ let msg;
3038
3046
  if (!isBinary && message.byteLength < 8192 &&
3039
3047
  (new Uint8Array(message))[3] === 0x79 /* 'y' in {"type" */) {
3040
3048
  /** @type {any} */
3041
- let msg;
3049
+ let parsed;
3042
3050
  try {
3043
- msg = JSON.parse(textDecoder.decode(message));
3051
+ parsed = JSON.parse(textDecoder.decode(message));
3044
3052
  } catch {
3045
- // Not valid JSON - fall through to user handler.
3046
- wsModule.message?.(ws, { data: message, isBinary, platform: ws.getUserData()[WS_PLATFORM] });
3053
+ parsed = undefined;
3054
+ }
3055
+ if (parsed === null || typeof parsed !== 'object') {
3056
+ // Not a JSON object envelope (parse failed, or parsed to
3057
+ // null / primitive / array). Forward raw bytes only.
3058
+ wsModule.message?.(ws, { data: message, isBinary, msg, platform: ws.getUserData()[WS_PLATFORM] });
3047
3059
  return;
3048
3060
  }
3061
+ msg = parsed;
3049
3062
  if (msg.type === 'subscribe' && typeof msg.topic === 'string') {
3050
3063
  const ref = hasRef(msg.ref) ? msg.ref : null;
3051
3064
  if (!isValidWireTopic(msg.topic, ALLOW_NON_ASCII_TOPICS)) {
@@ -3230,8 +3243,10 @@ if (WS_ENABLED) {
3230
3243
  return;
3231
3244
  }
3232
3245
  }
3233
- // Delegate everything else to the user's handler (if provided)
3234
- wsModule.message?.(ws, { data: message, isBinary, platform: ws.getUserData()[WS_PLATFORM] });
3246
+ // Delegate everything else to the user's handler (if provided).
3247
+ // `msg` is the JSON-parsed envelope when the prefix matched + parsed
3248
+ // to an object + no control type matched; otherwise undefined.
3249
+ wsModule.message?.(ws, { data: message, isBinary, msg, platform: ws.getUserData()[WS_PLATFORM] });
3235
3250
  },
3236
3251
 
3237
3252
  drain: (ws) => {
package/index.d.ts CHANGED
@@ -517,6 +517,26 @@ export interface MessageContext {
517
517
  data: ArrayBuffer;
518
518
  /** Whether the message is binary. */
519
519
  isBinary: boolean;
520
+ /**
521
+ * The JSON-parsed envelope, when the adapter parsed the frame for
522
+ * control-message routing (subscribe / unsubscribe / hello / resume /
523
+ * reply / subscribe-batch) but no control type matched.
524
+ *
525
+ * Plugin-layer JSON envelope dispatchers (e.g. svelte-realtime's
526
+ * `createMessage({ onJsonMessage })`) consume this directly instead of
527
+ * re-running `TextDecoder + JSON.parse` on every frame.
528
+ *
529
+ * `undefined` when:
530
+ * - the frame is binary (`isBinary === true`), or
531
+ * - the frame did not start with `{"ty` (byte[3] !== 0x79), or
532
+ * - the frame was larger than 8 KiB, or
533
+ * - `JSON.parse` threw, or
534
+ * - the parsed value was not a plain object (null / array / primitive).
535
+ *
536
+ * The adapter's `websocket.maxPayloadLength` (default 1 MB) is the
537
+ * structural ceiling for frame size; this field adds no separate cap.
538
+ */
539
+ msg?: any;
520
540
  /** The platform API - publish, send, topic helpers, etc. */
521
541
  platform: Platform;
522
542
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-adapter-uws",
3
- "version": "0.5.2",
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,17 +5,24 @@
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
  */
15
22
 
16
23
  const TOPIC_PREFIX = '__presence:';
17
24
 
18
- import { on } from '../../client.js';
25
+ import { on, connect, status } from '../../client.js';
19
26
  import { writable } from 'svelte/store';
20
27
 
21
28
  /** @type {Map<string, { subscribe: (fn: Function) => (() => void) }>} */
@@ -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;
@@ -83,9 +95,11 @@ export function presence(topic, options) {
83
95
  const output = writable(/** @type {any[]} */ ([]));
84
96
 
85
97
  let sourceUnsub = /** @type {(() => void) | null} */ (null);
98
+ let statusUnsub = /** @type {(() => void) | null} */ (null);
86
99
  /** @type {ReturnType<typeof setInterval> | null} */
87
100
  let sweepTimer = null;
88
101
  let refCount = 0;
102
+ let cancelled = false;
89
103
 
90
104
  function flush() {
91
105
  output.set([...userMap.values()]);
@@ -105,6 +119,7 @@ export function presence(topic, options) {
105
119
  }
106
120
 
107
121
  function startListening() {
122
+ cancelled = false;
108
123
  // Fresh on() call each time - the underlying writable in client.js
109
124
  // is cleaned up on full unsubscribe, so a stale reference would
110
125
  // silently stop receiving events.
@@ -150,29 +165,67 @@ export function presence(topic, options) {
150
165
  return;
151
166
  }
152
167
 
153
- if (event.event === 'heartbeat' && Array.isArray(event.data)) {
154
- // Server confirms these keys are still active - refresh their
155
- // timestamps so maxAge doesn't expire them. Keys not in the
156
- // heartbeat are left alone (maxAge will handle them).
168
+ if (event.event === 'heartbeat') {
157
169
  const now = Date.now();
158
- for (const key of event.data) {
159
- if (timestamps.has(key)) {
170
+ let changed = false;
171
+ if (event.data && typeof event.data === 'object' && !Array.isArray(event.data)) {
172
+ // New shape: `{userKey: data}` map. Refresh existing AND
173
+ // re-add any entry that aged out between heartbeats. The
174
+ // older "refresh existing only" branch (below) could not
175
+ // recover entries the local sweep had already removed -
176
+ // once an entry aged out, the next heartbeat couldn't
177
+ // bring it back and the user stayed missing until a
178
+ // presence_diff or presence_state arrived.
179
+ for (const [key, data] of Object.entries(event.data)) {
160
180
  timestamps.set(key, now);
181
+ const prev = userMap.get(key);
182
+ if (prev !== data) {
183
+ userMap.set(key, data);
184
+ changed = true;
185
+ }
186
+ }
187
+ } else if (Array.isArray(event.data)) {
188
+ // Back-compat: keys-only heartbeat (older server). Refresh
189
+ // existing entries; cannot recover aged-out ones from this
190
+ // shape. The presence_diff / presence_state reconciliation
191
+ // path still corrects missing entries on the next event.
192
+ for (const key of event.data) {
193
+ if (timestamps.has(key)) {
194
+ timestamps.set(key, now);
195
+ }
161
196
  }
162
197
  }
198
+ if (changed) flush();
199
+ return;
163
200
  }
164
201
  });
165
202
 
166
203
  if (maxAge > 0) {
167
204
  sweepTimer = setInterval(sweep, Math.max(maxAge / 2, 1000));
168
205
  }
206
+
207
+ // Request a presence snapshot every time the socket opens (initial
208
+ // connect AND reconnects). Without this, a reconnecting client
209
+ // missed any presence_diff frames that fired during the disconnect
210
+ // window and its in-memory map stayed at whatever it last knew.
211
+ // Symmetric to the cursor plugin's `cursor-snapshot` send.
212
+ statusUnsub = status.subscribe((s) => {
213
+ if (s === 'open' && !cancelled) {
214
+ connect().send({ type: 'presence-snapshot', topic });
215
+ }
216
+ });
169
217
  }
170
218
 
171
219
  function stopListening() {
220
+ cancelled = true;
172
221
  if (sourceUnsub) {
173
222
  sourceUnsub();
174
223
  sourceUnsub = null;
175
224
  }
225
+ if (statusUnsub) {
226
+ statusUnsub();
227
+ statusUnsub = null;
228
+ }
176
229
  if (sweepTimer) {
177
230
  clearInterval(sweepTimer);
178
231
  sweepTimer = null;
@@ -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
  }
package/testing.js CHANGED
@@ -568,12 +568,27 @@ export async function createTestServer(options = {}) {
568
568
 
569
569
  async message(ws, message, isBinary) {
570
570
  bumpInT(ws, message);
571
- // Handle subscribe/unsubscribe from client store
571
+ // Handle subscribe/unsubscribe from client store.
572
+ //
573
+ // `msg` is hoisted to outer scope so it can be forwarded to the
574
+ // user handler in the fall-through delegation below. When the
575
+ // prefix matched and JSON.parse produced an object that did NOT
576
+ // match any known control type, the parsed value reaches plugin-
577
+ // layer dispatchers (e.g. svelte-realtime's `onJsonMessage`)
578
+ // directly, so they don't re-run TextDecoder + JSON.parse on
579
+ // every frame. Mirrors handler.js + vite.js.
580
+ /** @type {any} */
581
+ let msg;
572
582
  if (!isBinary && message.byteLength < 8192) {
573
583
  const bytes = new Uint8Array(message);
574
584
  if (bytes[3] === 0x79) {
575
585
  try {
576
- const msg = JSON.parse(Buffer.from(message).toString());
586
+ msg = JSON.parse(Buffer.from(message).toString());
587
+ // Reject null / primitives / arrays so `msg` only reaches
588
+ // the user handler as a {type,...} object envelope. Throw
589
+ // to the catch (which clears `msg`) for a unified fall-
590
+ // through path with parse failures.
591
+ if (msg === null || typeof msg !== 'object' || Array.isArray(msg)) throw 0;
577
592
  if (msg.type === 'subscribe' && typeof msg.topic === 'string') {
578
593
  const ref = hasRefT(msg.ref) ? msg.ref : null;
579
594
  if (!isValidWireTopic(msg.topic, ALLOW_NON_ASCII_TOPICS_T)) {
@@ -702,7 +717,13 @@ export async function createTestServer(options = {}) {
702
717
  sendOutboundT(ws, '{"type":"resumed"}');
703
718
  return;
704
719
  }
705
- } catch {}
720
+ } catch {
721
+ // Not JSON, not an object envelope, or a known control
722
+ // type that threw inside its handler. Clear `msg` so the
723
+ // fall-through delegation sees `msg: undefined` (raw
724
+ // bytes only).
725
+ msg = undefined;
726
+ }
706
727
  }
707
728
  }
708
729
 
@@ -712,7 +733,9 @@ export async function createTestServer(options = {}) {
712
733
  }
713
734
  messageWaiters = [];
714
735
 
715
- handler.message?.(ws, { data: message, isBinary, platform: ws.getUserData()[WS_PLATFORM] });
736
+ // `msg` is the JSON-parsed envelope when the prefix matched + parsed
737
+ // to an object + no control type matched; otherwise undefined.
738
+ handler.message?.(ws, { data: message, isBinary, msg, platform: ws.getUserData()[WS_PLATFORM] });
716
739
  },
717
740
 
718
741
  close(ws, code, message) {
package/vite.js CHANGED
@@ -1003,9 +1003,24 @@ export default function uws(options = {}) {
1003
1003
  // {"topic" have byte[3]='o' - skip JSON.parse for non-control messages.
1004
1004
  // 8192 bytes matches the production handler ceiling and is large
1005
1005
  // enough for a subscribe-batch with many topics.
1006
+ //
1007
+ // `msg` is hoisted to outer scope so it can be forwarded to the
1008
+ // user handler in the fall-through delegation below. When the
1009
+ // prefix matched and JSON.parse produced an object that did NOT
1010
+ // match any known control type, the parsed value reaches plugin-
1011
+ // layer dispatchers (e.g. svelte-realtime's `onJsonMessage`)
1012
+ // directly, so they don't re-run TextDecoder + JSON.parse on
1013
+ // every frame.
1014
+ /** @type {any} */
1015
+ let msg;
1006
1016
  if (!isBinary && buf.byteLength < 8192 && buf[3] === 0x79) {
1007
1017
  try {
1008
- const msg = JSON.parse(buf.toString());
1018
+ msg = JSON.parse(buf.toString());
1019
+ // Reject null / primitives / arrays so `msg` only reaches
1020
+ // the user handler as a {type,...} object envelope. Throw
1021
+ // to the catch (which clears `msg`) for a unified fall-
1022
+ // through path with parse failures.
1023
+ if (msg === null || typeof msg !== 'object' || Array.isArray(msg)) throw 0;
1009
1024
  if (msg.type === 'subscribe' && typeof msg.topic === 'string') {
1010
1025
  const ref = hasRefValue(msg.ref) ? msg.ref : null;
1011
1026
  if (!isValidWireTopic(msg.topic, ALLOW_NON_ASCII_TOPICS_V)) {
@@ -1149,14 +1164,20 @@ export default function uws(options = {}) {
1149
1164
  return;
1150
1165
  }
1151
1166
  } catch {
1152
- // Not JSON - fall through to user handler
1167
+ // Not JSON, not an object envelope, or a known control
1168
+ // type that threw inside its handler. Clear `msg` so the
1169
+ // fall-through delegation sees `msg: undefined` (raw
1170
+ // bytes only).
1171
+ msg = undefined;
1153
1172
  }
1154
1173
  }
1155
1174
 
1156
- // Delegate to user handler
1175
+ // Delegate to user handler. `msg` is the JSON-parsed envelope
1176
+ // when the prefix matched + parsed to an object + no control
1177
+ // type matched; otherwise undefined.
1157
1178
  await handlerReady;
1158
1179
  if (userHandlers.message) {
1159
- userHandlers.message(wrapped, { data: arrayBuffer, isBinary: !!isBinary, platform: wrapped.getUserData()[WS_PLATFORM] });
1180
+ userHandlers.message(wrapped, { data: arrayBuffer, isBinary: !!isBinary, msg, platform: wrapped.getUserData()[WS_PLATFORM] });
1160
1181
  }
1161
1182
  });
1162
1183