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 +15 -6
- package/package.json +1 -1
- package/plugins/presence/client.js +13 -0
- package/plugins/presence/server.d.ts +19 -0
- package/plugins/presence/server.js +47 -0
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
|
|
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
|
-
//
|
|
1584
|
-
const
|
|
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
|
@@ -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;
|