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 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
- return on(topic).scan(/** @type {any[]} */ (initial), (list, { event, data }) => {
117
- if (event === 'created') return prepend ? [data, ...list] : [...list, data];
118
- if (event === 'updated') return list.map((item) => item[key] === data[key] ? data : item);
119
- if (event === 'deleted') return list.filter((item) => item[key] !== data[key]);
120
- return list;
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
- return on(topic).scan(initialMap, (map, { event, data }) => {
142
- const id = data[key];
143
- if (event === 'created' || event === 'updated') return { ...map, [id]: data };
144
- if (event === 'deleted') {
145
- const { [id]: _, ...rest } = map;
146
- return rest;
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
- return map;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-adapter-uws",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
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",
@@ -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>>>;
@@ -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 {
@@ -30,5 +30,6 @@ import type { Readable } from 'svelte/store';
30
30
  * ```
31
31
  */
32
32
  export function presence<T extends Record<string, any> = Record<string, any>>(
33
- topic: string
33
+ topic: string,
34
+ options?: { maxAge?: number }
34
35
  ): Readable<T[]>;
@@ -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')` multiple times (e.g. from
24
- * `$derived`) returns the same store instance, preventing flickering.
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 cached = presenceStores.get(topic);
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
- output.set([...userMap.values()]);
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
- output.set([...userMap.values()]);
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
- output.set([...userMap.values()]);
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(topic, store);
174
+ presenceStores.set(cacheKey, store);
122
175
  return store;
123
176
  }