svelte-adapter-uws 0.4.3 → 0.4.5

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
@@ -2008,26 +2008,12 @@ The client store is a `Readable<Map<string, { user, data }>>`. The Map updates w
2008
2008
 
2009
2009
  **Initial sync and reconnect.** The `cursor(topic)` store sends a `{ type: 'cursor-snapshot', topic }` message every time the WebSocket connection opens -- both on first connect and on every reconnect. The server calls `cursors.snapshot(ws, topic, platform)` in its `message` handler, which sends a `snapshot` event back with the current cursor state (or an empty array if nobody is active). The client replaces its entire cursor map with the snapshot contents, clearing any stale entries from before the disconnect. Wire `cursors.snapshot()` in your message handler as shown in the server example above.
2010
2010
 
2011
- The `cursor()` function accepts an optional second argument:
2011
+ 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:
2012
2012
 
2013
2013
  ```js
2014
2014
  const positions = cursor('canvas', { maxAge: 30_000 });
2015
2015
  ```
2016
2016
 
2017
- `maxAge` (milliseconds) -- cursor entries that haven't received an update within that window are automatically removed. Makes clients self-healing when the server fails to broadcast `remove` events under load.
2018
-
2019
- `interpolate` -- enables smooth cursor rendering via `requestAnimationFrame`. Incoming `update` events set a lerp target and the displayed position moves toward it at 30% of the remaining distance per frame, eliminating visual jitter from network timing. The first update for each cursor snaps immediately (no lerp from 0,0). `snapshot`, `bulk`, and `remove` events always snap. Only applies to cursor data with numeric `x` and `y` fields -- non-numeric data falls back to direct assignment.
2020
-
2021
- ```js
2022
- const positions = cursor('canvas', { interpolate: true });
2023
- ```
2024
-
2025
- Options can be combined:
2026
-
2027
- ```js
2028
- const positions = cursor('canvas', { maxAge: 30_000, interpolate: true });
2029
- ```
2030
-
2031
2017
  #### Server API
2032
2018
 
2033
2019
  | Method | Description |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-adapter-uws",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
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",
@@ -1,43 +1,34 @@
1
- import type { Readable } from 'svelte/store';
2
-
3
- export interface CursorPosition<UserInfo = unknown, Data = unknown> {
4
- /** User-identifying data from the server's `select` function. */
5
- user: UserInfo;
6
- /** Latest cursor/position data. */
7
- data: Data;
8
- }
9
-
10
- /**
11
- * Get a reactive store of cursor positions on a topic.
12
- *
13
- * Returns a `Readable<Map<string, CursorPosition>>` that updates
14
- * automatically when cursors move or disconnect.
15
- *
16
- * @example
17
- * ```svelte
18
- * <script>
19
- * import { cursor } from 'svelte-adapter-uws/plugins/cursor/client';
20
- *
21
- * const cursors = cursor('canvas');
22
- * </script>
23
- *
24
- * {#each [...$cursors] as [key, { user, data }] (key)}
25
- * <div style="left: {data.x}px; top: {data.y}px">
26
- * {user.name}
27
- * </div>
28
- * {/each}
29
- * ```
30
- */
31
- export function cursor<UserInfo = unknown, Data = unknown>(
32
- topic: string,
33
- options?: {
34
- maxAge?: number;
35
- /**
36
- * Enable smooth lerp interpolation for cursor movement.
37
- * When enabled, `update` events with numeric `x` and `y` fields
38
- * are interpolated via requestAnimationFrame instead of snapping.
39
- * `snapshot`, `bulk`, and `remove` events always snap immediately.
40
- */
41
- interpolate?: boolean;
42
- }
43
- ): Readable<Map<string, CursorPosition<UserInfo, Data>>>;
1
+ import type { Readable } from 'svelte/store';
2
+
3
+ export interface CursorPosition<UserInfo = unknown, Data = unknown> {
4
+ /** User-identifying data from the server's `select` function. */
5
+ user: UserInfo;
6
+ /** Latest cursor/position data. */
7
+ data: Data;
8
+ }
9
+
10
+ /**
11
+ * Get a reactive store of cursor positions on a topic.
12
+ *
13
+ * Returns a `Readable<Map<string, CursorPosition>>` that updates
14
+ * automatically when cursors move or disconnect.
15
+ *
16
+ * @example
17
+ * ```svelte
18
+ * <script>
19
+ * import { cursor } from 'svelte-adapter-uws/plugins/cursor/client';
20
+ *
21
+ * const cursors = cursor('canvas');
22
+ * </script>
23
+ *
24
+ * {#each [...$cursors] as [key, { user, data }] (key)}
25
+ * <div style="left: {data.x}px; top: {data.y}px">
26
+ * {user.name}
27
+ * </div>
28
+ * {/each}
29
+ * ```
30
+ */
31
+ export function cursor<UserInfo = unknown, Data = unknown>(
32
+ topic: string,
33
+ options?: { maxAge?: number }
34
+ ): Readable<Map<string, CursorPosition<UserInfo, Data>>>;
@@ -1,255 +1,196 @@
1
- /**
2
- * Client-side cursor helper for svelte-adapter-uws.
3
- *
4
- * Subscribes to the internal `__cursor:{topic}` channel and maintains
5
- * a live Map of cursor positions. The server handles throttling and
6
- * cleanup; this module keeps the client-side state in sync.
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
- *
13
- * @module svelte-adapter-uws/plugins/cursor/client
14
- */
15
-
16
- import { on, connect, status } from '../../client.js';
17
- import { writable } from 'svelte/store';
18
-
19
- /** @type {Map<string, ReturnType<typeof cursor>>} */
20
- const cursorStores = new Map();
21
-
22
- /**
23
- * Get a reactive store of cursor positions on a topic.
24
- *
25
- * Returns a readable Svelte store containing a Map of connection keys
26
- * to `{ user, data }` objects. The Map updates automatically when
27
- * cursors move or disconnect.
28
- *
29
- * @template UserInfo, Data
30
- * @param {string} topic - Topic to track cursors on
31
- * @param {{ maxAge?: number, interpolate?: boolean }} [options] - Options
32
- * @returns {import('svelte/store').Readable<Map<string, { user: UserInfo, data: Data }>>}
33
- *
34
- * @example
35
- * ```svelte
36
- * <script>
37
- * import { cursor } from 'svelte-adapter-uws/plugins/cursor/client';
38
- *
39
- * const cursors = cursor('canvas');
40
- * </script>
41
- *
42
- * {#each [...$cursors] as [key, { user, data }] (key)}
43
- * <div style="left: {data.x}px; top: {data.y}px" class="cursor">
44
- * {user.name}
45
- * </div>
46
- * {/each}
47
- * ```
48
- *
49
- * @example
50
- * ```svelte
51
- * <script>
52
- * // Self-healing: cursors expire after 30s without movement
53
- * const cursors = cursor('canvas', { maxAge: 30_000 });
54
- * </script>
55
- * ```
56
- */
57
- export function cursor(topic, options) {
58
- const maxAge = options?.maxAge;
59
- const interpolate = options?.interpolate === true;
60
- let cacheKey = topic;
61
- if (maxAge > 0) cacheKey += '\0' + maxAge;
62
- if (interpolate) cacheKey += '\0lerp';
63
-
64
- const cached = cursorStores.get(cacheKey);
65
- if (cached) return cached;
66
-
67
- const cursorTopic = '__cursor:' + topic;
68
-
69
- /** @type {Map<string, { user: any, data: any }>} */
70
- let cursorMap = new Map();
71
- /** @type {Map<string, number>} */
72
- const timestamps = new Map();
73
- const output = writable(/** @type {Map<string, any>} */ (new Map()));
74
-
75
- let sourceUnsub = /** @type {(() => void) | null} */ (null);
76
- let statusUnsub = /** @type {(() => void) | null} */ (null);
77
- /** @type {ReturnType<typeof setInterval> | null} */
78
- let sweepTimer = null;
79
- /** @type {Map<string, { x: number, y: number }>} */
80
- const targets = new Map();
81
- let rafId = /** @type {number | null} */ (null);
82
- let refCount = 0;
83
- let cancelled = false;
84
-
85
- function tick() {
86
- let changed = false;
87
- for (const [key, target] of targets) {
88
- const entry = cursorMap.get(key);
89
- if (!entry) { targets.delete(key); continue; }
90
- const dx = target.x - entry.data.x;
91
- const dy = target.y - entry.data.y;
92
- if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) {
93
- if (entry.data.x !== target.x || entry.data.y !== target.y) {
94
- entry.data = { ...entry.data, x: target.x, y: target.y };
95
- changed = true;
96
- }
97
- targets.delete(key);
98
- continue;
99
- }
100
- entry.data = { ...entry.data, x: entry.data.x + dx * 0.5, y: entry.data.y + dy * 0.5 };
101
- changed = true;
102
- }
103
- if (changed) output.set(new Map(cursorMap));
104
- if (targets.size > 0) {
105
- rafId = requestAnimationFrame(tick);
106
- } else {
107
- rafId = null;
108
- }
109
- }
110
-
111
- function sweep() {
112
- if (!maxAge || maxAge <= 0) return;
113
- const cutoff = Date.now() - maxAge;
114
- let changed = false;
115
- for (const [key, ts] of timestamps) {
116
- if (ts < cutoff) {
117
- timestamps.delete(key);
118
- if (cursorMap.delete(key)) changed = true;
119
- }
120
- }
121
- if (changed) output.set(new Map(cursorMap));
122
- }
123
-
124
- function startListening() {
125
- cancelled = false;
126
- const source = on(cursorTopic);
127
- sourceUnsub = source.subscribe((event) => {
128
- if (event === null) return;
129
-
130
- if (event.event === 'update' && event.data != null) {
131
- const { key, user, data } = event.data;
132
- timestamps.set(key, Date.now());
133
- if (interpolate && typeof data?.x === 'number' && typeof data?.y === 'number') {
134
- const existing = cursorMap.get(key);
135
- if (!existing) {
136
- cursorMap.set(key, { user, data });
137
- } else {
138
- const dx = data.x - existing.data.x;
139
- const dy = data.y - existing.data.y;
140
- existing.user = user;
141
- existing.data = {
142
- ...existing.data,
143
- x: existing.data.x + dx * 0.5,
144
- y: existing.data.y + dy * 0.5
145
- };
146
- }
147
- targets.set(key, { x: data.x, y: data.y });
148
- output.set(new Map(cursorMap));
149
- if (rafId === null) rafId = requestAnimationFrame(tick);
150
- } else {
151
- cursorMap.set(key, { user, data });
152
- output.set(new Map(cursorMap));
153
- }
154
- return;
155
- }
156
-
157
- if (event.event === 'snapshot' && Array.isArray(event.data)) {
158
- cursorMap = new Map();
159
- timestamps.clear();
160
- targets.clear();
161
- const now = Date.now();
162
- for (const entry of event.data) {
163
- const { key, user, data } = entry;
164
- cursorMap.set(key, { user, data });
165
- timestamps.set(key, now);
166
- }
167
- output.set(new Map(cursorMap));
168
- return;
169
- }
170
-
171
- if (event.event === 'bulk' && Array.isArray(event.data)) {
172
- targets.clear();
173
- const now = Date.now();
174
- for (const entry of event.data) {
175
- const { key, user, data } = entry;
176
- cursorMap.set(key, { user, data });
177
- timestamps.set(key, now);
178
- }
179
- output.set(new Map(cursorMap));
180
- return;
181
- }
182
-
183
- if (event.event === 'remove' && event.data != null) {
184
- const { key } = event.data;
185
- timestamps.delete(key);
186
- targets.delete(key);
187
- if (cursorMap.delete(key)) {
188
- output.set(new Map(cursorMap));
189
- }
190
- }
191
- });
192
-
193
- if (maxAge > 0) {
194
- sweepTimer = setInterval(sweep, Math.max(maxAge / 2, 1000));
195
- }
196
-
197
- // Request a snapshot of existing cursor positions every time the socket
198
- // opens (initial connect and reconnects). Without this, the store would
199
- // miss cursors that appeared while the client was offline.
200
- statusUnsub = status.subscribe((s) => {
201
- if (s === 'open' && !cancelled) {
202
- connect().send({ type: 'cursor-snapshot', topic });
203
- }
204
- });
205
- }
206
-
207
- function stopListening() {
208
- cancelled = true;
209
- if (sourceUnsub) {
210
- sourceUnsub();
211
- sourceUnsub = null;
212
- }
213
- if (statusUnsub) {
214
- statusUnsub();
215
- statusUnsub = null;
216
- }
217
- if (sweepTimer) {
218
- clearInterval(sweepTimer);
219
- sweepTimer = null;
220
- }
221
- if (rafId !== null) {
222
- cancelAnimationFrame(rafId);
223
- rafId = null;
224
- }
225
- targets.clear();
226
- cursorMap = new Map();
227
- timestamps.clear();
228
- // Push the cleared state to the output store so a new subscriber does
229
- // not see ghost cursors from the previous subscription cycle.
230
- output.set(new Map());
231
- }
232
-
233
- const store = {
234
- subscribe(fn) {
235
- if (refCount++ === 0) startListening();
236
- const unsub = output.subscribe(fn);
237
- return () => {
238
- unsub();
239
- if (--refCount === 0) {
240
- stopListening();
241
- cursorStores.delete(cacheKey);
242
- }
243
- };
244
- }
245
- };
246
-
247
- cursorStores.set(cacheKey, store);
248
-
249
- // If nothing subscribes before the next microtask, remove the cache entry.
250
- queueMicrotask(() => {
251
- if (refCount === 0) cursorStores.delete(cacheKey);
252
- });
253
-
254
- return store;
255
- }
1
+ /**
2
+ * Client-side cursor helper for svelte-adapter-uws.
3
+ *
4
+ * Subscribes to the internal `__cursor:{topic}` channel and maintains
5
+ * a live Map of cursor positions. The server handles throttling and
6
+ * cleanup; this module keeps the client-side state in sync.
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
+ *
13
+ * @module svelte-adapter-uws/plugins/cursor/client
14
+ */
15
+
16
+ import { on, connect, status } from '../../client.js';
17
+ import { writable } from 'svelte/store';
18
+
19
+ /** @type {Map<string, ReturnType<typeof cursor>>} */
20
+ const cursorStores = new Map();
21
+
22
+ /**
23
+ * Get a reactive store of cursor positions on a topic.
24
+ *
25
+ * Returns a readable Svelte store containing a Map of connection keys
26
+ * to `{ user, data }` objects. The Map updates automatically when
27
+ * cursors move or disconnect.
28
+ *
29
+ * @template UserInfo, Data
30
+ * @param {string} topic - Topic to track cursors on
31
+ * @param {{ maxAge?: number }} [options] - Options
32
+ * @returns {import('svelte/store').Readable<Map<string, { user: UserInfo, data: Data }>>}
33
+ *
34
+ * @example
35
+ * ```svelte
36
+ * <script>
37
+ * import { cursor } from 'svelte-adapter-uws/plugins/cursor/client';
38
+ *
39
+ * const cursors = cursor('canvas');
40
+ * </script>
41
+ *
42
+ * {#each [...$cursors] as [key, { user, data }] (key)}
43
+ * <div style="left: {data.x}px; top: {data.y}px" class="cursor">
44
+ * {user.name}
45
+ * </div>
46
+ * {/each}
47
+ * ```
48
+ *
49
+ * @example
50
+ * ```svelte
51
+ * <script>
52
+ * // Self-healing: cursors expire after 30s without movement
53
+ * const cursors = cursor('canvas', { maxAge: 30_000 });
54
+ * </script>
55
+ * ```
56
+ */
57
+ export function cursor(topic, options) {
58
+ const maxAge = options?.maxAge;
59
+ const cacheKey = maxAge > 0 ? topic + '\0' + maxAge : topic;
60
+
61
+ const cached = cursorStores.get(cacheKey);
62
+ if (cached) return cached;
63
+
64
+ const cursorTopic = '__cursor:' + topic;
65
+
66
+ /** @type {Map<string, { user: any, data: any }>} */
67
+ let cursorMap = new Map();
68
+ /** @type {Map<string, number>} */
69
+ const timestamps = new Map();
70
+ const output = writable(/** @type {Map<string, any>} */ (new Map()));
71
+
72
+ let sourceUnsub = /** @type {(() => void) | null} */ (null);
73
+ let statusUnsub = /** @type {(() => void) | null} */ (null);
74
+ /** @type {ReturnType<typeof setInterval> | null} */
75
+ let sweepTimer = null;
76
+ let refCount = 0;
77
+ let cancelled = false;
78
+
79
+ function sweep() {
80
+ if (!maxAge || maxAge <= 0) return;
81
+ const cutoff = Date.now() - maxAge;
82
+ let changed = false;
83
+ for (const [key, ts] of timestamps) {
84
+ if (ts < cutoff) {
85
+ timestamps.delete(key);
86
+ if (cursorMap.delete(key)) changed = true;
87
+ }
88
+ }
89
+ if (changed) output.set(new Map(cursorMap));
90
+ }
91
+
92
+ function startListening() {
93
+ cancelled = false;
94
+ const source = on(cursorTopic);
95
+ sourceUnsub = source.subscribe((event) => {
96
+ if (event === null) return;
97
+
98
+ if (event.event === 'update' && event.data != null) {
99
+ const { key, user, data } = event.data;
100
+ cursorMap.set(key, { user, data });
101
+ timestamps.set(key, Date.now());
102
+ output.set(new Map(cursorMap));
103
+ return;
104
+ }
105
+
106
+ if (event.event === 'snapshot' && Array.isArray(event.data)) {
107
+ cursorMap = new Map();
108
+ timestamps.clear();
109
+ const now = Date.now();
110
+ for (const entry of event.data) {
111
+ const { key, user, data } = entry;
112
+ cursorMap.set(key, { user, data });
113
+ timestamps.set(key, now);
114
+ }
115
+ output.set(new Map(cursorMap));
116
+ return;
117
+ }
118
+
119
+ if (event.event === 'bulk' && Array.isArray(event.data)) {
120
+ const now = Date.now();
121
+ for (const entry of event.data) {
122
+ const { key, user, data } = entry;
123
+ cursorMap.set(key, { user, data });
124
+ timestamps.set(key, now);
125
+ }
126
+ output.set(new Map(cursorMap));
127
+ return;
128
+ }
129
+
130
+ if (event.event === 'remove' && event.data != null) {
131
+ const { key } = event.data;
132
+ timestamps.delete(key);
133
+ if (cursorMap.delete(key)) {
134
+ output.set(new Map(cursorMap));
135
+ }
136
+ }
137
+ });
138
+
139
+ if (maxAge > 0) {
140
+ sweepTimer = setInterval(sweep, Math.max(maxAge / 2, 1000));
141
+ }
142
+
143
+ // Request a snapshot of existing cursor positions every time the socket
144
+ // opens (initial connect and reconnects). Without this, the store would
145
+ // miss cursors that appeared while the client was offline.
146
+ statusUnsub = status.subscribe((s) => {
147
+ if (s === 'open' && !cancelled) {
148
+ connect().send({ type: 'cursor-snapshot', topic });
149
+ }
150
+ });
151
+ }
152
+
153
+ function stopListening() {
154
+ cancelled = true;
155
+ if (sourceUnsub) {
156
+ sourceUnsub();
157
+ sourceUnsub = null;
158
+ }
159
+ if (statusUnsub) {
160
+ statusUnsub();
161
+ statusUnsub = null;
162
+ }
163
+ if (sweepTimer) {
164
+ clearInterval(sweepTimer);
165
+ sweepTimer = null;
166
+ }
167
+ cursorMap = new Map();
168
+ timestamps.clear();
169
+ // Push the cleared state to the output store so a new subscriber does
170
+ // not see ghost cursors from the previous subscription cycle.
171
+ output.set(new Map());
172
+ }
173
+
174
+ const store = {
175
+ subscribe(fn) {
176
+ if (refCount++ === 0) startListening();
177
+ const unsub = output.subscribe(fn);
178
+ return () => {
179
+ unsub();
180
+ if (--refCount === 0) {
181
+ stopListening();
182
+ cursorStores.delete(cacheKey);
183
+ }
184
+ };
185
+ }
186
+ };
187
+
188
+ cursorStores.set(cacheKey, store);
189
+
190
+ // If nothing subscribes before the next microtask, remove the cache entry.
191
+ queueMicrotask(() => {
192
+ if (refCount === 0) cursorStores.delete(cacheKey);
193
+ });
194
+
195
+ return store;
196
+ }
package/vite.js CHANGED
@@ -1,4 +1,3 @@
1
- import { WebSocketServer } from 'ws';
2
1
  import path from 'node:path';
3
2
  import { existsSync } from 'node:fs';
4
3
  import { parseCookies } from './files/cookies.js';
@@ -34,7 +33,7 @@ function esc(s) {
34
33
  export default function uws(options = {}) {
35
34
  const wsPath = options.path || '/ws';
36
35
 
37
- /** @type {WebSocketServer} */
36
+ /** @type {import('ws').WebSocketServer | undefined} */
38
37
  let wss;
39
38
 
40
39
  /** @type {Map<import('ws').WebSocket, Set<string>>} */
@@ -240,7 +239,7 @@ export default function uws(options = {}) {
240
239
  }
241
240
  }
242
241
  },
243
- configureServer(server) {
242
+ async configureServer(server) {
244
243
  // In middleware mode Vite does not own the HTTP server, so WS upgrade cannot be attached.
245
244
  if (!server.httpServer) {
246
245
  server.config.logger.warn(
@@ -251,6 +250,18 @@ export default function uws(options = {}) {
251
250
  return;
252
251
  }
253
252
 
253
+ /** @type {typeof import('ws').WebSocketServer} */
254
+ let WebSocketServer;
255
+ try {
256
+ ({ WebSocketServer } = await import('ws'));
257
+ } catch {
258
+ server.config.logger.warn(
259
+ '[svelte-adapter-uws] The "ws" package is not installed. ' +
260
+ 'WebSocket features are disabled in dev. Install with: npm i -D ws'
261
+ );
262
+ return;
263
+ }
264
+
254
265
  // E7: warn if our WS path collides with the Vite HMR WebSocket path.
255
266
  const hmrConfig = server.config.server?.hmr;
256
267
  if (hmrConfig && typeof hmrConfig === 'object' && hmrConfig.path === wsPath) {