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 +17 -12
- package/package.json +1 -1
- package/plugins/cursor/server.js +142 -28
- package/plugins/presence/client.d.ts +9 -0
- package/plugins/presence/client.js +20 -8
- package/plugins/presence/server.d.ts +18 -6
- package/plugins/presence/server.js +27 -11
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:
|
|
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:
|
|
2454
|
-
heartbeat:
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
2496
|
-
const users = presence('room', { maxAge:
|
|
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
package/plugins/cursor/server.js
CHANGED
|
@@ -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
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
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
|
|
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 = {
|
|
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
|
-
|
|
312
|
-
|
|
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
|
-
|
|
464
|
-
|
|
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
|
-
*
|
|
9
|
-
* or
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
* //
|
|
66
|
-
* const users = presence('room', { maxAge:
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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
|
|
62
|
+
* @default 30000
|
|
57
63
|
*
|
|
58
64
|
* @example
|
|
59
65
|
* ```js
|
|
60
|
-
* //
|
|
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=
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
}
|