svelte-adapter-uws 0.3.2 → 0.3.3

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
@@ -1488,7 +1488,8 @@ import { createPresence } from 'svelte-adapter-uws/plugins/presence';
1488
1488
 
1489
1489
  export const presence = createPresence({
1490
1490
  key: 'id',
1491
- select: (userData) => ({ id: userData.id, name: userData.name })
1491
+ select: (userData) => ({ id: userData.id, name: userData.name }),
1492
+ heartbeat: 60_000 // optional: needed if clients use maxAge
1492
1493
  });
1493
1494
  ```
1494
1495
 
@@ -1556,7 +1557,8 @@ import { createPresence } from 'svelte-adapter-uws/plugins/presence';
1556
1557
 
1557
1558
  const presence = createPresence({
1558
1559
  key: 'id', // field for multi-tab dedup (default: 'id')
1559
- select: (userData) => userData // extract public fields (default: full userData)
1560
+ select: (userData) => userData, // extract public fields (default: full userData)
1561
+ heartbeat: 60_000 // broadcast active keys every 60s (default: disabled)
1560
1562
  });
1561
1563
 
1562
1564
  presence.hooks // ready-made { subscribe, close } hooks
@@ -1565,7 +1567,7 @@ presence.leave(ws, platform) // remove from all topics (call from close
1565
1567
  presence.sync(ws, topic, platform) // send list without joining (for observers)
1566
1568
  presence.list(topic) // current user data array
1567
1569
  presence.count(topic) // unique user count
1568
- presence.clear() // reset everything
1570
+ presence.clear() // reset everything (stops heartbeat timer)
1569
1571
  ```
1570
1572
 
1571
1573
  #### Client API
@@ -1577,13 +1579,20 @@ const users = presence('room');
1577
1579
  // $users = [{ id: '1', name: 'Alice' }, { id: '2', name: 'Bob' }]
1578
1580
  ```
1579
1581
 
1580
- The `presence()` function accepts an optional second argument with a `maxAge` option (in milliseconds). When set, entries that haven't been refreshed (via `list` or `join` events) within that window are automatically removed from the store. This makes clients self-healing when the server fails to broadcast `leave` events under load:
1582
+ 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 `leave` events under load.
1583
+
1584
+ **Important:** `maxAge` requires the server-side `heartbeat` option. Without heartbeat, no events arrive between the initial `list` 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.
1581
1585
 
1582
1586
  ```js
1583
- // Entries expire after 90s without a server refresh
1584
- const users = presence('room', { maxAge: 90_000 });
1587
+ // Server: heartbeat every 60s
1588
+ const presence = createPresence({ key: 'id', heartbeat: 60_000 });
1589
+
1590
+ // Client: entries expire after 120s without a heartbeat refresh
1591
+ const users = presence('room', { maxAge: 120_000 });
1585
1592
  ```
1586
1593
 
1594
+ Rule of thumb: set `heartbeat` to half (or less) of the client's `maxAge`.
1595
+
1587
1596
  #### How multi-tab dedup works
1588
1597
 
1589
1598
  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.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "SvelteKit adapter for uWebSockets.js - high-performance C++ HTTP server with built-in WebSocket support",
5
5
  "author": "Kevin Radziszewski",
6
6
  "license": "MIT",
@@ -138,6 +138,19 @@ export function presence(topic, options) {
138
138
  if (userMap.delete(key)) {
139
139
  flush();
140
140
  }
141
+ return;
142
+ }
143
+
144
+ if (event.event === 'heartbeat' && Array.isArray(event.data)) {
145
+ // Server confirms these keys are still active -- refresh their
146
+ // timestamps so maxAge doesn't expire them. Keys not in the
147
+ // heartbeat are left alone (maxAge will handle them).
148
+ const now = Date.now();
149
+ for (const key of event.data) {
150
+ if (timestamps.has(key)) {
151
+ timestamps.set(key, now);
152
+ }
153
+ }
141
154
  }
142
155
  });
143
156
 
@@ -27,6 +27,25 @@ export interface PresenceOptions<UserData = unknown, Selected extends Record<str
27
27
  * ```
28
28
  */
29
29
  select?: (userData: UserData) => Selected;
30
+
31
+ /**
32
+ * Interval in milliseconds between heartbeat broadcasts.
33
+ *
34
+ * When set, the server periodically publishes a `heartbeat` event to all
35
+ * presence topics containing the list of active user keys. This resets
36
+ * the `maxAge` timer on clients, preventing live users from being expired.
37
+ *
38
+ * Set this to a value shorter than the client's `maxAge`.
39
+ *
40
+ * @default 0 (disabled)
41
+ *
42
+ * @example
43
+ * ```js
44
+ * // Server heartbeat every 60s, client maxAge 120s
45
+ * const presence = createPresence({ heartbeat: 60_000 });
46
+ * ```
47
+ */
48
+ heartbeat?: number;
30
49
  }
31
50
 
32
51
  export interface PresenceTracker<Selected extends Record<string, any> = Record<string, any>> {
@@ -20,6 +20,11 @@
20
20
  * presence data from the connection's userData (whatever your `upgrade` handler returned).
21
21
  * Only the selected fields are broadcast to other clients. Defaults to the full userData.
22
22
  * Use this to avoid leaking private fields like session tokens.
23
+ * @property {number} [heartbeat=0] - Interval in milliseconds between heartbeat broadcasts.
24
+ * When set, the server periodically publishes a `heartbeat` event to all presence topics
25
+ * containing the list of active keys. This resets the `maxAge` timer on clients, preventing
26
+ * live users from being expired. Set this to a value shorter than the client's `maxAge`.
27
+ * Disabled by default (0 or omitted).
23
28
  */
24
29
 
25
30
  /**
@@ -96,10 +101,21 @@
96
101
  export function createPresence(options = {}) {
97
102
  const keyField = options.key || 'id';
98
103
  const select = options.select || ((userData) => userData);
104
+ const heartbeatMs = options.heartbeat || 0;
99
105
 
100
106
  // Auto-generated ID counter for connections without a key field
101
107
  let connCounter = 0;
102
108
 
109
+ /**
110
+ * Platform reference, captured on first use of join/leave/sync.
111
+ * Needed by the heartbeat timer to publish without a hook context.
112
+ * @type {import('../../index.js').Platform | null}
113
+ */
114
+ let _platform = null;
115
+
116
+ /** @type {ReturnType<typeof setInterval> | null} */
117
+ let heartbeatTimer = null;
118
+
103
119
  /**
104
120
  * Per-connection state: which topics they've joined and their key on each.
105
121
  * @type {Map<any, Map<string, { key: string, data: Record<string, any> }>>}
@@ -126,9 +142,33 @@ export function createPresence(options = {}) {
126
142
  return '__conn:' + (++connCounter);
127
143
  }
128
144
 
145
+ /**
146
+ * Capture the platform reference and start the heartbeat if configured.
147
+ * Called lazily on first join/leave/sync -- the platform object isn't
148
+ * available at createPresence() time.
149
+ * @param {import('../../index.js').Platform} platform
150
+ */
151
+ function capturePlatform(platform) {
152
+ if (_platform) return;
153
+ _platform = platform;
154
+ if (heartbeatMs > 0) {
155
+ heartbeatTimer = setInterval(() => {
156
+ for (const [topic, users] of topicPresence) {
157
+ _platform.publish(
158
+ '__presence:' + topic,
159
+ 'heartbeat',
160
+ [...users.keys()]
161
+ );
162
+ }
163
+ }, heartbeatMs);
164
+ }
165
+ }
166
+
129
167
  /** @type {PresenceTracker} */
130
168
  const tracker = {
131
169
  join(ws, topic, platform) {
170
+ capturePlatform(platform);
171
+
132
172
  // Skip internal topics to prevent recursion when the subscribe
133
173
  // hook fires for __presence:* subscriptions
134
174
  if (topic.startsWith('__')) return;
@@ -181,6 +221,7 @@ export function createPresence(options = {}) {
181
221
  },
182
222
 
183
223
  leave(ws, platform) {
224
+ capturePlatform(platform);
184
225
  const connTopics = wsTopics.get(ws);
185
226
  if (!connTopics) return;
186
227
 
@@ -207,6 +248,7 @@ export function createPresence(options = {}) {
207
248
  },
208
249
 
209
250
  sync(ws, topic, platform) {
251
+ capturePlatform(platform);
210
252
  const users = topicPresence.get(topic);
211
253
  const presenceTopic = '__presence:' + topic;
212
254
  const list = [];
@@ -235,6 +277,11 @@ export function createPresence(options = {}) {
235
277
  },
236
278
 
237
279
  clear() {
280
+ if (heartbeatTimer) {
281
+ clearInterval(heartbeatTimer);
282
+ heartbeatTimer = null;
283
+ }
284
+ _platform = null;
238
285
  wsTopics.clear();
239
286
  topicPresence.clear();
240
287
  connCounter = 0;