svelte-adapter-uws 0.3.1 → 0.3.2
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 +43 -0
- package/client.d.ts +2 -2
- package/client.js +192 -16
- package/package.json +1 -1
- package/plugins/cursor/client.d.ts +2 -1
- package/plugins/cursor/client.js +45 -2
- package/plugins/presence/client.d.ts +2 -1
- package/plugins/presence/client.js +61 -8
package/README.md
CHANGED
|
@@ -60,6 +60,7 @@ I've been loving Svelte and SvelteKit for a long time. I always wanted to expand
|
|
|
60
60
|
|
|
61
61
|
**Help**
|
|
62
62
|
- [Troubleshooting](#troubleshooting)
|
|
63
|
+
- [Related projects](#related-projects)
|
|
63
64
|
- [License](#license)
|
|
64
65
|
|
|
65
66
|
---
|
|
@@ -998,6 +999,7 @@ One-liner for real-time collections. Handles `created`, `updated`, and `deleted`
|
|
|
998
999
|
Options:
|
|
999
1000
|
- `key` - property to match items by (default: `'id'`)
|
|
1000
1001
|
- `prepend` - add new items to the beginning instead of end (default: `false`)
|
|
1002
|
+
- `maxAge` - auto-remove entries that haven't been created/updated within this many milliseconds (see [maxAge](#maxage---client-side-entry-expiry) below)
|
|
1001
1003
|
|
|
1002
1004
|
```js
|
|
1003
1005
|
// Notifications, newest first
|
|
@@ -1044,6 +1046,29 @@ Like `crud()` but returns a `Record<string, T>` instead of an array. Better for
|
|
|
1044
1046
|
{/if}
|
|
1045
1047
|
```
|
|
1046
1048
|
|
|
1049
|
+
Options:
|
|
1050
|
+
- `key` - property to match items by (default: `'id'`)
|
|
1051
|
+
- `maxAge` - auto-remove entries that haven't been created/updated within this many milliseconds (see [maxAge](#maxage---client-side-entry-expiry) below)
|
|
1052
|
+
|
|
1053
|
+
### `maxAge` - client-side entry expiry
|
|
1054
|
+
|
|
1055
|
+
Both `crud()` and `lookup()` accept a `maxAge` option (in milliseconds). When set, entries that haven't received a `created` or `updated` event within that window are automatically removed from the store. Explicit `deleted` events still remove entries immediately.
|
|
1056
|
+
|
|
1057
|
+
This is useful for state backed by an external store with TTL (e.g. Redis). If the server fails to broadcast a removal event (mass disconnects, crashes, Redis TTL expiry without keyspace notifications), clients clean up on their own:
|
|
1058
|
+
|
|
1059
|
+
```js
|
|
1060
|
+
// Presence entries expire after 90s without a refresh
|
|
1061
|
+
const users = lookup('__presence:board', data.users, { key: 'key', maxAge: 90_000 });
|
|
1062
|
+
|
|
1063
|
+
// Sensor readings expire after 30s without an update
|
|
1064
|
+
const sensors = lookup('sensors', [], { key: 'id', maxAge: 30_000 });
|
|
1065
|
+
|
|
1066
|
+
// Same option works on crud()
|
|
1067
|
+
const items = crud('items', data.items, { maxAge: 60_000 });
|
|
1068
|
+
```
|
|
1069
|
+
|
|
1070
|
+
The sweep runs at `maxAge / 2` intervals (minimum 1 second). The timer is cleaned up automatically when the last subscriber unsubscribes.
|
|
1071
|
+
|
|
1047
1072
|
### `latest(topic, max?, initial?)` - ring buffer
|
|
1048
1073
|
|
|
1049
1074
|
Keeps the last N events. Perfect for chat, activity feeds, notifications:
|
|
@@ -1552,6 +1577,13 @@ const users = presence('room');
|
|
|
1552
1577
|
// $users = [{ id: '1', name: 'Alice' }, { id: '2', name: 'Bob' }]
|
|
1553
1578
|
```
|
|
1554
1579
|
|
|
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:
|
|
1581
|
+
|
|
1582
|
+
```js
|
|
1583
|
+
// Entries expire after 90s without a server refresh
|
|
1584
|
+
const users = presence('room', { maxAge: 90_000 });
|
|
1585
|
+
```
|
|
1586
|
+
|
|
1555
1587
|
#### How multi-tab dedup works
|
|
1556
1588
|
|
|
1557
1589
|
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.
|
|
@@ -1847,6 +1879,12 @@ export function close(ws, { platform }) {
|
|
|
1847
1879
|
|
|
1848
1880
|
The client store is a `Readable<Map<string, { user, data }>>`. The Map updates when cursors move or disconnect.
|
|
1849
1881
|
|
|
1882
|
+
The `cursor()` function accepts an optional second argument with a `maxAge` option (in milliseconds). When set, cursor entries that haven't received an update within that window are automatically removed. This makes clients self-healing when the server fails to broadcast `remove` events under load:
|
|
1883
|
+
|
|
1884
|
+
```js
|
|
1885
|
+
const positions = cursor('canvas', { maxAge: 30_000 });
|
|
1886
|
+
```
|
|
1887
|
+
|
|
1850
1888
|
#### Server API
|
|
1851
1889
|
|
|
1852
1890
|
| Method | Description |
|
|
@@ -2520,6 +2558,11 @@ Or if you're using `on()` directly (which auto-connects), call `connect()` first
|
|
|
2520
2558
|
|
|
2521
2559
|
---
|
|
2522
2560
|
|
|
2561
|
+
## Related projects
|
|
2562
|
+
|
|
2563
|
+
- [svelte-adapter-uws-extensions](https://github.com/lanteanio/svelte-adapter-uws-extensions) -- Redis-backed extensions for multi-server deployments: persistent presence, distributed pub/sub, session storage, and more.
|
|
2564
|
+
- [svelte-realtime](https://github.com/lanteanio/svelte-realtime) -- Opinionated full-stack starter built on this adapter. Auth, database, real-time CRUD, and deployment config out of the box.
|
|
2565
|
+
|
|
2523
2566
|
## License
|
|
2524
2567
|
|
|
2525
2568
|
[MIT](LICENSE)
|
package/client.d.ts
CHANGED
|
@@ -179,7 +179,7 @@ export const status: Readable<'connecting' | 'open' | 'closed'>;
|
|
|
179
179
|
export function crud<T extends Record<string, any>>(
|
|
180
180
|
topic: string,
|
|
181
181
|
initial?: T[],
|
|
182
|
-
options?: { key?: keyof T & string; prepend?: boolean }
|
|
182
|
+
options?: { key?: keyof T & string; prepend?: boolean; maxAge?: number }
|
|
183
183
|
): Readable<T[]>;
|
|
184
184
|
|
|
185
185
|
/**
|
|
@@ -207,7 +207,7 @@ export function crud<T extends Record<string, any>>(
|
|
|
207
207
|
export function lookup<T extends Record<string, any>>(
|
|
208
208
|
topic: string,
|
|
209
209
|
initial?: T[],
|
|
210
|
-
options?: { key?: keyof T & string }
|
|
210
|
+
options?: { key?: keyof T & string; maxAge?: number }
|
|
211
211
|
): Readable<Record<string, T>>;
|
|
212
212
|
|
|
213
213
|
/**
|
package/client.js
CHANGED
|
@@ -104,49 +104,225 @@ export function ready() {
|
|
|
104
104
|
* Live CRUD list - one line for real-time collections.
|
|
105
105
|
* Auto-connects, auto-subscribes, and auto-handles created/updated/deleted events.
|
|
106
106
|
*
|
|
107
|
+
* When `maxAge` is set, entries that haven't been created or updated
|
|
108
|
+
* within that window are automatically removed from the list.
|
|
109
|
+
*
|
|
107
110
|
* @template T
|
|
108
111
|
* @param {string} topic - Topic to subscribe to
|
|
109
112
|
* @param {T[]} [initial] - Starting data (e.g. from a load function)
|
|
110
|
-
* @param {{ key?: string, prepend?: boolean }} [options] - Options
|
|
113
|
+
* @param {{ key?: string, prepend?: boolean, maxAge?: number }} [options] - Options
|
|
111
114
|
* @returns {import('svelte/store').Readable<T[]>}
|
|
112
115
|
*/
|
|
113
116
|
export function crud(topic, initial = [], options = {}) {
|
|
114
117
|
const key = options.key || 'id';
|
|
115
118
|
const prepend = options.prepend || false;
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
119
|
+
const maxAge = options.maxAge;
|
|
120
|
+
|
|
121
|
+
if (maxAge == null || maxAge <= 0) {
|
|
122
|
+
return on(topic).scan(/** @type {any[]} */ (initial), (list, { event, data }) => {
|
|
123
|
+
if (event === 'created') return prepend ? [data, ...list] : [...list, data];
|
|
124
|
+
if (event === 'updated') return list.map((item) => item[key] === data[key] ? data : item);
|
|
125
|
+
if (event === 'deleted') return list.filter((item) => item[key] !== data[key]);
|
|
126
|
+
return list;
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// maxAge mode: track timestamps per key, sweep on interval
|
|
131
|
+
const conn = ensureConnection();
|
|
132
|
+
const source = conn.on(topic);
|
|
133
|
+
|
|
134
|
+
/** @type {any[]} */
|
|
135
|
+
let list = [...initial];
|
|
136
|
+
/** @type {Map<string, number>} */
|
|
137
|
+
const timestamps = new Map();
|
|
138
|
+
const now = Date.now();
|
|
139
|
+
for (const item of initial) {
|
|
140
|
+
timestamps.set(String(item[key]), now);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const output = writable(list);
|
|
144
|
+
/** @type {(() => void) | null} */
|
|
145
|
+
let sourceUnsub = null;
|
|
146
|
+
/** @type {ReturnType<typeof setInterval> | null} */
|
|
147
|
+
let sweepTimer = null;
|
|
148
|
+
let subCount = 0;
|
|
149
|
+
|
|
150
|
+
function sweep() {
|
|
151
|
+
const cutoff = Date.now() - /** @type {number} */ (maxAge);
|
|
152
|
+
let changed = false;
|
|
153
|
+
for (const [id, ts] of timestamps) {
|
|
154
|
+
if (ts < cutoff) {
|
|
155
|
+
timestamps.delete(id);
|
|
156
|
+
const before = list.length;
|
|
157
|
+
list = list.filter((item) => String(item[key]) !== id);
|
|
158
|
+
if (list.length !== before) changed = true;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (changed) output.set(list);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function start() {
|
|
165
|
+
sourceUnsub = source.subscribe((event) => {
|
|
166
|
+
if (event === null) return;
|
|
167
|
+
const { event: evt, data } = event;
|
|
168
|
+
const id = String(data[key]);
|
|
169
|
+
if (evt === 'created') {
|
|
170
|
+
timestamps.set(id, Date.now());
|
|
171
|
+
list = prepend ? [data, ...list] : [...list, data];
|
|
172
|
+
output.set(list);
|
|
173
|
+
} else if (evt === 'updated') {
|
|
174
|
+
timestamps.set(id, Date.now());
|
|
175
|
+
list = list.map((item) => String(item[key]) === id ? data : item);
|
|
176
|
+
output.set(list);
|
|
177
|
+
} else if (evt === 'deleted') {
|
|
178
|
+
timestamps.delete(id);
|
|
179
|
+
list = list.filter((item) => String(item[key]) !== id);
|
|
180
|
+
output.set(list);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
sweepTimer = setInterval(sweep, Math.max(maxAge / 2, 1000));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function stop() {
|
|
187
|
+
if (sourceUnsub) { sourceUnsub(); sourceUnsub = null; }
|
|
188
|
+
if (sweepTimer) { clearInterval(sweepTimer); sweepTimer = null; }
|
|
189
|
+
list = [...initial];
|
|
190
|
+
const now = Date.now();
|
|
191
|
+
timestamps.clear();
|
|
192
|
+
for (const item of initial) {
|
|
193
|
+
timestamps.set(String(item[key]), now);
|
|
194
|
+
}
|
|
195
|
+
output.set(list);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
subscribe(fn) {
|
|
200
|
+
if (subCount++ === 0) start();
|
|
201
|
+
const unsub = output.subscribe(fn);
|
|
202
|
+
return () => {
|
|
203
|
+
unsub();
|
|
204
|
+
if (--subCount === 0) stop();
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
};
|
|
122
208
|
}
|
|
123
209
|
|
|
124
210
|
/**
|
|
125
211
|
* Live keyed object - like `crud()` but returns a `Record` keyed by ID.
|
|
126
212
|
* Better for dashboards and fast lookups.
|
|
127
213
|
*
|
|
214
|
+
* When `maxAge` is set, entries that haven't been created or updated
|
|
215
|
+
* within that window are automatically removed. Useful for presence,
|
|
216
|
+
* cursors, or any state backed by an external store with TTL expiry.
|
|
217
|
+
*
|
|
128
218
|
* @template T
|
|
129
219
|
* @param {string} topic - Topic to subscribe to
|
|
130
220
|
* @param {T[]} [initial] - Starting data (e.g. from a load function)
|
|
131
|
-
* @param {{ key?: string }} [options] - Options
|
|
221
|
+
* @param {{ key?: string, maxAge?: number }} [options] - Options
|
|
132
222
|
* @returns {import('svelte/store').Readable<Record<string, T>>}
|
|
133
223
|
*/
|
|
134
224
|
export function lookup(topic, initial = [], options = {}) {
|
|
135
225
|
const key = options.key || 'id';
|
|
226
|
+
const maxAge = options.maxAge;
|
|
136
227
|
/** @type {Record<string, any>} */
|
|
137
228
|
const initialMap = {};
|
|
138
229
|
for (const item of initial) {
|
|
139
230
|
initialMap[/** @type {any} */ (item)[key]] = item;
|
|
140
231
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
232
|
+
|
|
233
|
+
if (maxAge == null || maxAge <= 0) {
|
|
234
|
+
return on(topic).scan(initialMap, (map, { event, data }) => {
|
|
235
|
+
const id = data[key];
|
|
236
|
+
if (event === 'created' || event === 'updated') return { ...map, [id]: data };
|
|
237
|
+
if (event === 'deleted') {
|
|
238
|
+
const { [id]: _, ...rest } = map;
|
|
239
|
+
return rest;
|
|
240
|
+
}
|
|
241
|
+
return map;
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// maxAge mode: track timestamps per key, sweep on interval
|
|
246
|
+
const conn = ensureConnection();
|
|
247
|
+
const source = conn.on(topic);
|
|
248
|
+
|
|
249
|
+
/** @type {Record<string, any>} */
|
|
250
|
+
let map = { ...initialMap };
|
|
251
|
+
/** @type {Map<string, number>} */
|
|
252
|
+
const timestamps = new Map();
|
|
253
|
+
const now = Date.now();
|
|
254
|
+
for (const id in initialMap) {
|
|
255
|
+
timestamps.set(id, now);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const output = writable(map);
|
|
259
|
+
/** @type {(() => void) | null} */
|
|
260
|
+
let sourceUnsub = null;
|
|
261
|
+
/** @type {ReturnType<typeof setInterval> | null} */
|
|
262
|
+
let sweepTimer = null;
|
|
263
|
+
let subCount = 0;
|
|
264
|
+
|
|
265
|
+
function sweep() {
|
|
266
|
+
const cutoff = Date.now() - /** @type {number} */ (maxAge);
|
|
267
|
+
let changed = false;
|
|
268
|
+
for (const [id, ts] of timestamps) {
|
|
269
|
+
if (ts < cutoff) {
|
|
270
|
+
timestamps.delete(id);
|
|
271
|
+
if (id in map) {
|
|
272
|
+
const { [id]: _, ...rest } = map;
|
|
273
|
+
map = rest;
|
|
274
|
+
changed = true;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
147
277
|
}
|
|
148
|
-
|
|
149
|
-
}
|
|
278
|
+
if (changed) output.set(map);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function start() {
|
|
282
|
+
sourceUnsub = source.subscribe((event) => {
|
|
283
|
+
if (event === null) return;
|
|
284
|
+
const { event: evt, data } = event;
|
|
285
|
+
const id = data[key];
|
|
286
|
+
if (evt === 'created' || evt === 'updated') {
|
|
287
|
+
timestamps.set(id, Date.now());
|
|
288
|
+
map = { ...map, [id]: data };
|
|
289
|
+
output.set(map);
|
|
290
|
+
} else if (evt === 'deleted') {
|
|
291
|
+
timestamps.delete(id);
|
|
292
|
+
if (id in map) {
|
|
293
|
+
const { [id]: _, ...rest } = map;
|
|
294
|
+
map = rest;
|
|
295
|
+
output.set(map);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
// Sweep at half the maxAge interval for responsive cleanup
|
|
300
|
+
// without burning cycles on very short intervals
|
|
301
|
+
sweepTimer = setInterval(sweep, Math.max(maxAge / 2, 1000));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function stop() {
|
|
305
|
+
if (sourceUnsub) { sourceUnsub(); sourceUnsub = null; }
|
|
306
|
+
if (sweepTimer) { clearInterval(sweepTimer); sweepTimer = null; }
|
|
307
|
+
map = { ...initialMap };
|
|
308
|
+
const now = Date.now();
|
|
309
|
+
timestamps.clear();
|
|
310
|
+
for (const id in initialMap) {
|
|
311
|
+
timestamps.set(id, now);
|
|
312
|
+
}
|
|
313
|
+
output.set(map);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return {
|
|
317
|
+
subscribe(fn) {
|
|
318
|
+
if (subCount++ === 0) start();
|
|
319
|
+
const unsub = output.subscribe(fn);
|
|
320
|
+
return () => {
|
|
321
|
+
unsub();
|
|
322
|
+
if (--subCount === 0) stop();
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
};
|
|
150
326
|
}
|
|
151
327
|
|
|
152
328
|
/**
|
package/package.json
CHANGED
|
@@ -29,5 +29,6 @@ export interface CursorPosition<UserInfo = unknown, Data = unknown> {
|
|
|
29
29
|
* ```
|
|
30
30
|
*/
|
|
31
31
|
export function cursor<UserInfo = unknown, Data = unknown>(
|
|
32
|
-
topic: string
|
|
32
|
+
topic: string,
|
|
33
|
+
options?: { maxAge?: number }
|
|
33
34
|
): Readable<Map<string, CursorPosition<UserInfo, Data>>>;
|
package/plugins/cursor/client.js
CHANGED
|
@@ -5,6 +5,11 @@
|
|
|
5
5
|
* a live Map of cursor positions. The server handles throttling and
|
|
6
6
|
* cleanup; this module keeps the client-side state in sync.
|
|
7
7
|
*
|
|
8
|
+
* When `maxAge` is set, cursor entries that haven't received an update
|
|
9
|
+
* within that window are automatically removed. This makes clients
|
|
10
|
+
* self-healing when the server fails to broadcast a `remove` event
|
|
11
|
+
* (e.g. mass disconnects overwhelming Redis cleanup).
|
|
12
|
+
*
|
|
8
13
|
* @module svelte-adapter-uws/plugins/cursor/client
|
|
9
14
|
*/
|
|
10
15
|
|
|
@@ -20,6 +25,7 @@ import { writable } from 'svelte/store';
|
|
|
20
25
|
*
|
|
21
26
|
* @template UserInfo, Data
|
|
22
27
|
* @param {string} topic - Topic to track cursors on
|
|
28
|
+
* @param {{ maxAge?: number }} [options] - Options
|
|
23
29
|
* @returns {import('svelte/store').Readable<Map<string, { user: UserInfo, data: Data }>>}
|
|
24
30
|
*
|
|
25
31
|
* @example
|
|
@@ -36,36 +42,68 @@ import { writable } from 'svelte/store';
|
|
|
36
42
|
* </div>
|
|
37
43
|
* {/each}
|
|
38
44
|
* ```
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* ```svelte
|
|
48
|
+
* <script>
|
|
49
|
+
* // Self-healing: cursors expire after 30s without movement
|
|
50
|
+
* const cursors = cursor('canvas', { maxAge: 30_000 });
|
|
51
|
+
* </script>
|
|
52
|
+
* ```
|
|
39
53
|
*/
|
|
40
|
-
export function cursor(topic) {
|
|
54
|
+
export function cursor(topic, options) {
|
|
55
|
+
const maxAge = options?.maxAge;
|
|
41
56
|
const cursorTopic = '__cursor:' + topic;
|
|
42
|
-
const source = on(cursorTopic);
|
|
43
57
|
|
|
44
58
|
/** @type {Map<string, { user: any, data: any }>} */
|
|
45
59
|
let cursorMap = new Map();
|
|
60
|
+
/** @type {Map<string, number>} */
|
|
61
|
+
const timestamps = new Map();
|
|
46
62
|
const output = writable(/** @type {Map<string, any>} */ (new Map()));
|
|
47
63
|
|
|
48
64
|
let sourceUnsub = /** @type {(() => void) | null} */ (null);
|
|
65
|
+
/** @type {ReturnType<typeof setInterval> | null} */
|
|
66
|
+
let sweepTimer = null;
|
|
49
67
|
let refCount = 0;
|
|
50
68
|
|
|
69
|
+
function sweep() {
|
|
70
|
+
if (!maxAge || maxAge <= 0) return;
|
|
71
|
+
const cutoff = Date.now() - maxAge;
|
|
72
|
+
let changed = false;
|
|
73
|
+
for (const [key, ts] of timestamps) {
|
|
74
|
+
if (ts < cutoff) {
|
|
75
|
+
timestamps.delete(key);
|
|
76
|
+
if (cursorMap.delete(key)) changed = true;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (changed) output.set(new Map(cursorMap));
|
|
80
|
+
}
|
|
81
|
+
|
|
51
82
|
function startListening() {
|
|
83
|
+
const source = on(cursorTopic);
|
|
52
84
|
sourceUnsub = source.subscribe((event) => {
|
|
53
85
|
if (event === null) return;
|
|
54
86
|
|
|
55
87
|
if (event.event === 'update' && event.data != null) {
|
|
56
88
|
const { key, user, data } = event.data;
|
|
57
89
|
cursorMap.set(key, { user, data });
|
|
90
|
+
timestamps.set(key, Date.now());
|
|
58
91
|
output.set(new Map(cursorMap));
|
|
59
92
|
return;
|
|
60
93
|
}
|
|
61
94
|
|
|
62
95
|
if (event.event === 'remove' && event.data != null) {
|
|
63
96
|
const { key } = event.data;
|
|
97
|
+
timestamps.delete(key);
|
|
64
98
|
if (cursorMap.delete(key)) {
|
|
65
99
|
output.set(new Map(cursorMap));
|
|
66
100
|
}
|
|
67
101
|
}
|
|
68
102
|
});
|
|
103
|
+
|
|
104
|
+
if (maxAge > 0) {
|
|
105
|
+
sweepTimer = setInterval(sweep, Math.max(maxAge / 2, 1000));
|
|
106
|
+
}
|
|
69
107
|
}
|
|
70
108
|
|
|
71
109
|
function stopListening() {
|
|
@@ -73,7 +111,12 @@ export function cursor(topic) {
|
|
|
73
111
|
sourceUnsub();
|
|
74
112
|
sourceUnsub = null;
|
|
75
113
|
}
|
|
114
|
+
if (sweepTimer) {
|
|
115
|
+
clearInterval(sweepTimer);
|
|
116
|
+
sweepTimer = null;
|
|
117
|
+
}
|
|
76
118
|
cursorMap = new Map();
|
|
119
|
+
timestamps.clear();
|
|
77
120
|
}
|
|
78
121
|
|
|
79
122
|
return {
|
|
@@ -5,6 +5,11 @@
|
|
|
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).
|
|
12
|
+
*
|
|
8
13
|
* @module svelte-adapter-uws/plugins/presence/client
|
|
9
14
|
*/
|
|
10
15
|
|
|
@@ -20,8 +25,9 @@ const presenceStores = new Map();
|
|
|
20
25
|
* Returns a readable Svelte store containing an array of user data objects.
|
|
21
26
|
* The array updates automatically when users join or leave.
|
|
22
27
|
*
|
|
23
|
-
* Memoized by topic: calling `presence('room'
|
|
24
|
-
* `$derived`) returns the same store instance,
|
|
28
|
+
* Memoized by topic + maxAge: calling `presence('room', { maxAge: 90000 })`
|
|
29
|
+
* multiple times (e.g. from `$derived`) returns the same store instance,
|
|
30
|
+
* preventing flickering.
|
|
25
31
|
*
|
|
26
32
|
* You must also subscribe to the topic itself (via `on()`, `crud()`, etc.)
|
|
27
33
|
* for the server's `subscribe` hook to fire and register your presence.
|
|
@@ -30,6 +36,7 @@ const presenceStores = new Map();
|
|
|
30
36
|
*
|
|
31
37
|
* @template T
|
|
32
38
|
* @param {string} topic - Topic to track presence on
|
|
39
|
+
* @param {{ maxAge?: number }} [options] - Options
|
|
33
40
|
* @returns {import('svelte/store').Readable<T[]>}
|
|
34
41
|
*
|
|
35
42
|
* @example
|
|
@@ -49,20 +56,52 @@ const presenceStores = new Map();
|
|
|
49
56
|
* {/each}
|
|
50
57
|
* </aside>
|
|
51
58
|
* ```
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* ```svelte
|
|
62
|
+
* <script>
|
|
63
|
+
* // Self-healing: entries expire after 90s without a refresh
|
|
64
|
+
* const users = presence('room', { maxAge: 90_000 });
|
|
65
|
+
* </script>
|
|
66
|
+
* ```
|
|
52
67
|
*/
|
|
53
|
-
export function presence(topic) {
|
|
54
|
-
const
|
|
68
|
+
export function presence(topic, options) {
|
|
69
|
+
const maxAge = options?.maxAge;
|
|
70
|
+
const cacheKey = maxAge > 0 ? topic + '\0' + maxAge : topic;
|
|
71
|
+
|
|
72
|
+
const cached = presenceStores.get(cacheKey);
|
|
55
73
|
if (cached) return cached;
|
|
56
74
|
|
|
57
75
|
const presenceTopic = '__presence:' + topic;
|
|
58
76
|
|
|
59
77
|
/** @type {Map<string, any>} */
|
|
60
78
|
let userMap = new Map();
|
|
79
|
+
/** @type {Map<string, number>} */
|
|
80
|
+
const timestamps = new Map();
|
|
61
81
|
const output = writable(/** @type {any[]} */ ([]));
|
|
62
82
|
|
|
63
83
|
let sourceUnsub = /** @type {(() => void) | null} */ (null);
|
|
84
|
+
/** @type {ReturnType<typeof setInterval> | null} */
|
|
85
|
+
let sweepTimer = null;
|
|
64
86
|
let refCount = 0;
|
|
65
87
|
|
|
88
|
+
function flush() {
|
|
89
|
+
output.set([...userMap.values()]);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function sweep() {
|
|
93
|
+
if (!maxAge || maxAge <= 0) return;
|
|
94
|
+
const cutoff = Date.now() - maxAge;
|
|
95
|
+
let changed = false;
|
|
96
|
+
for (const [key, ts] of timestamps) {
|
|
97
|
+
if (ts < cutoff) {
|
|
98
|
+
timestamps.delete(key);
|
|
99
|
+
if (userMap.delete(key)) changed = true;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (changed) flush();
|
|
103
|
+
}
|
|
104
|
+
|
|
66
105
|
function startListening() {
|
|
67
106
|
// Fresh on() call each time -- the underlying writable in client.js
|
|
68
107
|
// is cleaned up on full unsubscribe, so a stale reference would
|
|
@@ -73,29 +112,38 @@ export function presence(topic) {
|
|
|
73
112
|
|
|
74
113
|
if (event.event === 'list' && Array.isArray(event.data)) {
|
|
75
114
|
userMap = new Map();
|
|
115
|
+
timestamps.clear();
|
|
116
|
+
const now = Date.now();
|
|
76
117
|
for (const entry of event.data) {
|
|
77
118
|
userMap.set(entry.key, entry.data);
|
|
119
|
+
timestamps.set(entry.key, now);
|
|
78
120
|
}
|
|
79
|
-
|
|
121
|
+
flush();
|
|
80
122
|
return;
|
|
81
123
|
}
|
|
82
124
|
|
|
83
125
|
if (event.event === 'join' && event.data != null) {
|
|
84
126
|
const { key, data } = event.data;
|
|
127
|
+
timestamps.set(key, Date.now());
|
|
85
128
|
if (!userMap.has(key)) {
|
|
86
129
|
userMap.set(key, data);
|
|
87
|
-
|
|
130
|
+
flush();
|
|
88
131
|
}
|
|
89
132
|
return;
|
|
90
133
|
}
|
|
91
134
|
|
|
92
135
|
if (event.event === 'leave' && event.data != null) {
|
|
93
136
|
const { key } = event.data;
|
|
137
|
+
timestamps.delete(key);
|
|
94
138
|
if (userMap.delete(key)) {
|
|
95
|
-
|
|
139
|
+
flush();
|
|
96
140
|
}
|
|
97
141
|
}
|
|
98
142
|
});
|
|
143
|
+
|
|
144
|
+
if (maxAge > 0) {
|
|
145
|
+
sweepTimer = setInterval(sweep, Math.max(maxAge / 2, 1000));
|
|
146
|
+
}
|
|
99
147
|
}
|
|
100
148
|
|
|
101
149
|
function stopListening() {
|
|
@@ -103,7 +151,12 @@ export function presence(topic) {
|
|
|
103
151
|
sourceUnsub();
|
|
104
152
|
sourceUnsub = null;
|
|
105
153
|
}
|
|
154
|
+
if (sweepTimer) {
|
|
155
|
+
clearInterval(sweepTimer);
|
|
156
|
+
sweepTimer = null;
|
|
157
|
+
}
|
|
106
158
|
userMap = new Map();
|
|
159
|
+
timestamps.clear();
|
|
107
160
|
output.set([]);
|
|
108
161
|
}
|
|
109
162
|
|
|
@@ -118,6 +171,6 @@ export function presence(topic) {
|
|
|
118
171
|
}
|
|
119
172
|
};
|
|
120
173
|
|
|
121
|
-
presenceStores.set(
|
|
174
|
+
presenceStores.set(cacheKey, store);
|
|
122
175
|
return store;
|
|
123
176
|
}
|